Spend cap check
Does this offer fit within the cap you supplied?
Before an agent commits to a purchase, spend_cap multiplies the offer's unit price by its quantity, exactly, and compares the total with the remaining cap you supply. It signs the result when signing succeeds.
/v1/verify-factsFree during the beta.Answers at https://api.tanilo.io, with no key, rate-limited per network.
30-second summary
- It is a check type of POST /v1/verify-facts. No model is involved: the result is arithmetic and exact comparison, and anyone holding the same input can recompute it using the same published rule version.
- You supply the cap and what has already been spent against it; this check does not retrieve or track budgets, and keeps no running total.
- Three results: verified, contradicted, or indeterminate with a reason.
- A verified result says the offer's total is within the remaining cap. Whether to pay is your policy's decision.
- A valid signature shows which key signed these bytes and that they have not changed since. It does not show that the supplied cap was genuine or that the purchase is sensible.
Example
A seller offers three calls at USD 12.50. Your October cap is USD 100.00, of which USD 70.00 is already spent, so USD 30.00 remains. The total is USD 37.50: USD 7.50 over.
subject.claim_hash is required: a request without it gets HTTP 400 with error missing_subject_claim_hash. For subject.claim_hash, the service checks only that it is present: it does not check the format or recompute it from the checks. We recommend sha256- followed by the lowercase SHA-256 hex digest of the RFC 8785 canonical JSON of the check's input object, or of the whole checks array when a request carries more than one check. Request template: replace the subject.claim_hash placeholder with that value for this request's input object. The placeholder is not a valid hash.
curl -sS https://api.tanilo.io/v1/verify-facts \
-H "content-type: application/json" \
-d '{
"subject": { "claim_hash": "sha256-<SHA-256 of the canonical JSON of the input object>" },
"checks": [ { "check_type": "spend_cap", "input": {
"offer": { "unit_price": "12.50", "currency": "USD", "quantity": "3", "offered_at": "2026-10-10T12:00:00Z", "seller": "seller.example", "sku": "api.weather.v1" },
"cap": { "cap_id": "api-budget-2026-10", "amount": "100.00", "currency": "USD", "scope": "per_period", "already_spent": "70.00",
"period": { "from": "2026-10-01T00:00:00Z", "to": "2026-11-01T00:00:00Z" } }
} } ]
}'{
"jws": {
"payload": "eyJhZ2VudF9pZCI6InVybjp0YW5pbG86dmVyaWZ5LWZhY3RzOmFub255bW91cyIsImNoZWNrX21vZGUiOiJkZXRlcm1pbmlzdGljIiwiY2hlY2tfcmVzdWx0cyI6W3siY2hlY2tfdHlwZSI6InNwZW5kX2NhcCIsImV2aWRlbmNlIjp7ImNhcCI6eyJhbHJlYWR5X3NwZW50IjoiNzAuMDAiLCJhbW91bnQiOiIxMDAuMDAiLCJjYXBfaWQiOiJhcGktYnVkZ2V0LTIwMjYtMTAiLCJjdXJyZW5jeSI6IlVTRCIsInBlcmlvZCI6eyJmcm9tIjoiMjAyNi0xMC0wMVQwMDowMDowMFoiLCJ0byI6IjIwMjYtMTEtMDFUMDA6MDA6MDBaIn0sInNjb3BlIjoicGVyX3BlcmlvZCJ9LCJjYXBfYWxyZWFkeV9leGhhdXN0ZWQiOmZhbHNlLCJjYXBfc2hhMjU2Ijoic2hhMjU2LTZjMjBiMjU1YmMwMDgxNTdlNjg3ZWIyZTRiYmI4Yzc1Nzk2NDc3MmExNDIwMjBiYWVhYjQwZDgwYmRhNTI2NzkiLCJjYXBfc291cmNlIjoiY2FsbGVyX3N1cHBsaWVkIiwiZXhjZXNzIjoiNy41MCIsIm9mZmVyIjp7ImN1cnJlbmN5IjoiVVNEIiwib2ZmZXJlZF9hdCI6IjIwMjYtMTAtMTBUMTI6MDA6MDBaIiwicXVhbnRpdHkiOiIzIiwic2VsbGVyIjoic2VsbGVyLmV4YW1wbGUiLCJza3UiOiJhcGkud2VhdGhlci52MSIsInVuaXRfcHJpY2UiOiIxMi41MCJ9LCJvZmZlcl9zaGEyNTYiOiJzaGEyNTYtNzk5YWFjYjFhNzAxMmEyNjA0N2YxMDQ4MTIwOWNhMmQzZjUyYzgxM2QyMWM4ODJlYzliNTUxZDZmYTNiOWRkNiIsIm9mZmVyX3RvdGFsIjoiMzcuNTAiLCJyZWFzb24iOiJjYXBfZXhjZWVkZWQiLCJyZWFzb25fdGV4dCI6InRoZSBvZmZlcidzIHRvdGFsIGV4Y2VlZHMgdGhlIHJlbWFpbmluZyBjYXAiLCJyZW1haW5pbmciOiIzMC4wMCIsInJ1bGVfaWQiOiJzcGVuZC1jYXAvdjAuMSIsInN0YXRlIjoiY29udHJhZGljdGVkIiwic3RlcHMiOlt7Im1hdGNoZXMiOnRydWUsInJ1bGUiOiJjYXAuY3VycmVuY3kgZXF1YWxzIG9mZmVyLmN1cnJlbmN5OyBubyBjb252ZXJzaW9uIiwic3RlcCI6ImN1cnJlbmN5In0seyJwcmVzZW50Ijp0cnVlLCJydWxlIjoib2ZmZXIucXVhbnRpdHkgaXMgcHJlc2VudDsgd2l0aG91dCBpdCBubyB0b3RhbCBjYW4gYmUgY29tcHV0ZWQiLCJzdGVwIjoicXVhbnRpdHkifSx7Im9mZmVyX3RvdGFsIjoiMzcuNTAiLCJydWxlIjoib2ZmZXJfdG90YWwgPSB1bml0X3ByaWNlIMOXIHF1YW50aXR5IGJ5IGV4YWN0IGRlY2ltYWwgYXJpdGhtZXRpYzsgbm90aGluZyBpcyByb3VuZGVkIiwic3RlcCI6InRvdGFsIn0seyJhbHJlYWR5X3NwZW50IjoiNzAuMDAiLCJhbHJlYWR5X3NwZW50X2dpdmVuIjp0cnVlLCJvZmZlcl9pbl9wZXJpb2QiOnRydWUsIm9mZmVyX3RpbWVfcHJlc2VudCI6dHJ1ZSwicGVyaW9kX3ByZXNlbnQiOnRydWUsInJlbWFpbmluZyI6IjMwLjAwIiwicnVsZSI6InBlcl9wZXJpb2Q6IHBlcmlvZC5mcm9tIDw9IG9mZmVyZWRfYXQgPCBwZXJpb2QudG8gKGVuZCBleGNsdXNpdmUpOyByZW1haW5pbmcgPSBjYXAuYW1vdW50IOKIkiBhbHJlYWR5X3NwZW50IChhbHJlYWR5X3NwZW50IGRlZmF1bHRzIHRvIDAgYW5kIG1heSBleGNlZWQgYW1vdW50KSIsInNjb3BlIjoicGVyX3BlcmlvZCIsInN0ZXAiOiJyZW1haW5pbmdfY2FwIn0seyJmaXRzIjpmYWxzZSwicnVsZSI6Im9mZmVyX3RvdGFsIDw9IHJlbWFpbmluZyBieSBleGFjdCBkZWNpbWFsIHZhbHVlIChcIjFcIiBlcXVhbHMgXCIxLjAwXCIpOyBubyB0b2xlcmFuY2UsIG5vIHJvdW5kaW5nIiwic3RlcCI6ImNvbXBhcmUifV19LCJmYWlsdXJlX3JlYXNvbiI6ImNhcF9leGNlZWRlZCIsIm91dGNvbWUiOiJmYWlsIiwicmVjb21tZW5kYXRpb24iOiJvZmZlciB0b3RhbCBleGNlZWRzIHRoZSByZW1haW5pbmcgY2FwIHRoZSBjYWxsZXIgc3VwcGxpZWQiLCJzdGF0ZSI6ImNvbnRyYWRpY3RlZCJ9XSwiY2hlY2tfdHlwZXNfYXBwbGllZCI6WyJzcGVuZF9jYXAiXSwiZW52ZWxvcGVfa2luZCI6InZlcmlmaWNhdGlvbi52MC4zK2NvbXBvc2VkIiwicmVjZWlwdF92ZXJzaW9uIjoiMC4zLjAtY29tcG9zZWQiLCJzaWduYXR1cmVfbWV0YSI6eyJqd2tzX3VybCI6Imh0dHBzOi8vdGFuaWxvLmlvLy53ZWxsLWtub3duL2p3a3MuanNvbiJ9LCJzdWJqZWN0Ijp7ImNsYWltX2hhc2giOiJzaGEyNTYtMWVlMjY3MTlhNzlmZGE2MjA5MDI0MWQ4M2U1ZWY1ZDc3ZjRhMWIxMjAxZjMyN2I0ODlmZmFiMDE5ZTZiMGQ1ZiJ9LCJ0aW1lc3RhbXAiOiIyMDI2LTEwLTEzVDEzOjE0OjQwLjQzMFoiLCJ0aW1lc3RhbXBfbXMiOjE3OTE4OTcyODA0MzAsInZfZ2F0ZSI6eyJjb25maWRlbmNlIjoxLCJkZXRlcm1pbmlzdGljX21hcHBpbmdfaWQiOiJ0YW5pbG8tZGV0ZXJtaW5pc3RpYy12MC4zLTIwMjYtMTAtMTAiLCJpc3N1ZXIiOiJ0YW5pbG8uaW8iLCJtYXBwaW5nX2hhc2giOiJzaGEyNTYtNTI4NDI1MzZkNWM4NDQ0YjZhZGQ4ZThjYjdlODk4N2FhM2ZjYzBiZjQwY2M3YWMyNjZkZGMxNzQ0ZjA3MTFjZSIsIm1hcHBpbmdfaWQiOiJ0YW5pbG8tdjAuMy0yMDI2LTEwLTEzIiwic2lnbmVkX2F0IjoiMjAyNi0xMC0xM1QxMzoxNDo0MC40MzBaIiwidl9hZHZlcnNhcmlhbF9yZXN1bHQiOiJuL2EiLCJ2ZXJkaWN0IjoiaGFsdCJ9fQ",
"signatures": [
{
"protected": "eyJhbGciOiJFZERTQSIsImtpZCI6InRhbmlsby0yMDI2LTEwLWVkMjU1MTktN2Q4ODVkYTkiLCJ0eXAiOiJhcHBsaWNhdGlvbi92bmQudmVyaWZpY2F0aW9uLnYwLjMrY29tcG9zZWQrandzIiwicm9sZSI6ImV2YWx1YXRlZCJ9",
"signature": "Sn-8HAIJdF82pt_XxvQWdXYMJ2xAsCtq8cWChvj6L5ACmjpo_6I2BEasbuTQV7BuyDMFkjzAH9qVFRyGYS-8DA"
}
]
},
"canonical_sha256": "sha256-bbeb17bc2743deea478d403992528fc617b0278bcd53a3c730a3ecbb5fb2eec1",
"canonical_bytes_length": 2569,
"verdict": "halt",
"check_mode": "deterministic",
"check_types_applied": [
"spend_cap"
],
"check_results": [
{
"check_type": "spend_cap",
"outcome": "fail",
"state": "contradicted",
"recommendation": "offer total exceeds the remaining cap the caller supplied",
"evidence": {
"rule_id": "spend-cap/v0.1",
"cap_source": "caller_supplied",
"offer_sha256": "sha256-799aacb1a7012a26047f10481209ca2d3f52c813d21c882ec9b551d6fa3b9dd6",
"cap_sha256": "sha256-6c20b255bc008157e687eb2e4bbb8c757964772a142020baeab40d80bda52679",
"offer": {
"unit_price": "12.50",
"currency": "USD",
"quantity": "3",
"offered_at": "2026-10-10T12:00:00Z",
"seller": "seller.example",
"sku": "api.weather.v1"
},
"cap": {
"cap_id": "api-budget-2026-10",
"amount": "100.00",
"currency": "USD",
"scope": "per_period",
"already_spent": "70.00",
"period": {
"from": "2026-10-01T00:00:00Z",
"to": "2026-11-01T00:00:00Z"
}
},
"state": "contradicted",
"reason": "cap_exceeded",
"reason_text": "the offer's total exceeds the remaining cap",
"offer_total": "37.50",
"remaining": "30.00",
"excess": "7.50",
"cap_already_exhausted": false,
"steps": [
{
"step": "currency",
"rule": "cap.currency equals offer.currency; no conversion",
"matches": true
},
{
"step": "quantity",
"rule": "offer.quantity is present; without it no total can be computed",
"present": true
},
{
"step": "total",
"rule": "offer_total = unit_price × quantity by exact decimal arithmetic; nothing is rounded",
"offer_total": "37.50"
},
{
"step": "remaining_cap",
"rule": "per_period: period.from <= offered_at < period.to (end exclusive); remaining = cap.amount − already_spent (already_spent defaults to 0 and may exceed amount)",
"scope": "per_period",
"period_present": true,
"offer_time_present": true,
"offer_in_period": true,
"already_spent": "70.00",
"already_spent_given": true,
"remaining": "30.00"
},
{
"step": "compare",
"rule": "offer_total <= remaining by exact decimal value (\"1\" equals \"1.00\"); no tolerance, no rounding",
"fits": false
}
]
},
"failure_reason": "cap_exceeded"
}
],
"kid": "tanilo-2026-10-ed25519-7d885da9",
"envelope_kind": "verification.v0.3+composed",
"jwks_url": "https://tanilo.io/.well-known/jwks.json",
"elapsed_ms": 97
}With "already_spent": "62.50" the same offer is exactly at the cap: state verified, remaining 37.50, excess null, verdict act. With "already_spent": "62.51" it is one cent over: contradicted, excess 0.01.
A per-purchase cap
"cap": { "amount": "50.00", "currency": "USD", "scope": "per_purchase" } applies to this offer alone. With per_purchase, non-null values of already_spent and period are rejected as schema-invalid. Explicit null values are treated as absent.
Three results, never a guess
| Result | Meaning | Verdict returned |
|---|---|---|
verified | The offer's total is less than or equal to the remaining cap. | act |
contradicted | The total exceeds the remaining cap. The total, the remaining cap and the excess are recorded. cap_already_exhausted is true when the computed remaining cap is zero or below. | halt |
indeterminate | The comparison could not be made. A reason code says why. This is not a finding that the offer exceeds the cap. | halt |
act and halt are returned verdict labels. Tanilo does not execute, block or authorize a payment. Your application decides how to use the result. In a request containing multiple checks, this check being verified does not by itself establish the overall verdict.
Reasons for indeterminate: currency_mismatch (no conversion is made), quantity_missing, period_bounds_missing, offer_time_missing, offer_outside_period.
A contradicted result has reason cap_exceeded; a verified result has reason null. The rule returns the reason in reason. In the API check result, contradicted reasons appear in failure_reason and indeterminate reasons appear in indeterminate_reason.
cap_already_exhausted is true only on a contradicted result when the computed remaining cap is zero or below. This means the supplied inputs leave no positive allowance before including this offer; it does not establish that any spending actually occurred. It is false otherwise. For indeterminate results, false does not establish that budget remains.
Input
offer: unit_price (required), currency (required), quantity, offered_at, seller, sku. cap: amount (required), currency (required), scope (required: per_purchase or per_period), already_spent (per_period only, defaults to "0"), period.from and period.to (per_period), cap_id.
In short: every amount and quantity is a decimal number in quotes, such as "12.50", with up to 18 digits, then optionally a point and 1 to 8 digits; no sign, symbol or separator. Amounts may be zero; quantity must be greater than zero. Currency codes are three upper-case letters, compared as strings. Every time is UTC and ends in Z, with seconds and optionally up to three fractional digits; years 0100 through 9999. Objects are closed: an unknown member is refused. Optional fields accept null as absence. The exact formats, including everything that is rejected, are in the rule's README (tanilo-receipt-spec, checks/spend-cap/README.md, section "Input").
The rule, in order
The complete input is validated before evaluation begins. Schema-invalid input is rejected before any of the five rule steps runs. For valid input, evaluation stops at the first step that cannot continue.
- Currency: the cap's currency equals the offer's. Otherwise
currency_mismatch. - Quantity: present. Otherwise
quantity_missing. - Total: unit_price × quantity, exact; up to 16 fraction digits, nothing rounded.
- Remaining cap: per_purchase, the amount. For
per_period, an absent or nullperiodproducesperiod_bounds_missing. If a period object is supplied, bothfromandtomust be valid timestamps, withtostrictly later thanfrom; otherwise the input is schema-invalid. An absent or nulloffer.offered_atproducesoffer_time_missing. An offer time outsidefrom≤offered_at<toproducesoffer_outside_period. Otherwise, remaining =amount−already_spent. These indeterminate reasons remain subject to earlier evaluation steps succeeding. - Compare: total ≤ remaining is verified; otherwise contradicted with the excess. "1" equals "1.00". No tolerance.
The rule does not enforce currency-specific minor units. Products and differences may have up to 16 fractional digits, including fractions of a cent. Output amounts have at least two fractional digits; no rounding is applied.
What the receipt records
The signed payload carries the rule id, cap_source: caller_supplied, the offer and cap as supplied with their SHA-256 digests, the total, the remaining cap, the excess when contradicted, and the steps applied in order. The signed payload includes the supplied offer and cap, including optional identifiers. Signing does not encrypt this information; sharing the receipt exposes its payload to the recipient.
seller, sku and cap_id are recorded and included in the input hashes. They are not used to determine whether the cap applies to this seller, item or account.
Rerun the original input using the published rule version named by evidence.rule_id, then compare the resulting evidence with the evidence inside the signed receipt. The rule is in the public folder (tanilo-receipt-spec, checks/spend-cap/).
HTTP 422 and 503
Schema-invalid input receives HTTP 422 claim_not_deterministic and no receipt. Rule-validation errors identify the offending member and include spend-cap help and an example. Requests rejected earlier because required input objects are missing may use a different error shape.
A valid-input indeterminate result records that the check ran but could not complete the comparison. It receives a signed receipt only when signing succeeds. With no signing key, the service returns HTTP 503 with status: "not_signed" and unsigned check results.
Checking the receipt
The same as every Tanilo receipt: pip install tanilo-receipt-verify, verify against a key set you have authenticated as Tanilo's (published at https://tanilo.io/.well-known/jwks.json). A valid signature shows which key signed and that the bytes are unchanged; it does not show that the cap was genuine.
Not in this version
The cap and already_spent are caller-supplied. This check does not establish that the cap is authentic, approved or applicable to this purchase, or that already_spent is complete or current. It does not authenticate the supplied offer time or establish that the offer is still current. It does not retrieve budget balances, maintain a running balance or reserve funds: it keeps no state between calls and does not record this offer against the cap.
Each call evaluates only the supplied snapshot. Repeated or concurrent calls can each return verified against the same remaining amount. This check does not reserve funds, update spending or prevent combined purchases from exceeding a cap.
It makes no currency conversion, applies no rounding or tolerance, and calculates no tax. It does not judge whether the purchase is sensible. A verified result does not authorize a payment.
Availability
POST /v1/verify-facts is free during the beta, with no key, rate-limited per network. The spend_cap check type is part of catalog revision tanilo-deterministic-v0.3-2026-10-10; receipts carry the revision in force when signed.
Use these checks before an x402 payment: @tanilo/x402-guard
From an MCP client
Use the spend cap check from Claude, Cursor or any MCP client: npx tanilo-mcp (tanilo-mcp on npm).