Skip to content

Documentation

Sign-ups from Clerk and Kinde

A new account in Clerk or Kinde lands as a sign-up.

For the developer who already has sign-in working. If your product's accounts live in Clerk or in Kinde, Kicklace hears about them directly, from Clerk with one key and from Kinde with one application, and a sign-up becomes a person at Signed up on your Lifecycle. There's no code to write and nothing to deploy.

What arrives is exactly what would have arrived through POST /api/v1/events, so a sequence at Signed up answers a Clerk sign-up without knowing Clerk exists.

Both are under Connections, and only an owner or an admin can change them.

This is half of it. It tells Kicklace somebody signed up; it doesn't tell Kicklace that the browser reading your pricing page last week is the same somebody. A hosted sign-in, like Clerk's own components, leaves no form on your site for the tag to hear. <KicklaceTag user={…} /> is the other half: pass it the session Clerk (or Better Auth, or NextAuth) already gives you, and the tag joins that browser to that person. npx kicklace init writes it for you where it can check the call.

Clerk

Connections → Clerk → Connect asks for one thing: your Clerk secret key, the sk_live_… (or sk_test_…) on Clerk's API keys page. Paste it and press Connect. Kicklace checks once that the key works, keeps it locked away, and never shows it back.

Nothing is set up in Clerk. Clerk has no way for another service to add its own webhook, so Kicklace reads your users with the key every hour: whoever signed up since the last read lands as a sign-up, on the day Clerk says, and whoever signed in lands as a sign-in. Everybody from before you connected is read once as your history (below).

A sign-up arrives within the hour rather than the second, which an email that waits a day never notices. A workspace that set up Clerk's webhook by hand before this keeps it, and a sign-up both of them see is one row.

That webhook's address is https://<your Kicklace>/api/webhooks/clerk/<token>, subscribed to user.created, user.updated, session.created and user.deleted, and signed by Clerk through Svix (the headers are svix-id, svix-timestamp and svix-signature; the signed content is "<id>.<timestamp>.<body>"; five minutes of clock skew are allowed). Until its signing secret is in, nothing is taken from it.

Kinde

Connections → Kinde → Connect asks for one application. In Kinde, go to Settings → APIs → Kinde Management API and add a machine-to-machine application with these scopes: read:users, read:organizations, read:organization_users, read:webhooks and create:webhooks. Paste your Kinde domain (acme.kinde.com), the application's client id and its secret, and press Connect.

Kicklace asks Kinde for a token with the pair, so a pair Kinde won't take is said at once. Then it adds its own webhook named Kicklace for user.created, user.updated, user.authenticated and user.deleted, and starts reading your history. The pair is locked away when saved and never shown again. If the application is missing a scope, the screen says which.

Kinde signs each post as a JWT with a key only it holds, and publishes the other half at https://<your domain>/.well-known/jwks, so the domain isn't a secret and is shown back in the clear. Kicklace keeps those keys for an hour and reads them again the moment a post names one it hasn't seen, so a key rotation at Kinde doesn't cost you an hour of sign-ups. RS256 only.

What each event becomes

Over there In Kicklace
user.created (Clerk, Kinde) The person, by their main address, with their name, and one sign-up on their timeline, exactly as a signup posted to /api/v1/events. Their id at the provider is kept as an identity (clerk_user / kinde_user).
user.updated (Clerk, Kinde) The same person. A new address becomes another identity on them, never a second person; a blank name or email is filled in. Nothing goes on the timeline, because an edit isn't something that happened to them.
session.created (Clerk) / user.authenticated (Kinde) One sign_in on their timeline. It moves nobody unless the switch below is on.
user.deleted (Clerk, Kinde) One account_deleted on their timeline. Nothing is deleted here.
Anything else 200, ignored, and one line on your heartbeat saying so.

sign_in and account_deleted are ordinary names of your own, so a side road on your Lifecycle map can start from either.

The one switch: "A first sign-in counts as Activated"

Off unless you turn it on, because signing in isn't, by itself, using anything.

With it on, the first sign-in Kicklace sees for a person also records an activated event, and moves them to Activated if that's further along than where they are. Turning it on later doesn't go back over sign-ins that already happened.

What never happens

  • Nothing is ever deleted. A user.deleted is a line on a timeline. Deleting a record is your own act, on the record.
  • Nothing is emailed from here. These write events. An email only happens if a sequence you switched on says so.
  • Nobody is invented. A sign-in for somebody Kicklace has never been told about is answered { ok: true, status: "ignored", reason: "no_person" } and dropped.
  • Nothing is changed at the provider. Kicklace only ever reads from it.

Checking it worked

Connections → Your website → Your website's heartbeat. Every post lands there as one row, like clerk user.created or kinde user.authenticated, with what became of it. No bodies and no addresses are kept in that log.

The quickest test is to sign up to your own app once.

The refusals

Answer HTTP What to do
unknown_endpoint 404 The token in the address isn't one of ours. Copy the address again from Connections.
no_signing_secret 400 Clerk: the address is registered but the whsec_… hasn't been pasted in. Nothing is taken.
no_domain 400 Kinde: Kicklace doesn't know which Kinde business it is, so it can't check the signature.
bad_signature 400 The signature didn't hold: the wrong secret, a body changed on the way, a Clerk timestamp more than five minutes out, a Kinde token signed with a key that domain doesn't publish, or a token that isn't RS256.
invalid_json 400 Clerk's body wasn't JSON.
too_large 413 Over 256 KB.
{ ok: true, status: "ignored" } 200 Something Kicklace has no use for, or a sign-in for somebody it has never been told about. Not a refusal, so the provider stops retrying.
server_error 500 Kicklace fell over. A 500 asks the provider to try again, which is what should happen.

A retry lands once. A sign-up is keyed by the user the provider made, so one read from your history and the same one arriving as a webhook are one row.

Your history

A webhook only hears what happens next. If people have been signing up for two years, Kicklace reads those two years too, the moment you connect: every user, every organization and who belongs to which.

  • every user becomes a person, with the day they signed up as a sign-up and, where the provider has it, the day they were last here as a sign-in;
  • every organization becomes an organization, named the way the provider names it;
  • every membership links that person to that organization.

Reading your history sends nothing, puts nobody on a list (a row in your users table is an account, not consent), and dates nothing today. Nobody is made out of a membership alone. Deleted users aren't read.

It's safe to press twice: every row is keyed by what the provider calls it. A big instance takes more than one go; Read again picks up where it stopped.

If you use something else

Supabase, Auth0 and Firebase aren't built in yet. Post the same event yourself, three lines in the hook you already have:

await fetch("https://<your Kicklace>/api/v1/events", {
  method: "POST",
  headers: {
    "content-type": "application/json",
    authorization: `Bearer ${process.env.KICKLACE_SECRET_KEY}`,
  },
  body: JSON.stringify({
    type: "signup",
    email: user.email,
    idempotency_key: `signup:${user.id}`,
  }),
});

Or with the package: await kl.track("signup", { email, idempotencyKey: \signup:${user.id}` })`.