# 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

```text
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 ladder
```

The 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.

```json
{
  "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](https://www.salafems.com/developers/webhook-events.md). Treat it as an open set.                     |
| `api_version`            | The payload version pinned to *your endpoint* when it was created. See [Versioning](https://www.salafems.com/developers/versioning.md). |
| `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`.                                                   |

> **Note — Orders always arrive expanded**
>
> The REST list endpoint makes `items` and `customer` opt-in, because a sync
> walking a hundred orders a page does not want them. A webhook is the opposite
> situation — one event, delivered once, to a consumer who would otherwise need
> a second round trip — so order payloads always carry both.

## 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

1. **Verify the signature before you trust the body.** With a constant-time
   comparison, against the raw bytes.
   → [Verifying signatures](https://www.salafems.com/developers/webhook-signatures.md)
2. **De-duplicate on `X-Salaf-Event-Id`.** A unique index on that id, and a
   `DO NOTHING` on conflict, is the entire mechanism.
3. **Upsert from `data.object`.** Never patch field-by-field from an event, and
   never infer state from arrival order. When in doubt, `GET` the resource —
   the REST API is always authoritative.
4. **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.

> **Warning — A slow 200 costs you a duplicate**
>
> Ten seconds is generous for "I have it" and deliberately hostile to "let me
> do the work first". If your handler talks to a payment provider, rebuilds a
> cache, or sends an email inline, one slow dependency turns into a timeout on
> our side and a second delivery on yours — of an event you already processed.

## 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).

> **Note — Webhook endpoints cannot be managed over the API**
>
> There is no public endpoint for creating or editing them, and the
> `webhooks:manage` scope in the catalogue is not grantable. That is
> deliberate: a leaked API key must not be able to point a merchant's event
> stream at somebody else's server.

### URL requirements

Your URL is fetched by our servers, which makes it a
[server-side request forgery](https://owasp.org/www-community/attacks/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; `http`
  would 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.1` or `169.254.169.254` is refused.
- **Redirects are never followed.** A `3xx` is 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](https://www.salafems.com/developers/webhook-events.md) — every type we send.
- [Verifying signatures](https://www.salafems.com/developers/webhook-signatures.md) — with test vectors
  you can check your code against before going live.
- [Retries & ordering](https://www.salafems.com/developers/webhook-retries.md) — the ladder, dead
  deliveries, and the three-day auto-disable.
- [Testing](https://www.salafems.com/developers/webhook-testing.md) — `salaf.ping` and a local loop.
- [Handle webhooks idempotently](https://www.salafems.com/developers/guide-handle-webhooks.md) — the
  whole receiver, end to end.
