Skip to content

Documentation

Sign-ups from Clerk and Kinde

If the accounts in your product live in Clerk or in Kinde, Kicklace can hear about them directly. Paste one address into their dashboard, bring one thing back, and a new account becomes a person on a timeline with their lifecycle moved on. There is no code to write and nothing to deploy.

Written for the developer who already has sign-in working (#198). If your product's accounts live in Clerk or in Kinde, Kicklace can hear about them directly: one address pasted into their dashboard, one thing brought back, and a sign-up becomes a person on a timeline with the lifecycle moved to Account. There is no code to write and nothing to deploy.

It is the shape the Stripe receiver already has, on purpose. What arrives is exactly what would have arrived through POST /api/v1/events, so an automation written for a sign-up answers a Clerk sign-up without knowing Clerk exists.

Both screens are Settings → Integrations, and only an owner or an admin can change them.


Clerk

1. Make the address. Settings → Integrations → Clerk → Connect Clerk. You get an address of the form https://<your Kicklace>/api/webhooks/clerk/<token>. The token is not a secret on its own — the signature is what proves a post came from Clerk — but it is not published either, so a stranger cannot guess your endpoint.

2. Register it in Clerk. Clerk dashboard → Webhooks → add an endpoint. Paste the address as the Endpoint URL and subscribe to these four:

user.created, user.updated, session.created, user.deleted

Subscribing to more is harmless: anything else is answered 200 and ignored, which is what stops Clerk retrying it.

3. Bring back the signing secret. Clerk shows it on the endpoint's own page as Signing Secret; it starts with whsec_. Paste it into the second step in Kicklace. It is encrypted at rest and never shown again — the screen says "set on the 8th" and offers Replace. Until it is in, nothing is taken: Kicklace answers no_signing_secret and writes the refusal to your heartbeat.

Clerk delivers through Svix, which is the Standard Webhooks scheme: the headers are svix-id, svix-timestamp and svix-signature, the signed content is "<id>.<timestamp>.<body>", and five minutes of clock skew are allowed. Kicklace also reads the webhook-* header names, for a sender that has moved to them.

Kinde

1. Make the address. Settings → Integrations → Kinde → Connect Kinde, giving https://<your Kicklace>/api/webhooks/kinde/<token>.

2. Register it in Kinde. Kinde dashboard → Settings → Environment → Webhooks → Add webhook. Paste the address and tick these four:

user.created, user.updated, user.authenticated, user.deleted

Everything else Kinde offers — user.authentication_failed, the organization.*, role.*, permission.*, passkey.* and customer.* families — is answered 200 and ignored.

3. Say which Kinde business. The second step is your Kinde domain, acme.kinde.com, the address you sign in to Kinde at. Paste it however you have it — with or without https://, with or without a path — and Kicklace reduces it to the host.

Kinde does not hand over a signing secret: it 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 is not a secret, and it is shown back in the clear. Kicklace reads those keys, keeps them for an hour, and reads them again the moment a post names a key it has not seen — so a key rotation at Kinde does not cost you an hour of dropped sign-ups. RS256 only; nothing else is accepted.


What each event becomes

Over there In Kicklace
user.created (Clerk, Kinde) The person, by their primary address, with their name. One sign-up event on their timeline, and the lifecycle moves them to Account — exactly as a signup posted to /api/v1/events does. Their id at the provider is written down as an identity (clerk_user / kinde_user).
user.updated (Clerk, Kinde) The same person again. A changed address becomes another identity on them, never a second person; a blank name or a blank email field is filled in. Nothing is written to the timeline — an edit is not something that happened to them.
session.created (Clerk) / user.authenticated (Kinde) One sign_in event on their timeline. It moves nobody, unless the switch below is on. Found by the provider's user id alone, because neither provider puts an address on this one.
user.deleted (Clerk, Kinde) One account_deleted event on their timeline. Nothing is deleted here.
Anything else 200, ignored, and one line in your heartbeat saying so.

sign_in and account_deleted are ordinary custom event names, the kind /api/v1/events takes, so an automation can trigger on either and a chart can count them.

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

Off unless you turn it on, because signing in is not, 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 the Activated stage if that stage is further along than where they already are. A workspace made as a web app or service — the answer to "What are you building?" at /new, and what a new workspace is unless you say otherwise — has Account before Activated, so somebody who signed up through this same door does move: their first sign-in takes them to Activated. A workspace made as a downloadable app has Activated before Account, so a person who already has an account is further along and stays where they are; the event is still written either way, so hasEngaged, an automation, and a chart all see it. Your own order is whatever Settings → Pipelines says, and the rule reads it, not the shipped one.

Turning it on later does not go back over the sign-ins that already happened.

What never happens

  • Nothing is ever deleted. A user.deleted is a line on a timeline. An account going at Clerk is not consent to erase what your workspace knows about that person; deleting a record is your own act, on the record page, and it is what the export and the privacy tools are for.
  • Nothing is ever emailed from here. These endpoints write events. An email only happens if one of your own automations says so, under the same consent rules every other send obeys.
  • Nobody is invented. A sign-in for a user Kicklace has never been told about is answered { ok: true, status: "ignored", reason: "no_person" } and dropped. Kicklace will not make a person out of an id it has never seen beside an address.
  • Nothing is changed at the provider. Kicklace never calls Clerk's or Kinde's API. It listens.

Checking it worked

Settings → Your websiteYour website's heartbeat. Every post lands there as one row — clerk user.created, kinde user.authenticated — with what became of it. No bodies and no addresses are kept in that log. Each frame also carries its own line: "3 events have come through, the last one 4 minutes ago."

The quickest test is to sign up to your own app once. Both dashboards can also send a test event to the endpoint from the endpoint's own page.

The refusals, and what each one means

Answer HTTP What to do
unknown_endpoint 404 The token in the address is not one of ours. Copy the address again from Settings → Integrations.
no_signing_secret 400 Clerk: the address is registered but the whsec_… has not been pasted in. Nothing is being taken.
no_domain 400 Kinde: the address is registered but Kicklace does not know which Kinde business it is, so it cannot check the signature.
bad_signature 400 The signature did not hold: the wrong secret, a body rewritten in transit, a Clerk timestamp more than five minutes out, a Kinde token signed with a key that domain does not publish, or a token that is not RS256.
invalid_json 400 Clerk's body was not JSON.
too_large 413 Over 256 KB.
{ ok: true, status: "ignored" } 200 An event Kicklace has no use for, or a sign-in for somebody it has never been told about (reason: "no_person"). Deliberately 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: Clerk's svix-id and Kinde's event_id are the idempotency key on the event that is written.

If you use something else

Supabase, Auth0 and Firebase are not native yet. Post the same events yourself — it is 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 SDK (docs/sdk.md): await kl.track("signup", { email, idempotencyKey: \signup:${user.id}` })`.