# Webhooks: events delivered to your endpoint, signed, at least once

> **Резюме по-русски.** Вебхук — `POST` с подписью HMAC-SHA256 на ваш адрес при
> событии в магазине. Подписку заводит человек в кабинете (ключом нельзя);
> события с ПДн и деньгами подключает только владелец/администратор (§2).
> Конверт один для всех событий — `{event, event_id, created_at, data}` (§3);
> подпись считается от сырого тела (§4). Доставка «не менее одного раза»:
> повтор возможен — дедуплицируйте по `event_id`; порядок не гарантирован; не-2xx
> и таймаут 10 с = неудача, 8 попыток с паузами до получаса, потом запись
> «не доставлено» в журнале (§5). Одиннадцать событий и их поля — §6; список
> машиной — `GET /docs/events.json`.

**Status:** current · **Verified:** 2026-09-10, code anchors on `feature/launch-billing`
(`domain/api_access.go`, `services/webhooks/dispatcher.go`, `api/apiaccess/*.go`,
migration 0106) · **Owner:** backend · **Serves:** `GET /docs/webhooks` ·
**Machine reference:** `GET /docs/events.json`
**Neighbours:** [`orders.md`](orders.md) (what an order snapshot contains),
[`promotions.md`](promotions.md) (why `promotion.changed` carries no prices),
[`catalogue.md`](catalogue.md) (re-reading a product after `product.updated`).

## 1. What you can do here

Get told, instead of polling, when something happens in a shop: an order is
created, paid or moved between statuses, a product changes, a review is
approved, a promotion or pricing rule changes, a form lead arrives, a coupon is
issued to a buyer, the site chrome is rebound. Each subscription names an
endpoint URL, a set of events and a secret; the platform POSTs a signed JSON
envelope and retries on failure.

A webhook is an **optimisation, not the source of truth**. Every event names an
object by id; the object itself is read through the normal endpoints. If missing
one notification would hurt you, keep a periodic reconciliation by `updated_at`.

## 2. Decide first (forks)

| If you want… | Take this path | Cost |
|---|---|---|
| To react within seconds to orders, leads, payments | Subscribe (this area) and **also** reconcile periodically | An endpoint that answers 2xx in under 10 s and deduplicates by `event_id` |
| A one-off import or a report | Poll the endpoints (`/docs/catalogue`, `/docs/orders`); no subscription | Nothing to host |
| To know the price a shopper sees after `promotion.changed` | Re-read the product/cart — the event carries **no prices** | One extra `GET`; see `/docs/promotions` for why prices are computed, not stored |

Who can subscribe. Subscriptions are managed by a **signed-in person in the
cabinet** (API access → Webhooks) or via the `ApiAccess` service with a session.
A personal token cannot create, edit or delete them (`PAT_METHOD_NOT_ALLOWED`).
Events that carry personal or commercial data — everything except
`product.updated`, `review.created` and `design.changed` — can be subscribed to
only by the shop **owner or administrator**; an editor's attempt is refused.

## 3. Objects and where they live

**Subscription** (`ApiAccess`, session only):

| Call | What it does |
|---|---|
| `GET /webhooks` | list the company's subscriptions, newest first |
| `POST /webhooks` `{url, events[], secret?}` | create; `url` must be public http(s) — loopback, RFC1918, link-local and metadata addresses are refused (SSRF); 1–32 events from the whitelist, duplicates collapsed; `secret` empty → generated (32 hex) and **shown once** in the response, own secret 16–64 chars |
| `PUT /webhooks/{id}` | partial edit: `url`, `events` (non-empty = replace), `secret`, `is_active` |
| `DELETE /webhooks/{id}` | delete |
| `POST /webhooks/{id}/test` | **synchronous** ping of `webhook.test` with `data.webhook_id`; the receiver's HTTP status and error text come back in the response and are stored in `last_status`/`last_error` |

Each subscription carries `last_status` (0 = never delivered or network error),
`last_error`, `last_delivered_at` — the cabinet shows them.

**Envelope** — the same for every event; the body is JSON, `Content-Type: application/json`:

```json
{
  "event": "order.paid",
  "event_id": "6c0b3c8a-2d3e-4f0a-9c1e-1f2a3b4c5d6e",
  "created_at": "2026-09-10T12:34:56Z",
  "data": { "order_id": 1024, "payment_id": 77, "amount": 4589200, "currency": "RUB", "provider": "…" }
}
```

- `event_id` is one per **event**, shared by every subscription that receives
  it: deduplicate on it.
- `created_at` is the moment the event happened, UTC RFC 3339 — order by it, not
  by arrival.
