coal
coal

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.

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

  1. Open the Developer Console.
  2. Navigate to Settings → Webhook URL.
  3. 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

EventTrigger
checkout.confirmedPayment verified on-chain
checkout.failedPayment verification failed
checkout.expiredSession 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.

json
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.).

json
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.

json
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.

typescript
1// app/api/webhooks/coal/route.ts
2import { NextResponse } from 'next/server';
3import crypto from 'crypto';
4
5const COAL_WEBHOOK_SECRET = process.env.COAL_WEBHOOK_SECRET!;
6
7export async function POST(request: Request) {
8 const rawBody = await request.text();
9 const sigHeader = request.headers.get('x-coal-signature');
10
11 // 1. Reject requests with no signature header
12 if (!sigHeader) {
13 return NextResponse.json({ error: 'Missing signature' }, { status: 400 });
14 }
15
16 // 2. Verify HMAC-SHA256 signature
17 const expectedSig = crypto
18 .createHmac('sha256', COAL_WEBHOOK_SECRET)
19 .update(rawBody)
20 .digest('hex');
21
22 const providedSig = sigHeader.replace(/^sha256=/, '');
23
24 const signaturesMatch = crypto.timingSafeEqual(
25 Buffer.from(expectedSig, 'hex'),
26 Buffer.from(providedSig, 'hex')
27 );
28
29 if (!signaturesMatch) {
30 console.error('[coal/webhook] Invalid signature — possible tampering');
31 return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
32 }
33
34 const event = JSON.parse(rawBody) as CoalWebhookEvent;
35
36 // 3. Idempotency guard — check sessionId, not event id
37 const alreadyProcessed = await db.processedSessions.has(event.data.sessionId);
38 if (alreadyProcessed) {
39 return NextResponse.json({ received: true }); // silently ack duplicate
40 }
41
42 // 4. Dispatch
43 switch (event.event) {
44 case 'checkout.confirmed':
45 await fulfillOrder(event.data);
46 await db.processedSessions.add(event.data.sessionId);
47 break;
48
49 case 'checkout.failed':
50 await notifyPaymentFailed(event.data);
51 break;
52
53 case 'checkout.expired':
54 await releaseReservedInventory(event.data);
55 break;
56
57 default:
58 console.warn('[coal/webhook] Unknown event type:', (event as any).event);
59 }
60
61 // 5. Always return 2xx within 30 seconds
62 return NextResponse.json({ received: true });
63}
64
65// ── Types ──────────────────────────────────────────────────────────────────────
66
67interface CoalEventData {
68 sessionId: string;
69 merchantId: string;
70 amount: string;
71 currency: string; // configured settlement token
72 status: 'confirmed' | 'failed' | 'expired';
73 txHash: string | null;
74 description: string;
75 redirectUrl: string;
76 metadata: Record<string, unknown>;
77}
78
79interface 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.

typescript
1// Example with a simple Redis set
2const key = `coal:processed:${event.data.sessionId}`;
3const wasSet = await redis.set(key, '1', { NX: true, EX: 86400 }); // 24h TTL
4if (!wasSet) {
5 return NextResponse.json({ received: true }); // duplicate, skip
6}

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:

bash
1ngrok http 3000
2# Forwarding: https://abc123.ngrok.io -> http://localhost:3000

Set your callbackUrl to the HTTPS forwarding URL while testing.


Response Requirements

RequirementDetail
Status codeMust be 2xx (200–299)
TimeoutMust respond within 30 seconds
BodyAny 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.