Documentation
Events
An event is one thing a person did: they read a page, they signed up, they paid, they used the feature you have been waiting for somebody to use. Kicklace has no other idea of what a customer is. This is every name it knows, what each one moves, how to post a name of your own, and how to make sure nothing lands twice.
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 doors — 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 opens no door 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 are joined into one person — that
is the whole of identity resolution. 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 them to Downloader. A web workspace has no Downloader stage, so there it is recorded and moves nobody. | the tag, your server |
activated |
Somebody did the first real thing in your product. What that is, you say: Settings → Pipelines names any other events that count as well. | Moves them to Activated | your server |
signup |
Somebody created an account. | Moves them to Account | your server, Clerk or Kinde |
trial_started |
Somebody started a trial. | Moves them to Trial, where the workspace has switched that stage on, and never past somebody who has already paid. | your server, Stripe |
purchase |
Somebody paid. | Moves them to Purchaser. A second purchase moves them to Repeat, and one from Churned moves them back up to Purchaser. | your server, Stripe, GitHub |
credit_purchase |
The same as purchase, under the name an older billing webhook posts. |
Moves them to Purchaser | your server |
churned |
Somebody who was paying stopped. | Moves them to Churned — the one event that moves anybody down the ladder, and only from Purchaser or Repeat. From Trial it moves them to Account, because a trial that ended is not a customer who left. | your server, Stripe |
subscription_cancelled |
The same as churned, under the name Stripe and GitHub post. |
Moves them to Churned | 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. | Moves nobody | Stripe, GitHub |
subscription_renewed |
A subscription renewed, or a sponsor changed tier. | Moves nobody | 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 at your auth provider. Nothing here is deleted with it; the record and its history stay. | Moves nobody | Clerk or Kinde |
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 |
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. The lifecycle is
the whole ladder, both orders of it, and the two stages you can switch on.
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, an
automation can be triggered by 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 an automation trigger 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 — see below. |
expires_at |
your promise about how long this row may be kept — see below. |
detail |
one line of your own for the timeline. |
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.
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 Settings → 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.