Spend Guard is the buy-side of the Fidacy engine. Where the sell-side asks whether a merchant should accept an agent payment, Spend Guard asks whether the consumer actually authorized this purchase — within the limits they set, for the merchant they intended, on the rail they approved. It runs outside the model, enforces the consumer’s mandate in code, and returns a signed, auditable verdict before any money moves.
Off by default and no new endpoint. Pass a spending_mandate alongside the AP2 mandate in your normal POST /v1/assess call. When the field is absent the response is byte-identical to today — the buy-side gate does not run.
/v1/assesscurl -X POST https://api.fidacy.com/v1/assess \
-H "Authorization: Bearer fky_live_…" \
-d '{
"mandate": {
"vct": "urn:ietf:params:ap2:payment",
"payee": { "id": "merch_acme", "name": "Acme Office Supplies" },
"payment_instrument": { "type": "card_debit" },
"payment_amount": { "amount": 4999, "currency": "USD" }
},
"spending_mandate": {
"policy_owner": "consumer",
"subject": { "user_id": "usr_123", "agent_id": "kya_thumbprint_…" },
"per_transaction_max": { "amount": 10000, "currency": "USD" },
"daily_max": { "amount": 50000, "currency": "USD" },
"monthly_max": { "amount": 200000, "currency": "USD" },
"velocity": { "window": "1h", "max_count": 5 },
"allow": { "merchants": ["merch_acme", "merch_staples"] },
"deny": { "merchants": ["merch_casino"] },
"require_human_confirmation_above": { "amount": 7500, "currency": "USD" },
"rails_allowed": ["card_debit", "card_credit"],
"expires_at": "2026-12-31T23:59:59Z"
}
}'The top-level decision is the composed verdict (most restrictive of sell-side and buy-side). The additive spend_guard object carries the buy-side verdict alone so you can distinguish why a decision changed.
{
"decision": "approve",
"score": 14,
"assessmentId": "asmt_…",
"spend_guard": {
"evaluated": true,
"decision": "approve",
"reasons": [],
"unevaluable": [],
"deferred": []
}
}All monetary amounts are in minor units (integer) with an ISO-4217 currency code. All fields optional unless noted. Enforced now (stateless):
per_transaction_maxAmount exceeds cap → deny. Currency mismatch → review. Amount absent → review.
allow.merchantsNon-empty allowlist. Merchant not in list → deny.
deny.merchantsExplicit blocklist. Merchant matched by id or name → deny.
require_human_confirmation_aboveAmount over threshold → review (forces human step-up regardless of sell-side).
rails_allowedInstrument types matched against payment_instrument.type. Rail not in list → deny.
expires_atMandate past this instant → deny. Unparseable → review (fail-safe).
Enforced now (stateful) — aggregate the consumer’s own history, keyed to subject. Spend counts only when the final decision is approve.
daily_maxPrior approved spend in the last 24h + this purchase exceeds the cap → deny.
monthly_maxPrior approved spend in the current calendar month (UTC) + this purchase exceeds the cap → deny.
velocityApproved-purchase count within the rolling window reaches max_count → review.
Subject required. daily_max, monthly_max, and velocity need a resolvable subject. Sent without one, the engine degrades to review (recorded in spend_guard.unevaluable) — never silently passed.
approveBoth sides cleared. Forward the signed mandate; receipt in riskPayloadJws.
reviewA buy-side step-up threshold was crossed. Pause the agent; the engine creates a pending confirmation and emits spend.review_required, resolved with POST /v1/confirmations/{id}.
denyA hard buy-side limit was breached. Block the payment and log spend_guard.reasons.
Composition invariant. The buy-side gate can only tighten the sell-side verdict, never relax it. Ambiguity (unknown amount, unreadable rail) always degrades toward review, never toward approve.
The AP2 payment mandate carries payee and payment_instrument but no MCC or category, so allow.mccs, allow.categories and deny.categories cannot be evaluated — they are recorded in spend_guard.unevaluable, never silently treated as a pass.
/v1/spending-mandates/v1/spending-mandates/v1/confirmations/{id}Persist mandates and resolve human step-ups. A confirm records the spend and emits spend.confirmed; a deny emits spend.denied. Resolving an already-resolved confirmation returns 409, so a retry can never double-count the spend.
spend.review_requiredA purchase composed to review and a pending confirmation was created.
spend.confirmedA human resolved a confirmation with confirm.
spend.deniedA purchase composed to deny on the buy-side, or a confirmation was denied.