Overview
Outbound events: the envelope, the guarantees, the rules.
A webhook is a signed POST from us to a URL you own, sent when something
happens in a store. It carries the same object shape the REST API returns, so
an order.created webhook and GET /orders/{id} can never disagree about what
an order looks like — they are produced by the same serializer.
Webhooks replace polling for the things you need to know about quickly. They
do not replace polling entirely: updated_after remains the reconciliation
path for anything you missed while your receiver was down.
How an event reaches you
your store does something (a sale, a stock move, a status change)
│
├─ in the SAME database transaction ──▶ the event is recorded
│ (with a snapshot of the object)
├─ just after the commit ─────────────▶ fan-out to your subscribed endpoints
│ (a 30s sweeper re-drives anything missed)
└─ per endpoint ──────────────────────▶ signed POST → 2xx, or the retry ladderThe first step is the one that matters. The event row is written inside the same transaction as the fact it describes, so "the database committed but the event vanished" cannot happen: they commit together or not at all. Everything after that — the queue, the sweeper, the retries — is about speed, not correctness.
The payload
Every delivery has the same envelope. Only data changes between event types.
{
"id": "evt_01J4X8G9ABCDEFGHJKMNPQRSTV",
"type": "order.updated",
"api_version": "2026-08",
"created_at": "2026-08-11T10:00:00.000Z",
"company_id": "e07b4a29-6c13-4d85-b920-3f8e1c65d704",
"store_id": "b21c5f04-9d7e-4c11-8f3a-6e0d2b915a77",
"data": {
"object": {
"id": "c9d8e7f6-1a2b-4c3d-8e9f-0a1b2c3d4e5f",
"number": "ORD-20260811-1042",
"status": "confirmed",
"payment_status": "partially_paid",
"total_amount": "2310.00"
},
"previous": {
"status": "pending"
}
}
}| Field | What it is |
|---|---|
id | evt_ + a ULID. Globally unique, sortable by creation time. This is your de-duplication key. |
type | The event type, from the catalog. Treat it as an open set. |
api_version | The payload version pinned to your endpoint when it was created. See Versioning. |
created_at | When the event was recorded — not when it was delivered. A retry two days later still carries the original. |
company_id, store_id | Which tenant this happened in. Load-bearing if one endpoint receives several stores. |
data.object | The full object, from the same serializer the REST API uses. |
data.previous | Only on *.updated events, and only the fields that changed. Today that is status. |
What we guarantee, and what we do not
| At-least-once delivery | You will receive every event at least once, and occasionally more than once. A network timeout is indistinguishable from a slow success, so we retry. |
| No ordering guarantee | Deliveries run in parallel and retries reshuffle everything. order.updated can arrive before order.created. |
| A snapshot, not a pointer | data.object is the object as it was inside the transaction that produced the event — not the state at delivery time, and not something you must re-fetch. |
| Byte-identical redelivery | A retry sends exactly the same bytes as the first attempt, so a signature you stored still verifies. |
Those three properties add up to one rule, which is the whole of writing a
correct receiver: de-duplicate on id, and treat every handler as an
upsert.
The four rules of a receiver
- Verify the signature before you trust the body. With a constant-time comparison, against the raw bytes. → Verifying signatures
- De-duplicate on
X-Salaf-Event-Id. A unique index on that id, and aDO NOTHINGon conflict, is the entire mechanism. - Upsert from
data.object. Never patch field-by-field from an event, and never infer state from arrival order. When in doubt,GETthe resource — the REST API is always authoritative. - Answer 2xx fast, work asynchronously. Anything is a success if it is
2xx; we give you 10 seconds before we call it a timeout and retry. Queue the work, then answer.
Registering an endpoint
Endpoints are created in the dashboard, under Settings → Developer →
Webhooks. You give a name, an https:// URL, and the events you want.
- The signing secret is shown once, at creation, and again only when you
rotate it. It starts with
whsec_. - Subscribe to specific types, or to
*. The wildcard means every event, including types added later. It cannot be mixed with an explicit list —["*", "order.created"]is refused, because it reads as if the second entry narrowed something, and it does not. - Five endpoints per store by default (
max_webhook_endpoints, raised by plan).
URL requirements
Your URL is fetched by our servers, which makes it a server-side request forgery target. Four rules follow, and they are enforced both when you save an endpoint and again on every delivery, because DNS can change under a name that validated last week:
https://only. A payload carries customer data and a signature;httpwould send both in the clear.- No credentials in the URL, and no privileged port other than
443. - Public addresses only. The hostname is resolved and the actual IP is
checked — a name that points at
127.0.0.1or169.254.169.254is refused. - Redirects are never followed. A
3xxis recorded as a failed delivery with the message "Redirects are not followed. Register the final URL directly." Register the URL you actually want called.
Next
- Event catalog — every type we send.
- Verifying signatures — with test vectors you can check your code against before going live.
- Retries & ordering — the ladder, dead deliveries, and the three-day auto-disable.
- Testing —
salaf.pingand a local loop. - Handle webhooks idempotently — the whole receiver, end to end.