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
TaxAdaptertrait. Default: zero-tax pass-through. Swap inFlatRateTaxAdapter(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) andHardLimit(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?
- Set up webhooks to receive real-time billing events
- Configure spend alerts to notify customers before they hit limits
- Read about idempotency to make your integration bulletproof
- Explore the interactive API explorer
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 IDexp— expiration timestamp (max 1 hour for customer tokens, 8 hours for admin tokens)scope—customeroradmin
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 prefix | Required scope |
|---|---|
GET /health | None (public) |
GET /openapi.json | None (public) |
/v1/* | customer or admin |
/admin/v1/* | admin only |
Environment Variables
| Variable | Description |
|---|---|
JWT_SECRET | HMAC-SHA256 secret for JWT signing/verification |
API_KEY_HASH | SHA-256 hex of the valid API key |
STRIPE_WEBHOOK_SECRET | Stripe 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 periodcharged_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
| Type | Description |
|---|---|
Organization | Top-level company account. Can have children. |
Individual | Single-person account. No children. |
Subsidiary | Child account under an Organization. Used for cost-centers and subsidiaries. |
Consolidation Modes
| Mode | Description |
|---|---|
Standalone | Billed independently (default) |
ConsolidateToParent | Charges roll up to the parent invoice |
Fields
| Field | Type | Description |
|---|---|---|
id | string (UUIDv7) | Unique customer ID |
display_name | string | Human-readable name |
legal_name | string? | Legal entity name (for invoices) |
email | string | Billing contact email |
account_type | enum | Organization / Individual / Subsidiary |
status | enum | Active / Suspended / Closed |
parent_id | string? | Parent customer ID (for subsidiaries) |
bill_to_id | string? | Override: which account receives the invoice |
consolidation | enum | Standalone / ConsolidateToParent |
billing_currency | string | ISO 4217 (e.g., “USD”, “EUR”) |
payment_terms_days | integer | Net payment terms (default: 30) |
tax_id | string? | 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:
| Model | Description | Use case |
|---|---|---|
Flat | Fixed monthly fee | Seat license, base platform fee |
PerUnit | Per-unit price × quantity | Per API call, per GB |
Graduated | Tiered pricing, each tier applies to quantity in that tier | Usage-based |
Volume | All units at the tier price that the total quantity falls into | Volume discounts |
Package | Price per bundle of N units | Credits pack |
Committed | Minimum commit + overage rate | Enterprise 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
| Field | Type | Description |
|---|---|---|
id | string (UUIDv7) | Unique subscription ID |
customer_id | string | Owner customer |
plan_id | string | The plan being subscribed to |
status | enum | See lifecycle below |
currency | string | ISO 4217 billing currency |
period_start | datetime | Current billing period start |
period_end | datetime | Current billing period end |
charged_through_date | datetime | How far billing has been collected |
trial_days | integer | Trial days remaining at creation |
contract_id | string? | Linked enterprise contract (if any) |
Lifecycle States
| Status | Meaning |
|---|---|
Trialing | In trial period — not yet billed |
Active | Billing normally |
PastDue | Payment failed, in dunning |
Paused | Billing paused (admin action) |
Cancelled | Cancelled, active through period end |
Expired | Period 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
| Type | Description |
|---|---|
Standard | Regular billing invoice |
CreditNote | Negative invoice offsetting a prior charge |
ProForma | Estimate/quote (not legally binding) |
Key Fields
| Field | Type | Description |
|---|---|---|
id | string (UUIDv7) | Invoice ID |
invoice_number | string? | Sequential number assigned on finalization (e.g., INV-000042) |
status | enum | Draft / Open / Paid / Void |
invoice_type | enum | Standard / CreditNote / ProForma |
customer_id | string | Owning customer |
subscription_id | string | Source subscription |
total_nanos | string | Total in pico-units (i128 as string) |
currency | string | ISO 4217 |
due_date | date | Payment due date |
period_start / period_end | datetime | Billing period |
line_items | array | Charges detail |
applies_to_invoice_id | string? | For credit notes: which invoice this offsets |
voided_reason | string? | Why the invoice was voided |
finalized_at | datetime? | 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:
| Field | Type | Description |
|---|---|---|
id | string | Client-generated unique ID — used for deduplication |
event_type | string | Event type for meter matching (e.g., api.request, storage.byte-hour) |
customer_id | string (UUIDv7) | Customer who generated this usage |
subscription_id | string? | Subscription to attribute to (optional) |
timestamp | datetime | When the event occurred (ISO 8601) |
properties | object | Arbitrary 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
| Type | Description | Field |
|---|---|---|
Sum | Sum of a numeric property across all events | properties.tokens |
Count | Number of events (ignores properties) | — |
Max | Maximum value of a property | properties.response_time_ms |
UniqueCount | Count of distinct values for a property | properties.user_id |
Window Types
| Type | Description |
|---|---|
BillingPeriod | Aggregate across the entire billing period (most common) |
Sliding | Rolling window (e.g., last 30 days) |
Tumbling | Fixed 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
| Contract | Subscription | |
|---|---|---|
| Owner | Sales / Legal | Finance / Engineering |
| Purpose | Commercial terms | Billing cycle execution |
| Lifecycle | Draft → Active → Amended/Expired/Terminated | Trialing → Active → Cancelled |
| PLG customers | None required | Required |
| Enterprise customers | Required | Linked via ContractSubscription |
Contract States
Draft → Active → Amended (immutable chain)
→ Expired (natural end)
→ Terminated (early exit)
Amendment is immutable — amend() 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 Credit | Credit Note | |
|---|---|---|
| When | Proactively, before invoice | After invoice is finalized |
| Mechanism | TigerBeetle balance | Negative invoice |
| Use case | Goodwill credit, prepaid top-up | Dispute resolution, partial refund |
| Accounting | Debit platform liability / Credit wallet | Negative 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
| Attempt | Delay from failure | Action |
|---|---|---|
| 1 | +3 days | Retry charge + notify customer |
| 2 | +7 days | Retry charge + escalate notification |
| 3 | +14 days | Retry charge + warn of suspension |
| 4 | +21 days | Retry charge + suspend if failed |
After attempt 4 fails, the subscription moves to PastDue and eventually Cancelled.
How It Works
- Stripe webhook delivers
payment_intent.payment_failedorinvoice.payment_failed - The
DunningServicecreates aDunningSchedulefor the subscription - A background job (cron) calls
check_due_attempts()daily and triggers retries - On success: subscription returns to
Active, schedule is cleared - On exhaustion: subscription transitions to
PastDue→ (admin action) →PausedorCancelled
Dunning Schedule
Each schedule tracks:
subscription_id— the subscription in arrearsinvoice_id— the outstanding invoiceattempt_count— how many retries have been made (1-4)next_attempt_at— when to fire the next retrystatus—Active,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
| Type | Behavior when triggered |
|---|---|
SoftLimit | Sends a notification to the customer and fires a webhook event. Billing continues. |
HardLimit | Blocks 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:
- Fetches all active alerts for the customer
- Compares
current_spend_nanosagainst each alert’sthreshold_nanos - For
SoftLimit: fires a notification webhook if threshold crossed and not already triggered - For
HardLimit: returns an error that blocks the charge if threshold is met or exceeded - 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
- Create a
HardLimitalert at the customer’s budget ceiling - Create a
SoftLimitalert at 80% of the ceiling for advance warning - Customer approaches limit →
SoftLimitfires notification - Customer hits limit →
HardLimitblocks further charges - Customer pays bill or requests increase → Admin resets the alert
- 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
- Client sends a request with
Idempotency-Key: <your-key> - Server processes the request and stores the response (keyed by
method + path + idempotency_key) - If the same key is received again within 24 hours, the server returns the cached response immediately
- The response includes
X-Idempotency-Replayed: truewhen a cached response is served
Scoping
The idempotency key is scoped to method + path + key. This means:
POST /v1/subscriptionswith keyabcandPOST /v1/invoices/$ID/finalizewith keyabcare separate — different paths, no conflictPOST /v1/subscriptionswith keyabctwice 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
| Event | Description |
|---|---|
invoice.finalized | Invoice transitioned to Open with invoice number |
invoice.paid | Payment collected |
invoice.voided | Invoice was voided |
subscription.created | New subscription created |
subscription.cancelled | Subscription cancelled |
subscription.past_due | Payment failed, entering dunning |
payment.failed | Stripe charge failed |
spend_alert.triggered | SoftLimit or HardLimit threshold crossed |
credit_note.issued | Credit note created against invoice |
Webhook Signature Verification
Every webhook delivery includes a X-Billing-Signature header containing an HMAC-SHA256 signature of the request body.
# [curl]
# Register a webhook endpoint
curl -X POST https://api.bill.sh/admin/v1/webhooks \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/billing/webhooks",
"events": ["invoice.finalized", "subscription.past_due", "payment.failed"],
"description": "Production webhook endpoint"
}'
# [Python]
import hmac
import hashlib
def verify_webhook(payload: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
# Full Flask webhook handler
from flask import Flask, request, abort
import json
app = Flask(__name__)
BILLING_WEBHOOK_SECRET = "whsec_your_secret_here"
@app.route("/billing/webhooks", methods=["POST"])
def billing_webhook():
payload = request.get_data()
signature = request.headers.get("X-Billing-Signature", "")
if not verify_webhook(payload, signature, BILLING_WEBHOOK_SECRET):
abort(400, "Invalid webhook signature")
event = json.loads(payload)
event_type = event["type"]
data = event["data"]
if event_type == "invoice.finalized":
invoice_id = data["invoice_id"]
invoice_number = data["invoice_number"]
total_nanos = int(data["total_nanos"])
total_usd = total_nanos / 1e12
print(f"Invoice {invoice_number} finalized: ${total_usd:.2f}")
# Send invoice email to customer, update your records, etc.
elif event_type == "subscription.past_due":
sub_id = data["subscription_id"]
customer_id = data["customer_id"]
print(f"Subscription {sub_id} for customer {customer_id} is past due")
# Trigger in-app notification, restrict access, etc.
elif event_type == "payment.succeeded":
invoice_id = data["invoice_id"]
amount_nanos = int(data["amount_nanos"])
print(f"Payment received for invoice {invoice_id}: ${amount_nanos / 1e12:.2f}")
# Unlock features, send receipt, etc.
elif event_type == "spend_alert.triggered":
alert_type = data["alert_type"]
customer_id = data["customer_id"]
threshold_nanos = int(data["threshold_nanos"])
print(f"{alert_type} triggered for {customer_id} at ${threshold_nanos / 1e12:.2f}")
return "", 200
// [Node.js]
import express from "express";
import { createHmac, timingSafeEqual } from "crypto";
const app = express();
const BILLING_WEBHOOK_SECRET = process.env.BILLING_WEBHOOK_SECRET;
// IMPORTANT: use express.raw() to preserve the raw body for signature verification
app.post("/billing/webhooks", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.headers["x-billing-signature"] ?? "";
const expected = createHmac("sha256", BILLING_WEBHOOK_SECRET)
.update(req.body)
.digest("hex");
if (!timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(signature, "hex"))) {
return res.status(400).json({ error: "Invalid webhook signature" });
}
const event = JSON.parse(req.body.toString());
const { type, data } = event;
switch (type) {
case "invoice.finalized": {
const totalUsd = (BigInt(data.total_nanos) / BigInt(1e12)).toString();
console.log(`Invoice ${data.invoice_number} finalized: $${totalUsd}`);
// Send invoice email, update records, etc.
break;
}
case "subscription.past_due": {
console.log(`Subscription ${data.subscription_id} is past due`);
// Restrict access, send warning email, etc.
break;
}
case "payment.succeeded": {
const amountUsd = (BigInt(data.amount_nanos) / BigInt(1e12)).toString();
console.log(`Payment for invoice ${data.invoice_id}: $${amountUsd}`);
// Unlock features, send receipt, etc.
break;
}
case "spend_alert.triggered": {
const thresholdUsd = (BigInt(data.threshold_nanos) / BigInt(1e12)).toString();
console.log(`${data.alert_type} triggered for ${data.customer_id} at $${thresholdUsd}`);
break;
}
default:
console.log(`Unhandled event type: ${type}`);
}
res.status(200).send("ok");
});
app.listen(3000, () => console.log("Webhook server on port 3000"));
// [Go]
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
)
var webhookSecret = os.Getenv("BILLING_WEBHOOK_SECRET")
type WebhookEvent struct {
ID string `json:"id"`
Type string `json:"type"`
Timestamp string `json:"timestamp"`
Data map[string]interface{} `json:"data"`
}
func handleBillingWebhook(w http.ResponseWriter, r *http.Request) {
payload, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
signature := r.Header.Get("X-Billing-Signature")
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write(payload)
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(signature)) {
http.Error(w, "Invalid signature", http.StatusBadRequest)
return
}
var event WebhookEvent
if err := json.Unmarshal(payload, &event); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
switch event.Type {
case "invoice.finalized":
log.Printf("Invoice %v finalized: %v nanos",
event.Data["invoice_number"], event.Data["total_nanos"])
case "subscription.past_due":
log.Printf("Subscription %v is past due", event.Data["subscription_id"])
case "payment.succeeded":
log.Printf("Payment for invoice %v: %v nanos",
event.Data["invoice_id"], event.Data["amount_nanos"])
case "spend_alert.triggered":
log.Printf("%v triggered for customer %v",
event.Data["alert_type"], event.Data["customer_id"])
default:
log.Printf("Unhandled event: %s", event.Type)
}
fmt.Fprint(w, "ok")
}
func main() {
http.HandleFunc("/billing/webhooks", handleBillingWebhook)
log.Fatal(http.ListenAndServe(":8080", nil))
}
Stripe Webhooks
Stripe payment events are received at POST /v1/webhooks/stripe. The endpoint verifies the Stripe-Signature header using your STRIPE_WEBHOOK_SECRET before processing:
# [curl]
# Configure in Stripe Dashboard:
# Endpoint URL: https://api.bill.sh/v1/webhooks/stripe
# Events to listen for: payment_intent.*, invoice.*
# Test with Stripe CLI
stripe listen --forward-to https://api.bill.sh/v1/webhooks/stripe
# [Python]
# Stripe webhook verification uses their SDK
import stripe
stripe.api_key = STRIPE_SECRET_KEY
STRIPE_WEBHOOK_SECRET = "whsec_..."
@app.route("/stripe/webhooks", methods=["POST"])
def stripe_webhook():
payload = request.get_data()
sig_header = request.headers.get("Stripe-Signature")
try:
event = stripe.Webhook.construct_event(payload, sig_header, STRIPE_WEBHOOK_SECRET)
except stripe.error.SignatureVerificationError:
abort(400, "Invalid Stripe signature")
# Handle event...
return "", 200
// [Node.js]
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
app.post("/stripe/webhooks", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers["stripe-signature"],
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Handle event...
res.status(200).send("ok");
});
// [Go]
import "github.com/stripe/stripe-go/v76/webhook"
func handleStripeWebhook(w http.ResponseWriter, r *http.Request) {
payload, _ := io.ReadAll(r.Body)
event, err := webhook.ConstructEvent(
payload,
r.Header.Get("Stripe-Signature"),
os.Getenv("STRIPE_WEBHOOK_SECRET"),
)
if err != nil {
http.Error(w, "Invalid signature", http.StatusBadRequest)
return
}
log.Printf("Stripe event: %s", event.Type)
w.WriteHeader(http.StatusOK)
}
Retry Policy
Failed webhook deliveries are retried with exponential backoff:
- Attempt 1: immediate
- Attempt 2: +30 seconds
- Attempt 3: +5 minutes
- Attempt 4: +30 minutes
- Attempt 5: +4 hours
After 5 attempts, the delivery is abandoned and the failure is logged.
Circuit Breaker
The webhook delivery system includes a circuit breaker. After 5 consecutive failures to the same endpoint, the circuit opens and deliveries are suspended for 60 seconds. This prevents piling up retries against a down endpoint.
Webhook Payload Format
{
"id": "evt-01944b1f-0000-7000-8000-000000000060",
"type": "invoice.finalized",
"timestamp": "2026-02-28T12:00:00Z",
"data": {
"invoice_id": "01944b1f-0000-7000-8000-000000000004",
"customer_id": "01944b1f-0000-7000-8000-000000000001",
"invoice_number": "INV-000001",
"total_nanos": "9990000000000",
"currency": "USD"
}
}
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
quantityon subscription creation for seat-based plans - Use
prorate: trueon 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'))"
Step 2: Link a Subscription to the Contract
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
ContractTermphases with differentcommitted_amount_nanos - True-up runs at phase boundaries to collect any shortfall
- Amendment is immutable: the
parent_contract_idfield 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
| Prefix | Type | Use |
|---|---|---|
bsk_live_* | Secret (Admin) | Full admin access — server-side only, never expose |
bpk_live_* | Publishable | Read-only, customer-facing |
brk_live_* | Restricted | Custom 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.
| Method | Path | Description |
|---|---|---|
POST | /v1/subscriptions | Create a subscription |
GET | /v1/subscriptions/:id | Get a subscription |
POST | /v1/subscriptions/:id/cancel | Cancel a subscription |
GET | /v1/subscriptions/:id/invoices | List invoices for subscription |
GET | /v1/subscriptions/:id/usage | Get usage summary |
GET | /v1/invoices/:id | Get an invoice |
POST | /v1/invoices/:id/finalize | Finalize a draft invoice |
POST | /v1/events | Ingest a usage event |
POST | /v1/webhooks/stripe | Stripe webhook receiver |
Admin API (/admin/v1/)
Used by your Finance and Support teams for customer management, invoice operations, and financial reporting.
| Method | Path | Description |
|---|---|---|
GET | /admin/v1/customers | List all customers |
POST | /admin/v1/customers | Create customer |
GET | /admin/v1/customers/:id | Customer 360 view |
GET | /admin/v1/customers/:id/hierarchy | Entity hierarchy |
GET | /admin/v1/customers/:id/credits | List wallet credits |
POST | /admin/v1/customers/:id/credits | Issue wallet credit |
GET | /admin/v1/customers/:id/alerts | List spend alerts |
POST | /admin/v1/customers/:id/alerts | Create spend alert |
POST | /admin/v1/customers/:id/alerts/:aid/reset | Reset triggered alert |
GET | /admin/v1/invoices | List invoices |
GET | /admin/v1/invoices/:id | Get invoice |
POST | /admin/v1/invoices/:id/void | Void invoice |
POST | /admin/v1/invoices/:id/finalize | Finalize invoice |
POST | /admin/v1/invoices/:id/pay | Mark paid |
POST | /admin/v1/invoices/:id/credit-note | Issue credit note |
POST | /admin/v1/invoices/:id/calculate-tax | Apply tax |
POST | /admin/v1/subscriptions/:id/pause | Pause subscription |
POST | /admin/v1/subscriptions/:id/resume | Resume subscription |
POST | /admin/v1/subscriptions/:id/bill | Manual billing run |
GET | /admin/v1/catalog/products | List products |
POST | /admin/v1/catalog/products | Create product |
GET | /admin/v1/catalog/plans | List plans |
POST | /admin/v1/catalog/plans | Create plan |
POST | /admin/v1/catalog/skus | Create SKU |
GET | /admin/v1/reports/ar-aging | AR aging report |
GET | /admin/v1/reports/mrr | MRR/ARR report |
GET | /admin/v1/audit | Audit log |
GET | /admin/v1/audit/:entity_type/:entity_id | Entity 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
| Environment | Base URL |
|---|---|
| Production | https://api.bill.sh |
| Local dev | http://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:
- openapi-generator — supports 50+ languages
- Speakeasy — generates high-quality SDKs
- Postman — import directly for a full workspace
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
| Component | Technology | Notes |
|---|---|---|
| Language | Rust 2021 | Fearless concurrency, zero-cost abstractions |
| HTTP Framework | Axum 0.7 | Tower middleware, typed extractors |
| Ledger | TigerBeetle | In-memory shim in dev; TB client in prod |
| Database | CockroachDB | REGIONAL BY ROW multi-region schema |
| Analytics | ClickHouse | Metering aggregation, MV queries |
| Event streaming | Kafka | billing.<service>.<event> topic naming |
| Cache | Redis | Rate limiting (token bucket), hot-path accumulators |
| Payments | Stripe | PaymentIntent API + webhook verification |
| Auth | JWT HS256 + API keys | Constant-time comparison |
| Serialization | serde + serde_json | All 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
- Correctness over performance: i128 pico-units eliminate money rounding bugs
- Pure functions for business logic:
rate_all(),prorate_days(),compute_true_up()have no I/O — deterministic and easily tested - Idempotency everywhere: All mutating operations safe to retry
- In-memory for development: All repositories have
InMemory*implementations — no database required for local dev or tests - Swap-in production backends: Implement
Repositorytraits with CockroachDB/ClickHouse/TigerBeetle for production
Service Crates
| Crate | Responsibility |
|---|---|
common | Shared types: Money, NanoMoney, typed IDs, Currency, BillingError, TaxAdapter |
metering | UsageEvent ingestion, MeterDefinition, aggregation, EventStore |
rating | PriceSheet, rate_all() pure engine, RatedLineItem |
pricing | CatalogService, PriceVersionService, plan/product/SKU CRUD |
subscriptions | SubscriptionService, state machine, proration engine |
invoicing | InvoicingService, CalculateFeesService, FinalizeService, CreditNoteService |
contracts | ContractService, amendment chain, coterm, commit draw-down |
ledger | TigerBeetle client wrapper, double-entry transfers |
dunning | DunningSchedule, retry scheduler |
payments | StripeClient, PaymentIntent, webhook verification |
notifications | Outbox pattern, webhook delivery, circuit breaker |
audit | AuditLog, 25+ action types, before/after state |
reporting | AR aging, MRR movements, deferred revenue (ASC 606) |
wallet | CreditWalletService, auto-recharge |
spend-alerts | SpendAlertService, SoftLimit/HardLimit |
customers | CustomerService, hierarchy, entity tree |
engine | BillingEngine orchestrator (ties all services together) |
demo | Seed 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
i128integers with scale=12 (pico-units). Field names always end in_nanos. Never usef64for 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 amount | Internal representation |
|---|---|
| $0.00 | 0 |
| $0.000001 | 1_000_000 |
| $0.01 | 10_000_000_000 |
| $1.00 | 1_000_000_000_000 |
| $9.99 | 9_990_000_000_000 |
| $100.00 | 100_000_000_000_000 |
| $1,000,000.00 | 1_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.000001per token =0.000001 * 100 = 0.0001cents → 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-Replayedresponse header - Spend Alerts: New
spend-alertscrate —SoftLimit/HardLimitalert 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+MeteringServiceinAppState - Tax Engine:
TaxAdaptertrait +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 athttps://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:
EventPublishertrait,StubEventPublisher,RdkafkaProducer(feature-gated) - CockroachDB Schema: Multi-region
REGIONAL BY ROWschema ininfra/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:
ContractTermphases 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-auditcrate — 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— convertsRatedLineItem→InvoiceLineItem - Invoice Finalization:
FinalizeService—finalize_auto()withArc<AtomicU64>sequential counter (INV-XXXXXX) - Progressive Billing:
ProgressiveBillingService— threshold-triggered finalize + new draft - Period Advance:
advance_period()on subscriptions — movescharged_through_date - Stripe Integration:
StripeClient—create_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
commoncrate: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