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 responseResult
Any 2xxDelivered. We stop.
429 or 503Failed. We retry, and we honor your Retry-After header (see below).
Any other status, including 3xxFailed. We retry. We never follow redirects.
No response headers within 10 secondsFailed (Timeout). We retry.
Connection or TLS failureFailed. 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:

AttemptWhen
1Immediately
21 minute after the first
35 minutes after the first
430 minutes after the first
51 hour after the first
64 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 id and the same body bytes. Its Wingspan-Signature differs, 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.Paid arrives before Invoice.DepositConfirmed. When order matters, compare the events.*At timestamps 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"
statusMeaning
PendingWaiting for its next attempt.
DeliveringAn attempt is in progress.
DeliveredYour endpoint returned 2xx.
ExhaustedRetries ended without a 2xx. events.exhaustionReason says why.
CanceledA 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.

  1. Verify the signature first. Reject anything that fails with 401. See Verify signatures.
  2. Respond 2xx fast. Put the event on a queue and return 200 right away. Anything slow in the request, such as database writes or calls back to Wingspan, risks the 10-second timeout and a retry.
  3. Do the work from the queue. A worker reads the queue and processes each event.
  4. Dedupe on id. Store the event id of each event you've processed, and skip any id you've seen. Keep them for at least 30 days, since that's how long an event can come back through the log.
  5. Use occurrenceId when you receive more than one party's copy. Payments events such as Invoice.Paid go to both the payer's and the payee's Account as two events with different ids and the same occurrenceId. If one system of yours handles both Accounts (for example, a platform that owns both sides), dedupe the business action on occurrenceId.
  6. 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.
  7. Ignore types you don't handle. Return 2xx for them. Wildcard subscriptions receive new event types as we publish them.
  8. Persist the event log cursor. Save the resumeCursor from every event log read. See Recover missed events.
  9. 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.
  10. Alert on Exhausted deliveries. Check diagnostics for filter[status][eq]=Exhausted so you notice a broken endpoint before your backlog grows.

Related pages


Did this page help you?