Vizen Shop

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 pathCost
To react within seconds to orders, leads, paymentsSubscribe (this area) and also reconcile periodicallyAn endpoint that answers 2xx in under 10 s and deduplicates by event_id
A one-off import or a reportPoll the endpoints (/docs/catalogue, /docs/orders); no subscriptionNothing to host
To know the price a shopper sees after promotion.changedRe-read the product/cart — the event carries no pricesOne 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):

CallWhat it does
GET /webhookslist 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}/testsynchronous 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_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:

HeaderValue
X-Vizen-Eventevent name, same as event in the body
X-Vizen-Event-Idsame as event_id
X-Vizen-Delivery-Idid of this delivery row (event × subscription); absent on webhook.test
X-Vizen-Attemptattempt number, 1-based; absent on webhook.test
X-Vizen-Timestampwhen this attempt was sent, RFC 3339 UTC
X-Vizen-Signaturesha256=<hex> — see §4
User-AgentVizen-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

  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.jsondelivery.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 doWhat actually happens
Subscribe to an event name not in the whitelistRefused at create (INVALID_EVENT) — not silent
Disable a subscription (is_active: false) while deliveries are queuedQueued 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.createdOnly 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 stockNo event: stock movements are a ledger, not a product edit (/docs/stock)
Wait for order.status_changed after a paymentThe 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.

EventSensitivitydata fieldsEmitted when
order.createdpiiorder_id, company_id, status, items_total, currencycheckout confirmed a cart into an order
order.paidfinancialorder_id, payment_id, amount (minor units), currency, providerthe payment provider confirmed the money — the only reliable "paid" signal
order.status_changedpiiorder_id, company_id, old_status, new_statusthe seller moved the order between statuses (any direction, including the same status)
product.updatedpublicproduct_id, company_ida product was edited, its kind or children changed, or a draft containing it was published
review.createdpublicreview_id, company_id, rating, product_id?a review was approved by moderation (it became public)
promotion.changedcommercialpromotion_id, company_id, kind, active, namea promotion rule was created, edited or deleted — prices may have changed; no per-product events follow
pricing_rule.changedcommercialcompany_id, action, pricing_rule_id?, version?a quantity-pricing rule was created, edited or deleted
pricing_rule.activatedcommercialcompany_id, action (activated/deactivated), pricing_rule_id, versiona pricing rule was switched on or off
key.issuedpiiorder_id, company_id, user_id, promotion_id, code, expires_ata personal coupon was issued to a buyer for a purchase
lead.createdpiilead_id, form_id, form_name, values, page_url?, utm, created_at; test: true on a test submissiona real form submission arrived (spam and repeats are not emitted)
design.changedpubliccompany_id, resource_type, resource_id (0 = the default template), slotsthe chrome bindings of a resource were changed
webhook.testwebhook_idyou 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

LimitValueWhere
events per subscription1…32apiaccess.proto CreateWebhookRequest
URL length≤ 1024, http(s), public address onlysame + webhooks.CheckURL
secret16…64 chars; generated = 32 hexsame
delivery timeout10 s total (dial 5 s, TLS 5 s)dispatcher.go newHTTPClient
attempts8, backoff 1s 5s 30s 2m 10m 30m 30mdomain.WebhookMaxAttempts, domain.WebhookBackoff
response body readfirst 4 KiB, discardeddispatcher.go deliverOnce
workerpolls the journal every 2 s, 32 deliveries per claimdispatcher.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.

Исходник: https://api.vizen.shop/docs/webhooks

Работает в демо-режиме