Skip to content

Documentation

The kicklace package

Your server says what a person did, in one line.

One line in your own server code that says what a person did.

npm install kicklace
import { Kicklace } from "kicklace";

const kl = new Kicklace(process.env.KICKLACE_SECRET_KEY!);
await kl.track("purchase", { email, value: 49, idempotencyKey: invoice.id });

It adds no address of its own. Everything it does goes through POST /api/v1/events, POST /api/v1/messages, GET /api/v1/messages, GET /api/v1/people/[ref]/messages, and GET /api/v1/{people,organizations,subscriptions,events,releases,journey,populations} for reading your data back, plus {app}/api/in/{public key}/track.js for the tag. Anything it does, the API does by hand too.

It has no runtime dependencies; react and next are optional, for the kicklace/next entry only. It runs on Node 18+, on Edge runtimes, in workers, in Bun and in Deno. Every method is in the package's own README on npm.

What it gives you

Kicklace track, identify, subscribe, support, batch
kl.messages send, list, thread
kl.people, .organizations, .subscriptions, .events, .releases reading your data back
kl.journey(), kl.populations() the same, where there's no list to page
kicklace/next <KicklaceTag /> for the App Router, and tagSrc
npx kicklace init wires it all into a Next.js project

Two rules

  1. A refusal is an answer. Every method resolves to { ok: true, status, id? } or { ok: false, error, message, httpStatus }, with the route's own code and sentence. Nothing throws inside somebody's checkout. The only throws are when you build it: no key, or a key that isn't sk_live_… (a public wk_… key is refused by name, because it belongs in the tag).
  2. Never a secret in a browser. The main entry is for servers. kicklace/next takes the public key, and given a secret one it renders nothing and says why in the console.

It retries a 5xx or a network failure, three times with a short wait between. A 4xx is an answer, and asking again won't change it. idempotencyKey is what makes a retried purchase land once.

The names track knows

kl.track(type, …) takes any name Kicklace knows, and any name of your own. The known ones are the BuiltInEvent type, so your editor completes them:

pageview · configure · cta_click · waitlist_cta · measure_cta · download
activated · signup · value · trial_started · purchase · credit_purchase
churned · subscription_cancelled

Events says what each one means and what it moves. Three behave unusually: value is your app saying somebody got what they came for, and the first one moves them to that stage; trial_started and churned move nobody, because a trial and a cancellation are read from the customer's own subscriptions and payments.

npx kicklace init

One command for a Next.js project that already exists:

npx kicklace init
npx kicklace init --setup <token>   # and fetch this workspace's keys
npx kicklace init --dry-run         # print every change and make none

It adds <KicklaceTag /> inside <body> of your root layout, adds kicklace to your dependencies, and appends the four environment names to .env.example. With --setup and the token from Setup's Hear it step, it also writes this workspace's keys into .env.local.

It never overwrites a value somebody set, writes nothing until every step can be done, and runs nothing: npm install is printed, not run. Run it twice and it says "already in".

Where an auth library is a dependency (@clerk/nextjs, better-auth or next-auth), init also wires the tag to the signed-in user, below. Where it can't find your setup with confidence, it guesses nothing: the plain tag goes in, and one line says what to pass by hand.

The tag and a signed-in user

<KicklaceTag user={{ id, email, name }} /> closes a gap the tag can't close on its own. A hosted sign-in, like Clerk's own pages, leaves no form for the tag to hear, so the visitor who's been on your site for a week and the person who just signed in would stay two records. With an email, the tag carries the three as data-kl-* attributes on its own script element (never an inline script, so a strict CSP still holds), and the tag reads them back and joins that browser to that person. With no user, or no email, it renders the plain tag.

Reading your data back

The same key reads what it can write. kl.people, .organizations, .subscriptions, .events and .releases each come a page at a time:

const paying = await kl.people.list({ stage: "paying", limit: 20 });
if (paying.ok) for (const person of paying.data) console.log(person.name, person.money);

for await (const person of kl.people.iterate({ population: "group:likely-to-leave" })) {
  // every page, read one by one
}

list() never throws; iterate() is the same read as an async generator, and a page that comes back refused ends the loop rather than throwing into yours. Reading your data has every field and every filter.

Not yet

  • subscribe takes no name. Set one with identify in the same batch.
  • No waiting out a 429: a rate limit is answered, not retried.
  • No queue and no background flush. batch is explicit, the way the route is.