> ## Documentation Index
> Fetch the complete documentation index at: https://docs.userkit.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Outbound webhooks

> The same domain events, delivered to your server — signed, retried, and honest about arriving twice.

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

```bash theme={null}
curl -X POST https://api.userkit.dev/v1/organization/webhooks \
  -H "Authorization: Bearer uk_st_…" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://api.example.com/userkit/webhooks",
    "environment": "live",
    "subscribed_events": ["contact.identified", "contact.signed_in"]
  }'
```

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

```json theme={null}
{
  "id": "evt_10482",
  "type": "contact.signed_in",
  "sequence": 10482,
  "environment": "live",
  "aggregate": { "type": "contact", "id": "0f9a…" },
  "data": { "method": "password", "ip": "203.0.113.7", "user_agent": "…" },
  "occurred_at": "2026-03-04T18:22:11Z"
}
```

`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:

```http theme={null}
UserKit-Signature: t=1772648531,v1=6f3a…
```

`v1` is `HMAC-SHA256(secret, "<t>.<raw body>")`, hex. Sign the **raw bytes** you
received — parsing and re-serializing the JSON changes them.

```js theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    header.split(",").map((pair) => pair.split("=")),
  );
  const age = Math.abs(Date.now() / 1000 - Number(parts.t));
  if (!(age < toleranceSeconds)) return false;

  const expected = createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest("hex");
  return timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
```

**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:

```
30s · 1m · 2m · 4m · 8m · 16m · 32m · 1h04 · 2h08 · 4h16 · 6h · 6h
```

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.

| Type                                   | Aggregate      | When                                                                                                                                  |
| -------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `organization.created`                 | `organization` | An organization is created, by sign-up or by a signed-in user                                                                         |
| `member.invited`                       | `invitation`   | An invitation is created                                                                                                              |
| `member.role_changed`                  | `membership`   | A member's role changes                                                                                                               |
| `member.removed`                       | `membership`   | A member loses access — removed, or they left. The payload says which                                                                 |
| `organization.usage_threshold_reached` | `organization` | Monthly active contacts crossed 80% or 100% of the free limit                                                                         |
| `contact.identified`                   | `contact`      | A contact stops being just a visitor. It fires on the transition only — a second magic link is a sign-in, not a second identification |
| `contact.merged`                       | `contact`      | One contact is folded into another                                                                                                    |
| `contact.signed_in`                    | `contact`      | An authenticated session was minted for an identified contact, with how (`method`) and from where (`ip`, `user_agent`)                |

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.
