Purchases
Submit a purchase for a decision, check one before committing, and follow an approval escalation to its outcome. Decisions, reason codes, idempotency and budget holds.
A purchase request asks the Trust Engine one question: may this agent buy this thing, right now? The answer is a decision, and it is always explained.
The shape of a decision#
One response shape for all three outcomes. A client reads decision.outcome and branches; it never has to parse prose or infer meaning from a status code.
| Outcome | Meaning | Budget |
|---|---|---|
APPROVED | Permitted under the rules in force. | Committed |
REJECTED | Refused. The explanation says why. | Nothing reserved |
REQUIRES_APPROVAL | A person has been asked. | Held until approval.expiresAt |
ruleEvaluations lists every rule considered, including the ones that passed and the ones that did not apply. That is deliberate: “why was this approved” needs the passes as much as “why was this refused” needs the failures.
explanation is a sentence written for a person to read, and it may be reworded. Branch on reasonCodes, which are stable.
Reason codes#
| Code | Meaning |
|---|---|
WITHIN_POLICY | Every rule that applied was satisfied. |
MERCHANT_BLOCKED | The merchant is on a block list. |
MERCHANT_NOT_ALLOW_LISTED | A policy uses an allow list and this merchant is not on it. |
AMOUNT_EXCEEDS_MAX | The amount is above a single-purchase maximum. |
APPROVAL_THRESHOLD_EXCEEDED | The amount is above a threshold that requires a person to approve it. |
DAILY_LIMIT_EXCEEDED | This would take the day's spending past its limit. |
MONTHLY_LIMIT_EXCEEDED | This would take the month's spending past its limit. |
AGENT_DISABLED | The agent has been switched off. The purchase is refused by the engine rather than at the door, so the attempt is still recorded. |
NO_APPLICABLE_POLICY | No policy governs this agent for this currency, so it has no spending authority. Veriticity approves nothing by default. |
CURRENCY_NOT_COVERED | Policies govern this agent, but none covers the currency of this purchase. Veriticity performs no currency conversion. |
POLICY_CHANGED_SINCE_ESCALATION | A person approved it, but the policies changed before it was re-evaluated, and it no longer passes. |
Checking without committing#
POST /v1/simulations/current-policies runs the same evaluation and changes nothing — no purchase request, no decision, no budget reservation, no audit evidence, and no credential last-use stamp.
It returns more than a real decision does: every policy considered, every policy excluded for currency scope, and the budget windows with their current balances and what this purchase would do to them. It is the endpoint to use when an agent needs to decide whether to ask.
An agent-bound credential simulates as itself and may not name an agentId. An organisation-scoped key must name one.
Approval escalations#
When a purchase crosses an approval threshold, the engine opens an escalation, holds the budget, and tells the people who can answer. Three things follow from that:
- The hold is real. The amount is reserved against the relevant budget windows while the purchase waits, so a second purchase cannot quietly spend the same headroom.
- The deadline is real. If nobody answers by
approval.expiresAt, the hold is released and the purchase will not happen unless it is submitted again. - Approval is not a bypass. When a person approves, the purchase is re-evaluated against the policies in force at that moment. If the rules changed, the new rules apply — you may see
POLICY_CHANGED_SINCE_ESCALATION.
An agent cannot resolve its own escalation. There is no API for it and no MCP tool for it, by design: an assistant that could answer its own escalation would make the human half of the trust layer decorative.
Idempotency#
Idempotency-Key is a header, and POST /v1/purchase-requests is the only operation that accepts one.
| You send | You get |
|---|---|
| Same key, same body | 200 with the original decision and replayed: true. No new budget reserved. |
| Same key, different body | 409 idempotency_key_reuse |
| No key | Every call is a new purchase. A retried request is a second purchase. |
Choosing a key#
Derive it from something stable in your own system — an invoice id, a job id, a cart id. A random value generated per attempt defeats the whole mechanism, because the retry carries a different key and becomes a second purchase.
Reading a purchase back#
GET /v1/purchase-requests/{id} returns a purchase with its decision and its approval, if one was opened.
A purchase is readable by the credential that submitted it and by nothing else. Another agent’s, another connection’s, another organisation’s and a malformed id are all the same 404 — distinguishing them would turn the endpoint into an oracle for which purchase ids exist.
There is no endpoint that lists purchases over HTTP. An assistant connected over MCP can enumerate its own with list_purchases; the REST API reads one at a time by id.
What a purchase body may contain#
Seven fields, and unknown fields are refused rather than ignored. A body containing agentId is not a request Veriticity can satisfy — it is a caller who believes they are choosing the agent, and silently dropping the field would leave them believing it.
| Field | Required | Notes |
|---|---|---|
merchant | Yes | As a person would write it. Normalised for matching. |
amountMinor | Yes | Minor units, as a string. "7500" is GBP 75.00. |
reason | Yes | Why the agent wants it. Every purchase must say. |
currency | No | Defaults to the organisation’s own currency. |
merchantDomain | No | Recorded as evidence. Policy is not matched on domains. |
category | No | Yours to define. |
metadata | No | Any JSON object. No rule reads it. |
The organisation and the agent are settled by the credential before the body is read at all. There is no code path by which a body field could reach either.