Webhook delivery and retries
How Wingspan delivers webhook pushes, when it retries, what counts as success, and how to build a consumer that never misses an event.
This page explains how Wingspan delivers each webhook push, when we retry, and what your endpoint has to do so you don't lose or double-process events.
The short version: push is fast and usually arrives once, but it can arrive twice, and in a long outage it might not arrive at all. The event log keeps every event for 30 days, so treat push as the quick path and the log as the safety net.
What counts as success
We send each event as an HTTPS POST to your subscription URL. The outcome is decided by your response status code:
| Your response | Result |
|---|---|
Any 2xx | Delivered. We stop. |
429 or 503 | Failed. We retry, and we honor your Retry-After header (see below). |
Any other status, including 3xx | Failed. We retry. We never follow redirects. |
| No response headers within 10 seconds | Failed (Timeout). We retry. |
| Connection or TLS failure | Failed. We retry. |
The 10 seconds covers the whole request up to your response headers: DNS lookup, connecting, TLS, sending the body, and your server starting its response. We decide the outcome as soon as your headers arrive. We ignore your response body, so a slow or malformed body can't turn a 2xx into a failure.
Retry schedule
Each delivery has six attempts, at fixed times after the first one was due:
| Attempt | When |
|---|---|
| 1 | Immediately |
| 2 | 1 minute after the first |
| 3 | 5 minutes after the first |
| 4 | 30 minutes after the first |
| 5 | 1 hour after the first |
| 6 | 4 hours after the first |
The times are fixed, not counted from the previous failure. If an attempt runs late and a later attempt's time has already passed, we skip the passed ones and use the next one that's still ahead. After the sixth attempt fails, or once the window of about four hours has closed, the delivery is Exhausted and we don't try again. The event is still in the event log.
Asking us to slow down with Retry-After
If your endpoint is overloaded, return 429 or 503 with Retry-After set to a number of seconds. We skip every attempt scheduled before that time and use the next one after it. For example, if attempt 1 gets 503 with Retry-After: 360, attempts 2 and 3 (at 1 and 5 minutes) are skipped and attempt 4 runs at 30 minutes.
We only read Retry-After on 429 and 503, and only as a whole number of seconds. An HTTP date is ignored. A value that reaches past the last attempt ends retries for that delivery.
Duplicates, order, and gaps
Plan for three things.
- Duplicates. You can receive the same event more than once, for example if we lose track of an attempt that actually reached you. A duplicate has the same event
idand the same body bytes. ItsWingspan-Signaturediffers, because every attempt is signed with a fresh timestamp. - Order. Events arrive in roughly the order they happened, but not strictly. Don't assume
Invoice.Paidarrives beforeInvoice.DepositConfirmed. When order matters, compare theevents.*Attimestamps on the resource. - Gaps. If your endpoint is down for longer than the retry window, or Wingspan has a prolonged outage, a delivery can end with no successful push. We don't have a dead-letter queue, a failure notification, or a redelivery endpoint. You recover from the event log.
Changes that cancel pending deliveries
These actions cancel every delivery for the subscription that hasn't started yet:
- Disabling or deleting the subscription.
- Changing its URL, scope, or event patterns.
- Rotating its secret.
New settings apply only to events that happen after the change. Cancelled deliveries show status: Canceled with a cancellationReason. Re-enabling a subscription doesn't replay anything. Read what you missed from the event log.
Read delivery diagnostics
GET /v3/platform/webhooks/{webhookId}/deliveries lists deliveries for a subscription. Filter with filter[status][eq], filter[eventType][eq], and filter[createdAt][gte] or [lte]:
curl -G https://api.wingspan.app/v3/platform/webhooks/Wb3nR7tYq1LmK5xPz9cVa2/deliveries \
-H "Authorization: Bearer $WINGSPAN_TOKEN" \
--data-urlencode "filter[status][eq]=Exhausted" \
--data-urlencode "filter[createdAt][gte]=2026-09-20T00:00:00Z"status | Meaning |
|---|---|
Pending | Waiting for its next attempt. |
Delivering | An attempt is in progress. |
Delivered | Your endpoint returned 2xx. |
Exhausted | Retries ended without a 2xx. events.exhaustionReason says why. |
Canceled | A subscription change cancelled it before it was sent. events.cancellationReason says which change. |
GET /v3/platform/webhooks/{webhookId}/deliveries/{deliveryId} shows every attempt, with its time, response code, duration, and an error code such as Timeout, ConnectionFailed, TlsFailed, RedirectRejected, or ResponseRejected. It also shows skipped attempts.
attemptCount can be higher than six. If we lose track of an attempt mid-flight, we may start it again. That's one source of duplicates.
Diagnostics explain what happened on the wire. They don't include the event body, and you can't use them to resend. Diagnostics are kept for 30 days.
Endpoint requirements
- HTTPS only, with a valid certificate for the hostname.
- A public address. We reject URLs that resolve to private, reserved, loopback, or link-local addresses, both when you save the subscription and before every push.
- No username or password in the URL.
- No redirects.
Build a reliable consumer
Work through this list before you go live.
- Verify the signature first. Reject anything that fails with
401. See Verify signatures. - Respond
2xxfast. Put the event on a queue and return200right away. Anything slow in the request, such as database writes or calls back to Wingspan, risks the 10-second timeout and a retry. - Do the work from the queue. A worker reads the queue and processes each event.
- Dedupe on
id. Store the eventidof each event you've processed, and skip anyidyou've seen. Keep them for at least 30 days, since that's how long an event can come back through the log. - Use
occurrenceIdwhen you receive more than one party's copy. Payments events such asInvoice.Paidgo to both the payer's and the payee's Account as two events with differentids and the sameoccurrenceId. If one system of yours handles both Accounts (for example, a platform that owns both sides), dedupe the business action onoccurrenceId. - Reconcile from the resource API. Treat an event as "go look". Fetch the resource by
data.id(for example,GET /v3/payments/payables/{payableId}) and act on what the API returns now. This also handles events that arrive out of order. - Ignore types you don't handle. Return
2xxfor them. Wildcard subscriptions receive new event types as we publish them. - Persist the event log cursor. Save the
resumeCursorfrom every event log read. See Recover missed events. - Poll the event log as a backstop. On a schedule (for example, every few minutes) and after any outage on your side, read the log from your saved cursor. Your dedupe step from item 4 drops anything push already delivered.
- Alert on
Exhausteddeliveries. Check diagnostics forfilter[status][eq]=Exhaustedso you notice a broken endpoint before your backlog grows.
Related pages
- Recover missed events
- Verify signatures
- Rate limiting. Deliveries to your endpoint don't use your API rate limit, but your calls to fetch resources do.
Updated 10 days ago