coal
coal

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:

AttemptDelay after previous failure
1 (initial)Immediate
21 minute
35 minutes
430 minutes
52 hours
68 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

Initial Delivery
Coalstatus: "pending" — about to send first request
CoalYour App
POST webhook payload

Signed with HMAC-SHA256

Success Path
Your App2xx within 30s → status: "delivered" ✓ (terminal)
Failure & Retry Path
Your Appnon-2xx or timeout → status: "failed"
CoalRetry scheduled: 1m → 5m → 30m → 2h → 8h
CoalAfter 6th failure → status: "exhausted" (no more retries)

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.
typescript
1// Good — acknowledge first, process async
2export async function POST(request: Request) {
3 const rawBody = await request.text();
4 // ... verify signature ...
5
6 const event = JSON.parse(rawBody);
7
8 // Enqueue for background processing
9 await queue.add('coal-webhook', event);
10
11 // Return 200 immediately
12 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)

typescript
1// Using Prisma + PostgreSQL
2export async function POST(request: Request) {
3 const rawBody = await request.text();
4 // ... signature verification omitted for brevity ...
5
6 const event = JSON.parse(rawBody) as CoalWebhookEvent;
7
8 if (event.event !== 'checkout.confirmed') {
9 return NextResponse.json({ received: true });
10 }
11
12 const { sessionId } = event.data;
13
14 // Atomic upsert — idempotent by design
15 const result = await prisma.processedWebhook.upsert({
16 where: { sessionId },
17 update: {}, // already exists — do nothing
18 create: {
19 sessionId,
20 event: event.event,
21 processedAt: new Date(),
22 },
23 });
24
25 // If `processedAt` is more than a few seconds old, it was a duplicate
26 const isNewRecord =
27 Date.now() - result.processedAt.getTime() < 5_000;
28
29 if (isNewRecord) {
30 await fulfillOrder(event.data);
31 }
32
33 return NextResponse.json({ received: true });
34}

Redis deduplication (lightweight)

typescript
1import { redis } from '@/lib/redis';
2
3const key = `coal:processed:${event.data.sessionId}`;
4
5// NX = only set if not exists; EX = expire after 48h
6const acquired = await redis.set(key, '1', { NX: true, EX: 172800 });
7
8if (!acquired) {
9 // Duplicate delivery — skip processing
10 return NextResponse.json({ received: true });
11}
12
13await 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:

bash
1# List failed webhook deliveries
2curl https://api.usecoal.xyz/api/console/webhooks?status=failed \
3 -H "Authorization: Bearer <privy_access_token>"

Response:

json
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": null
11 }
12 ],
13 "total": 1
14}

You can then manually re-trigger delivery for a specific event:

bash
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:

  1. Start your local server.
  2. Temporarily return a 500 from your handler.
  3. Use the Console → Webhook Logs panel to observe the retry schedule.
  4. 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.