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

Billing Platform

A production-grade billing engine built in Rust — engineered for correctness, performance, and enterprise-scale complexity.

What It Does

The billing platform is a complete revenue operations engine. It handles every stage of the billing lifecycle, from tracking raw usage events to sending final invoices and collecting payment:

  • Metering — Ingest CloudEvents-compatible usage events at high volume. Aggregate by SUM, COUNT, MAX, or unique count across BillingPeriod, Sliding, or Tumbling windows.
  • Rating — Pure-function rating engine supporting flat, per-unit, graduated, volume, package, and committed pricing models with contract discount application (basis points) and tier overrides for enterprise custom pricing.
  • Invoicing — Full invoice lifecycle: Draft → Open → Paid or Void. Sequential invoice numbers (INV-XXXXXX), credit notes, proration, true-up billing, and progressive billing thresholds.
  • Contracts — Enterprise commercial contracts with ramp phases, escalation clauses, commit draw-downs, coterming, and immutable amendment chains.
  • Dunning — Automated failed payment retry with 4-attempt schedule (+3d/+7d/+14d/+21d post-failure).
  • Payments — Stripe integration for PaymentIntent creation, capture, refund, and webhook verification.
  • Tax — Pluggable TaxAdapter trait. Default: zero-tax pass-through. Swap in FlatRateTaxAdapter (basis points) or connect to Avalara/TaxJar in production.
  • Ledger — TigerBeetle-compatible double-entry ledger for AR, prepaid credit, and deferred revenue tracking.
  • Spend Alerts — Threshold-based SoftLimit (notify) and HardLimit (block charges) with reset workflow.
  • Credit Wallets — Prepaid top-up, drawdown, and auto-recharge via wallet credit service.
  • Audit Trail — Append-only audit log with 25+ action types, actor tracking, before/after state capture.

Key Design Principles

No floats, ever. All monetary amounts are i128 pico-units (scale=12). This eliminates floating-point rounding errors that plague billing systems. A $9.99 charge is stored as 9_990_000_000_000.

Purity where it counts. The rating engine, proration engine, and true-up calculations are pure functions — no I/O, fully deterministic, easily tested in isolation.

Idempotent by default. Every mutating operation accepts an Idempotency-Key header. The platform caches responses for 24 hours, making all operations safe to retry after network failures.

Enterprise-ready. Multi-entity customer hierarchies (parent/subsidiary/cost-center), consolidated billing, multi-currency support, B2B tax exemptions, and RBAC-ready auth.

Technology Stack

Rust · Axum · TigerBeetle (ledger) · CockroachDB (CRDB multi-region) · ClickHouse (metering analytics) · Kafka (event streaming) · Stripe (payments) · Redis (rate limiting, hot-path accumulators)


Ready to get started? Head to the Quick Start guide.

Quick Start

This guide walks through the full billing cycle: create a customer, subscribe them to a plan, record usage, and generate an invoice — all using the REST API.

Base URL: https://api.bill.sh


Step 1: Create a Customer

# [curl]
curl -X POST https://api.bill.sh/admin/v1/customers \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "display_name": "Acme Corp",
    "email": "billing@acmecorp.com",
    "currency": "USD",
    "account_type": "Organization"
  }'
# [Python]
import requests

resp = requests.post(
    "https://api.bill.sh/admin/v1/customers",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
    json={
        "display_name": "Acme Corp",
        "email": "billing@acmecorp.com",
        "currency": "USD",
        "account_type": "Organization",
    },
)
customer = resp.json()
customer_id = customer["id"]
print(f"Created customer: {customer_id}")
// [Node.js]
const resp = await fetch("https://api.bill.sh/admin/v1/customers", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${ADMIN_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    display_name: "Acme Corp",
    email: "billing@acmecorp.com",
    currency: "USD",
    account_type: "Organization",
  }),
});
const customer = await resp.json();
const customerId = customer.id;
console.log("Created customer:", customerId);
// [Go]
import (
    "bytes"
    "encoding/json"
    "net/http"
)

body, _ := json.Marshal(map[string]string{
    "display_name": "Acme Corp",
    "email":        "billing@acmecorp.com",
    "currency":     "USD",
    "account_type": "Organization",
})
req, _ := http.NewRequest("POST", "https://api.bill.sh/admin/v1/customers", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()

var customer map[string]interface{}
json.NewDecoder(resp.Body).Decode(&customer)
customerID := customer["id"].(string)

Response:

{
  "id": "01944b1f-0000-7000-8000-000000000001",
  "display_name": "Acme Corp",
  "account_type": "Organization",
  "parent_id": null
}

Save the id — you’ll need it for subsequent calls.


Step 2: Subscribe the Customer to a Plan

First, list available plans to find the plan_id:

# [curl]
curl https://api.bill.sh/admin/v1/catalog/plans \
  -H "Authorization: Bearer $ADMIN_TOKEN"
# [Python]
plans_resp = requests.get(
    "https://api.bill.sh/admin/v1/catalog/plans",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
)
plans = plans_resp.json()
plan_id = plans[0]["id"]  # pick the right plan for your use case
// [Node.js]
const plansResp = await fetch("https://api.bill.sh/admin/v1/catalog/plans", {
  headers: { "Authorization": `Bearer ${ADMIN_TOKEN}` },
});
const plans = await plansResp.json();
const planId = plans[0].id;
// [Go]
req, _ := http.NewRequest("GET", "https://api.bill.sh/admin/v1/catalog/plans", nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
// decode plans list...

Then create the subscription:

# [curl]
curl -X POST https://api.bill.sh/v1/subscriptions \
  -H "Authorization: Bearer $CUSTOMER_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: onboard-acme-sub-001" \
  -d '{
    "customer_id": "01944b1f-0000-7000-8000-000000000001",
    "plan_id": "01944b1f-0000-7000-8000-000000000002",
    "currency": "USD",
    "trial_days": 14
  }'
# [Python]
sub_resp = requests.post(
    "https://api.bill.sh/v1/subscriptions",
    headers={
        "Authorization": f"Bearer {CUSTOMER_TOKEN}",
        "Idempotency-Key": "onboard-acme-sub-001",
    },
    json={
        "customer_id": customer_id,
        "plan_id": plan_id,
        "currency": "USD",
        "trial_days": 14,
    },
)
subscription = sub_resp.json()
subscription_id = subscription["id"]
print(f"Subscription: {subscription_id}, status: {subscription['status']}")
// [Node.js]
const subResp = await fetch("https://api.bill.sh/v1/subscriptions", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${CUSTOMER_TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": "onboard-acme-sub-001",
  },
  body: JSON.stringify({
    customer_id: customerId,
    plan_id: planId,
    currency: "USD",
    trial_days: 14,
  }),
});
const subscription = await subResp.json();
const subscriptionId = subscription.id;
console.log(`Subscription: ${subscriptionId}, status: ${subscription.status}`);
// [Go]
body, _ := json.Marshal(map[string]interface{}{
    "customer_id": customerID,
    "plan_id":     planID,
    "currency":    "USD",
    "trial_days":  14,
})
req, _ := http.NewRequest("POST", "https://api.bill.sh/v1/subscriptions", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+customerToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "onboard-acme-sub-001")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()

var subscription map[string]interface{}
json.NewDecoder(resp.Body).Decode(&subscription)
subscriptionID := subscription["id"].(string)

Response:

{
  "id": "01944b1f-0000-7000-8000-000000000003",
  "customer_id": "01944b1f-0000-7000-8000-000000000001",
  "plan_id": "01944b1f-0000-7000-8000-000000000002",
  "status": "Trialing",
  "currency": "USD",
  "period_start": "2026-02-28T00:00:00Z",
  "period_end": "2026-03-28T00:00:00Z"
}

Step 3: Record Usage Events

Send usage events as they occur. Each event is a CloudEvents-compatible JSON object:

# [curl]
curl -X POST https://api.bill.sh/v1/events \
  -H "Authorization: Bearer $CUSTOMER_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: evt-acme-2026-02-28-001" \
  -d '{
    "id": "evt-acme-2026-02-28-001",
    "event_type": "api.request",
    "customer_id": "01944b1f-0000-7000-8000-000000000001",
    "subscription_id": "01944b1f-0000-7000-8000-000000000003",
    "timestamp": "2026-02-28T12:34:56Z",
    "properties": {
      "model": "gpt-4",
      "tokens": 1500,
      "region": "us-east-1"
    }
  }'
# [Python]
import uuid

event_id = f"evt-acme-{uuid.uuid4()}"
event_resp = requests.post(
    "https://api.bill.sh/v1/events",
    headers={
        "Authorization": f"Bearer {CUSTOMER_TOKEN}",
        "Idempotency-Key": event_id,
    },
    json={
        "id": event_id,
        "event_type": "api.request",
        "customer_id": customer_id,
        "subscription_id": subscription_id,
        "timestamp": "2026-02-28T12:34:56Z",
        "properties": {
            "model": "gpt-4",
            "tokens": 1500,
            "region": "us-east-1",
        },
    },
)
print(event_resp.json())  # {"accepted": true, "event_id": "..."}
// [Node.js]
import { randomUUID } from "crypto";

const eventId = `evt-acme-${randomUUID()}`;
const eventResp = await fetch("https://api.bill.sh/v1/events", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${CUSTOMER_TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": eventId,
  },
  body: JSON.stringify({
    id: eventId,
    event_type: "api.request",
    customer_id: customerId,
    subscription_id: subscriptionId,
    timestamp: new Date().toISOString(),
    properties: { model: "gpt-4", tokens: 1500, region: "us-east-1" },
  }),
});
console.log(await eventResp.json()); // { accepted: true, event_id: "..." }
// [Go]
import "github.com/google/uuid"

