Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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