Register an endpoint
https:// only. Deliveries carry facts about your own customers and a
signature that proves who sent them, and neither survives being read off the
wire. A redirect is refused rather than followed — register the address that
actually answers.
An endpoint belongs to an environment and never leaves it: a test endpoint
hears test traffic, a live one hears live traffic. Facts about the organization
itself (organization.created, member.invited) carry no environment and go to
your live endpoints — an organization has one real existence, and the test
environment is where the customer plane is rehearsed, not where the organization
is.
An empty subscribed_events means all of them. A type added to the catalogue
later starts arriving without you changing anything, which is usually what you
want on the first endpoint and rarely what you want on the third.
The payload
data carries ids, not snapshots — re-read the resource for its current
state. That keeps names and addresses out of every queue, log and retry buffer
between us and you, and it means a handler that runs late reads what is true now
rather than what was true when the event was written.
Delivery is at-least-once, and unordered
This is the contract, not a caveat. The same delivery can arrive twice and arrival order is not the order of the facts. Two fields exist to make that workable, and using them is not optional:idis what you deduplicate on. It is stable across every retry and across a manual replay — a replay is the same fact arriving again, and a handler that already processed it must be able to say so. Write it down before you act on the event; skip anything you have seen.sequenceis what you order on. It is a global monotonic number assigned at commit, so of two events about the same contact the larger one is the later fact. Compare it. Do not compare arrival times, and do not assumecontact.identifiedreaches you beforecontact.signed_in.
UserKit-Delivery-Id names
this particular attempt set. A retry keeps it, a replay gets a new one. It is
what to quote in a support conversation; UserKit-Event-Id is what your code
branches on.
Verifying the signature
Every request carries:v1 is HMAC-SHA256(secret, "<t>.<raw body>"), hex. Sign the raw bytes you
received — parsing and re-serializing the JSON changes them.
v1 a tag rather than an assumption: read
the pairs you know and ignore the rest, so a future algorithm can be added beside
this one instead of instead of it.
The secret is re-showable
Unlike an API key, you can read it again atGET /v1/organization/webhooks/{id}/secret.
That is a consequence, not a convenience. An API key is only ever compared, so
storing its hash is enough; a webhook secret has to sign every delivery, so
the row holds it either way. Once that is true, showing it once would only stop
you from reading what the database plainly contains — while costing you the one
recovery that is not “rotate and break every deployed verifier at once”.
Rotation is the actual control, and it has no grace window: the new secret
takes effect on the next delivery. Two live secrets would mean a verifier that
accepts either, which is the property a rotation exists to remove. Deploy the new
secret first, then rotate.
Retries, and when we stop
Your endpoint has ten seconds to answer. Any2xx is success; acknowledge first
and do the work afterwards.
A failed attempt is retried twelve times, doubling from 30 seconds and capped
at six hours:
Auto-disable
Five deliveries in a row spending every attempt turns the endpoint off. That takes at least the fourteen hours the first one spent retrying, across five different facts, which is what “sustained” has to mean before we stop calling your server. Any single success resets the streak. A disabled endpoint is queued nothing new — and your organization’s owners are emailed, because an integration that goes quiet without a word is discovered by absence. Nothing is lost: the deliveries are in the log, and replaying one is a click once the address answers again. Re-enabling clears the streak, so the first failure after a repair does not turn it off again.The delivery log, replay and test sends
GET /v1/organization/webhooks/{id}/deliveries is the last hundred deliveries,
newest first, with the attempts spent, the status that came back and how long it
took. Finished deliveries are kept for 30 days; a pending one is never pruned.
POST …/deliveries/{deliveryId}/replay sends the stored body again, byte for
byte, under a new delivery id and the same event id.
POST …/test queues one delivery carrying webhook.test — a type deliberately
absent from the catalogue, so a handler reacting to it is reacting to a button
somebody pressed rather than to a fact.
Both answer 202: the row is durable, the attempt has not happened yet, and the
outcome shows up in the log.
The published catalogue
This is the contract to build against, and it is the same list the delivery fan-out reads — a type missing here is a type that will not arrive.GET /v1/organization/webhooks/events returns it at runtime.
One fact is published internally and never delivered:
webhook.endpoint_disabled. It would be addressed to the endpoint that just
stopped answering, which is a message to nobody — the email to your owners is
what carries it instead.
Permission
Everything above needswebhooks:manage, held by owner and admin by default.
There is no read/write split, because the screen shows the signing secret:
reading an endpoint is holding its credential.