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
| Event | Description |
|---|---|
invoice.finalized | Invoice transitioned to Open with invoice number |
invoice.paid | Payment collected |
invoice.voided | Invoice was voided |
subscription.created | New subscription created |
subscription.cancelled | Subscription cancelled |
subscription.past_due | Payment failed, entering dunning |
payment.failed | Stripe charge failed |
spend_alert.triggered | SoftLimit or HardLimit threshold crossed |
credit_note.issued | Credit 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"
}
}