Webhooks
Webhooks are HTTP callbacks that Coal sends to your server when something happens in your account — a payment is confirmed, a session expires, or a transaction fails. Instead of your application continuously polling the Coal API to check for status changes, Coal pushes the data to you the moment it occurs.
Webhooks are the recommended way to trigger fulfillment logic — provisioning access, sending receipts, updating a database — because polling introduces latency and wastes API quota.
Configuring Webhooks
There are two ways to receive webhooks:
Option 1 — Per-session callback URL
Pass a callbackUrl when creating a checkout session. Coal will deliver events for that session exclusively to this URL.
1curl -X POST https://api.usecoal.xyz/api/checkouts \2 -H "Content-Type: application/json" \3 -H "x-api-key: coal_live_..." \4 -d '{5 "amount": 49.99,6 "description": "Pro Plan",7 "redirectUrl": "https://yoursite.com/success",8 "callbackUrl": "https://yoursite.com/api/webhooks/coal"9 }'
Option 2 — Default webhook URL (Console)
Set a global webhook URL that applies to all sessions in your account:
- Open the Developer Console.
- Navigate to Settings → Webhook URL.
- Paste your endpoint URL and click Save.
If both a callbackUrl and a default URL are configured, Coal delivers the event to the per-session URL only.
Events
| Event | Trigger |
|---|---|
checkout.confirmed | Payment verified on-chain |
checkout.failed | Payment verification failed |
checkout.expired | Session expired before payment was received |
Payload Structure
Every webhook is a POST request with a JSON body. The top-level shape is the same for all events; only the event field and parts of data vary.
checkout.confirmed
Fired when the on-chain transaction has been verified by Coal's indexer and the session is marked confirmed.
1{2 "event": "checkout.confirmed",3 "id": "evt_clx7k2p3q0000abc12345",4 "timestamp": "2026-03-22T12:00:00.000Z",5 "data": {6 "sessionId": "clx7k2p3q0000abc12345",7 "merchantId": "clx1mer0h0000xyz98765",8 "amount": "49.99",9 "currency": "USDC",10 "status": "confirmed",11 "txHash": "0xabc123def456abc123def456abc123def456abc123def456abc123def456abc123",12 "description": "Pro Plan",13 "redirectUrl": "https://yoursite.com/success",14 "metadata": {15 "userId": "usr_42",16 "plan": "pro"17 }18 }19}
checkout.failed
Fired when Coal attempted to verify the on-chain transaction but validation failed (insufficient amount, wrong address, replay, etc.).
1{2 "event": "checkout.failed",3 "id": "evt_clx7k2p3q0001abc12346",4 "timestamp": "2026-03-22T12:04:33.217Z",5 "data": {6 "sessionId": "clx7k2p3q0001abc12346",7 "merchantId": "clx1mer0h0000xyz98765",8 "amount": "49.99",9 "currency": "USDC",10 "status": "failed",11 "txHash": "0xdeadbeef...",12 "description": "Pro Plan",13 "redirectUrl": "https://yoursite.com/success",14 "metadata": {}15 }16}
checkout.expired
Fired when the session's TTL (30 minutes by default) elapsed without receiving a payment.
1{2 "event": "checkout.expired",3 "id": "evt_clx7k2p3q0002abc12347",4 "timestamp": "2026-03-22T12:30:00.000Z",5 "data": {6 "sessionId": "clx7k2p3q0002abc12347",7 "merchantId": "clx1mer0h0000xyz98765",8 "amount": "49.99",9 "currency": "USDC",10 "status": "expired",11 "txHash": null,12 "description": "Pro Plan",13 "redirectUrl": "https://yoursite.com/success",14 "metadata": {}15 }16}
Handling Webhooks — Node.js / Next.js
The following is a production-ready route handler for Next.js App Router. It verifies the signature, guards against replays using an idempotency check, and dispatches to your business logic.
1// app/api/webhooks/coal/route.ts2import { NextResponse } from 'next/server';3import crypto from 'crypto';45const COAL_WEBHOOK_SECRET = process.env.COAL_WEBHOOK_SECRET!;67export async function POST(request: Request) {8 const rawBody = await request.text();9 const sigHeader = request.headers.get('x-coal-signature');1011 // 1. Reject requests with no signature header12 if (!sigHeader) {13 return NextResponse.json({ error: 'Missing signature' }, { status: 400 });14 }1516 // 2. Verify HMAC-SHA256 signature17 const expectedSig = crypto18 .createHmac('sha256', COAL_WEBHOOK_SECRET)19 .update(rawBody)20 .digest('hex');2122 const providedSig = sigHeader.replace(/^sha256=/, '');2324 const signaturesMatch = crypto.timingSafeEqual(25 Buffer.from(expectedSig, 'hex'),26 Buffer.from(providedSig, 'hex')27 );2829 if (!signaturesMatch) {30 console.error('[coal/webhook] Invalid signature — possible tampering');31 return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });32 }3334 const event = JSON.parse(rawBody) as CoalWebhookEvent;3536 // 3. Idempotency guard — check sessionId, not event id37 const alreadyProcessed = await db.processedSessions.has(event.data.sessionId);38 if (alreadyProcessed) {39 return NextResponse.json({ received: true }); // silently ack duplicate40 }4142 // 4. Dispatch43 switch (event.event) {44 case 'checkout.confirmed':45 await fulfillOrder(event.data);46 await db.processedSessions.add(event.data.sessionId);47 break;4849 case 'checkout.failed':50 await notifyPaymentFailed(event.data);51 break;5253 case 'checkout.expired':54 await releaseReservedInventory(event.data);55 break;5657 default:58 console.warn('[coal/webhook] Unknown event type:', (event as any).event);59 }6061 // 5. Always return 2xx within 30 seconds62 return NextResponse.json({ received: true });63}6465// ── Types ──────────────────────────────────────────────────────────────────────6667interface CoalEventData {68 sessionId: string;69 merchantId: string;70 amount: string;71 currency: string; // configured settlement token72 status: 'confirmed' | 'failed' | 'expired';73 txHash: string | null;74 description: string;75 redirectUrl: string;76 metadata: Record<string, unknown>;77}7879interface CoalWebhookEvent {80 event: 'checkout.confirmed' | 'checkout.failed' | 'checkout.expired';81 id: string;82 timestamp: string;83 data: CoalEventData;84}
Idempotency
Coal may deliver the same event more than once (see Retry Logic). Your handler must be idempotent — processing the same event twice must not double-charge, double-provision, or cause any side effects.
Best practice: Use data.sessionId as your idempotency key, not id (the event envelope ID). Store processed session IDs in a database set or cache and skip re-processing.
1// Example with a simple Redis set2const key = `coal:processed:${event.data.sessionId}`;3const wasSet = await redis.set(key, '1', { NX: true, EX: 86400 }); // 24h TTL4if (!wasSet) {5 return NextResponse.json({ received: true }); // duplicate, skip6}
HTTPS Requirement
Production webhooks must be delivered to an HTTPS endpoint with a valid TLS certificate. Coal will not deliver to http:// URLs in live mode. During development, use a tunneling tool like ngrok or Cloudflare Tunnel to expose your local server:
1ngrok http 30002# Forwarding: https://abc123.ngrok.io -> http://localhost:3000
Set your callbackUrl to the HTTPS forwarding URL while testing.
Response Requirements
| Requirement | Detail |
|---|---|
| Status code | Must be 2xx (200–299) |
| Timeout | Must respond within 30 seconds |
| Body | Any body — Coal ignores the response body |
If your handler returns a non-2xx status or takes longer than 30 seconds, Coal marks the attempt as failed and schedules a retry. See Retry Logic for the full schedule.
