Webhook delivery
How Hatched enqueues, signs, retries, and dedupes webhook deliveries — what the platform promises and what it expects from your handler.
This page is the contract between Hatched and your backend. The Verify webhooks guide covers the per-framework handler code; this one explains the system delivering them.
At-least-once delivery
Hatched stores every webhook payload in a BullMQ queue the moment the
originating event commits. Delivery is at-least-once — never zero, but
the same X-Hatched-Delivery id can arrive more than once if your endpoint
returns a non-2xx before our retry window expires.
The implication: your handler must be idempotent. Hatched does not attempt server-side delivery deduplication on your behalf because the correct dedupe boundary is your business logic, not the HTTP layer.
The Idempotency section of the verify guide has the canonical Redis-SETNX pattern.
Retry curve
When your endpoint returns a 4xx/5xx or times out (default 10s), Hatched re-enqueues the delivery with exponential backoff:
| Attempt | Delay since previous |
|---|---|
| 1 (initial) | — |
| 2 | +5 seconds |
| 3 | +30 seconds |
| 4 (final) | +5 minutes |
After the fourth attempt the delivery is marked failed in the delivery
log. Hatched does not retry automatically beyond that — the operator
can replay manually from the dashboard once the endpoint is healthy.
A 2xx response any time during the window stops retries. A 4xx terminates
faster than a 5xx because Hatched assumes the payload is structurally
unacceptable (most often: signature reject). Both still mark the delivery
failed after the final attempt.
Delivery id uniqueness
Webhook metadata lives in HTTP headers, not in the JSON body:
X-Hatched-Event— the event name (badge.awarded,buddy.hatched).X-Hatched-Delivery— the outbound delivery id. This is the dedupe key.
The body is the raw per-event payload and does not contain a universal
deliveryId, eventId, type or data envelope. Some event payloads carry
domain ids such as event_id, ledger_id, purchase_id or buddy_id; use
those only when you intentionally want once-per-business-object semantics.
Producer idempotency
Hatched itself dedupes on the producer side using an internal idempotency key derived from the originating action. Re-running the same business action — for example a retried hatch on a stuck operation — will not emit duplicate webhooks for the parts that already succeeded. This is separate from your consumer-side dedupe and you don't need to do anything to benefit from it.
Ordering
Hatched does not guarantee global ordering. Two events for the same buddy tend to arrive in send order because the queue is FIFO per partition, but cross-buddy or cross-event ordering is not reliable.
If ordering matters for your business logic, carry or compare domain-specific timestamps/sequence ids in the payload rather than relying on arrival order.
Replay window
Each delivery carries an X-Hatched-Timestamp header (unix seconds) that
Hatched signs alongside the body — the X-Hatched-Signature HMAC is computed
over `${timestamp}.${rawBody}`. SDK adapters reject anything older than
300 seconds by default — same convention as Stripe / Slack / GitHub.
Consequences:
- Every (re)delivery is re-signed with a fresh
X-Hatched-Timestampat send time, so there is no server-side age check — a retry minutes later still carries a current timestamp, and dashboard replays carry a fresh, valid one too. The 5-minute window is enforced only on your side by the verifier (SDK adapters default to a 300s tolerance). - You must validate the timestamp on your side. The SDK adapter does
this automatically; manual implementations need to compare against
Date.now() / 1000.
A persistent ~30s skew between Hatched and your handler signals NTP drift on your host — fix the clock rather than widening the tolerance.
Delivery log
Every delivery — successful or failed — is recorded in
webhook_delivery_logs with:
- The masked request URL
- The signed raw payload
- Response status + body excerpt
- Attempt number
- Duration
Dashboard → Developers → Webhook deliveries surfaces this log per endpoint.
The SDK exposes it via client.webhooks.deliveries({ endpointId }).
Dead-letter handling
Failed deliveries stay in the log indefinitely (retention follows the customer's data retention setting, default 90 days). The operator can:
- Inspect the response body to debug the handler.
- Click Replay in the dashboard, or call
client.webhooks.replay(endpointId, deliveryId), after fixing the endpoint. - Bulk-replay a date range when migrating to a new endpoint.
There is no separate DLQ — the delivery log is the DLQ.
Cause webhooks
The cause.threshold_reached event uses a parallel delivery system that
ships per-cause webhook URLs configured in the dashboard, rather than the
customer-wide endpoints. Same signing envelope and same replay window, but a
different retry curve — 3 attempts (initial + 2 retries) with +1s/+4s backoff,
dispatched inline rather than via the BullMQ queue. The
webhook payloads reference
covers the wire format.
What this means for your handler
In summary, your endpoint needs three guarantees and one habit:
- Idempotent —
X-Hatched-Deliverydedupe before any side effect. - Signature-verifying — never trust the body before checking the HMAC.
- Fast — acknowledge with
2xxwithin 10s; queue slow work. - Observable — log the delivery id and response status so you can correlate platform-side dashboard entries with your own traces.
Get those right and the platform handles the rest.