Webhook Retry Handling

What GIFQ retries, the backoff schedule, when an event is abandoned, and how to deduplicate at-least-once delivery correctly.

GIFQ delivers webhooks at least once. We retry a delivery that fails in transit or comes back 5xx, on a widening schedule. We abandon a delivery your endpoint rejects with a 4xx.

This page is the contract for that behaviour. Read Overview first, since signature verification and the two body shapes apply to every retry.

What gets retried

How the delivery failed decides whether we retry, not which event it was.

Outcome of an attemptRetried?Why
Connection refused, DNS failure, TLS failureYesTransport-level, so we assume it is transient.
Connect timeout (5s) or read timeout (10s)YesAssumed transient.
5xx from your endpointYesWe read this as you being temporarily broken.
4xx from your endpointNoA 4xx will not start succeeding on replay. Abandoned on the first one.
3xx from your endpointNoWe never follow redirects. Counts the same as a 4xx.
2xx from your endpointn/aAcknowledged. The delivery is complete.
🚧

A single 4xx ends the event permanently

There is no retry after a 4xx, not one. If your handler returns 422 because it could not parse a body, or 404 because a route moved, or 401 because a secret rotated, that event is gone and no later attempt will carry it.

Return 5xx for anything you want us to bring back. Keep 4xx for bodies you are certain you will never be able to process.

Backoff schedule

Up to 8 attempts, meaning the first delivery plus 7 retries. The wait grows polynomially, and we add up to 15% random jitter so that many failing deliveries do not synchronise.

RetryNominal waitWith jitterElapsed since first attempt
13s3s~3s
218s18 to 20s~21s
383s83 to 95s~2 min
4258s4 to 5 min~6 min
5627s10 to 12 min~17 to 19 min
61298s22 to 25 min~38 to 44 min
72403s40 to 46 min~78 to 90 min

An outage shorter than about an hour usually gets absorbed without loss. An outage longer than roughly 90 minutes will drop events permanently.

When an event is abandoned

An event stops being retried when any of these is true.

  • Your endpoint returned 2xx, so it is delivered and nothing further is sent.
  • Your endpoint returned 4xx or 3xx, so we mark it permanently failed on that attempt.
  • All 8 attempts went by without a 2xx, so we mark it permanently failed.
📘

There is no self-service replay

You cannot re-send a permanently failed event, and there is no dead-letter endpoint to poll. Recovery means a support request, or a reconciliation read against the payout order and recipient endpoints.

If you need to rebuild state without us, treat the REST API as your source of truth and webhooks as a speed improvement on top of it.

Rate limiting does not consume attempts

We cap deliveries at 5 per second per account. Anything over that gets delayed and rescheduled. It is not an attempt, not a failure, and does not count against the 8.

So a burst, such as an order with 500 recipients finishing at once, spreads over seconds or minutes rather than getting dropped. It also means arrival order is not delivery order.

Late arrivals beyond the backoff window

An event can arrive much later than the 90 minute window suggests. We record deliveries durably before dispatching them, and a sweeper re-queues anything that was never actually sent, for example when a dispatch was lost to a process restart. That sweep runs every 15 minutes and respects the same 8-attempt ceiling.

Design for this. An event reporting a state change from hours ago is normal, so do not reject it on age alone.

Deduplicating retries

At-least-once delivery means your handler will see the same event twice. Deduplication is your job, and the wrong key silently does nothing.

🚧

Do not deduplicate on X-Gifq-Delivery

We regenerate X-Gifq-Delivery for every attempt, so a retry of an event you already handled arrives with a different delivery UUID. Storing it and skipping repeats never matches, and the duplicate gets processed anyway.

It is useful for pointing us at one attempt in our logs during a support conversation. It is not an idempotency key.

Build the key from the body instead. The event name, the identity of the record that changed, and updated_at stay stable across retries, because a retry replays the byte-identical body we captured when the event fired.

Body shapeSuggested idempotency key
gift_cardsevent + subject_type + recipient_uuid + updated_at
cryptoevent + data.id, the provider send-request id
# Rails. Dedupe on a body-derived key, not on the delivery header.
def idempotency_key(body)
  case body['payout_type']
  when 'gift_cards'
    [body['event'], body['subject_type'], body['recipient_uuid'], body['updated_at']].join(':')
  when 'crypto'
    [body['event'], body.dig('data', 'id')].join(':')
  end
end

Let the insert of that key enforce uniqueness, with a unique index and a rescued conflict, rather than a read-then-write check. Two retries of the same event can land on two of your workers at once.

Ordering

Retries break ordering by construction. A payout.processing we retry for 40 minutes can land well after the payout.fulfilled that followed it.

Sort on updated_at from the body and discard transitions older than the state you already hold. Never infer order from arrival time, and never assume a terminal event is the last thing you will receive.

What your endpoint should do

  1. Read the raw body and verify X-Gifq-Signature. Reject with 401 on mismatch, since a forged body is one you genuinely never want replayed.
  2. Compute the idempotency key. If you have seen it, return 2xx straight away.
  3. Store the body durably. Do not process it inline.
  4. Return 2xx inside the 10 second read timeout, ideally in well under a second.
  5. Do the real work asynchronously, off the request.
📘

Acknowledge first, work second

Slow handlers cause most avoidable retries. If your processing takes longer than 10 seconds we time out and retry, and you end up doing the same work several times over, concurrently.

Returning 2xx means you have durably accepted the event, not that you have finished acting on it.

Troubleshooting

SymptomLikely cause
Events stopped entirely for one orderAn early 4xx abandoned each event on its first attempt. Check your auth and routing, then reconcile over REST.
Same event processed repeatedlyYou are deduplicating on X-Gifq-Delivery, which changes per attempt. Switch to a body-derived key.
Duplicates processed concurrentlyA read-then-write dedupe check. Enforce uniqueness with an index instead.
Bursts arrive minutes lateThe per-account rate limit delaying the excess. Expected, not a failure.
Events arrive out of orderRetries and rate limiting. Sort on updated_at.
Signature fails only sometimesYou are re-serialising the parsed body. Verify against the raw bytes. See Overview.