Skip to main content
An endpoint is a URL, a secret and a list of event types. Every fact UserKit publishes internally can leave the building through it, which is why the feature was cheap to build and why nothing about the events changes shape at the edge. Webhooks are never behind a plan. They are a developer primitive, like the API itself.

Register an endpoint

Three things about that call are worth saying out loud. 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:
  • id is 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.
  • sequence is 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 assume contact.identified reaches you before contact.signed_in.
Headers carry the same two values, plus one more: 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.
The timestamp is inside what is signed, and checking it is your half of the deal. Signing the body alone would make a captured request valid forever: anybody who recorded one delivery could replay it at any later moment and the signature would still check out. Signed together — and refused when old — you have a five-minute window instead of an open hole. Compare in constant time, and keep 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 at GET /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. Any 2xx 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:
About fourteen and a half hours from the first attempt to the last — long enough that a bad deploy found in the morning has lost nothing, short enough that a permanently dead address stops being called within a day. Each wait carries a little jitter, which only ever subtracts: a burst of deliveries would otherwise retry in lockstep and hit a recovering server as the same burst that knocked it over. The retry is a column in a table, not a timer in a process, so a deploy in the middle of the schedule changes nothing.

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 needs webhooks: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.