Skip to content

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 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 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.