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

Webhooks

The billing platform delivers real-time event notifications via webhooks. When a billing event occurs (invoice finalized, payment failed, subscription cancelled), an HTTP POST is sent to your configured endpoint.

Webhook Events

EventDescription
invoice.finalizedInvoice transitioned to Open with invoice number
invoice.paidPayment collected
invoice.voidedInvoice was voided
subscription.createdNew subscription created
subscription.cancelledSubscription cancelled
subscription.past_duePayment failed, entering dunning
payment.failedStripe charge failed
spend_alert.triggeredSoftLimit or HardLimit threshold crossed
credit_note.issuedCredit note created against invoice

Webhook Signature Verification

Every webhook delivery includes a X-Billing-Signature header containing an HMAC-SHA256 signature of the request body.

# [curl]
# Register a 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-app.com/billing/webhooks",
    "events": ["invoice.finalized", "subscription.past_due", "payment.failed"],
    "description": "Production webhook endpoint"
  }'
# [Python]
import hmac
import hashlib

def verify_webhook(payload: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(
        secret.encode(),
        payload,
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)


# Full Flask webhook handler
from flask import Flask, request, abort
import json

app = Flask(__name__)
BILLING_WEBHOOK_SECRET = "whsec_your_secret_here"

@app.route("/billing/webhooks", methods=["POST"])
def billing_webhook():
    payload = request.get_data()
    signature = request.headers.get("X-Billing-Signature", "")

    if not verify_webhook(payload, signature, BILLING_WEBHOOK_SECRET):
        abort(400, "Invalid webhook signature")

    event = json.loads(payload)
    event_type = event["type"]
    data = event["data"]

    if event_type == "invoice.finalized":
        invoice_id = data["invoice_id"]
        invoice_number = data["invoice_number"]
        total_nanos = int(data["total_nanos"])
        total_usd = total_nanos / 1e12
        print(f"Invoice {invoice_number} finalized: ${total_usd:.2f}")
        # Send invoice email to customer, update your records, etc.

    elif event_type == "subscription.past_due":
        sub_id = data["subscription_id"]
        customer_id = data["customer_id"]
        print(f"Subscription {sub_id} for customer {customer_id} is past due")
        # Trigger in-app notification, restrict access, etc.

    elif event_type == "payment.succeeded":
        invoice_id = data["invoice_id"]
        amount_nanos = int(data["amount_nanos"])
        print(f"Payment received for invoice {invoice_id}: ${amount_nanos / 1e12:.2f}")
        # Unlock features, send receipt, etc.

    elif event_type == "spend_alert.triggered":
        alert_type = data["alert_type"]
        customer_id = data["customer_id"]
        threshold_nanos = int(data["threshold_nanos"])
        print(f"{alert_type} triggered for {customer_id} at ${threshold_nanos / 1e12:.2f}")

    return "", 200
// [Node.js]
import express from "express";
import { createHmac, timingSafeEqual } from "crypto";

const app = express();
const BILLING_WEBHOOK_SECRET = process.env.BILLING_WEBHOOK_SECRET;

// IMPORTANT: use express.raw() to preserve the raw body for signature verification
app.post("/billing/webhooks", express.raw({ type: "application/json" }), (req, res) => {
  const signature = req.headers["x-billing-signature"] ?? "";
  const expected = createHmac("sha256", BILLING_WEBHOOK_SECRET)
    .update(req.body)
    .digest("hex");

  if (!timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(signature, "hex"))) {
    return res.status(400).json({ error: "Invalid webhook signature" });
  }

  const event = JSON.parse(req.body.toString());
  const { type, data } = event;

  switch (type) {
    case "invoice.finalized": {
      const totalUsd = (BigInt(data.total_nanos) / BigInt(1e12)).toString();
      console.log(`Invoice ${data.invoice_number} finalized: $${totalUsd}`);
      // Send invoice email, update records, etc.
      break;
    }
    case "subscription.past_due": {
      console.log(`Subscription ${data.subscription_id} is past due`);
      // Restrict access, send warning email, etc.
      break;
    }
    case "payment.succeeded": {
      const amountUsd = (BigInt(data.amount_nanos) / BigInt(1e12)).toString();
      console.log(`Payment for invoice ${data.invoice_id}: $${amountUsd}`);
      // Unlock features, send receipt, etc.
      break;
    }
    case "spend_alert.triggered": {
      const thresholdUsd = (BigInt(data.threshold_nanos) / BigInt(1e12)).toString();
      console.log(`${data.alert_type} triggered for ${data.customer_id} at $${thresholdUsd}`);
      break;
    }
    default:
      console.log(`Unhandled event type: ${type}`);
  }

  res.status(200).send("ok");
});

app.listen(3000, () => console.log("Webhook server on port 3000"));
// [Go]
package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "io"
    "log"
    "net/http"
    "os"
)

