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

**Status:** current · **Verified:** 2026-09-24, against the server code and a
signed delivery to a real receiver (§8) · **Owner:** Vizen API documentation · **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, refunded 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 four `order.*` events also carry the **full order snapshot**
(§6.1) — enough to create or update the order in your CRM without a read-back;
other events carry a reference and the object is read through the normal
endpoints. If missing one notification would hurt you, keep a periodic
reconciliation by `updated_from` (§4.3).

## 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`, `design.changed` and `task.*` — can be subscribed to
only by the shop **owner or administrator**; an editor's attempt is refused.
The same gate applies to editing, deletion and test/ping of an existing sensitive subscription.
Ordinary members do not see these subscriptions in `GET /webhooks`: neither the
receiver URL (which may contain credentials) nor its HMAC secret is disclosed.
Owner/admin sessions retain the complete configuration. An empty edit and an
ordinary-member deletion recheck event sensitivity atomically against the current
row, so a concurrent owner change cannot expose or delete a protected subscription.

## 3. Objects and where they live

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

| Call | What it does |
|---|---|
| `GET /webhooks` | list visible subscriptions of the company, newest first; sensitive subscriptions are owner/admin only |
| `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 **returned to the permitted session** (also present in its subscription list), 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, "order_no": "2609-17", "status": 1, "status_code": "new",
    "total": 45892, "currency": "RUB", "payment_status": "paid",
    "contact": { "name": "…", "phone": "…", "email": "…" },
    "items": [ { "product_id": 5, "name": "…", "quantity": 1, "unit_price": 45892, "line_total": 45892, "key": "", "data": null } ],
    "…": "the rest of the order snapshot, §6.1",
    "payment_id": 77, "amount": 4589200, "provider": "robokassa", "kassa_id": 3, "payment_contour": "live"
  }
}
```

- `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). For `order.*` it is the full order snapshot
  (§6.1); for every other event it is a **reference**: 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.0 Forms, quizzes and the CRM board

The UI can be a platform form, its popup, or your own styled form/quiz. They
submit to the same platform form schema and lead intake; `lead.created` is the
outbound notification, not a browser submission endpoint. Setup and request
examples: `/docs/forms`, especially its test modes.

Expose the whole chain when handing over an integration: form id → `sandbox_id`
→ intake column → lead → webhook subscription → receiver. `GET /sandboxes`
lists CRM boards; creating a webhook does not create a board. A separate board
is created explicitly and assigned to the form. CRM sandbox, dev draft and HTML
iframe sandbox are three different things; a CRM board is not an isolated test
environment. Its mutations are live operations, unavailable to dev PATs.

There are two different tests. Authenticated owner **JWT dev-preview** submissions
are test leads with deliveries suppressed. `/forms/{id}/test-lead` intentionally
sends test notifications, and `/webhooks/{id}/test` actually calls your receiver.
A dev PAT attached to public `POST /form-leads` is downgraded to anonymous and
does not select dev mode. Do not use that request as a delivery-free test.
PATs cannot configure webhook subscriptions; use the owner's session (§2).

### 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. For `order.*` the body **is** the order: number, stage, contact, address,
   items, methods, sums and payment state (§6.1). Upsert it by `order_id`;
   `GET /orders/{id}` is needed only for what the event does not carry — the
   buyer–seller chat and the order journal. For other events, when the work
   needs the object (a product, a lead), `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 — keeping a CRM in sync with orders

The recipe the shop owner sets up for an external CRM:

1. **Subscribe** (cabinet, owner or administrator) to all four order events:
   `order.created`, `order.status_changed`, `order.paid`, `order.refunded`.
   Each body is the full snapshot (§6.1) — upsert by `order_id`, keep the
   newest by `updated_at` (events are not ordered, §5).
2. **Poll once an hour** with a personal token (`orders:read`, prod contour)
   for everything that changed since your last successful poll:

   ```bash
   curl -X POST "$API/orders" -H "Authorization: Bearer $TOKEN" \
     -d '{"filter":{"company_id":'$SHOP',"updated_from":"2026-09-24T09:00:00Z"},"page":{"number":1,"limit":100}}'
   # or the same window as CSV
   curl "$API/orders/export?updated_from=2026-09-24T09:00:00Z" -H "Authorization: Bearer $TOKEN"
   ```

   `updated_from` is RFC 3339, inclusive, and compares against
   `COALESCE(updated_at, created_at)`: a new order, a stage change, a
   confirmed payment and a refund all move it. Store the start time of each
   poll (minus a minute of overlap) as the next `updated_from`.
