Webhooks
Configure a callback URL in your checkout config to receive real-time HTTP POST notifications when invoice status changes.
Events
| Event | Trigger |
|---|---|
payment_received | Full BTC amount detected on-chain (invoice moves to paid) |
payment_confirmed | Payment reaches 1 block confirmation (invoice moves to confirmed) |
invoice_expired | Invoice TTL elapsed with no payment (invoice moves to expired) |
invoice_underpaid | Partial payment received but grace period elapsed (invoice moves to underpaid) |
Payload
All webhook payloads have the same shape:
json
{
"event": "payment_received",
"invoice": {
"id": "a1b2c3d4-...",
"amountUsd": 49.99,
"amountBtc": 0.0005,
"btcAddress": "bc1q...",
"status": "paid",
"txHash": "def456...",
"confirmations": 0,
"paidAt": "2025-01-15T10:35:00.000Z",
"confirmedAt": null,
"metadata": {"orderId": "order_123"}
}
}Signature verification
Every webhook request includes an X-Checkout-Signature header containing an HMAC-SHA256 hex digest of the raw JSON body, signed with your checkout config ID as the secret.
To verify:
javascript
import crypto from 'node:crypto';
function verifySignature(body, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(body)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected),
);
}
// In your webhook handler:
const body = req.rawBody; // raw JSON string
const signature = req.headers['x-checkout-signature'];
const configId = 'your-checkout-config-id'; // from GET /api/checkout/config
if (!verifySignature(body, signature, configId)) {
return res.status(401).send('Invalid signature');
}python
import hmac
import hashlib
def verify_signature(body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(), body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)TIP
Your config ID (returned by GET /api/checkout/config) is the signing secret. Keep it private.
Retry policy
If your endpoint returns a non-2xx status or the request times out (10 seconds), the webhook is retried:
| Attempt | Delay after failure |
|---|---|
| 1st retry | 1 minute |
| 2nd retry | 5 minutes |
| 3rd retry | 15 minutes |
After 3 failed attempts, the webhook is permanently marked as failed and not retried again. Each invoice event is delivered independently.
Best practices
- Respond quickly — return a 2xx status within 10 seconds. Process the event asynchronously if needed.
- Verify signatures — always validate the
X-Checkout-Signatureheader before processing. - Handle duplicates — use the
invoice.id+eventcombination as an idempotency key. Retries may deliver the same event multiple times. - Use HTTPS — your callback URL should use HTTPS in production.