# Event catalog

Every event type we send, and what each one carries.

Twenty-four event types, in eight groups. Every one of them maps to a real
write in the product — nothing here fires from a place that does not exist, and
nothing is emitted "for completeness".

Subscribe to the ones you need, or to `*` for everything including types added
later.

> **Warning — Treat the catalog as an open set**
>
> Event names are **additive forever**: a new event is a new entry, but an
> existing name is never renamed and never changes meaning. A rename would be a
> silent outage on your side that no deprecation header could reach.
>
> The corollary is that your handler must ignore types it does not recognise
> rather than throw on them — a `default:` branch that logs and returns.

## Orders

One fact, one event. An order rung up on a till, typed in at the desk, placed
on a storefront or posted through this API is the same fact — *an order was
created* — and all four emit `order.created`. Tell them apart with
`data.object.source` (`admin`, `storefront`, `pos`, `channel`, `api`) or
`data.object.channel`.

| Event                   | `data.object`  | Fires when                                                                                                                                  |
| ----------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `order.created`         | order          | An order was placed on **any** surface.                                                                                                     |
| `order.updated`         | order          | The order status changed — whether a human moved it or a courier webhook did. Carries `data.previous.status`.                               |
| `order.paid`            | order          | The order crossed into fully-paid. **Edge-triggered**: it fires on the transition, not on every subsequent payment.                         |
| `order.payment.created` | order\_payment | A payment row was written against an order. Refunds appear here too, as negative amounts — read `data.object.amount`, do not assume a sign. |
| `order.cancelled`       | order          | The order moved to cancelled and its stock was released.                                                                                    |
| `order.fulfilled`       | order          | The order reached delivered — the point where the sale is finalised and stock is committed.                                                 |
| `order.refund.created`  | order\_payment | A refund was paid out against a return.                                                                                                     |

## Returns

| Event            | `data.object` | Fires when                                                                                         |
| ---------------- | ------------- | -------------------------------------------------------------------------------------------------- |
| `return.created` | return        | A customer return was opened.                                                                      |
| `return.updated` | return        | A return changed status (approved, received, completed, rejected). Carries `data.previous.status`. |

## Shipping

| Event              | `data.object` | Fires when                                                                                                                             |
| ------------------ | ------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `shipment.created` | shipment      | A shipment was booked for an order, with or without a courier.                                                                         |
| `shipment.updated` | shipment      | A shipment changed status — including the courier-webhook-driven transitions that also move the order. Carries `data.previous.status`. |

## Inventory

| Event                   | `data.object`     | Fires when                                                                                     |
| ----------------------- | ----------------- | ---------------------------------------------------------------------------------------------- |
| `inventory.adjusted`    | stock\_movement   | A manual stock adjustment was made (correction, damage, initial stock) — always with a reason. |
| `inventory.transferred` | stock\_transfer   | Stock moved between two locations. One event for the whole transfer, never one per line.       |
| `inventory.updated`     | variant level set | The sellable level for a variant at a location changed, for any reason.                        |
| `inventory.low_stock`   | inventory\_level  | A variant **crossed** its low-stock threshold at a location.                                   |

> **Note — Two inventory events that behave unusually — on purpose**
>
> **`inventory.updated` is coalesced**: at most one event per variant per ten
> seconds, so a 500-line stock import does not become 500 events describing
> levels that were superseded before the first one arrived. The payload carries
> every location for that variant, so it is always internally consistent — read
> the level from `data.object`, never a delta.
>
> **`inventory.low_stock` is edge-triggered**: it fires on the crossing, once.
> Staying below the threshold does not re-fire it, and rising back above it
> re-arms the trigger. A variant with a threshold of `0` never fires it at all —
> that means "not configured", not "always low".

## Catalog

| Event              | `data.object` | Fires when                                                                            |
| ------------------ | ------------- | ------------------------------------------------------------------------------------- |
| `product.created`  | product       | A product and its variants were created.                                              |
| `product.updated`  | product       | A product was edited. A bulk save emits one event per product, not one for the batch. |
| `product.archived` | product       | A product was archived and is no longer sellable.                                     |

## Customers

| Event              | `data.object`   | Fires when                                                                                                              |
| ------------------ | --------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `customer.created` | customer        | A customer record was created — including the find-or-create that happens at storefront checkout and on `POST /orders`. |
| `customer.updated` | customer        | A customer record was edited.                                                                                           |
| `customer.merged`  | customer\_merge | Two customer records were merged.                                                                                       |

> **Warning — customer.merged means an id you hold may be stale**
>
> `data.object` is the **survivor**, and `data.object.merged_from_id` is now a
> pointer rather than a record. Repoint anything you hold against it.
>
> Nothing breaks if you do not: the merged-away id still resolves to the
> survivor on every lookup path. But your own reports will double-count a
> customer that Salaf now considers one person.

