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.