kicklace
v0.5.1
Published
Kicklace, customer follow-up for small businesses: the one-line SDK to track events, identify people, subscribe them with the words they agreed to, and email one person.
Maintainers
Readme
kicklace
Kicklace is customer follow-up for small businesses: it turns the interest your website earns into customers, following up with every lead in your own voice. This package is its runtime layer: one line in your
server code sends what a person did — a signup, a purchase, an event of your own — and Kicklace
keeps the record, moves them along your Lifecycle map, and sends the emails you switched on. It has no dependencies, runs anywhere fetch does, and never
throws when Kicklace refuses something.
Start here: Installation · The JavaScript SDK · All the docs
npm install kicklaceimport { Kicklace } from "kicklace";
const kl = new Kicklace(process.env.KICKLACE_SECRET_KEY!);
await kl.track("purchase", { email, value: 49, idempotencyKey: invoice.id });In a Next.js project you already have, one command does the wiring:
npx kicklace initYour secret key is under Connections → Your website → For your developers, where it is shown once. It belongs on a server: this entry point is not for browser code.
Every method
Each one resolves. None of them throws.
await kl.track("signup", { email });
await kl.track({ type: "credit_added", accountId, properties: { credits: 10 } });
await kl.identify({ email, anonId, name, fields: { phone } });
await kl.subscribe({
email,
list: "product-updates",
consent: "Send me product updates. I can unsubscribe with one click.",
});
await kl.support({ from: email, who: name, topic: "Billing", message: "…" });
await kl.batch([
{ type: "pageview", anonId },
{ type: "identify", email, anonId },
]); // one request, up to 100
await kl.messages.send({ to: email, template: "Welcome" });
await kl.messages.list({ person: email, limit: 20 });
await kl.messages.thread(email);| Method | Answers |
| --- | --- |
| track(type, options?) / track({ type, ...options }) | { ok: true, status: "recorded", id }, or status: "expired" with no id when the event had already run out |
| identify(options) | { ok: true, status: "created" \| "merged" \| "linked", id } |
| subscribe(options) | { ok: true, status: "subscribed" \| "already_subscribed", id } |
| support(options) | { ok: true, status: "recorded", id } |
| batch(items) | { ok: true, results } — one result per item, in the order you sent them |
| messages.send(options) | { ok: true, id, providerId? } |
| messages.list(query?) | { ok: true, messages, nextCursor? } |
| messages.thread(who) | { ok: true, person, messages } — oldest first |
Option names are camelCase (anonId, accountId, occurredAt, expiresAt, idempotencyKey);
the package writes the API's own spelling on the way out. occurredAt, expiresAt and the
message dates take a Date or an ISO 8601 string.
A refusal is an answer
Every method resolves to one of two shapes, and TypeScript will make you look at ok:
type Outcome =
| { ok: true; status: string; id?: string }
| { ok: false; error: string; message: string; httpStatus: number };error is Kicklace's own code and message is its own sentence, handed on untouched, because
the fix is usually inside it:
const sent = await kl.messages.send({ to: email, template: "Welcome" });
if (!sent.ok) log.warn(sent.message); // "Kicklace only emails a person who…"A network failure that survived the retries is { ok: false, error: "unreachable", message,
httpStatus: 0 }. Nothing this package does can throw inside your checkout.
The only exceptions are thrown when you build the client, and only for the two mistakes
worth stopping a program for: no key at all, and a key that is the wrong kind. A public key
(wk_…) passed here is refused with a sentence saying it belongs in the site tag.
Retries, and the key that makes them safe
A 5xx and a network failure are tried again — three attempts, a short wait between them. A 4xx is never retried, because a refusal is an answer and asking again will not change it.
Give anything that must land exactly once an idempotencyKey. It is what makes a retried
purchase — ours, or your own webhook's — land once:
await kl.track("purchase", { email, value: 49, idempotencyKey: invoice.id });Events of your own
Besides Kicklace's own names (pageview, signup, download, activated, purchase, and the
rest), you can post any name of your own: 2 to 40 characters of lowercase letters, digits and
underscores, like credit_added. It lands on the person's timeline and an automation can answer
it. It moves nobody between stages, because Kicklace cannot know what a name it has never seen
means.
Options
const kl = new Kicklace({
key: process.env.KICKLACE_SECRET_KEY!,
url: process.env.KICKLACE_URL, // https://www.kicklace.com by default
fetch: myFetch, // your runtime's, or a test's
timeoutMs: 5_000,
retries: 3, // counting the first attempt
release: "[email protected]", // KICKLACE_RELEASE, else VERCEL_GIT_COMMIT_SHA
});Which deployment it happened on
Every event carries a release: KICKLACE_RELEASE, else Vercel's own
VERCEL_GIT_COMMIT_SHA, else nothing at all. Kicklace joins it to the deployment it names, so
"which customers met that release" is a question with an answer. Pass release on one call to
override it, or release: "" to send none.
Reading the graph
Everything above writes. kl.people, .organizations, .subscriptions, .events and
.releases read it back — the same facts as GET /api/v1/people and the rest, and the same ones
an MCP resource or a tool answers, because all three read through one place. kl.journey() and
kl.populations() have no list of their own to page, so they are methods rather than namespaces.
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);
const ada = await kl.people.get("rec_123");
if (ada.ok) console.log(ada.reading); // Kicklace's own sentence about them
for await (const person of kl.people.iterate({ population: "group:likely-to-leave" })) {
console.log(person.name, person.via?.name); // whose money it is, when it is not their own
}| Method | Reads |
| --- | --- |
| people.list(query?) / people.iterate(query?) / people.get(id) | q, stage, population, organization, updatedSince |
| organizations.list(query?) / .iterate(query?) / .get(id) | q, updatedSince |
| subscriptions.list(query?) / .iterate(query?) | every customer_subscriptions row, each with its payer |
| events.list(query?) / .iterate(query?) | person, name, since — what people did, track()'s twin |
| releases.list(query?) / .iterate(query?) / .get(sha) | newest first, each with what it changed where a compare has been read |
| journey() | the seven lifecycle rungs, how many stand on each, and how many are stuck |
| populations() | every population, with its count and the formula that qualifies it |
Every list() answers one page — { ok: true, data, next } — and never throws; next is a
cursor for the next call, or null at the end. iterate() is the same read as an async
generator that pages through on its own:
for await (const event of kl.events.iterate({ name: "purchase", since: "2026-01-01" })) {
// every purchase since the new year, across as many pages as it takes
}It never throws either — a page that comes back refused simply ends the loop, so call list()
directly where the reason for stopping matters. Money is minor units, in two halves —
monthlyRecurring and creditsRunRate — never added into one figure; updatedSince, since
and the date fields on what comes back take a Date or an ISO 8601 string, the same rule
occurredAt follows above.
The tag, for the traffic side
The events API is what your server knows. The tag is what your website knows: page reads, where somebody came from, and the signup forms it can hear. In Next.js:
import { KicklaceTag } from "kicklace/next";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<KicklaceTag />
</body>
</html>
);
}It reads NEXT_PUBLIC_KICKLACE_KEY and NEXT_PUBLIC_KICKLACE_URL, or takes
<KicklaceTag publicKey="wk_…" url="…" />. Given a secret key it renders nothing and says why
in the console: a component's output is in the page's source, and a secret key must never be
there.
Anywhere else, it is one line of HTML — the same one Connections → Your website shows:
<script async src="https://www.kicklace.com/api/in/YOUR_PUBLIC_KEY/track.js"></script>The tag itself is not in this package — it is served by your workspace with your own settings
inside it, so there is nothing to install and nothing to keep up to date. Once it is on the page
it gives you kl.track, kl.identify, kl.subscribe and kl.capture in the browser, and it
hears a form marked data-kl-list="your-list" without you writing a handler at all. Where it
goes on Webflow, Framer, WordPress, Squarespace, Carrd and Next.js is in
The tag on your site.
Consent, and who may be emailed
Two rules travel with this package because they are the product, not settings:
- A subscription needs the words the person agreed to.
subscribetakes the sentence they read and stores it verbatim as the proof, with the day and, if you send it, their IP. Without one it is refused (missing_consent). A list's own wording is not a substitute for what the person actually saw. - Kicklace emails a person who has been in touch. Somebody who bought, signed up, wrote in,
replied, or joined a list. Anybody else is
not_engaged, and there is no switch that turns that off.
npx kicklace init
The one command the package ships, 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
npx kicklace init --skill # write the agent skill insteadIt does four things and says each one as it does it:
- adds
import { KicklaceTag } from "kicklace/next"and<KicklaceTag />inside<body>of your root layout (app/layout.tsx, or the same undersrc/); - adds
kicklacetodependencies, and prints the install command rather than running it; - appends the four names below to
.env.example— never a value you already set, and never a key; - says where the keys come from.
Run it twice and the second run says "already in" and changes nothing. It refuses a project
with no next in its package.json, and one with no root layout, in a sentence saying where
the tag goes instead. It installs nothing and sends nothing.
--setup <token>
The token is on Kicklace's setup screen, which is where this command line is copied from. It
lasts 15 minutes and works once. With it, init also:
- exchanges the token for this workspace's keys, over HTTPS, in one request;
- writes them into
.env.local— a name that is already there is left alone, and the secret key is never printed, only named; - writes a Kicklace section into
AGENTS.md, once, so a coding agent working in the project knows the package is there and that the keys are never committed or printed.
The exchange happens before any file is written, so an expired token leaves the project exactly
as it was. --url <base> points at another deployment of Kicklace, such as a dev server; the
default is https://www.kicklace.com.
--skill
Writes the Anthropic agent-skill text — the same words as docs/add-kicklace-to-a-nextjs-saas.md,
Kicklace's own canonical recipe — to .claude/skills/kicklace/SKILL.md, so a coding agent working
in the project already knows how to wire Kicklace in without a network call. It needs no Next.js
project and does nothing else init does; combined with --dry-run it prints the text instead of
writing it, for an agent that would rather read it than have a file appear.
Environment variables
| Name | What it is |
| --- | --- |
| KICKLACE_SECRET_KEY | your secret key, sk_live_…, on the server only |
| NEXT_PUBLIC_KICKLACE_KEY | your public key, for the tag |
| KICKLACE_URL | where Kicklace is, if not https://www.kicklace.com |
| NEXT_PUBLIC_KICKLACE_URL | where the tag is served from, if not the default |
| KICKLACE_RELEASE | which deployment the server is, if not VERCEL_GIT_COMMIT_SHA |
| NEXT_PUBLIC_KICKLACE_RELEASE | which deployment the page is, if not NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA |
The package reads none of them by itself — you pass the key in — except the release above and
KicklaceTag, which reads the two public ones so the tag needs no props.
What it never does
- It never puts a secret key in browser code. The main entry point is for servers; the tag entry takes the public key and refuses a secret one.
- It never throws because Kicklace said no. A refusal comes back as data.
- It never retries a 4xx.
- It has no dependencies, and neither entry point imports anything from
node:, so it runs on Node 18+, on Edge runtimes, in workers, in Bun and in Deno. (npx kicklace initreads and writes files, so it does; it is a command you run at a terminal and nothing imports it.)
Configure it with Claude
The other half of Kicklace is the MCP server: point Claude at your workspace and it can set up the lists, templates, stages and automations that this package's events then feed. Connections → Claude connects it.
MIT
