Delivery and retries
At-least-once semantics, retry schedule, and what 4xx/5xx do.
Delivery guarantees
- At least once. The same event may be delivered more than once if your
endpoint times out, returns a non-2xx, or briefly refuses connections. Use
the
event.idto deduplicate. See Idempotency. - In-order is not guaranteed. Events for the same resource may arrive
out of order during retries. Use
createdAtto sort and the resource state (e.g.order.status) to reconcile. - At-least-once, not exactly-once. Even after a 200, a duplicate may arrive if our acknowledgement was lost. Always deduplicate.
What counts as "delivered"
A delivery is considered successful when:
- The HTTP response has status
2xx(200, 201, 202, 204 all work). - The response was received within 10 seconds of the request being sent.
- The TLS handshake completed.
Anything else (timeout, connection refused, non-2xx, redirect, 1xx) is a failure and triggers the retry chain.
We follow up to two HTTP redirects (301, 302, 307, 308). After
that the delivery fails.
Retry schedule
Exponential backoff with jitter, capped at 24 hours total:
| Attempt | Time after original event |
|---|---|
| 1 | immediately |
| 2 | ~30 seconds |
| 3 | ~2 minutes |
| 4 | ~10 minutes |
| 5 | ~30 minutes |
| 6 | ~2 hours |
| 7 | ~6 hours |
| 8 | ~18 hours |
| 9 | ~24 hours (last attempt) |
After the 9th failed attempt the delivery is marked dropped and a
webhook.failed meta-event fires (if any other webhook subscribes to it).
We do not retry beyond 24 hours. If your endpoint is down for a day, those events are lost. For backfills, you can query the source resource via the admin API.
What status codes do
| Response | What we do |
|---|---|
2xx | Success. Stop retrying. |
4xx (except 408, 429) | Treat as permanent failure. Retry once after ~30s, then drop. The thinking: 4xx means the client (your endpoint) is rejecting the payload structurally, and retrying the same payload won't change that. The one retry catches the rare case where you just deployed a fix. |
408 Request Timeout | Full retry chain. |
429 Too Many Requests | Full retry chain, honouring Retry-After. |
5xx | Full retry chain. |
| No response (timeout, connection reset) | Full retry chain. |
Redirect (3xx) | Follow up to 2 hops, then fail. |
Respecting your Retry-After
If your endpoint returns 429 with a Retry-After header, we honour it for
the next attempt. If the next scheduled attempt is sooner than
Retry-After, we wait the longer of the two.
Concurrent delivery
Up to 4 deliveries per shop are in flight at any moment across all that
shop's webhooks. Spikes (e.g. an inventory sync that fires 1000
inventory.adjusted events) are queued; they don't all hit your endpoint
at once.
This is per-shop, not per-webhook URL — if you have three webhooks subscribing to the same events, each event fans out to all three but the total in-flight count for that shop stays bounded.
Latency expectations
- p50: < 2 seconds from event firing to your endpoint receiving the POST.
- p99: < 10 seconds.
- Pathological: minutes, when the dispatcher cron is catching up after
an outage on our side. The
webhook.failedand panel UI surface these.
Inspecting delivery history
Every attempt is logged. Retrieve via:
curl "https://api.sellvik.app/api/v1/admin/webhooks/wh_a1b2.../deliveries?status=failed" \
-H "Authorization: Bearer <admin-key>"Or in the panel: Settings → Webhooks → click a webhook → Deliveries tab.
Deliveries are retained for 30 days.