## POS

| Event                | `data.object` | Fires when                                                                        |
| -------------------- | ------------- | --------------------------------------------------------------------------------- |
| `pos.session.opened` | pos\_session  | A cashier opened a register with a starting float.                                |
| `pos.session.closed` | pos\_session  | A register was counted and closed. Carries the expected/counted/variance summary. |

## System

| Event        | `data.object` | Fires when                                                                                                                                                                                                                                    |
| ------------ | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `salaf.ping` | ping          | Sent by the **Send test event** button. It rides the real pipeline — same signing, same retries, same delivery log — so a ping that arrives proves a real event would. See [Testing](https://www.salafems.com/developers/webhook-testing.md). |

## Object shapes

Most `data.object` shapes are the ones the REST API already publishes, field
for field:

| Object           | Documented at                                                                                |
| ---------------- | -------------------------------------------------------------------------------------------- |
| order            | [Orders](https://www.salafems.com/developers/reference/orders.md)                            |
| order\_payment   | [Orders → List an order's payments](https://www.salafems.com/developers/reference/orders.md) |
| product          | [Products](https://www.salafems.com/developers/reference/products.md)                        |
| customer         | [Customers](https://www.salafems.com/developers/reference/customers.md)                      |
| inventory\_level | [Inventory](https://www.salafems.com/developers/reference/inventory.md)                      |
| stock\_movement  | [Inventory](https://www.salafems.com/developers/reference/inventory.md)                      |

Six shapes are webhook-only, because the v1 REST surface has no endpoint for
them yet. They follow the same conventions — `snake_case`, whitelisted fields,
money as two-decimal strings, ISO-8601 UTC timestamps — and when a REST
endpoint for them lands, it will publish exactly these:

**`return`** — `id`, `store_id`, `order_id`, `order_number`, `number`,
`status`, `reason`, `location_id`, `refund_amount`, `restock`, `note`,
`received_at`, `refunded_at`, `created_at`, `updated_at`, and `items[]` of
`{ id, order_item_id, variant_id, quantity, restock }`.

**`shipment`** — `id`, `store_id`, `order_id`, `order_number`, `location_id`,
`courier` (the slug, or `null` for a self-delivered shipment),
`tracking_number`, `external_id` (the courier's own consignment id), `status`,
`shipping_charge`, `shipped_at`, `delivered_at`, `returned_at`, `created_at`,
`updated_at`.

**The `inventory.updated` variant level set** — `variant_id`, `store_id`,
`sku`, `total_on_hand`, `total_available` (summed across locations), and
`levels[]`, one full inventory-level object per location. Note that this is a
*set*, not a single level: a transfer changes two locations at once, and a
consumer receiving them separately would briefly believe stock had vanished.
The catalog endpoint reports this event's `objectType` as `inventory_level`,
which describes the entries in `levels[]` rather than the wrapper.

**`stock_transfer`** — `reference_id` (the id the two ledger entries share),
`reference_type`, `store_id`, `from_location_id`, `to_location_id`, `note`,
`occurred_at`, and `lines[]` of `{ variant_id, quantity }`.

**`customer_merge`** — the full survivor customer object, plus
`merged_from_id`, `moved` (a count of the rows of each kind that moved across)
and `merged_at`.

**`pos_session`** — `id`, `store_id`, `register` (`{ id, name, code }`),
`number`, `status`, `opened_at`, `opening_float`, `closed_at`, `created_at`,
`updated_at`, and `cash_summary` — `null` while the session is open, and
otherwise `{ expected_cash, counted_cash, variance, variance_note,
needs_approval }`.

**`ping`** — `store_id`, `message`, `sent_at`.

> **Note — Who closed the till is deliberately absent**
>
> `pos_session` carries no staff user ids. They are internal principals with no
> meaning outside our system, and there is no users API to resolve them
> against, so publishing them would be an id you could store and never use.

## Fetching the catalog programmatically

The dashboard's event picker, the validation that runs when you save an
endpoint, and this page are all fed by one typed constant in the backend. You
can read it too, from the dashboard API:

```http
GET /api/v1/developer/webhook-endpoints/events
```

It answers with `wildcard`, the current `apiVersion`, and an `events` array of
`{ type, group, label, description, objectType, carriesPrevious }`.

> **Warning — That is the dashboard API, not the public one**
>
> This endpoint lives on `/api/v1` and authenticates with a **staff session**
> (`developer.read` permission, plus a plan carrying `api_access`) — not with a
> `salaf_sk_` API key. It is there so a tool building a subscription UI can
> read the same list the dashboard does. Everything it returns is on this page.
