Webhooks push events to your server in real time. Register an HTTPS endpoint and billing.io sends a signed POST request when something happens — a checkout confirms, a subscription renews, a payout settles.
Webhooks are available on Starter plans and above and require a publicly accessible HTTPS endpoint.
Setting Up a Webhook Endpoint
Register your endpoint by specifying the URL and which events you want to receive.
curl -X POST https://api.billing.io/v1/webhooks \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/billing",
"events": [
"checkout.created",
"checkout.completed",
"checkout.expired",
"checkout.failed"
],
"description": "Production checkout events"
}'
Response:
{
"webhook_id": "we_f6a7b8c9d0e1f2a3",
"url": "https://example.com/webhooks/billing",
"events": [
"checkout.created",
"checkout.completed",
"checkout.expired",
"checkout.failed"
],
"secret": "whsec_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
"description": "Production checkout events",
"status": "active",
"created_at": "2025-06-15T10:00:00Z"
}
The secret field is only returned once when the webhook endpoint is
created. Store it securely in your environment variables. You will need it
to verify webhook signatures.
Supported Event Types
Checkout events
| Event | Description |
|---|
checkout.created | A new checkout was created |
checkout.payment_detected | An on-chain transaction was detected (unconfirmed) |
checkout.confirming | Block confirmations are in progress |
checkout.completed | Payment is fully confirmed (terminal) |
checkout.expired | Checkout expired without payment (terminal) |
checkout.failed | Unrecoverable error (terminal) |
Subscription events
| Event | Description |
|---|
subscription.renewed | Subscription period was successfully renewed |
subscription.past_due | Payment failed after retries, subscription is past due |
subscription.paused | Subscription was paused |
subscription.canceled | Subscription was canceled |
Payout events
| Event | Description |
|---|
payout.settled | Payout was verified and settled on-chain |
payout.failed | Payout verification failed |
Every webhook delivery is an HTTP POST with a JSON body:
{
"event_id": "evt_7f8a9b0c1d2e3f4a",
"type": "checkout.completed",
"checkout_id": "co_9a8b7c6d5e4f",
"data": {
"checkout_id": "co_9a8b7c6d5e4f",
"deposit_address": "TXrkA1b2C3d4E5f6G7h8I9j0K1l2M3n4",
"chain": "tron",
"token": "USDT",
"amount_usd": 49.99,
"amount_atomic": "49990000",
"status": "confirmed",
"tx_hash": "abc123def456...",
"confirmations": 19,
"required_confirmations": 19,
"expires_at": "2025-06-15T11:00:00Z",
"detected_at": "2025-06-15T10:35:00Z",
"confirmed_at": "2025-06-15T10:42:00Z",
"created_at": "2025-06-15T10:30:00Z",
"metadata": {
"order_id": "ord_12345"
}
},
"created_at": "2025-06-15T10:42:00Z"
}
Signature Verification
Every webhook delivery includes an X-Billing-Signature header. You must verify this signature to confirm the request came from billing.io and was not tampered with.
The X-Billing-Signature header has this format:
t={unix_timestamp},v1={hex_hmac_sha256}
Example:
t=1718451600,v1=5a2f3c4d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b
Signed payload
The HMAC-SHA256 is computed over the following string:
{timestamp}.{raw_request_body}
Where:
{timestamp} is the t value from the header
{raw_request_body} is the exact raw body of the HTTP request (not parsed JSON)
Verification code
const crypto = require("crypto");
function verifyWebhookSignature(rawBody, signatureHeader, secret) {
// 1. Parse the signature header
const parts = {};
for (const part of signatureHeader.split(",")) {
const [key, value] = part.split("=", 2);
parts[key] = value;
}
const timestamp = parts["t"];
const receivedSignature = parts["v1"];
if (!timestamp || !receivedSignature) {
throw new Error("Invalid signature header format");
}
// 2. Reject old timestamps (prevent replay attacks)
const tolerance = 5 * 60; // 5 minutes
const currentTime = Math.floor(Date.now() / 1000);
if (Math.abs(currentTime - parseInt(timestamp)) > tolerance) {
throw new Error("Timestamp outside tolerance window");
}
// 3. Compute the expected signature
const signedPayload = `${timestamp}.${rawBody}`;
const expectedSignature = crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest("hex");
// 4. Compare using constant-time comparison
const isValid = crypto.timingSafeEqual(
Buffer.from(receivedSignature, "hex"),
Buffer.from(expectedSignature, "hex")
);
if (!isValid) {
throw new Error("Invalid webhook signature");
}
return true;
}
// Express middleware example
app.post("/webhooks/billing", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.headers["x-billing-signature"];
const rawBody = req.body.toString();
try {
verifyWebhookSignature(rawBody, signature, process.env.WEBHOOK_SECRET);
} catch (err) {
console.error("Webhook verification failed:", err.message);
return res.status(400).json({ error: "Invalid signature" });
}
const event = JSON.parse(rawBody);
// Process the event...
res.status(200).json({ received: true });
});
Always verify signatures. Without verification, an attacker could send
fake events to your webhook endpoint. Use constant-time comparison functions
to prevent timing attacks.
Retry Policy
If your endpoint does not respond with a 2xx status code, billing.io retries the delivery with exponential backoff:
| Attempt | Timing | Delay after previous |
|---|
| 1 | Immediate | — |
| 2 | 5 minutes later | 5 min |
| 3 | 30 minutes later | 25 min |
After 3 failed attempts, the delivery is marked as failed. You can find failed deliveries in the event log at Developers > Event Log in the dashboard, or by querying the GET /events endpoint.
Each retry uses the same event payload and event_id. Your endpoint should
handle duplicate deliveries idempotently. See the best practices below.
Best Practices
1. Respond with 2xx quickly
Return a 200 or 202 status code as fast as possible. Do your heavy processing (database writes, email sends, fulfillment) asynchronously after responding.
app.post("/webhooks/billing", express.raw({ type: "application/json" }), (req, res) => {
// Verify signature first
verifyWebhookSignature(req.body.toString(), req.headers["x-billing-signature"], secret);
// Respond immediately
res.status(200).json({ received: true });
// Process asynchronously
const event = JSON.parse(req.body.toString());
processEventAsync(event).catch(console.error);
});
2. Handle duplicates idempotently
Due to retries and at-least-once delivery, your endpoint may receive the same event more than once. Use the event_id to deduplicate:
const processedEvents = new Set(); // Use a database in production
app.post("/webhooks/billing", handler, async (req, res) => {
const event = JSON.parse(req.body.toString());
// Check if already processed
if (processedEvents.has(event.event_id)) {
return res.status(200).json({ received: true, duplicate: true });
}
// Process the event
await processEvent(event);
// Mark as processed
processedEvents.add(event.event_id);
res.status(200).json({ received: true });
});
In production, store processed event IDs in a database or Redis rather than
in-memory. Use a TTL of at least 24 hours for the deduplication cache.
3. Verify the signature before processing
Always verify the X-Billing-Signature header before trusting the payload. Reject requests with invalid or missing signatures.
4. Protect against replay attacks
Check the timestamp in the signature header. Reject events older than 5 minutes to prevent replay attacks. The verification code examples above include this check.
5. Use HTTPS endpoints only
Webhook URLs must use HTTPS. billing.io does not send events to HTTP endpoints in production (sandbox allows HTTP for local development).
6. Log failed deliveries
Monitor your webhook endpoint’s success rate. Check the Developers > Event Log page in the dashboard to see delivery attempts and failures.
Testing Webhooks in Sandbox
When using sandbox API keys (sk_test_), you can test webhook deliveries without real on-chain transactions.
1. Use a tunnel for local development
Use a tunneling service to expose your local server:
# Using ngrok
ngrok http 3000
# Register the ngrok URL as a webhook endpoint
curl -X POST https://api.billing.io/v1/webhooks \
-H "Authorization: Bearer sk_test_xxx" \
-H "Content-Type: application/json" \
-d '{
"url": "https://abc123.ngrok.io/webhooks/billing",
"events": ["checkout.completed"],
"description": "Local development"
}'
2. Simulate a payment
Use the sandbox simulation endpoint to trigger a checkout.completed event:
# First create a checkout
curl -X POST https://api.billing.io/v1/checkouts \
-H "Authorization: Bearer sk_test_xxx" \
-H "Content-Type: application/json" \
-d '{
"amount_usd": 10.00,
"chain": "tron",
"token": "USDT"
}'
# Then simulate the payment confirmation
curl -X POST https://api.billing.io/v1/sandbox/simulate-payment \
-H "Authorization: Bearer sk_test_xxx" \
-H "Content-Type: application/json" \
-d '{
"checkout_id": "co_abc123"
}'
This triggers the full webhook delivery flow to your registered endpoint.
See the Sandbox Testing guide for more details on testing.
Managing Webhook Endpoints
List endpoints
curl https://api.billing.io/v1/webhooks \
-H "Authorization: Bearer sk_live_xxx"
Get endpoint details
curl https://api.billing.io/v1/webhooks/we_f6a7b8c9d0e1f2a3 \
-H "Authorization: Bearer sk_live_xxx"
Delete an endpoint
curl -X DELETE https://api.billing.io/v1/webhooks/we_f6a7b8c9d0e1f2a3 \
-H "Authorization: Bearer sk_live_xxx"
Query event history
Use the events endpoint to review past events and debug delivery issues:
# List all events
curl "https://api.billing.io/v1/events?limit=10" \
-H "Authorization: Bearer sk_live_xxx"
# Filter by event type
curl "https://api.billing.io/v1/events?type=checkout.completed&limit=10" \
-H "Authorization: Bearer sk_live_xxx"
# Filter by checkout
curl "https://api.billing.io/v1/events?checkout_id=co_9a8b7c6d5e4f" \
-H "Authorization: Bearer sk_live_xxx"
Next Steps