npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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 kicklace
import { 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 init

Your 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. subscribe takes 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 instead

It 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 under src/);
  • adds kicklace to dependencies, 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 init reads 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