quotastack Docs
Docs / Changelog / 2026-07-25

2026-07-25

Three breaking changes: subscription lookup by UUID only, a unified subscription.renewed payload, and failed renewals no longer advancing the period. Plus idempotency replay semantics on consume, a new overage reconciliation endpoint, and working prepaid lifecycle webhooks.

2026-07-25

This release contains three breaking changes. If you integrate subscriptions or consume the subscription.renewed webhook, read the migration notes below before upgrading.

Breaking changes

1. GET /v1/subscriptions/{id} takes a subscription UUID only

The undocumented customer-id lookup is gone. Passing a customer identifier where a subscription UUID is expected now returns 404.

# Before — worked by accident, undocumented
GET /v1/subscriptions/{customer_id}

# After — 404
GET /v1/subscriptions/{customer_id}

Replacement: list subscriptions filtered by customer.

GET /v1/subscriptions?customer_id={customer_id}

The list endpoint also accepts status, cursor, and limit, and returns the standard { data, pagination } envelope.

The route is now environment-scoped. A sandbox key (qs_test_...) can no longer read a live subscription by UUID, and vice versa — the lookup resolves within the API key’s environment only. If you have tooling that reads live subscriptions with a sandbox key, it will now 404.

Migration: replace GET /v1/subscriptions/{customer_id} with GET /v1/subscriptions?customer_id={customer_id} and read data[0] (or filter by status). Audit any cross-environment reads.

2. subscription.renewed is one unified payload for every API-origin renewal path

Previously data was a raw subscription object. It is now a renewal summary, identical in shape across every path that rolls a subscription into a new period: scheduler auto-advance (postpaid), POST /v1/subscriptions/{id}/renew (prepaid), and scheduled 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": {
        "chat_message": 30000,
        "image_generation": 12000
      },
      "total_overage": 2000,
      "overage_by_billable_metric": {
        "image_generation": 2000
      }
    }
  }
}

subscription_id, customer_id, billing_mode, prior_period, and new_period are always present. usage_summary is omitted entirely if the prior-period usage read fails — a partial payload is delivered rather than the webhook being dropped. Within usage_summary, total_overage and overage_by_billable_metric are present only when the overage read succeeds. All amounts are millicredits.

Migration: handlers that read subscription fields directly off data (e.g. data.status, data.plan_variant_id, data.current_period_end) will break. Map them across:

Old (data = subscription)New
data.iddata.subscription_id
data.customer_iddata.customer_id (unchanged)
data.current_period_startdata.new_period.start
data.current_period_enddata.new_period.end
data.status, data.plan_variant_id, other fieldsNot in the payload — fetch via GET /v1/subscriptions/{id} if you need them

Handlers that previously branched on billing mode to decide whether usage_summary exists can drop that branch: prepaid renewals now carry the same summary postpaid ones do.

3. A failed POST /renew no longer advances the billing period

Previously a grant failure mid-renewal could leave the period advanced with no credits granted — the customer moved into a new cycle with an empty balance, and a retry would advance the period a second time.

Now a grant failure returns 5xx with nothing persisted. The period does not advance, no ledger entries are written, and no webhook fires.

Retrying the same renewal is safe. Grants dedupe on their idempotency key, so the period advances exactly once no matter how many times you retry. If your renewal path had compensating logic to detect and repair half-applied renewals, you can remove it.

Changed — POST /v1/subscriptions/{id}/renew semantics

These are bugfix-to-spec corrections: renew now behaves the way the docs always described. If you built around the old behavior, these will change what you observe.

  • Rollover rules now apply. rollover_percentage and max_rollover_cycles finally take effect for prepaid subscriptions. Renew is prepaid’s only grant path, so these settings were previously inert — a prepaid plan configured with 50% rollover was granting the flat base amount every cycle. It now carries unused credit forward as configured. Expect granted amounts on prepaid renewals to change if you had rollover configured.
  • ISO-8601 cadence grants are now skipped. Grants with an interval like PT5H fire only on their own schedule. Renew previously double-fired them — granting once on the cadence and again on renewal. If you have sub-cycle cadence grants, per-cycle totals will drop to the correct amount.
  • Racing renewals resolve cleanly. If you call /renew while the scheduler is renewing the same subscription (or two of your workers race), the loser gets 200 with credits_granted: 0. Treat that as “already renewed” and succeed — it is not an error, and no second grant occurred.

