Sign in

API reference

Customer spending caps: the wire

The customer tag, the budget headers, the refusals and the budgets API shapes for customer spending caps.

On this page

The exact shapes. This is the one page that is technical on purpose; the plain version is Customer spending caps and the step-by-step is the guide.

"Customer" on this page means your customer. Your own account with us is your organization. Customer spending caps are set, raised and armed on Team and above.


1 · The request header

Text
x-recovea-tags: customer:<your id>
Rule Value
Header x-recovea-tags, the attribution header every request may carry
Prefix customer:
Length of the value 1 to 128 characters, any UTF-8
Separator ,, so the value may not contain one
Several header lines all read, as one list
More than one customer: piece the first decides, on the first line that declares one
Decoding the bytes are decoded as UTF-8, piece by piece
Truncation never
Case, spaces significant inside the value; whitespace around a piece is trimmed
Reaches your provider no, it is removed before the request is forwarded
Optional yes; a request with no customer: piece counts against the organization and the key only

The other three namespaces (team:, feature:, enduser:) and the 32-tag limit are on Tags.


2 · The response headers

Five values in two tiers.

-scope and -window ride together on every cap refusal and on every response served under an armed cap of any kind: organization, key, run, customer. On a served response the pair names the tightest armed cap the request is bound by, the same cap a refusal would name; where the three figures ride it names the customer's own cap instead, because one response describes one cap.

-remaining, -cap and -resets-at ride only a response served under an armed customer spending cap. A stored cap that is not armed prints nothing. On a refusal -remaining and -cap are never written at all, and -resets-at rides a customer refusal alone.

Header Format Served, armed customer cap Served, armed cap of another kind Customer refusal Other cap refusal
x-recovea-budget-scope organization · key · run · customer customer the cap's kind customer the cap's kind
x-recovea-budget-window day · week · month · year · lifetime · run present present present present
x-recovea-budget-remaining dollars, exactly six decimals (4.998740) present absent absent absent
x-recovea-budget-cap dollars, exactly six decimals present absent absent absent
x-recovea-budget-resets-at RFC 3339 UTC (2026-11-01T00:00:00Z) present; absent on a lifetime cap absent present; absent on a lifetime cap absent

When the whole family is absent: no armed cap of any kind stood over the request (nothing armed on the organization or the key, and no armed customer cap either), or the only cap that matched has its hard stop off; and every refusal that read no cap figure, which is a 401, a tenant refusal, a throttle, the 400 for a malformed tag, the overflow 402 and the kill switch's 402.

Three cases that lose the figures and keep the pair: a request with no customer: piece, a tagged request for a customer with no armed cap, and a customer above the 10,000 bound that was therefore never counted. Each of them still hears the pair wherever an armed cap of yours is standing over the request: declaring a customer cannot conjure a figure about a cap nobody set.

remaining semantics. The customer's own cap minus that customer's counter after this request is admitted, the smallest across that customer's own windows, clamped at 0.000000. It is never your organization's or your key's headroom, even when one of those is tighter, and the pair beside it says customer for the same reason. Treat it as the most that is left, never the least: requests still running for the same customer can lower it. 0.000000 on a 200 means the next request for this customer is refused; it is a rare reading, because the entry quoted is armed and the request in front of it already cleared the comparison, so zero arrives through pricing, a peer node, or a cap you lowered. Act on a small figure, not a zero one.

scope is a kind, never an identifier. No header value carries a customer value, a key id, a budget id or an organization id. That is what makes the family safe to return.

These headers are for your server. Do not forward them to a browser or a customer-facing client.


3 · The refusal

Text
HTTP/1.1 402 Payment Required
content-type: application/json
x-recovea-request-id: req_…
x-recovea-budget-scope: customer
x-recovea-budget-window: month
x-recovea-budget-resets-at: 2026-11-01T00:00:00Z
JSON
{
  "error": {
    "message": "Monthly budget reached for this customer. Traffic resumes at reset, or raise the cap.",
    "type": "insufficient_quota",
    "param": null,
    "code": "budget_exceeded"
  }
}

The field order is message, type, param, code. The status, the type, the code and the null param do not move with the scope or the window: branch on the code, not the status. Read error.code beside x-recovea-budget-scope: budget_exceeded with customer is that customer's included usage ending, and any other scope is a cap of your own.

The message is composed as <window opening> + <scope suffix> + ". " + <window closing>, so the customer sentences are:

Window The sentence, verbatim
day Daily budget reached for this customer. Traffic resumes at reset, or raise the cap.
week Weekly budget reached for this customer. Traffic resumes at reset, or raise the cap.
month Monthly budget reached for this customer. Traffic resumes at reset, or raise the cap.
year Yearly budget reached for this customer. Traffic resumes at reset, or raise the cap.
lifetime Lifetime budget reached for this customer. This budget does not reset; raise the cap to continue.