var webhookSecret = os.Getenv("BILLING_WEBHOOK_SECRET")

type WebhookEvent struct {
    ID        string                 `json:"id"`
    Type      string                 `json:"type"`
    Timestamp string                 `json:"timestamp"`
    Data      map[string]interface{} `json:"data"`
}

func handleBillingWebhook(w http.ResponseWriter, r *http.Request) {
    payload, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "Bad request", http.StatusBadRequest)
        return
    }

    signature := r.Header.Get("X-Billing-Signature")
    mac := hmac.New(sha256.New, []byte(webhookSecret))
    mac.Write(payload)
    expected := hex.EncodeToString(mac.Sum(nil))

    if !hmac.Equal([]byte(expected), []byte(signature)) {
        http.Error(w, "Invalid signature", http.StatusBadRequest)
        return
    }

    var event WebhookEvent
    if err := json.Unmarshal(payload, &event); err != nil {
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }

    switch event.Type {
    case "invoice.finalized":
        log.Printf("Invoice %v finalized: %v nanos",
            event.Data["invoice_number"], event.Data["total_nanos"])
    case "subscription.past_due":
        log.Printf("Subscription %v is past due", event.Data["subscription_id"])
    case "payment.succeeded":
        log.Printf("Payment for invoice %v: %v nanos",
            event.Data["invoice_id"], event.Data["amount_nanos"])
    case "spend_alert.triggered":
        log.Printf("%v triggered for customer %v",
            event.Data["alert_type"], event.Data["customer_id"])
    default:
        log.Printf("Unhandled event: %s", event.Type)
    }

    fmt.Fprint(w, "ok")
}

func main() {
    http.HandleFunc("/billing/webhooks", handleBillingWebhook)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

Stripe Webhooks

Stripe payment events are received at POST /v1/webhooks/stripe. The endpoint verifies the Stripe-Signature header using your STRIPE_WEBHOOK_SECRET before processing:

# [curl]
# Configure in Stripe Dashboard:
# Endpoint URL: https://api.bill.sh/v1/webhooks/stripe
# Events to listen for: payment_intent.*, invoice.*

# Test with Stripe CLI
stripe listen --forward-to https://api.bill.sh/v1/webhooks/stripe
# [Python]
# Stripe webhook verification uses their SDK
import stripe

stripe.api_key = STRIPE_SECRET_KEY
STRIPE_WEBHOOK_SECRET = "whsec_..."

@app.route("/stripe/webhooks", methods=["POST"])
def stripe_webhook():
    payload = request.get_data()
    sig_header = request.headers.get("Stripe-Signature")
    try:
        event = stripe.Webhook.construct_event(payload, sig_header, STRIPE_WEBHOOK_SECRET)
    except stripe.error.SignatureVerificationError:
        abort(400, "Invalid Stripe signature")
    # Handle event...
    return "", 200
// [Node.js]
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

app.post("/stripe/webhooks", express.raw({ type: "application/json" }), (req, res) => {
  let event;
  try {
    event = stripe.webhooks.constructEvent(
      req.body,
      req.headers["stripe-signature"],
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }
  // Handle event...
  res.status(200).send("ok");
});
// [Go]
import "github.com/stripe/stripe-go/v76/webhook"

func handleStripeWebhook(w http.ResponseWriter, r *http.Request) {
    payload, _ := io.ReadAll(r.Body)
    event, err := webhook.ConstructEvent(
        payload,
        r.Header.Get("Stripe-Signature"),
        os.Getenv("STRIPE_WEBHOOK_SECRET"),
    )
    if err != nil {
        http.Error(w, "Invalid signature", http.StatusBadRequest)
        return
    }
    log.Printf("Stripe event: %s", event.Type)
    w.WriteHeader(http.StatusOK)
}

Retry Policy

Failed webhook deliveries are retried with exponential backoff:

  • Attempt 1: immediate
  • Attempt 2: +30 seconds
  • Attempt 3: +5 minutes
  • Attempt 4: +30 minutes
  • Attempt 5: +4 hours

After 5 attempts, the delivery is abandoned and the failure is logged.

Circuit Breaker

The webhook delivery system includes a circuit breaker. After 5 consecutive failures to the same endpoint, the circuit opens and deliveries are suspended for 60 seconds. This prevents piling up retries against a down endpoint.

Webhook Payload Format

{
  "id": "evt-01944b1f-0000-7000-8000-000000000060",
  "type": "invoice.finalized",
  "timestamp": "2026-02-28T12:00:00Z",
  "data": {
    "invoice_id": "01944b1f-0000-7000-8000-000000000004",
    "customer_id": "01944b1f-0000-7000-8000-000000000001",
    "invoice_number": "INV-000001",
    "total_nanos": "9990000000000",
    "currency": "USD"
  }
}