Webhook Integration
A complete Node.js Express webhook handler for the billing platform. Covers HMAC-SHA256 signature verification, handling the key billing events, and idempotent event processing.
Setup
npm install express
// webhook-server.js
import express from "express";
import { createHmac, timingSafeEqual } from "crypto";
const app = express();
// CRITICAL: Use express.raw() — NOT express.json()
// JSON parsing changes the body bytes, breaking HMAC verification
app.use("/billing/webhooks", express.raw({ type: "application/json" }));
const BILLING_WEBHOOK_SECRET = process.env.BILLING_WEBHOOK_SECRET;
if (!BILLING_WEBHOOK_SECRET) {
throw new Error("BILLING_WEBHOOK_SECRET not set");
}
Signature Verification Middleware
Always verify the X-Billing-Signature header before processing any event. This prevents replay attacks and spoofed events.
function verifyBillingSignature(req, res, next) {
const signature = req.headers["x-billing-signature"];
if (!signature) {
return res.status(400).json({ error: "Missing X-Billing-Signature header" });
}
const expectedHmac = createHmac("sha256", BILLING_WEBHOOK_SECRET)
.update(req.body)
.digest("hex");
// Use timingSafeEqual to prevent timing attacks
try {
const sigBuffer = Buffer.from(signature, "hex");
const expectedBuffer = Buffer.from(expectedHmac, "hex");
if (sigBuffer.length !== expectedBuffer.length ||
!timingSafeEqual(sigBuffer, expectedBuffer)) {
return res.status(401).json({ error: "Invalid webhook signature" });
}
} catch {
return res.status(401).json({ error: "Invalid webhook signature" });
}
next();
}
app.post("/billing/webhooks", verifyBillingSignature, handleBillingWebhook);
Event Handler
// In-memory deduplication (use Redis/DB in production)
const processedEvents = new Set();
async function handleBillingWebhook(req, res) {
// Respond 200 immediately to prevent retry storms
// (process asynchronously if you have slow handlers)
res.status(200).send("ok");
const event = JSON.parse(req.body.toString());
const { id: eventId, type, data, timestamp } = event;
// Idempotent processing — skip if we've seen this event
if (processedEvents.has(eventId)) {
console.log(`Skipping duplicate event: ${eventId}`);
return;
}
processedEvents.add(eventId);
console.log(`[${timestamp}] ${type} — ${eventId}`);
switch (type) {
case "invoice.finalized":
await onInvoiceFinalized(data);
break;
case "subscription.past_due":
await onSubscriptionPastDue(data);
break;
case "payment.succeeded":
await onPaymentSucceeded(data);
break;
case "payment.failed":
await onPaymentFailed(data);
break;
case "spend_alert.triggered":
await onSpendAlertTriggered(data);
break;
case "credit_note.issued":
await onCreditNoteIssued(data);
break;
default:
console.log(`Unhandled event type: ${type}`);
}
}
Event Handlers
async function onInvoiceFinalized(data) {
const { invoice_id, invoice_number, customer_id, total_nanos, currency } = data;
const totalUsd = (BigInt(total_nanos) / BigInt(1e12)).toString();
console.log(`Invoice ${invoice_number} finalized for customer ${customer_id}: $${totalUsd}`);
// 1. Send invoice email to customer
await sendInvoiceEmail({
customerId: customer_id,
invoiceNumber: invoice_number,
amount: `$${totalUsd} ${currency}`,
invoiceUrl: `https://app.yoursaas.com/billing/invoices/${invoice_id}`,
});
// 2. Update your internal database
await db.invoices.upsert({
where: { billing_invoice_id: invoice_id },
data: { invoice_number, total_cents: Number(total_nanos) / 1e10, status: "open" },
});
}
async function onSubscriptionPastDue(data) {
const { subscription_id, customer_id } = data;
console.warn(`Subscription ${subscription_id} is past due!`);
// 1. Restrict access in your app
await revokeCustomerAccess(customer_id);
// 2. Send warning email
await sendPaymentFailureEmail(customer_id);
// 3. Update internal state
await db.subscriptions.update({
where: { billing_sub_id: subscription_id },
data: { status: "past_due", access_restricted: true },
});
}
async function onPaymentSucceeded(data) {
const { invoice_id, customer_id, amount_nanos } = data;
const amountUsd = (BigInt(amount_nanos) / BigInt(1e12)).toString();
console.log(`Payment received: $${amountUsd} for invoice ${invoice_id}`);
// Restore access if it was restricted
await restoreCustomerAccess(customer_id);
await sendPaymentReceiptEmail(customer_id, amountUsd);
}
async function onPaymentFailed(data) {
const { invoice_id, customer_id, dunning_attempt } = data;
console.warn(`Payment failed (attempt ${dunning_attempt}) for invoice ${invoice_id}`);
if (dunning_attempt >= 3) {
// Final warning — suspension is imminent
await sendFinalWarningEmail(customer_id);
}
}
async function onSpendAlertTriggered(data) {
const { alert_type, customer_id, threshold_nanos, current_spend_nanos } = data;
const thresholdUsd = (BigInt(threshold_nanos) / BigInt(1e12)).toString();
if (alert_type === "HardLimit") {
console.warn(`HardLimit hit for ${customer_id} — usage blocked above $${thresholdUsd}`);
await sendBudgetCapEmail(customer_id, thresholdUsd);
} else {
console.log(`SoftLimit: customer ${customer_id} approaching $${thresholdUsd}`);
await sendSpendWarningEmail(customer_id, thresholdUsd);
}
}
async function onCreditNoteIssued(data) {
const { credit_note_id, customer_id, amount_nanos } = data;
const amountUsd = (BigInt(amount_nanos) / BigInt(1e12)).toString();
console.log(`Credit note issued to ${customer_id}: -$${amountUsd}`);
await sendCreditNoteEmail(customer_id, amountUsd);
}
app.listen(3000, () => console.log("Webhook server listening on :3000"));
Testing Locally
# Use ngrok or similar to expose your local server
ngrok http 3000
# Register the tunnel URL as your webhook endpoint
curl -X POST https://api.bill.sh/admin/v1/webhooks \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-ngrok-url.ngrok.io/billing/webhooks",
"events": ["invoice.finalized", "subscription.past_due", "payment.succeeded"],
"description": "Local dev webhook"
}'
Key Points
- Always verify signatures before processing — never trust unsigned webhooks
- Respond 200 immediately and process async to avoid retry storms from slow handlers
- Deduplicate by event ID — webhooks can be delivered more than once
- Use
express.raw()— body parsers that modify the raw bytes will break HMAC verification