eventID := "evt-acme-" + uuid.New().String()
body, _ := json.Marshal(map[string]interface{}{
    "id":              eventID,
    "event_type":      "api.request",
    "customer_id":     customerID,
    "subscription_id": subscriptionID,
    "timestamp":       time.Now().UTC().Format(time.RFC3339),
    "properties": map[string]interface{}{
        "model":  "gpt-4",
        "tokens": 1500,
        "region": "us-east-1",
    },
})
req, _ := http.NewRequest("POST", "https://api.bill.sh/v1/events", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+customerToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", eventID)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()

Response:

{
  "accepted": true,
  "event_id": "evt-acme-2026-02-28-001"
}

You can check the usage summary at any time:

# [curl]
curl https://api.bill.sh/v1/subscriptions/01944b1f-0000-7000-8000-000000000003/usage \
  -H "Authorization: Bearer $CUSTOMER_TOKEN"
# [Python]
usage_resp = requests.get(
    f"https://api.bill.sh/v1/subscriptions/{subscription_id}/usage",
    headers={"Authorization": f"Bearer {CUSTOMER_TOKEN}"},
)
usage = usage_resp.json()
for meter in usage["meters"]:
    print(f"{meter['event_type']} ({meter['aggregation']}): {meter['value']}")
// [Node.js]
const usageResp = await fetch(
  `https://api.bill.sh/v1/subscriptions/${subscriptionId}/usage`,
  { headers: { "Authorization": `Bearer ${CUSTOMER_TOKEN}` } }
);
const usage = await usageResp.json();
for (const meter of usage.meters) {
  console.log(`${meter.event_type} (${meter.aggregation}): ${meter.value}`);
}
// [Go]
req, _ := http.NewRequest("GET",
    "https://api.bill.sh/v1/subscriptions/"+subscriptionID+"/usage", nil)
req.Header.Set("Authorization", "Bearer "+customerToken)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var usage map[string]interface{}
json.NewDecoder(resp.Body).Decode(&usage)

Step 4: Generate an Invoice

At the end of the billing period, trigger billing for the subscription:

# [curl]
curl -X POST https://api.bill.sh/admin/v1/subscriptions/01944b1f-0000-7000-8000-000000000003/bill \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Idempotency-Key: bill-acme-2026-02"
# [Python]
bill_resp = requests.post(
    f"https://api.bill.sh/admin/v1/subscriptions/{subscription_id}/bill",
    headers={
        "Authorization": f"Bearer {ADMIN_TOKEN}",
        "Idempotency-Key": f"bill-acme-2026-02",
    },
)
bill_resp.raise_for_status()
// [Node.js]
await fetch(
  `https://api.bill.sh/admin/v1/subscriptions/${subscriptionId}/bill`,
  {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${ADMIN_TOKEN}`,
      "Idempotency-Key": "bill-acme-2026-02",
    },
  }
);
// [Go]
req, _ := http.NewRequest("POST",
    "https://api.bill.sh/admin/v1/subscriptions/"+subscriptionID+"/bill", nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Idempotency-Key", "bill-acme-2026-02")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()

Then finalize the draft invoice to assign an invoice number:

# [curl]
# List invoices to find the Draft
curl "https://api.bill.sh/admin/v1/invoices?customer_id=01944b1f-0000-7000-8000-000000000001" \
  -H "Authorization: Bearer $ADMIN_TOKEN"

# Finalize it
curl -X POST https://api.bill.sh/admin/v1/invoices/01944b1f-0000-7000-8000-000000000004/finalize \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Idempotency-Key: finalize-inv-2026-02"
# [Python]
# Find the Draft invoice
invoices_resp = requests.get(
    "https://api.bill.sh/admin/v1/invoices",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
    params={"customer_id": customer_id, "status": "Draft"},
)
draft = invoices_resp.json()[0]
invoice_id = draft["id"]

# Finalize it
finalize_resp = requests.post(
    f"https://api.bill.sh/admin/v1/invoices/{invoice_id}/finalize",
    headers={
        "Authorization": f"Bearer {ADMIN_TOKEN}",
        "Idempotency-Key": f"finalize-inv-{invoice_id}",
    },
)
invoice = finalize_resp.json()
print(f"Invoice {invoice['invoice_number']} is now {invoice['status']}")
// [Node.js]
// Find the Draft invoice
const invoicesResp = await fetch(
  `https://api.bill.sh/admin/v1/invoices?customer_id=${customerId}&status=Draft`,
  { headers: { "Authorization": `Bearer ${ADMIN_TOKEN}` } }
);
const invoices = await invoicesResp.json();
const invoiceId = invoices[0].id;

// Finalize it
const finalizeResp = await fetch(
  `https://api.bill.sh/admin/v1/invoices/${invoiceId}/finalize`,
  {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${ADMIN_TOKEN}`,
      "Idempotency-Key": `finalize-inv-${invoiceId}`,
    },
  }
);
const invoice = await finalizeResp.json();
console.log(`Invoice ${invoice.invoice_number} is now ${invoice.status}`);
// [Go]
// Find the Draft invoice
req, _ := http.NewRequest("GET",
    "https://api.bill.sh/admin/v1/invoices?customer_id="+customerID+"&status=Draft", nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var invoices []map[string]interface{}
json.NewDecoder(resp.Body).Decode(&invoices)
invoiceID := invoices[0]["id"].(string)

// Finalize it
req2, _ := http.NewRequest("POST",
    "https://api.bill.sh/admin/v1/invoices/"+invoiceID+"/finalize", nil)
req2.Header.Set("Authorization", "Bearer "+adminToken)
req2.Header.Set("Idempotency-Key", "finalize-inv-"+invoiceID)
resp2, _ := http.DefaultClient.Do(req2)
defer resp2.Body.Close()
var invoice map[string]interface{}
json.NewDecoder(resp2.Body).Decode(&invoice)
fmt.Printf("Invoice %s is now %s\n", invoice["invoice_number"], invoice["status"])

Response:

{
  "id": "01944b1f-0000-7000-8000-000000000004",
  "status": "Open",
  "invoice_number": "INV-000001"
}

The invoice is now Open and ready for payment collection. Stripe will automatically collect payment if the customer has a payment method on file.


What’s Next?

Authentication

The billing API supports two authentication methods: JWT Bearer tokens and API keys.

JWT Bearer Token

For most integrations, use a short-lived JWT signed with HS256:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

JWTs must include:

  • sub — customer or user ID
  • exp — expiration timestamp (max 1 hour for customer tokens, 8 hours for admin tokens)
  • scopecustomer or admin

The JWT signing secret is configured via JWT_SECRET environment variable.

API Keys

Long-lived API keys are supported for server-to-server integrations:

X-API-Key: bsk_live_xxxxxxxxxxxxxxxxxxxx

API keys are validated in constant time to prevent timing attacks. Prefix bsk_live_ for production, bsk_test_ for sandbox.

Route Security

Route prefixRequired scope
GET /healthNone (public)
GET /openapi.jsonNone (public)
/v1/*customer or admin
/admin/v1/*admin only

Environment Variables

VariableDescription
JWT_SECRETHMAC-SHA256 secret for JWT signing/verification
API_KEY_HASHSHA-256 hex of the valid API key
STRIPE_WEBHOOK_SECRETStripe webhook signing secret (whsec_...)

Core Concepts

Understanding these three concepts will help you work effectively with the billing platform.


1. NanoMoney — Why Not Floats?

All monetary amounts in this system are stored as i128 integers with scale=12 (pico-units).

This means 1 USD = 1_000_000_000_000 (one trillion raw units). The type is called NanoMoney internally, and field names always end with _nanos (e.g., total_nanos, amount_nanos, threshold_nanos).

Why not floats? IEEE 754 double-precision floats have about 15-16 significant decimal digits — which sounds like a lot until you’re summing millions of small charges. 0.1 + 0.2 != 0.3 in floating point. For a billing system handling millions of dollars, these errors compound into real money. Using i128 integers eliminates this class of bug entirely.

Why scale=12 (not 2)? Standard “cents” arithmetic (i64 with scale=2) breaks down for usage-based pricing. An AI API might charge $0.000001 per token — that’s 0.000001 * 10^12 = 1_000_000 pico-units. With scale=2, this would round to zero. With scale=12, we have sub-nanodollar precision.

In API responses, _nanos fields are serialized as strings to avoid JavaScript’s 53-bit integer limit. To display to users, divide by 10^12:

const dollars = BigInt(total_nanos) / BigInt(1_000_000_000_000);
const cents = (BigInt(total_nanos) % BigInt(1_000_000_000_000)) / BigInt(10_000_000_000);
console.log(`$${dollars}.${String(cents).padStart(2, '0')}`);

Rounding happens exactly once — at invoice output, when converting to Money (2-decimal display). Never round intermediate calculations.


2. Subscription Lifecycle

A subscription moves through these states:

                    ┌─────────────┐
                    │   Trialing  │ ← trial_days > 0
                    └──────┬──────┘
                           │ trial ends / payment succeeds
                    ┌──────▼──────┐
              ┌────►│   Active    │◄────┐
              │     └──────┬──────┘     │
              │            │ payment fails
              │     ┌──────▼──────┐     │
              │     │  PastDue    │     │ resume()
              │     └──────┬──────┘     │
              │            │ dunning exhausted
              │     ┌──────▼──────┐     │
              │     │   Paused    ├─────┘
              │     └──────┬──────┘
              │            │ period ends
              │     ┌──────▼──────┐
              └─────┤  Cancelled  │
                    └─────────────┘
                           │ period ends
                    ┌──────▼──────┐
                    │   Expired   │
                    └─────────────┘

Key fields:

  • period_start / period_end — the current billing period
  • charged_through_date — how far the subscription has been billed (advances with each successful charge)
  • status — the current lifecycle state

State transitions are enforced — the service will reject invalid transitions (e.g., you cannot resume a Cancelled subscription).


3. The Metering → Rating → Invoicing Pipeline

The billing loop flows in three stages:

Usage Events → [Metering] → UsageQuantity (Decimal)
                                    │
PriceSheet ───────────────→ [Rating] → RatedLineItem (i128 nanos)
                                    │
                            [Invoicing] → Invoice (Money, ISO precision)

Metering receives raw usage events (CloudEvents-compatible JSON), deduplicates by event id, and aggregates quantities by meter definition. Output is always a Decimal quantity — never money. Metering doesn’t know prices.

Rating takes UsageQuantity + a PriceSheet (the plan’s pricing configuration) and produces RatedLineItem records with amount_nanos: i128. The rate_all() function is a pure function — no I/O, fully deterministic. Supports flat, per-unit, graduated, volume, package, and committed pricing models.

Invoicing collects RatedLineItem records, adds proration credits if applicable, appends tax line items if a TaxAdapter is configured, and produces a finalized Invoice. Rounding from pico-units to display currency happens exactly once at this stage.

Draft invoices can be modified (add line items, apply credits). Once finalized (POST /admin/v1/invoices/:id/finalize), the invoice is immutable and receives a sequential number (INV-XXXXXX).

SDK & Client Libraries

No official SDKs yet — but the OpenAPI spec makes generating one straightforward.

Generate a Client

Python (openapi-generator)

pip install openapi-generator-cli
openapi-generator generate \
  -i https://api.bill.sh/openapi.json \
  -g python \
  -o ./billing-client-python \
  --additional-properties=packageName=billing_client

TypeScript/Node.js

npx @openapitools/openapi-generator-cli generate \
  -i https://api.bill.sh/openapi.json \
  -g typescript-fetch \
  -o ./billing-client-ts

Go

openapi-generator generate \
  -i https://api.bill.sh/openapi.json \
  -g go \
  -o ./billing-client-go \
  --additional-properties=packageName=billingclient

Manual HTTP Client Pattern

Until official SDKs ship, here’s a minimal Python client that wraps requests with authentication, error handling, and idempotency support:

"""
billing_client.py — Minimal billing platform HTTP client
"""
from __future__ import annotations

import uuid
import hashlib
import requests
from typing import Any


class BillingClient:
    """Minimal client for the billing platform REST API."""

    def __init__(self, admin_token: str, base_url: str = "https://api.bill.sh"):
        self.base_url = base_url.rstrip("/")
        self._session = requests.Session()
        self._session.headers.update({
            "Authorization": f"Bearer {admin_token}",
            "Content-Type": "application/json",
        })

    def _idempotency_key(self, method: str, path: str, body: dict) -> str:
        """Generate a deterministic idempotency key from the request."""
        canonical = f"{method}:{path}:{sorted(body.items())}"
        return hashlib.sha256(canonical.encode()).hexdigest()[:32]

    def get(self, path: str, params: dict | None = None) -> Any:
        resp = self._session.get(f"{self.base_url}{path}", params=params)
        resp.raise_for_status()
        return resp.json()

    def post(self, path: str, body: dict, idempotency_key: str | None = None) -> Any:
        key = idempotency_key or self._idempotency_key("POST", path, body)
        headers = {"Idempotency-Key": key}
        resp = self._session.post(f"{self.base_url}{path}", json=body, headers=headers)
        resp.raise_for_status()
        return resp.json()

    def delete(self, path: str) -> None:
        resp = self._session.delete(f"{self.base_url}{path}")
        resp.raise_for_status()

    # --- Convenience methods ---

    def create_customer(self, display_name: str, email: str,
                        currency: str = "USD", **kwargs) -> dict:
        return self.post("/admin/v1/customers", {
            "display_name": display_name,
            "email": email,
            "currency": currency,
            **kwargs,
        })

    def create_subscription(self, customer_id: str, plan_id: str,
                            currency: str = "USD", **kwargs) -> dict:
        return self.post("/v1/subscriptions", {
            "customer_id": customer_id,
            "plan_id": plan_id,
            "currency": currency,
            **kwargs,
        }, idempotency_key=f"sub-{customer_id}-{plan_id}")

    def send_event(self, event_type: str, customer_id: str,
                   subscription_id: str, properties: dict,
                   timestamp: str | None = None) -> dict:
        import datetime
        event_id = f"evt-{uuid.uuid4()}"
        return self.post("/v1/events", {
            "id": event_id,
            "event_type": event_type,
            "customer_id": customer_id,
            "subscription_id": subscription_id,
            "timestamp": timestamp or datetime.datetime.utcnow().isoformat() + "Z",
            "properties": properties,
        }, idempotency_key=event_id)

    def get_usage(self, subscription_id: str) -> dict:
        return self.get(f"/v1/subscriptions/{subscription_id}/usage")

    def bill_subscription(self, subscription_id: str) -> None:
        self.post(f"/admin/v1/subscriptions/{subscription_id}/bill", {},
                  idempotency_key=f"bill-{subscription_id}")

    def finalize_invoice(self, invoice_id: str) -> dict:
        return self.post(f"/admin/v1/invoices/{invoice_id}/finalize", {},
                         idempotency_key=f"finalize-{invoice_id}")


# Usage example
if __name__ == "__main__":
    client = BillingClient(admin_token="bsk_live_your_key_here")

    # Create a customer and subscribe them
    customer = client.create_customer("Acme Corp", "billing@acme.com")
    subscription = client.create_subscription(
        customer_id=customer["id"],
        plan_id="your-plan-id",
        trial_days=14,
    )
    print(f"Customer {customer['id']} subscribed: {subscription['id']}")

    # Send a usage event
    result = client.send_event(
        event_type="api.request",
        customer_id=customer["id"],
        subscription_id=subscription["id"],
        properties={"model": "gpt-4o", "input_tokens": 512, "output_tokens": 128},
    )
    print(f"Event accepted: {result['event_id']}")

TypeScript Minimal Client

/**
 * billing-client.ts — Minimal TypeScript client
 */
export class BillingClient {
  private baseUrl: string;
  private token: string;

  constructor(token: string, baseUrl = "https://api.bill.sh") {
    this.baseUrl = baseUrl;
    this.token = token;
  }

  private headers(extra: Record<string, string> = {}): HeadersInit {
    return {
      Authorization: `Bearer ${this.token}`,
      "Content-Type": "application/json",
      ...extra,
    };
  }

  async get<T>(path: string, params?: Record<string, string>): Promise<T> {
    const url = new URL(`${this.baseUrl}${path}`);
    if (params) Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
    const resp = await fetch(url.toString(), { headers: this.headers() });
    if (!resp.ok) throw new Error(`GET ${path} failed: ${resp.status} ${await resp.text()}`);
    return resp.json() as Promise<T>;
  }

  async post<T>(path: string, body: object, idempotencyKey?: string): Promise<T> {
    const extra = idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {};
    const resp = await fetch(`${this.baseUrl}${path}`, {
      method: "POST",
      headers: this.headers(extra),
      body: JSON.stringify(body),
    });
    if (!resp.ok) throw new Error(`POST ${path} failed: ${resp.status} ${await resp.text()}`);
    return resp.json() as Promise<T>;
  }

  async createCustomer(displayName: string, email: string, currency = "USD") {
    return this.post<{ id: string }>("/admin/v1/customers", {
      display_name: displayName, email, currency, account_type: "Organization",
    });
  }

  async subscribe(customerId: string, planId: string, opts?: { trial_days?: number }) {
    return this.post<{ id: string; status: string }>("/v1/subscriptions", {
      customer_id: customerId, plan_id: planId, currency: "USD", ...opts,
    }, `sub-${customerId}-${planId}`);
  }

  async sendEvent(customerId: string, subscriptionId: string,
                  eventType: string, properties: Record<string, unknown>) {
    const id = `evt-${crypto.randomUUID()}`;
    return this.post<{ accepted: boolean; event_id: string }>("/v1/events", {
      id, event_type: eventType, customer_id: customerId,
      subscription_id: subscriptionId,
      timestamp: new Date().toISOString(), properties,
    }, id);
  }
}

What’s in the OpenAPI Spec

The spec at https://api.bill.sh/openapi.json includes:

  • All request/response schemas with field descriptions
  • Auth requirements per endpoint
  • Idempotency header documentation
  • Error response formats

The Interactive API Explorer is powered by the same spec.

Customers

The Customer resource represents a billing entity — an organization, individual, or subsidiary. The billing platform supports multi-entity hierarchies for enterprise customers with complex corporate structures.

Account Types

TypeDescription
OrganizationTop-level company account. Can have children.
IndividualSingle-person account. No children.
SubsidiaryChild account under an Organization. Used for cost-centers and subsidiaries.

Consolidation Modes

ModeDescription
StandaloneBilled independently (default)
ConsolidateToParentCharges roll up to the parent invoice

Fields

FieldTypeDescription
idstring (UUIDv7)Unique customer ID
display_namestringHuman-readable name
legal_namestring?Legal entity name (for invoices)
emailstringBilling contact email
account_typeenumOrganization / Individual / Subsidiary
statusenumActive / Suspended / Closed
parent_idstring?Parent customer ID (for subsidiaries)
bill_to_idstring?Override: which account receives the invoice
consolidationenumStandalone / ConsolidateToParent
billing_currencystringISO 4217 (e.g., “USD”, “EUR”)
payment_terms_daysintegerNet payment terms (default: 30)
tax_idstring?VAT/EIN for B2B tax exemption

Create a Customer

# [curl]
curl -X POST https://api.bill.sh/admin/v1/customers \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "display_name": "Globex Corp",
    "email": "billing@globex.com",
    "currency": "USD",
    "account_type": "Organization"
  }'
# [Python]
import requests

resp = requests.post(
    "https://api.bill.sh/admin/v1/customers",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
    json={
        "display_name": "Globex Corp",
        "email": "billing@globex.com",
        "currency": "USD",
        "account_type": "Organization",
    },
)
customer = resp.json()
print(f"Customer ID: {customer['id']}")
// [Node.js]
const resp = await fetch("https://api.bill.sh/admin/v1/customers", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${ADMIN_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    display_name: "Globex Corp",
    email: "billing@globex.com",
    currency: "USD",
    account_type: "Organization",
  }),
});
const customer = await resp.json();
console.log("Customer ID:", customer.id);
// [Go]
body, _ := json.Marshal(map[string]string{
    "display_name": "Globex Corp",
    "email":        "billing@globex.com",
    "currency":     "USD",
    "account_type": "Organization",
})
req, _ := http.NewRequest("POST", "https://api.bill.sh/admin/v1/customers", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var customer map[string]interface{}
json.NewDecoder(resp.Body).Decode(&customer)
fmt.Println("Customer ID:", customer["id"])

Create a Subsidiary

# [curl]
curl -X POST https://api.bill.sh/admin/v1/customers \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "display_name": "Globex — Europe",
    "email": "billing-eu@globex.com",
    "currency": "EUR",
    "account_type": "Subsidiary",
    "parent_id": "01944b1f-0000-7000-8000-000000000001"
  }'
# [Python]
resp = requests.post(
    "https://api.bill.sh/admin/v1/customers",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
    json={
        "display_name": "Globex — Europe",
        "email": "billing-eu@globex.com",
        "currency": "EUR",
        "account_type": "Subsidiary",
        "parent_id": "01944b1f-0000-7000-8000-000000000001",
    },
)
subsidiary = resp.json()
print(f"Subsidiary ID: {subsidiary['id']}, parent: {subsidiary.get('parent_id')}")
// [Node.js]
const resp = await fetch("https://api.bill.sh/admin/v1/customers", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${ADMIN_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    display_name: "Globex — Europe",
    email: "billing-eu@globex.com",
    currency: "EUR",
    account_type: "Subsidiary",
    parent_id: "01944b1f-0000-7000-8000-000000000001",
  }),
});
const subsidiary = await resp.json();
console.log("Subsidiary:", subsidiary.id, "parent:", subsidiary.parent_id);
// [Go]
body, _ := json.Marshal(map[string]string{
    "display_name": "Globex — Europe",
    "email":        "billing-eu@globex.com",
    "currency":     "EUR",
    "account_type": "Subsidiary",
    "parent_id":    "01944b1f-0000-7000-8000-000000000001",
})
req, _ := http.NewRequest("POST", "https://api.bill.sh/admin/v1/customers", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()

Get Customer 360 View

The 360 view returns everything about a customer in one call — subscriptions, invoices, ledger balances, and recent audit history:

# [curl]
curl https://api.bill.sh/admin/v1/customers/01944b1f-0000-7000-8000-000000000001 \
  -H "Authorization: Bearer $ADMIN_TOKEN"
# [Python]
resp = requests.get(
    f"https://api.bill.sh/admin/v1/customers/{customer_id}",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
)
view = resp.json()
print(f"Subscriptions: {len(view.get('subscriptions', []))}")
print(f"Open invoices: {[i['invoice_number'] for i in view.get('invoices', []) if i['status'] == 'Open']}")
// [Node.js]
const resp = await fetch(
  `https://api.bill.sh/admin/v1/customers/${customerId}`,
  { headers: { "Authorization": `Bearer ${ADMIN_TOKEN}` } }
);
const view = await resp.json();
console.log("Subscriptions:", view.subscriptions?.length ?? 0);
// [Go]
req, _ := http.NewRequest("GET",
    "https://api.bill.sh/admin/v1/customers/"+customerID, nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var view map[string]interface{}
json.NewDecoder(resp.Body).Decode(&view)

Get Entity Hierarchy

# [curl]
curl https://api.bill.sh/admin/v1/customers/01944b1f-0000-7000-8000-000000000001/hierarchy \
  -H "Authorization: Bearer $ADMIN_TOKEN"
# [Python]
resp = requests.get(
    f"https://api.bill.sh/admin/v1/customers/{customer_id}/hierarchy",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
)
hierarchy = resp.json()
# Returns the full tree of parent/subsidiary accounts
// [Node.js]
const resp = await fetch(
  `https://api.bill.sh/admin/v1/customers/${customerId}/hierarchy`,
  { headers: { "Authorization": `Bearer ${ADMIN_TOKEN}` } }
);
const hierarchy = await resp.json();
console.log(JSON.stringify(hierarchy, null, 2));
// [Go]
req, _ := http.NewRequest("GET",
    "https://api.bill.sh/admin/v1/customers/"+customerID+"/hierarchy", nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var hierarchy map[string]interface{}
json.NewDecoder(resp.Body).Decode(&hierarchy)

Returns the full tree of parent/subsidiary accounts rooted at the given customer.

Products & Plans

The product catalog defines what you sell. It has three layers:

Product → Plan → SKU (price)

Products

A Product is a named offering (e.g., “API Platform”, “Storage”, “Support Tier”).

# [curl]
# List products
curl https://api.bill.sh/admin/v1/catalog/products \
  -H "Authorization: Bearer $ADMIN_TOKEN"

# Create a product
curl -X POST https://api.bill.sh/admin/v1/catalog/products \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "API Platform",
    "description": "Usage-based API access",
    "tags": ["api", "core"]
  }'
# [Python]
import requests

# List products
products = requests.get(
    "https://api.bill.sh/admin/v1/catalog/products",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
).json()

# Create a product
resp = requests.post(
    "https://api.bill.sh/admin/v1/catalog/products",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
    json={
        "name": "API Platform",
        "description": "Usage-based API access",
        "tags": ["api", "core"],
    },
)
product = resp.json()
product_id = product["id"]
print(f"Product: {product_id}")
// [Node.js]
// List products
const productsResp = await fetch("https://api.bill.sh/admin/v1/catalog/products", {
  headers: { "Authorization": `Bearer ${ADMIN_TOKEN}` },
});
const products = await productsResp.json();

// Create a product
const resp = await fetch("https://api.bill.sh/admin/v1/catalog/products", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${ADMIN_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    name: "API Platform",
    description: "Usage-based API access",
    tags: ["api", "core"],
  }),
});
const product = await resp.json();
console.log("Product:", product.id);
// [Go]
// List products
req, _ := http.NewRequest("GET", "https://api.bill.sh/admin/v1/catalog/products", nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var products []map[string]interface{}
json.NewDecoder(resp.Body).Decode(&products)

// Create a product
body, _ := json.Marshal(map[string]interface{}{
    "name":        "API Platform",
    "description": "Usage-based API access",
    "tags":        []string{"api", "core"},
})
req2, _ := http.NewRequest("POST", "https://api.bill.sh/admin/v1/catalog/products",
    bytes.NewReader(body))
req2.Header.Set("Authorization", "Bearer "+adminToken)
req2.Header.Set("Content-Type", "application/json")
resp2, _ := http.DefaultClient.Do(req2)
defer resp2.Body.Close()
var product map[string]interface{}
json.NewDecoder(resp2.Body).Decode(&product)

Plans

A Plan bundles one or more SKUs together into a subscribable offering.

# [curl]
curl -X POST https://api.bill.sh/admin/v1/catalog/plans \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Startup",
    "description": "For teams up to 10 users",
    "billing_cadence": "Monthly",
    "trial_days": 14
  }'
# [Python]
resp = requests.post(
    "https://api.bill.sh/admin/v1/catalog/plans",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
    json={
        "name": "Startup",
        "description": "For teams up to 10 users",
        "billing_cadence": "Monthly",
        "trial_days": 14,
    },
)
plan = resp.json()
plan_id = plan["id"]
print(f"Plan ID: {plan_id}")
// [Node.js]
const resp = await fetch("https://api.bill.sh/admin/v1/catalog/plans", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${ADMIN_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    name: "Startup",
    description: "For teams up to 10 users",
    billing_cadence: "Monthly",
    trial_days: 14,
  }),
});
const plan = await resp.json();
console.log("Plan ID:", plan.id);
// [Go]
body, _ := json.Marshal(map[string]interface{}{
    "name":             "Startup",
    "description":      "For teams up to 10 users",
    "billing_cadence":  "Monthly",
    "trial_days":       14,
})
req, _ := http.NewRequest("POST", "https://api.bill.sh/admin/v1/catalog/plans",
    bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var plan map[string]interface{}
json.NewDecoder(resp.Body).Decode(&plan)
fmt.Println("Plan ID:", plan["id"])

SKUs (Price Points)

A SKU defines how a plan charges. Supported pricing models:

ModelDescriptionUse case
FlatFixed monthly feeSeat license, base platform fee
PerUnitPer-unit price × quantityPer API call, per GB
GraduatedTiered pricing, each tier applies to quantity in that tierUsage-based
VolumeAll units at the tier price that the total quantity falls intoVolume discounts
PackagePrice per bundle of N unitsCredits pack
CommittedMinimum commit + overage rateEnterprise committed use
# [curl]
curl -X POST https://api.bill.sh/admin/v1/catalog/skus \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "plan_id": "01944b1f-0000-7000-8000-000000000002",
    "name": "API Requests",
    "pricing_model": "PerUnit",
    "unit_amount_nanos": "1000000",
    "meter_event_type": "api.request",
    "currency": "USD"
  }'