Added — idempotency replay semantics on POST /v1/entitlements/consume

Reusing an Idempotency-Key after the 24-hour HTTP idempotency cache has expired no longer re-executes the debit. The duplicate reaches the service, which recognizes the key and replays the original outcome.

Same key, same body → 200 with reason: duplicate_replay. The response carries the exact original outcome: the original request’s units and estimated_cost, and the original debit’s exact split — debited and overage echo what actually happened the first time, not what would happen against the current balance.

That last point matters for overage. If the original consume drained partial credit and recorded the remainder as overage, the replay reports debited: true and the original overage amount. It never collapses to a generic success with the overage silently dropped. When the original response’s own business reason was overage_recorded, it is preserved in original_reasonreason itself always stays duplicate_replay on a replay.

Same key, different body → 409 application/problem+json with type https://api.quotastack.io/errors/idempotency-key-reuse. The hash that decides replay-vs-conflict covers customer_id + billable_metric_key + units only; metadata is excluded — changing only metadata under the same key still replays rather than conflicting.

Dedup is durable. Receipts are permanent server-side — there is no expiry after which a duplicate silently re-debits. A lost response can always be recovered by replaying the same request with the same key.

Best practice: mint a unique key per logical operation, derived from the business event (message ID, request ID, job ID). Do not reuse a key across different operations, and do not vary the body under a key you have already used.

See Idempotency for the full contract.

Added — GET /v1/customers/{customer_id}/overage

Per-metric overage totals over a period. This is the arrears-reconciliation path for prepaid overage — you no longer have to wait for, or replay, the subscription.renewed webhook to invoice what a customer overran.

GET /v1/customers/{customer_id}/overage?from=2026-04-01T00:00:00Z&to=2026-05-01T00:00:00Z
{
  "data": [
    { "billable_metric_key": "image_generation", "overage_mc": 2000 },
    { "billable_metric_key": "chat_message", "overage_mc": 500 }
  ],
  "total_overage_mc": 2500,
  "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. data is sorted by metric key. Amounts are millicredits.

ResponseWhen
200Window is valid. data is empty and total_overage_mc is 0 if no overage accrued.
422Bad window — missing from/to, unparseable RFC3339, or to not after from.
404Unknown customer. Reads never create a customer.

A by-external-id twin is available at GET /v1/customer-by-external-id/{external_id}/overage with identical parameters and response shape.

See Billing Overage in Arrears for the full reconciliation recipe.

Fixed — prepaid lifecycle webhooks

  • subscription.renewal_due now actually fires. It was previously unreachable. It fires renewal_due_days before period end, once per cycle. This is the signal to charge your customer and call renew. The full loop: subscription.renewal_due → you charge your PSP → POST /v1/subscriptions/{id}/renew → credits granted → subscription.renewed.
  • subscription.contract_ending_soon now fires at its configured threshold. It could previously fire late, past contract_ending_soon_days.

Fixed — async usage delivery guarantees

Failed usage events retry up to 5 deliveries, then dead-letter. There is no age limit. Usage delayed by an outage of any length is still billed exactly once — dedup is server-side and permanent, so a redelivery that arrives days later is applied if it was never applied, and ignored if it was.

Two outcomes are terminal and never retried: insufficient credit under a block overage policy, and duplicate events. Both are recorded decisions, not failures.

If you previously read that usage events older than 24 hours were discarded, that is no longer true — and never shipped as final behavior. Delayed usage is billed.

Fixed — customer validation errors return 422, not 500

Validation failures on customer create and update now return 422 application/problem+json with a validation_errors array. They previously returned 500, which made them look transient and retryable when they were not.

{
  "type": "https://api.quotastack.io/errors/validation-error",
  "title": "Validation Error",
  "status": 422,
  "detail": "One or more fields failed validation",
  "validation_errors": [
    {
      "field": "external_id",
      "message": "external_id is required",
      "code": "required"
    }
  ]
}

Each entry carries field, message, and code. Retry logic that treated 5xx as retryable and 4xx as terminal will now correctly stop retrying malformed customer payloads.