- `data` is event-specific (§6) and is a **reference**, not a snapshot: ids plus
  the few fields needed to decide whether to read the object.

**Request headers:**

| Header | Value |
|---|---|
| `X-Vizen-Event` | event name, same as `event` in the body |
| `X-Vizen-Event-Id` | same as `event_id` |
| `X-Vizen-Delivery-Id` | id of this delivery row (event × subscription); absent on `webhook.test` |
| `X-Vizen-Attempt` | attempt number, 1-based; absent on `webhook.test` |
| `X-Vizen-Timestamp` | when this attempt was sent, RFC 3339 UTC |
| `X-Vizen-Signature` | `sha256=<hex>` — see §4 |
| `User-Agent` | `Vizen-Webhooks/1.0` |

## 4. Recipes

### 4.1 Verify the signature (mandatory)

`X-Vizen-Signature` is `sha256=` + hex(HMAC-SHA256(secret, **raw request body**)).
The signature is over the exact bytes received — do not re-serialise the JSON
before checking. Compare in constant time.

```python
import hmac, hashlib

def verify(secret: str, raw_body: bytes, header: str) -> bool:
    expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header or "")
```

```go
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(rawBody)
want := "sha256=" + hex.EncodeToString(mac.Sum(nil))
ok := hmac.Equal([]byte(want), []byte(r.Header.Get("X-Vizen-Signature")))
```

Verify: send `POST /webhooks/{id}/test` from the cabinet; the receiver must
answer 2xx and the response `status` must be 200–299. A wrong secret shows up
here as a verification failure on your side, not as an error from the platform.

### 4.2 Receive

1. Read the raw body, verify §4.1, reject with 401 otherwise.
2. Look up `event_id` in your store; if seen, answer **200** and do nothing (the
   platform retries until it gets a 2xx, so a duplicate is normal).
3. Persist `{event_id, event, created_at, data}` and answer **2xx immediately**.
   Do the real work asynchronously: the delivery timeout is 10 s including
   connection and TLS; a slow handler earns a retry, not more time.
4. When the work needs the object (an order, a product), `GET` it by id — do not
   reconstruct it from `data`.

Verify: after a real event, `GET /webhooks` shows `last_status` 2xx and a fresh
`last_delivered_at`.

### 4.3 Reconcile

Once an hour (or whatever your tolerance is), list the objects you care about
with a filter on `updated_at` and compare with what your webhook feed produced.
A gap means a delivery died (§5) or your endpoint was down longer than the
retry window.

## 5. Delivery guarantees and silently ignored

**Guarantees:**

- **At least once.** A delivery is a row in a journal (`webhook_deliveries`,
  since migration 0106): it survives a restart of the platform and is retried
  until 2xx or exhaustion. Duplicates are therefore possible — dedupe by `event_id`.
- **No ordering.** Deliveries of different events to the same endpoint are
  independent; a retried older event can arrive after a newer one.
- **Success = HTTP 2xx** within **10 s** (dial 5 s, TLS 5 s inside that).
  Anything else — 3xx included — is a failure. **Redirects are not followed:**
  a 301/302 from your endpoint is recorded as the final status of the attempt.
- **Retries:** up to **8 attempts** in total, pauses before attempts 2…8 of
  `1s · 5s · 30s · 2m · 10m · 30m · 30m` (machine-readable in
  `/docs/events.json` → `delivery.backoff_seconds`). After the 8th failure the
  row is marked **dead** (`dead_at`) and stays in the journal as the answer to
  "why did it not arrive"; it is not retried again.
- **Recipients are resolved when the event is queued**, not when it is sent:
  editing a subscription afterwards does not change who receives an event that
  already happened.
- **The body is stored and replayed byte-for-byte**, so the signature of a
  retry matches the first attempt.
- `webhook.test` is synchronous and single-attempt; it is not queued.

**Silently ignored / not what you might expect:**

| You do | What actually happens |
|---|---|
| Subscribe to an event name not in the whitelist | Refused at create (`INVALID_EVENT`) — not silent |
| Disable a subscription (`is_active: false`) while deliveries are queued | Queued rows are closed as dead with `subscription is gone or disabled`; nothing is sent later for them |
| Answer 200 slowly (over 10 s) | Counted as a failure; you receive the same delivery again with the next `X-Vizen-Attempt` |
| Expect the order's money in `order.created` | Only `items_total`, `currency`, `status` are there — read `GET /orders/{id}` for the snapshot (`/docs/orders`) |
| Expect a price in `promotion.changed` / `pricing_rule.*` | None: id, kind/action, version only — prices are computed per shopper (`/docs/promotions`) |
| Subscribe to `product.updated` and edit a product's **stock** | No event: stock movements are a ledger, not a product edit (`/docs/stock`) |
| Wait for `order.status_changed` after a payment | The seller moves statuses by hand; the reliable signal for "paid" is `order.paid` |