3. A difference between the poll and your webhook feed 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 internal price decision of an order line | Not in any event: the line carries `unit_price`, `base_unit_price`, `promotion` and (for a combo) `combo_items`; the rule identity behind a price never leaves the platform |
| Expect the chat or the journal of an order in `order.*` | Not carried — `GET /orders/{id}` (journal) and the order messages endpoints (`/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` and `operational` (the team's own work, no buyer
data) — any member; `pii`, `commercial`, `financial` — owner or administrator only.

| Event | Sensitivity | `data` fields | Emitted when |
|---|---|---|---|
| `order.created` | pii | order snapshot (§6.1), `payment_status: ""` | checkout confirmed a cart into an order |
| `order.paid` | financial | order snapshot (§6.1) + `payment_id, amount` (minor units), `currency, provider, kassa_id, payment_contour` (`live`/`test`) | the payment provider confirmed the money — the only reliable "paid" signal |
| `order.refunded` | financial | order snapshot (§6.1) with `payment_status: "refunded"` + `amount` (minor units), `currency` | the seller marked the payment of a paid order as refunded |
| `order.status_changed` | pii | order snapshot (§6.1) + `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); approving an already approved review sends nothing |
| `review.submitted` | operational | `review_id, company_id, rating, product_id?` (no text, no author) | a buyer review arrived for moderation, or a rejected one was fixed and sent again (`/docs/reviews`) |
| `promotion.changed` | commercial | `promotion_id, company_id, kind, active, name` (on publish: `promotion_id, company_id` only) | a promotion rule was created, edited or deleted on the live shop, or a draft containing it was published — prices may have changed; no per-product events follow. Writes into the draft emit nothing; a publication rollback emits no event either |
| `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 |
| `task.created` | operational | task card: `id, sandbox_id, column_id, title, position, assignee_user_id` (0 — none), `assignee_name, due_at` (`""` — none), `extra` (a JSON object, not base64), `created_at, updated_at`; ids are numbers; no `description` | a task was created (`/docs/tasks`) |
| `task.updated` | operational | task card | task fields were edited (`PATCH /tasks/{id}`) |
| `task.moved` | operational | task card + `from_column_id` | a task changed column or position (`PUT /tasks/{id}/position`); cards moved by deleting their column send nothing |
| `task.deleted` | operational | task card as it was before deletion | a task was deleted |
| `webhook.test` | — | `webhook_id` | you pressed "test"; synchronous, not in the whitelist |

`amount` in `order.paid` and `order.refunded` is in **minor units** (kopecks
for RUB) — it is the payment, not the order; everywhere else money is whole
units of the shop currency, as in the catalogue. `payment` (§6.1) is the
snapshot of the **payment method** the buyer chose at checkout; `payment_id` /
`amount` describe the **payment** itself.

### 6.1 Order snapshot (`order.*` bodies)

One builder (`domain.OrderWebhookData`) for all four events, read **after** the
change. Money in whole units of `currency` unless stated.

| Key | Type | Meaning |
|---|---|---|
| `order_id`, `company_id` | int | order and shop ids |
| `order_no` | string | human order number; empty on orders made before numbering |
| `status`, `status_code` | int, string | stage: `1 new`, `2 processing`, `3 done`, `4 cancelled` |
| `buyer_id` | int | buyer user id; `0` — guest order |
| `created_at`, `updated_at` | RFC 3339 UTC | `updated_at` absent until the first change |
| `items_total` | int | goods after discounts |
| `discount_total` | int | discounts applied to the goods |
| `delivery_cost`, `payment_cost`, `total` | int | delivery, payment-method surcharge, payable total |
| `currency` | string | order currency |
| `payment_status` | string | `""` — no payment yet; `pending`, `paid`, `refunded`, `expired`, … as `Order.payment_status` in `/docs/orders` |
| `contact` | object | `{name, phone, email}` |
| `comment` | string | buyer's comment |
| `address` | object | address node as the storefront sent it; absent when empty |
| `utm` | object | campaign labels of the order; absent when empty |
| `delivery`, `payment` | object | chosen method snapshot `{id, code, name, type, cost}`; absent when no method |
| `fields` | object | checkout fields `{key: value}`; absent when empty |
| `items[]` | array | `product_id, name, key, quantity, unit_price, line_total, data` (configuration node or `null`); `base_unit_price` only when a discount changed the price; `promotion` `{id, name, amount}` only when one applied; `combo_items` only on a combo line |
| `old_status`, `new_status` | int | only in `order.status_changed` |

The snapshot carries personal data (contact, address, fields) — hence the
`pii`/`financial` classes and the owner-only subscription (§2).

## 7. Limits

| Limit | Value |
|---|---|
| events per subscription | 1…32 |
| URL length | ≤ 1024, http(s), public address only |
| secret | 16…64 chars; generated = 32 hex |
| delivery timeout | 10 s total (dial 5 s, TLS 5 s) |
| attempts | 8, backoff `1s 5s 30s 2m 10m 30m 30m` — also as numbers in `/docs/events.json` |
| response body read | first 4 KiB, discarded |
| worker | polls the journal every 2 s, 32 deliveries per claim |

## 8. How this was verified

- Event list, sensitivity classes and the owner-only gate: read from the
  server's event registry; a test on every build fails if an event exists on
  the server and is missing from this document, or the other way round.
- Envelope, headers, signature, timeout, no-redirect and the private-address
  filter: read from the delivery worker.
- Attempts and backoff: served as numbers in `/docs/events.json` from the same
  constants the worker uses.
- Journal semantics (recipients resolved at enqueue, body replayed
  byte-for-byte, `dead_at`): the delivery journal and its worker.
- `data` fields: every place the server emits an event (2026-09-10); the order
  snapshot has a test that it carries no `pricing_snapshot` and omits optional
  keys, and an integration test in which `order.created` → `order.paid` →
  `order.refunded` reach a real receiver, signed (2026-09-24).
- A token cannot manage subscriptions: those methods are not in the token
  scope map, so a token gets `PAT_METHOD_NOT_ALLOWED`.
