Webhooks: events delivered to your endpoint, signed, at least once
/docs/webhookscurrentEN· проверено 2026-09-10
Резюме по-русски. Вебхук —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` (what an order snapshot contains), `promotions.md` (why promotion.changed carries no prices), `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:
{
"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_idis one per event, shared by every subscription that receives it: deduplicate on it.created_atis the moment the event happened, UTC RFC 3339 — order by it, not by arrival.datais 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.
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 "")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
- Read the raw body, verify §4.1, reject with 401 otherwise.
- Look up
event_idin your store; if seen, answer 200 and do nothing (the platform retries until it gets a 2xx, so a duplicate is normal). - 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. - When the work needs the object (an order, a product),
GETit by id — do not reconstruct it fromdata.
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 byevent_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.testis 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 ininternal/api/apiaccess/create_webhook.goandedit_webhook.go. The testreferences_test.goininternal/api/discoveryfails 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.jsonfrom 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. datafields: everyhooks.Emit(...)call site ininternal/api/catalog/*,internal/core/services/shop_payments.go,dev_publish.go(2026-09-10).- PAT cannot manage subscriptions:
ApiAccess/*is absent fromDefaultMethodScopes(guard.go), so the PAT branch answersPAT_METHOD_NOT_ALLOWED.
Исходник: https://api.vizen.shop/docs/webhooks