## 6. Events

Machine list with sensitivity: `GET /docs/events.json`. Sensitivity decides who
may subscribe (§2): `public` — any member; `pii`, `commercial`, `financial` —
owner or administrator only.

| Event | Sensitivity | `data` fields | Emitted when |
|---|---|---|---|
| `order.created` | pii | `order_id, company_id, status, items_total, currency` | checkout confirmed a cart into an order |
| `order.paid` | financial | `order_id, payment_id, amount` (minor units), `currency, provider` | the payment provider confirmed the money — the only reliable "paid" signal |
| `order.status_changed` | pii | `order_id, company_id, old_status, new_status` | the seller moved the order between statuses (any direction, including the same status) |
| `product.updated` | public | `product_id, company_id` | a product was edited, its kind or children changed, or a draft containing it was published |
| `review.created` | public | `review_id, company_id, rating, product_id?` | a review was **approved** by moderation (it became public) |
| `promotion.changed` | commercial | `promotion_id, company_id, kind, active, name` | a promotion rule was created, edited or deleted — prices may have changed; no per-product events follow |
| `pricing_rule.changed` | commercial | `company_id, action, pricing_rule_id?, version?` | a quantity-pricing rule was created, edited or deleted |
| `pricing_rule.activated` | commercial | `company_id, action` (`activated`/`deactivated`), `pricing_rule_id, version` | a pricing rule was switched on or off |
| `key.issued` | pii | `order_id, company_id, user_id, promotion_id, code, expires_at` | a personal coupon was issued to a buyer for a purchase |
| `lead.created` | pii | `lead_id, form_id, form_name, values, page_url?, utm, created_at`; `test: true` on a test submission | a real form submission arrived (spam and repeats are not emitted) |
| `design.changed` | public | `company_id, resource_type, resource_id` (0 = the default template), `slots` | the chrome bindings of a resource were changed |
| `webhook.test` | — | `webhook_id` | you pressed "test"; synchronous, not in the whitelist |

`amount` in `order.paid` is in **minor units** (kopecks for RUB); everywhere else
money is whole units of the shop currency, as in the catalogue.

## 7. Limits

| Limit | Value | Where |
|---|---|---|
| events per subscription | 1…32 | `apiaccess.proto CreateWebhookRequest` |
| URL length | ≤ 1024, http(s), public address only | same + `webhooks.CheckURL` |
| secret | 16…64 chars; generated = 32 hex | same |
| delivery timeout | 10 s total (dial 5 s, TLS 5 s) | `dispatcher.go newHTTPClient` |
| attempts | 8, backoff `1s 5s 30s 2m 10m 30m 30m` | `domain.WebhookMaxAttempts`, `domain.WebhookBackoff` |
| response body read | first 4 KiB, discarded | `dispatcher.go deliverOnce` |
| worker | polls the journal every 2 s, 32 deliveries per claim | `dispatcher.go` |

## 8. How this was verified

- Event whitelist, sensitivity classes and owner-only gate:
  `internal/core/domain/api_access.go` (`validWebhookEvents`,
  `webhookEventSensitivity`, `WebhookEventRequiresOwner`), gate applied in
  `internal/api/apiaccess/create_webhook.go` and `edit_webhook.go`. The test
  `references_test.go` in `internal/api/discovery` fails if an event exists in
  the code and is missing from this document, or the other way round.
- Envelope, headers, signature, timeout, no-redirect, SSRF filter:
  `internal/core/services/webhooks/dispatcher.go` (`envelope`, `deliverOnce`,
  `newHTTPClient`, `Sign`).
- Attempts and backoff: `domain.WebhookMaxAttempts = 8`, `domain.WebhookBackoff`;
  served as numbers in `/docs/events.json` from the same functions.
- Journal semantics (recipients resolved at enqueue, body replayed
  byte-for-byte, `dead_at`): `sql/migrations/0106_webhook_deliveries.sql`,
  `dispatcher.go deliverOne`.
- `data` fields: every `hooks.Emit(...)` call site in `internal/api/catalog/*`,
  `internal/core/services/shop_payments.go`, `dev_publish.go` (2026-09-10).
- PAT cannot manage subscriptions: `ApiAccess/*` is absent from
  `DefaultMethodScopes` (`guard.go`), so the PAT branch answers
  `PAT_METHOD_NOT_ALLOWED`.