# [Python]
resp = requests.post(
    "https://api.bill.sh/admin/v1/catalog/skus",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
    json={
        "plan_id": plan_id,
        "name": "API Requests",
        "pricing_model": "PerUnit",
        "unit_amount_nanos": "1000000",   # $0.000001 per request
        "meter_event_type": "api.request",
        "currency": "USD",
    },
)
sku = resp.json()
print(f"SKU {sku['id']} — {sku['pricing_model']} at {sku['unit_amount_nanos']} nanos/unit")
// [Node.js]
const resp = await fetch("https://api.bill.sh/admin/v1/catalog/skus", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${ADMIN_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    plan_id: planId,
    name: "API Requests",
    pricing_model: "PerUnit",
    unit_amount_nanos: "1000000",  // $0.000001 per request
    meter_event_type: "api.request",
    currency: "USD",
  }),
});
const sku = await resp.json();
console.log(`SKU ${sku.id} — ${sku.pricing_model}`);
// [Go]
body, _ := json.Marshal(map[string]interface{}{
    "plan_id":           planID,
    "name":              "API Requests",
    "pricing_model":     "PerUnit",
    "unit_amount_nanos": "1000000",
    "meter_event_type":  "api.request",
    "currency":          "USD",
})
req, _ := http.NewRequest("POST", "https://api.bill.sh/admin/v1/catalog/skus",
    bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var sku map[string]interface{}
json.NewDecoder(resp.Body).Decode(&sku)
fmt.Println("SKU:", sku["id"])

Note: unit_amount_nanos is the price in pico-units. 1000000 = $0.000001 per request.

Graduated Pricing Example

{
  "pricing_model": "Graduated",
  "tiers": [
    { "up_to": 1000000, "unit_amount_nanos": "1000000" },
    { "up_to": 10000000, "unit_amount_nanos": "750000" },
    { "up_to": null, "unit_amount_nanos": "500000" }
  ]
}

First 1M requests at $0.000001, next 9M at $0.00000075, everything after at $0.0000005.

Subscriptions

A Subscription links a Customer to a Plan and drives the billing cycle. It tracks the billing period, payment status, and charged-through date.

Subscription Fields

FieldTypeDescription
idstring (UUIDv7)Unique subscription ID
customer_idstringOwner customer
plan_idstringThe plan being subscribed to
statusenumSee lifecycle below
currencystringISO 4217 billing currency
period_startdatetimeCurrent billing period start
period_enddatetimeCurrent billing period end
charged_through_datedatetimeHow far billing has been collected
trial_daysintegerTrial days remaining at creation
contract_idstring?Linked enterprise contract (if any)

Lifecycle States

StatusMeaning
TrialingIn trial period — not yet billed
ActiveBilling normally
PastDuePayment failed, in dunning
PausedBilling paused (admin action)
CancelledCancelled, active through period end
ExpiredPeriod ended, no renewal

Create a Subscription

# [curl]
curl -X POST https://api.bill.sh/v1/subscriptions \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: create-sub-acme-startup" \
  -d '{
    "customer_id": "01944b1f-0000-7000-8000-000000000001",
    "plan_id": "01944b1f-0000-7000-8000-000000000002",
    "currency": "USD",
    "trial_days": 14
  }'
# [Python]
import requests

resp = requests.post(
    "https://api.bill.sh/v1/subscriptions",
    headers={
        "Authorization": f"Bearer {TOKEN}",
        "Idempotency-Key": "create-sub-acme-startup",
    },
    json={
        "customer_id": "01944b1f-0000-7000-8000-000000000001",
        "plan_id": "01944b1f-0000-7000-8000-000000000002",
        "currency": "USD",
        "trial_days": 14,
    },
)
subscription = resp.json()
print(f"Subscription {subscription['id']} — {subscription['status']}")
// [Node.js]
const resp = await fetch("https://api.bill.sh/v1/subscriptions", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": "create-sub-acme-startup",
  },
  body: JSON.stringify({
    customer_id: "01944b1f-0000-7000-8000-000000000001",
    plan_id: "01944b1f-0000-7000-8000-000000000002",
    currency: "USD",
    trial_days: 14,
  }),
});
const subscription = await resp.json();
console.log(`Subscription ${subscription.id} — ${subscription.status}`);
// [Go]
body, _ := json.Marshal(map[string]interface{}{
    "customer_id": "01944b1f-0000-7000-8000-000000000001",
    "plan_id":     "01944b1f-0000-7000-8000-000000000002",
    "currency":    "USD",
    "trial_days":  14,
})
req, _ := http.NewRequest("POST", "https://api.bill.sh/v1/subscriptions", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "create-sub-acme-startup")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var subscription map[string]interface{}
json.NewDecoder(resp.Body).Decode(&subscription)
fmt.Printf("Subscription %s — %s\n", subscription["id"], subscription["status"])

Get a Subscription

# [curl]
curl https://api.bill.sh/v1/subscriptions/01944b1f-0000-7000-8000-000000000003 \
  -H "Authorization: Bearer $TOKEN"
# [Python]
resp = requests.get(
    f"https://api.bill.sh/v1/subscriptions/{subscription_id}",
    headers={"Authorization": f"Bearer {TOKEN}"},
)
print(resp.json())
// [Node.js]
const resp = await fetch(
  `https://api.bill.sh/v1/subscriptions/${subscriptionId}`,
  { headers: { "Authorization": `Bearer ${TOKEN}` } }
);
const subscription = await resp.json();
console.log(subscription);
// [Go]
req, _ := http.NewRequest("GET",
    "https://api.bill.sh/v1/subscriptions/"+subscriptionID, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var subscription map[string]interface{}
json.NewDecoder(resp.Body).Decode(&subscription)

Cancel a Subscription

Cancellation is immediate in the system but the customer retains access through period_end:

# [curl]
curl -X POST https://api.bill.sh/v1/subscriptions/01944b1f-0000-7000-8000-000000000003/cancel \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: cancel-acme-sub-2026-02" \
  -d '{ "reason": "Customer requested cancellation" }'
# [Python]
resp = requests.post(
    f"https://api.bill.sh/v1/subscriptions/{subscription_id}/cancel",
    headers={
        "Authorization": f"Bearer {TOKEN}",
        "Idempotency-Key": f"cancel-{subscription_id}-2026-02",
    },
    json={"reason": "Customer requested cancellation"},
)
print(resp.json())  # {"status": "Cancelled", "period_end": "..."}
// [Node.js]
const resp = await fetch(
  `https://api.bill.sh/v1/subscriptions/${subscriptionId}/cancel`,
  {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${TOKEN}`,
      "Content-Type": "application/json",
      "Idempotency-Key": `cancel-${subscriptionId}-2026-02`,
    },
    body: JSON.stringify({ reason: "Customer requested cancellation" }),
  }
);
const result = await resp.json();
console.log("Status:", result.status, "Active until:", result.period_end);
// [Go]
body, _ := json.Marshal(map[string]string{
    "reason": "Customer requested cancellation",
})
req, _ := http.NewRequest("POST",
    "https://api.bill.sh/v1/subscriptions/"+subscriptionID+"/cancel",
    bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "cancel-"+subscriptionID+"-2026-02")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()

Pause / Resume (Admin)

# [curl]
# Pause
curl -X POST https://api.bill.sh/admin/v1/subscriptions/$SUB_ID/pause \
  -H "Authorization: Bearer $ADMIN_TOKEN"

# Resume
curl -X POST https://api.bill.sh/admin/v1/subscriptions/$SUB_ID/resume \
  -H "Authorization: Bearer $ADMIN_TOKEN"
# [Python]
# Pause
requests.post(
    f"https://api.bill.sh/admin/v1/subscriptions/{sub_id}/pause",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
)

# Resume
requests.post(
    f"https://api.bill.sh/admin/v1/subscriptions/{sub_id}/resume",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
)
// [Node.js]
// Pause
await fetch(`https://api.bill.sh/admin/v1/subscriptions/${subId}/pause`, {
  method: "POST",
  headers: { "Authorization": `Bearer ${ADMIN_TOKEN}` },
});

// Resume
await fetch(`https://api.bill.sh/admin/v1/subscriptions/${subId}/resume`, {
  method: "POST",
  headers: { "Authorization": `Bearer ${ADMIN_TOKEN}` },
});
// [Go]
// Pause
req, _ := http.NewRequest("POST",
    "https://api.bill.sh/admin/v1/subscriptions/"+subID+"/pause", nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
http.DefaultClient.Do(req)

// Resume
req2, _ := http.NewRequest("POST",
    "https://api.bill.sh/admin/v1/subscriptions/"+subID+"/resume", nil)
req2.Header.Set("Authorization", "Bearer "+adminToken)
http.DefaultClient.Do(req2)

Trigger Manual Billing (Admin)

Force a billing run outside the normal cycle — useful for testing or ad-hoc charges:

# [curl]
curl -X POST https://api.bill.sh/admin/v1/subscriptions/$SUB_ID/bill \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Idempotency-Key: manual-bill-$SUB_ID-2026-02"
# [Python]
resp = requests.post(
    f"https://api.bill.sh/admin/v1/subscriptions/{sub_id}/bill",
    headers={
        "Authorization": f"Bearer {ADMIN_TOKEN}",
        "Idempotency-Key": f"manual-bill-{sub_id}-2026-02",
    },
)
resp.raise_for_status()
print("Billing triggered")
// [Node.js]
await fetch(`https://api.bill.sh/admin/v1/subscriptions/${subId}/bill`, {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${ADMIN_TOKEN}`,
    "Idempotency-Key": `manual-bill-${subId}-2026-02`,
  },
});
// [Go]
req, _ := http.NewRequest("POST",
    "https://api.bill.sh/admin/v1/subscriptions/"+subID+"/bill", nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Idempotency-Key", "manual-bill-"+subID+"-2026-02")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()

List Invoices for a Subscription

# [curl]
curl https://api.bill.sh/v1/subscriptions/$SUB_ID/invoices \
  -H "Authorization: Bearer $TOKEN"
# [Python]
resp = requests.get(
    f"https://api.bill.sh/v1/subscriptions/{sub_id}/invoices",
    headers={"Authorization": f"Bearer {TOKEN}"},
)
for inv in resp.json():
    print(f"{inv.get('invoice_number', 'Draft')} — {inv['status']} — ${int(inv['total_nanos']) / 1e12:.2f}")
// [Node.js]
const resp = await fetch(
  `https://api.bill.sh/v1/subscriptions/${subId}/invoices`,
  { headers: { "Authorization": `Bearer ${TOKEN}` } }
);
const invoices = await resp.json();
for (const inv of invoices) {
  const amount = (BigInt(inv.total_nanos) / BigInt(1e12)).toString();
  console.log(`${inv.invoice_number ?? "Draft"} — ${inv.status} — $${amount}`);
}
// [Go]
req, _ := http.NewRequest("GET",
    "https://api.bill.sh/v1/subscriptions/"+subID+"/invoices", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var invoices []map[string]interface{}
json.NewDecoder(resp.Body).Decode(&invoices)
for _, inv := range invoices {
    fmt.Printf("%v — %v\n", inv["invoice_number"], inv["status"])
}

Invoices

An Invoice represents a billing statement — a set of line items for a billing period, with a total amount, currency, and lifecycle state.

Invoice Lifecycle

Draft → Open → Paid
              → Void
  • Draft — Being assembled. Line items can be added. Not visible to customers yet.
  • Open — Finalized with a sequential number (INV-XXXXXX). Payment collection begins.
  • Paid — Payment received (via Stripe or manual record).
  • Void — Cancelled before payment. A reason is required.

Invoice Types

TypeDescription
StandardRegular billing invoice
CreditNoteNegative invoice offsetting a prior charge
ProFormaEstimate/quote (not legally binding)

Key Fields

FieldTypeDescription
idstring (UUIDv7)Invoice ID
invoice_numberstring?Sequential number assigned on finalization (e.g., INV-000042)
statusenumDraft / Open / Paid / Void
invoice_typeenumStandard / CreditNote / ProForma
customer_idstringOwning customer
subscription_idstringSource subscription
total_nanosstringTotal in pico-units (i128 as string)
currencystringISO 4217
due_datedatePayment due date
period_start / period_enddatetimeBilling period
line_itemsarrayCharges detail
applies_to_invoice_idstring?For credit notes: which invoice this offsets
voided_reasonstring?Why the invoice was voided
finalized_atdatetime?When the invoice was finalized

Get an Invoice

# [curl]
curl https://api.bill.sh/v1/invoices/01944b1f-0000-7000-8000-000000000004 \
  -H "Authorization: Bearer $TOKEN"
# [Python]
import requests

resp = requests.get(
    f"https://api.bill.sh/v1/invoices/{invoice_id}",
    headers={"Authorization": f"Bearer {TOKEN}"},
)
invoice = resp.json()
total_usd = int(invoice["total_nanos"]) / 1e12
print(f"Invoice {invoice.get('invoice_number', 'Draft')}: ${total_usd:.2f} {invoice['currency']}")
for line in invoice.get("line_items", []):
    line_usd = int(line["amount_nanos"]) / 1e12
    print(f"  {line['description']}: ${line_usd:.2f}")
// [Node.js]
const resp = await fetch(
  `https://api.bill.sh/v1/invoices/${invoiceId}`,
  { headers: { "Authorization": `Bearer ${TOKEN}` } }
);
const invoice = await resp.json();
const totalUsd = (BigInt(invoice.total_nanos) / BigInt(1e12)).toString();
console.log(`Invoice ${invoice.invoice_number ?? "Draft"}: $${totalUsd} ${invoice.currency}`);
// [Go]
req, _ := http.NewRequest("GET",
    "https://api.bill.sh/v1/invoices/"+invoiceID, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var invoice map[string]interface{}
json.NewDecoder(resp.Body).Decode(&invoice)
fmt.Printf("Invoice %v: %v %v\n",
    invoice["invoice_number"], invoice["total_nanos"], invoice["currency"])

Response:

{
  "id": "01944b1f-0000-7000-8000-000000000004",
  "invoice_number": "INV-000001",
  "status": "Open",
  "invoice_type": "Standard",
  "customer_id": "01944b1f-0000-7000-8000-000000000001",
  "subscription_id": "01944b1f-0000-7000-8000-000000000003",
  "total_nanos": "9990000000000",
  "currency": "USD",
  "due_date": "2026-03-28",
  "period_start": "2026-02-28T00:00:00Z",
  "period_end": "2026-03-28T00:00:00Z",
  "line_items": [
    {
      "id": "01944b1f-0000-7000-8000-000000000010",
      "description": "Startup Plan — Monthly",
      "quantity": "1",
      "unit_amount_nanos": "9990000000000",
      "amount_nanos": "9990000000000",
      "currency": "USD",
      "is_tax": false
    }
  ],
  "finalized_at": "2026-02-28T10:00:00Z",
  "created_at": "2026-02-28T00:00:00Z"
}

Finalize an Invoice

Assigns a sequential invoice number and transitions from Draft → Open:

# [curl]
curl -X POST https://api.bill.sh/admin/v1/invoices/$INV_ID/finalize \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Idempotency-Key: finalize-$INV_ID"
# [Python]
resp = requests.post(
    f"https://api.bill.sh/admin/v1/invoices/{inv_id}/finalize",
    headers={
        "Authorization": f"Bearer {ADMIN_TOKEN}",
        "Idempotency-Key": f"finalize-{inv_id}",
    },
)
invoice = resp.json()
print(f"Finalized: {invoice['invoice_number']} — {invoice['status']}")
// [Node.js]
const resp = await fetch(
  `https://api.bill.sh/admin/v1/invoices/${invId}/finalize`,
  {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${ADMIN_TOKEN}`,
      "Idempotency-Key": `finalize-${invId}`,
    },
  }
);
const invoice = await resp.json();
console.log(`Finalized: ${invoice.invoice_number} — ${invoice.status}`);
// [Go]
req, _ := http.NewRequest("POST",
    "https://api.bill.sh/admin/v1/invoices/"+invID+"/finalize", nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Idempotency-Key", "finalize-"+invID)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var invoice map[string]interface{}
json.NewDecoder(resp.Body).Decode(&invoice)
fmt.Printf("Finalized: %v — %v\n", invoice["invoice_number"], invoice["status"])

Void an Invoice

# [curl]
curl -X POST https://api.bill.sh/admin/v1/invoices/$INV_ID/void \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "reason": "Duplicate invoice — customer billed twice in error",
    "actor_id": "support-agent-001",
    "actor_name": "Jane Smith"
  }'
# [Python]
resp = requests.post(
    f"https://api.bill.sh/admin/v1/invoices/{inv_id}/void",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
    json={
        "reason": "Duplicate invoice — customer billed twice in error",
        "actor_id": "support-agent-001",
        "actor_name": "Jane Smith",
    },
)
print(resp.json())  # {"status": "Void", ...}
// [Node.js]
const resp = await fetch(
  `https://api.bill.sh/admin/v1/invoices/${invId}/void`,
  {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${ADMIN_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      reason: "Duplicate invoice — customer billed twice in error",
      actor_id: "support-agent-001",
      actor_name: "Jane Smith",
    }),
  }
);
const result = await resp.json();
console.log("Voided:", result.status);
// [Go]
body, _ := json.Marshal(map[string]string{
    "reason":     "Duplicate invoice — customer billed twice in error",
    "actor_id":   "support-agent-001",
    "actor_name": "Jane Smith",
})
req, _ := http.NewRequest("POST",
    "https://api.bill.sh/admin/v1/invoices/"+invID+"/void",
    bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()

Voiding requires a reason of at least 10 characters. Only Open invoices can be voided.

Issue a Credit Note

Creates a negative invoice offsetting some or all charges from a finalized invoice:

# [curl]
curl -X POST https://api.bill.sh/admin/v1/invoices/$INV_ID/credit-note \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "reason": "Service incident credit — 4-hour outage on 2026-02-28",
    "actor_id": "support-agent-001",
    "actor_name": "Jane Smith",
    "idempotency_key": "cn-ticket-1234"
  }'
# [Python]
resp = requests.post(
    f"https://api.bill.sh/admin/v1/invoices/{inv_id}/credit-note",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
    json={
        "reason": "Service incident credit — 4-hour outage on 2026-02-28",
        "actor_id": "support-agent-001",
        "actor_name": "Jane Smith",
        "idempotency_key": "cn-ticket-1234",
    },
)
credit_note = resp.json()
print(f"Credit note {credit_note['id']} — {credit_note.get('total_nanos')} nanos")
// [Node.js]
const resp = await fetch(
  `https://api.bill.sh/admin/v1/invoices/${invId}/credit-note`,
  {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${ADMIN_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      reason: "Service incident credit — 4-hour outage on 2026-02-28",
      actor_id: "support-agent-001",
      actor_name: "Jane Smith",
      idempotency_key: "cn-ticket-1234",
    }),
  }
);
const creditNote = await resp.json();
console.log("Credit note:", creditNote.id);
// [Go]
body, _ := json.Marshal(map[string]string{
    "reason":          "Service incident credit — 4-hour outage on 2026-02-28",
    "actor_id":        "support-agent-001",
    "actor_name":      "Jane Smith",
    "idempotency_key": "cn-ticket-1234",
})
req, _ := http.NewRequest("POST",
    "https://api.bill.sh/admin/v1/invoices/"+invID+"/credit-note",
    bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()

Pass line_item_ids to credit specific charges only. Empty array credits the full invoice.

Mark as Paid

For payments collected outside Stripe (wire transfer, check):

# [curl]
curl -X POST https://api.bill.sh/admin/v1/invoices/$INV_ID/pay \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "actor_id": "finance-001",
    "actor_name": "Alex Finance"
  }'
# [Python]
resp = requests.post(
    f"https://api.bill.sh/admin/v1/invoices/{inv_id}/pay",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
    json={"actor_id": "finance-001", "actor_name": "Alex Finance"},
)
print(resp.json())  # {"status": "Paid", ...}
// [Node.js]
const resp = await fetch(
  `https://api.bill.sh/admin/v1/invoices/${invId}/pay`,
  {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${ADMIN_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ actor_id: "finance-001", actor_name: "Alex Finance" }),
  }
);
const result = await resp.json();
console.log("Marked paid:", result.status);
// [Go]
body, _ := json.Marshal(map[string]string{
    "actor_id":   "finance-001",
    "actor_name": "Alex Finance",
})
req, _ := http.NewRequest("POST",
    "https://api.bill.sh/admin/v1/invoices/"+invID+"/pay",
    bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()

Metering & Usage

The metering system ingests high-volume usage events, aggregates them by meter definition, and feeds the rating engine. It’s designed for correctness first (deduplication, idempotency) and scale second (ClickHouse-ready batch queries, Redis hot-path accumulators).

Usage Events

Usage events are CloudEvents-compatible JSON objects. Every event must have:

FieldTypeDescription
idstringClient-generated unique ID — used for deduplication
event_typestringEvent type for meter matching (e.g., api.request, storage.byte-hour)
customer_idstring (UUIDv7)Customer who generated this usage
subscription_idstring?Subscription to attribute to (optional)
timestampdatetimeWhen the event occurred (ISO 8601)
propertiesobjectArbitrary key-value pairs for filtering and aggregation

Send a Usage Event

# [curl]
curl -X POST https://api.bill.sh/v1/events \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: evt-$UUID" \
  -d '{
    "id": "evt-2026-02-28-abc123",
    "event_type": "api.request",
    "customer_id": "01944b1f-0000-7000-8000-000000000001",
    "subscription_id": "01944b1f-0000-7000-8000-000000000003",
    "timestamp": "2026-02-28T14:22:01Z",
    "properties": {
      "model": "gpt-4-turbo",
      "input_tokens": 512,
      "output_tokens": 256,
      "region": "us-east-1"
    }
  }'
# [Python]
import requests, uuid

event_id = f"evt-{uuid.uuid4()}"
resp = requests.post(
    "https://api.bill.sh/v1/events",
    headers={
        "Authorization": f"Bearer {TOKEN}",
        "Idempotency-Key": event_id,
    },
    json={
        "id": event_id,
        "event_type": "api.request",
        "customer_id": customer_id,
        "subscription_id": subscription_id,
        "timestamp": "2026-02-28T14:22:01Z",
        "properties": {
            "model": "gpt-4-turbo",
            "input_tokens": 512,
            "output_tokens": 256,
            "region": "us-east-1",
        },
    },
)
result = resp.json()
print(f"Accepted: {result['accepted']}, ID: {result['event_id']}")
// [Node.js]
import { randomUUID } from "crypto";

const eventId = `evt-${randomUUID()}`;
const resp = await fetch("https://api.bill.sh/v1/events", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": eventId,
  },
  body: JSON.stringify({
    id: eventId,
    event_type: "api.request",
    customer_id: customerId,
    subscription_id: subscriptionId,
    timestamp: new Date().toISOString(),
    properties: {
      model: "gpt-4-turbo",
      input_tokens: 512,
      output_tokens: 256,
      region: "us-east-1",
    },
  }),
});
const result = await resp.json();
console.log("Accepted:", result.accepted, "ID:", result.event_id);
// [Go]
import (
    "bytes"
    "encoding/json"
    "net/http"
    "github.com/google/uuid"
)

eventID := "evt-" + uuid.New().String()
body, _ := json.Marshal(map[string]interface{}{
    "id":              eventID,
    "event_type":      "api.request",
    "customer_id":     customerID,
    "subscription_id": subscriptionID,
    "timestamp":       "2026-02-28T14:22:01Z",
    "properties": map[string]interface{}{
        "model":         "gpt-4-turbo",
        "input_tokens":  512,
        "output_tokens": 256,
        "region":        "us-east-1",
    },
})
req, _ := http.NewRequest("POST", "https://api.bill.sh/v1/events", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", eventID)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()

Important: The id field is your deduplication key. Re-sending an event with the same id is safe — it will be deduplicated and only counted once.

Batch Event Ingestion

For high-throughput workloads, send events in batches:

# [curl]
curl -X POST https://api.bill.sh/v1/events/batch \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "events": [
      {
        "id": "evt-batch-001",
        "event_type": "api.request",
        "customer_id": "01944b1f-0000-7000-8000-000000000001",
        "timestamp": "2026-02-28T14:22:01Z",
        "properties": { "model": "gpt-4-turbo", "input_tokens": 512 }
      },
      {
        "id": "evt-batch-002",
        "event_type": "api.request",
        "customer_id": "01944b1f-0000-7000-8000-000000000001",
        "timestamp": "2026-02-28T14:22:05Z",
        "properties": { "model": "gpt-4-turbo", "input_tokens": 1024 }
      }
    ]
  }'
