Webhooks tell your storefront what happened after the request finished: an order fulfilled, a preorder released, an allocation depleted, a refund completed. This page covers building a consumer that verifies signatures correctly, tolerates duplicates and reordering, and recovers from missed deliveries.
Delivery and retry lifecycle#
If you implement only one thing from this page, implement signature verification.
Registering an endpoint#
curl -s -X POST "https://api.avrix.io/api/seller/v1/webhooks" \
-H "Authorization: Bearer avrix_sk_your_key_here" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: webhook-create-1" \
-d '{
"url": "https://store.example.com/avrix/webhooks",
"events": ["order.fulfilled", "order.failed", "allocation.depleted"]
}'
{
"data": {
"id": "wh_7d3f...",
"url": "https://store.example.com/avrix/webhooks",
"events": ["order.fulfilled", "order.failed", "allocation.depleted"],
"secret": "whsec_2fA9x...",
"status": "active",
"createdAt": "2026-08-05T09:20:11.004Z"
}
}
Requirements: the URL must be HTTPS and publicly reachable. Subscribe only to events you handle; subscribing to everything and ignoring most of it wastes your capacity and obscures real failures.
Scope: seller:webhooks:write to manage endpoints, seller:webhooks:read to list deliveries.
Delivery format#
Avrix POSTs JSON with these headers:
| Header | Purpose |
|---|---|
X-Avrix-Signature | HMAC-SHA256 in the form t=<unix-seconds>,v1=<hex> |
X-Avrix-Signature-Version | Currently 1 |
X-Avrix-Event-Version | Payload schema version, currently 1 |
X-Avrix-Event-Id | Unique delivery id — use this for deduplication |
X-Avrix-Event-Type | Event name, matching your subscription |
Body:
{
"eventId": "evt_01J8XYZ...",
"eventType": "order.fulfilled",
"eventVersion": 1,
"createdAt": "2026-08-05T09:14:22.108Z",
"data": {
"orderReference": "store-order-10432",
"skuCode": "EXAMPLE-WIN-US",
"quantity": 2,
"fulfilledAt": "2026-08-05T09:14:22.108Z"
}
}
Verifying signatures#
Verify before you parse. An unverified webhook is untrusted input from the open internet, and your handler performs money-relevant side effects.
The signature is computed as HMAC-SHA256(secret, "{timestamp}.{raw_body}"), hex-encoded.
Three rules that are easy to get wrong:
- Use the raw request body bytes. Not a re-serialised object. Most frameworks parse JSON before your handler runs — you must configure a raw-body reader. Re-serialising changes whitespace and key order, and the signature will never match.
- Compare in constant time. A naive
==leaks timing information. - Enforce the timestamp tolerance. Default 300 seconds (5 minutes). Without it, a captured payload can be replayed indefinitely.
# Verification is server-side in your webhook handler — not a curl call.
# Header: X-Avrix-Signature: t=<unix>,v1=<hex>
# Signed payload: "{t}.{rawBody}" with HMAC-SHA256 and your endpoint secret.Express (Node)#
# Webhook handlers are inbound — register the URL with POST /webhooks, then verify signatures in your app.FastAPI (Python)#
# Inbound webhook — not invoked via curl from your store.Laravel (PHP)#
# Inbound webhookSpring (Java)#
# Inbound webhookASP.NET (C#)#
# Inbound webhookExpress handler with a raw body (TypeScript):
import express from "express";
const app = express();
app.post(
"/avrix/webhooks",
express.raw({ type: "application/json" }),
async (req, res) => {
const rawBody = req.body.toString("utf8");
const result = verifyAvrixWebhook(
rawBody,
req.header("X-Avrix-Signature"),
process.env.AVRIX_WEBHOOK_SECRET!
);
if (!result.ok) return res.status(400).send(result.reason);
const event = JSON.parse(rawBody);
// Acknowledge fast; process out of band.
res.status(200).send("ok");
await enqueue(event);
}
);
Avrix also publishes a verification helper (@avrix/seller-api-helpers) that implements the
same algorithm using Web Crypto, so it runs on Node, edge runtimes, and workers.
Responding#
Return 2xx as soon as the signature verifies. Do your real work asynchronously — enqueue the
event and process it in a worker.
Anything non-2xx, or a response that exceeds the 30-second delivery timeout, is treated as a
failed delivery and triggers the retry schedule. Avrix only reads a short slice of your response
body for diagnostics (a few hundred bytes); keep acknowledgements tiny (ok / empty body).
A handler that does database writes and outbound calls inline will eventually time out under load, and Avrix will retry, and you will process the same event repeatedly.
Retries#
A delivery gets at most nine attempts, spanning roughly 45 hours end to end — an endpoint outage of a full day still receives every event without manual replay:
| Attempt | Delay after previous |
|---|---|
| 1 | immediate |
| 2 | 30 seconds |
| 3 | 2 minutes |
| 4 | 10 minutes |
| 5 | 1 hour |
| 6 | 2 hours |
| 7 | 6 hours |
| 8 | 12 hours |
| 9 | 24 hours |
The ninth attempt is terminal — if it fails the delivery is marked failed and is not retried
automatically. Use POST /webhooks/{id}/deliveries/{deliveryId}/retry or bulk replay after you
recover, and reconcile with GET /orders and GET /catalog/changes?since= for anything outside
retained delivery history.
Operational contract#
Delivery timeout#
Avrix waits up to 30 seconds for your HTTPS response. Exceeding that window counts as a failed
attempt (same as a non-2xx).
Auto-disable (circuit breaker)#
After 10 consecutive terminal failures (deliveries that exhaust the full retry schedule), the
endpoint status becomes disabled with reason consecutive_delivery_failures. A successful
delivery resets the consecutive-failure streak.
Partner notification: the breaker never trips silently. On the active → disabled
transition Avrix (1) delivers a signed system.webhook_endpoint_disabled event to your
company's endpoints, (2) posts an in-app console notification to company admins, and (3) emails
company admin addresses. Fix your receiver, then re-enable with POST /webhooks/{id}/enable
(or the Console).
The event's data.disabledAt is the moment the breaker opened. Because the same event is
buffered for the disabled endpoint itself, it is delivered to that endpoint after you
re-enable it — ignore it when disabledAt predates your last enable, or re-read the endpoint
status (GET /webhooks/{id}) before acting on it.
Events while the endpoint is disabled#
New events are buffered, not lost: deliveries for a disabled endpoint are parked and flushed
automatically when the endpoint is re-enabled (POST /webhooks/{id}/enable responds with
flushedBufferedDeliveries). Deliveries already pending when the endpoint was disabled are
buffered the same way on their next sweep.
Avrix alerts your company admins (console notification, plus email from 80%) when a disabled
endpoint's buffer crosses 50%, 80%, and 100% of its cap, and the webhook listing
(GET /api/seller/v1/webhooks) reports bufferedDeliveries / bufferCapacity per endpoint so
you can watch the depth yourself.
The buffer is capped at 10,000 events per endpoint. Beyond the cap, new events for that
endpoint are dropped — recover via polling (GET /orders?orderReference=…,
GET /catalog/changes?since=, allocations) after re-enabling. Do not treat the buffer as an
unbounded queue: re-enable promptly after fixing your receiver.
Replay window and delivery history#
- Bulk replay (
POST /webhooks/{id}/deliveries/replay) accepts asince/untilwindow of at most 7 days and up to 1,000 source deliveries per call (default limit 200). - A replayed delivery carries a NEW
X-Avrix-Event-Id. It is a new delivery of the same event, not a redelivery of the same one. If you deduplicate onX-Avrix-Event-Idalone, as recommended above, a replay will not be recognised as a duplicate and your handler will run again for events you may already have processed. That is intended: replay exists to fill a gap after downtime, and suppressing it would defeat the purpose. - To make replay idempotent on your side, deduplicate on
replay.originalEventIdin the payload (alsoreplay.sourceDeliveryIdfor the original delivery row). It is present only on replayed deliveries, soevent.replay?.originalEventId ?? event.eventIdgives you a stable key across both live and replayed traffic. - List deliveries returns the newest rows first (default 50, max 100 per page).
- Retention: delivery rows are retained for at least 7 days — the full replay window is always recoverable. Contact support@avrix.io for older windows if you need history beyond what the list/replay APIs return; do not assume an unpublished multi-month retention SLA.
Management routes#
| Method | Path | Purpose |
|---|---|---|
POST | /api/seller/v1/webhooks/{id}/enable | Re-enable after circuit-breaker or manual disable (clears failure streak) |
POST | /api/seller/v1/webhooks/{id}/deliveries/{deliveryId}/retry | Re-queue a failed delivery (fresh attempt cycle); returns 202 |
GET | /api/seller/v1/webhooks/event-types | Catalog of event names and short descriptions for subscription discovery |
GET | /api/seller/v1/webhooks/{id}/deliveries | Delivery history (attempts, status codes) |
POST | /api/seller/v1/webhooks/{id}/deliveries/replay | Bulk clone deliveries in a time window (fresh eventId; correlate via payload.replay) |
POST | /api/seller/v1/webhooks/{id}/test | Signed test.ping |
PATCH / DELETE | /api/seller/v1/webhooks/{id} | Update or remove an endpoint |
GET | /api/seller/v1/events | Account-level event feed for polling / ops correlation. Page with meta.nextCursor (keyset), not since alone: a burst of events sharing one timestamp cannot be paged by time. |
Scopes: seller:webhooks:write for enable/retry/replay/test/mutate; seller:webhooks:read for
list, deliveries, event-types, and GET /events.
Egress IP allowlisting#
GET /api/seller/v1/webhooks/egress-ips (key-free) publishes the webhook delivery egress
identity: { egressIps: [], pinned: false, verificationRequired: true }.
- When
pinnedis true, the listed addresses are stable; any change is announced at least 30 days in advance through the changelog andsystem.*notices — re-read the endpoint on a schedule rather than hardcoding. - When
pinnedis false (the current default — delivery egress is not pinned on this infrastructure), do not build source-IP allowlists. Signature verification is the authoritative control either way; strict-firewall partners who require pinned IPs or mTLS delivery should contact support@avrix.io.
Payload size and endpoints#
- Seller API request bodies (including webhook create/patch/replay) are capped at the platform
100 KB request-body limit (
413PAYLOAD_TOO_LARGE when exceeded). - Each endpoint’s
eventsarray must be non-empty, unique, and within the published event catalog (GET /webhooks/event-types). - There is no published hard cap on the number of webhook endpoints per company in the API schema — register only what you need; abuse or extreme fan-out may still be limited operationally.
Webhook management traffic on sandbox keys shares the sandbox abuse cap under the webhooks management class — see Rate limits. Production keys are not rate limited.
Duplicates and ordering#
Delivery is at-least-once. You will receive duplicates. This is normal and not an error.
Events are not globally ordered. A retry of an earlier event can arrive after a later one.
Both are handled by the same pattern: deduplicate on X-Avrix-Event-Id and make handlers
idempotent.
async function handleEvent(event: AvrixEvent) {
const seen = await db.webhookEvents.findUnique({ where: { eventId: event.eventId } });
if (seen) return;
await db.$transaction(async (tx) => {
await tx.webhookEvents.create({ data: { eventId: event.eventId, type: event.eventType } });
await applyEvent(tx, event);
});
}
Record the event id and apply the effect in the same transaction. Recording first and applying after leaves a window where a crash loses the effect permanently.
For ordering, make each handler evaluate current state rather than assume a sequence. An
allocation.depleted that arrives after an allocation.updated should cause a re-read of
allocations, not a blind decrement.
The webhook may arrive first#
order.fulfilled can reach your endpoint before POST /orders returns to your checkout process.
There is no guaranteed ordering between an HTTP response and its webhook.
Design your handler to tolerate an order it has not recorded yet: buffer the event for a short retry, or create the record from the webhook and reconcile when the HTTP response lands. Never error out on "unknown order" — that turns a benign race into a failed delivery and a retry storm.
Rotating the secret#
curl -s -X PATCH "https://api.avrix.io/api/seller/v1/webhooks/wh_7d3f..." \
-H "Authorization: Bearer avrix_sk_your_key_here" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: webhook-rotate-1" \
-d '{ "rotateSecret": true }'
The new secret is returned once. The previous secret stays valid for 48 hours, and during that
window deliveries carry two v1= values — one per secret. The verification code above already
handles this by checking every v1= value present, which is why it is written as a loop rather
than a single comparison.
Sequence: store the new secret, keep accepting both, then drop the old one after 48 hours.
Testing#
Send a test delivery:
curl -s -X POST "https://api.avrix.io/api/seller/v1/webhooks/wh_7d3f.../test" \
-H "Authorization: Bearer avrix_sk_your_key_here"
Your endpoint receives a signed test.ping. Verify that:
- the signature validates;
- a deliberately corrupted signature is rejected — an endpoint that accepts everything is worse than no verification, and this is the test most integrations skip;
- an old timestamp is rejected;
- a duplicate
eventIdis ignored; - your handler responds within your timeout budget.
Inspect delivery history with GET /api/seller/v1/webhooks/{id}/deliveries to see attempts, status
codes, and failures.
Recovering missed events#
Webhooks are a notification channel, not a system of record. If the endpoint was disabled, new events were buffered (up to 10,000 per endpoint) and flush automatically when you re-enable it; events beyond the cap were dropped and must be recovered by polling. If it was up but failing, failed delivery rows may still be retryable or replayable within the operational window above.
| Recovery | Use |
|---|---|
| Re-enable | POST /api/seller/v1/webhooks/{id}/enable before retry/replay |
| Retry one delivery | POST /api/seller/v1/webhooks/{id}/deliveries/{deliveryId}/retry |
| Replay deliveries | POST /api/seller/v1/webhooks/{id}/deliveries/replay or the Console. Replays carry a fresh eventId; correlate via payload.replay.originalEventId. |
| Poll orders | GET /api/seller/v1/orders?from=…&to=…&status=…&cursor=… — page the listing and reconcile (authoritative for order state); ?orderReference=… for a single order |
| Poll catalog | GET /api/seller/v1/catalog/changes?since=… |
| Poll allocations | GET /api/seller/v1/allocations |
Run a reconciliation job regardless of webhook health. Compare your order records against GET /orders on a schedule. Webhook-only architectures fail silently, and the first symptom is a customer who paid and received nothing.
Event reference#
All 32 events. Subscribe only to what you handle.
Orders#
| Event | Fires when |
|---|---|
order.fulfilled | Order completed; entitlements available |
order.failed | Order could not be fulfilled after commit |
order.activated | First successful keyless buyer activation |
order.reserved | Preorder or reservation placed; keys not yet delivered |
order.ready | Deferred preorder or backorder has inventory and can be fulfilled |
order.preorder_fulfilled | Preorder converted to fulfilled delivery (keys revealed on fulfill) |
order.returned | Buyer return recorded against a prior order |
order.keys_returned | Keys quarantined after return — never recycled into sellable inventory without operator review |
Refunds#
| Event | Fires when |
|---|---|
refund.completed | Refund finalised for a sale line |
chargeback.resolved | A chargeback dispute was resolved as won or lost via POST /chargebacks/resolve |
Keys#
| Event | Fires when |
|---|---|
keys.pulled | Keys pulled via POST /keys/pull (non-order path) |
redemption.expiring | A keyless redemption URL enters its ~24h expiry notice window — nudge the buyer |
redemption.expired | A keyless redemption URL has passed expiresAt without activation |
Allocations#
| Event | Fires when |
|---|---|
allocation.updated | Capacity or metadata changed |
allocation.depleted | Allocation reached zero remaining |
allocation.low_stock | Allocation crossed the low-stock threshold |
Catalog#
| Event | Fires when |
|---|---|
product.updated | Product metadata changed — use for incremental sync |
product.announced | Release advanced to announced |
product.delisted | Product removed from your catalog (tombstone) |
product.delisting_scheduled | Product will leave the catalog at a future effectiveAt (only when archived_at is in the future) |
release.available | Release advanced to available |
release.date_changed | Publisher moved a release date (pre-order slip signal) |
sku.pricing_updated | Territory catalog list price changed |
sku.sellability_changed | A shared SKU crossed the sellable boundary — Buy-button signal |
Contracts#
| Event | Fires when |
|---|---|
contract.activated | BMA or MCA became active |
contract.superseded | Prior active contract term superseded |
contract.amendment_accepted | Amendment accepted by the counterparty |
Finance and system#
| Event | Fires when |
|---|---|
invoice.created | Seller invoice document created |
report.ready | Report export is ready |
catalog.export_ready | An async catalog export artifact is ready — poll GET /catalog/exports/{exportId} |
test.ping | You called POST /webhooks/{id}/test |
system.webhook_endpoint_disabled | The circuit breaker auto-disabled one of your endpoints (delivered to your other endpoints) |
Per-event payload schemas live in the OpenAPI webhooks section
(GET /api/seller/v1/openapi on your API host).
Worked examples and seller-action notes for each event are in Event reference.
Checklist#
- Signature verified on every delivery, using the raw body
- Constant-time comparison
- Timestamp tolerance enforced at 300 seconds
- Multiple
v1=values handled for rotation - Deduplication on
X-Avrix-Event-Id - Event id and effect committed in one transaction
-
2xxreturned within the 30-second delivery timeout; work done asynchronously - Handler tolerates an order it has not yet recorded
- Handler tolerates out-of-order events
- Corrupted-signature rejection tested
- Reconciliation job running independently of webhooks
- Secret in a managed secret store
- Delivery-failure alerting in place (Avrix notifies on circuit open via
system.webhook_endpoint_disabled, console, and email — but watch your own receiver too) - Know how to
enable,retry, andreplayafter an outage
Related#
- Order lifecycle — which transition emits which event
- Catalog and allocations — event-driven catalog refresh
- Idempotency — write retries and
orderReferencerecovery - Rate limits — webhooks management class
- Error reference — every error code