The organization's and a key's sentences are on Cap. On a Free organization still holding a customer cap armed before it left Team, the sentence ends with a second one that names the Free ceiling (On Free).

No refusal body ever names an identifier: not the customer value, not a key id, not a budget id, not a project string. x-recovea-request-id is the join key to your own record, and each refusal is kept in the record.

mode must be block. A customer spending cap stops; it does not pause, so no customer refusal ever answers 429.

When the spend cannot be confirmed

A node that cannot confirm a customer's spend refuses rather than guess, and says so with a different status:

Text
HTTP/1.1 503 Service Unavailable
retry-after: 1
x-recovea-budget-scope: customer
x-recovea-budget-window: month
x-recovea-budget-resets-at: 2026-11-01T00:00:00Z
JSON
{
  "error": {
    "message": "Monthly spend for this customer cannot be confirmed right now. Retry in a moment.",
    "type": "insufficient_quota",
    "param": null,
    "code": "budget_unverifiable"
  }
}

It is temporary and it is ours. The official OpenAI and Anthropic SDKs retry a 503 by default; a bare HTTP client should retry after Retry-After. Never show your customer a quota message for it (Fail-open and fail-closed).


4 · The 400 for a malformed tag

On an organization holding an armed customer spending cap, a value that is empty, longer than 128 characters, or not valid UTF-8 is refused. Those are the three triggers, and all three carry this one body. It is built from constants and takes no argument, so it is the same bytes for every malformed tag:

JSON
{
  "error": {
    "message": "The customer tag must be 1 to 128 characters.",
    "type": "invalid_request_error",
    "param": "x-recovea-tags",
    "code": "invalid_customer_tag"
  }
}

400, and the body never echoes the value. On an organization with no armed customer spending cap the malformed value is dropped and the request is counted with no customer: there is no cap for it to be wrong about.


5 · The overflow refusal

JSON
{
  "error": {
    "message": "Customer spending cap bound reached: 10,000 distinct customers this period. New customers are refused until the period resets, or contact us to raise the bound.",
    "type": "insufficient_quota",
    "param": null,
    "code": "customer_cardinality_exceeded"
  }
}

402, fired only where a customer spending cap is armed, and only for a customer we have not counted in the calendar month. Nothing is charged. The number in the sentence is the per-organization bound in force, so a raised bound never reads the default. A customer holding a cap of its own is admitted past the bound.


6 · The budgets API

POST /platform/v1/budgets and PATCH /platform/v1/budgets/{id} take the scope as an object. These are platform routes: they are authenticated by your signed-in session, and an rcv_ gateway key presented as a bearer is read as a Viewer of its own organization and cannot write a budget.

The cap for every customer, the default rule, which names nobody:

JSON
{
  "name": "Customer spending cap, monthly",
  "scope": { "type": "customer_default" },
  "window": "month",
  "capUsd": 5,
  "mode": "block"
}

A cap on one customer, an override:

JSON
{
  "name": "Customer 8812, monthly",
  "scope": { "type": "customer", "customer": "8812" },
  "window": "month",
  "capUsd": 50,
  "mode": "block"
}
Field Rule
scope.type customer_default is spelled out on the wire, exactly so
scope.customer 1 to 128 characters, no comma; a comma is its own refusal because the tag header splits on commas
window day · week · month · year · lifetime
capUsd greater than zero; 0 is refused with "capUsd must be greater than zero."
mode block only for these two scopes; pause is refused with "A customer spending cap stops; it does not pause."
thresholds, recipients accepted, and on a customer cap they send no mail
Reads a read answers with the same two shapes byte for byte

POST /platform/v1/budgets/{id}/arm and /disarm are the hard stop. confirmHardStop=true is required on arm for a yearly cap.

The conflicts

One default rule per window, and one override per customer per window. A second of either is a 409.

JSON
// a second default rule on the same window
{
  "error": {
    "code": "default_rule_exists",
    "message": "A cap for every customer already exists on this window. Edit that one, or set this cap on another window."
  },
  "existingBudgetId": "bgt_…"
}

existingBudgetId sits beside error, not inside it, the one detail a client gets wrong by guessing. It is absent, never empty, when the existing row could not be named; the refusal is still correct and still actionable from the list.

JSON
// a second cap for one customer on the same window
{
  "error": {
    "code": "duplicate_customer_cap",
    "message": "This customer already has a cap on this window. Edit that cap, or set this one on another window."
  }
}

This one carries no id, ever: naming the row would mean naming a customer in a refusal body.

The two 403s

JSON
// the wrong role
{ "error": { "code": "forbidden", "message": "Only owners and admins can change customer spending caps." } }
JSON
// the wrong plan
{
  "error": {
    "code": "entitlement_required",
    "message": "Customer spending caps require the Team plan or higher. Caps on your organization and on your API keys keep working on every plan."
  },
  "locked": true,
  "requiredPlan": "Team"
}

