Skip to content

Documentation

The kicklace package

kicklace is the package your own server posts with. One line gives it a secret key, and from then on it says what a person did: they signed up, they paid, they used the thing. It has no dependencies, every method answers instead of throwing, and it opens no door of its own — everything it does you could post by hand.

The runtime half of the product. The MCP door (docs/mcp.md) is how a workspace is configured — lists, templates, stages, automations, the website hookup — and this package is how it is run: one line in a customer's server code that posts what a person did.

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

It opens no door 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 — for the tag — {app}/api/in/{public key}/track.js. If it can do something, the API could already do it by hand.

Where it lives

packages/kicklace/, an npm workspace of this repository, so one npm run check tests the API and the SDK that wraps it. The root package is named kicklace-app and is private, because one workspace tree cannot hold two packages called kicklace; nothing reads the root package's name (src/lib/mcp/server.ts reads its version and writes the server's name itself).

File What it is
src/types.ts every option and every answer, mirroring what the routes read
src/body.ts camelCase in, the routes' own snake_case out
src/http.ts one request: the timeout, the retries, and a refusal turned into an answer
src/client.ts Kicklacetrack, identify, subscribe, support, batch
src/messages.ts kl.messagessend, list, thread
src/tag.ts, src/next.ts tagSrc, and <KicklaceTag /> for the App Router
src/cli.ts npx kicklace init — the bin, and the only file that reads from node:
README.md what a customer reads on npm

Zero runtime dependencies; react and next are optional peers for the kicklace/next entry only. Neither entry point imports anything from node:, so it runs on Node 18+, on Edge runtimes, in workers, in Bun and in Deno. cli.ts uses node:fs and node:path, and no entry point imports it: it is a command, not a library.

npx kicklace init (#216)

One command, for a Next.js project that already exists. It finds the nearest package.json, refuses anything with no next in it — "This is not a Next.js project; the tag goes in any HTML page — see https://www.kicklace.com/developers" — then adds the import and <KicklaceTag /> inside <body> of app/layout.tsx (or .jsx, or the same under src/), adds kicklace to dependencies at the package's own version, and appends KICKLACE_SECRET_KEY, NEXT_PUBLIC_KICKLACE_KEY, KICKLACE_URL and NEXT_PUBLIC_KICKLACE_URL to .env.example. Those are the four the Vercel integration writes into a project (#200), which is why they are four and not three: <KicklaceTag /> reads NEXT_PUBLIC_KICKLACE_URL.

The rules it keeps:

  • It never overwrites a value somebody set. A name already in .env.example, and a kicklace already in dependencies, are left exactly as they are.
  • Nothing is written until every step says it can be done, so a refusal leaves the project untouched rather than half changed.
  • It runs nothing. npm install is printed, not executed.
  • --dry-run prints every change and makes none. Running it twice says "already in".

VERSION in cli.ts is a constant rather than a read of package.json, because the same source is built as both ESM and CommonJS and neither import.meta.url nor __dirname exists in both; cli.test.ts fails if the two disagree. The bin is dist/cjs/cli.js, and scripts/finish.mjs is what puts the shebang on it and makes it executable — a #! on the first line of a module a test imports is a hazard, and CommonJS is the only build node ever runs as a program.

The two rules that are the product

  1. A refusal is an answer. Every method resolves to { ok: true, status, id? } or { ok: false, error, message, httpStatus }, carrying the route's own code and sentence untouched — where a route answers a code and no sentence, the SDK writes one naming the code. Nothing here throws inside somebody's checkout. The only throws are at construction: no key, or a key that is not 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.

Retries are 5xx and network failures only — three attempts, a short wait between them. A 4xx is an answer and asking again will not change it. idempotencyKey is what makes a retried purchase land once, and the README says so.

How npm run check covers it

check is tsc --noEmit && tsc -p packages/kicklace --noEmit && eslint src scripts packages/kicklace/src && vitest run, and vitest.config.mts includes packages/*/src/**/*.test.ts beside the app's own. Two kinds of test:

  • Unit (packages/kicklace/src/*.test.ts), against a fake fetch: the exact body and headers of every method, the batch of 100 and the local refusal of 101, the retries, the timeout, the Outcome shapes, the construction refusals, and the tag's markup and its refusal of a secret key.
  • The drift guard (src/lib/__tests__/sdk.test.ts), against the real route handlers: fetch is a function that hands a Request to the module each route exports, so the SDK drives the app with no server and no network. Every method's payload has to be accepted. If a route changes what it takes, this file fails before anybody's npm install does.

The app resolves kicklace to packages/kicklace/src/index.ts — a paths entry in tsconfig.json and an alias in vitest.config.mts — so the guard reads the source we edit, not a build that may not exist yet. next build never sees the package: nothing under src/app imports it, and the root tsconfig.json excludes packages.

CI builds the package after npm run check, so a broken exports map is caught before a publish rather than after one.

Building it

npm run build -w kicklace

tsc twice and nothing else — tsconfig.esm.json writes dist/esm with the declarations, tsconfig.cjs.json writes dist/cjs, and scripts/finish.mjs (plain node, no dependency) writes the two package.json markers that tell Node which folder is which kind and copies the declarations beside the CommonJS output, so a require and an import each get types of their own kind. dist/ is not committed.

How Michael publishes it

The npm account and the token are his; nobody else holds them, and CI does not publish. Provenance from CI is a later step.

cd packages/kicklace
npm run build -w kicklace          # or `npm run build` from this folder
npm pack --dry-run                 # dist and the README, and nothing else
npm login                          # once
npm publish --access public

Bump version in packages/kicklace/package.json first; it is the package's own version and has nothing to do with the app's. 0.1.0 was the first.

The site says nothing about the package until it is on npm — that is #201, and the honesty rail in CLAUDE.md is why.

0.2.0, the version that carries init

0.2.0 adds npx kicklace init and nothing else: no method changed, no answer changed, no option was added or taken away, so anything written against 0.1.0 works untouched.

npm run build -w kicklace
npm pack --dry-run -w kicklace     # dist/cjs/cli.js is in the list, with the shebang
npm publish --access public        # from packages/kicklace

Two things to check on the tarball before publishing, because they are what make the command work at all:

tar -xOf kicklace-0.2.0.tgz package/dist/cjs/cli.js | head -1   # #!/usr/bin/env node
node -e "console.log(require('./package.json').bin)"            # { kicklace: 'dist/cjs/cli.js' }

npm sets the executable bit on a bin file when it installs one, and finish.mjs sets it here as well, so npx kicklace init works from a fresh install either way.

Then, in a scratch Next.js project, the proof that matters:

npx kicklace@0.2.0 init --dry-run
npx kicklace@0.2.0 init

VERSION in cli.ts and version in package.json are bumped together — npm run check fails if they are not.

What it does not have yet

  • subscribe takes no name. The public ingest route accepts one; /api/v1/events does not pass it to subscribe(), so the SDK does not offer a field the route would ignore. Set a name with identify in the same batch.
  • No retry-after handling for a 429: a rate limit is answered, not waited out.
  • No queue, no background flush, no batching of its own. batch is explicit, the way the route is.