quotastack Docs
Docs / Cookbook / Billing Overage in Arrears

Billing Overage in Arrears

Let customers keep working past their credit balance, then invoice the recorded overage each cycle through your own PSP. QuotaStack stays payment-agnostic.

Billing Overage in Arrears

Pattern: soft limits + record-and-report. Customers never hit a hard wall; you invoice what they overran at the end of the cycle.

New to overage? Overage explains the three policies, the overage record, and the read endpoint this recipe uses.

The goal

Run a postpaid (or soft-limit) model: a customer consumes past their granted credit, keeps working, and you invoice the overage after the fact — through your own payment processor. QuotaStack records the overage; you own the money movement.

How overage works

Under an allow or notify overage policy, a consume that exceeds spendable credit does not fail and does not push the balance negative. Instead QuotaStack:

  1. Drains whatever spendable credit exists, flooring the balance at zero.
  2. Records the uncovered remainder as an append-only overage event.
  3. Surfaces that amount inline on the consume response and rolls it into the next postpaid subscription.renewed webhook.

block is unchanged — it refuses usage beyond the spendable balance.

Step 1: Set the overage policy

Overage is opt-in. The tenant default is block. Set it to allow to bill in arrears:

curl -X PATCH https://api.quotastack.io/v1/tenants/{tenant_id}/config \
  -H "X-API-Key: qs_live_..." \
  -H "Idempotency-Key: config-overage:{tenant_id}" \
  -H "Content-Type: application/json" \
  -d '{ "overage_policy": "allow" }'

Use notify instead of allow to also flag the account for webhook notification. (A dedicated pre-invoice alert, credit.overage_threshold_exceeded, is planned but not yet shipped — today notify behaves like allow.)

The tenant setting is the default. Every customer without their own value uses it. A customer can set its own overage_policy field, and that value wins when set — see Overage policy. So arrears for some accounts and hard blocking for others is one PATCH call per customer.

Step 2: Let consume run past zero

Once spendable credit is exhausted, POST /v1/entitlements/consume still succeeds:

POST /v1/entitlements/consume
Idempotency-Key: consume:img:req_98f2

{ "customer_id": "user_abc", "billable_metric_key": "image_generation", "units": 3 }

Here the customer had 1 credit (1,000 mc) of spendable balance left and requested 3 credits’ worth of work. One credit is drained; the remaining two are recorded as overage:

{
  "type": "metered",
  "allowed": true,
  "debited": true,
  "reason": "overage_recorded",
  "customer_id": "019d6258-07ba-7418-83be-58f5fde53e4e",
  "billable_metric_key": "image_generation",
  "units": 3,
  "estimated_cost": 3000,
  "balance": 0,
  "reserved_balance": 0,
  "pending_balance": 0,
  "effective_balance": 0,
  "overage": 2000,
  "subscription_status": "active",
  "overage_policy": "allow"
}

Key fields:

  • allowed: true and reason: "overage_recorded" mean the request went through. This is informational, not an error.
  • overage: 2000 is the millicredits recorded for arrears billing — present only when overage accrued.
  • balance stays at 0. It never goes negative.

Step 3: Collect the cycle’s overage from the renewal webhook

Each subscription.renewed webhook reports the old term’s overage by billable metric. All three API renew paths use the same shape. They are the postpaid job, prepaid POST /renew, and planned downgrades:

{
  "event_id": "019d8a20-4ff5-7be0-81da-e1454b3d6f64",
  "event_type": "subscription.renewed",
  "tenant_id": "019d6258-07ba-7418-83be-58f5fde53e4e",
  "environment": "live",
  "customer_id": "019d6258-07ba-7418-83be-58f5fde53e4e",
  "external_customer_id": "user_abc",
  "created_at": "2026-05-01T00:00:00Z",
  "idempotency_key": "sub-renew:019d...:cycle-2",
  "data": {
    "subscription_id": "019d8a20-4ff5-7be0-81da-e1454b3d6f70",
    "customer_id": "019d6258-07ba-7418-83be-58f5fde53e4e",
    "billing_mode": "postpaid",
    "prior_period": { "start": "2026-04-01T00:00:00Z", "end": "2026-05-01T00:00:00Z" },
    "new_period": { "start": "2026-05-01T00:00:00Z", "end": "2026-06-01T00:00:00Z" },
    "usage_summary": {
      "total_credits_consumed": 42000,
      "net_balance_at_cycle_start": 50000,
      "net_balance_at_cycle_end": 58000,
      "by_billable_metric": { "image_generation": 12000, "chat_message": 30000 },
      "total_overage": 2000,
      "overage_by_billable_metric": { "image_generation": 2000 }
    }
  }
}