# [Python]
events = [
    {
        "id": f"evt-batch-{i:04d}",
        "event_type": "api.request",
        "customer_id": customer_id,
        "subscription_id": subscription_id,
        "timestamp": "2026-02-28T14:22:01Z",
        "properties": {"model": "gpt-4-turbo", "input_tokens": 512 * i},
    }
    for i in range(1, 101)  # 100 events in one batch
]

resp = requests.post(
    "https://api.bill.sh/v1/events/batch",
    headers={"Authorization": f"Bearer {TOKEN}"},
    json={"events": events},
)
result = resp.json()
print(f"Accepted: {result['accepted_count']}, Duplicates: {result.get('duplicate_count', 0)}")
// [Node.js]
const events = Array.from({ length: 100 }, (_, i) => ({
  id: `evt-batch-${String(i).padStart(4, "0")}`,
  event_type: "api.request",
  customer_id: customerId,
  subscription_id: subscriptionId,
  timestamp: new Date().toISOString(),
  properties: { model: "gpt-4-turbo", input_tokens: 512 * (i + 1) },
}));

const resp = await fetch("https://api.bill.sh/v1/events/batch", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ events }),
});
const result = await resp.json();
console.log(`Accepted: ${result.accepted_count}, Duplicates: ${result.duplicate_count ?? 0}`);
// [Go]
type Event struct {
    ID             string                 `json:"id"`
    EventType      string                 `json:"event_type"`
    CustomerID     string                 `json:"customer_id"`
    SubscriptionID string                 `json:"subscription_id"`
    Timestamp      string                 `json:"timestamp"`
    Properties     map[string]interface{} `json:"properties"`
}

events := make([]Event, 100)
for i := range events {
    events[i] = Event{
        ID:             fmt.Sprintf("evt-batch-%04d", i),
        EventType:      "api.request",
        CustomerID:     customerID,
        SubscriptionID: subscriptionID,
        Timestamp:      "2026-02-28T14:22:01Z",
        Properties:     map[string]interface{}{"model": "gpt-4-turbo", "input_tokens": 512 * (i + 1)},
    }
}
body, _ := json.Marshal(map[string]interface{}{"events": events})
req, _ := http.NewRequest("POST", "https://api.bill.sh/v1/events/batch", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()

Meter Definitions

A MeterDefinition maps an event_type to an aggregation function. Multiple meters can match the same event type (e.g., one meter for input_tokens and another for output_tokens from the same api.request event).

Aggregation Types

TypeDescriptionField
SumSum of a numeric property across all eventsproperties.tokens
CountNumber of events (ignores properties)
MaxMaximum value of a propertyproperties.response_time_ms
UniqueCountCount of distinct values for a propertyproperties.user_id

Window Types

TypeDescription
BillingPeriodAggregate across the entire billing period (most common)
SlidingRolling window (e.g., last 30 days)
TumblingFixed non-overlapping windows (e.g., daily buckets)

Query Usage Summary

Get the current usage summary for a subscription — shows each meter’s current value for the billing period:

# [curl]
curl https://api.bill.sh/v1/subscriptions/$SUB_ID/usage \
  -H "Authorization: Bearer $TOKEN"
# [Python]
resp = requests.get(
    f"https://api.bill.sh/v1/subscriptions/{subscription_id}/usage",
    headers={"Authorization": f"Bearer {TOKEN}"},
)
usage = resp.json()
print(f"Period: {usage['period_start']} → {usage['period_end']}")
for meter in usage["meters"]:
    print(f"  {meter['meter_id']}: {meter['value']} {meter.get('unit', '')}")
// [Node.js]
const resp = await fetch(
  `https://api.bill.sh/v1/subscriptions/${subscriptionId}/usage`,
  { headers: { "Authorization": `Bearer ${TOKEN}` } }
);
const usage = await resp.json();
console.log(`Period: ${usage.period_start} → ${usage.period_end}`);
for (const meter of usage.meters) {
  console.log(`  ${meter.meter_id}: ${meter.value} ${meter.unit ?? ""}`);
}
// [Go]
req, _ := http.NewRequest("GET",
    "https://api.bill.sh/v1/subscriptions/"+subscriptionID+"/usage", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var usage map[string]interface{}
json.NewDecoder(resp.Body).Decode(&usage)
meters := usage["meters"].([]interface{})
for _, m := range meters {
    meter := m.(map[string]interface{})
    fmt.Printf("  %v: %v\n", meter["meter_id"], meter["value"])
}

Response:

{
  "subscription_id": "01944b1f-0000-7000-8000-000000000003",
  "period_start": "2026-02-28T00:00:00Z",
  "period_end": "2026-03-28T00:00:00Z",
  "meters": [
    {
      "meter_id": "meter-input-tokens",
      "event_type": "api.request",
      "aggregation": "Sum",
      "value": "128450",
      "unit": "tokens"
    },
    {
      "meter_id": "meter-requests",
      "event_type": "api.request",
      "aggregation": "Count",
      "value": "842",
      "unit": "requests"
    }
  ]
}

Hot-Path Accumulator

For real-time spend controls, the platform maintains an in-memory accumulator (backed by Redis in production) that updates on every event ingestion. The spend alert service reads from this accumulator to enforce SoftLimit and HardLimit thresholds without round-tripping to ClickHouse.

ClickHouse Integration

For analytics queries (cohort analysis, revenue forecasting, usage breakdown by property), events are streamed to ClickHouse via Kafka. The schema uses a materialized view for efficient aggregation by (customer_id, event_type, billing_period, property_key).

Contracts

Contracts represent commercial/legal agreements between your company and an enterprise customer. They live in the contracts layer — separate from subscriptions (which are billing engine artifacts) — because Sales owns contracts while Finance/Engineering owns subscriptions.

Contract vs Subscription

ContractSubscription
OwnerSales / LegalFinance / Engineering
PurposeCommercial termsBilling cycle execution
LifecycleDraft → Active → Amended/Expired/TerminatedTrialing → Active → Cancelled
PLG customersNone requiredRequired
Enterprise customersRequiredLinked via ContractSubscription

Contract States

Draft → Active → Amended (immutable chain)
              → Expired (natural end)
              → Terminated (early exit)

Amendment is immutableamend() marks the current contract Amended and creates a new Active contract with parent_contract_id linking back. The full history is queryable via the amendment chain.

Key Concepts

Ramp Contracts

Enterprise contracts often have ramp phases — lower pricing for the first months, ramping up to full rate. Each ContractTerm phase has its own pricing, discount, and committed amount.

Commit Draw-Down

When a customer has a committed minimum spend (e.g., $100k/year), usage charges first draw down from the commit. apply_commit_drawdown() applies draws in priority order, tag-scoped if needed.

Coterming

When a customer adds a new subscription mid-contract, it can be cotermed to the existing contract’s end date via coterm(). If fewer than 30 days remain, a one-time fee is generated instead.

Escalation

Contracts support automatic price escalation: Percent (e.g., 3% annual) or Fixed amount per period.

Rate Overrides

Enterprise custom pricing via RateOverride — a basis-points multiplier scoped to a product_tag. Lets you give a customer 20% off all storage SKUs without touching the catalog.

Contract CRUD

Create a Contract

# [curl]
curl -X POST https://api.bill.sh/admin/v1/contracts \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "customer_id": "01944b1f-0000-7000-8000-000000000001",
    "name": "Acme Corp — Enterprise 2026",
    "start_date": "2026-01-01",
    "end_date": "2026-12-31",
    "terms": [
      {
        "phase": 1,
        "start_date": "2026-01-01",
        "end_date": "2026-03-31",
        "committed_amount_nanos": "50000000000000000",
        "description": "Q1 ramp — $50k"
      },
      {
        "phase": 2,
        "start_date": "2026-04-01",
        "end_date": "2026-12-31",
        "committed_amount_nanos": "100000000000000000",
        "description": "Q2-Q4 full rate — $100k/quarter"
      }
    ]
  }'
# [Python]
import requests

resp = requests.post(
    "https://api.bill.sh/admin/v1/contracts",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
    json={
        "customer_id": customer_id,
        "name": "Acme Corp — Enterprise 2026",
        "start_date": "2026-01-01",
        "end_date": "2026-12-31",
        "terms": [
            {
                "phase": 1,
                "start_date": "2026-01-01",
                "end_date": "2026-03-31",
                "committed_amount_nanos": "50000000000000000",
                "description": "Q1 ramp — $50k",
            },
            {
                "phase": 2,
                "start_date": "2026-04-01",
                "end_date": "2026-12-31",
                "committed_amount_nanos": "100000000000000000",
                "description": "Q2-Q4 full rate — $100k/quarter",
            },
        ],
    },
)
contract = resp.json()
print(f"Contract {contract['id']} — {contract['status']}")
// [Node.js]
const resp = await fetch("https://api.bill.sh/admin/v1/contracts", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${ADMIN_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    customer_id: customerId,
    name: "Acme Corp — Enterprise 2026",
    start_date: "2026-01-01",
    end_date: "2026-12-31",
    terms: [
      {
        phase: 1,
        start_date: "2026-01-01",
        end_date: "2026-03-31",
        committed_amount_nanos: "50000000000000000",
        description: "Q1 ramp — $50k",
      },
      {
        phase: 2,
        start_date: "2026-04-01",
        end_date: "2026-12-31",
        committed_amount_nanos: "100000000000000000",
        description: "Q2-Q4 full rate — $100k/quarter",
      },
    ],
  }),
});
const contract = await resp.json();
console.log(`Contract ${contract.id} — ${contract.status}`);
// [Go]
type ContractTerm struct {
    Phase                 int    `json:"phase"`
    StartDate             string `json:"start_date"`
    EndDate               string `json:"end_date"`
    CommittedAmountNanos  string `json:"committed_amount_nanos"`
    Description           string `json:"description"`
}

