---
title: "2026-07-25"
description: "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."
order: 8
date: "2026-07-25"
---

# 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**.

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

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

**Replacement:** list subscriptions filtered by customer.

```bash
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.

```json
{
  "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.id` | `data.subscription_id` |
| `data.customer_id` | `data.customer_id` (unchanged) |
| `data.current_period_start` | `data.new_period.start` |
| `data.current_period_end` | `data.new_period.end` |
| `data.status`, `data.plan_variant_id`, other fields | Not 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_reason` — `reason` 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](/docs/concepts/idempotency#replay-semantics-on-consume) 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.

```bash
GET /v1/customers/{customer_id}/overage?from=2026-04-01T00:00:00Z&to=2026-05-01T00:00:00Z
```

```json
{
  "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.

| Response | When |
|---|---|
| `200` | Window is valid. `data` is empty and `total_overage_mc` is `0` if no overage accrued. |
| `422` | Bad window — missing `from`/`to`, unparseable RFC3339, or `to` not after `from`. |
| `404` | Unknown 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](/docs/cookbook/overage-billing-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.

```json
{
  "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.
