Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Idempotency

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

How It Works

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

Scoping

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

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

Using Idempotency Keys

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

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

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

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

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

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

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

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

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

Key Naming Conventions

Good idempotency keys should be:

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

TTL

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

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

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

Which Endpoints Support Idempotency?

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

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