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.

An escalated purchase
{
  "purchaseRequest": {
    "id": "0199a3f1-8c2e-7b40-9f31-6d2e5a1c0b77",
    "status": "PENDING_APPROVAL",
    "amountMinor": "42000",
    "currency": "GBP"
  },
  "decision": {
    "id": "0199a3f1-91b4-7c02-a55d-3e7f1d9c4a20",
    "outcome": "REQUIRES_APPROVAL",
    "reasonCodes": ["APPROVAL_THRESHOLD_EXCEEDED"],
    "explanation": "GBP 420.00 is above the GBP 250.00 threshold that requires a person to approve, so this is waiting for someone in your organisation.",
    "evaluatedAt": "2026-09-12T09:14:07.882Z",
    "engineVersion": "veriticity-trust-engine@0.1.0"
  },
  "approval": {
    "required": true,
    "expiresAt": "2026-09-13T09:14:07.882Z"
  },
  "policyVersions": [
    { "policyVersionId": "0199a3e0-…", "applicabilityReason": "ORGANIZATION scope" }
  ],
  "ruleEvaluations": [
    {
      "rule": "AMOUNT_LIMIT",
      "outcome": "PASSED",
      "role": "MAXIMUM",
      "limitMinor": "50000",
      "observedMinor": "42000",
      "headroomMinor": "8000",
      "currency": "GBP"
    },
    {
      "rule": "AMOUNT_LIMIT",
      "outcome": "ESCALATED",
      "role": "APPROVAL_THRESHOLD",
      "limitMinor": "25000",
      "observedMinor": "42000",
      "headroomMinor": "-17000",
      "currency": "GBP",
      "reasonCode": "APPROVAL_THRESHOLD_EXCEEDED"
    }
  ],
  "replayed": false
}
OutcomeMeaningBudget
APPROVEDPermitted under the rules in force.Committed
REJECTEDRefused. The explanation says why.Nothing reserved
REQUIRES_APPROVALA 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#

CodeMeaning
WITHIN_POLICYEvery rule that applied was satisfied.
MERCHANT_BLOCKEDThe merchant is on a block list.
MERCHANT_NOT_ALLOW_LISTEDA policy uses an allow list and this merchant is not on it.
AMOUNT_EXCEEDS_MAXThe amount is above a single-purchase maximum.
APPROVAL_THRESHOLD_EXCEEDEDThe amount is above a threshold that requires a person to approve it.
DAILY_LIMIT_EXCEEDEDThis would take the day's spending past its limit.
MONTHLY_LIMIT_EXCEEDEDThis would take the month's spending past its limit.
AGENT_DISABLEDThe 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_POLICYNo policy governs this agent for this currency, so it has no spending authority. Veriticity approves nothing by default.
CURRENCY_NOT_COVEREDPolicies govern this agent, but none covers the currency of this purchase. Veriticity performs no currency conversion.
POLICY_CHANGED_SINCE_ESCALATIONA 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 sendYou get
Same key, same body200 with the original decision and replayed: true. No new budget reserved.
Same key, different body409 idempotency_key_reuse
No keyEvery call is a new purchase. A retried request is a second purchase.
Retrying safely
// One key per logical purchase, derived from something stable in your own
// system. Not a random UUID per attempt -- that would make every retry a new
// purchase, which is the thing the header exists to prevent.
const idempotencyKey = `invoice-${invoice.id}`;

async function submit(): Promise<Decision> {
  const response = await fetch(url, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${key}`,
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey,
    },
    body: JSON.stringify(purchase),
  });

  if (response.status === 409) {
    const { error } = await response.json();

    if (error.code === "idempotency_key_reuse") {
      // The key was used for a *different* purchase. Retrying will never
      // succeed; this is a bug in how the key was derived.
      throw new Error("Idempotency key collision: " + error.message);
    }
  }

  return response.json();
}

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.

FieldRequiredNotes
merchantYesAs a person would write it. Normalised for matching.
amountMinorYesMinor units, as a string. "7500" is GBP 75.00.
reasonYesWhy the agent wants it. Every purchase must say.
currencyNoDefaults to the organisation’s own currency.
merchantDomainNoRecorded as evidence. Policy is not matched on domains.
categoryNoYours to define.
metadataNoAny 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.