SALAF EMSDevelopers
salafems.com

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

{
  "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"
    }
  }
}
FieldWhat it is
idevt_ + a ULID. Globally unique, sortable by creation time. This is your de-duplication key.
typeThe event type, from the catalog. Treat it as an open set.
api_versionThe payload version pinned to your endpoint when it was created. See Versioning.
created_atWhen the event was recorded — not when it was delivered. A retry two days later still carries the original.
company_id, store_idWhich tenant this happened in. Load-bearing if one endpoint receives several stores.
data.objectThe full object, from the same serializer the REST API uses.
data.previousOnly on *.updated events, and only the fields that changed. Today that is status.

What we guarantee, and what we do not

At-least-once deliveryYou 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 guaranteeDeliveries run in parallel and retries reshuffle everything. order.updated can arrive before order.created.
A snapshot, not a pointerdata.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 redeliveryA 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
  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.

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