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.session.completedHuman checkout payment verified on-chain (settled via /api/paywalls/[id]/verify or the verify-payments cron)
payment.refundedMerchant initiated a refund via /api/console/payments/refund and the on-chain refund() call succeeded
subscription.renewal.createdSubscription cron generated a renewal invoice + checkout session for an active subscription

Today Coal fires three webhook events. checkout.failed and checkout.expired are NOT delivered as webhooks — those states only live in the DB and are visible via GET /api/console/transactions. Agent-pay calls through /api/p/{slug} and /api/agent/paywalls/{id}/settle do 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.

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

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

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

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.session.completed':
45 await fulfillOrder(event.data);
46 await db.processedSessions.add(event.data.id);
47 break;
48
49 case 'payment.refunded':
50 await reverseFulfillment(event.data);
51 break;
52
53 case 'subscription.renewal.created':
54 // Optional: email the customer their hosted renewal URL
55 await notifyCustomerRenewalDue(event.data);
56 break;
57
58 default:
59 console.warn('[coal/webhook] Unknown event type:', (event as any).event);
60 }
61
62 // 5. Always return 2xx within 30 seconds
63 return NextResponse.json({ received: true });
64}
65
66// ── Types ──────────────────────────────────────────────────────────────────────
67
68interface CheckoutCompletedData {
69 id: string; // checkout session id
70 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}
86
87interface RefundData {
88 id: string; // checkout session id
89 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}
100
101interface 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}
112
113type 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.

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.