Retry Logic & Idempotency
When Coal delivers a webhook and your endpoint does not respond with a 2xx status within 30 seconds, the delivery is marked as failed and Coal schedules a retry. This page explains the full retry schedule, delivery status lifecycle, and how to make your handler idempotent so that re-deliveries never cause duplicate side effects.
Retry inspection and manual re-delivery are console actions. Use a Privy Bearer token for
/api/console/webhooks/*; do not use a merchant API key here.
Retry Schedule
Coal retries failed deliveries up to 5 times using exponential backoff:
| Attempt | Delay after previous failure |
|---|---|
| 1 (initial) | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 8 hours |
After the 6th failed attempt, the delivery is marked exhausted and no further retries are scheduled.
The total window from initial delivery to final retry is approximately 10 hours 36 minutes. Build your recovery path accordingly.
Delivery Status Lifecycle
Signed with HMAC-SHA256
A delivery moves from failed back to pending while a retry is queued, then transitions to delivered or back to failed after the retry attempt resolves.
Endpoint Requirements
Your webhook endpoint must:
- Respond with a 2xx status code (200, 201, 204 all work).
- Respond within 30 seconds.
- Return immediately and delegate heavy processing to a background queue or worker.
1// Good — acknowledge first, process async2export async function POST(request: Request) {3 const rawBody = await request.text();4 // ... verify signature ...56 const event = JSON.parse(rawBody);78 // Enqueue for background processing9 await queue.add('coal-webhook', event);1011 // Return 200 immediately12 return NextResponse.json({ received: true });13}
If your handler does all processing synchronously and an external dependency (database, email service) is slow, you risk hitting the 30-second limit and triggering unnecessary retries.
Idempotency
Because the same event can be delivered multiple times, your handler must be idempotent. The same business logic must not run twice for the same session.
The right key: data.sessionId
Use data.sessionId as your idempotency key — not the top-level id (which is the event envelope ID and may differ across retries).
Database deduplication (recommended)
1// Using Prisma + PostgreSQL2export async function POST(request: Request) {3 const rawBody = await request.text();4 // ... signature verification omitted for brevity ...56 const event = JSON.parse(rawBody) as CoalWebhookEvent;78 if (event.event !== 'checkout.confirmed') {9 return NextResponse.json({ received: true });10 }1112 const { sessionId } = event.data;1314 // Atomic upsert — idempotent by design15 const result = await prisma.processedWebhook.upsert({16 where: { sessionId },17 update: {}, // already exists — do nothing18 create: {19 sessionId,20 event: event.event,21 processedAt: new Date(),22 },23 });2425 // If `processedAt` is more than a few seconds old, it was a duplicate26 const isNewRecord =27 Date.now() - result.processedAt.getTime() < 5_000;2829 if (isNewRecord) {30 await fulfillOrder(event.data);31 }3233 return NextResponse.json({ received: true });34}
Redis deduplication (lightweight)
1import { redis } from '@/lib/redis';23const key = `coal:processed:${event.data.sessionId}`;45// NX = only set if not exists; EX = expire after 48h6const acquired = await redis.set(key, '1', { NX: true, EX: 172800 });78if (!acquired) {9 // Duplicate delivery — skip processing10 return NextResponse.json({ received: true });11}1213await fulfillOrder(event.data);
Recovering Missed Events
If your endpoint was down during a retry window and all attempts were exhausted, you can retrieve failed deliveries via the Console API and replay them:
1# List failed webhook deliveries2curl https://api.usecoal.xyz/api/console/webhooks?status=failed \3 -H "Authorization: Bearer <privy_access_token>"
Response:
1{2 "data": [3 {4 "id": "evt_clx7k2p3q0000abc12345",5 "event": "checkout.confirmed",6 "status": "exhausted",7 "sessionId": "clx7k2p3q0000abc12345",8 "attempts": 6,9 "lastAttemptAt": "2026-03-22T22:36:00.000Z",10 "nextRetryAt": null11 }12 ],13 "total": 114}
You can then manually re-trigger delivery for a specific event:
1curl -X POST https://api.usecoal.xyz/api/console/webhooks/evt_clx7k2p3q0000abc12345/retry \2 -H "Authorization: Bearer <privy_access_token>"
Because your handler is idempotent, replaying already-processed events is safe — they will be silently acknowledged.
Testing Retries Locally
To simulate retry behavior during development:
- Start your local server.
- Temporarily return a
500from your handler. - Use the Console → Webhook Logs panel to observe the retry schedule.
- Fix your handler and watch the next retry succeed.
Alternatively, use the Send test event button in Console → Settings → Webhook URL to trigger a synthetic checkout.confirmed against your endpoint.
