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.deletedis 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 website → Your 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}` })`.