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.session.completed | Human checkout payment verified on-chain (settled via /api/paywalls/[id]/verify or the verify-payments cron) |
payment.refunded | Merchant initiated a refund via /api/console/payments/refund and the on-chain refund() call succeeded |
subscription.renewal.created | Subscription cron generated a renewal invoice + checkout session for an active subscription |
Today Coal fires three webhook events.
checkout.failedandcheckout.expiredare NOT delivered as webhooks — those states only live in the DB and are visible viaGET /api/console/transactions. Agent-pay calls through/api/p/{slug}and/api/agent/paywalls/{id}/settledo not fire webhooks today (the agent learns the settle result from the HTTP response inline); subscribe to webhooks if you want async notification on human checkouts only.
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.session.completed
Fired when the on-chain transaction has been verified by Coal's indexer and the session is marked confirmed. The dedupe key on Coal's side is checkout.session.completed:{sessionId} so a retry never enqueues a duplicate delivery.
1{2 "event": "checkout.session.completed",3 "data": {4 "id": "clx7k2p3q0000abc12345",5 "amount": "49.99",6 "currency": "USDC",7 "status": "confirmed",8 "txHash": "0xabc123def456abc123def456abc123def456abc123def456abc123def456abc123",9 "chain": "base",10 "chainId": 8453,11 "explorerUrl": "https://basescan.org/tx/0xabc…",12 "customerEmail": "buyer@example.com",13 "payerInfo": { "name": "Jane Doe" },14 "zeroG": {15 "storageUri": "0g://…",16 "storageRoot": "0x…",17 "storageTxHash": "0x…",18 "anchorTxHash": "0x…"19 }20 }21}
payment.refunded
Fired when a merchant successfully refunded a payment (full or partial). Only applies to payments that went through the Coinbase Commerce Payments escrow flow (sessions with a non-null authId). See Refunds for the full story on what's refundable. Dedupe key is payment.refunded:{refundId}.
1{2 "event": "payment.refunded",3 "data": {4 "id": "clx7k2p3q0000abc12345",5 "refundId": "rfd_clx9z8y…",6 "amount": "10.00",7 "totalRefunded": "10.00",8 "originalAmount": "49.99",9 "currency": "USDC",10 "status": "partially_refunded",11 "reason": "Customer requested",12 "txHash": "0xrefund…",13 "explorerUrl": "https://basescan.org/tx/0xrefund…",14 "refundRecipient": "0xpayer…"15 }16}
subscription.renewal.created
Fired when the subscription cron creates the next billing invoice for an active subscription. The invoice has a hostedUrl the customer can pay; you don't need to take action server-side unless you want to email the customer outside Coal's built-in renewal email. Dedupe key is subscription.renewal.created:{invoiceId}.
1{2 "event": "subscription.renewal.created",3 "data": {4 "subscriptionId": "sub_…",5 "invoiceId": "inv_…",6 "checkoutSessionId": "ckt_…",7 "amount": "29.99",8 "currency": "USDC",9 "hostedUrl": "https://usecoal.xyz/pay/checkout/ckt_…",10 "dueAt": "2026-04-22T00:00:00.000Z",11 "periodStart": "2026-03-22T00:00:00.000Z",12 "periodEnd": "2026-04-22T00:00:00.000Z"13 }14}
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.session.completed':45 await fulfillOrder(event.data);46 await db.processedSessions.add(event.data.id);47 break;4849 case 'payment.refunded':50 await reverseFulfillment(event.data);51 break;5253 case 'subscription.renewal.created':54 // Optional: email the customer their hosted renewal URL55 await notifyCustomerRenewalDue(event.data);56 break;5758 default:59 console.warn('[coal/webhook] Unknown event type:', (event as any).event);60 }6162 // 5. Always return 2xx within 30 seconds63 return NextResponse.json({ received: true });64}6566// ── Types ──────────────────────────────────────────────────────────────────────6768interface CheckoutCompletedData {69 id: string; // checkout session id70 amount: string;71 currency: string;72 status: 'confirmed';73 txHash: string;74 chain: 'base' | 'worldchain';75 chainId: number;76 explorerUrl: string;77 customerEmail?: string;78 payerInfo?: Record<string, unknown>;79 zeroG?: {80 storageUri: string;81 storageRoot: string;82 storageTxHash: string;83 anchorTxHash: string | null;84 };85}8687interface RefundData {88 id: string; // checkout session id89 refundId: string;90 amount: string;91 totalRefunded: string;92 originalAmount: string;93 currency: string;94 status: 'refunded' | 'partially_refunded';95 reason: string | null;96 txHash: string;97 explorerUrl: string;98 refundRecipient: string;99}100101interface SubscriptionRenewalData {102 subscriptionId: string;103 invoiceId: string;104 checkoutSessionId: string;105 amount: string;106 currency: string;107 hostedUrl: string;108 dueAt: string;109 periodStart: string;110 periodEnd: string;111}112113type CoalWebhookEvent =114 | { event: 'checkout.session.completed'; data: CheckoutCompletedData }115 | { event: 'payment.refunded'; data: RefundData }116 | { event: 'subscription.renewal.created'; data: SubscriptionRenewalData };
Idempotency
Coal de-duplicates webhook events on the producer side using a logical key (<event>:<entity_id>), so the same checkout.session.completed:abc123 won't be enqueued twice even if two internal code paths both ask to fire it. That said, the retry pipeline still re-delivers events that failed (5xx, timeout, etc.) until your handler returns 2xx (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.id for checkout.session.completed (the checkout session id), data.refundId for payment.refunded, or data.invoiceId for subscription.renewal.created. Each Coal webhook delivery also carries an Idempotency-Key HTTP header — set to Coal's internal WebhookEvent row id — which is unique per delivery and works as an alternative dedup key.
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.
