Skip to content

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. KicklaceTag from kicklace/next writes it from Vercel's own NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA, so a site on Vercel needs no step for it.
  • The package reads KICKLACE_RELEASE, else Vercel's VERCEL_GIT_COMMIT_SHA. Pass release on one call to override it, or release: "" to send none.
  • Anything else sends release in 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 with invalid_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, subscribe and support take 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 requires consent.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 with missing_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.