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.
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 theproduct 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
actorobject —user_id,email,ip,user_agent— which no product delivery ever has, whatever the fact. - It takes everything.
subscribed_eventsis 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
liveenvironment and receives staff actions taken in both. The trail is not an environment’s. - It needs
audit:deliver— owner-only, and deliberately separate from bothwebhooks:manageandaudit: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.
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:/webhooks — or
through the API, with their own contact session:
What is different, and why
- The vocabulary is yours. The
typeisorder.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_eventsis required. On theproductplane, 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_idagainst the endpoint’s team: one customer never hears another’s traffic. A/v1/trackline with nocustomer_idis 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
productplane. 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_availablewithout 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.
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 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.
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.