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
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
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{
"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:
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{
"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:
{
"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
{
"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:
{
"name": "Customer spending cap, monthly",
"scope": { "type": "customer_default" },
"window": "month",
"capUsd": 5,
"mode": "block"
}A cap on one customer, an override:
{
"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.
// 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.
// 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
// the wrong role
{ "error": { "code": "forbidden", "message": "Only owners and admins can change customer spending caps." } }// 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:
capUsdis 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:
GET /platform/v1/attribution/customer-summary?period=YYYY-MMThe whole body is four keys:
{
"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:
GET /platform/v1/attribution/cuts?dim=customer&period=YYYY-MMBelow Team the answer is 403 with
{ "locked": true, "requiredPlan": "Team", "requestsMetered": 3 }and a customer row carries its basis beside the money:
{
"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.