Documentation
Events
Every event Kicklace knows, and what each one moves.
The timeline is events. The lifecycle stage is worked out from events and never set by hand, and a chart is a question asked of them: Kicklace has no other idea of what a customer is, which is why this is the page to read before the rest.
Events arrive three ways: from the tag on your website, from your own servers with a secret key, and from the connections (Stripe, Clerk, Kinde, GitHub) that post for you once an address is pasted into them.
Posting one
From your own code, with the package:
import { Kicklace } from "kicklace";
const kl = new Kicklace(process.env.KICKLACE_SECRET_KEY!);
await kl.track("signup", { email });
await kl.track("purchase", { email, value: 49, idempotencyKey: invoice.id });
await kl.track({ type: "credit_added", accountId, properties: { credits: 10 } });
Or by hand. The package adds no address of its own, so this is exactly the same thing:
curl -X POST https://www.kicklace.com/api/v1/events \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{
"type": "purchase",
"email": "ada@example.com",
"value": 49,
"occurred_at": "2026-09-09T10:04:00Z",
"idempotency_key": "in_1a2b3c",
"properties": { "plan": "studio" }
}'
Every event has to say who it is about: an email, an anon_id (the browser id the tag keeps),
an account_id, or several at once. Send more than one and they're joined into one person; that's
the whole of One person, however they arrive. Without any of them the post is refused with
no_identity.
The answer is { "ok": true, "status": "recorded", "id": "rec_…" }.
The names Kicklace knows
| Name | What it means | What it moves | Who posts it |
|---|---|---|---|
pageview |
Somebody read a page. The tag posts one per page, and it is what makes an anonymous visitor a record at all. | Moves nobody | the tag, your server |
configure |
Somebody used a demo or a configurator on your site. Recorded as a visit. | Moves nobody | the tag, your server |
cta_click |
Somebody clicked a download or install link. The tag posts it for a link it recognises, and for anything marked data-kl. |
Moves nobody | the tag, your server |
waitlist_cta |
A waitlist button was clicked. Recorded as a visit; kept for sites that already post this name. | Moves nobody | the tag, your server |
measure_cta |
A second call to action was clicked. Recorded as a visit; kept for sites that already post this name. | Moves nobody | the tag, your server |
download |
Somebody downloaded or started the product. | Moves nobody: a download is a thing that happened rather than a rung. Name it on the Activated stage of your Lifecycle if it is what activation means in your product. | the tag, your server |
activated |
Somebody did the first real thing in your product. What that is, you say: its stage on your Lifecycle names any other events that count as well. | Moves them to Activated | your server |
signup |
Somebody created an account. | Moves them to Signed up | your server, Clerk or Kinde, a row appearing in your database |
value |
Your product did the thing they came for: the invoice went out, the drawing was exported, the report ran. Post it every time it happens. | Moves them to First value, on the first one. A product that never posts one has nobody on that rung. | your server |
trial_started |
Somebody started a trial. | Moves nobody: a trial is the account's own state, read from its subscription beside whatever rung its people are on. | your server, Stripe |
purchase |
Somebody paid. | Moves them to Paying. Paying past one full period is Retained, worked out from the money rather than from an event. | your server, Stripe, GitHub |
credit_purchase |
The same as purchase, under the name an older billing webhook posts. |
Moves them to Paying | your server |
churned |
Somebody who was paying stopped. | Moves nobody: the ladder is what somebody reached, and cancelling does not undo having paid. Their account reads Churned from its own money. | your server, Stripe |
subscription_cancelled |
The same as churned, under the name Stripe and GitHub post. |
Moves nobody | Stripe, GitHub, your server |
subscription_started |
A subscription began outside a trial. A paid invoice arrives beside it as a purchase, which is what moves them, and the subscription itself is written on the account. | Moves nobody | Stripe, GitHub |
subscription_renewed |
A subscription renewed, or a sponsor changed tier. The row on the account is brought up to date. | Moves nobody | Stripe, GitHub |
expanded |
A subscription's plan or seat count went up after somebody started paying. Kicklace writes it itself, off the row your billing changed, so nobody has to post it. | Moves them to Expanding. It is the account's, not one person's: everybody on it who is already paying moves together, and the row says what went up: "Seats went from 2 to 3". | Stripe, GitHub |
refund |
A charge was refunded. | Moves nobody | Stripe |
checkout_abandoned |
Somebody left a checkout without paying. | Moves nobody | Stripe |
sign_in |
Somebody signed in. It moves nobody: signing in is not a stage. | Moves nobody | Clerk or Kinde |
account_deleted |
An account was deleted where it was made. Nothing here is deleted with it; the record and its history stay. | Moves nobody | Clerk or Kinde, a row deleted in your database |
github_star |
Somebody starred the repository, if you asked for stars to be recorded. A star is not consent; it only marks that they were here. | Moves nobody | GitHub |
error |
Somebody hit an error in your product. The same person hitting the same issue again within the hour is counted onto the row that is already there rather than becoming a new one. | Moves nobody | Sentry |
A stage a workspace does not have is not an error. A download posted to a workspace built for
a web app is recorded exactly as it arrived and moves nobody, with no complaint anywhere: the event
is what happened, and the stage is only what Kicklace makes of it. On your map each stage is named
in your own words; The Lifecycle has the map.
Names of your own
Any other name is yours: 2 to 40 characters of lowercase letters, digits and underscores, like
credit_added or report_exported. It lands on the person's timeline under that name, a side road
on your Lifecycle map can start from it, and a chart can count it.
It moves nobody between stages, because Kicklace cannot know what a name it has never seen means. It is refused if it collides with one of the names above, with an action name, or with a timeline kind Kicklace uses itself.
Names of your own go through your secret key only. The public route a browser posts to answers
unknown_event for one, because a name that has never been posted is indistinguishable from a typo,
and a website is not a trusted source of new vocabulary.
A name is offered as a side road's way in and in a chart once the workspace has seen it. Post
credit_added once and it is in the list; until then it is not, because Kicklace will not suggest
an event nothing has ever sent.
Everything else an event may carry
| Field | What it does |
|---|---|
value |
money, as a plain number in whole units. A chart counting events sums properties.value, so this is what "revenue this month" reads. |
properties |
anything else you want kept: a plan name, a count, a feature. It is stored on the row and shown on the timeline. |
occurred_at |
when it really happened, ISO 8601. Without it, now. |
idempotency_key |
your own id for this event, so a retry lands once (below). |
expires_at |
your promise about how long this row may be kept (below). |
detail |
one line of your own for the timeline. |
release |
which deployment this happened on: a commit sha, or a version string like myapp@2.4.1+4f2c9a1, up to 64 characters. The tag and the package fill it in by themselves (below). |
source |
how the person arrived: path, referrer, os, utm, click_id. The tag fills this in by itself. |
Charts also read source, medium and campaign from the person's first touch, so a revenue
figure can be cut by the campaign that brought them without any of that being on the event.
Which deployment it happened on
An event may say which release of your product it happened on, and then "which customers met that deployment" is a question with an answer.
- The tag reads
<meta name="kicklace-release" content="…">out of the page.KicklaceTagfromkicklace/nextwrites it from Vercel's ownNEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA, so a site on Vercel needs no step for it. - The package reads
KICKLACE_RELEASE, else Vercel'sVERCEL_GIT_COMMIT_SHA. Passreleaseon one call to override it, orrelease: ""to send none. - Anything else sends
releasein the body.
Kicklace keeps the string on the row and joins it to the deployment it names: a full sha, a short
one, or the build in myapp@2.4.1+4f2c9a1. Where it names nothing Kicklace knows, the string is
kept and no join is written, which is the honest answer rather than a wrong one.
Naming your onboarding steps
Post onboarding_step with the step in properties, and Kicklace can say which one people stopped
at after a deployment:
await kl.track("onboarding_step", { email, properties: { step: "3" } });
The step is whatever you call it, a number or a name, and it's counted, never interpreted. Without it a release watch still says what moved; it just cannot say where.
Nothing lands twice
Give anything that must land exactly once an idempotency_key: an invoice id, a charge id, your
own event id. A second post with the same key writes nothing and answers as if it had. It is what
makes a retry safe, and every retry in the system is built around it: the package's own retries, a
webhook your server sends again after a timeout, and Stripe's three deliveries of one payment.
Events that expire
An event may carry an expires_at, ISO 8601, after its own occurred_at. It is your promise about
how long that row may be kept, and Kicklace honours it: on that day the timeline row is deleted.
- One that has already run out on the way here answers
{ "ok": true, "status": "expired" }with no id, and nothing at all is written, not even the person. In a batch, only that item. - One that is not a date, or is not after
occurred_at, is refused withinvalid_expiry. - Only the row goes. The person stays, the stage that event moved them to stays (stages are decided when the event arrives and are never recomputed), and so do their first touch, their identities, their subscriptions and the consent behind them, their notes and their emails. A chart counting events simply stops counting that row.
identify,subscribeandsupporttake no expiry, because consent is not analytics.
Batches
Up to a hundred at a time, in one request:
{ "events": [
{ "type": "pageview", "anon_id": "kl_…" },
{ "type": "identify", "email": "ada@example.com", "anon_id": "kl_…" },
{ "type": "purchase", "email": "ada@example.com", "value": 49, "idempotency_key": "in_1a2b3c" }
] }
The answer is one result per item, in the order you sent them, each with its own ok. One item
failing does not throw the rest away.
Three other things this address takes
Besides an event, POST /api/v1/events accepts three actions, so a server has one address to know
about:
identify: an address, a browser id, an account id, a name, fields. It writes no timeline row: it is how you tell Kicklace that these keys are one person.subscribe: puts somebody on a list. It requiresconsent.text: the exact words the person read next to the form, stored verbatim as the proof. A list's own wording is not a substitute for what they actually saw, and without it the post is refused withmissing_consent.support: somebody wrote to you. It lands as a support message on their record, and it is one of the things that makes a person engaged, so you can write back. Email has that rule.
Rate limits
The window is ten minutes everywhere.
| Address | Counted per | Posts |
|---|---|---|
POST {your base URL}/event |
each visitor's IP, per workspace | 240 |
POST {your base URL}/identify |
each visitor's IP, per workspace | 60 |
POST {your base URL}/subscribe |
each visitor's IP, per workspace | 20 |
POST {your base URL}/support |
each visitor's IP, per workspace | 10 |
POST /api/v1/events, /api/v1/messages |
each secret key | 600 |
Over the limit is a 429 with rate_limited. A body is capped at 8 KB from a browser and 64 KB
from a server.
Every refusal, generated
Kicklace answers a refusal with both a code and a sentence with the fix inside it, because a
bare unknown_list sends whoever wired it up looking for documentation. The
API reference is generated from the routes themselves: every request shape, every
answer, every refusal code with the words it carries, and the limits above read from the same table
the routes read. It cannot drift from what the address really does.
Where you see it working
Your website's heartbeat, on Connections → Your website, prints the last week of posts, from your site and from your servers alike, in plain English, with what happened to each one. It keeps no bodies and no addresses: the action, the answer, the origin, the minute. It is the first place to look when something is not arriving.