requiredPlan is the display name of the plan, and it comes from one server constant rather than from any client.

On a patch the plan fence is a widening fence. It fires on

  • a move: the target scope is a customer scope and differs from the stored one;
  • a raise: capUsd is greater than the stored cap while the effective scope is a customer scope; or
  • an arm: the stored scope is a customer scope, whatever the cap's value.

Creating a customer cap meets the same fence at any value. Lowering, renaming, thresholds, recipients and the window stay open on every plan, and disarm and delete were never plan-gated at all, so turning a hard stop off below Team is one-way: arming it again needs the plan back. An armed cap keeps refusing when an organization drops below Team, because the enforcing node reads no plan.


7 · The count and the per-customer figures

The count, on every plan, Free included:

HTTP
GET /platform/v1/attribution/customer-summary?period=YYYY-MM

The whole body is four keys:

JSON
{
  "period": "2026-10",
  "customersSeen": 4,
  "spend": { "microUsd": 2500000, "display": "$2.50" },
  "invalidTagRequests": null
}

customersSeen is a live count over the record, and spend is the total of the customer cut's monthly rows other than untagged; each request is added to its row in the same step that records it, and one that could not be priced adds nothing, so a non-zero count can sit beside a zero total. invalidTagRequests is always null: the 400s for a malformed tag are not counted anywhere, and null says so where a 0 would claim a count. There is no field for a per-customer row, by construction.

The per-customer figures, on Team and above:

HTTP
GET /platform/v1/attribution/cuts?dim=customer&period=YYYY-MM

Below Team the answer is 403 with

JSON
{ "locked": true, "requiredPlan": "Team", "requestsMetered": 3 }

and a customer row carries its basis beside the money:

JSON
{
  "dim": "customer", "value": "8812", "requests": 12,
  "spendMicroUsd": 7400000, "spend": "$7.40", "share": 0.8809,
  "unpricedRequests": 3,
  "priceLists": "one",
  "priceListVersion": "rpl-2026-09-22",
  "capMicroUsd": 5000000, "cap": "$5.00",
  "leftMicroUsd": 0, "left": "$0.00",
  "lastRefusalAt": "2026-10-14T14:02:11.413Z",
  "spendAtRefusalMicroUsd": 4980000, "spendAtRefusal": "$4.98"
}
Field What it means
value the customer: tag as you sent it, with one exception: the row "value": "untagged" holds every request in the period that carried no customer: piece, so the cut still sums to the month's total. It is not a customer: no customer cap can refuse that traffic, so cap and left are always null there, and spend-descending often puts it first. Drop it before you build a per-customer table
spend / spendMicroUsd the priced record for the period: your provider's reported tokens priced at the list, the number to bill from
unpricedRequests requests in this cut that carry no price: a model that is not on the price list, or a response we could not price at all. Counted, never totaled into spendMicroUsd. They count toward the cap while they run: each one counts toward the counter at its floor, and when it ends its floor is released on the node that admitted it, so the cap does not bound their spend. In two cases a floor outlasts its request. When the gateway's nodes exchanged the counter while the request ran, the floor can stay on the counter until the cap's window ends, since an exchange only ever raises a counter. While a send from the gateway to its metering queue is slow, the floors of requests that have already ended stay on the node that admitted them, and a cap can refuse a new request on those floors. Once the send returns they are released, or replaced by the requests' prices; the gateway sets no time limit on that wait. A request whose client left after it was sent has its floor released too, though your provider may bill the call. The cut and the cap's counter are two different figures
priceLists which price lists priced this customer's requests in the period: "one" when one list priced every priced request (priceListVersion names it), "more_than_one" when the period spans two lists, and "none" when no request in the period was priced. null when the record holds no request under this row's value in the period, so there is nothing to read it from: usually the untagged row, unless your requests sent that literal tag
priceListVersion the one price list that priced this customer's requests in the period; null when the period spans two lists, and null when nothing in it could be priced, rather than naming one of them. Its meaning is unchanged: priceLists says which of the two a null is
cap / capMicroUsd the armed monthly cap that applies: that customer's own monthly override, else the monthly rule over every customer. Never a sum. null when no armed cap runs on this cut's own clock (a cap on another window is not shown here and is never stood in for), when the cap is stored but not armed, and on that one row
left / leftMicroUsd cap − spend, floored at zero, for the month the cap's counter is on now and no other. null on every closed or future month, even where cap is a figure
lastRefusalAt, spendAtRefusal, spendAtRefusalMicroUsd when a cap last stopped this customer and what the enforcing node's counter read when it decided. All three or none: absent on a customer with no refusal recorded, and absent when the last refusal was a 503, which carries no figure. The counter's reading is routinely below spend, because the request that crossed the line completes and is priced after the decision

Rows are spend-descending. Every money field is an integer number of micro-dollars beside its display string, and the unit is in the name: there is one money type here and it is USD.


See also