payload := map[string]interface{}{
    "customer_id": customerID,
    "name":        "Acme Corp — Enterprise 2026",
    "start_date":  "2026-01-01",
    "end_date":    "2026-12-31",
    "terms": []ContractTerm{
        {1, "2026-01-01", "2026-03-31", "50000000000000000", "Q1 ramp — $50k"},
        {2, "2026-04-01", "2026-12-31", "100000000000000000", "Q2-Q4 full rate"},
    },
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "https://api.bill.sh/admin/v1/contracts",
    bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var contract map[string]interface{}
json.NewDecoder(resp.Body).Decode(&contract)
fmt.Printf("Contract %v — %v\n", contract["id"], contract["status"])

Get a Contract

# [curl]
curl https://api.bill.sh/admin/v1/contracts/$CONTRACT_ID \
  -H "Authorization: Bearer $ADMIN_TOKEN"
# [Python]
resp = requests.get(
    f"https://api.bill.sh/admin/v1/contracts/{contract_id}",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
)
contract = resp.json()
print(f"Contract: {contract['name']}, Status: {contract['status']}")
print(f"Terms: {len(contract.get('terms', []))}")
// [Node.js]
const resp = await fetch(
  `https://api.bill.sh/admin/v1/contracts/${contractId}`,
  { headers: { "Authorization": `Bearer ${ADMIN_TOKEN}` } }
);
const contract = await resp.json();
console.log(`Contract: ${contract.name}, Status: ${contract.status}`);
// [Go]
req, _ := http.NewRequest("GET",
    "https://api.bill.sh/admin/v1/contracts/"+contractID, nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var contract map[string]interface{}
json.NewDecoder(resp.Body).Decode(&contract)

Amend a Contract

# [curl]
curl -X POST https://api.bill.sh/admin/v1/contracts/$CONTRACT_ID/amend \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "reason": "Expansion — added Data Analytics module",
    "new_committed_amount_nanos": "150000000000000000",
    "effective_date": "2026-06-01"
  }'
# [Python]
resp = requests.post(
    f"https://api.bill.sh/admin/v1/contracts/{contract_id}/amend",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
    json={
        "reason": "Expansion — added Data Analytics module",
        "new_committed_amount_nanos": "150000000000000000",
        "effective_date": "2026-06-01",
    },
)
amended = resp.json()
print(f"New contract: {amended['id']}, parent: {amended.get('parent_contract_id')}")
// [Node.js]
const resp = await fetch(
  `https://api.bill.sh/admin/v1/contracts/${contractId}/amend`,
  {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${ADMIN_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      reason: "Expansion — added Data Analytics module",
      new_committed_amount_nanos: "150000000000000000",
      effective_date: "2026-06-01",
    }),
  }
);
const amended = await resp.json();
console.log(`New contract: ${amended.id}, parent: ${amended.parent_contract_id}`);
// [Go]
body, _ := json.Marshal(map[string]string{
    "reason":                      "Expansion — added Data Analytics module",
    "new_committed_amount_nanos":  "150000000000000000",
    "effective_date":              "2026-06-01",
})
req, _ := http.NewRequest("POST",
    "https://api.bill.sh/admin/v1/contracts/"+contractID+"/amend",
    bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()

Contract Lifecycle (Customer 360)

# [curl]
# The contract API is managed internally by the Sales/Finance team.
# External integrations access contract data via the Customer 360 view.

curl https://api.bill.sh/admin/v1/customers/$CUSTOMER_ID \
  -H "Authorization: Bearer $ADMIN_TOKEN"
# Returns linked contracts under the customer's subscriptions
# [Python]
resp = requests.get(
    f"https://api.bill.sh/admin/v1/customers/{customer_id}",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
)
view = resp.json()
# Access linked contracts
for sub in view.get("subscriptions", []):
    if sub.get("contract_id"):
        print(f"Subscription {sub['id']} linked to contract {sub['contract_id']}")
// [Node.js]
const resp = await fetch(
  `https://api.bill.sh/admin/v1/customers/${customerId}`,
  { headers: { "Authorization": `Bearer ${ADMIN_TOKEN}` } }
);
const view = await resp.json();
for (const sub of view.subscriptions ?? []) {
  if (sub.contract_id) {
    console.log(`Subscription ${sub.id} linked to contract ${sub.contract_id}`);
  }
}
// [Go]
req, _ := http.NewRequest("GET",
    "https://api.bill.sh/admin/v1/customers/"+customerID, nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var view map[string]interface{}
json.NewDecoder(resp.Body).Decode(&view)

See the Interactive API Explorer for full contract CRUD endpoints.

Credit Wallets

The credit wallet system supports prepaid top-up credits, automatic drawdown against invoices, and auto-recharge workflows. It’s backed by the TigerBeetle ledger for double-entry accounting.

Overview

Each customer has a prepaid credit wallet — a TigerBeetle account that holds a positive balance representing available credit. When an invoice is generated, the billing engine checks the wallet balance and applies available credits before charging the customer’s payment method.

Issue a Wallet Credit (Admin)

Credits are issued by Finance or Support via an admin-only endpoint. Every credit is:

  • Idempotent — safe to retry with the same idempotency_key
  • Audited — creates an audit log entry with actor, reason, and amount
  • Ledger-backed — debits the platform liability account and credits the customer wallet account in TigerBeetle
# [curl]
curl -X POST https://api.bill.sh/admin/v1/customers/$CUSTOMER_ID/credits \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "amount_nanos": "10000000000000",
    "wallet_account_id": "123456789",
    "platform_account_id": "999888777",
    "description": "Service credit — February outage compensation",
    "reason": "4-hour outage on 2026-02-28 per incident INC-4421",
    "actor_id": "support-agent-001",
    "actor_name": "Jane Smith",
    "idempotency_key": "credit-inc-4421-acme"
  }'
# [Python]
import requests

resp = requests.post(
    f"https://api.bill.sh/admin/v1/customers/{customer_id}/credits",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
    json={
        "amount_nanos": "10000000000000",          # $10.00
        "wallet_account_id": wallet_account_id,
        "platform_account_id": platform_account_id,
        "description": "Service credit — February outage compensation",
        "reason": "4-hour outage on 2026-02-28 per incident INC-4421",
        "actor_id": "support-agent-001",
        "actor_name": "Jane Smith",
        "idempotency_key": "credit-inc-4421-acme",
    },
)
result = resp.json()
print(f"Transfer: {result['transfer_id']}, Audit: {result['audit_entry_id']}")
credit_usd = 10_000_000_000_000 / 1e12
print(f"Issued ${credit_usd:.2f} credit to customer {customer_id}")
// [Node.js]
const resp = await fetch(
  `https://api.bill.sh/admin/v1/customers/${customerId}/credits`,
  {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${ADMIN_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      amount_nanos: "10000000000000",           // $10.00
      wallet_account_id: walletAccountId,
      platform_account_id: platformAccountId,
      description: "Service credit — February outage compensation",
      reason: "4-hour outage on 2026-02-28 per incident INC-4421",
      actor_id: "support-agent-001",
      actor_name: "Jane Smith",
      idempotency_key: "credit-inc-4421-acme",
    }),
  }
);
const result = await resp.json();
console.log(`Transfer: ${result.transfer_id}, Audit: ${result.audit_entry_id}`);
// [Go]
body, _ := json.Marshal(map[string]string{
    "amount_nanos":        "10000000000000",
    "wallet_account_id":   walletAccountID,
    "platform_account_id": platformAccountID,
    "description":         "Service credit — February outage compensation",
    "reason":              "4-hour outage on 2026-02-28 per incident INC-4421",
    "actor_id":            "support-agent-001",
    "actor_name":          "Jane Smith",
    "idempotency_key":     "credit-inc-4421-acme",
})
req, _ := http.NewRequest("POST",
    "https://api.bill.sh/admin/v1/customers/"+customerID+"/credits",
    bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
fmt.Printf("Transfer: %v, Audit: %v\n", result["transfer_id"], result["audit_entry_id"])

amount_nanos: 10000000000000 = $10.00 USD.

Response:

{
  "transfer_id": "01944b1f-0000-7000-8000-000000000020",
  "amount_nanos": "10000000000000",
  "audit_entry_id": "01944b1f-0000-7000-8000-000000000021"
}

View Credit History

# [curl]
curl https://api.bill.sh/admin/v1/customers/$CUSTOMER_ID/credits \
  -H "Authorization: Bearer $ADMIN_TOKEN"
# [Python]
resp = requests.get(
    f"https://api.bill.sh/admin/v1/customers/{customer_id}/credits",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
)
history = resp.json()
for entry in history:
    amount_usd = int(entry["amount_nanos"]) / 1e12
    print(f"{entry['created_at']} — ${amount_usd:.2f} by {entry['actor_name']}: {entry['reason']}")
// [Node.js]
const resp = await fetch(
  `https://api.bill.sh/admin/v1/customers/${customerId}/credits`,
  { headers: { "Authorization": `Bearer ${ADMIN_TOKEN}` } }
);
const history = await resp.json();
for (const entry of history) {
  const amountUsd = (BigInt(entry.amount_nanos) / BigInt(1e12)).toString();
  console.log(`${entry.created_at} — $${amountUsd} by ${entry.actor_name}`);
}
// [Go]
req, _ := http.NewRequest("GET",
    "https://api.bill.sh/admin/v1/customers/"+customerID+"/credits", nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var history []map[string]interface{}
json.NewDecoder(resp.Body).Decode(&history)
for _, entry := range history {
    fmt.Printf("%v — %v by %v\n",
        entry["created_at"], entry["amount_nanos"], entry["actor_name"])
}

Returns audit log entries for all wallet credit actions on this customer.

Credit vs Credit Note

Wallet CreditCredit Note
WhenProactively, before invoiceAfter invoice is finalized
MechanismTigerBeetle balanceNegative invoice
Use caseGoodwill credit, prepaid top-upDispute resolution, partial refund
AccountingDebit platform liability / Credit walletNegative AR entry

Auto-Recharge

When the wallet balance drops below a threshold, the platform can automatically charge the customer’s payment method to top up. This is configured per-customer via the wallet service.

Tax Engine

The billing platform uses a pluggable TaxAdapter trait for tax calculation. This lets you start with zero-tax for development, use a simple flat-rate adapter for testing, and connect to a full tax engine (Avalara, TaxJar) in production — all without changing the billing core.

TaxAdapter Trait

#![allow(unused)]
fn main() {
pub trait TaxAdapter: Send + Sync {
    fn calculate_tax(
        &self,
        line_items: &[InvoiceLineItem],
        customer: &Customer,
    ) -> Result<Option<TaxResult>, BillingError>;
}
}

The adapter receives the invoice line items and customer context. It returns either None (no tax) or a TaxResult with the tax amount and description to add as a line item.

Built-in Adapters

PassThroughTaxAdapter (Default)

Always returns zero tax. Used in development and for B2B customers who handle their own tax reporting.

# Configured via AppState — default in all environments
tax_adapter: Arc::new(PassThroughTaxAdapter)

FlatRateTaxAdapter

Applies a flat percentage (specified in basis points) to all non-tax line items. Respects B2B exemption.

#![allow(unused)]
fn main() {
// 8.5% tax = 850 basis points
let adapter = FlatRateTaxAdapter::new(850);

// B2B customers with a tax_id are exempt
// The adapter checks customer.tax_id.is_some()
}

Production: Avalara / TaxJar

For production deployments, implement the TaxAdapter trait against your preferred tax engine’s API. The adapter receives customer.billing_address and customer.tax_id for jurisdiction and exemption determination.

Apply Tax to an Invoice

Tax is applied to a Draft invoice before finalization:

# [curl]
curl -X POST https://api.bill.sh/admin/v1/invoices/$INV_ID/calculate-tax \
  -H "Authorization: Bearer $ADMIN_TOKEN"
# [Python]
import requests

resp = requests.post(
    f"https://api.bill.sh/admin/v1/invoices/{inv_id}/calculate-tax",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
)
result = resp.json()
if result["tax_applied"]:
    tax_nanos = int(result["tax_line_item"]["amount_nanos"])
    tax_usd = tax_nanos / 1e12
    print(f"Tax applied: ${tax_usd:.2f} — {result['tax_line_item']['description']}")
else:
    print(f"No tax: {result['message']}")
// [Node.js]
const resp = await fetch(
  `https://api.bill.sh/admin/v1/invoices/${invId}/calculate-tax`,
  {
    method: "POST",
    headers: { "Authorization": `Bearer ${ADMIN_TOKEN}` },
  }
);
const result = await resp.json();
if (result.tax_applied) {
  const taxUsd = (BigInt(result.tax_line_item.amount_nanos) / BigInt(1e12)).toString();
  console.log(`Tax applied: $${taxUsd} — ${result.tax_line_item.description}`);
} else {
  console.log("No tax:", result.message);
}
// [Go]
req, _ := http.NewRequest("POST",
    "https://api.bill.sh/admin/v1/invoices/"+invID+"/calculate-tax", nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
if result["tax_applied"].(bool) {
    lineItem := result["tax_line_item"].(map[string]interface{})
    fmt.Printf("Tax applied: %v — %v\n", lineItem["amount_nanos"], lineItem["description"])
} else {
    fmt.Println("No tax:", result["message"])
}

Response (tax applied):

{
  "tax_applied": true,
  "tax_line_item": {
    "id": "01944b1f-0000-7000-8000-000000000050",
    "description": "Sales Tax (8.5%)",
    "amount_nanos": "849150000000",
    "currency": "USD",
    "is_tax": true
  }
}

Response (zero tax):

{
  "tax_applied": false,
  "message": "zero tax — no tax line item added"
}

Idempotency

This endpoint is idempotent. The adapter excludes existing tax line items (is_tax: true) from the taxable subtotal, so calling it twice does not compound tax. It’s safe to call multiple times before finalization.

B2B Tax Exemption

Customers with a tax_id set (VAT number, EIN, etc.) are treated as B2B and exempt from tax collection when using FlatRateTaxAdapter. The customer is responsible for reverse-charge VAT in their jurisdiction.

# [curl]
# Set tax_id on customer creation or update
curl -X POST https://api.bill.sh/admin/v1/customers \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "display_name": "Acme GmbH", "email": "billing@acme.de", "currency": "EUR", "tax_id": "DE123456789" }'
# [Python]
resp = requests.post(
    "https://api.bill.sh/admin/v1/customers",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
    json={
        "display_name": "Acme GmbH",
        "email": "billing@acme.de",
        "currency": "EUR",
        "tax_id": "DE123456789",   # B2B exempt from tax collection
    },
)
customer = resp.json()
print(f"Customer {customer['id']} — tax exempt with VAT: DE123456789")
// [Node.js]
const resp = await fetch("https://api.bill.sh/admin/v1/customers", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${ADMIN_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    display_name: "Acme GmbH",
    email: "billing@acme.de",
    currency: "EUR",
    tax_id: "DE123456789",  // B2B exempt from tax collection
  }),
});
const customer = await resp.json();
console.log(`Customer ${customer.id} — tax exempt`);
// [Go]
body, _ := json.Marshal(map[string]string{
    "display_name": "Acme GmbH",
    "email":        "billing@acme.de",
    "currency":     "EUR",
    "tax_id":       "DE123456789",
})
req, _ := http.NewRequest("POST", "https://api.bill.sh/admin/v1/customers",
    bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var customer map[string]interface{}
json.NewDecoder(resp.Body).Decode(&customer)
fmt.Printf("Customer %v — tax exempt\n", customer["id"])

Dunning

Dunning is the process of automatically retrying failed payments. When a Stripe charge fails, the billing platform schedules up to 4 retry attempts before marking the subscription as past due and eventually cancelling.

Retry Schedule

AttemptDelay from failureAction
1+3 daysRetry charge + notify customer
2+7 daysRetry charge + escalate notification
3+14 daysRetry charge + warn of suspension
4+21 daysRetry charge + suspend if failed

After attempt 4 fails, the subscription moves to PastDue and eventually Cancelled.

How It Works

  1. Stripe webhook delivers payment_intent.payment_failed or invoice.payment_failed
  2. The DunningService creates a DunningSchedule for the subscription
  3. A background job (cron) calls check_due_attempts() daily and triggers retries
  4. On success: subscription returns to Active, schedule is cleared
  5. On exhaustion: subscription transitions to PastDue → (admin action) → Paused or Cancelled

Dunning Schedule

Each schedule tracks:

  • subscription_id — the subscription in arrears
  • invoice_id — the outstanding invoice
  • attempt_count — how many retries have been made (1-4)
  • next_attempt_at — when to fire the next retry
  • statusActive, Resolved, Exhausted

Webhook Integration

Configure your Stripe webhook endpoint to receive payment failure events:

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

# Verify the webhook is registered
curl https://api.bill.sh/admin/v1/webhooks \
  -H "Authorization: Bearer $ADMIN_TOKEN"
# [Python]
# Listen for dunning-related webhook events
import hmac
import hashlib
from flask import Flask, request, abort

app = Flask(__name__)

WEBHOOK_SECRET = "your-webhook-secret"

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

    # Verify signature
    expected = hmac.new(
        WEBHOOK_SECRET.encode(), payload, hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(expected, signature):
        abort(400, "Invalid signature")

    event = request.get_json()
    if event["type"] == "subscription.past_due":
        sub_id = event["data"]["subscription_id"]
        customer_id = event["data"]["customer_id"]
        print(f"Subscription {sub_id} for customer {customer_id} is past due")
        # Send notification email, update your DB, etc.
    elif event["type"] == "payment.failed":
        invoice_id = event["data"]["invoice_id"]
        attempt = event["data"].get("dunning_attempt", 1)
        print(f"Payment failed for invoice {invoice_id} (attempt {attempt})")

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

const app = express();

app.post("/billing/webhooks", express.raw({ type: "application/json" }), (req, res) => {
  const signature = req.headers["x-billing-signature"] ?? "";
  const expected = createHmac("sha256", process.env.WEBHOOK_SECRET)
    .update(req.body)
    .digest("hex");

  if (!timingSafeEqual(Buffer.from(expected), Buffer.from(signature))) {
    return res.status(400).send("Invalid signature");
  }

  const event = JSON.parse(req.body.toString());
  if (event.type === "subscription.past_due") {
    const { subscription_id, customer_id } = event.data;
    console.log(`Subscription ${subscription_id} for customer ${customer_id} is past due`);
    // Update your DB, send notification, etc.
  } else if (event.type === "payment.failed") {
    const { invoice_id } = event.data;
    console.log(`Payment failed for invoice ${invoice_id}`);
  }

  res.status(200).send("ok");
});
// [Go]
import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "io"
    "net/http"
)

func handleBillingWebhook(w http.ResponseWriter, r *http.Request) {
    payload, _ := io.ReadAll(r.Body)
    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 map[string]interface{}
    json.Unmarshal(payload, &event)

    switch event["type"] {
    case "subscription.past_due":
        data := event["data"].(map[string]interface{})
        log.Printf("Subscription %v past due for customer %v",
            data["subscription_id"], data["customer_id"])
    case "payment.failed":
        data := event["data"].(map[string]interface{})
        log.Printf("Payment failed for invoice %v", data["invoice_id"])
    }

    w.WriteHeader(http.StatusOK)
}

The endpoint verifies the HMAC-SHA256 signature against STRIPE_WEBHOOK_SECRET before processing. Invalid signatures return 400.

Manual Recovery

Once dunning is exhausted and the customer resolves the payment manually:

# [curl]
# Mark the invoice as paid
curl -X POST https://api.bill.sh/admin/v1/invoices/$INV_ID/pay \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "actor_id": "finance-001", "actor_name": "Alex Finance" }'

# Resume the subscription
curl -X POST https://api.bill.sh/admin/v1/subscriptions/$SUB_ID/resume \
  -H "Authorization: Bearer $ADMIN_TOKEN"
# [Python]
# Mark the invoice as paid (manual payment received)
pay_resp = requests.post(
    f"https://api.bill.sh/admin/v1/invoices/{inv_id}/pay",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
    json={"actor_id": "finance-001", "actor_name": "Alex Finance"},
)
print("Invoice paid:", pay_resp.json()["status"])

# Resume the subscription
resume_resp = requests.post(
    f"https://api.bill.sh/admin/v1/subscriptions/{sub_id}/resume",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
)
print("Subscription resumed:", resume_resp.json()["status"])
// [Node.js]
// Mark invoice as paid
const payResp = await fetch(
  `https://api.bill.sh/admin/v1/invoices/${invId}/pay`,
  {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${ADMIN_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ actor_id: "finance-001", actor_name: "Alex Finance" }),
  }
);
console.log("Invoice paid:", (await payResp.json()).status);

// Resume the subscription
const resumeResp = await fetch(
  `https://api.bill.sh/admin/v1/subscriptions/${subId}/resume`,
  {
    method: "POST",
    headers: { "Authorization": `Bearer ${ADMIN_TOKEN}` },
  }
);
console.log("Subscription resumed:", (await resumeResp.json()).status);
// [Go]
// Mark invoice as paid
payBody, _ := json.Marshal(map[string]string{
    "actor_id":   "finance-001",
    "actor_name": "Alex Finance",
})
req, _ := http.NewRequest("POST",
    "https://api.bill.sh/admin/v1/invoices/"+invID+"/pay",
    bytes.NewReader(payBody))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
http.DefaultClient.Do(req)

// Resume the subscription
req2, _ := http.NewRequest("POST",
    "https://api.bill.sh/admin/v1/subscriptions/"+subID+"/resume", nil)
req2.Header.Set("Authorization", "Bearer "+adminToken)
http.DefaultClient.Do(req2)

Spend Alerts

Spend alerts let you set thresholds on customer spending and take action when those thresholds are hit. There are two alert types: SoftLimit (notify) and HardLimit (block).

Alert Types

TypeBehavior when triggered
SoftLimitSends a notification to the customer and fires a webhook event. Billing continues.
HardLimitBlocks new usage charges until the alert is reset. Returns an error if the customer tries to incur new charges.

Use Cases

  • Spend notifications: Alert customers at 80% of their expected monthly spend
  • Budget caps: Block usage for free-tier users beyond their quota
  • Enterprise spend controls: Finance-set hard limits on department cost centers
  • Anomaly detection: Alert when a customer’s spend spikes unexpectedly

Create a Spend Alert

# [curl]
curl -X POST https://api.bill.sh/admin/v1/customers/$CUSTOMER_ID/alerts \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "alert_type": "SoftLimit",
    "threshold_nanos": "100000000000000"
  }'
# [Python]
import requests

# Create a SoftLimit at $100
resp = requests.post(
    f"https://api.bill.sh/admin/v1/customers/{customer_id}/alerts",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
    json={
        "alert_type": "SoftLimit",
        "threshold_nanos": "100000000000000",  # $100.00
    },
)
alert = resp.json()
print(f"Alert {alert['id']} — {alert['alert_type']} at threshold {alert['threshold_nanos']}")

# Also set a HardLimit at $125 (25% buffer)
hard_resp = requests.post(
    f"https://api.bill.sh/admin/v1/customers/{customer_id}/alerts",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
    json={
        "alert_type": "HardLimit",
        "threshold_nanos": "125000000000000",  # $125.00
    },
)
hard_alert = hard_resp.json()
print(f"Hard limit set: {hard_alert['id']}")
// [Node.js]
// Create a SoftLimit at $100
const softResp = await fetch(
  `https://api.bill.sh/admin/v1/customers/${customerId}/alerts`,
  {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${ADMIN_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      alert_type: "SoftLimit",
      threshold_nanos: "100000000000000",  // $100.00
    }),
  }
);
const softAlert = await softResp.json();
console.log(`SoftLimit alert: ${softAlert.id}`);

