Retries and delivery
Webhook delivery is at-least-once with fixed-schedule retries on failure. A successful delivery is any 2xx response within 10 seconds.
Retry timeline at a glance
If your endpoint isn’t reachable, we retry up to 4 more times on a fixed schedule that totals roughly 5 hours:
flowchart LR
A(["Initial<br/>delivery"]) --> B(["Attempt 2<br/>+5 min"])
B --> C(["Attempt 3<br/>+30 min"])
C --> D(["Attempt 4<br/>+2 hr"])
D --> E(["Attempt 5<br/>+5 hr"])
E --> F{{"Endpoint<br/>auto-disabled"}}
classDef stage fill:#E2E6EE,stroke:#2A3A5E,color:#0F172A;
classDef terminal fill:#F8E5E1,stroke:#C64A3B,color:#8B3023;
class A,B,C,D,E stage
class F terminal Retry schedule
| Attempt | Delay from emitted_at |
|---|---|
| 1 (initial) | Immediate |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 5 hours |
All delays are measured from the original emitted_at timestamp on the event — not from the time of the previous attempt.
What counts as failure
We retry if:
- The request times out (no response after 10 seconds).
- We receive a
408,409,425,429, or any5xxresponse. - The TLS handshake fails.
- The connection is refused or dropped mid-stream.
We do not retry if:
- We receive any other
4xxresponse. We assume your endpoint is rejecting deliberately (signature failure, schema rejection, etc.) and that retrying won’t help. - We receive a
2xxresponse. Even if your handler errored after responding, we consider the delivery successful.
Auto-disable after exhaustion
When attempt 5 fails, the endpoint is automatically disabled:
statusis set to"disabled"disabled_reasonis set to"retry_exhausted"- A
webhook_endpoint.disabledevent is emitted to all other active endpoints on your account
To re-enable the endpoint after fixing the underlying issue:
curl -X PATCH https://api.qairopay.com/v1/webhook-endpoints/whe_01HABC \ -H "Authorization: Bearer $QAIROPAY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"status": "enabled"}'You can also re-enable from the Webhooks section of the dashboard.
Manual retry
Any individual delivery attempt can be retried from the dashboard or via the API, regardless of whether the endpoint is currently enabled:
curl -X POST https://api.qairopay.com/v1/webhook-endpoints/whe_01HABC/delivery-attempts/wdla_01HXYZ/retry \ -H "Authorization: Bearer $QAIROPAY_API_KEY"This resets that attempt’s status and immediately queues a new delivery. Manual retries do not count toward the 5-attempt auto-disable threshold.
SSRF policy
To prevent server-side request forgery, QairoPay enforces the following on webhook endpoint URLs:
- URLs must use HTTPS in the live environment (
https://). - The following destination addresses are blocked:
- RFC 1918 private ranges (
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16) - IPv4/IPv6 loopback (
127.0.0.0/8,::1) - Link-local (
169.254.0.0/16, including the GCP metadata server at169.254.169.254) - Any DNS name that resolves to the above ranges
- RFC 1918 private ranges (
Endpoints that fail the SSRF check are rejected at creation time with a 422 Unprocessable Entity response.
HTTP endpoints (http://) are allowed in the sandbox environment only and are blocked in live.
Designing your endpoint for at-least-once delivery
Two requirements your handler should satisfy:
- Acknowledge fast, process async. Reply
200 OKas soon as you’ve durably enqueued the event for processing (e.g., wrote it to a queue). The 10-second budget is generous, but you should not spend it doing CPU-bound work or database writes that might block. - Deduplicate by
event.id. The same event id can arrive twice (rarely, but it happens). Persist the id with a unique constraint, or use an idempotent upsert keyed by id.
A canonical pattern:
export async function handler(req: Request) { const event = verifyAndParse(req); // throws on invalid signature
await db.webhookEvents.insertIgnoreConflict({ id: event.id, received_at: new Date(), payload: event, });
await queue.enqueue("process-event", { id: event.id });
return new Response("ok");}The downstream worker reads from the queue, fetches the event by id, processes it, and marks done. Re-deliveries hit the insertIgnoreConflict and short-circuit harmlessly.
Event ordering
Events for different resources can arrive in any order. Events for the same resource are best-effort ordered by created time, but at-least-once delivery means you may occasionally see a pass.updated arrive before the pass.created it depends on (if the latter was retried).
Resolve this by treating every event as a state update on a snapshot, using previous_attributes to detect ordering anomalies, and refetching the resource from the API when in doubt. Don’t rely on event order for correctness.
Scaling your endpoint
If you’re processing high-volume events (e.g., card.transaction.authorized at peak hours):
- Use a queue between receipt and processing — at-least-once delivery from QairoPay plus retries inside your system gives you a clean separation.
- Run the receipt handler in an autoscaling environment (serverless or a long-lived service with HPA).
- Watch your endpoint’s p99 response time in the QairoPay dashboard. If it creeps toward 10 s, you’re heading for retry storms.