Retries & ordering
What happens when your endpoint is down, and why an event arrives twice.
Your endpoint will go down at some point, and Wajub will retry. That is the easy half. The harder half is that a retry means your handler runs a second time on an event it may already have processed, and if it ships an order every time it runs, it ships that order twice.
This page covers what Wajub guarantees, what it does not, and the handler shape that survives both.
What Wajub guarantees
At least once, not exactly once. Every event reaches your endpoint at least one time. Some will reach it more than once. Building for exactly-once delivery is building for something no webhook system provides.
Two rare cases bend that promise, and you should know both:
- An event can arrive late, or with only part of its data. Rarely, an event reaches you
after events that happened later, or with
dataholding only the object'sidandobject. In that case, read the object from the API. - Deliveries stop while Wajub restricts your webhooks, as part of a restriction on your account
or its closure. Events are still recorded and listed by
GET /events, but none is sent, to your endpoints or towajub listen. The events recorded during the restriction are not sent when it lifts: read them withGET /eventsand replay the ones you need withPOST /events/{id}/resend.
This is why a daily reconciliation is a required part of an integration, not a safety net you may skip. See The return page is not a confirmation.
No ordering guarantee. Events are delivered as they are produced, with retries
interleaved. A payment.succeeded can land before the payment.processing that preceded
it. Never infer a payment's state from the order events arrive in. Read the state from the event
body, or fetch the payment.
When a delivery counts as failed
A delivery succeeds when your endpoint answers with any 2xx inside 10 seconds. Anything
else is a failure.
| Your response | What happens |
|---|---|
2xx | Delivered. Nothing more. |
5xx, or a network error | Retried, following the schedule below |
Any 4xx | Retried, following the schedule below |
410 Gone | Not retried. Your endpoint is saying it will never accept this event |
| No response within 10 seconds | Retried |
A 3xx redirect | Retried. Redirects are never followed, so a redirect never delivers |
Only a 410 ends the delivery
A 400 or a 404 is retried like a 500, so an endpoint that answers 404 during a deploy
still gets the event once it is back. Return 410 only when you want Wajub to stop trying this
delivery for good, and 2xx once you have it.
The retry schedule
Wajub keeps trying for 72 hours after the first attempt. There is no fixed number of attempts. The wait starts at 30 seconds and doubles after each failure, up to 6 hours. Each wait is drawn at random between half and all of that value, so endpoints recovering from an outage are not hit by every retry at the same moment.
| After a failed attempt | Next attempt within |
|---|---|
| 1 | 15 to 30 seconds |
| 2 | 30 to 60 seconds |
| 3 | 1 to 2 minutes |
| 4 | 2 to 4 minutes |
| 5 | 4 to 8 minutes |
| 6 | 8 to 16 minutes |
| 7 to 10 | the wait keeps doubling |
| 11 and after | 3 to 6 hours |
Once the 72 hours are up, the delivery is marked failed and Wajub stops. Your endpoint is not
disabled, so the next event will still be attempted, and you can replay the failed one by hand from
Konsole or with POST /events/{id}/resend. If you delete or disable the endpoint while a delivery
is still being retried, the attempts left are abandoned.
Deduplicating: two keys, two meanings
Every delivery carries both. They are not interchangeable, and picking the wrong one produces a bug you will not see until you add a second endpoint.
id in the body, an evt_… value, identifies the event. It is the same on every retry,
and the same for every endpoint that receives this event.
X-Wajub-Delivery-Id in the headers, a whd_… value, identifies the delivery to one
endpoint. It is stable across every attempt, a manual resend included, and different for each endpoint.
Which one to store
Deduplicate on the body id when the work must happen once per business event, which is almost
always the case: shipping an order, crediting an account, sending a receipt. Use
X-Wajub-Delivery-Id when you are tracking delivery health per endpoint rather than business
effects.
The handler shape
Four steps, in this order. The order is the point.
# The same event, arriving a second time after a timeout.
# Body id and delivery id are both the ones of the first attempt.
X-Wajub-Delivery-Id: whd_7Yh2MpL4tRb3nP8sZcXv
{"id":"evt_aio5DpN577tNU2vOxdmuZGhT","event":"payment.succeeded","data":{…}}200 means received, not processed
Acknowledge on receipt and process asynchronously. If your own processing can fail, retry it from your own job queue. Relying on Wajub retries to replay your business logic leaves it waiting on a schedule that grows to hours between attempts, and stops after 72 hours.
Mark the event as handled before doing the work, not after. If you mark it after, two retries arriving close together can both pass the check and both run.
Reconciling with the request that caused the event
The body carries the request that produced the event:
request describes the event record, not your API call. request.id is null, and
request.idempotency_key is an idem_… value generated when the event was stored. Neither one
carries back the Idempotency-Key header you sent.
Match an event to an order through the resource instead: data.reference is the reference you
supplied when you created the payment, and data.metadata is yours to use. Both survive every
retry, because they are part of the resource rather than the delivery.
`livemode`, not `sandbox`
The webhook envelope uses livemode, which is the inverse of the sandbox field returned by
the REST API. A sandbox event carries "livemode": false.
api_version is the platform's current version at the moment the event was recorded, and the body
is shaped for it. Pinning your account or a request to an older version changes what your API calls
return, never what your endpoint receives.
Inspecting and replaying
Every attempt is recorded with its status code, response body and latency in Konsole. Once your endpoint is fixed you can replay a single delivery, or up to 100 failed deliveries at once, with the original payload and a freshly signed header.
Each replayed delivery counts against a monthly allowance set by your plan: 5 on Pay as you go, 25
on Growth, see Limits. POST /events/{id}/resend sends the event to every
endpoint that has not accepted it yet.
Webhook deliveries
| Method | Endpoint | Status | Duration | Attempt |
|---|---|---|---|---|
| POST | /webhooks/wajub | 500 | 10021 ms | 1 |
| POST | /webhooks/wajub | 500 | 240 ms | 2 |
| POST | /webhooks/wajub | 200 | 142 ms | 3 |