// Also set a HardLimit at $125
const hardResp = await fetch(
  `https://api.bill.sh/admin/v1/customers/${customerId}/alerts`,
  {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${ADMIN_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      alert_type: "HardLimit",
      threshold_nanos: "125000000000000",  // $125.00
    }),
  }
);
const hardAlert = await hardResp.json();
console.log(`HardLimit alert: ${hardAlert.id}`);
// [Go]
// Create a SoftLimit at $100
body, _ := json.Marshal(map[string]string{
    "alert_type":       "SoftLimit",
    "threshold_nanos":  "100000000000000",
})
req, _ := http.NewRequest("POST",
    "https://api.bill.sh/admin/v1/customers/"+customerID+"/alerts",
    bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var alert map[string]interface{}
json.NewDecoder(resp.Body).Decode(&alert)
fmt.Printf("Alert %v — %v\n", alert["id"], alert["alert_type"])

threshold_nanos: 100000000000000 = $100.00 USD.

Response:

{
  "id": "01944b1f-0000-7000-8000-000000000030",
  "customer_id": "01944b1f-0000-7000-8000-000000000001",
  "alert_type": "SoftLimit",
  "threshold_nanos": "100000000000000",
  "triggered": false,
  "created_at": "2026-02-28T10:00:00Z"
}

List Alerts for a Customer

# [curl]
curl https://api.bill.sh/admin/v1/customers/$CUSTOMER_ID/alerts \
  -H "Authorization: Bearer $ADMIN_TOKEN"
# [Python]
resp = requests.get(
    f"https://api.bill.sh/admin/v1/customers/{customer_id}/alerts",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
)
alerts = resp.json()
for alert in alerts:
    threshold_usd = int(alert["threshold_nanos"]) / 1e12
    status = "TRIGGERED" if alert["triggered"] else "active"
    print(f"{alert['alert_type']:10} ${threshold_usd:.2f} — {status}")
// [Node.js]
const resp = await fetch(
  `https://api.bill.sh/admin/v1/customers/${customerId}/alerts`,
  { headers: { "Authorization": `Bearer ${ADMIN_TOKEN}` } }
);
const alerts = await resp.json();
for (const alert of alerts) {
  const thresholdUsd = (BigInt(alert.threshold_nanos) / BigInt(1e12)).toString();
  console.log(`${alert.alert_type}: $${thresholdUsd} — ${alert.triggered ? "TRIGGERED" : "active"}`);
}
// [Go]
req, _ := http.NewRequest("GET",
    "https://api.bill.sh/admin/v1/customers/"+customerID+"/alerts", nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var alerts []map[string]interface{}
json.NewDecoder(resp.Body).Decode(&alerts)
for _, alert := range alerts {
    fmt.Printf("%v — triggered: %v\n", alert["alert_type"], alert["triggered"])
}

How check_spend() Works

The platform calls check_spend(customer_id, current_spend_nanos) before every metered charge. The SpendAlertService:

  1. Fetches all active alerts for the customer
  2. Compares current_spend_nanos against each alert’s threshold_nanos
  3. For SoftLimit: fires a notification webhook if threshold crossed and not already triggered
  4. For HardLimit: returns an error that blocks the charge if threshold is met or exceeded
  5. Marks the alert as triggered = true

Reset an Alert

After investigating or when the customer has cleared their balance, reset the alert to allow billing to resume:

# [curl]
curl -X POST https://api.bill.sh/admin/v1/customers/$CUSTOMER_ID/alerts/$ALERT_ID/reset \
  -H "Authorization: Bearer $ADMIN_TOKEN"
# [Python]
resp = requests.post(
    f"https://api.bill.sh/admin/v1/customers/{customer_id}/alerts/{alert_id}/reset",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
)
print(resp.json())  # {"triggered": false, ...}
// [Node.js]
const resp = await fetch(
  `https://api.bill.sh/admin/v1/customers/${customerId}/alerts/${alertId}/reset`,
  {
    method: "POST",
    headers: { "Authorization": `Bearer ${ADMIN_TOKEN}` },
  }
);
const result = await resp.json();
console.log("Alert reset, triggered:", result.triggered); // false
// [Go]
req, _ := http.NewRequest("POST",
    "https://api.bill.sh/admin/v1/customers/"+customerID+"/alerts/"+alertID+"/reset",
    nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()

This sets triggered = false and clears the blocked state for HardLimit alerts.

Typical Workflow

  1. Create a HardLimit alert at the customer’s budget ceiling
  2. Create a SoftLimit alert at 80% of the ceiling for advance warning
  3. Customer approaches limit → SoftLimit fires notification
  4. Customer hits limit → HardLimit blocks further charges
  5. Customer pays bill or requests increase → Admin resets the alert
  6. Billing resumes normally

Idempotency

All mutating operations in the billing API accept an Idempotency-Key header. This makes your integration bulletproof — network failures, timeouts, and retries are safe.

How It Works

  1. Client sends a request with Idempotency-Key: <your-key>
  2. Server processes the request and stores the response (keyed by method + path + idempotency_key)
  3. If the same key is received again within 24 hours, the server returns the cached response immediately
  4. The response includes X-Idempotency-Replayed: true when a cached response is served

Scoping

The idempotency key is scoped to method + path + key. This means:

  • POST /v1/subscriptions with key abc and POST /v1/invoices/$ID/finalize with key abc are separate — different paths, no conflict
  • POST /v1/subscriptions with key abc twice is idempotent — same method, path, and key

Using Idempotency Keys

# [curl]
# First attempt
curl -X POST https://api.bill.sh/v1/subscriptions \
  -H "Idempotency-Key: create-acme-startup-sub-2026-02" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "customer_id": "...", "plan_id": "...", "currency": "USD" }'

# Retry after network timeout — returns same response
curl -X POST https://api.bill.sh/v1/subscriptions \
  -H "Idempotency-Key: create-acme-startup-sub-2026-02" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "customer_id": "...", "plan_id": "...", "currency": "USD" }'
# Response header: X-Idempotency-Replayed: true
# [Python]
import requests

def create_subscription_safe(customer_id, plan_id, idempotency_key):
    """Create a subscription with automatic retry on network failure."""
    payload = {
        "customer_id": customer_id,
        "plan_id": plan_id,
        "currency": "USD",
    }
    headers = {
        "Authorization": f"Bearer {TOKEN}",
        "Idempotency-Key": idempotency_key,
    }
    # On any network error, retry — the idempotency key ensures no duplicates
    for attempt in range(3):
        try:
            resp = requests.post(
                "https://api.bill.sh/v1/subscriptions",
                headers=headers,
                json=payload,
                timeout=10,
            )
            resp.raise_for_status()
            replayed = resp.headers.get("X-Idempotency-Replayed", "false")
            if replayed == "true":
                print(f"Replayed cached response (attempt {attempt + 1})")
            return resp.json()
        except requests.RequestException as e:
            if attempt == 2:
                raise
            print(f"Attempt {attempt + 1} failed: {e}, retrying...")

sub = create_subscription_safe(
    customer_id="01944b1f-0000-7000-8000-000000000001",
    plan_id="01944b1f-0000-7000-8000-000000000002",
    idempotency_key=f"create-sub-{customer_id}-{plan_id}",
)
// [Node.js]
async function createSubscriptionSafe(customerId, planId, idempotencyKey) {
  const payload = {
    customer_id: customerId,
    plan_id: planId,
    currency: "USD",
  };
  const headers = {
    "Authorization": `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": idempotencyKey,
  };

  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      const resp = await fetch("https://api.bill.sh/v1/subscriptions", {
        method: "POST",
        headers,
        body: JSON.stringify(payload),
        signal: AbortSignal.timeout(10_000),
      });
      if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
      const replayed = resp.headers.get("X-Idempotency-Replayed");
      if (replayed === "true") console.log(`Replayed (attempt ${attempt + 1})`);
      return await resp.json();
    } catch (err) {
      if (attempt === 2) throw err;
      console.log(`Attempt ${attempt + 1} failed: ${err.message}, retrying...`);
    }
  }
}

const subscription = await createSubscriptionSafe(
  "01944b1f-0000-7000-8000-000000000001",
  "01944b1f-0000-7000-8000-000000000002",
  `create-sub-${customerId}-${planId}`
);
// [Go]
func createSubscriptionSafe(customerID, planID, idempotencyKey string) (map[string]interface{}, error) {
    body, _ := json.Marshal(map[string]string{
        "customer_id": customerID,
        "plan_id":     planID,
        "currency":    "USD",
    })

    for attempt := 0; attempt < 3; attempt++ {
        req, _ := http.NewRequest("POST",
            "https://api.bill.sh/v1/subscriptions",
            bytes.NewReader(body))
        req.Header.Set("Authorization", "Bearer "+token)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idempotencyKey)

        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            if attempt == 2 {
                return nil, err
            }
            continue
        }
        defer resp.Body.Close()

        if resp.Header.Get("X-Idempotency-Replayed") == "true" {
            log.Printf("Replayed cached response (attempt %d)", attempt+1)
        }
        var result map[string]interface{}
        json.NewDecoder(resp.Body).Decode(&result)
        return result, nil
    }
    return nil, fmt.Errorf("all attempts failed")
}

Key Naming Conventions

Good idempotency keys should be:

  • Deterministic — derived from the operation, not random
  • Unique per operation — reuse only if you intend to retry the exact same operation
  • Human-readable — helps with debugging
create-sub-{customer_id}-{plan_id}
finalize-inv-{invoice_id}-{billing_period}
credit-ticket-{ticket_id}

TTL

Responses are cached for 24 hours. After 24 hours, the same key can be reused for a fresh operation.

What Happens if the Same Key Is Reused for a Different Body?

The cached response is returned without checking the request body. The billing platform assumes that if you’re sending the same method + path + key, you intend to retry the same operation. If you need to create a different resource, use a different key.

Which Endpoints Support Idempotency?

All POST, DELETE, and PATCH endpoints. GET requests are always idempotent by nature and do not use the store.

The middleware self-selects on mutating HTTP methods — GET and HEAD bypass it entirely.

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"
  }
}

Usage-Based SaaS: AI API Billing

A complete end-to-end example for an AI API platform that charges per token. This walkthrough covers everything from onboarding a new customer to issuing their first invoice.

Scenario: You’re running an LLM API service. You charge $0.000002 per input token and $0.000006 per output token. Customers send requests, you track token usage, and bill monthly.


Step 1: Create a Product and Per-Token Plan

import requests, uuid

BASE_URL = "https://api.bill.sh"
ADMIN_TOKEN = "bsk_live_your_admin_key"

headers = {"Authorization": f"Bearer {ADMIN_TOKEN}"}

# Create the product
product = requests.post(f"{BASE_URL}/admin/v1/catalog/products", headers=headers, json={
    "name": "LLM API",
    "description": "Usage-based large language model API",
    "tags": ["llm", "api", "usage-based"],
}).json()

# Create a plan with 14-day trial
plan = requests.post(f"{BASE_URL}/admin/v1/catalog/plans", headers=headers, json={
    "name": "Pay-As-You-Go",
    "description": "No monthly fee — pay only for what you use",
    "billing_cadence": "Monthly",
    "trial_days": 14,
}).json()
plan_id = plan["id"]

# Add input token SKU — $0.000002 per token = 2_000_000 nanos
requests.post(f"{BASE_URL}/admin/v1/catalog/skus", headers=headers, json={
    "plan_id": plan_id,
    "name": "Input Tokens",
    "pricing_model": "PerUnit",
    "unit_amount_nanos": "2000000",       # $0.000002 per token
    "meter_event_type": "llm.completion",
    "meter_property": "input_tokens",
    "currency": "USD",
})

# Add output token SKU — $0.000006 per token = 6_000_000 nanos
requests.post(f"{BASE_URL}/admin/v1/catalog/skus", headers=headers, json={
    "plan_id": plan_id,
    "name": "Output Tokens",
    "pricing_model": "PerUnit",
    "unit_amount_nanos": "6000000",       # $0.000006 per token
    "meter_event_type": "llm.completion",
    "meter_property": "output_tokens",
    "currency": "USD",
})
print(f"Plan ready: {plan_id}")

Step 2: Onboard a Customer

# Create the customer
customer = requests.post(f"{BASE_URL}/admin/v1/customers", headers=headers, json={
    "display_name": "NeuralWorks Inc",
    "email": "billing@neuralworks.io",
    "currency": "USD",
    "account_type": "Organization",
}).json()
customer_id = customer["id"]

# Subscribe them to the plan (with 14-day trial)
subscription = requests.post(
    f"{BASE_URL}/v1/subscriptions",
    headers={**headers, "Idempotency-Key": f"sub-{customer_id}-payg"},
    json={
        "customer_id": customer_id,
        "plan_id": plan_id,
        "currency": "USD",
        "trial_days": 14,
    },
).json()
subscription_id = subscription["id"]
print(f"Customer {customer_id} subscribed: {subscription_id} ({subscription['status']})")

Step 3: Send Usage Events in Bulk

Each time a customer makes an API call, emit an event. In production you’d batch these and flush every few seconds.

def emit_completion_event(customer_id: str, subscription_id: str,
                           model: str, input_tokens: int, output_tokens: int) -> str:
    """Emit a usage event for an LLM completion. Returns the event ID."""
    event_id = f"llm-{uuid.uuid4()}"
    requests.post(
        f"{BASE_URL}/v1/events",
        headers={**headers, "Idempotency-Key": event_id},
        json={
            "id": event_id,
            "event_type": "llm.completion",
            "customer_id": customer_id,
            "subscription_id": subscription_id,
            "timestamp": "2026-02-28T14:30:00Z",
            "properties": {
                "model": model,
                "input_tokens": input_tokens,
                "output_tokens": output_tokens,
                "request_id": str(uuid.uuid4()),
            },
        },
    )
    return event_id

# Simulate a day of API usage
# A typical customer might make 5,000 calls/day
# Each call: ~800 input tokens, ~200 output tokens
CALLS_PER_DAY = 5000
total_input = 0
total_output = 0

# Batch up to 100 events at a time for efficiency
batch = []
for i in range(CALLS_PER_DAY):
    input_tokens = 800 + (i % 400)   # 800-1200 input tokens
    output_tokens = 150 + (i % 200)  # 150-350 output tokens
    total_input += input_tokens
    total_output += output_tokens
    batch.append({
        "id": f"llm-day1-{i:05d}",
        "event_type": "llm.completion",
        "customer_id": customer_id,
        "subscription_id": subscription_id,
        "timestamp": "2026-02-28T14:30:00Z",
        "properties": {
            "model": "gpt-4o",
            "input_tokens": input_tokens,
            "output_tokens": output_tokens,
        },
    })
    if len(batch) == 100:
        requests.post(f"{BASE_URL}/v1/events/batch",
                      headers=headers, json={"events": batch})
        batch = []

if batch:
    requests.post(f"{BASE_URL}/v1/events/batch",
                  headers=headers, json={"events": batch})

input_cost = total_input * 0.000002
output_cost = total_output * 0.000006
print(f"Day 1: {total_input:,} input tokens (${input_cost:.2f}) + "
      f"{total_output:,} output tokens (${output_cost:.2f}) = "
      f"${input_cost + output_cost:.2f}")

Step 4: Query Usage Summary

Check the running meter values at any point during the billing period:

usage = requests.get(
    f"{BASE_URL}/v1/subscriptions/{subscription_id}/usage",
    headers=headers,
).json()

print(f"Billing period: {usage['period_start']} → {usage['period_end']}")
for meter in usage["meters"]:
    print(f"  {meter['meter_id']}: {int(meter['value']):,} {meter.get('unit', 'units')}")

# Calculate estimated bill
input_meter = next(m for m in usage["meters"] if "input" in m["meter_id"])
output_meter = next(m for m in usage["meters"] if "output" in m["meter_id"])
est_cost = int(input_meter["value"]) * 0.000002 + int(output_meter["value"]) * 0.000006
print(f"\nEstimated invoice: ${est_cost:.2f}")

Step 5: Generate and Finalize the Invoice

At the end of the billing period (or on-demand):

# Trigger billing
requests.post(
    f"{BASE_URL}/admin/v1/subscriptions/{subscription_id}/bill",
    headers={**headers, "Idempotency-Key": f"bill-{subscription_id}-2026-02"},
)

# Find the Draft invoice
invoices = requests.get(
    f"{BASE_URL}/admin/v1/invoices",
    headers=headers,
    params={"customer_id": customer_id, "status": "Draft"},
).json()
invoice_id = invoices[0]["id"]

# Apply tax if configured
requests.post(
    f"{BASE_URL}/admin/v1/invoices/{invoice_id}/calculate-tax",
    headers=headers,
)

# Finalize — assigns invoice number, begins payment collection
invoice = requests.post(
    f"{BASE_URL}/admin/v1/invoices/{invoice_id}/finalize",
    headers={**headers, "Idempotency-Key": f"finalize-{invoice_id}"},
).json()

total_usd = int(invoice["total_nanos"]) / 1e12
print(f"Invoice {invoice['invoice_number']} — ${total_usd:.2f} — {invoice['status']}")
for line in invoice.get("line_items", []):
    if not line.get("is_tax"):
        line_usd = int(line["amount_nanos"]) / 1e12
        print(f"  {line['description']}: ${line_usd:.2f}")

What’s Next?

  • Set spend alerts so customers know when they’re approaching their budget
  • Use credit wallets for prepaid top-ups
  • Configure webhooks to notify customers when their invoice is ready

Seat-Based Pricing

A complete example for a SaaS product that charges per user seat. Covers subscribing with 10 seats, upgrading to 25 seats mid-period with proration, and generating an invoice that shows the prorated credit.

Scenario: Your product charges $49/seat/month. A company starts with 10 seats and grows to 25 seats on day 15 of their billing period.


Step 1: Create a Per-Seat Plan

const BASE_URL = "https://api.bill.sh";
const ADMIN_TOKEN = process.env.ADMIN_TOKEN;
const headers = {
  "Authorization": `Bearer ${ADMIN_TOKEN}`,
  "Content-Type": "application/json",
};

// Create the product
const productResp = await fetch(`${BASE_URL}/admin/v1/catalog/products`, {
  method: "POST", headers,
  body: JSON.stringify({
    name: "Collaboration Suite",
    description: "Seat-based team collaboration platform",
    tags: ["saas", "seats"],
  }),
});
const product = await productResp.json();

// Create a monthly plan
const planResp = await fetch(`${BASE_URL}/admin/v1/catalog/plans`, {
  method: "POST", headers,
  body: JSON.stringify({
    name: "Teams",
    description: "Per-seat monthly billing",
    billing_cadence: "Monthly",
    trial_days: 0,
  }),
});
const plan = await planResp.json();
const planId = plan.id;

// Per-seat SKU: $49/seat = 49_000_000_000_000 nanos
const skuResp = await fetch(`${BASE_URL}/admin/v1/catalog/skus`, {
  method: "POST", headers,
  body: JSON.stringify({
    plan_id: planId,
    name: "User Seat",
    pricing_model: "PerUnit",
    unit_amount_nanos: "49000000000000",  // $49.00 per seat
    currency: "USD",
    // No meter_event_type — quantity is set explicitly on subscription
  }),
});
const sku = await skuResp.json();
console.log(`Plan ready: ${planId}, SKU: ${sku.id}`);

Step 2: Subscribe with 10 Seats

// Create the customer
const customerResp = await fetch(`${BASE_URL}/admin/v1/customers`, {
  method: "POST", headers,
  body: JSON.stringify({
    display_name: "Sprocket Co",
    email: "billing@sprocket.co",
    currency: "USD",
    account_type: "Organization",
  }),
});
const customer = await customerResp.json();
const customerId = customer.id;

// Subscribe with 10 seats
const subResp = await fetch(`${BASE_URL}/v1/subscriptions`, {
  method: "POST",
  headers: { ...headers, "Idempotency-Key": `sub-${customerId}-teams-10` },
  body: JSON.stringify({
    customer_id: customerId,
    plan_id: planId,
    currency: "USD",
    quantity: 10,        // 10 seats × $49 = $490/month
  }),
});
const subscription = await subResp.json();
const subscriptionId = subscription.id;
console.log(`Subscribed with 10 seats: ${subscriptionId} (${subscription.status})`);
console.log(`Period: ${subscription.period_start} → ${subscription.period_end}`);

Step 3: Upgrade to 25 Seats Mid-Period

On day 15, the customer adds 15 more seats. The billing platform automatically calculates proration:

// Upgrade quantity mid-period
// The platform will:
//  1. Credit the remaining days of 10-seat pricing
//  2. Charge the remaining days of 25-seat pricing
const upgradeResp = await fetch(
  `${BASE_URL}/v1/subscriptions/${subscriptionId}/quantity`,
  {
    method: "POST",
    headers: { ...headers, "Idempotency-Key": `upgrade-${subscriptionId}-25seats` },
    body: JSON.stringify({
      quantity: 25,
      prorate: true,
      effective_date: "2026-03-15T00:00:00Z",  // Day 15 of billing period
    }),
  }
);
const upgraded = await upgradeResp.json();
console.log(`Upgraded to ${upgraded.quantity} seats`);
console.log("Proration line items:");
for (const line of upgraded.proration_preview ?? []) {
  const amountUsd = (BigInt(line.amount_nanos) / BigInt(1e12)).toString();
  console.log(`  ${line.description}: $${amountUsd}`);
}
// Example output:
//   Credit: 10 seats × 16 remaining days: -$26.13
//   Charge: 25 seats × 16 remaining days: +$65.32
//   Net proration: +$39.19

Step 4: Generate Invoice with Prorated Credit

At the end of the billing period, the invoice will include both the prorated credit and the full new seat charge:

// Trigger billing
await fetch(
  `${BASE_URL}/admin/v1/subscriptions/${subscriptionId}/bill`,
  {
    method: "POST",
    headers: { ...headers, "Idempotency-Key": `bill-${subscriptionId}-march` },
  }
);

// Find and finalize the Draft invoice
const invoicesResp = await fetch(
  `${BASE_URL}/admin/v1/invoices?customer_id=${customerId}&status=Draft`,
  { headers }
);
const invoices = await invoicesResp.json();
const invoiceId = invoices[0].id;

const finalizeResp = await fetch(
  `${BASE_URL}/admin/v1/invoices/${invoiceId}/finalize`,
  {
    method: "POST",
    headers: { ...headers, "Idempotency-Key": `finalize-${invoiceId}` },
  }
);
const invoice = await finalizeResp.json();

const totalUsd = (BigInt(invoice.total_nanos) / BigInt(1e12)).toString();
console.log(`\nInvoice ${invoice.invoice_number} — $${totalUsd}`);
console.log("Line items:");
for (const line of invoice.line_items) {
  const lineUsd = (BigInt(line.amount_nanos) / BigInt(1e12)).toString();
  const sign = line.amount_nanos.startsWith("-") ? "" : "+";
  console.log(`  ${line.description}: ${sign}$${lineUsd}`);
}
// Example output:
//   Invoice INV-000023 — $515.19
//   10 seats × 14 days (Mar 1-14): +$219.35
//   Credit: 10 seats × 16 days proration: -$26.13
//   25 seats × 16 days (Mar 15-31): +$322.58
//   Total: $515.80  (with rounding)

Key Takeaways

  • Set quantity on subscription creation for seat-based plans
  • Use prorate: true on quantity changes to get automatic mid-period credits
  • The invoice line items clearly show the credit and new charge so customers understand their bill
  • For annual plans, consider billing_cadence: "Annual" with monthly proration to avoid large surprises

Enterprise Contract Onboarding

A complete walkthrough of an enterprise deal: ramp pricing, true-up billing, and contract amendment when the deal expands. All examples use curl.

Scenario: Acme Corp signs a $325k annual contract with ramp pricing:

  • Q1: $50k (land and expand)
  • Q2: $75k (ramping up)
  • Q3-Q4: $100k/quarter (full rate)

Step 1: Create the Customer and Contract

# Create the enterprise customer with legal name for invoice header
CUSTOMER=$(curl -s -X POST https://api.bill.sh/admin/v1/customers \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "display_name": "Acme Corp",
    "legal_name": "Acme Corporation, Inc.",
    "email": "billing@acmecorp.com",
    "currency": "USD",
    "account_type": "Organization",
    "payment_terms_days": 30,
    "tax_id": "US-EIN-12-3456789"
  }')

CUSTOMER_ID=$(echo $CUSTOMER | jq -r '.id')
echo "Customer: $CUSTOMER_ID"
# Create the contract with ramp terms
CONTRACT=$(curl -s -X POST https://api.bill.sh/admin/v1/contracts \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{
    \"customer_id\": \"$CUSTOMER_ID\",
    \"name\": \"Acme Corp — Enterprise Platform 2026\",
    \"start_date\": \"2026-01-01\",
    \"end_date\": \"2026-12-31\",
    \"terms\": [
      {
        \"phase\": 1,
        \"start_date\": \"2026-01-01\",
        \"end_date\": \"2026-03-31\",
        \"committed_amount_nanos\": \"50000000000000000\",
        \"description\": \"Q1 — Ramp: \$50k\"
      },
      {
        \"phase\": 2,
        \"start_date\": \"2026-04-01\",
        \"end_date\": \"2026-06-30\",
        \"committed_amount_nanos\": \"75000000000000000\",
        \"description\": \"Q2 — Ramp: \$75k\"
      },
      {
        \"phase\": 3,
        \"start_date\": \"2026-07-01\",
        \"end_date\": \"2026-12-31\",
        \"committed_amount_nanos\": \"200000000000000000\",
        \"description\": \"Q3-Q4 — Full rate: \$100k/quarter\"
      }
    ]
  }")

CONTRACT_ID=$(echo $CONTRACT | jq -r '.id')
echo "Contract: $CONTRACT_ID (status: $(echo $CONTRACT | jq -r '.status'))"

Enterprise customers typically get a custom plan with rate overrides. Here we subscribe them to the Enterprise plan and link it to the contract:

# Create the subscription linked to the contract
SUB=$(curl -s -X POST https://api.bill.sh/v1/subscriptions \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: sub-acme-enterprise-2026" \
  -d "{
    \"customer_id\": \"$CUSTOMER_ID\",
    \"plan_id\": \"$ENTERPRISE_PLAN_ID\",
    \"contract_id\": \"$CONTRACT_ID\",
    \"currency\": \"USD\",
    \"start_date\": \"2026-01-01\"
  }")

SUB_ID=$(echo $SUB | jq -r '.id')
echo "Subscription: $SUB_ID ($(echo $SUB | jq -r '.status'))"

Step 3: Monthly Billing with Commit Draw-Down

Each month, trigger billing. The platform draws down from the quarterly commit before charging the payment method:

# End of January — trigger billing
curl -s -X POST "https://api.bill.sh/admin/v1/subscriptions/$SUB_ID/bill" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Idempotency-Key: "bill-acme-2026-01"

# List the resulting Draft invoice
INVOICES=$(curl -s "https://api.bill.sh/admin/v1/invoices?customer_id=$CUSTOMER_ID&status=Draft" \
  -H "Authorization: Bearer $ADMIN_TOKEN")
INV_ID=$(echo $INVOICES | jq -r '.[0].id')
echo "Draft invoice: $INV_ID"

# Finalize the invoice
INV=$(curl -s -X POST "https://api.bill.sh/admin/v1/invoices/$INV_ID/finalize" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Idempotency-Key: finalize-$INV_ID")
echo "Invoice: $(echo $INV | jq -r '.invoice_number') — \$$(echo $INV | jq -r '.total_nanos | tonumber / 1e12 | tostring')"

Step 4: True-Up at Quarter End

At Q1 end, if the customer used less than their $50k commit, a true-up charge covers the difference:

# Request a true-up calculation for Q1
TRUE_UP=$(curl -s -X POST \
  "https://api.bill.sh/admin/v1/contracts/$CONTRACT_ID/true-up" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "period_start": "2026-01-01",
    "period_end": "2026-03-31"
  }')

SHORTFALL=$(echo $TRUE_UP | jq -r '.shortfall_nanos')
if [ "$SHORTFALL" != "0" ] && [ "$SHORTFALL" != "null" ]; then
  SHORTFALL_USD=$(echo "scale=2; $SHORTFALL / 1000000000000" | bc)
  echo "True-up required: \$$SHORTFALL_USD"
  # The true-up creates an invoice line item automatically
  TRUE_UP_INV_ID=$(echo $TRUE_UP | jq -r '.invoice_id')
  echo "True-up invoice: $TRUE_UP_INV_ID"
else
  echo "No true-up required — customer met commit"
fi

Step 5: Contract Amendment When Deal Expands

In June, Acme adds a Data Analytics module, expanding the contract to $450k total. Amendment is immutable — it creates a new contract linked to the old one:

# Amend the contract
AMENDED=$(curl -s -X POST \
  "https://api.bill.sh/admin/v1/contracts/$CONTRACT_ID/amend" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "reason": "Expansion — Data Analytics module added per SOW-2026-06",
    "effective_date": "2026-07-01",
    "additional_terms": [
      {
        "phase": 4,
        "start_date": "2026-07-01",
        "end_date": "2026-12-31",
        "committed_amount_nanos": "250000000000000000",
        "description": "Q3-Q4 expanded — \$125k/quarter (was \$100k)"
      }
    ]
  }')

NEW_CONTRACT_ID=$(echo $AMENDED | jq -r '.id')
PARENT_CONTRACT_ID=$(echo $AMENDED | jq -r '.parent_contract_id')
echo "New contract: $NEW_CONTRACT_ID"
echo "Amendment chain: $PARENT_CONTRACT_ID → $NEW_CONTRACT_ID"

Key Takeaways

  • Contracts track commercial terms; subscriptions drive billing — they’re separate concerns
  • Ramp pricing is modeled as multiple ContractTerm phases with different committed_amount_nanos
  • True-up runs at phase boundaries to collect any shortfall
  • Amendment is immutable: the parent_contract_id field traces the full history
  • Rate overrides (RateOverride) can apply custom pricing at the SKU level without touching the catalog

See Contracts for the full API reference.

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

API Key Setup

A guide to creating, scoping, and managing API keys. Covers restricted keys for backend services, customer-scoped keys for metering, and key revocation.

Key Prefixes

PrefixTypeUse
bsk_live_*Secret (Admin)Full admin access — server-side only, never expose
bpk_live_*PublishableRead-only, customer-facing
brk_live_*RestrictedCustom scope — for specific backend services

Create a Restricted Key (events:write + metering:write)

Restricted keys let you give a backend service exactly the permissions it needs — no more. A metering service only needs events:write:

# [curl]
curl -X POST https://api.bill.sh/admin/v1/api-keys \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Metering Service — Production",
    "scopes": ["events:write", "metering:read"],
    "description": "Used by the metering sidecar to ingest usage events"
  }'
# [Python]
import requests

resp = requests.post(
    "https://api.bill.sh/admin/v1/api-keys",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
    json={
        "name": "Metering Service — Production",
        "scopes": ["events:write", "metering:read"],
        "description": "Used by the metering sidecar to ingest usage events",
    },
)
key_data = resp.json()
restricted_key = key_data["key"]   # brk_live_xxxxx — store this securely!
key_id = key_data["id"]
print(f"Restricted key: {restricted_key[:16]}... (ID: {key_id})")
# WARNING: the key value is only returned once — store it in your secrets manager
// [Node.js]
const resp = await fetch("https://api.bill.sh/admin/v1/api-keys", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${ADMIN_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    name: "Metering Service — Production",
    scopes: ["events:write", "metering:read"],
    description: "Used by the metering sidecar to ingest usage events",
  }),
});
const keyData = await resp.json();
const restrictedKey = keyData.key;  // brk_live_xxxxx — only returned once!
const keyId = keyData.id;
console.log(`Restricted key created: ${restrictedKey.slice(0, 16)}... (ID: ${keyId})`);
// [Go]
body, _ := json.Marshal(map[string]interface{}{
    "name":        "Metering Service — Production",
    "scopes":      []string{"events:write", "metering:read"},
    "description": "Used by the metering sidecar to ingest usage events",
})
req, _ := http.NewRequest("POST", "https://api.bill.sh/admin/v1/api-keys",
    bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()

var keyData map[string]interface{}
json.NewDecoder(resp.Body).Decode(&keyData)
fmt.Printf("Key: %v (ID: %v)\n", keyData["key"], keyData["id"])
// Store keyData["key"] in your secrets manager immediately

Validate a Key in Your Integration

Before trusting a key, verify it’s valid and has the expected scopes:

# [curl]
curl https://api.bill.sh/v1/api-keys/validate \
  -H "Authorization: Bearer $RESTRICTED_KEY"
# [Python]
resp = requests.get(
    "https://api.bill.sh/v1/api-keys/validate",
    headers={"Authorization": f"Bearer {RESTRICTED_KEY}"},
)
validation = resp.json()
print(f"Key valid: {validation['valid']}")
print(f"Scopes: {validation['scopes']}")
print(f"Key ID: {validation['key_id']}")

# Check the key has the scope you need
if "events:write" not in validation["scopes"]:
    raise PermissionError("Key missing events:write scope")
// [Node.js]
const resp = await fetch("https://api.bill.sh/v1/api-keys/validate", {
  headers: { "Authorization": `Bearer ${RESTRICTED_KEY}` },
});
const validation = await resp.json();
console.log("Valid:", validation.valid);
console.log("Scopes:", validation.scopes.join(", "));

if (!validation.scopes.includes("events:write")) {
  throw new Error("Key missing events:write scope");
}
// [Go]
req, _ := http.NewRequest("GET", "https://api.bill.sh/v1/api-keys/validate", nil)
req.Header.Set("Authorization", "Bearer "+restrictedKey)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()

var validation map[string]interface{}
json.NewDecoder(resp.Body).Decode(&validation)
fmt.Printf("Valid: %v, Scopes: %v\n", validation["valid"], validation["scopes"])

Create a Customer-Scoped Key (Option D)

For multi-tenant architectures, create a key scoped to a specific customer. This key can only read/write data for that customer — perfect for giving a customer programmatic access to their own billing data:

# [curl]
curl -X POST https://api.bill.sh/admin/v1/customers/$CUSTOMER_ID/api-keys \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme Corp — Integration Key",
    "scopes": ["events:write", "subscriptions:read", "invoices:read"],
    "description": "Acme self-service integration — created by their ops team"
  }'
# [Python]
resp = requests.post(
    f"https://api.bill.sh/admin/v1/customers/{customer_id}/api-keys",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
    json={
        "name": "Acme Corp — Integration Key",
        "scopes": ["events:write", "subscriptions:read", "invoices:read"],
        "description": "Acme self-service integration — created by their ops team",
    },
)
key_data = resp.json()
print(f"Customer-scoped key: {key_data['key'][:20]}...")
print(f"Customer scope enforced for: {customer_id}")
# This key can ONLY access data for customer_id — enforced server-side
// [Node.js]
const resp = await fetch(
  `https://api.bill.sh/admin/v1/customers/${customerId}/api-keys`,
  {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${ADMIN_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      name: "Acme Corp — Integration Key",
      scopes: ["events:write", "subscriptions:read", "invoices:read"],
      description: "Acme self-service integration",
    }),
  }
);
const keyData = await resp.json();
console.log(`Customer key: ${keyData.key.slice(0, 20)}...`);
// This key is scoped to customerId — any attempt to access other customers returns 403
// [Go]
body, _ := json.Marshal(map[string]interface{}{
    "name":        "Acme Corp — Integration Key",
    "scopes":      []string{"events:write", "subscriptions:read", "invoices:read"},
    "description": "Acme self-service integration",
})
req, _ := http.NewRequest("POST",
    "https://api.bill.sh/admin/v1/customers/"+customerID+"/api-keys",
    bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+adminToken)
req.Header.Set("Content-Type", "application/json")
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var keyData map[string]interface{}
json.NewDecoder(resp.Body).Decode(&keyData)
fmt.Printf("Customer key: %.20s...\n", keyData["key"])

Revoke a Key

Immediately invalidate a key — useful when rotating credentials or offboarding a service:

# [curl]
curl -X DELETE https://api.bill.sh/admin/v1/api-keys/$KEY_ID \
  -H "Authorization: Bearer $ADMIN_TOKEN"
# [Python]
resp = requests.delete(
    f"https://api.bill.sh/admin/v1/api-keys/{key_id}",
    headers={"Authorization": f"Bearer {ADMIN_TOKEN}"},
)
print(f"Revoked: {resp.status_code == 200}")
// [Node.js]
const resp = await fetch(`https://api.bill.sh/admin/v1/api-keys/${keyId}`, {
  method: "DELETE",
  headers: { "Authorization": `Bearer ${ADMIN_TOKEN}` },
});
console.log("Revoked:", resp.ok);
// [Go]
req, _ := http.NewRequest("DELETE",
    "https://api.bill.sh/admin/v1/api-keys/"+keyID, nil)
req.Header.Set("Authorization", "Bearer "+adminToken)
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
fmt.Println("Revoked:", resp.StatusCode == 200)

Security Best Practices

  • Never expose bsk_live_* keys in client-side code, browser environments, or version control
  • Use restricted keys (brk_live_*) for any service that only needs specific scopes
  • Rotate keys regularly — revoke old keys after creating new ones
  • Store in secrets managers — AWS Secrets Manager, GCP Secret Manager, Vault, etc.
  • The key value is only returned once at creation time — store it immediately or you’ll need to revoke and regenerate

API Reference Overview

The billing platform exposes two API surfaces:

Customer-Facing API (/v1/)

Used by your application to manage subscriptions, send usage events, and retrieve invoices on behalf of your customers.

MethodPathDescription
POST/v1/subscriptionsCreate a subscription
GET/v1/subscriptions/:idGet a subscription
POST/v1/subscriptions/:id/cancelCancel a subscription
GET/v1/subscriptions/:id/invoicesList invoices for subscription
GET/v1/subscriptions/:id/usageGet usage summary
GET/v1/invoices/:idGet an invoice
POST/v1/invoices/:id/finalizeFinalize a draft invoice
POST/v1/eventsIngest a usage event
POST/v1/webhooks/stripeStripe webhook receiver

Admin API (/admin/v1/)

Used by your Finance and Support teams for customer management, invoice operations, and financial reporting.

MethodPathDescription
GET/admin/v1/customersList all customers
POST/admin/v1/customersCreate customer
GET/admin/v1/customers/:idCustomer 360 view
GET/admin/v1/customers/:id/hierarchyEntity hierarchy
GET/admin/v1/customers/:id/creditsList wallet credits
POST/admin/v1/customers/:id/creditsIssue wallet credit
GET/admin/v1/customers/:id/alertsList spend alerts
POST/admin/v1/customers/:id/alertsCreate spend alert
POST/admin/v1/customers/:id/alerts/:aid/resetReset triggered alert
GET/admin/v1/invoicesList invoices
GET/admin/v1/invoices/:idGet invoice
POST/admin/v1/invoices/:id/voidVoid invoice
POST/admin/v1/invoices/:id/finalizeFinalize invoice
POST/admin/v1/invoices/:id/payMark paid
POST/admin/v1/invoices/:id/credit-noteIssue credit note
POST/admin/v1/invoices/:id/calculate-taxApply tax
POST/admin/v1/subscriptions/:id/pausePause subscription
POST/admin/v1/subscriptions/:id/resumeResume subscription
POST/admin/v1/subscriptions/:id/billManual billing run
GET/admin/v1/catalog/productsList products
POST/admin/v1/catalog/productsCreate product
GET/admin/v1/catalog/plansList plans
POST/admin/v1/catalog/plansCreate plan
POST/admin/v1/catalog/skusCreate SKU
GET/admin/v1/reports/ar-agingAR aging report
GET/admin/v1/reports/mrrMRR/ARR report
GET/admin/v1/auditAudit log
GET/admin/v1/audit/:entity_type/:entity_idEntity audit trail

OpenAPI Spec

The full OpenAPI 3.0 specification is available at:

  • JSON: https://api.bill.sh/openapi.json
  • Redoc UI: https://api.bill.sh/docs/api

Base URLs

EnvironmentBase URL
Productionhttps://api.bill.sh
Local devhttp://localhost:3000

Interactive API Explorer

Explore and test the billing API directly in your browser using the interactive Redoc documentation.

Live API Explorer

👉 Open Interactive API Explorer

The explorer provides:

  • Full OpenAPI 3.0 documentation for all endpoints
  • Request/response schema viewer with field descriptions
  • Example values and curl snippets
  • Authentication configuration

OpenAPI Spec

The machine-readable OpenAPI 3.0 JSON spec is available at:

https://api.bill.sh/openapi.json

Use this to generate client SDKs with tools like:

Local Development

When running locally, the explorer is available at:

http://localhost:3000/docs/api
http://localhost:3000/openapi.json

System Architecture Overview

The billing platform is a Rust workspace composed of 18 crates arranged in a layered dependency graph.

Crate Dependency Graph

                      ┌─────────────┐
                      │   common    │  Money, IDs, BillingError, types, tax trait
                      └──────┬──────┘
              ┌──────────────┼──────────────┬──────────────┐
              ▼              ▼              ▼              ▼
        ┌─────────┐   ┌──────────┐   ┌──────────┐   ┌──────────┐
        │metering │   │ pricing  │   │customers │   │  ledger  │
        └────┬────┘   └────┬─────┘   └──────────┘   └──────────┘
             │             │
             ▼             ▼
        ┌─────────┐   ┌──────────┐
        │ rating  │◄──│ pricing  │
        └────┬────┘   └──────────┘
             │
             ▼
       ┌──────────────┐
       │  invoicing   │  + ledger, wallet
       └──────┬───────┘
              │
    ┌─────────┼──────────┐
    ▼         ▼          ▼
┌────────┐ ┌────────┐ ┌──────────┐
│dunning │ │payments│ │contracts │
└────────┘ └────────┘ └──────────┘
              │
    ┌─────────┼──────────┐
    ▼         ▼          ▼
┌────────┐ ┌────────┐ ┌───────────────┐
│ audit  │ │ wallet │ │spend-alerts   │
└────────┘ └────────┘ └───────────────┘
              │
              ▼
     ┌───────────────────┐
     │    api-gateway    │  Axum HTTP server
     └───────────────────┘

Technology Stack

ComponentTechnologyNotes
LanguageRust 2021Fearless concurrency, zero-cost abstractions
HTTP FrameworkAxum 0.7Tower middleware, typed extractors
LedgerTigerBeetleIn-memory shim in dev; TB client in prod
DatabaseCockroachDBREGIONAL BY ROW multi-region schema
AnalyticsClickHouseMetering aggregation, MV queries
Event streamingKafkabilling.<service>.<event> topic naming
CacheRedisRate limiting (token bucket), hot-path accumulators
PaymentsStripePaymentIntent API + webhook verification
AuthJWT HS256 + API keysConstant-time comparison
Serializationserde + serde_jsonAll monetary i128 as string

Deploy Architecture

Internet
   │
   ▼
Caddy (TLS termination, reverse proxy)
   │
   ├─► api.bill.sh → api-gateway (port 3000)
   ├─► docs.bill.sh → mdBook static site
   └─► ui.bill.sh → Askama/HTMX server-rendered UI

Infrastructure: Hetzner VPS (Ubuntu 24.04, Docker 29.x)

Key Design Principles

  1. Correctness over performance: i128 pico-units eliminate money rounding bugs
  2. Pure functions for business logic: rate_all(), prorate_days(), compute_true_up() have no I/O — deterministic and easily tested
  3. Idempotency everywhere: All mutating operations safe to retry
  4. In-memory for development: All repositories have InMemory* implementations — no database required for local dev or tests
  5. Swap-in production backends: Implement Repository traits with CockroachDB/ClickHouse/TigerBeetle for production

Service Crates

CrateResponsibility
commonShared types: Money, NanoMoney, typed IDs, Currency, BillingError, TaxAdapter
meteringUsageEvent ingestion, MeterDefinition, aggregation, EventStore
ratingPriceSheet, rate_all() pure engine, RatedLineItem
pricingCatalogService, PriceVersionService, plan/product/SKU CRUD
subscriptionsSubscriptionService, state machine, proration engine
invoicingInvoicingService, CalculateFeesService, FinalizeService, CreditNoteService
contractsContractService, amendment chain, coterm, commit draw-down
ledgerTigerBeetle client wrapper, double-entry transfers
dunningDunningSchedule, retry scheduler
paymentsStripeClient, PaymentIntent, webhook verification
notificationsOutbox pattern, webhook delivery, circuit breaker
auditAuditLog, 25+ action types, before/after state
reportingAR aging, MRR movements, deferred revenue (ASC 606)
walletCreditWalletService, auto-recharge
spend-alertsSpendAlertService, SoftLimit/HardLimit
customersCustomerService, hierarchy, entity tree
engineBillingEngine orchestrator (ties all services together)
demoSeed data for development

Data Model

The billing platform’s data model reflects the lifecycle of a billing relationship.

Core Entities

Customer

Customer {
  id: CustomerId (UUIDv7)
  display_name: String
  legal_name: Option<String>
  email: String
  account_type: Organization | Individual | Subsidiary
  status: Active | Suspended | Closed
  parent_id: Option<CustomerId>        // hierarchy
  bill_to_id: Option<CustomerId>       // invoice recipient override
  consolidation: Standalone | ConsolidateToParent
  billing_currency: Currency
  payment_terms_days: u32
  tax_id: Option<String>               // VAT/EIN — exempts from tax
}

Subscription

Subscription {
  id: SubscriptionId (UUIDv7)
  customer_id: CustomerId
  plan_id: PlanId
  status: Trialing | Active | PastDue | Paused | Cancelled | Expired
  currency: Currency
  period_start: DateTime<Utc>
  period_end: DateTime<Utc>
  charged_through_date: DateTime<Utc>  // advance with each billing cycle
  trial_days: u32
  contract_id: Option<ContractId>      // enterprise contract link
  billing_cadence: Monthly | Annual | Weekly | Custom
}

Invoice

Invoice {
  id: InvoiceId (UUIDv7)
  invoice_number: Option<String>       // "INV-000001" — assigned on finalization
  status: Draft | Open | Paid | Void
  invoice_type: Standard | CreditNote | ProForma
  customer_id: CustomerId
  subscription_id: SubscriptionId
  line_items: Vec<InvoiceLineItem>
  total_nanos: i128                    // pico-units, sum of line items
  currency: Currency
  period_start: DateTime<Utc>
  period_end: DateTime<Utc>
  due_date: NaiveDate
  applies_to_invoice_id: Option<InvoiceId>  // credit notes only
  voided_reason: Option<String>
  finalized_at: Option<DateTime<Utc>>
  created_at: DateTime<Utc>
}

InvoiceLineItem

InvoiceLineItem {
  id: InvoiceLineItemId (UUIDv7)
  invoice_id: InvoiceId
  description: String
  quantity: Decimal
  unit_amount_nanos: i128
  amount_nanos: i128
  currency: Currency
  is_tax: bool                         // true for tax lines
}

Contract

Contract {
  id: ContractId (UUIDv7)
  customer_id: CustomerId
  status: Draft | Active | Amended | Expired | Terminated
  parent_contract_id: Option<ContractId>  // amendment chain
  start_date: NaiveDate
  end_date: NaiveDate
  terms: Vec<ContractTerm>             // ramp phases
  rate_overrides: Vec<RateOverride>    // enterprise custom pricing
  escalation: Option<Escalation>
}

Entity Relationships

Customer (1) ──► (*) Subscription
Customer (1) ──► (*) Invoice
Customer (1) ──► (*) Contract
Customer (1) ──► (0-1) CreditWallet (TigerBeetle account)
Subscription (1) ──► (*) Invoice
Subscription (1) ──► (0-1) Contract (via ContractSubscription)
Invoice (1) ──► (*) InvoiceLineItem
Invoice (CreditNote) ──► (1) Invoice (Standard)  // applies_to_invoice_id

CockroachDB Schema

Multi-region tables use REGIONAL BY ROW for geo-distributed data locality. Each billing entity is homed in the region closest to the customer’s billing address.

CREATE TABLE subscriptions (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  customer_id UUID NOT NULL,
  plan_id UUID NOT NULL,
  status subscription_status NOT NULL DEFAULT 'trialing',
  currency VARCHAR(3) NOT NULL,
  period_start TIMESTAMPTZ NOT NULL,
  period_end TIMESTAMPTZ NOT NULL,
  charged_through_date TIMESTAMPTZ NOT NULL,
  crdb_region crdb_internal_region AS (
    CASE WHEN billing_region = 'EU' THEN 'eu-west' ELSE 'us-east' END
  ) STORED
) LOCALITY REGIONAL BY ROW AS crdb_region;

See infra/crdb-schema/ for the full DDL (6 SQL files).

Amount Convention — NanoMoney & Pico-Units

This is the most important convention in the billing platform. Getting this wrong would corrupt financial data.

The Rule

All internal monetary amounts are stored as i128 integers with scale=12 (pico-units). Field names always end in _nanos. Never use f64 for money. Ever.

Scale=12 Explained

1 USD = 1_000_000_000_000 (one trillion raw units)
       10^12 raw units per major currency unit

Examples:

Display amountInternal representation
$0.000
$0.0000011_000_000
$0.0110_000_000_000
$1.001_000_000_000_000
$9.999_990_000_000_000
$100.00100_000_000_000_000
$1,000,000.001_000_000_000_000_000_000

Why Not Floats?

IEEE 754 double-precision has ~15-16 significant decimal digits. Sounds sufficient — but isn’t:

>>> 0.1 + 0.2
0.30000000000000004  # NOT 0.3

In a billing system summing millions of small charges, these errors compound. $0.000001 * 1_000_000 in float arithmetic can yield $0.9999999999999999 instead of $1.00. That’s money lost.

With i128:

#![allow(unused)]
fn main() {
let per_token: i128 = 1_000_000; // $0.000001
let tokens: i128 = 1_000_000;
let total: i128 = per_token * tokens; // exactly 1_000_000_000_000 = $1.00
}

Why Not Cents (scale=2)?

Cents (i64 with scale=2) break down for AI/API pricing:

  • $0.000001 per token = 0.000001 * 100 = 0.0001 cents → rounds to zero
  • Scale=12 preserves sub-nanodollar precision needed for usage-based pricing

The NanoMoney Type

#![allow(unused)]
fn main() {
pub struct NanoMoney {
    raw: i128,    // pico-units
    currency: Currency,
}

impl NanoMoney {
    pub fn new(nanos: i128, currency: &Currency) -> Self { ... }
    pub fn to_money_round(&self) -> Money { ... }  // rounds ONCE at output
    pub fn add(&self, other: &NanoMoney) -> NanoMoney { ... }
    pub fn mul_decimal(&self, factor: &Decimal) -> NanoMoney { ... }
}
}

NanoAccumulator for Hot Paths

For real-time spend tracking (spend alerts, rate limiting), the platform uses a NanoAccumulator:

#![allow(unused)]
fn main() {
// Atomic i128 operations for concurrent accumulation
pub struct NanoAccumulator {
    inner: DashMap<CustomerId, AtomicI128>,
}
}

Redis implementation uses a Lua script for atomic compare-and-add.

Where Rounding Happens

Rounding happens exactly once: at invoice output, when converting from pico-units to display currency.

#![allow(unused)]
fn main() {
// In InvoicingService::finalize():
let display_total = NanoMoney::new(invoice.total_nanos, &invoice.currency)
    .to_money_round(); // rounds to 2 decimal places here
}

Never round intermediate calculations. The NanoMoney type does not round on arithmetic operations.

API Serialization

In API responses, _nanos fields are serialized as strings (not integers) to avoid JavaScript’s 53-bit integer limit (Number.MAX_SAFE_INTEGER = 9_007_199_254_740_991):

{
  "total_nanos": "9990000000000"
}

JavaScript clients should use BigInt to parse these values:

const totalNanos = BigInt(response.total_nanos);
const dollars = Number(totalNanos / 1_000_000_000_000n);
const cents = Number((totalNanos % 1_000_000_000_000n) / 10_000_000_000n);
const display = `$${dollars}.${String(cents).padStart(2, '0')}`;

Summary

Storage:    i128 pico-units (scale=12)
Field name: _nanos suffix
Type:       NanoMoney for arithmetic
Output:     Round ONCE via .to_money_round()
API:        Serialize as string to avoid JS BigInt overflow
NEVER:      Use f64 for money calculations

Changelog

Phase 5 — Production Hardening (Feb 28, 2026)

  • Idempotency: IdempotencyStore (DashMap, 24h TTL, method+path+key scoped) + Axum middleware + X-Idempotency-Replayed response header
  • Spend Alerts: New spend-alerts crate — SoftLimit/HardLimit alert types, SpendAlertService (create/check/reset), GET/POST /admin/v1/customers/:id/alerts + reset endpoint
  • Usage Summary: metering::summary::{UsageSummary, MeterSummary} + GET /v1/subscriptions/:id/usage + MeteringService in AppState
  • Tax Engine: TaxAdapter trait + PassThroughTaxAdapter + FlatRateTaxAdapter (bps, B2B exempt) + InvoiceLineItem.is_tax + InvoicingService::apply_tax() + POST /admin/v1/invoices/:id/calculate-tax
  • Developer Docs: mdBook docs site deployed to docs.bill.sh + OpenAPI 3.0 spec + Redoc UI at https://api.bill.sh/docs/api

Phase 4 — Scale & High Availability (Weeks 7-8)

  • ClickHouse Metering: DDL, materialized views, aggregation query builder, batch/top/summary helpers (11 tests)
  • Kafka Integration: EventPublisher trait, StubEventPublisher, RdkafkaProducer (feature-gated)
  • CockroachDB Schema: Multi-region REGIONAL BY ROW schema in infra/crdb-schema/ (6 SQL files)
  • Auth Middleware: JWT HS256 + constant-time API key validation (auth.rs)
  • Rate Limiting: Redis token bucket + InMemoryRateLimiter + Lua script for atomic Redis operations
  • Webhook Delivery: Retry engine + circuit breaker + HMAC-SHA256 signatures (notifications/delivery.rs)
  • Docker Compose: Full local dev environment (139 lines) — CockroachDB, ClickHouse, Kafka, Redis, TigerBeetle
  • CI Pipeline: GitHub Actions clippy + test on every push

Phase 3 — Enterprise Features (Weeks 5-6)

  • Contracts: Full contract CRUD — Draft→Active→Amended/Expired/Terminated state machine
  • Ramp Contracts: ContractTerm phases with per-phase pricing, discounts, and committed amounts
  • ContractSubscription: Join table with PLG/Coterm/Recontract adoption types
  • Proration Engine: Pure functions — prorate_days(), prorate_to_contract_end(), prorate_overlap_credit(), cancellation_credit()
  • Commit Draw-Down: apply_commit_drawdown() pure fn — priority-ordered, tag-scoped
  • True-Up: compute_true_up() + compute_phase_true_ups() pure fns
  • Ledger Provisioning: TigerBeetle two-phase transfers — pending/post/void engine, customer account provisioning
  • Audit Log: billing-audit crate — 25+ action types, ActorRef/ActorRole, InMemoryAuditLog, idempotency
  • Financial Reporting: billing-reporting — AR aging, MRR report, MRR movements, deferred revenue schedule (ASC 606 compliant)
  • Credit Notes: CreditNoteService — partial/full credits, issue_wallet_credit() (TigerBeetle transfer, idempotent)
  • PLG Adoption: PlgAdoptionService — in-place contract linking for product-led growth companies
  • Admin API: Customer 360, invoice void, credit note, pause/resume, catalog management endpoints

Phase 2 — Core Billing Loop (Weeks 3-4)

  • Rating Engine: PriceConfig, PriceSheet, TierOverride, RatedLineItem — flat/per_unit/graduated/volume/package/committed models
  • rate_all(): Pure rating function with contract discount (basis points) and tier override support
  • Invoice Calculation: CalculateFeesService — converts RatedLineItemInvoiceLineItem
  • Invoice Finalization: FinalizeServicefinalize_auto() with Arc<AtomicU64> sequential counter (INV-XXXXXX)
  • Progressive Billing: ProgressiveBillingService — threshold-triggered finalize + new draft
  • Period Advance: advance_period() on subscriptions — moves charged_through_date
  • Stripe Integration: StripeClientcreate_payment_intent(), capture(), refund(), webhook verification
  • Dunning: 4-attempt retry schedule (+3d/+7d/+14d/+21d)
  • Notifications: OutboxEntry + OutboxRepository — transactional outbox pattern
  • API Gateway: Axum router with subscriptions, invoices, events, webhooks routes

Phase 1 — Foundation (Weeks 1-2)

  • Workspace Scaffold: 10 service crates in Cargo workspace
  • common crate: Money, NanoMoney, typed IDs (UUIDv7), domain enums, BillingEvent, BillingError (87 unit tests)
  • Metering: MeterDefinition, MeterFilter, FilterOperator (7 operators), UsageEvent (CloudEvents-compatible), aggregation engine (COUNT/SUM/MAX/UNIQUE_COUNT/LATEST/WEIGHTED_SUM), InMemoryUsageEventStore
  • Redis Accumulator: Real-time spend controls — InMemoryAccumulator + Redis Lua script
  • Subscriptions: CRUD with state machine enforcement, InMemorySubscriptionRepository
  • Invoicing: Invoice + InvoiceLineItem + finalization state machine
  • Ledger: TigerBeetle client wrapper — InMemoryLedger (account create, transfer, balance query)
  • API Gateway: Axum router scaffold with health check