Delivery, Retry & Dedup
What your endpoint must do
Your handler must:
- Acknowledge fast: respond with 2xx within ~10 seconds. Do the heavy work asynchronously after responding, or queue the event and return 202.
- Be idempotent — the same
X-Event-Idmay arrive more than once (see dedup contract). - Tolerate unknown event types and unknown fields by returning 2xx, not 4xx (versioning).
If you respond with anything other than 2xx, see the retry policy.
Retry policy
Each event carries a per-merchant attempt counter (DeliveryAttempts in the cabinet’s
Webhook Events view). Outcomes are classified as the request returns:
| Outcome | What triggers it | What happens next |
|---|---|---|
| Success | HTTP 2xx | Marked delivered. No further attempts. |
| Transient | HTTP 408, 429, any 5xx; network error / DNS failure; per-attempt timeout (30s wall-clock). | Auto-retried on a fixed backoff (see below). |
| Terminal | HTTP 4xx (except 408/429); SSRF block; webhook URL or secret missing; unexpected serialization. | Marked failed immediately. No auto-retries. |
The auto-retry backoff is fixed: 1 minute → 10 minutes → 1 hour → marked failed.
Three retries after the first attempt is the cap. Schedule is keyed off DeliveryAttempts
after the latest failure, not from created_at.
attempt #1 fails transiently → schedule attempt #2 in 1 min
attempt #2 fails transiently → schedule attempt #3 in 10 min
attempt #3 fails transiently → schedule attempt #4 in 1 hour
attempt #4 fails transiently → terminal `failed` (no further auto-retries)A 4xx (other than 408/429) short-circuits the chain immediately. We don’t re-validate your URL on a 404 because the failure is on your side; rotate your webhook URL and ask support for a replay (see Manual Replay).
Per-attempt timeout
Each delivery attempt is bounded by 30 seconds wall-clock including DNS, TLS, request, and response body read. Anything slower is treated as a transient timeout and retried. Don’t try to do work synchronously in the handler that won’t fit in that budget.
Dedup contract
We deliver at-least-once. Network glitches, your transient 5xx, and edge-case duplicate insertions (e.g. concurrent state transitions) can all cause the same logical event to arrive twice. Your receiver MUST dedup. The key depends on the event:
| Event type | Dedup key |
|---|---|
purchase.created, purchase.sent_to_steam, purchase.completed, purchase.failed, purchase.refunded | (data.purchase_id, event) — one delivery per pair. |
balance.deposited | data.provider_uuid (Heleket payment UUID, stable). |
X-Event-Id is also unique per delivery and is the simplest dedup key for receivers
that store events generically — but the contractual keys above are what we guarantee on
our side, and they are what you should use for business-level dedup (e.g. “did I already
mark this purchase complete?”).
A retried attempt keeps the same X-Event-Id and body. Only X-Timestamp and
X-Signature change between attempts.
Operational tuning
These constants are not configurable per merchant in v1 — they are listed only so you know what to expect:
| Knob | Value |
|---|---|
| Sender poll interval | 5 seconds |
| Batch size per poll | 50 events |
| Per-attempt wall-clock cap | 30 seconds |
| Stale-claim reclaim window | 120 seconds |
| Auto-retry backoffs | 1m → 10m → 1h |
| Max auto-retries | 3 (after attempt #1) |
| Stored response body length | First 500 chars |
| Worker startup delay | 10 seconds (after deploy/restart) |