coal
coal

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

json
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}
FieldTypeDescription
idstringUnique webhook ID (CUID)
merchantIdstringID of the owning merchant
urlstringYour HTTPS endpoint that receives events
secretstringSigning secret used to verify requests (whsec_...)
eventsstring[]List of event types subscribed to
activebooleanfalse = webhook is paused
createdAtISO 8601Creation timestamp

Event Types

EventFired when
checkout.confirmedPayment verified on-chain
checkout.failedTransaction failed or was invalid
checkout.expiredSession timed out without payment
checkout.authorizedAuth & Capture — funds reserved
checkout.voidedAuthorization released without capture

Subscribe to * to receive all events.


Event Payload Structure

Every webhook request is a POST with Content-Type: application/json:

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}
Note:

Always use event.id to deduplicate — Coal may retry failed deliveries, and your handler may receive the same event more than once.


List Webhooks

GET/api/console/webhooks

Authentication: Authorization: Bearer <Privy JWT>

bash
1curl https://api.usecoal.xyz/api/console/webhooks \
2 -H "Authorization: Bearer <Privy JWT>"
json
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

POST/api/console/webhooks

Authentication: Authorization: Bearer <Privy JWT>

ParameterTypeRequiredDescription
urlstringrequiredYour HTTPS endpoint. Must be publicly reachable and return 2xx within 10 seconds.
eventsstring[]requiredArray of event types to subscribe to. Pass ["*"] to receive all events.
bash
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 }'
json
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": true
8 }
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

PUT/api/console/webhooks/:id

All fields optional.

bash
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

DELETE/api/console/webhooks/:id
bash
1curl -X DELETE https://api.usecoal.xyz/api/console/webhooks/clwh001 \
2 -H "Authorization: Bearer <Privy JWT>"
json
1{
2 "data": { "success": true }
3}

Verifying Signatures

Every request includes a Coal-Signature header. Always verify it before processing the event.

text
1Coal-Signature: t=1711108821,v1=a8f3b2c1...

Verification algorithm:

  1. Extract t (timestamp) and v1 (signature) from the header
  2. Build the signed payload string: "{t}.{raw_body}"
  3. Compute HMAC-SHA256(signed_payload, webhook_secret)
  4. Compare with v1 using a timing-safe comparison
typescript
1// app/api/webhooks/coal/route.ts
2import { NextResponse } from 'next/server';
3import crypto from 'crypto';
4
5export async function POST(req: Request) {
6 const rawBody = await req.text();
7 const sig = req.headers.get('Coal-Signature') ?? '';
8
9 const parts = Object.fromEntries(sig.split(',').map((p) => p.split('=')));
10 const { t, v1 } = parts;
11
12 if (!t || !v1) {
13 return NextResponse.json({ error: 'Missing signature' }, { status: 400 });
14 }
15
16 const signedPayload = `${t}.${rawBody}`;
17 const expected = crypto
18 .createHmac('sha256', process.env.COAL_WEBHOOK_SECRET!)
19 .update(signedPayload)
20 .digest('hex');
21
22 if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))) {
23 return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
24 }
25
26 const event = JSON.parse(rawBody);
27
28 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 }
36
37 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:

AttemptDelay
1st retry1 minute
2nd retry5 minutes
3rd retry30 minutes
4th retry2 hours
5th retry8 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

CodeHTTPDescription
UNAUTHORIZED401Missing or invalid Privy JWT
NOT_FOUND404Webhook does not exist or belongs to another merchant
VALIDATION_ERROR400Invalid URL or unknown event type
URL_UNREACHABLE422Coal could not reach the provided URL during registration