The payload has total_overage and overage_by_billable_metric only when the overage read works. It omits usage_summary if the old usage read fails. Handle both cases. See subscription.renewed for all fields.

Reconciling without the webhook

You do not have to depend on receiving — or replaying — the renewal webhook. GET /v1/customers/{customer_id}/overage returns per-metric overage totals for any window:

curl "https://api.quotastack.io/v1/customers/{customer_id}/overage?from=2026-04-01T00:00:00Z&to=2026-05-01T00:00:00Z" \
  -H "X-API-Key: qs_live_..."
{
  "data": [
    { "billable_metric_key": "image_generation", "overage_mc": 2000 }
  ],
  "total_overage_mc": 2000,
  "period": {
    "from": "2026-04-01T00:00:00Z",
    "to": "2026-05-01T00:00:00Z"
  }
}

from and to are both required and define a half-open window [from, to) in RFC3339 — an event exactly at to belongs to the next window, so consecutive periods never double-count. data is sorted by metric key; amounts are millicredits.

ResponseWhen
200Valid window. data is empty and total_overage_mc is 0 if nothing overran.
422Missing from/to, unparseable RFC3339, or to not after from.
404Unknown customer. Reads never create a customer.

A by-external-id twin exists at GET /v1/customer-by-external-id/{external_id}/overage with identical parameters and response shape — use it when you only have your own identifier.

This is the path to use for a monthly reconciliation job, for re-billing a cycle whose webhook you lost, or for showing a customer their current overage mid-cycle.

Step 4: Invoice through your own PSP

QuotaStack is payment-agnostic — it records the overage but never charges anyone. Convert the millicredit overage to fiat with your own pricing and raise the invoice with your processor:

def on_subscription_renewed(event):
    data = event["data"]

    # usage_summary is omitted entirely if the prior-period usage read failed.
    # Fall back to the overage endpoint rather than silently under-billing.
    summary = data.get("usage_summary")
    if summary is None:
        summary = fetch_overage(data["customer_id"], data["prior_period"])

    # total_overage is present only when the overage read succeeded.
    total_overage = summary.get("total_overage", 0)
    if total_overage > 0:
        # 1 credit = 1000 millicredits. Apply your own fiat rate.
        cents = round(total_overage / 1000 * PRICE_PER_CREDIT_CENTS)
        stripe.Invoice.create(
            # external_customer_id is on the ENVELOPE, not inside data.
            customer=event["external_customer_id"],
            amount=cents,
            description=f"Overage: {summary.get('overage_by_billable_metric', {})}",
        )


def fetch_overage(customer_id, prior_period):
    """Authoritative fallback — works for any window, webhook or not."""
    r = requests.get(
        f"https://api.quotastack.io/v1/customers/{customer_id}/overage",
        params={"from": prior_period["start"], "to": prior_period["end"]},
        headers={"X-API-Key": QS_API_KEY},
    )
    r.raise_for_status()
    body = r.json()
    return {
        "total_overage": body["total_overage_mc"],
        "overage_by_billable_metric": {
            m["billable_metric_key"]: m["overage_mc"] for m in body["data"]
        },
    }

This works for prepaid and postpaid alike — the renewal payload is now the same on both, so there is no billing-mode branch to write.

The new cycle’s credit grant fires automatically, so the customer keeps working without interruption.

Gotchas

  • Overage surfaces in three places. The overage field on the consume response, total_overage / overage_by_billable_metric on the subscription.renewed webhook, and GET /v1/customers/{customer_id}/overage for any window. The last one means a lost webhook is recoverable — you are no longer forced to persist overage the moment you see it.
  • The balance never goes negative. Under allow/notify the spendable balance floors at zero and overage is tracked separately. Don’t write settlement logic that “zeroes out a negative balance” — there is nothing negative to settle; the overage figure is purely what you invoice.
  • overage_recorded is a success. allowed is true; debited reflects whether any credit was drained. Don’t treat it as a denial.
  • Manual grants and adjustments stay strict. Overage policy applies only to consumption (consume and usage events). A negative adjust that exceeds the balance still returns 409 Conflict, even under allow.
  • block accounts don’t accrue overage. A consume that would overdraft a block customer is denied (allowed: false) and nothing is recorded. Switch a customer to block when their invoice goes overdue to cut off further usage.

See also