Webhooks API
Register HTTPS endpoints to receive real-time event notifications from Coal. When a payment is confirmed, an authorization is captured, or a session expires, Coal fires a signed POST request to your webhook URL.
This is a console-managed endpoint family. The public merchant API uses x-api-key; these /api/console/* routes use Privy Bearer tokens and are dashboard-only.
The Webhook Object
1{2 "id": "clwh001",3 "merchantId": "clmerchant456",4 "url": "https://yoursite.com/api/webhooks/coal",5 "secret": "whsec_...",6 "events": ["checkout.confirmed", "checkout.failed"],7 "active": true,8 "createdAt": "2026-01-15T10:00:00.000Z"9}
| Field | Type | Description |
|---|---|---|
id | string | Unique webhook ID (CUID) |
merchantId | string | ID of the owning merchant |
url | string | Your HTTPS endpoint that receives events |
secret | string | Signing secret used to verify requests (whsec_...) |
events | string[] | List of event types subscribed to |
active | boolean | false = webhook is paused |
createdAt | ISO 8601 | Creation timestamp |
Event Types
| Event | Fired when |
|---|---|
checkout.confirmed | Payment verified on-chain |
checkout.failed | Transaction failed or was invalid |
checkout.expired | Session timed out without payment |
checkout.authorized | Auth & Capture — funds reserved |
checkout.voided | Authorization released without capture |
Subscribe to * to receive all events.
Event Payload Structure
Every webhook request is a POST with Content-Type: application/json:
1{2 "id": "evt_clxxx001",3 "event": "checkout.confirmed",4 "createdAt": "2026-03-22T12:03:41.000Z",5 "data": {6 "sessionId": "clxxx789",7 "amount": "49.99",8 "currency": "USDC",9 "status": "confirmed",10 "txHash": "0xdeadbeef...",11 "merchantId": "clmerchant456",12 "productId": "clxxx123",13 "confirmedAt": "2026-03-22T12:03:41.000Z"14 }15}
Always use event.id to deduplicate — Coal may retry failed deliveries, and your handler may receive the same event more than once.
List Webhooks
Authentication: Authorization: Bearer <Privy JWT>
1curl https://api.usecoal.xyz/api/console/webhooks \2 -H "Authorization: Bearer <Privy JWT>"
1{2 "data": {3 "webhooks": [4 {5 "id": "clwh001",6 "url": "https://yoursite.com/api/webhooks/coal",7 "events": ["checkout.confirmed"],8 "active": true,9 "createdAt": "2026-01-15T10:00:00.000Z"10 }11 ]12 }13}
Register a Webhook
Authentication: Authorization: Bearer <Privy JWT>
| Parameter | Type | Required | Description |
|---|---|---|---|
url | string | required | Your HTTPS endpoint. Must be publicly reachable and return 2xx within 10 seconds. |
events | string[] | required | Array of event types to subscribe to. Pass ["*"] to receive all events. |
1curl -X POST https://api.usecoal.xyz/api/console/webhooks \2 -H "Authorization: Bearer <Privy JWT>" \3 -H "Content-Type: application/json" \4 -d '{5 "url": "https://yoursite.com/api/webhooks/coal",6 "events": ["checkout.confirmed", "checkout.failed"]7 }'
1{2 "data": {3 "id": "clwh001",4 "url": "https://yoursite.com/api/webhooks/coal",5 "secret": "whsec_a1b2c3d4e5f6...",6 "events": ["checkout.confirmed", "checkout.failed"],7 "active": true8 }9}
Save your secret
The secret is shown only once at creation. Store it securely (e.g. in an environment variable) — you'll need it to verify incoming signatures.
Update a Webhook
All fields optional.
1curl -X PUT https://api.usecoal.xyz/api/console/webhooks/clwh001 \2 -H "Authorization: Bearer <Privy JWT>" \3 -H "Content-Type: application/json" \4 -d '{ "active": false }'
Delete a Webhook
1curl -X DELETE https://api.usecoal.xyz/api/console/webhooks/clwh001 \2 -H "Authorization: Bearer <Privy JWT>"
1{2 "data": { "success": true }3}
Verifying Signatures
Every request includes a Coal-Signature header. Always verify it before processing the event.
1Coal-Signature: t=1711108821,v1=a8f3b2c1...
Verification algorithm:
- Extract
t(timestamp) andv1(signature) from the header - Build the signed payload string:
"{t}.{raw_body}" - Compute
HMAC-SHA256(signed_payload, webhook_secret) - Compare with
v1using a timing-safe comparison
1// app/api/webhooks/coal/route.ts2import { NextResponse } from 'next/server';3import crypto from 'crypto';45export async function POST(req: Request) {6 const rawBody = await req.text();7 const sig = req.headers.get('Coal-Signature') ?? '';89 const parts = Object.fromEntries(sig.split(',').map((p) => p.split('=')));10 const { t, v1 } = parts;1112 if (!t || !v1) {13 return NextResponse.json({ error: 'Missing signature' }, { status: 400 });14 }1516 const signedPayload = `${t}.${rawBody}`;17 const expected = crypto18 .createHmac('sha256', process.env.COAL_WEBHOOK_SECRET!)19 .update(signedPayload)20 .digest('hex');2122 if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))) {23 return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });24 }2526 const event = JSON.parse(rawBody);2728 switch (event.event) {29 case 'checkout.confirmed':30 await fulfillOrder(event.data.sessionId);31 break;32 case 'checkout.failed':33 await notifyCustomer(event.data.sessionId);34 break;35 }3637 return NextResponse.json({ received: true });38}
See Signature Verification for the full guide including timestamp tolerance and replay protection.
Retry Behavior
Coal retries failed deliveries with exponential back-off:
| Attempt | Delay |
|---|---|
| 1st retry | 1 minute |
| 2nd retry | 5 minutes |
| 3rd retry | 30 minutes |
| 4th retry | 2 hours |
| 5th retry | 8 hours |
After 5 failed attempts, the event is marked failed and no further retries are made. See Retry Logic for how to replay missed events.
Error Codes
| Code | HTTP | Description |
|---|---|---|
UNAUTHORIZED | 401 | Missing or invalid Privy JWT |
NOT_FOUND | 404 | Webhook does not exist or belongs to another merchant |
VALIDATION_ERROR | 400 | Invalid URL or unknown event type |
URL_UNREACHABLE | 422 | Coal could not reach the provided URL during registration |
