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. Your own webhooks are never behind a plan. They are a developer primitive, like the API itself — and that is what this whole guide is about until the end, where the two planes that sentence does not cover live: the audit trail, and the webhooks you resell to your own customers, which are a service we perform continuously rather than access to your own data.

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. Editing or deleting a feedback post is not delivered either, and the reason is worth knowing because it is not secrecy: a retitled post is public the moment it is saved. Nothing acts on “a typo was fixed”, and deliverability is a one-way door — turning one on later is a new capability, turning one off is a broken contract. When a fact is publishable and nobody has a use for it, withholding it is the only direction that stays reversible.

The audit plane

Every endpoint above is on the product plane: your product’s facts, filtered by subscribed_events. There is a second plane, and registering an endpoint with "plane": "audit" puts it there. An audit endpoint receives the staff audit trail — every action somebody performed in the panel, the same records GET /v1/organization/audit-events shows. None of those types is in the catalogue above and none of them ever arrives at a product endpoint: reading the trail is audit:read, which only owner holds, while webhooks:manage is held by admin too, so delivering them through the ordinary fan-out would route an owner-only record out of the organization without anybody touching the narrower permission. The audit plane is the door that does have the narrower permission on it. Four things about it differ from a product endpoint, and each of them follows from the record being a record:
  • It carries the actor. Every delivery has an extra actor object — user_id, email, ip, user_agent — which no product delivery ever has, whatever the fact.
  • It takes everything. subscribed_events is refused: a record with the interesting lines filtered out reads as nothing having happened.
  • It belongs to the organization, so it is registered against the live environment and receives staff actions taken in both. The trail is not an environment’s.
  • It needs audit:deliver — owner-only, and deliberately separate from both webhooks:manage and audit:read: reading a record and deciding where it is sent forever are different acts. Registering one also needs a plan that carries the capability, and a plan that stops carrying it stops the deliveries. The trail itself is recorded and kept on every plan; what a plan buys is the carrying.
Everything else is identical: the same signature, the same event ids, the same at-least-once and unordered contract, the same retry schedule and delivery log.

The customer plane: webhooks you resell

The two planes above deliver to you. The third delivers to your customers: one of your teams registers an endpoint in the portal and starts receiving the events you tracked about that team — under your product’s event names, not the catalogue above. It is the answer to the request every B2B SaaS gets early: “tell me when an order is approved, instead of me polling your API”. Without it you would build the queue, the signature, the retries, the log and the screen from scratch. With it, your customers use the same infrastructure you already use.

How it works, end to end

1. Your backend reports the fact, through the door that already exists — naming the team it belongs to:
2. Your customer registers their endpoint, in the portal at /webhooks — or through the API, with their own contact session:
3. The delivery lands on their server, signed like any other:

What is different, and why

  • The vocabulary is yours. The type is order.approved — the name you tracked. UserKit’s catalogue never reaches here, and the receiver never has to know there is a UserKit in the middle.
  • subscribed_events is required. On the product plane, empty means everything; here it may not be empty. Your vocabulary is open-ended — there is no closed list to check against — so “everything” would mean subscribing to every name you ever invent, on behalf of a receiver who wrote none of them. It is 1 to 20 names, and at most 5 endpoints per team.
  • The scope is the team, and a fact with no team reaches nobody. The fan-out matches the event’s customer_id against the endpoint’s team: one customer never hears another’s traffic. A /v1/track line with no customer_id is a fact about the person rather than about a team — and it is delivered to no endpoint on this plane. If the event matters to the customer, name the team.
  • Your own endpoints do not get the echo. The event you just sent us does not come back through the product plane. An echo is the first half of a loop, and your analytics stream already has that data.
  • The failure notice goes to the customer, not to you. If their server stops answering and the endpoint is turned off, the email goes to whoever can fix it — the administrators of that team, in their own language, signed with your brand. You do not become third-line support for machines that are not yours.
  • It is part of your plan. Registering needs a plan that carries the capability (403 webhooks_not_available without it), and a downgrade stops the deliveries without deleting anything — the endpoints stay registered for when the plan comes back. Same posture as the audit plane, for the same reason: what is sold here is not the customer’s access to their own data, it is the carrying we perform continuously on their behalf.
Everything else is identical to what you have already read: https:// only, HMAC over t.body, twelve attempts, auto-disable after five exhausted deliveries, a re-showable and rotatable secret, the delivery log and the test send — all under /v1/contact/customer/webhooks/{id}/….

Who administers it, on the customer’s side

The capability is $webhooks.manage, from your customer plane’s role vocabulary. The owner role holds it already; if you have defined roles of your own (a “finance”, a “technical”), decide which of them should be able to point the team’s events at a server — that is your decision, on the roles screen, and not one we make for you.

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. The audit plane adds audit:deliver on top, for owner only — registering, re-aiming, disabling or deleting an audit endpoint. Reading one stays on webhooks:manage: noticing that an export exists is not the same act as choosing where it goes. The customer plane uses neither. It is administered by your customer, from their own contact session, under $webhooks.manage — a capability from your customer plane’s role vocabulary, not a staff permission. The practical consequence: your customers’ endpoints do not appear on your webhooks screen and are not addressable through /v1/organization/webhooks/{id} (it answers 404). They are another party’s credentials, and the signing secret that screen re-shows is theirs, not yours.