# Veriticity documentation Every page of https://docs.veriticity.com, in reading order. The index is https://docs.veriticity.com/llms.txt. --- Developer documentation # Veriticity for developers Veriticity is the trust layer between an AI agent and the money it wants to spend. Give agents spending rules, approval thresholds and budgets, and get an explained decision on every purchase. Veriticity is the trust layer between an AI agent and the money it wants to spend. An agent asks whether a purchase is allowed; spending rules a person wrote decide; and every answer comes back explained and recorded. The problem it solves is narrow and real. An autonomous agent that can spend is either given a credential with no limits, or given none at all and made useless. Neither is a position anybody is comfortable defending to a finance team. Veriticity is the third option: the agent gets a credential, and the credential is governed. ## What a call looks like One endpoint carries the whole product. You submit a purchase, and you get a decision. **Submit a purchase** ```bash curl -X POST https://app.veriticity.com/v1/purchase-requests \ -H "Authorization: Bearer cmp_live_9f2a1c4b7e30." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: build-credits-2026-09-12" \ -d '{ "merchant": "Acme Cloud", "amountMinor": "7500", "currency": "GBP", "category": "software", "reason": "Renew the build pipeline compute credits" }' ``` **201 Created** ```json { "purchaseRequest": { "id": "0199a3f1-…", "status": "APPROVED" }, "decision": { "outcome": "APPROVED", "reasonCodes": ["WITHIN_POLICY"], "explanation": "Acme Cloud is on the allow list. GBP 75.00 is within the GBP 500.00 single-purchase maximum, and the daily budget has GBP 420.00 remaining." }, "approval": { "required": false, "expiresAt": null }, "replayed": false } ``` Three outcomes are possible — `APPROVED`, `REJECTED` and `REQUIRES_APPROVAL` — and all three are successful calls. A refusal is an answer, not an error. > **Branch on the decision, never on the status code** > > The HTTP status describes whether the question could be answered. A rejected purchase returns `201`, because Veriticity was asked and it answered. Read `decision.outcome`. ## The mental model Five things, in a line. Getting this right makes the rest of the documentation obvious, and getting it wrong makes all of it confusing. **How authority flows** ``` Human │ creates an account and writes the spending rules ▼ Organisation │ contains the identities that are allowed to spend ▼ Agent ← an identity. Holds no credential. │ ├── API key bound to it ← your own software authenticates with this └── OAuth connection ← an AI assistant acts through this, by a person's grant │ ▼ Trust Engine ← evaluates the purchase against the rules in force │ ▼ Decision ← approved, refused, or escalated to a person ``` The line that matters most: **an agent never authenticates**. An agent is an identity, like a role. A credential authenticates, and it *acts as* the agent. So there is an “API key bound to an agent” and an “OAuth connection acting as an agent”, and never an agent that logs in. ## Connecting an AI assistant Claude, ChatGPT and other assistants connect to an agent that **already exists**, through an OAuth connection a person authorises. The assistant does not become a Veriticity user, does not create an agent, and gains no authority of its own — it receives a revocable grant over authority that was already there, and the grant can be withdrawn without touching the agent. Once connected, the assistant reaches Veriticity over [MCP](https://docs.veriticity.com/mcp.md), where it can ask what it is allowed to spend, request a purchase, and find out what a person decided. It cannot approve its own escalations, cannot change the rules it is judged against, and cannot see the organisation’s other agents. ## What Veriticity does not do Veriticity **does not move money**. It governs, checks, records and explains purchase decisions; settlement happens wherever it already happens for you. An `APPROVED` decision means the purchase is permitted under your rules, not that a payment has been made. Requests are rate limited per credential. Every response says how much of the limit is left; see [Errors](https://docs.veriticity.com/errors.md). To try policies, agents and purchases without making real ones, use [Sandbox](https://docs.veriticity.com/sandbox.md): a separate environment at `https://sandbox.veriticity.com` with its own `cmp_test_` API keys. ## Where to go next [**Quickstart** Your first governed purchase, start to finish, in about five minutes.](https://docs.veriticity.com/quickstart.md) [**Authentication** API keys, OAuth connections, scopes, and which credential reaches which endpoint.](https://docs.veriticity.com/authentication.md) [**Purchases** Decisions, reason codes, approval escalations and idempotent retries.](https://docs.veriticity.com/purchases.md) [**Policies** Spending limits, merchant lists, budget windows and approval thresholds.](https://docs.veriticity.com/policies.md) [**Webhooks** Signed events for purchase decisions, budget thresholds and connection changes.](https://docs.veriticity.com/webhooks.md) [**API reference** Every endpoint, with credentials, parameters, responses and errors.](https://docs.veriticity.com/api-reference.md) --- Source: https://docs.veriticity.com --- # Quickstart Submit your first purchase through the Veriticity API and read the decision. A complete worked example in cURL and TypeScript, from API key to explained outcome. This walks through one complete purchase: check it, submit it, handle the answer, follow it if a person has to decide, and use the authorization before you buy. About five minutes. ## Before you start You need an API key bound to an agent. In the dashboard: 1. Create an agent, if you have not already. 2. Write at least one policy that governs it — without one, purchases are refused with `NO_APPLICABLE_POLICY`. Veriticity approves nothing by default. 3. Issue an API key for that agent. The secret is shown once. **Set it in your environment** ```bash export VERITICITY_API_KEY="cmp_live_9f2a1c4b7e30." ``` > **Nothing is allowed until a policy allows it** > > An agent with no applicable policy has no spending authority at all. That is deliberate: purchasing authority is granted, never assumed. ## 1. Check before you buy A simulation runs the same evaluation a real purchase would and changes nothing — no purchase record, no decision, no budget reserved. **Simulate** ```bash curl -X POST https://app.veriticity.com/v1/simulations/current-policies \ -H "Authorization: Bearer $VERITICITY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "merchant": "Acme Cloud", "amountMinor": "7500", "currency": "GBP", "category": "software", "reason": "Renew build pipeline compute credits" }' ``` **200 OK** ```json { "simulation": { "isSimulation": true, "mode": "CURRENT_POLICIES", "disclaimer": "This is a simulation, not a decision. Veriticity created no purchase request, no decision, no budget reservation and no audit evidence, and consumed no budget.", "simulatedAt": "2026-09-12T09:14:02.471Z" }, "simulatedDecision": { "outcome": "APPROVED", "wouldRequireApproval": false, "reasonCodes": ["WITHIN_POLICY"], "explanation": "Approved. GBP 75.00 is within the GBP 500.00 limit for a single purchase." }, "budgets": [ { "periodKind": "DAY", "currency": "GBP", "simulated": { "requestedMinor": "7500", "projectedMinor": "12500" }, "wouldExceed": false } ] } ``` Note there is no `id` anywhere in a simulation response. The fields a real decision is identified by are absent rather than null, so a simulation cannot be mistaken for one. ## 2. Submit the purchase **Submit** ```bash curl -X POST https://app.veriticity.com/v1/purchase-requests \ -H "Authorization: Bearer $VERITICITY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: build-credits-2026-09-12" \ -d '{ "merchant": "Acme Cloud", "merchantDomain": "acme-cloud.example.com", "amountMinor": "7500", "currency": "GBP", "category": "software", "reason": "Renew build pipeline compute credits" }' ``` **201 Created** ```json { "purchaseRequest": { "id": "0199a3f1-8c2e-7b40-9f31-6d2e5a1c0b77", "status": "APPROVED", "amountMinor": "7500", "currency": "GBP" }, "decision": { "id": "0199a3f1-91b4-7c02-a55d-3e7f1d9c4a20", "outcome": "APPROVED", "reasonCodes": ["WITHIN_POLICY"], "authority": "WITHIN_POLICY", "explanation": "Approved. GBP 75.00 is within the GBP 500.00 limit for a single purchase.", "evaluatedAt": "2026-09-12T09:14:07.882Z", "engineVersion": "veriticity-trust-engine@0.1.0" }, "approval": { "required": false, "expiresAt": null }, "findings": [ /* every rule considered, including the ones that passed */ ], "replayed": false } ``` The same call in TypeScript, using the official client. Install it with `npm install @veriticity/sdk`; the [SDK page](https://docs.veriticity.com/sdk.md) covers the rest. **With the SDK** ```typescript import { Veriticity } from "@veriticity/sdk"; const veriticity = new Veriticity({ apiKey: process.env.VERITICITY_API_KEY! }); const result = await veriticity.purchases.submit({ merchant: "Acme Cloud", amountMinor: 42000n, currency: "GBP", category: "software", reason: "Annual renewal of the build pipeline plan", }); console.log(result.decision.outcome, result.decision.explanation); ``` `Idempotency-Key` is required, and makes a retry safe. Send the same key with the same body and you get the original decision back with `replayed: true` and a `200`, with no new budget reserved. Send it with a *different* body and you get `409 idempotency_key_reuse`. ## 3. Handle all three outcomes **Handling a decision** ```typescript type Outcome = "APPROVED" | "REJECTED" | "REQUIRES_APPROVAL"; const response = await fetch( "https://app.veriticity.com/v1/purchase-requests", { method: "POST", headers: { Authorization: `Bearer ${process.env.VERITICITY_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": idempotencyKey, }, body: JSON.stringify({ merchant: "Acme Cloud", amountMinor: "7500", currency: "GBP", category: "software", reason: "Renew build pipeline compute credits", }), }, ); if (!response.ok) { const { error } = await response.json(); throw new Error(`${error.code}: ${error.message}`); } const result = await response.json(); // Branch on the decision, never on the status code. All three outcomes // arrive as 201 (or 200 for an idempotent replay). switch (result.decision.outcome as Outcome) { case "APPROVED": await completeCheckout(result.purchaseRequest.id); break; case "REJECTED": // result.decision.explanation says why, in a sentence you can log or // show. result.decision.reasonCodes is the machine-readable half. await recordRefusal(result.decision); break; case "REQUIRES_APPROVAL": // A person has been asked, and the budget is held until this instant. await waitForHuman( result.purchaseRequest.id, result.approval.expiresAt, ); break; } ``` > **A refusal is a successful call** > > `REJECTED` comes back as `201`. Veriticity was asked a question and answered it. If you branch on the status code you will treat a correctly refused purchase as an outage. ## 4. Follow an escalation When the answer is `REQUIRES_APPROVAL`, a person has been asked and the budget is held until `approval.expiresAt`. If nobody answers by then, the hold is released and the purchase will not happen unless you submit it again. Read the purchase back to find out what they decided: **Read it back** ```bash curl https://app.veriticity.com/v1/purchase-requests/0199a3f1-8c2e-7b40-9f31-6d2e5a1c0b77 \ -H "Authorization: Bearer $VERITICITY_API_KEY" ``` **200 OK** ```json { "purchase": { "id": "0199a3f1-8c2e-7b40-9f31-6d2e5a1c0b77", "status": "APPROVED", "merchant": "Acme Cloud", "amountMinor": "42000", "currency": "GBP", "decision": { "outcome": "APPROVED", "kind": "APPROVAL_REEVALUATION", "explanation": "A person approved this purchase, and it was re-evaluated against the policies in force." }, "approval": { "state": "APPROVED", "isPending": false, "resolvedBy": { "name": "Priya Raman" }, "comment": "Signed off — this is the renewal we discussed." }, "authorization": { "id": "0199a3f2-0d1a-7e55-8c40-2b9e6f3a1d08", "status": "active", "expiresAt": "2026-09-13T10:02:41.117Z" } } } ``` `decision.kind` is `APPROVAL_REEVALUATION` once a person has answered, against `INITIAL` for the engine’s first decision. The purchase is re-evaluated against the policies in force at that moment, so an approval is not a bypass — if the rules changed in the meantime, the new rules apply. Polling works, and [webhooks](https://docs.veriticity.com/webhooks.md) are better: subscribe to `purchase.approved` and `purchase.rejected` and you will be told rather than having to ask. ## 5. Use the authorization An approved purchase carries `authorization`. Before you make the purchase, ask for an execution token for it with the same key: **Get an execution token** ```bash curl -X POST https://app.veriticity.com/v1/purchase-authorizations/$AUTHORIZATION_ID/execution-token \ -H "Authorization: Bearer $VERITICITY_API_KEY" ``` The token is shown once. It works only for this authorization, and stops working when the authorization is used, withdrawn, voided or expires. Send it with the terms you are about to buy on: **Use the authorization** ```bash curl -X POST https://app.veriticity.com/v1/purchase-authorizations/$AUTHORIZATION_ID/use \ -H "Authorization: Bearer $VERITICITY_API_KEY" \ -H "Veriticity-Execution-Token: $EXECUTION_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "amountMinor": "42000", "currency": "GBP", "merchant": "Acme Cloud", "category": "software", "executionReference": "order-1182" }' ``` **200 OK** ```json { "result": "authorized", "authorization": { "id": "0199a3f2-0d1a-7e55-8c40-2b9e6f3a1d08", "status": "used", "usedAt": "2026-09-12T10:05:12.604Z", "expiresAt": "2026-09-13T10:02:41.117Z" }, "replayed": false } ``` Go ahead only on `authorized`. A refusal is also a `200`, with a `reason`. Reading the purchase back now shows the authorization as `used`. > **Authorized is not purchased** > > `authorized` means the authorization was checked and used. Veriticity does not make the purchase and is not told whether it went through. If it fails, your organisation voids the authorization from the dashboard. ## Where to go next - [TypeScript SDK](https://docs.veriticity.com/sdk.md) — the official client, with typed decisions, typed errors and webhook verification. - [Purchases](https://docs.veriticity.com/purchases.md) — reason codes, budget holds, and the full decision shape. - [Policies](https://docs.veriticity.com/policies.md) — how the rules that decided this are written. - [Examples](https://docs.veriticity.com/examples.md) — webhook verification, OAuth, and idempotent retries in full. - [API reference](https://docs.veriticity.com/api-reference.md) — every endpoint. --- Source: https://docs.veriticity.com/quickstart --- # TypeScript SDK @veriticity/sdk is the official TypeScript client for the Veriticity API: typed purchase decisions, typed errors, webhook signature verification and discriminated event types, with zero runtime dependencies. `@veriticity/sdk` is the official TypeScript client for the Veriticity API. It is a consumer of the same public API this site documents — every method maps to one endpoint, and nothing it returns has been reshaped on the way through. It is optional. The API is plain HTTP with JSON, and every example on this site is also shown as raw `curl`. Use the SDK if you are writing TypeScript and would like typed decisions, typed errors and a webhook verifier you do not have to write yourself. > **Server-side only** > > An API key carries organisation authority and must never reach a browser. The API sends no CORS headers, so a browser cannot call it in any case. There is no browser build. ## Install **npm** ```bash npm install @veriticity/sdk ``` ESM only, with **zero runtime dependencies**. Installing it adds one package to your tree and nothing else. ## Authenticate The SDK accepts the same two credentials the API does: an API key, or an OAuth access token from a connection somebody authorised. **API key** ```typescript import { Veriticity } from "@veriticity/sdk"; const veriticity = new Veriticity({ apiKey: process.env.VERITICITY_API_KEY!, }); ``` **OAuth access token** ```typescript // An OAuth access token from a connection somebody authorised. const veriticity = new Veriticity({ accessToken: "cmp_oat_...", }); // Or a function, called before every request. Access tokens last 60 minutes, // so a client built once at module scope with a string stops working after an // hour. const veriticity = new Veriticity({ accessToken: () => tokenStore.currentAccessToken(), }); ``` The client calls Live, `https://app.veriticity.com`, unless you set `baseUrl`. For [Sandbox](https://docs.veriticity.com/sandbox.md), use a `cmp_test_` key with `baseUrl: "https://sandbox.veriticity.com"`. A Sandbox key with Live’s address, or a Live key with Sandbox’s, is refused when the client is created. An API key bound to an agent and an organisation-scoped key look identical — in Live both begin `cmp_live_`, and the difference between them is an authority the server holds. The SDK cannot check it locally, so each method’s documentation says which it needs and the server returns `api_key_not_bound_to_agent` or `api_key_not_organization_scoped` when it is not met. | Method | Credential | OAuth scope | | --- | --- | --- | | `purchases.submit` | API key bound to an agent | `purchase:request` | | `purchases.get` | API key bound to an agent | `purchase:read` | | `simulations.currentPolicies` | Either kind | `purchase:check` | | `purchaseAuthorizations.executionToken` | The API key bound to an agent that submitted the purchase | — | | `purchaseAuthorizations.use` | Either kind. An agent’s key also passes `executionToken` | — | | Everything else | Organisation-scoped API key | — | The SDK does not implement OAuth authorisation or refresh. It consumes an access token; obtaining one is your application’s job, for the reasons set out in [Authentication](https://docs.veriticity.com/authentication.md). ## Your first purchase **purchases.submit** ```typescript const result = await veriticity.purchases.submit({ merchant: "Acme Cloud", reason: "Renew build pipeline compute credits", amountMinor: 7500n, currency: "GBP", category: "software", }); switch (result.decision.outcome) { case "APPROVED": // Go ahead. result.decision.explanation says why. break; case "REJECTED": // A refusal is a successful call, not an exception. break; case "REQUIRES_APPROVAL": // A person has been asked. Wait for the webhook, or poll: // await veriticity.purchases.get(result.purchaseRequest.id) break; } ``` **A refusal is not an exception.** Veriticity was asked a question and answered it, so a `REJECTED` decision resolves normally. Branch on `decision.outcome`, never on the HTTP status. ### Money `amountMinor` accepts a string, a number or a bigint and is always sent as a decimal string. It is *always a string* coming back, and the SDK never converts it to a JavaScript number — JSON numbers lose precision above 253, and this is money. ## Check before you buy **simulations.currentPolicies** ```typescript const check = await veriticity.simulations.currentPolicies({ merchant: "Acme Cloud", reason: "Renew build pipeline compute credits", amountMinor: "7500", currency: "GBP", category: "software", }); if (check.simulatedDecision.outcome === "REJECTED") { // Nothing was recorded, no budget was reserved, no evidence was written. return; } ``` A simulation records nothing, reserves nothing and costs nothing, so an agent that is unsure should ask before it acts. It is not a guarantee — policy can change between the two calls, and only the submission decides. ## Verify a webhook Verification needs no API credential and no HTTP client, so it lives in its own entry point. A receiver deployed to an edge runtime can import it without the rest of the package. **Next.js App Router** ```typescript import { Webhooks, isKnownWebhookEvent } from "@veriticity/sdk/webhooks"; const webhooks = new Webhooks({ secret: process.env.VERITICITY_WEBHOOK_SECRET!, }); export async function POST(request: Request) { // Pass the Request itself. The verifier reads the raw bytes, because a // re-serialised body is not the body that was signed. const event = await webhooks.unwrap(request); if (!isKnownWebhookEvent(event)) { // A type added after this version of the SDK. Acknowledge and ignore. return new Response(null, { status: 204 }); } switch (event.type) { case "purchase.approved": event.data.purchaseRequestId; break; case "budget.exhausted": event.data.policyName; break; case "webhook.test": event.data.message; break; } return new Response(null, { status: 204 }); } ``` > **Pass the Request, not a parsed body** > > `JSON.parse` followed by `JSON.stringify` is not the identity function — key order, number formatting and whitespace all move — so a re-serialised body never verifies. Passing a parsed object is both a TypeScript error and a named runtime error. > > If your framework parses JSON before your handler sees it, configure that route to hand you the raw bytes and pass them with the headers instead. The [Webhooks](https://docs.veriticity.com/webhooks.md) page shows the same verification without the SDK. During a rotation Veriticity signs each delivery with every live secret and sends both signatures, so a receiver holding either succeeds. Pass an array while you change over: **Rotation** ```typescript new Webhooks({ secret: [nextSecret, currentSecret] }); ``` ## Errors **Error handling** ```typescript import { InvalidRequestError, VeriticityApiError, VeriticityConnectionError, } from "@veriticity/sdk"; try { await veriticity.purchases.submit(body); } catch (error) { if (error instanceof InvalidRequestError) { // Every problem, not just the first. for (const issue of error.issues) { console.error(issue.path, issue.code, issue.message); } } else if (error instanceof VeriticityApiError) { // error.code is the contract. The classes are convenience over the status. console.error(error.code, error.status); } else if (error instanceof VeriticityConnectionError) { // Nothing reached Veriticity. Safe to retry with the same idempotency key. } else { throw error; } } ``` Every failure the SDK throws extends `VeriticityError`. HTTP failures become a `VeriticityApiError` subclass chosen by status; transport failures become `VeriticityConnectionError` or `VeriticityTimeoutError`; a mistake the SDK can see without asking the server becomes `VeriticityConfigError`. The classes are convenience. `error.code` is the same stable code documented under [Errors](https://docs.veriticity.com/errors.md), and the SDK invents none of its own. `issues` is always an array, so iterating it is always safe. Errors carry `status`, `code`, `message`, `issues`, `method`, `path` and `operationId` — and deliberately carry no credential, no request headers, no request body and no full URL. An error object ends up in a log, and none of those belong there. ## Retries and idempotency **The SDK never retries.** A silently retried purchase submission would ask for a second decision, take a second budget hold and write a second row of permanent evidence. That includes rate limits. A `429` is thrown as a `RateLimitError` with `code` `rate_limited`, and a `503` `temporarily_unavailable` as a `ServiceUnavailableError`. Both carry `retryAfterSeconds` and, where the response described one, `rateLimit` — the limit, what is left of it, and how long until it resets. Either is `null` when the headers did not arrive, which means unknown rather than zero. The SDK reads them and waits for nothing; scheduling the retry is yours. In both cases nothing was done, and retrying with the same idempotency key is safe. The limits and headers are on [Errors](https://docs.veriticity.com/errors.md). **purchases.submit** ```typescript // The same key across every attempt is what makes a retry safe. The SDK never // generates one, because a key has to survive your retry -- which means only // you can mint it. const result = await veriticity.purchases.submit(body, { idempotencyKey: attemptId, }); result.replayed; // true when this was the stored answer to an earlier identical request ``` Idempotency is available on purchase submission and nowhere else, which the types enforce: an `idempotencyKey` passed to any other method is a compile error. A key over 255 characters is refused before the request is made. ## Policies and pagination **policies** ```typescript // The API's own shape, returned unchanged. const { policies, pagination } = await veriticity.policies.list({ status: "ACTIVE", pageSize: 50, }); // The one ergonomic addition: walk every page. for await (const policy of veriticity.policies.listAll({ status: "ACTIVE" })) { console.log(policy.name); } ``` The SDK preserves the API’s pagination rather than inventing one of its own, so what you get back is what [the API reference](https://docs.veriticity.com/api-reference.md) describes. `policies.listAll()` is the single addition — an async generator that walks pages until the server reports the last one. Webhook deliveries take a `limit` and nothing else, so there is no iterator for them; raise the limit to see more. `purchases.list()` returns one page of what the calling credential submitted, with a `nextCursor` to pass back for the next. An agent-bound credential can also read about itself: `agents.current()`, `agents.spendingRules()` and `agents.budgetStatus()`. None takes an agent id. ## Runtimes | Runtime | Supported | | --- | --- | | Node.js 20.19 or newer | Yes — the primary target | | Next.js, both the Node and Edge runtimes | Yes | | Serverless functions | Yes | | Cloudflare Workers | Yes | | Deno, Bun | Yes | | Browser | No, deliberately | The SDK uses Web APIs only — `fetch`, `URL`, `AbortSignal`, `TextEncoder` and Web Crypto — so it runs unchanged everywhere above. A CommonJS project on a supported version of Node can `require` it. ## What the SDK deliberately does not do - **No automatic retries**, backoff, jitter or circuit breaking. Retry deliberately, with your own stable idempotency key. - **No retry safety from a generated key.** The API requires an idempotency key, so `purchases.submit` generates one when you pass none. A key has to be stable across your retries to protect them, which means only you can mint that one. - **No OAuth flow.** It consumes an access token; it does not obtain, refresh or store one. - **No payment execution.** Veriticity decides. It does not hold funds and does not move money. - **No logging or telemetry.** The SDK writes nothing to stdout or stderr. Supply your own `fetch` to observe requests. - **No browser support.** - **No environment option.** The base URL and the key decide it: point `baseUrl` at `https://sandbox.veriticity.com` and use a `cmp_test_` key to call [Sandbox](https://docs.veriticity.com/sandbox.md). ## Where to go next - [Quickstart](https://docs.veriticity.com/quickstart.md) — the same first purchase, in cURL and TypeScript. - [Purchases](https://docs.veriticity.com/purchases.md) — decisions, reason codes and approval escalations in full. - [Webhooks](https://docs.veriticity.com/webhooks.md) — the event catalogue, signature scheme and delivery behaviour. - [Examples](https://docs.veriticity.com/examples.md) — complete integrations, with and without the SDK. - [API reference](https://docs.veriticity.com/api-reference.md) — every endpoint the SDK calls, and the authority on all of it. --- Source: https://docs.veriticity.com/sdk --- # Sandbox Try policies, agents and purchasing workflows in Sandbox without making real purchases. How to get in, cmp_test_ API keys, the Sandbox base URL, webhooks, copying your setup to Live and resetting. ## What Sandbox is Sandbox is a separate Veriticity environment at `https://sandbox.veriticity.com`, with its own agents, policies, budgets, API keys, webhooks and purchase history. Use it to try policies, agents and purchasing workflows without making real purchases. Everything else is **Live**, at `https://app.veriticity.com`. Purchases in Sandbox are decided exactly as they are in Live: the same rules produce the same outcome, approvals escalate to a person in the same way, and every decision is explained in the same terms. Nothing you do in Sandbox affects Live. | | Live | Sandbox | | --- | --- | --- | | Dashboard and API | `https://app.veriticity.com` | `https://sandbox.veriticity.com` | | API key prefix | `cmp_live_` | `cmp_test_` | | Email from Veriticity | Veriticity | Veriticity Sandbox, with `[Sandbox]` at the start of the subject | | Plan limits | Your plan’s purchase requests and limits apply | None. Nothing counts toward your plan | | Dashboard session | A week, renewed while you use it | 12 hours, then switch again from Live | ## Getting in There is no separate Sandbox account and no Sandbox password. 1. Sign in at `https://app.veriticity.com`. 2. Open the environment switcher in the sidebar and choose **Sandbox**. 3. You arrive in the Sandbox dashboard, already signed in, with a banner that reads “Real purchases cannot be made here.” Your email address must be confirmed first. To go back, choose **Live** in the same switcher; you stay signed in to both. If Sandbox has policies, agents or AI connections, you are asked whether to copy them to Live first — see [Copying your setup to Live](#copying-your-setup-to-live). > **Account security is managed in Live** > > Your password, email address and other account security settings can only be changed in Live. Sandbox has no password to reset and no way to sign in directly. ## What comes with you Each time you switch to Sandbox, a few details are brought across from Live. Nothing else is, and nothing goes back on its own. | Brought across on every switch | Never brought across | | --- | --- | | Your name and email address | Agents, policies and budgets | | Your role in the organisation | API keys and OAuth connections | | The organisation’s name, account type, currency, timezone and approval window | Webhook endpoints and their signing secrets | | Notifications you have switched off | Purchases, approvals and audit history | So a name changed in Live appears in Sandbox the next time you switch. Anything you create or change in Sandbox stays in Sandbox unless you choose to copy it. ## API keys Sandbox keys are issued in the Sandbox dashboard, in the same way and with the same two kinds described in [Authentication](https://docs.veriticity.com/authentication.md). They start with `cmp_test_`. A key only works in the environment that issued it. A `cmp_test_` key sent to Live, or a `cmp_live_` key sent to Sandbox, is refused before it is looked up, and the message names the address it belongs to: **A Sandbox key sent to Live** ```http HTTP/1.1 401 Unauthorized { "error": { "code": "wrong_environment", "message": "This is a Sandbox key. Send Sandbox requests to https://sandbox.veriticity.com." } } ``` Fix the base URL rather than the key. Retrying the same request will get the same answer. ## Calling the API Every endpoint in the [API reference](https://docs.veriticity.com/api-reference.md) is served from the Sandbox address at the same path, and returns the same shapes and the same [errors](https://docs.veriticity.com/errors.md). **Sandbox** ```bash curl https://sandbox.veriticity.com/v1/agents \ -H "Authorization: Bearer cmp_test_9f2a1c4b7e30." ``` With the [TypeScript SDK](https://docs.veriticity.com/sdk.md), pass the Sandbox address as `baseUrl`. It defaults to Live, and a `cmp_test_` key with Live’s address is refused when the client is created. **@veriticity/sdk** ```typescript import { Veriticity } from "@veriticity/sdk"; // Keep the base URL and the key together, so one can never be switched // without the other. const veriticity = new Veriticity({ apiKey: process.env.VERITICITY_API_KEY!, // cmp_test_ in Sandbox baseUrl: process.env.VERITICITY_BASE_URL, // https://sandbox.veriticity.com }); ``` > **Responses do not say which environment answered** > > A Sandbox purchase and a Live purchase look the same in a response body. Keep the base URL and the key in the same configuration, and switch them together. ## Connecting an AI assistant Sandbox has its own MCP endpoint, `https://sandbox.veriticity.com/mcp`, and its own connections. An assistant added with that address signs in through Sandbox and acts as a Sandbox agent, under Sandbox policies and budgets. - If you are not already in Sandbox when the assistant asks you to sign in, you sign in to Veriticity and are brought back to finish connecting. - Every tool works as it does in Live: checks, requests, approvals, cancelling and reading purchases back. `get_agent` returns `"environment": "sandbox"`. - A Sandbox connection’s token is not accepted by Live, and a Live token is not accepted by Sandbox. - Nothing decided in Sandbox authorises a real purchase. To use an assistant for real purchases, add the Live endpoint to it as a separate connection. See [MCP](https://docs.veriticity.com/mcp.md) for the tools. ## Webhooks Webhook endpoints are registered separately in each environment. An endpoint registered in Sandbox receives Sandbox events only, signed with its own secret; Live endpoints never receive them. Event payloads have the same shape in both environments, so point Sandbox at a different receiving URL from Live. Signature verification works exactly as described in [Webhooks](https://docs.veriticity.com/webhooks.md). ## Copying your setup to Live When you switch back to Live from a Sandbox that has policies, agents or AI connections, Veriticity asks whether to bring them with you. Choose what to copy and select **Review in Live**, or continue without copying anything. Nothing is copied until you review the list in Live and select **Copy to Live**. Sandbox stays as it is. | From Sandbox | Arrives in Live as | | --- | --- | | Policies | Drafts. Nothing takes effect until you activate it in Live. | | Agents | Turned off and without API keys. Create new keys for them in Live. | | AI connections | Not copied. Connect each assistant again in Live. | - Nothing in Live is overwritten. If a name is already taken, the copy is named with `(Sandbox)` after it, and the review shows the name first. - Copying the same item again does nothing; it is reported as already in Live. - A policy that applies to particular agents is copied only if those agents are in Live too. - Each item is copied only if your role in Live allows you to create it. - Copied agents count toward your Live plan’s agent limit. If they would pass it, nothing is copied. - Budgets, purchase history, approvals and policy history are never copied. - A review you don’t finish expires after an hour. ## Resetting Sandbox An organisation owner can reset Sandbox from **Settings** in the Sandbox dashboard. It can’t be undone, and it takes effect immediately. | Removed | Kept | | --- | --- | | Sandbox agents, policies and budgets | Your Veriticity account | | Sandbox API keys — code using one stops working | Everything in Live | | Connected AI apps, which need connecting again | Everyone who can reach this Sandbox, with the same roles | | Webhook endpoints and their signing secrets | | | Sandbox purchases, approvals and transaction history | | **Reset and load examples** does the same, then adds an agent called `Example Shopping Agent` and an active policy called `Example spending rules`: purchases over 100 need approval, purchases over 500 are blocked, and spending is capped at 1,000 a month, in the organisation’s currency. It adds no purchase history. --- Source: https://docs.veriticity.com/sandbox --- # Building with AI Give a coding assistant the Veriticity docs: llms.txt, llms-full.txt, every page as Markdown, the OpenAPI document and the copy-page menu. A coding assistant writes a better Veriticity integration when it reads these docs rather than guessing from what it was trained on. Every page is published as plain Markdown as well as HTML, with an index at `/llms.txt` in the format most AI tools look for. ## Docs for your assistant | Address | What it holds | | --- | --- | | [`/llms.txt`](https://docs.veriticity.com/llms.txt) | An index: what Veriticity is, rules for building with it, and a link to every page’s Markdown. Start here. | | [`/llms-full.txt`](https://docs.veriticity.com/llms-full.txt) | Every page in one Markdown file, in reading order. | | [`/quickstart.md`](https://docs.veriticity.com/quickstart.md) | Any page as Markdown: add `.md` to its address. The introduction is `/index.md`. | | [`/openapi.json`](https://docs.veriticity.com/openapi.json) | The OpenAPI document: every endpoint, parameter, schema and error code. | They are made from these pages each time the docs are published, so they always say what the pages say. ## Giving your assistant the docs Most coding assistants can read a web address you give them. Point yours at the index, or at the one page you are working from: **Prompt** ``` Read https://docs.veriticity.com/llms.txt and follow its instructions for coding assistants. Then help me submit a purchase to Veriticity from my server, using the Sandbox. ``` Where your editor keeps documentation sources, add `https://docs.veriticity.com/llms-full.txt` as one, so the whole docs are available without fetching page by page. ## The copy-page menu Every page has a **Copy page** button beside its breadcrumbs: - **Copy page** copies the page as Markdown, ready to paste into a chat. - **View as Markdown** opens the page’s `.md` address. - **Open in Claude** and **Open in ChatGPT** start a chat that reads the page, so you can ask questions about it. ## What llms.txt tells an assistant Besides the links, `/llms.txt` gives an assistant these rules. Each restates something these pages already say. - Build and test against Sandbox: `https://sandbox.veriticity.com` with a `cmp_test_` key. Live is `https://app.veriticity.com` with a `cmp_live_` key. - Keep API keys on the server. A key's secret is shown once and only a hash is stored. - Nothing is allowed until a policy allows it. An agent with no applicable policy gets `NO_APPLICABLE_POLICY`. - Check before you buy: a simulation runs the same evaluation as a purchase and changes nothing. - Send an `Idempotency-Key` on every purchase request. It is required, and makes a retry safe. - Identify the merchant by its website, `merchantDomain`. A merchant name identifies no merchant. - `APPROVED` is permission for that one purchase on exactly its terms, until `expiresAt`. It is not a purchase and not a payment. - `REQUIRES_APPROVAL` means a person decides. Wait for the decision; never treat it as approved. - A decision is final. Do not resubmit a refused purchase with altered details to get a different answer. ## Agents that make purchases This page is about an assistant helping you build. An AI agent that makes purchases through Veriticity is a different case: it connects to the [MCP server](https://docs.veriticity.com/mcp.md) or calls the API with its own key, and its rules are the [assistant instructions](https://docs.veriticity.com/assistant-instructions.md). > **The MCP server is for purchases** > > Its tools check and request purchases for one agent. It does not search these docs. Give a coding assistant the Markdown above instead. ## Keep secrets out of prompts Never paste an API key, a webhook signing secret or an execution token into a chat. Use a `cmp_test_` key in Sandbox while you build, and keep keys in your server’s environment. The Markdown docs hold no secrets and no account details. --- Source: https://docs.veriticity.com/building-with-ai --- # Authentication How to authenticate against the Veriticity API. Agent-bound and organisation-scoped API keys, OAuth connections for AI assistants, scopes, and which credential reaches which endpoint. ## Two credentials, one header Two things authenticate against the Veriticity API, and both are presented the same way. | Credential | Format | Used by | | --- | --- | --- | | API key | `cmp_live_<12 hex>.<43 chars>` | Your own software, against Live | | Sandbox API key | `cmp_test_<12 hex>.<43 chars>` | Your own software, against [Sandbox](https://docs.veriticity.com/sandbox.md) | | OAuth access token | `cmp_oat_<12 hex>.<43 chars>` | A connected AI assistant | **Authenticating** ```bash curl https://app.veriticity.com/v1/agents \ -H "Authorization: Bearer cmp_live_9f2a1c4b7e30." ``` There is no `X-API-Key` header and no query-parameter form. A token in a URL ends up in access logs, browser history and `Referer` headers, so the header is the only way in. The [TypeScript SDK](https://docs.veriticity.com/sdk.md) takes either credential and builds the same header: **@veriticity/sdk** ```typescript import { Veriticity } from "@veriticity/sdk"; // An API key -- agent-bound or organisation-scoped. Both are cmp_live_, and the // difference between them is an authority Veriticity holds, not a format. const veriticity = new Veriticity({ apiKey: process.env.VERITICITY_API_KEY! }); // An OAuth access token, from a connection somebody authorised. const asConnection = new Veriticity({ accessToken: "cmp_oat_..." }); // Or a supplier, called before every request. Access tokens last 60 minutes. const longLived = new Veriticity({ accessToken: () => tokenStore.currentAccessToken(), }); ``` Pass a function for `accessToken` whenever the client outlives a token. The SDK calls it before every request, and never caches, refreshes or stores what it returns — for the reasons under [OAuth connections](#oauth-connections) below. ## An agent never authenticates This is the single most important sentence on this page, and getting it wrong makes everything else confusing. - An **agent** is an identity — a named thing that is allowed to spend within rules. It holds no credential and presents nothing. - An **API key** may be *bound to* an agent. The key authenticates; it then acts as that agent. - An **OAuth connection** is one person’s grant letting one application act as one existing agent. The token authenticates; the connection acts as the agent. So the correct phrasings are “an API key bound to an agent” and “an OAuth connection acting as an agent”. There is no sense in which an agent logs in. ## API keys Keys bound to an agent are issued in the dashboard by an owner, and the secret is shown once. Only a hash is stored, so a lost key is replaced rather than recovered. There are two kinds, and the difference is an authority: ### Bound to an agent The credential your agent uses. It submits purchases and reads back its own, and it simulates as itself. It cannot name a different agent, cannot read the policies it is judged against, and cannot change them. An agent may hold several keys, rotated and revoked independently. Rotating a key in the dashboard issues a replacement and gives the key it replaces an end date — 24 hours later by default. Both authenticate until then, so a deployment can move to the new one without an interruption. After that the old key is refused with `api_key_expired`, and its last day is visible on the key itself before it arrives. ### Execution tokens To use an approved purchase’s authorization, the key that submitted the purchase asks for an execution token and sends it in the `Veriticity-Execution-Token` header, beside its own key. A token begins `cmp_pex_`, is shown once, and works for that one authorization until it is used, withdrawn, voided or expires. It is not a credential on its own: it is refused anywhere else, and refused without the agent’s key. See [Purchase authorization](https://docs.veriticity.com/purchases.md#purchase-authorization). ### Organisation-scoped Acts for the organisation rather than for any one identity. It reads the organisation’s agents and policies, simulates against named policy versions, manages webhooks, and reads, uses, withdraws and voids purchase authorizations. It **cannot submit purchases**, because it names no identity to attribute one to. The dashboard does not issue organisation-scoped keys. Your organisation’s members manage webhooks and withdraw or void authorizations in the dashboard instead. > **Individual accounts cannot issue API keys** > > An individual account manages everything from the dashboard, so the REST API is unavailable to one. Convert to an organisation account to use it. ## OAuth connections OAuth is how an external AI assistant — Claude, ChatGPT, or your own — comes to act as an agent that already exists. A person authorises it, chooses which agent it acts as, and sees what it will be able to do before they agree. Owners, admins and users can connect an assistant. Viewers can’t. The assistant does not become a Veriticity user, does not create an agent, and gains no authority of its own. ### Start with discovery Endpoints are published, so nothing needs hard-coding into a configuration file that somebody has to keep correct. **RFC 8414 discovery** ```bash # Start here rather than hard-coding endpoints. curl https://app.veriticity.com/.well-known/oauth-authorization-server ``` **Response** ```json { "issuer": "https://app.veriticity.com", "authorization_endpoint": "https://app.veriticity.com/oauth/authorize", "token_endpoint": "https://app.veriticity.com/oauth/token", "revocation_endpoint": "https://app.veriticity.com/oauth/revoke", "scopes_supported": ["purchase:check", "purchase:request", "purchase:read"], "response_types_supported": ["code"], "grant_types_supported": ["authorization_code", "refresh_token"], "code_challenge_methods_supported": ["S256"], "authorization_response_iss_parameter_supported": true, "client_id_metadata_document_supported": true } ``` ### The flow Authorization Code with PKCE. `S256` only — `plain` is refused. 1. Send the person to `/oauth/authorize` with your `client_id`, `redirect_uri`, `code_challenge`, `state` and the scopes you need. 2. They sign in, choose the organisation and the agent, and approve the scopes. Your request cannot name either — a client that could name a tenant would be choosing whose money it was about to spend. 3. They return to your `redirect_uri` with a `code` and an `iss`. Check `iss` and `state`. 4. Exchange the code within 60 seconds. **Token exchange** ```bash curl -X POST https://app.veriticity.com/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d grant_type=authorization_code \ -d code=cmp_oac_... \ -d redirect_uri=https://your-app.example.com/callback \ -d code_verifier= \ -d client_id=https://your-app.example.com/client.json ``` **200 OK** ```json { "access_token": "cmp_oat_4d1e8a90c375.7mZx...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "cmp_ort_8b2f1c94e05a.Qw3p...", "scope": "purchase:check purchase:request" } ``` Token requests are limited to 300 a minute from one client address. Over the limit, `/oauth/token` answers `429` with `Retry-After` in seconds and `{"error": "temporarily_unavailable"}`; nothing was issued or rotated, and the refresh token you sent is still valid. Retry after that many seconds. ### Lifetimes and rotation | Credential | Lifetime | | --- | --- | | Authorization code | 60 seconds, single use | | Access token | 1 hour | | Refresh token | 90 days, sliding — each rotation resets it | > **Refresh tokens rotate, and reuse is treated as theft** > > Every refresh issues a new refresh token and retires the old one. Presenting a refresh token that has already been used **revokes the entire connection** — every token in the family stops working, and the person who authorised it is told why. > > Store the newest refresh token atomically. Two concurrent refreshes from the same stored token will cost you the connection. ### Registering a client Veriticity accepts **Client ID Metadata Documents** from a fixed list of supported AI assistants: Claude (documents on `claude.ai`) and ChatGPT (on `chatgpt.com`). Their `client_id` is an HTTPS URL to a document they host, so they connect without anybody registering anything in advance. A `client_id` on any other origin is refused, because the consent screen shows the name the document gives, and that name has to be one we’ve checked. Dynamic Client Registration is not supported. To connect your own OAuth or MCP client, ask us to register it: send its name, what it does and its exact redirect URIs through [Veriticity support](https://app.veriticity.com/support). A registered client is given a `client_id` (and, for a server-side client, a secret) for Live; Sandbox is registered separately. ## Which credential reaches what | Operation | Agent-bound key | Organisation key | OAuth connection | | --- | --- | --- | --- | | Submit a purchase | Yes | No | `purchase:request` | | Read back or list its own purchases | Yes | No | `purchase:read` | | Cancel a request still waiting for approval | Its own | Any in the organisation | `purchase:request`, its own | | Read its agent, spending rules and budget status | Yes | No | `purchase:check` | | Simulate against current policies | Yes, as itself | Yes, must name `agentId` | `purchase:check` | | Get an execution token for its own authorization | Yes, the key that submitted it | No | No scope reaches this | | Use a purchase authorization | Its own agent’s, with the execution token | Yes | No scope reaches this | | Read, list or void authorizations | No | Yes | No scope reaches this | | Withdraw an unused authorization | Of a purchase it submitted | Yes | `purchase:request`, of a purchase it submitted | | Read policies, list agents | No | Yes | No scope reaches this | | Simulate a named policy version | No | Yes | No scope reaches this | | Manage webhooks | No | Yes | No scope reaches this | | Write policy | No | No | No | | MCP tools | API keys refused | API keys refused | Per-tool scope | Response shapes do not vary by credential. The same endpoint returns the same body whether an API key or a connection called it. ## Scopes There are exactly three, and no scope implies another. Holding `purchase:request` does not grant `purchase:check`, and the reverse is equally false. | Scope | What the person is consenting to | | --- | --- | | `purchase:check` | Check whether a purchase would be allowed | | `purchase:request` | Request a purchase | | `purchase:read` | See what happened to purchases it requested | > **A scope says what may be asked, never how much may be spent** > > `purchase:request` means the application may ask. It says nothing about the answer and nothing about the amount — a request for GBP 10,000 is refused by exactly the same policy evaluation that would refuse it from an API key. What may be spent is [policy](https://docs.veriticity.com/policies.md), and a person writes it. There is no `full_access` scope, no implicit scope, and no `client_credentials` grant. A client-credentials token would carry an application and an organisation but no person and no agent, which is an organisation-scoped API key with extra ceremony. ## When a credential stops working Revocation takes effect on the **next call**, not at the end of a token’s hour. The connection, the client, the organisation and the granting person’s membership and role are all re-read on every request. That means a connection can stop working without your credential changing at all. Three refusals matter, and they are not interchangeable: | Code | What to do | | --- | --- | | `insufficient_scope` | Re-authorise with a wider grant. This will work. | | `oauth_not_permitted` | Stop. No scope reaches this operation, so reconnecting changes nothing. | | `connection_unauthorized` | Stop and tell the person. The human behind the grant has left or lost permission; refreshing will succeed and the next call will fail identically. | The full list is on [Errors](https://docs.veriticity.com/errors.md). --- Source: https://docs.veriticity.com/authentication --- # 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** ```json { "purchaseRequest": { "id": "0199a3f1-8c2e-7b40-9f31-6d2e5a1c0b77", "status": "PENDING_APPROVAL", "amountMinor": "42000", "maximumMinor": null, "currency": "GBP" }, "decision": { "id": "0199a3f1-91b4-7c02-a55d-3e7f1d9c4a20", "outcome": "REQUIRES_APPROVAL", "authority": null, "reasonCodes": ["AMOUNT_OVER_LIMIT"], "explanation": "Requires approval. GBP 420.00 is over the GBP 250.00 limit for a single purchase.", "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": "Bound to the organization" } ], "findings": [ { "policyId": "0199a3e0-…", "policyName": "Company baseline", "policyVersionId": "0199a3e0-…", "policyVersionNumber": 4, "policyRuleId": "0199a3e0-…", "ruleKey": "approval-threshold", "rule": "Approval threshold", "outcome": "REQUIRE_APPROVAL", "role": "BINDING", "limitMinor": "25000", "observedMinor": "42000", "headroomMinor": "-17000", "currency": "GBP", "reasonCode": "AMOUNT_OVER_LIMIT", "message": "GBP 420.00 is over the GBP 250.00 limit for a single purchase." }, { "policyId": "0199a3e0-…", "policyName": "Company baseline", "policyVersionId": "0199a3e0-…", "policyVersionNumber": 4, "policyRuleId": "0199a3e0-…", "ruleKey": "max-purchase", "rule": "Maximum purchase amount", "outcome": "PASS", "role": "SATISFIED", "limitMinor": "50000", "observedMinor": "42000", "headroomMinor": "8000", "currency": "GBP", "reasonCode": "REQUIREMENT_MET", "message": "GBP 420.00 is within the GBP 500.00 limit for a single purchase." } ], "replayed": false } ``` | 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` | `APPROVED` means Veriticity permits the purchase. Veriticity does not make the purchase and is not told whether it happened. A spending limit counts every purchase that is approved or waiting for approval, by the agents its policy covers, whether or not the money was spent. `findings` 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. Each finding names its rule by `ruleKey`. On an approved purchase, `decision.authority` says whether it was `WITHIN_POLICY` or `AUTHORIZED` by a person. `purchaseRequest.amountMinor` is the known amount. When a charge can still vary, `purchaseRequest.maximumMinor` is the most the purchase can cost, with every such charge at its maximum, and is what rules and budgets judged. It is `null` when no charge can vary. A purchase you read or list carries both, and a simulation’s `purchase` does too. `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. | | `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 that governs this agent is about this purchase, so it has no spending authority. Veriticity approves nothing by default. | | `NO_APPLICABLE_RULE` | Policies govern this agent, but none of their rules applies to this purchase, so nothing grants it. | | `POLICY_CHANGED_SINCE_ESCALATION` | A person approved it, but the policies changed before it was re-evaluated, and it no longer passes. | | `POLICY_MALFORMED` | A policy version could not be read, so everything it governs is refused. | | `RULE_MALFORMED` | A rule could not be read, so it refuses. | | `FACT_UNVERIFIED` | A rule reads something the agent stated and nobody has verified, such as the merchant, so a person has to confirm it. | | `FACT_UNAVAILABLE` | A rule reads something this purchase did not say. | | `FACT_STALE` | A rule reads something that was verified too long ago to rely on. | | `MEASUREMENT_UNAVAILABLE` | A rule measures something that could not be measured. | | `CONDITION_ERROR` | A condition failed while it was being checked, so its rule refuses. | | `AMOUNT_OVER_LIMIT` | The amount is over a limit on a single purchase. The rule's effect says whether that refuses it or asks a person. | | `AMOUNT_UNKNOWN` | A rule limits the amount and the amount could not be read. | | `CURRENCY_NOT_COMPARABLE` | A limit is set in one currency and the purchase is in another. Veriticity performs no currency conversion. | | `AMOUNT_AT_OR_OVER_MINIMUM` | | | `AMOUNT_UNDER_MINIMUM` | | | `AMOUNT_MINIMUM_NOT_ESTABLISHED` | | | `SPEND_OVER_LIMIT` | This would take spending in a window, such as a day or a month, over its limit. | | `SPEND_NOT_COUNTED` | | | `SPEND_NOT_READ` | A rule limits spending in a window and the total could not be read. | | `SPEND_NOT_ESTABLISHED` | | | `SCOPE_SUBJECT_NOT_ESTABLISHED` | A limit names an agent, person, merchant or category the organisation does not have, so what it limits cannot be established. | | `PURCHASE_COUNT_WITHIN_LIMIT` | | | `PURCHASE_COUNT_OVER_LIMIT` | | | `PURCHASE_COUNT_NOT_COUNTED` | | | `PURCHASE_COUNT_NOT_READ` | | | `PURCHASE_COUNT_NOT_ESTABLISHED` | | | `MERCHANT_IN_SET` | Recorded on earlier decisions: a rule required the merchant not to be on a list of typed names, and it was. No longer produced. | | `MERCHANT_NOT_IN_SET` | A rule reads a list of typed merchant names, and the purchase is with a merchant on record. A name identifies no merchant, so the merchant is not on the list. With `reasonParams.state`, the name is on the list and the rule refuses whichever way it reads. A purchase with no merchant on record gets `MERCHANT_IDENTITY_UNESTABLISHED` instead. | | `MERCHANT_UNKNOWN` | A rule reads the merchant and the purchase did not name one. | | `MERCHANT_IDENTITY_UNESTABLISHED` | A rule reads the merchant, and the domain or reference on this purchase is not on record for one merchant, or the purchase states neither. `reasonParams.state` says whether it is unknown, points to more than one merchant, or is out of date. | | `CURRENCY_NOT_LISTED` | Policies govern this agent, but none sets limits in the currency of this purchase. Veriticity performs no currency conversion. | | `CURRENCY_UNKNOWN` | A rule reads the currency and it could not be read. | | `MERCHANT_NOT_VERIFIED` | A rule requires a verified merchant, and this one is not currently verified. | | `MERCHANT_LISTED` | A rule requires the merchant not to be one of those it lists, and it is. | | `MERCHANT_NOT_LISTED` | A rule requires the merchant to be one of those it lists, and it is not. | | `MERCHANT_COUNTRY_LISTED` | A rule excludes the merchant's country. | | `MERCHANT_COUNTRY_NOT_LISTED` | A rule lists the countries a merchant may be in, and this one is elsewhere. | | `MERCHANT_COUNTRY_UNESTABLISHED` | A rule reads the merchant's country and none is on record. | | `LEGAL_ENTITY_JURISDICTION_LISTED` | A rule excludes the country the merchant's company is registered in. | | `LEGAL_ENTITY_JURISDICTION_NOT_LISTED` | A rule lists where a merchant's company may be registered, and this one is registered elsewhere. | | `LEGAL_ENTITY_UNESTABLISHED` | A rule reads the company behind the merchant and none is on record. | | `PRODUCT_IDENTITY_UNESTABLISHED` | A rule reads the product, and the product reference on this purchase is not on record for this merchant. | | `PRODUCT_CATEGORY_LISTED` | A rule excludes this product's category, or one it is inside. | | `PRODUCT_CATEGORY_NOT_LISTED` | A rule lists the categories allowed, and this product is in none of them. | | `PRODUCT_CATEGORY_UNESTABLISHED` | A rule reads the product's category and none is on record. | | `PRODUCT_ATTRIBUTE_LISTED` | A rule excludes a detail of this product, such as its condition. | | `PRODUCT_ATTRIBUTE_NOT_LISTED` | A rule lists the values a detail of the product may have, and this one has another. | | `PRODUCT_ATTRIBUTE_UNESTABLISHED` | A rule reads a detail of the product and it is not on record. | | `ATTEMPT_COUNT_WITHIN_LIMIT` | | | `ATTEMPT_COUNT_OVER_LIMIT` | | | `ATTEMPT_COUNT_NOT_COUNTED` | | | `ATTEMPT_COUNT_NOT_READ` | | | `HISTORY_NOT_ATTRIBUTABLE` | | | `DISTINCT_MERCHANTS_WITHIN_LIMIT` | | | `DISTINCT_MERCHANTS_OVER_LIMIT` | | | `DISTINCT_MERCHANTS_NOT_ESTABLISHED` | | | `DISTINCT_MERCHANTS_NOT_COUNTED` | | | `DISTINCT_MERCHANTS_NOT_READ` | | | `DISTINCT_CATEGORIES_WITHIN_LIMIT` | | | `DISTINCT_CATEGORIES_OVER_LIMIT` | | | `DISTINCT_CATEGORIES_NOT_ESTABLISHED` | | | `DISTINCT_CATEGORIES_NOT_COUNTED` | | | `DISTINCT_CATEGORIES_NOT_READ` | | | `PURCHASE_HISTORY_SUFFICIENT` | | | `PURCHASE_HISTORY_INSUFFICIENT` | | | `PURCHASE_HISTORY_NOT_READ` | | | `AMOUNT_WITHIN_BASELINE` | | | `AMOUNT_ABOVE_BASELINE` | | | `AMOUNT_NOT_COMPARED` | | | `BASELINE_NOT_ESTABLISHED` | | | `BASELINE_NOT_READ` | | | `ITEM_CATEGORY_INCLUDED` | | | `ITEM_CATEGORY_NOT_INCLUDED` | | | `ITEM_CATEGORY_ALL_LISTED` | | | `ITEM_CATEGORY_NOT_ALL_LISTED` | | | `ITEM_CATEGORY_NOT_ESTABLISHED` | | | `ITEM_CATEGORY_UNESTABLISHED` | | | `VARIABLE_COSTS_NONE` | | | `VARIABLE_COSTS_WITHIN_ALLOWANCE` | | | `VARIABLE_COST_NOT_PERMITTED` | | | `VARIABLE_COST_OVER_ALLOWANCE` | | | `VARIABLE_COSTS_OVER_CEILING` | | | `VARIABLE_COSTS_UNKNOWN` | | | `NOT_DECIDED_IN_ADVANCE` | | > **A disabled agent is refused by the engine, not at the door** > > Authenticating with a key bound to a disabled agent succeeds, and the purchase is then rejected with `AGENT_DISABLED`. That is not an oversight: a 403 would leave no evidence, and an organisation must be able to look up the fact that their disabled agent tried to buy something. ## 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*. Rules about recent activity are not checked in advance. Those are rules on how many purchases were attempted, how many merchants or categories they span, how much purchase history there is, or how far an amount is from the usual one. They are decided when a purchase is requested. A simulation lists them in `simulatedDecision.deferredRules`, and `simulatedDecision.advance` says how much of the decision the answer is: - `COMPLETE`: every rule was checked. The outcome is what a request made now would be decided as. - `PARTIAL`: some rules are decided on request, and the others already refuse. The refusal stands. - `NOT_DECIDED`: some rules are decided on request, and the outcome is `NOT_DECIDED_IN_ADVANCE`. That is neither an approval nor a refusal. Request the purchase to find out. Budgets are always checked, and a simulation still returns each budget window and what the purchase would leave in it. 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. The status changes to `EXPIRED` within a few minutes of the deadline, so a poll immediately after it may still read `PENDING_APPROVAL`. - **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. Owners and admins approve and reject in the dashboard, including a purchase they requested themselves or that an assistant they connected requested. Users can request purchases and can’t approve or reject any. Viewers can do neither. ## Purchase authorization An `APPROVED` purchase carries `authorization`: an `id`, a `status` and `expiresAt`. It is permission for that one purchase, on exactly the terms you submitted, until that time. Hand the id to whatever will make the purchase. Veriticity authorizes a purchase. It does not make one, and an authorization is not a record that anything was bought. | Status | Meaning | | --- | --- | | `active` | Can be used until `expiresAt`. | | `used` | Used once. It can’t be used again. | | `expired` | Can’t be used. It is not renewed: request the purchase again. | | `cancelled` | Can’t be used. | An expiry is never extended, and a retried submission returns the same authorization with the same expiry. A purchase that was not approved has `authorization: null`. So does a purchase approved before purchase authorizations were introduced: it stays approved, still counts towards your budgets, and can’t be used, withdrawn or voided. Nor can an authorization for a purchase made before its terms were recorded in full: it can still be withdrawn or voided. Your agent reads the authorization on its own purchase, and can withdraw it while it is unused. Reading, listing and voiding authorizations, and withdrawing another’s, take an organization-scoped API key, which the dashboard does not issue. Your organisation’s members do these in the dashboard. The authorization endpoints return none of the purchase’s terms. ### Using an authorization Before making the purchase, the system that will make it calls `POST /v1/purchase-authorizations/{authorizationId}/use` and says what it is about to do: the purchase’s terms exactly as it stated them, and its own `executionReference`. That is the amount, the currency, the merchant and its identifiers, and either the `category` or the `items`, `charges` and `taxIncluded`, in any order. When the purchase has charges that can still vary, `variableFinal` gives what each came to, by kind, and `amountMinor` is the known amount plus those finals. Your agent can make this call with its own key. First it asks for an execution token for the authorization, then it sends that token with the use: **Using an authorization with the agent's key** ```bash # 1. With the agent's key, ask for a token for the authorization. curl -X POST https://app.veriticity.com/v1/purchase-authorizations/$AUTHORIZATION_ID/execution-token \ -H "Authorization: Bearer $VERITICITY_API_KEY" # {"executionToken": {"token": "cmp_pex_...", "authorizationId": "...", "expiresAt": "..."}, "replaced": false} # 2. Use the authorization, with the token in its own header. curl -X POST https://app.veriticity.com/v1/purchase-authorizations/$AUTHORIZATION_ID/use \ -H "Authorization: Bearer $VERITICITY_API_KEY" \ -H "Veriticity-Execution-Token: $EXECUTION_TOKEN" \ -H "Content-Type: application/json" \ -d '{"amountMinor": "42000", "currency": "GBP", "merchant": "Acme Cloud", "category": "software", "executionReference": "order-1182"}' ``` - **Only the key that submitted the purchase can get a token**, and only while the authorization is `active`. The token is shown once. - **The token follows the authorization.** It stops working when the authorization is used, withdrawn, voided or expires, whichever comes first. It can’t change the terms or the expiry, and it works for no other authorization. - **Asking again replaces the token.** The earlier one is refused with `execution_token_invalid`. If the response to a token request is lost, ask again. - An organization-scoped API key uses an authorization without a token. No OAuth connection can use one. | `result` | Meaning | | --- | --- | | `authorized` | The terms match and the authorization has been used. Go ahead. It can’t be used again. | | `refused` | Don’t make the purchase. `reason` is one of `expired`, `already_used`, `withdrawn`, `voided`, `not_usable`, `terms_mismatch`, `allowance_exceeded`, `execution_token_invalid` or `plan_required`. `plan_required` means the organisation’s trial has ended or it has no plan: the authorization can be used once an owner chooses one, until it expires. | Both answers are a `200`. Branch on `result`, never on the HTTP status. - **`authorized` means the authorization was verified and used. It does not mean the purchase was made.** Veriticity does not make the purchase and is not told its outcome. - **Terms are compared exactly.** The amount must match to the minor unit, and every identifier, item, charge and option the purchase stated must be presented and must match. A different item at the same total is refused. You are not told which term failed. - **A charge that can still vary is used within its maximum.** A final above the maximum the purchase was approved for is refused with `allowance_exceeded`: ask again for a new authorization. - **A refusal changes nothing.** The authorization is exactly as it was, however many times the wrong terms are presented. - **A retry is safe.** Call again with the same `executionReference`, the same terms, the same finals and the same token, and you get the original answer with `replayed: true`. A different reference is refused with `already_used`. - **If the purchase fails after an `authorized`,** the authorization stays used and keeps counting towards budgets until your organisation voids it. If you don’t know whether the purchase went through, find out from the merchant before anything is voided or retried. ### Ending an authorization An authorization that should no longer stand is ended by your organisation, from the dashboard or with an organization-scoped API key. The credential that submitted a purchase can also withdraw its authorization while it is unused, when the purchase is no longer wanted or its terms have changed. Each of these needs a `reason`, releases the purchase from your budgets, and can’t be undone. | Call | When | | --- | --- | | `POST /v1/purchase-authorizations/{authorizationId}/withdraw` | The authorization has not been used. It may be active or expired. It can’t be used afterwards. | | `POST /v1/purchase-authorizations/{authorizationId}/void` | The authorization was used and the purchase did not go through. Voiding does not refund or reverse a payment. | | `POST /v1/purchase-requests/{purchaseRequestId}/cancel` | The purchase is still waiting for approval. The agent that asked can call this with its own credential, and the reason is optional. | - **An expired authorization still counts towards budgets.** Expiry releases nothing and sends no event. Withdraw the authorization to release it. To find them, call `GET /v1/purchase-authorizations?status=expired`. - Calling the wrong one for the authorization’s state returns `409 purchase_authorization_conflict` and changes nothing. - Repeating a call that already succeeded returns the same answer with `replayed: true`. - Each of these sends `purchase.cancelled` to your webhook endpoints. ## Reporting what happened After an approved purchase has gone through, or hasn’t, the credential that submitted it says so with `POST /v1/purchase-requests/{purchaseRequestId}/outcome`: `COMPLETED` with `finalAmountMinor`, `COMPLETED_AMOUNT_UNKNOWN`, `PARTIAL` with the bought items’ `itemPositions`, `FAILED`, or `REFUNDED` with `refundMinor`. A purchase with charges that could vary can give each one’s final in `variableFinal`. - A report is the agent’s word, and is shown as reported by the assistant. By default it waits for the purchaser or someone who can approve purchases to confirm what was charged. An organisation can choose to accept assistant reports instead. - Until an outcome is accepted, the purchase is recorded at the most it was approved for. A report never approves anything, and never makes, changes or cancels an order or a payment. - An amount above what was approved is recorded and flagged for the organisation to review. It counts until someone who can approve purchases confirms what was charged. - A refund can be reported once the authorization has been used or withdrawn. Before then it is `purchase_outcome_conflict`. - Send a `reportId`. The same report again returns `replayed: true`; a different report under a used `reportId` is `report_id_reuse`. A purchase that was never approved is `purchase_outcome_conflict`. - The answer’s `purchase` names the purchase the report was recorded against: its `merchant`, `merchantDomain`, `amountMinor`, `maximumMinor`, `currency` and `requestedAt`. ## Idempotency `Idempotency-Key` is a header, and `POST /v1/purchase-requests` is the only operation that accepts one. It is **required**: a submission without it is refused and nothing is created. | You send | You get | | --- | --- | | Same key, same body | `200` with the original decision, the same purchase authorization and `replayed: true`. No new budget reserved. | | Same key, different body | `409 idempotency_key_reuse` | | Same key, same body, a different credential | `409 idempotency_key_reuse`. A retry is answered only for the credential that made the request. | | No key | `400 invalid_request`, with an issue at `Idempotency-Key`. Nothing is evaluated. | **Retrying safely** ```typescript // 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 { 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. A key is unique across your **whole organisation**, not per agent or per credential. Two agents that send the same key with different purchases collide: the second gets `409 idempotency_key_reuse`. If several agents derive keys from the same kind of id, put the agent in the key. ## 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. > **The readback names people** > > `approval.resolvedBy.name` is the person who answered, and `submittedVia.grantedBy.name` is the person who authorised a connection. If you are building an integration that forwards this data onward — particularly to an external AI provider — that is personal data and you should handle it accordingly. > > An OAuth connection made by a member with the User role is told only its own person’s name. For that connection, `approval.resolvedBy` is `null` when somebody else answered, and an explanation that opens with who approved it reads “Approved, then re-evaluated against current policy.” The decision, its reason codes and the approver’s comment are unchanged. The member’s current role applies on every request. ### Listing your purchases `GET /v1/purchase-requests` returns the purchases the calling credential submitted, newest first. Filter with `status`, `since` and `until`; page with `limit` (1 to 50, default 20) and the `nextCursor` from the previous page. It is the REST counterpart of [MCP](https://docs.veriticity.com/mcp.md)’s `list_purchases`. > **A rotated key can't read the old key's purchases** > > Both reads are scoped to the credential, not the agent. When you rotate or replace an API key, the new key does not see purchases the old key submitted, by id or in the list. For a record that survives rotation, subscribe to [webhooks](https://docs.veriticity.com/webhooks.md) and keep the events. ## What an agent can read about itself Three more reads for an agent-bound credential. None takes an agent id: a credential acts as one agent. Each answers the same as its MCP tool. | Endpoint | Returns | | --- | --- | | `GET /v1/agent` | The agent, and the currency and timezone its organisation operates in. | | `GET /v1/spending-rules` | The policies in force for it, with their rules, and the size of any merchant list. | | `GET /v1/budget-status` | Each budget window, whatever its length: spent, held for approvals, and remaining under each limit. | `spending-rules` and `budget-status` take an optional `?currency=`. Without it they answer in the organisation’s default currency. An organisation-scoped key is refused with `api_key_not_bound_to_agent`. ## 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 | The host you would buy from. It is what identifies the merchant: the name never does. It matches where the organisation has that host on record, with or without a leading `www.`, or has chosen to trust the subdomains of a host it is under. A host that is sent and not on record leaves the merchant unrecognised, and so does sending none. | | `merchantReference` | No | A commerce platform’s id for the merchant, as `{ provider, id }`. | | `productReference` | No | An id for the product, as `{ provider, id }`. The provider is a platform, or a scheme such as `sku` or `gtin`. It is looked up within the merchant. | | `category` | Unless `items` | What kind of product it is, from Veriticity’s list: `electronics`, `software`, `office-supplies`, `clothing`, `home-garden`, `food-drink`, `health-beauty`, `kids-baby`, `pet-supplies`, `sports-outdoors`, `automotive`, `travel`, `media-entertainment`, `services`, `medicines`, `gift-cards`, `alcohol`, `tobacco-nicotine`, `adult`, `gambling`, `crypto`, `recreational-drugs` or `other`. Choose by what the product is, not where it’s sold. `other` is for a product you can identify that no category covers, not for one you can’t. Any other value is refused. A purchase holding different kinds of product, or a product that is two of `medicines`, `gift-cards`, `alcohol`, `tobacco-nicotine`, `adult`, `gambling`, `crypto` and `recreational-drugs` at once, states `items` instead. Each category’s definition, and where its neighbours go, is in the [API reference](https://docs.veriticity.com/api-reference.md). It is your claim, and Veriticity doesn’t check it: see [Policies](https://docs.veriticity.com/policies.md) for how a rule treats it. | | `items` | Instead of `category` | Up to 50 items, each with a `description`, a `category`, a `quantity` and a `unitAmountMinor`, and optionally a `discountMinor`, a `productUrl` on the merchant’s website, a `productReference` and `options`. Tag an item with `riskTags` for every restricted kind it also involves: a crypto casino deposit is `gambling` tagged `crypto`, and a rule on either applies to it. If you can’t establish what an item is, send `categoryUncertain: true` and an `uncertaintyReason` instead of a category; never guess one. `amountMinor` must then be exactly the item lines plus the charges. Text limits are counted in bytes. | | `charges` | No | Up to 10 charges on the whole basket. `KNOWN` is an exact `TAX`, `DELIVERY`, `DUTY`, `FEE` or a negative `DISCOUNT`; a fee or discount says what it is. `UNEXPLAINED` is any part of the total you can’t account for. `VARIABLE` is a `TAX`, `DELIVERY` or `DUTY` charge that can still change, with `maximumMinor`, the most it may come to; `amountMinor` then leaves it out. The purchase is decided, and counts towards budgets, at the known amount plus every maximum. | | `taxIncluded` | With `items` | Whether the item prices already include tax. When they do, tax isn’t stated again as a charge. | | `metadata` | No | Any JSON object of at most 16 KiB, nested at most 8 levels. No rule reads it. | A body that states `items` may be up to 640 KiB; any other purchase body up to 64 KiB. A body with items is decided item by item: a rule on a category applies to every item in it or tagged with it, and an item whose category isn’t established is never taken to be outside a rule or a budget. Its authorization is used only with exactly those items and charges. 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. --- Source: https://docs.veriticity.com/purchases --- # Policies How Veriticity decides what an AI agent may buy. Spending limits, merchant allow and block lists, budget windows, approval thresholds, and why policies are authored by people. A policy is the set of rules the Trust Engine evaluates a purchase against. Each rule states something a purchase must satisfy, and what happens when it does not. A policy is bound to an organisation, to particular agents, or to both. ## Policies are written by people There is no API for creating, editing, activating or archiving a policy, and there will not be one. Every credential of every kind is refused with `403 api_key_not_permitted`. > **This is the product, not a limitation** > > The whole claim Veriticity makes is that an AI agent operates within limits a person set. A credential that could rewrite the limits it is judged against would make that claim meaningless — and an organisation-scoped key is a bearer token in an environment variable, with no person behind it and nothing to name in an audit trail. > > So policy is authored in the dashboard, by someone signed in, with a role that permits it, and every change names who made it. If you receive `api_key_not_permitted`, no other credential will work. Do not go looking for one. A policy is created and edited in the rule builder. Creating one takes a name, who it applies to and its rules, and produces a draft. A draft affects no purchase until somebody activates it, and activation checks the draft first. Every condition on this page can be chosen in the builder except the ones marked as unavailable, and a rule that names a merchant chooses it from the merchants your organisation has on record. ## What a policy contains **GET /v1/policies/{id}** ```json { "policy": { "id": "0199a3e0-1f4c-7a11-b3d2-5e8c7a4f2b90", "name": "Company baseline", "boundToOrganization": true, "status": "ACTIVE", "inForce": true, "bindings": { "organization": true, "agentIds": [] }, "activeVersion": { "id": "0199a3e0-2a7b-7c33-9f10-1b2c3d4e5f60", "versionNumber": 4, "status": "ACTIVE", "languageVersion": 1, "ruleCount": 3, "activatedAt": "2026-09-01T08:00:00.000Z" } } } ``` | Field | Meaning | | --- | --- | | `bindings` | Who the policy governs. `organization: true` governs every agent; `agentIds` names particular ones. A policy bound to nobody governs nobody. | | `status` | `ACTIVE`, `DISABLED` or `ARCHIVED`. | | `inForce` | Whether the engine is actually reading it right now. Not the same as having an active version — see below. | | `activeVersion` | The version the engine evaluates against, or null. | > **inForce is not status === ACTIVE** > > A `DISABLED` policy keeps its active version — that is what lets somebody park a policy, fix it in a draft, activate the fix and re-enable, without a window of known-bad enforcement. But the engine skips it entirely while it is parked. > > Read `inForce`. Deriving it from `activeVersion` alone gives the wrong answer. ## What a version contains **GET /v1/policies/{id}/versions/{versionId}** ```json { "version": { "id": "0199a3e0-2a7b-7c33-9f10-1b2c3d4e5f60", "versionNumber": 4, "status": "ACTIVE", "languageVersion": 1, "policyId": "0199a3e0-1f4c-7a11-b3d2-5e8c7a4f2b90", "appliesWhen": null, "appliesWhenConditions": [], "rules": [ { "id": "0199a3e0-…", "key": "max-purchase", "name": "Maximum purchase amount", "when": null, "require": { "atom": "limit" }, "effect": "BLOCK", "onUnestablished": "BLOCK", "conditions": [ { "atom": "limit", "condition": "AMOUNT_LIMIT", "config": { "max": { "amountMinor": "50000", "currency": "GBP" } } } ] }, { "id": "0199a3e0-…", "key": "daily-budget", "name": "Daily spending limit", "when": null, "require": { "atom": "limit" }, "effect": "BLOCK", "onUnestablished": "BLOCK", "conditions": [ { "atom": "limit", "condition": "SPEND_LIMIT", "config": { "max": { "amountMinor": "100000", "currency": "GBP" }, "window": "DAY" } } ] }, { "id": "0199a3e0-…", "key": "approved-suppliers", "name": "Approved suppliers", "when": null, "require": { "atom": "merchant" }, "effect": "BLOCK", "onUnestablished": "REQUIRE_APPROVAL", "conditions": [ { "atom": "merchant", "condition": "MERCHANT_IN_SET", "config": { "set": "suppliers" } } ] } ], "valueSets": [ { "key": "suppliers", "kind": "MERCHANT", "members": [{ "value": "acme cloud", "label": "Acme Cloud" }] } ] } } ``` ### Rules A rule is a requirement. `require` is an expression over the rule’s `conditions`: `all`, `any` and `not`, over `atom` leaves that each name one condition. A rule can only restrict. Nothing in a policy grants a purchase that another rule refuses. | Field | Meaning | | --- | --- | | `key` | The rule’s identity across versions of its policy. An approval is bound to it. | | `when` | Which purchases the rule is about. `null` means every purchase. A rule steps aside only when it is established that the purchase is outside `when`. | | `effect` | What happens when the requirement is broken: `BLOCK` refuses, `REQUIRE_APPROVAL` asks a person, `FLAG` records it and changes nothing. A rule that only flags is not shown to the agent it applies to, and never approves a purchase: a policy needs at least one rule that enforces. | | `onUnestablished` | What happens when the requirement cannot be checked, because a fact it reads is missing or has not been verified. A rule whose `effect` or `onUnestablished` is `REQUIRE_APPROVAL` can't require a limit with `not`: amount, minimum, spend, purchase count, charges that can vary, attempts, different merchants or categories, and unusual amounts. A person could never approve it. Such a version is refused with `NEGATED_NOT_APPROVABLE`. Use `BLOCK`, or require the check without `not`. | ### Conditions | Condition | Holds when | | --- | --- | | `AMOUNT_LIMIT` | The purchase amount is at or under `config.max`. | | `AMOUNT_MINIMUM` | The purchase amount is at or over `config.min`. | | `SPEND_LIMIT` | Spending in `config.window`, including this purchase, is at or under `config.max`. | | `PURCHASE_COUNT_LIMIT` | The purchases in `config.window`, including this one, number at most `config.maxPurchases`. | | `MERCHANT_IN_SET` | Earlier versions only. Never satisfied: a name in the value set named by `config.set` identifies no merchant. Use `MERCHANT_IS`. | | `CURRENCY_IN` | The purchase currency is one of `config.currencies`. | | `MERCHANT_IDENTIFIED` | The merchant is one the organisation has on record. | | `MERCHANT_VERIFIED` | It is, and an independent source currently verifies it. No such source is available yet, so this does not pass. | | `MERCHANT_IS` | The merchant is one of `config.merchants`. | | `MERCHANT_COUNTRY_IN` | The merchant’s country is one of `config.countries`. | | `LEGAL_ENTITY_JURISDICTION_IN` | The company behind the merchant is registered in one of `config.countries`. | | `PRODUCT_IDENTIFIED` | The product is one the organisation has on record for that merchant. | | `PRODUCT_CATEGORY_IN` | The product’s category is one of `config.categories`, or inside one. | | `ITEM_CATEGORY_IN` | With `config.items` `ANY`, an item of the basket is in, or tagged as, one of `config.categories`; with `EVERY`, every item and every tag on it is. “Must not include any of” is `ANY` under `not`. An uncertain item or an unexplained charge leaves it unknown unless another item settles it. The agent’s categories count in a purchase’s favour only where `accept` takes `AGENT_ASSERTED` for `fact.state[items.category_id]`. | | `VARIABLE_COST_LIMIT` | Every charge that can vary is of a kind in `config.allowances` (`TAX`, `DELIVERY` or `DUTY`, each once), its maximum is within the smaller of its `fixed` amount plus `rateBasisPoints` of the items and its `ceiling`, and the maxima together are within `config.overallCeiling`. Every amount is in the overall ceiling’s currency. Without such a rule, charges that can vary are limited only by their stated maxima, budgets and amount rules. | | `PRODUCT_ATTRIBUTE_IN` | The product’s `config.attribute` is one of `config.values`. | | `ATTEMPT_COUNT_LIMIT` | The purchase attempts in `config.window` number at most `config.maxAttempts`. | | `DISTINCT_MERCHANT_LIMIT` | The purchases in `config.window`, including this one, are with at most `config.maxMerchants` different merchants. | | `DISTINCT_CATEGORY_LIMIT` | The purchases in `config.window`, including this one, are in at most `config.maxCategories` different categories. | | `PURCHASE_HISTORY_MINIMUM` | At least `config.minPurchases` purchases were approved in `config.window` before this one. | | `AMOUNT_DEVIATION_LIMIT` | The amount is at or under `config.maxPercent` per cent of the usual amount over `config.window`. | The same condition does different jobs under different rules. An `AMOUNT_LIMIT` under a `BLOCK` rule is a maximum; under a `REQUIRE_APPROVAL` rule it is an approval threshold. A rule that requires `MERCHANT_IS` allows only the merchants it names; one that requires `{ "not": … }` of it rules them out. ### Typed merchant names in earlier versions A version written before merchants were kept on record may hold a list of typed merchant names and a `MERCHANT_IN_SET` condition that reads it. You will still see such a version when you read policies. A merchant is identified by its website, never by its name, so no purchase passes such a list. Names can no longer be typed into a policy: - A new policy or draft cannot add a list, a name or a `MERCHANT_IN_SET` condition. - A draft that still holds one cannot be activated. Each is replaced with merchants on record, using `MERCHANT_IS`, or removed. - No typed name is carried across. A person chooses the merchants on record that replace the list. A list is a value set: a named list of values a version’s conditions refer to. Names on a list are kept without regard to case, so “Acme Cloud” and “acme cloud” are one entry. A typed name is not a merchant and never identifies one. A name can refuse a purchase and can never allow one, whatever website the purchase states. - A purchase whose domain or reference is not on record for one merchant, or that states neither, is refused by an allowed list with `MERCHANT_IDENTITY_UNESTABLISHED`, whatever name it states. A person cannot approve it. A blocked list applies its rule’s `onUnestablished` with the same code, and refuses where the name is on the list. - A purchase with a merchant on record, under a name missing from an allowed list, is refused with `MERCHANT_NOT_IN_SET`. Under a name missing from a blocked list it passes that rule, as before. - A purchase with a merchant on record, under a name on a list, is refused with `MERCHANT_NOT_IN_SET` and `reasonParams.state`, on an allowed list and on a blocked one. A person cannot approve it. Policies with no merchant rule are unaffected. An agent can read that a policy has a merchant list and how long it is. It cannot read the names on it. ### Merchants, products and categories An organisation keeps a record of the merchants it deals with, each under a key it chooses, such as `acme-cloud`. A purchase never states that key. It states the website it would buy from, and optionally a platform reference, and the merchant is the one those are on record for. The name on a purchase is a label: it is shown in the dashboard and in history, and is never used to match a merchant. Owners and admins add merchants under **Merchants** in the dashboard. A website is required and is what purchases are matched on; a platform reference is optional; the name is a label. A merchant on record is one the organisation has chosen to trust. Adding it does not verify its identity, and withdrawing it stops purchases matching it without removing its history. There is no API for merchant records. That trust lasts 90 days. After that, purchases stop matching the merchant until an owner or admin confirms it again in the dashboard. Nothing a purchase states renews it, and a rule set to go by what the agent states does not match a merchant whose trust has expired or been withdrawn. - Matching is exact. A host or a reference identifies a merchant only if that exact value is on record for it, and `shop.example.com` is not `example.com`. - `example.com` and `www.example.com` are the same website, whichever of them is on record and whichever the purchase states. - A purchase that states no website, or one that is not a host name, matches no merchant by its name. A matching name never makes up for a missing or different website, and a different name never stops a website matching. - A website matches its subdomains only when an owner or admin chose to trust them. Then `checkout.example.com` matches a merchant on record at `example.com`; `notexample.com` and `example.com.attacker.test` never do. - A public suffix or a shared-hosting domain, such as `co.uk` or `github.io`, cannot be put on record as a merchant’s website, and trusting subdomains never reaches a host that belongs to a different registrant. - A domain or a reference that is sent must be on record. A purchase naming a known merchant at an unknown domain is not recognised. Matching a merchant only identifies it: every rule, limit and trust check still applies to the purchase. - If the domain and the reference point to different merchants, the merchant is not recognised. - A merchant that is not recognised never satisfies a rule, is never off a block list, and is never outside a rule’s scope. The rule’s `onUnestablished` applies, with `MERCHANT_IDENTITY_UNESTABLISHED`. What is on record about a merchant — that it is verified, its country, the company behind it — has a source and a date, and can expire or be withdrawn. Expired or withdrawn, it no longer counts. Where two sources disagree, neither counts. Only an independent source can say a merchant is verified: a member of the organisation cannot, and adding a merchant never does. An agent’s statement of which merchant it is buying from is still the agent’s statement. By default a rule about the merchant is satisfied only when a person named the merchant, as on a purchase made from the dashboard; otherwise the purchase waits for a person with `FACT_UNVERIFIED`. A condition’s `accept` can allow the agent’s statement of the merchant. Nothing an agent sends can make a merchant verified. An agent reading its own rules is shown how many merchants a `MERCHANT_IS` condition lists, as `merchantsCount`, and not which ones. A product is identified by `productReference`, within its merchant. A category is a key, and includes everything inside it. Every organisation has Veriticity’s categories, `veriticity:electronics`, `veriticity:software` and the rest, including `veriticity:adult` and `veriticity:alcohol`: one for each `category` a purchase may state. Every purchase states one. A purchase decided before categories were required may have none, and has no category a rule can rely on. The `category` a purchase states is the caller’s claim, and Veriticity doesn’t check it. It can make a purchase fail a category rule but never pass one, unless the condition’s `accept` takes `AGENT_ASSERTED` for `fact.state[product.category_id]`. In the dashboard that is “Accept the category the agent gives”. Window selectors and `DISTINCT_CATEGORY_LIMIT` say the same with `acceptStated: true`. ### Recent activity Five conditions look at what surrounds a purchase: how many attempts there have been, across how many merchants or categories, with how much history, and how far the amount is from what is usual. Each takes a `window` and a `scope`, written as for a budget. `scope` is required. An attempt is a purchase request Veriticity decided on: approved, refused, or waiting on a person. It stays one attempt when a person later approves or rejects it, and when it expires or is cancelled. - A request refused before a decision is not an attempt. That covers a malformed request, a failed sign-in, a rate limit and a plan allowance. - Sending the same request again with the same `Idempotency-Key` is the same attempt. Sending it with a new key, or with none, is a new one. - A simulation is not an attempt, and does not check these rules: they are decided when a purchase is requested. | Condition | Configuration | | --- | --- | | `ATTEMPT_COUNT_LIMIT` | `maxAttempts`, and `counting`: `ALL` counts every attempt including this one; `REFUSED` counts earlier refusals; `PENDING` counts purchases waiting on a person. | | `DISTINCT_MERCHANT_LIMIT` | `maxMerchants`. Counts purchases that were approved or are waiting on a person. | | `DISTINCT_CATEGORY_LIMIT` | `maxCategories`, and `level`: `EXACT` counts each category separately, so a category and one inside it are two; `TOP` counts by top-level category. | | `PURCHASE_HISTORY_MINIMUM` | `minPurchases`, a rolling `window`, and optionally `currency`. With `"selector": { "kind": "EACH_MERCHANT" }` it counts earlier purchases at this purchase’s merchant. | | `AMOUNT_DEVIATION_LIMIT` | `baseline` (`MEAN` or `MAX`), `maxPercent`, `currency`, `minSample` (5 or more) and a rolling `window`. | Purchase history and the usual amount come from approved purchases only. A purchase that was refused, or is waiting on a person, is not history. - With fewer than `minSample` approved purchases in the window there is no usual amount. The rule’s `onUnestablished` applies, with `BASELINE_NOT_ESTABLISHED`. - `PURCHASE_HISTORY_MINIMUM` is not met when there are too few approved purchases, with `PURCHASE_HISTORY_INSUFFICIENT`. The rule’s `effect` applies. - A purchase whose merchant or category is not recognised could be anywhere. If the limit holds wherever it was, the rule is met. If it cannot be told, the rule’s `onUnestablished` applies, with `DISTINCT_MERCHANTS_NOT_ESTABLISHED` or `DISTINCT_CATEGORIES_NOT_ESTABLISHED`. - A merchant the agent named is not a recognised merchant for `DISTINCT_MERCHANT_LIMIT` unless the condition sets `"acceptStated": true`. These conditions cannot be used in a rule’s `when`. To leave purchases with too little history to other rules, require either too little history or a usual amount: **Too little history, or a usual amount** ```json "require": { "any": [{ "not": { "atom": "history" } }, { "atom": "amount" }] } ``` A usual amount rises as an agent spends more. Keep an `AMOUNT_LIMIT` or a `SPEND_LIMIT` alongside it. A limit on refused attempts stays in force while the agent keeps trying. With a `scope` of `ORGANIZATION` or `POLICY`, one agent’s attempts count against every agent the rule covers; use `EACH_AGENT` to count each agent’s own. An agent is told that one of these rules objected, and its reason code. It is not shown the limit, the count, the window or the usual amount, and an agent reading its own rules sees that the rule exists and not its configuration. These rules are not listed as budgets. ## Versions and lifecycle Policies are versioned, and versions are immutable once active. A decision records which version it was evaluated against, so a purchase from March can be explained against the rules that were in force in March rather than today’s. - `DRAFT` — editable, not enforced. A policy may have one. - `ACTIVE` — enforced. Exactly one per policy. - `ARCHIVED` — superseded, kept for evidence. ## Budget windows A `SPEND_LIMIT` or a `PURCHASE_COUNT_LIMIT` measures a window. A calendar window — `DAY`, `WEEK`, `MONTH`, `QUARTER` or `YEAR` — is a period in the **organisation’s** timezone, not the server’s or the caller’s. A daily limit resets at midnight where the organisation is. A week starts on Monday unless `weekStartsOn` names another day. **config, three ways** ```json // A calendar week in the organisation's timezone, from Monday { "max": { "amountMinor": "100000", "currency": "GBP" }, "window": "WEEK" } // The same, from Sunday { "max": { "amountMinor": "100000", "currency": "GBP" }, "window": { "kind": "CALENDAR", "unit": "WEEK", "weekStartsOn": "SUNDAY" } } // No more than three purchases in any ten minutes { "maxPurchases": 3, "window": { "kind": "ROLLING", "seconds": 600 } } ``` A rolling window is a length of elapsed time, from 60 seconds to 366 days. It is never a calendar period: `86400` seconds is twenty-four hours, not a day, and `604800` is not a week. A rolling limit holds for every stretch of that length, so no ten minutes can contain more than a ten-minute limit allows. A window counts purchases that were approved or are waiting on a person. A refused, expired or cancelled purchase is not counted. It includes purchases made before the limit existed: a monthly limit activated on the 20th starts from what was spent since the 1st. ## Whose spending a limit measures `config.scope` says whose purchases the window holds. Left out, it is everything the policy governs: a policy bound to the organisation limits the whole organisation, and a policy bound to two agents limits those two together. | `scope` | The window holds | | --- | --- | | omitted, or `{ "kind": "POLICY" }` | The agents the policy binds, together. | | `{ "kind": "ORGANIZATION" }` | Every agent in the organisation. | | `{ "kind": "EACH_AGENT" }` | The agent making the purchase. Each agent has its own. | | `{ "kind": "AGENT", "agentId": "…" }` | One agent. | | `{ "kind": "EACH_PRINCIPAL" }` | The person the purchase is made for: whoever granted the connection, or asked in the dashboard. Purchases made with an API key share one window. | | `{ "kind": "PRINCIPAL", "userId": "…" }` | One person. | An `AGENT` scope must name one of the organisation’s agents that is not archived, and a `PRINCIPAL` scope one of its members. A version that names anybody else is refused with `SUBJECT_NOT_IN_ORGANIZATION` when it is saved and when it is activated. If the agent is archived or the person leaves afterwards, the limit can no longer be applied: purchases under that rule are decided by its `onUnestablished`, with the reason code `SCOPE_SUBJECT_NOT_ESTABLISHED`, until the policy is changed. Limits add up; none overrides another. A purchase is approved only when every limit that applies to it holds, so an organisation-wide limit of £1,000 a day does not raise an agent’s own limit of £100. ### Limits for one merchant or one category `config.selector` narrows a window to purchases of one kind. It combines with any `scope`: £500 a month at one merchant, for each agent, is a selector and `EACH_AGENT`. | `selector` | The window holds | | --- | --- | | `{ "kind": "MERCHANT", "merchant": "acme-cloud" }` | Purchases at that merchant. | | `{ "kind": "EACH_MERCHANT" }` | Purchases at this purchase’s merchant. Each merchant has its own. | | `{ "kind": "CATEGORY", "category": "veriticity:electronics" }` | Purchases in that category or anything inside it. | A purchase is outside a selector only when its merchant or category is on record as a different one. A purchase whose merchant is not recognised, or is only the agent’s statement, is tested against the limit and counted in it, so a merchant’s budget cannot be avoided by naming the merchant differently. `acceptStated: true` on the selector lets the agent’s own statement put a purchase outside. Under `EACH_MERCHANT`, a purchase whose merchant is not recognised has no window to be measured in, and the rule’s `onUnestablished` applies. A selector must name a merchant or category the organisation has, on the same terms as a named agent. Purchases made before a merchant was on record have no recognised merchant, so they count toward every merchant and category limit whose window still includes them. ## Currency A monetary limit compares amounts in its own currency only. Veriticity performs no conversion. A purchase in another currency cannot be shown to be within the limit, so the rule’s `onUnestablished` applies, and it is not added to the window. To leave other currencies to other rules, give the rule a `when` that names its currency. `PURCHASE_COUNT_LIMIT` has no currency and counts purchases in all of them. A window tracks two separate quantities: | Quantity | Meaning | | --- | --- | | `heldMinor` | Reserved by purchases waiting on a person. | | `committedMinor` | Spent by purchases that were approved. | A simulation returns both plus what the purchase would add, which is the honest way to answer “how much is left”. Subscribe to `budget.threshold_reached` and `budget.exhausted` on [webhooks](https://docs.veriticity.com/webhooks.md) to be told rather than having to poll. ## What a version applies to A version can say which purchases it is about with `appliesWhen`, an expression over `appliesWhenConditions`. A version about sterling only uses a `CURRENCY_IN` condition. `null` means every purchase. **Veriticity performs no currency conversion.** A limit set in one currency says nothing about a purchase in another, so a purchase in a currency no rule covers is refused rather than converted at a rate nobody agreed to: with `CURRENCY_NOT_LISTED` when a policy lists the currencies it has limits for, and `NO_APPLICABLE_RULE` when no rule applied at all. A simulation lists every policy that governs the agent under `policiesConsidered`, each with a `participation` of `PARTICIPATING` or `NOT_APPLICABLE`, which is what makes a `NO_APPLICABLE_POLICY` answer actionable. ## Reading policies over the API Three read endpoints, all **organisation-scoped API keys only**: - `GET /v1/policies` — filter with `?status` and `?search`, page with `?page` and `?pageSize`. - `GET /v1/policies/{id}` - `GET /v1/policies/{id}/versions/{versionId}` An agent-bound credential is refused. The constraints being drawn up for an agent are the authoring side’s business — an agent learns what it may spend by *asking*, through a simulation, rather than by reading the rules. > **What an agent can see** > > A simulation does return policy names, limits and remaining budget for the agent making it. That is intentional: an agent that has just been refused needs to know what the limit was in order to do anything sensible next. What it cannot see is the authoring model — drafts, version history, which other agents a policy governs. ## Testing a change before it is active Two endpoints help before an activation: ### Validate Runs activation’s own check as a dry run. It answers `200` even when the draft is unacceptable — you asked a question and got an answer. **Validate a draft** ```bash curl -X POST https://app.veriticity.com/v1/policies/$POLICY_ID/versions/$VERSION_ID/validate \ -H "Authorization: Bearer $VERITICITY_ORG_KEY" ``` **200 OK** ```json { "valid": false, "issues": [ { "path": "version.rules", "code": "NO_RULES", "message": "A policy version needs at least one rule. A policy that requires nothing grants nothing." } ], "warnings": [], "budgetImpact": [], "version": { "versionNumber": 5, "status": "DRAFT" } } ``` `budgetImpact` is empty in the ordinary case. A non-empty one lists each window where a limit in this version would start from spending already made, as `spentMinor` — worth looking at before you activate. `warnings` never stop an activation. Each describes purchases the version refuses that its author may expect to pass. Those about one rule have a `path` of `version.rules[n]`: - `HISTORY_NEVER_BUILT`: a rule refuses purchases until enough have been approved, so with no approved history it refuses every purchase it applies to. - `ZERO_MAXIMUM`: a rule allows nothing above zero. - `AMOUNT_IN_OTHER_CURRENCY`: a rule sets amounts in one currency and the version accepts another. Amounts aren’t converted, so a purchase in the other currency can’t be checked against that rule. - `EVERY_RULE_CONDITIONAL`: every rule applies only to some purchases, so a purchase outside all of them is refused. ### Simulate `POST /v1/simulations/draft-policy` asks what a draft would decide about a specific purchase. `POST /v1/simulations/policy-version` asks the same of an active or archived version, exactly as it was frozen — which is how you explain a decision that was made months ago. --- Source: https://docs.veriticity.com/policies --- # Webhooks Receive signed events when purchases are decided, budgets move or connections change. Signature verification, the event catalogue, retries and at-least-once delivery. Veriticity posts a signed copy of what happens in your account to a URL you choose. It is how you find out that a person approved a purchase without polling for it. ## Creating an endpoint **Create an endpoint** ```bash curl -X POST https://app.veriticity.com/v1/webhooks \ -H "Authorization: Bearer $VERITICITY_ORG_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://hooks.example.com/veriticity", "description": "Production ledger sync", "subscribedTypes": ["purchase.approved", "purchase.rejected", "budget.exhausted"] }' ``` **201 Created** ```json { "id": "0199a4b2-7c31-7e50-a1f2-9d3b8c6e4a05", "url": "https://hooks.example.com/veriticity", "status": "ACTIVE", "subscribedTypes": ["purchase.approved", "purchase.rejected", "budget.exhausted"], "apiVersion": "2026-09-01", "consecutiveFailures": 0, "secret": "cmp_whs_Kp8Qw3pZx7mN2vB4cD6fG9hJ1kL3nP5rS7tU9wX0yZ2" } ``` > **The secret is shown once** > > `secret` appears in exactly two responses in the whole API — this one and a rotation. Veriticity cannot show it to you again. Store it before you close the response; if you lose it, rotate. Webhook management requires an **organisation-scoped API key**. An agent-bound credential is refused, and no OAuth scope reaches it at all — admitting a connection would let an AI assistant point your account’s event stream at a URL of its own choosing. ## The envelope Five fields, fixed, on every event. **A webhook body** ```json { "id": "0199a3f1-8c2e-7b40-9f31-6d2e5a1c0b77", "type": "purchase.approved", "apiVersion": "2026-09-01", "createdAt": "2026-09-12T14:03:21.114Z", "accountId": "0198c2d4-1a3b-7c50-8e21-4f9a2b6d8e10", "data": { "purchaseRequestId": "0199a3f1-8c2e-7b40-9f31-6d2e5a1c0b77", "approvalRequestId": "0199a3f2-1b4d-7a20-8c31-2e5f7a9b1c40", "resolvedBy": "HUMAN", "agentId": "0199a3d1-5e6f-7a80-9b10-3c4d5e6f7a80", "agentName": "Build pipeline", "merchant": "Acme Cloud", "amountMinor": "42000", "currency": "GBP" } } ``` | Field | Meaning | | --- | --- | | `id` | The logical event. Stable across retries, identical at every endpoint subscribed to it. **Deduplicate on this.** | | `type` | What happened. | | `apiVersion` | The contract version this endpoint receives. Currently `2026-09-01`. | | `createdAt` | When it happened in the domain, *not* when it was sent. | | `accountId` | Your Veriticity account. Route on it if you serve several. | > **Adding fields and event types is not a breaking change** > > Fields may be added to `data`, and new event types may appear, at any time without `apiVersion` moving. Ignore what you do not recognise. An endpoint keeps the version it was created with, so a future breaking change will not move underneath you. ## Event types | Type | Sent when | | --- | --- | | `purchase.approval_required` | A purchase needs a person to approve it before it can go ahead. | | `purchase.approved` | A purchase was approved, either by your policies or by a person. | | `purchase.rejected` | A purchase was refused, either by your policies or by a person. | | `purchase.approval_expired` | Nobody answered in time, so the purchase was refused. | | `purchase.authorization_used` | A purchase authorization was checked against its exact terms and used. It can't be used again. | | `purchase.cancelled` | A purchase was cancelled: a request taken back before approval, or an approval withdrawn or voided. It no longer counts towards budgets. | | `budget.threshold_reached` | Spending under a policy has passed 80% of its limit. | | `budget.exhausted` | A policy has spent its full limit. Sent once, when spending crosses it. To hear about a purchase being refused, subscribe to purchase.rejected. | | `agent.disabled` | An agent was switched off and can no longer buy anything. | | `agent.archived` | An agent was archived. | | `connection.created` | Someone connected an AI assistant to one of your agents. | | `connection.revoked` | An AI assistant's access to one of your agents was withdrawn. | | `security.connection_token_reuse_detected` | A connected application reused a token it should not have. Its access was withdrawn automatically. | There is one more type you can receive but cannot subscribe to: `webhook.test`, sent by `POST /v1/webhooks/{id}/test` and delivered regardless of your subscriptions. If you switch exhaustively on event type, handle it. `purchase.approved` and `purchase.rejected` fire for *every* terminal decision, not only ones a person answered. `data.resolvedBy` is `HUMAN` or `ENGINE`, and `approvalRequestId` is null for the latter. `purchase.approval_expired` is the one event a request does not cause: nobody answered, so nothing happened at the moment the deadline passed. It is sent within a few minutes of `approval.expiresAt` rather than at it. `purchase.authorization_used` means a purchase authorization was verified and used. It does not mean the purchase was made: Veriticity is not told its outcome. `purchase.cancelled` is sent when a pending request is cancelled, or an authorization is withdrawn or voided. `data.cancellation` says which, and `data.cancelledBy` is `user`, `api_key` or `connection`. The reason and the person’s name are in your audit trail, not in the event. Nothing is sent when an authorization expires. ## Verifying a signature Four headers arrive with every delivery. **Delivery headers** ```http Veriticity-Webhook-Id: 0199a3f1-8c2e-7b40-9f31-6d2e5a1c0b77 Veriticity-Webhook-Delivery: 0199a3f5-2d6e-7b91-a042-8f1c3e5d7b60 Veriticity-Webhook-Timestamp: 1789394601 Veriticity-Webhook-Signature: v1=3Qm8xZ... ``` The signed input is: **Signed material** ``` v1:{timestamp}:{raw body bytes} ``` HMAC-SHA256 under your endpoint’s signing secret, base64url encoded. The [TypeScript SDK](https://docs.veriticity.com/sdk.md) ships this verifier. It takes the `Request` itself, reads the raw bytes, checks the timestamp and the signature, and hands back a typed event — and it needs no API credential, so a receiver can import it on its own: **With the SDK** ```typescript import { Webhooks, isKnownWebhookEvent } from "@veriticity/sdk/webhooks"; const webhooks = new Webhooks({ // An array while a rotation is in flight: either secret verifies. secret: process.env.VERITICITY_WEBHOOK_SECRET!, }); export async function POST(request: Request) { let event; try { // Verifies and parses together, from the bytes as they arrived. event = await webhooks.unwrap(request); } catch { return new Response(null, { status: 400 }); } // Deduplicate on event.id: delivery is at-least-once. if (await alreadyHandled(event.id)) { return new Response(null, { status: 204 }); } if (isKnownWebhookEvent(event) && event.type === "purchase.approved") { // event.data is narrowed to this event's payload. await recordApproval(event.data.purchaseRequestId); } return new Response(null, { status: 204 }); } ``` The same scheme written by hand, for any other language or a project that would rather not take the dependency: **Without the SDK** ```typescript import { createHmac, timingSafeEqual } from "node:crypto"; const TOLERANCE_SECONDS = 5 * 60; /** * Verify a Veriticity webhook. * * rawBody must be the exact bytes that arrived. JSON.parse followed by * JSON.stringify is not the identity function, and a re-serialised body will * not verify. */ export function verifyWebhook( rawBody: string, headers: Headers, secret: string, now: Date = new Date(), ): boolean { const timestamp = headers.get("veriticity-webhook-timestamp"); const signatureHeader = headers.get("veriticity-webhook-signature"); if (timestamp === null || signatureHeader === null) { return false; } // Reject anything too far from our own clock, in either direction, so a // captured request cannot be replayed tomorrow. const age = Math.abs(Math.floor(now.getTime() / 1000) - Number(timestamp)); if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) { return false; } // The version is inside the signed material, not only in the header. const signed = `v1:${timestamp}:${rawBody}`; const expected = createHmac("sha256", secret) .update(signed, "utf8") .digest("base64url"); // During the 24 hours after a rotation the header carries two signatures. // Verifying against either is a success. return signatureHeader .split(",") .map((part) => part.trim()) .filter((part) => part.startsWith("v1=")) .map((part) => part.slice(3)) .some((candidate) => { const a = Buffer.from(candidate); const b = Buffer.from(expected); // timingSafeEqual throws on a length mismatch rather than returning // false, so the lengths are compared first. return a.length === b.length && timingSafeEqual(a, b); }); } ``` > **Verify the raw bytes** > > `JSON.parse` followed by `JSON.stringify` is not the identity function. If your framework parses the body before you see it, the signature will never verify — and this is the single most common reason a first webhook integration fails. **An Express handler** ```typescript import express from "express"; const app = express(); // The raw body, not a parsed one. Most frameworks parse JSON by default and // discard the bytes -- that is the single most common reason a first webhook // integration fails to verify. app.post( "/veriticity", express.raw({ type: "application/json" }), (req, res) => { const rawBody = req.body.toString("utf8"); if (!verifyWebhook(rawBody, new Headers(req.headers as never), SECRET)) { return res.status(400).send("bad signature"); } const event = JSON.parse(rawBody); // Deduplicate on the event id. Delivery is at-least-once, so the same // event can arrive more than once and you will see it eventually. if (alreadyHandled(event.id)) { return res.status(200).send("ok"); } // Acknowledge quickly, then do the work. Veriticity gives you 10 seconds. res.status(200).send("ok"); void handleAsync(event); }, ); ``` ## Delivery semantics - **At least once.** Duplicates are possible. Deduplicate on `Veriticity-Webhook-Id`. - **No ordering guarantee.** Retries reorder, and workers run concurrently. Every purchase event carries enough to implement last-write-wins on `(purchaseRequestId, createdAt)`, and `id` is a UUIDv7 so it sorts by creation time. - **No sequence numbers.** One would invite you to assume gapless delivery, which is impossible — an endpoint subscribed to three of the types sees gaps by design. - **The body is identical across attempts.** A retry sends the same bytes with a new timestamp, so a stored raw body can be re-verified later. If you need certainty that you have not missed anything, reconcile against `GET /v1/purchase-requests/{id}`. ## Retries | Your response | Verdict | | --- | --- | | `2xx` | Success | | `408`, `429` | Retry | | `5xx` | Retry | | Network, DNS, TLS or timeout failure | Retry | | `3xx` | **Permanent failure.** Redirects are never followed. | | Any other `4xx` | **Permanent failure.** The event is dropped for this endpoint. | Seven attempts over roughly 24 hours: `0, +1m, +5m, +25m, +2h, +6h, +15h`, each jittered by about ±20% so a mass outage does not produce a thundering herd on recovery. > **A 403 during a rolling deploy will drop events** > > Treating a non-`408`/`429` `4xx` as permanent is a deliberate judgement, and it can surprise you. If your receiver might answer `401` or `403` while redeploying, answer `503` instead — that is retried. **Veriticity does not disable a failing endpoint.** A three-day outage should not also cost you a re-enable. Watch `consecutiveFailures` on the endpoint to alert yourself. ## Rotating a secret `POST /v1/webhooks/{id}/rotate-secret` issues a new secret and returns it once. For **24 hours** the old one keeps signing alongside it, so every delivery in that window carries two signatures and a receiver verifying with either succeeds. That is what lets you deploy the new secret without coordinating a cutover. Send `{"retireImmediately": true}` to kill the old secret at once, which is what a leak calls for. ## Where Veriticity will send | Rule | Requirement | | --- | --- | | Scheme | HTTPS only | | Port | 443 only | | Host | A hostname. IP literals are refused. | | Userinfo | Refused | | Resolution | Every resolved address must be publicly routable. Checked at creation *and* before every delivery. | | Redirects | Never followed | | Custom headers | Not supported | A URL that fails these answers `400 webhook_url_not_permitted` rather than `invalid_request` — the address is the problem, not your JSON. Since custom headers are unsupported, authenticate inbound deliveries by verifying the signature. A secret path segment works too, but the signature is the control that actually proves the request came from Veriticity. ## Delivery history `GET /v1/webhooks/{id}/deliveries` shows recent attempts: what was sent, what your server answered, and when the next attempt is due. `lastResponseSnippet` carries up to 512 bytes of your own error response, which is usually the fastest way to find out what went wrong. Use `POST /v1/webhooks/{id}/test` to send a real signed event through the real pipeline. There is no verification handshake — a signature proves control on every request, which is better than proving it once. Test events are limited to 5 a minute for each endpoint and 30 every 15 minutes for the organisation, whether they are sent from the API or the dashboard. Over either limit the answer is `429` and no event is queued. See [Errors](https://docs.veriticity.com/errors.md). See [Examples](https://docs.veriticity.com/examples.md) for a complete endpoint lifecycle, and [API reference](https://docs.veriticity.com/api-reference.md) for every field. --- Source: https://docs.veriticity.com/webhooks --- # MCP Veriticity's Model Context Protocol endpoint lets an AI assistant check, request and follow up on purchases within the spending rules a person set. Nine tools, three scopes, one OAuth connection. Veriticity exposes a Model Context Protocol endpoint so an AI assistant can check, request and follow up on purchases — within the spending rules a person set, and acting as an agent that already exists. **Endpoint** ``` POST https://app.veriticity.com/mcp ``` Streamable HTTP, stateless. Nothing is remembered between calls, which is correct for a multi-instance deployment and means one caller’s session can never be served to another. ## How an assistant connects MCP uses **the same OAuth boundary** as the rest of the API — not a second authentication model. An access token from a connection a person authorised is the only credential accepted; an API key is refused outright. The discovery chain is standard, so most clients do it for you: **1. Unauthenticated call** ```bash # An unauthenticated call tells a client where to authenticate. curl -i -X POST https://app.veriticity.com/mcp HTTP/1.1 401 Unauthorized WWW-Authenticate: Bearer realm="veriticity", resource_metadata="https://app.veriticity.com/.well-known/oauth-protected-resource" ``` **2. Protected resource metadata** ```json { "resource": "https://app.veriticity.com/mcp", "authorization_servers": ["https://app.veriticity.com"], "scopes_supported": ["purchase:check", "purchase:request", "purchase:read"], "bearer_methods_supported": ["header"] } ``` From there the client fetches the authorization server’s own metadata and runs Authorization Code with PKCE. A person signs in, chooses the organisation and the agent, and approves the scopes. See [Authentication](https://docs.veriticity.com/authentication.md) for the full flow. > **The assistant connects to an agent that already exists** > > It does not create an agent, does not become a Veriticity user, and gains no authority of its own. It receives a revocable grant over authority that was already there, and revoking the connection leaves the agent untouched. ## Live and Sandbox Each environment has its own endpoint, its own sign-in and its own connections. A token issued by one is not accepted by the other. | Environment | Endpoint | | --- | --- | | Live | `https://app.veriticity.com/mcp` | | Sandbox | `https://sandbox.veriticity.com/mcp` | Every tool behaves the same in both. In Sandbox the agents, policies, budgets, purchases and approvals are Sandbox’s own, and nothing decided there authorises a real purchase. `get_agent` returns `environment` as `live` or `sandbox`, so an assistant can tell which it is connected to. Connecting in Sandbox does not connect Live: add the Live endpoint separately. See [Sandbox](https://docs.veriticity.com/sandbox.md). ## The eleven tools | Tool | Scope | What it does | | --- | --- | --- | | `get_agent` | `purchase:check` | Who this connection acts as, in which environment, currency and calendar, and which scopes it was granted. Takes no arguments. | | `get_spending_rules` | `purchase:check` | The rules in force for that agent. | | `get_budget_status` | `purchase:check` | What the budget windows currently hold. | | `check_purchase` | `purchase:check` | Would this be allowed, right now? Changes nothing. | | `request_purchase` | `purchase:request` | Submits it, and the Trust Engine decides. An approval is permission to buy; nothing is bought. | | `cancel_purchase` | `purchase:request` | Takes back a request of this connection’s that is still waiting on a person. | | `request_payment` | `purchase:request` | Asks Veriticity to pay for an approved purchase of this connection’s, on its approved terms. Answers `unavailable` where paying isn’t set up. | | `get_purchase` | `purchase:read` | What happened to one purchase this connection submitted. | | `list_purchases` | `purchase:read` | Everything this connection has submitted. | | `get_pending_approvals` | `purchase:read` | The ones a person still has to answer. | **A tool call** ```json { "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "check_purchase", "arguments": { "merchant": "Acme Cloud", "merchantDomain": "acme-cloud.example.com", "amountMinor": "7500", "currency": "GBP", "category": "software" } } } ``` `check_purchase` answers with `simulatedDecision` (`outcome`, `reasonCodes`, `explanation`, `advance` and `deferredRules`), `findings` for each rule checked, `budgets` for each window the purchase would touch, and `policiesConsidered`, where each policy’s `participation` is `PARTICIPATING` or `NOT_APPLICABLE`. It is the body `POST /v1/simulations/current-policies` returns. Every answer that carries a purchase’s `amountMinor`, the known amount, carries `maximumMinor` beside it: the most the purchase can cost with every variable charge at its maximum, which is what rules and budgets judged, or `null` when no charge can vary. On `request_purchase`, `get_purchase` and `list_purchases`, an approved decision’s `authority` is `WITHIN_POLICY` when the rules allowed it with no person deciding, and `AUTHORIZED` when a rule asked for approval and a person gave it. Read it, not `reasonCodes`, to tell the two apart. Three scopes across eleven tools is not a gap. The vocabulary describes what a person consented to, not how many functions the transport exposes — reading what is left of a budget is the same grant as asking whether a purchase would be allowed, because it is that question with the purchase left out. ### Paging through history `list_purchases` uses keyset cursor pagination: `limit` between 1 and 50, defaulting to 20, and a `nextCursor` to carry forward. This is deliberately *not* the page-and-size model `GET /v1/policies` uses; see [Conventions](https://docs.veriticity.com/conventions.md). ## Naming the merchant A merchant is identified by its website. Send `merchantDomain`, the host the purchase would be made on, with every `check_purchase` and `request_purchase`. A full URL is accepted and only its host is used. - `merchant` is required and is a label for people. It is never looked up, and a familiar name establishes nothing. - `example.com` and `www.example.com` are the same website. Any other subdomain is a different one unless the organisation has recorded it for that merchant. - `merchantReference` supports the website and never replaces it. Sent without `merchantDomain` it establishes no merchant. Without a website on record, the reason code is `MERCHANT_IDENTITY_UNESTABLISHED`, and a policy that restricts merchants refuses the purchase or holds it for a person. That is a different answer from a merchant that was identified and is not allowed: sending the website fixes the first, and only a change of policy fixes the second. ## Naming the category Both purchase tools take `category`: exactly one value from Veriticity’s list, which the tool’s input schema sets out with a definition of each. The assistant chooses it from what the product is. `other` is for a product it can identify that no category covers, never for one it can’t. Any other value is refused. A purchase holding different kinds of product, or a product that involves more than one restricted kind, is sent as `items` instead, each with its own category and a risk tag for every other restricted kind it involves, with `charges` and `taxIncluded`. An item whose category the assistant can’t establish is sent as uncertain, with a reason, rather than given a guessed category. Asking again for the same items in another order, within fifteen minutes, is the same purchase. A category is recorded as the assistant stated it. It can make a purchase fail a category rule, and it counts in a purchase’s favour only where the rule accepts the category the agent gives. ## What an assistant cannot do Each absence is a design decision rather than a missing feature. - **No tool approves, rejects, resolves or overrides an approval.** When the engine escalates, a person answers in the dashboard and the assistant finds out by calling `get_purchase`. An assistant that could answer its own escalation would make the human half of the trust layer decorative. It can take its own waiting request back with `cancel_purchase`, which only removes authority. - **No tool writes, activates or archives a policy.** The rules an agent is judged against are not editable by the thing being judged. - **No tool creates, renames, disables or archives an agent**, and none issues, reads or revokes a credential or a connection. - **No tool enumerates the organisation’s agents.** An agent handed a roster of its organisation’s other agents has been handed a map of the identities it might impersonate. `get_agent` describes the one agent the caller already is. - **No tool reads the authoring model** — no drafts, no version history, no assignment lists. `get_spending_rules` reports what is in force, not how somebody arrived at it. - **No tool reads merchant names.** `get_spending_rules` says that a policy has a merchant list and how long it is, and never what is on it. A merchant is identified by the website a purchase states, never by its name, and what an assistant states is not verified. Under such a policy a purchase is refused or waits for a person unless its merchant can be established. - **No generic passthrough.** There is no tool that forwards an arbitrary request to `/v1`. - **No tool moves money.** Veriticity governs purchase decisions; it does not execute payments. A check, an approval and an `authorization` are each permission at most. None is a record that anything was bought, and no tool uses an authorization. ## Identity comes from the connection No tool accepts `organizationId`, `agentId` or `connectionId`. They are **absent from every schema**, so a value an assistant sends for one is dropped before any tool reads it and changes nothing. Both come from the authenticated connection. An assistant that could choose its own agent would be choosing whose money it was about to spend. Every read is narrowed on the submitting connection inside the query. Another connection’s purchases — even another connection to the same agent — are indistinguishable from purchases that do not exist. ## Approval A request that needs approval is decided by a person in the dashboard. Who may decide depends on that person’s role in the organisation, and on nothing else. An assistant never approves or rejects anything, whoever connected it. | Role | Connect an assistant | Approve or reject its requests | | --- | --- | --- | | Owner | Yes | Yes, including requests from an assistant they connected | | Admin | Yes | Yes, including requests from an assistant they connected | | User | Yes | No. An owner or admin decides | | Viewer | No | No | On an individual account you are the owner, so you approve your own assistant’s requests. An approval is permission for that purchase and nothing more: no checkout is run and no payment is taken. A purchase your rules refuse outright can’t be approved by anybody. Disconnecting an assistant stops its token at once. A request it left waiting stays in the approval queue for a person to approve or reject, and the disconnected assistant cannot read the result. Removing Veriticity inside an assistant does not always disconnect it: Claude keeps the connection open, and ChatGPT ends it. Disconnect it in Veriticity’s settings to be sure it has ended. ## Retries and asking again A tool call carries no `Idempotency-Key` convention, so `request_purchase` derives one from the connection and what is being bought: `merchant`, `merchantDomain`, `amountMinor`, `currency`, `category` and any references. A host that retries a timed-out call gets the original decision instead of making a second purchase. Calling `request_purchase` again for the same purchase **within fifteen minutes** is one purchase, whatever was decided, and the reply says `replayed: true`. `reason` is not part of the key, so rewording it does not make a second purchase. After fifteen minutes the same purchase is a new request. A refusal is therefore repeated for fifteen minutes. To have the same purchase decided again sooner, pass the earlier purchase’s id as `previousPurchaseRequestId`: **Asking again** ```json { "name": "request_purchase", "arguments": { "merchant": "Acme Cloud", "merchantDomain": "acme-cloud.example.com", "amountMinor": "7500", "category": "software", "reason": "Renew the build pipeline's compute credits", "previousPurchaseRequestId": "0199c0de-0000-7000-8000-000000000001" } } ``` - The earlier purchase must be this connection’s, and must not be waiting for approval. Otherwise the call is `invalid_arguments` and nothing is submitted. - If it was approved and its authorization has not been used, that authorization is withdrawn. It can’t be used afterwards, whatever the new request is decided, and its purchase stops counting towards budgets. - It is a new request, decided against the rules and budgets as they stand, and counted as a new attempt. The earlier one is kept as it was. - Retrying that call returns the same new purchase. To ask a third time, name the second. ## Cancelling a request `cancel_purchase` takes a `purchaseRequestId` and an optional `reason`. It ends a request that is still waiting on a person and releases the budget it held. It is the operation `POST /v1/purchase-requests/{id}/cancel` performs. **cancel_purchase result** ```json { "purchaseRequest": { "id": "0199c0de-0000-7000-8000-000000000001", "status": "CANCELLED" }, "replayed": false } ``` - A second call for the same request answers the same way with `replayed: true`. - A request that was approved, refused or expired, or already decided by a person, is not cancelled: the tool answers `not_cancellable` and nothing is changed. It does not end an authorization. - A purchase another connection submitted is `not_found`. ## Paying for an approved purchase `request_payment` takes a `purchaseRequestId` for an approved purchase this connection requested, and `variableFinal` only when the purchase was approved with charges that could still vary. It asks Veriticity to pay exactly what was approved: the amount, merchant and currency are the purchase’s own and cannot be changed. There is no HTTP equivalent. The person the purchase was made for approves paying it in the dashboard first; until then the answer is `not_payable` and nothing is reserved. Their approval covers one payment attempt. If that attempt charged nothing, they approve again before the next. Where Veriticity pays by issuing a one-time card for the purchase, the answer to the call that issued it carries the card in `credential`, once. Your assistant’s model can read those details; use them only to check out that purchase at its merchant. Every other answer has `credential` set to `null`. An issued card is not a payment. Before a card is issued, the person confirms it in the dashboard, for that one payment. Until they do, `payment.status` is `verification_required`, with `verificationExpiresAt`, and nothing is reserved or paid. They have five minutes to confirm, and the confirmation can be used for five minutes after that. Call `request_payment` again once they have. Your assistant can’t confirm it, and no code is sent to it. **request_payment result, payments not set up** ```json { "purchaseRequest": { "id": "0199c0de-0000-7000-8000-000000000001" }, "payment": { "status": "unavailable", "paymentId": null, "amountMinor": null, "currency": null, "capturedMinor": null }, "replayed": false, "verificationExpiresAt": null, "credential": null } ``` - Paying through Veriticity is not yet available. Until it is, every call answers `payment.status` `unavailable`, and nothing is reserved or paid. - Where it is available, `payment.status` is the payment’s state, or `verification_required` before a card is issued: `in_progress`, `outcome_unknown`, `authorized`, `captured`, `declined`, `failed` or `cancelled`. None says a purchase was made: a payment is not an order. An unknown outcome stays unknown until the payment provider says. - A second call for the same purchase answers with the same payment and `replayed: true`. Only one payment can hold a purchase’s authorization at a time. - A purchase that wasn’t approved, has expired, was withdrawn, voided or used, or was already reported on is `not_payable`, and nothing is paid. A purchase another connection submitted, or one requested in the dashboard, is `not_found`. - The person you work for must approve paying for the purchase in Veriticity first. Until they do, the answer is `not_payable` and nothing is reserved. You can’t approve it yourself. - A purchase paid this way takes its outcome from the payment provider. `report_purchase_outcome` refuses it. ## Reporting what happened `report_purchase_outcome` tells Veriticity what became of an approved purchase the connection requested: `COMPLETED` with `finalAmountMinor`, `COMPLETED_AMOUNT_UNKNOWN`, `PARTIAL` with `itemPositions`, `FAILED`, or `REFUNDED` with `refundMinor`. It takes the fields of `POST /v1/purchase-requests/{id}/outcome` and records the same report. - The report is the assistant’s word. Depending on the organisation’s settings it is accepted as what was charged or waits for a person to confirm it. It approves nothing and makes, changes or cancels no order or payment. - When more than one purchase could be the one meant, the assistant is told to confirm which, by merchant, amount and when it was requested, before it reports. The answer’s `purchase` names the purchase the report was recorded against. - Send a `reportId` with each report. The same report again answers with `replayed: true`; a different report with a used `reportId` is `report_id_reuse`. - A purchase that was never approved is `not_reportable`. One another connection submitted is `not_found`. ## Errors and refusals > **A refused purchase is an answer, not an error** > > `isError` means the call produced no answer. A `REJECTED` decision leaves by the ordinary return carrying its explanation, so an assistant can tell the person why rather than reporting a tool failure. Tools return `structuredContent` as well as text, so a client that wants fields gets fields. There are deliberately no declared output schemas: the shapes come from the same builders the REST API uses, and a second hand-maintained description of them would drift. A tool that cannot answer returns `isError: true` inside an ordinary `200` response. Its text is `code: sentence`, and its `structuredContent` is `{ error, message }`, plus `retryAfterSeconds` when waiting is the answer. This is not the REST API’s `{ error: { code, message } }` envelope. | Code | Meaning | What to do | | --- | --- | --- | | `insufficient_scope` | The connection was not granted this tool’s scope. | Don’t retry. The person reconnects the assistant with the wider grant. | | `invalid_arguments` | The arguments can’t be evaluated. Nothing was submitted. | Correct the arguments the message names, then call again. | | `not_found` | No purchase with that id was submitted by this connection. | Use an id from `request_purchase` or `list_purchases`. | | `not_cancellable` | The purchase is no longer waiting on a person: it was decided or expired. Nothing was cancelled. Cancelling one already cancelled answers as before, with `replayed: true`. | Call `get_purchase` to see where it stands. | | `not_reportable` | The purchase was never approved, so it has no outcome. Nothing was recorded. | Don’t retry. | | `report_id_reuse` | That `reportId` was already used for a different report. Nothing was recorded. | Send the new report with a new `reportId`. | | `rate_limited` | Too many calls recently. | Wait `retryAfterSeconds`, then call again. | | `temporarily_unavailable` | A limit or the plan couldn’t be checked, so nothing was submitted. | Wait `retryAfterSeconds`, then call again. | | `purchase_allowance_exhausted`, `plan_required`, `plan_limit_exceeded`, `feature_not_included`, `billing_suspended` | The organisation’s plan refuses this. | Don’t retry. Tell the person; an owner changes the plan. | | `invalid_caller` | The credential doesn’t act as an agent. | Connect the assistant to an agent through OAuth. | | `internal_error` | Veriticity couldn’t complete the call. | Reads can be repeated. Before repeating `request_purchase`, call `list_purchases` to see whether it was recorded. | Arguments that don’t match a tool’s input schema, and a tool name this server doesn’t have, are refused before the tool runs. That error has text only, beginning `MCP error -32602`, and no `structuredContent`. Correct the call; repeating it unchanged fails the same way. A token that is missing, expired or revoked is refused at the HTTP layer with `401` and a `WWW-Authenticate` header naming the resource metadata, before any tool runs. When a token was sent, the header also says `error="invalid_token"`. An expired access token is renewed with the refresh token. A revoked connection, or one whose person can no longer connect assistants, needs that person to connect it again. See [Errors](https://docs.veriticity.com/errors.md). Every tool shares the limits its REST equivalent spends, counted against the connection: 600 reads a minute, 120 `check_purchase` calls a minute, 120 `cancel_purchase` calls a minute, and for `request_purchase` 120 a minute for the connection and 600 a minute for the organisation. Reaching a limit on MCP spends the same allowance as reaching it over HTTP; there is one allowance per connection, not one per transport. Over a limit, the tool answers `rate_limited`; if the limit can’t be checked, purchases answer `temporarily_unavailable` and nothing is submitted. Both carry `retryAfterSeconds` in `structuredContent` alongside the sentence, so a host can back off without parsing English. > **One request per HTTP request** > > JSON-RPC batches are not supported. An array body is refused with `400` and JSON-RPC error `-32600`, and nothing in it runs — not even its first element. Send each call on its own. A request body over 256 KB is refused with `413 payload_too_large`. --- Source: https://docs.veriticity.com/mcp --- # Assistant instructions Recommended instructions to paste into Claude or ChatGPT so a connected assistant checks with Veriticity before buying, states prices and authority accurately, and reports outcomes. Instructions guide; Veriticity's decisions are what count. An assistant connected to Veriticity reads what each tool says about itself. Claude and ChatGPT don’t reliably show it the server’s own instructions, so you can add the rules below yourself. They are the same text the dashboard offers under Settings, AI & developer connections. ## The instructions Copy them as they are. They hold no account, organisation or secret, and fit in 1,500 characters. **Recommended instructions** ``` Veriticity checks purchases against rules the user's organisation set. When the Veriticity tools are connected and a purchase comes up, follow these rules. - Don't buy anything without asking Veriticity first. Use check_purchase before request_purchase, then request_purchase. - Send merchantDomain, the website you'd buy from. - Describe what is being bought honestly: a category, or items with a category each. If you can't tell what something is, mark it categoryUncertain with an uncertaintyReason. Never guess. - Give every charge that can still change, such as delivery or tax, its maximum. - APPROVED is permission for that one purchase on exactly those terms, until expiresAt. It is not a purchase. - request_payment has Veriticity pay an approved purchase on its approved terms. It may be unavailable. A payment is not an order. - REQUIRES_APPROVAL means wait: only a person can approve it, and you cannot approve your own request. Check later with get_purchase. - Read authority, not reasonCodes: WITHIN_POLICY means the rules allowed it, AUTHORIZED means a person approved it. - amountMinor is the known amount and maximumMinor the most it can cost. Tell the user both. - After an approved purchase you paid yourself, report what happened with report_purchase_outcome. If more than one could match, ask the user which first. - Never say something was bought unless the user has told you it was. - Veriticity's answers are final. Don't reword a refused purchase to get a different answer. ``` ## Adding them to Claude - **For one project:** open the project and paste them into its instructions. They apply to chats in that project. - **For every chat:** open Settings and paste them under Instructions for Claude. - **For one chat:** add the purchase rules prompt from the + menu (below). ## Adding them to ChatGPT - **For every chat:** open Settings, then Personalization, then Custom instructions, and paste them into the field for how ChatGPT should respond. The field holds at least 1,500 characters on every plan, so the instructions fit. - **For one project:** open the project and paste them into its instructions. In that project they replace your custom instructions. ## The purchase rules prompt The MCP endpoint offers one prompt, `veriticity-purchase-rules`, titled Veriticity purchase rules. It returns the instructions above as a single message and is the same for every connection. It takes no arguments. A prompt is used only when a person adds it. In Claude, open the + menu in the message box, choose Connectors, then the Veriticity connector, and pick Veriticity purchase rules. ChatGPT doesn’t offer MCP prompts; paste the instructions instead. ## What they ask of the assistant - Check with `check_purchase`, then ask with `request_purchase`, before buying anything. - Send `merchantDomain`, the website it would buy from. - Describe what is being bought honestly: a category, or items with a category each. An item it can’t identify is marked `categoryUncertain` with an `uncertaintyReason`, never guessed. - Give every charge that can still change, such as delivery or tax, its maximum. - Wait on `REQUIRES_APPROVAL`. Only a person can approve, and the assistant can’t approve its own request. - Report what happened with `report_purchase_outcome`, and ask which purchase is meant when more than one could match. ## Prices, authority and outcomes - `amountMinor` is the known amount and `maximumMinor` the most the purchase can cost. The assistant states both. - `authority` says why a purchase was approved: `WITHIN_POLICY` when the rules allowed it, `AUTHORIZED` when a person approved it. The assistant reads it rather than inferring from `reasonCodes`. - `APPROVED` is permission for one purchase on exactly those terms, until `expiresAt`. It is not a purchase, and the assistant never says something was bought unless you told it so. ## What instructions cannot do > **Instructions guide; they don't enforce** > > What a tool answers and what Veriticity decides are what count. An assistant can ignore instructions, and other instructions in the conversation can override them. They can’t stop a purchase made without asking Veriticity. Veriticity checks each purchase it is asked about, against your policies and budgets. Stopping a payment that was never checked needs a checkout or payment method that validates the authorization before it pays. None is available yet. See [MCP](https://docs.veriticity.com/mcp.md) for the tools and what each returns. --- Source: https://docs.veriticity.com/assistant-instructions --- # Examples Copyable, working examples: a first purchase, checking before buying, following an approval, idempotent retries, OAuth connection and webhook signature verification. Working examples against the real contract. Every identifier, merchant and domain below is invented and safe to copy; replace the credentials with your own. > **These use fetch and cURL directly** > > Raw HTTP, so they read the same whatever language you are writing. If you are writing TypeScript, the official client is `npm install @veriticity/sdk` — see [the SDK page](https://docs.veriticity.com/sdk.md). It is the only client library we publish; anything else claiming to be one is not ours. ## 1. A first purchase **cURL** ```bash curl -X POST https://app.veriticity.com/v1/purchase-requests \ -H "Authorization: Bearer $VERITICITY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: renewal-2026-09" \ -d '{ "merchant": "Acme Cloud", "merchantDomain": "acme-cloud.example.com", "amountMinor": "7500", "currency": "GBP", "category": "software", "reason": "Renew build pipeline compute credits" }' ``` **TypeScript (fetch)** ```typescript const response = await fetch( "https://app.veriticity.com/v1/purchase-requests", { method: "POST", headers: { Authorization: `Bearer ${process.env.VERITICITY_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": "renewal-2026-09", }, body: JSON.stringify({ merchant: "Acme Cloud", merchantDomain: "acme-cloud.example.com", amountMinor: "7500", currency: "GBP", category: "software", reason: "Renew build pipeline compute credits", }), }, ); const result = await response.json(); console.log(result.decision.outcome); // "APPROVED" console.log(result.decision.explanation); // a sentence you can show a person ``` **TypeScript (SDK)** ```typescript import { Veriticity } from "@veriticity/sdk"; const veriticity = new Veriticity({ apiKey: process.env.VERITICITY_API_KEY! }); const result = await veriticity.purchases.submit({ merchant: "Acme Cloud", amountMinor: 7500n, currency: "GBP", category: "software", reason: "Renew build pipeline compute credits", }); switch (result.decision.outcome) { case "APPROVED": break; case "REJECTED": break; case "REQUIRES_APPROVAL": break; } ``` Branch on `decision.outcome`, never on the status code. All three outcomes are `201`. ## 2. Check before buying A simulation runs the same evaluation and writes nothing. Use it when an agent needs to decide whether to ask. **Simulate** ```bash curl -X POST https://app.veriticity.com/v1/simulations/current-policies \ -H "Authorization: Bearer $VERITICITY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "merchant": "Northwind Supplies", "amountMinor": "180000", "currency": "GBP", "category": "office-supplies", "reason": "Quarterly stationery order" }' ``` **200 OK** ```json { "simulation": { "isSimulation": true, "mode": "CURRENT_POLICIES" }, "simulatedDecision": { "outcome": "REQUIRES_APPROVAL", "wouldRequireApproval": true, "reasonCodes": ["AMOUNT_OVER_LIMIT"], "explanation": "Requires approval. GBP 1800.00 is over the GBP 250.00 limit for a single purchase." }, "budgets": [ { "periodKind": "MONTH", "currency": "GBP", "current": { "committedMinor": "340000", "heldMinor": "0" }, "simulated": { "requestedMinor": "180000", "projectedMinor": "520000" }, "limits": [ { "limitMinor": "1000000", "remainingAfterMinor": "480000", "wouldExceed": false } ], "wouldExceed": false } ] } ``` `budgets[]` is the honest answer to “how much is left”: what the window holds now, what this would add, and what the limits say about the sum. ## 3. Following an approval **Polling for an outcome** ```typescript /** * Follow an escalated purchase to its outcome. * * Polling is shown because it is the simplest thing that works. In production * subscribe to purchase.approved and purchase.rejected instead -- being told * beats asking, and an approval can take hours. */ async function waitForOutcome( purchaseRequestId: string, expiresAt: string, ): Promise<"APPROVED" | "REJECTED" | "EXPIRED"> { const deadline = new Date(expiresAt).getTime(); while (Date.now() < deadline) { const response = await fetch( `https://app.veriticity.com/v1/purchase-requests/${purchaseRequestId}`, { headers: { Authorization: `Bearer ${process.env.VERITICITY_API_KEY}` } }, ); const { purchase } = await response.json(); // approval.isPending is the authoritative "is a person still deciding". // Reading decision.outcome alone would see the original REQUIRES_APPROVAL // and never notice the re-evaluation. if (purchase.approval && !purchase.approval.isPending) { return purchase.decision.outcome; } await new Promise((resolve) => setTimeout(resolve, 30_000)); } // Nobody answered. The budget hold has been released and the purchase will // not happen unless it is submitted again. return "EXPIRED"; } ``` Prefer [webhooks](https://docs.veriticity.com/webhooks.md) in production. Subscribe to `purchase.approved` and `purchase.rejected` and you will be told rather than having to ask. ## 4. Idempotent submission **Retrying safely** ```typescript /** * Submit a purchase that is safe to retry. * * The key is derived from something stable in our own system, so every retry * of *this* purchase carries the same key. A random key per attempt would turn * each retry into a separate purchase, which is the whole thing the header * exists to prevent. */ async function submitPurchase(invoiceId: string, amountMinor: string) { const idempotencyKey = `invoice-${invoiceId}`; for (let attempt = 0; attempt < 3; attempt += 1) { const response = await fetch( "https://app.veriticity.com/v1/purchase-requests", { method: "POST", headers: { Authorization: `Bearer ${process.env.VERITICITY_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": idempotencyKey, }, body: JSON.stringify({ merchant: "Northwind Supplies", amountMinor, currency: "GBP", category: "office-supplies", reason: `Payment for invoice ${invoiceId}`, }), }, ); // 201 = evaluated now. 200 = we already asked; this is the stored answer, // and no additional budget was reserved. if (response.status === 201 || response.status === 200) { return response.json(); } if (response.status >= 500) { // Ours. Safe to retry -- the key guarantees we cannot double-spend. await new Promise((r) => setTimeout(r, 2 ** attempt * 1000)); continue; } const { error } = await response.json(); if (error.code === "idempotency_key_reuse") { // This key was used for a *different* purchase. Retrying can never // succeed: the key derivation is wrong, or the amount changed. throw new Error(`Key collision for invoice ${invoiceId}`); } throw new Error(`${error.code}: ${error.message}`); } throw new Error("Veriticity did not answer after three attempts"); } ``` ## 5. Reading policies **List active policies** ```bash # Organisation-scoped key. An agent-bound key is refused here. curl "https://app.veriticity.com/v1/policies?status=ACTIVE&pageSize=10" \ -H "Authorization: Bearer $VERITICITY_ORG_KEY" ``` **200 OK** ```json { "policies": [ { "id": "0199a3e0-1f4c-7a11-b3d2-5e8c7a4f2b90", "name": "Company baseline", "status": "ACTIVE", "inForce": true, "activeVersion": { "versionNumber": 4, "ruleCount": 3, "merchantCount": 12 } } ], "pagination": { "page": 1, "pageSize": 10, "total": 1, "totalPages": 1 } } ``` Read `inForce` rather than deriving enforcement from `status` and `activeVersion` — a `DISABLED` policy keeps its active version but is not enforced. ## 6. Creating an OAuth connection **Authorization Code with PKCE** ```typescript import { createHash, randomBytes } from "node:crypto"; const CLIENT_ID = "https://your-app.example.com/client.json"; const REDIRECT_URI = "https://your-app.example.com/callback"; /** Step 1: build a PKCE pair and send the person to authorise. */ export function startConnection(): { url: string; verifier: string; state: string } { const verifier = randomBytes(32).toString("base64url"); const challenge = createHash("sha256").update(verifier).digest("base64url"); const state = randomBytes(16).toString("base64url"); const url = new URL("https://app.veriticity.com/oauth/authorize"); url.searchParams.set("response_type", "code"); url.searchParams.set("client_id", CLIENT_ID); url.searchParams.set("redirect_uri", REDIRECT_URI); url.searchParams.set("code_challenge", challenge); url.searchParams.set("code_challenge_method", "S256"); url.searchParams.set("state", state); url.searchParams.set("scope", "purchase:check purchase:request purchase:read"); // RFC 8707. Exactly this value -- anything else is refused with // invalid_target. url.searchParams.set("resource", "https://app.veriticity.com/mcp"); // Store verifier and state against the session. Both must survive the // round trip and neither may reach the browser. return { url: url.toString(), verifier, state }; } /** Step 2: exchange the code. The person chose the agent, not us. */ export async function completeConnection( code: string, returnedState: string, returnedIss: string, expected: { verifier: string; state: string }, ) { if (returnedState !== expected.state) { throw new Error("state mismatch"); } // RFC 9207. Veriticity always sends iss, so a response without it -- or // with the wrong one -- is a mix-up attack and must be refused. if (returnedIss !== "https://app.veriticity.com") { throw new Error("issuer mismatch"); } const response = await fetch("https://app.veriticity.com/oauth/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: REDIRECT_URI, code_verifier: expected.verifier, client_id: CLIENT_ID, resource: "https://app.veriticity.com/mcp", }), }); const tokens = await response.json(); // Store the refresh token ATOMICALLY. It rotates on every use, and // presenting a consumed one revokes the entire connection. await storeTokens(tokens); return tokens; } ``` > **Store the refresh token atomically** > > Refresh tokens rotate on every use. Two concurrent refreshes from the same stored token will present a consumed one, and that revokes the entire connection. ## 7. Verifying a webhook **With the SDK** ```typescript import { Webhooks, isKnownWebhookEvent } from "@veriticity/sdk/webhooks"; const webhooks = new Webhooks({ secret: process.env.VERITICITY_WEBHOOK_SECRET! }); export async function POST(request: Request) { // Verifies and parses in one step, from the raw bytes. const event = await webhooks.unwrap(request); if (isKnownWebhookEvent(event) && event.type === "purchase.approved") { // event.data is narrowed to this event's payload. console.log(event.data.purchaseRequestId); } return new Response(null, { status: 204 }); } ``` The same verification written by hand, for any other language or for a project that would rather not take the dependency: **Without the SDK** ```typescript import { createHmac, timingSafeEqual } from "node:crypto"; const TOLERANCE_SECONDS = 5 * 60; /** * Verify a Veriticity webhook signature. * * rawBody must be the exact bytes received. Most frameworks parse JSON before * a handler sees it and discard the bytes; re-serialising will not verify, * because JSON.parse followed by JSON.stringify is not the identity function. */ export function verifyWebhook( rawBody: string, headers: { timestamp: string | null; signature: string | null }, secret: string, now: Date = new Date(), ): boolean { if (headers.timestamp === null || headers.signature === null) { return false; } const age = Math.abs( Math.floor(now.getTime() / 1000) - Number(headers.timestamp), ); if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) { return false; } // The scheme version is inside the signed material, not only in the header. const expected = createHmac("sha256", secret) .update(`v1:${headers.timestamp}:${rawBody}`, "utf8") .digest("base64url"); // Two signatures arrive during the 24 hours after a rotation. Either one // verifying is a success -- that is what makes a zero-downtime rotation // possible. return headers.signature .split(",") .map((part) => part.trim()) .filter((part) => part.startsWith("v1=")) .map((part) => part.slice(3)) .some((candidate) => { const a = Buffer.from(candidate); const b = Buffer.from(expected); // timingSafeEqual throws on a length mismatch rather than returning // false, so lengths are compared first. return a.length === b.length && timingSafeEqual(a, b); }); } ``` Then deduplicate on the `Veriticity-Webhook-Id` header: delivery is at-least-once, so the same event can arrive twice. ## 8. Creating and testing an endpoint **Endpoint lifecycle** ```bash # 1. Create the endpoint. The secret is in this response and nowhere else. curl -X POST https://app.veriticity.com/v1/webhooks \ -H "Authorization: Bearer $VERITICITY_ORG_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://hooks.example.com/veriticity", "description": "Ledger sync", "subscribedTypes": ["purchase.approved", "purchase.rejected"] }' # 2. Send a real signed event through the real pipeline. Answers 202: the # event is queued, not delivered. curl -X POST https://app.veriticity.com/v1/webhooks/$WEBHOOK_ID/test \ -H "Authorization: Bearer $VERITICITY_ORG_KEY" # 3. See what happened, including your own error body if it failed. curl "https://app.veriticity.com/v1/webhooks/$WEBHOOK_ID/deliveries?limit=5" \ -H "Authorization: Bearer $VERITICITY_ORG_KEY" # 4. Rotate. Both secrets sign for 24 hours, so deploy the new one at leisure. curl -X POST https://app.veriticity.com/v1/webhooks/$WEBHOOK_ID/rotate-secret \ -H "Authorization: Bearer $VERITICITY_ORG_KEY" # 5. Revoke. Terminal -- the secret is destroyed and cannot be restored. curl -X DELETE https://app.veriticity.com/v1/webhooks/$WEBHOOK_ID \ -H "Authorization: Bearer $VERITICITY_ORG_KEY" ``` Full detail on every one of these is in the [API reference](https://docs.veriticity.com/api-reference.md). --- Source: https://docs.veriticity.com/examples --- # API reference The complete Veriticity REST API: every endpoint, its credential requirements, scopes, parameters, request and response shapes, and the errors it can return. 30 operations across 6 groups. Everything on this page is generated from the Veriticity OpenAPI document, which you can download and feed to a client generator or an HTTP client: [openapi.json](https://docs.veriticity.com/openapi.json) — OpenAPI 3.1, 30 operations, including the outbound webhook contract. > **Base URL** > > `https://app.veriticity.com` — every path below is relative to it. Authenticate with `Authorization: Bearer `; see [Authentication](https://docs.veriticity.com/authentication.md). > **Policies are not written through this API** > > There are no endpoints for creating, editing, activating or archiving a policy, and there will not be. Changing the rules that govern spending is a human act performed in the dashboard, so that every change names the person who made it. See [Policies](https://docs.veriticity.com/policies.md). ## Endpoints at a glance | Method | Path | Credential | | --- | --- | --- | | GET | [/v1/purchase-requests](#get-v1-purchase-requests) | API key bound to an agent · purchase:read | | POST | [/v1/purchase-requests](#post-v1-purchase-requests) | API key bound to an agent · purchase:request | | GET | [/v1/purchase-requests/{purchaseRequestId}](#get-v1-purchase-requests-purchaseRequestId) | API key bound to an agent · purchase:read | | POST | [/v1/purchase-requests/{purchaseRequestId}/cancel](#post-v1-purchase-requests-purchaseRequestId-cancel) | API key of either kind · purchase:request | | POST | [/v1/purchase-requests/{purchaseRequestId}/outcome](#post-v1-purchase-requests-purchaseRequestId-outcome) | API key of either kind · purchase:request | | GET | [/v1/purchase-authorizations](#get-v1-purchase-authorizations) | Organisation-scoped API key | | GET | [/v1/purchase-authorizations/{authorizationId}](#get-v1-purchase-authorizations-authorizationId) | Organisation-scoped API key | | POST | [/v1/purchase-authorizations/{authorizationId}/execution-token](#post-v1-purchase-authorizations-authorizationId-execution-token) | API key bound to an agent | | POST | [/v1/purchase-authorizations/{authorizationId}/use](#post-v1-purchase-authorizations-authorizationId-use) | API key of either kind | | POST | [/v1/purchase-authorizations/{authorizationId}/void](#post-v1-purchase-authorizations-authorizationId-void) | Organisation-scoped API key | | POST | [/v1/purchase-authorizations/{authorizationId}/withdraw](#post-v1-purchase-authorizations-authorizationId-withdraw) | API key of either kind · purchase:request | | POST | [/v1/simulations/current-policies](#post-v1-simulations-current-policies) | API key of either kind · purchase:check | | POST | [/v1/simulations/draft-policy](#post-v1-simulations-draft-policy) | Organisation-scoped API key | | POST | [/v1/simulations/policy-version](#post-v1-simulations-policy-version) | Organisation-scoped API key | | GET | [/v1/policies](#get-v1-policies) | Organisation-scoped API key | | GET | [/v1/policies/{policyId}](#get-v1-policies-policyId) | Organisation-scoped API key | | GET | [/v1/policies/{policyId}/versions/{versionId}](#get-v1-policies-policyId-versions-versionId) | Organisation-scoped API key | | POST | [/v1/policies/{policyId}/versions/{versionId}/validate](#post-v1-policies-policyId-versions-versionId-validate) | Organisation-scoped API key | | GET | [/v1/agent](#get-v1-agent) | API key bound to an agent · purchase:check | | GET | [/v1/agents](#get-v1-agents) | Organisation-scoped API key | | GET | [/v1/budget-status](#get-v1-budget-status) | API key bound to an agent · purchase:check | | GET | [/v1/spending-rules](#get-v1-spending-rules) | API key bound to an agent · purchase:check | | GET | [/v1/webhooks](#get-v1-webhooks) | Organisation-scoped API key | | POST | [/v1/webhooks](#post-v1-webhooks) | Organisation-scoped API key | | GET | [/v1/webhooks/{webhookId}](#get-v1-webhooks-webhookId) | Organisation-scoped API key | | PATCH | [/v1/webhooks/{webhookId}](#patch-v1-webhooks-webhookId) | Organisation-scoped API key | | DELETE | [/v1/webhooks/{webhookId}](#delete-v1-webhooks-webhookId) | Organisation-scoped API key | | GET | [/v1/webhooks/{webhookId}/deliveries](#get-v1-webhooks-webhookId-deliveries) | Organisation-scoped API key | | POST | [/v1/webhooks/{webhookId}/rotate-secret](#post-v1-webhooks-webhookId-rotate-secret) | Organisation-scoped API key | | POST | [/v1/webhooks/{webhookId}/test](#post-v1-webhooks-webhookId-test) | Organisation-scoped API key | ## Purchases Submit a purchase for a decision, and find out what happened to it. ### List the purchases you submitted GET `/v1/purchase-requests` API key bound to an agent OAuth: `purchase:read` The purchases the calling credential submitted, newest first. Scoped to the credential, exactly as reading one purchase is. A key that replaced another does not see the earlier key's purchases; subscribe to webhooks for a record that survives rotation. Credential: An API key bound to an agent. OAuth connections need `purchase:read`. Rate limit: 600 per minute per credential. #### Parameters | Name | In | Description | | --- | --- | --- | | `status` | query | Only purchases in this state. | | `since` | query | Only purchases requested at or after this instant. | | `until` | query | Only purchases requested before this instant. | | `limit` | query | How many to return. | | `cursor` | query | The `nextCursor` from the previous page. An unrecognised value returns the first page. | #### Responses | Status | Meaning | | --- | --- | | `200` | One page of purchases. | | `400` | `invalid_request` | | `401` | `unauthorized` `invalid_api_key` `api_key_revoked` `api_key_expired` `wrong_environment` `invalid_token` | | `402` | `feature_not_included` | | `403` | `connection_unauthorized` `organization_suspended` `api_key_not_bound_to_agent` `insufficient_scope` | | `429` | `rate_limited` | | `500` | `internal_error` | #### Response body | Field | Type | Description | | --- | --- | --- | | `hasMore` required | `boolean` | | | `nextCursor` required | `string \| null` | | | `purchases` required | `PurchaseReadback[]` | | | `agent` required | `object` | | | `id` required | `UUID` | | | `name` required | `string` | | | `status` required | `ENABLED \| DISABLED \| ARCHIVED` | | | `amountMinor` required | `MinorAmount` | | | `approval` | `object \| null` | | | `comment` required | `string \| null` | | | `escalationReasonCodes` required | `ReasonCode[]` | | | `expiresAt` required | `Timestamp` | | | `isPending` required | `boolean` | | | `resolvedAt` required | `Timestamp \| null` | | | `resolvedBy` required | `object \| null` | | | `state` required | `PENDING \| APPROVED \| REJECTED \| EXPIRED \| CANCELLED` | | | `authorization` | `PurchaseAuthorizationSummary \| null` | | | `expiresAt` required | `Timestamp` | | | `id` required | `UUID` | | | `status` required | `active \| used \| expired \| cancelled` | `active` until `expiresAt`, unless it is used or cancelled first. An expired authorization is not renewed: request the purchase again. | | `category` | `string \| null` | | | `currency` required | `Currency` | | | `decision` | `object \| null` | | | `authority` required | `WITHIN_POLICY \| AUTHORIZED \| null` | | | `engineVersion` required | `string` | | | `evaluatedAt` required | `Timestamp` | | | `explanation` required | `string` | | | `kind` required | `string` | INITIAL, or APPROVAL_REEVALUATION once a person answered. | | `outcome` required | `APPROVED \| REJECTED \| REQUIRES_APPROVAL` | | | `reasonCodes` required | `ReasonCode[]` | | | `id` required | `UUID` | | | `initiatedBy` | `object \| null` | | | `name` required | `string` | | | `maximumMinor` required | `MinorAmount \| null` | The most the purchase can cost, with every `VARIABLE` charge at its maximum: what rules and budgets judged. `amountMinor` is the known amount. Null when no charge can still vary. | | `merchant` required | `string` | | | `merchantDomain` | `string \| null` | | | `origin` | `DASHBOARD \| API \| MCP \| null` | | | `reason` required | `string` | | | `requestedAt` required | `Timestamp` | | | `resolvedAt` | `Timestamp \| null` | | | `status` required | `EVALUATING \| PENDING_APPROVAL \| APPROVED \| REJECTED \| EXPIRED \| CANCELLED` | | | `submittedVia` | `object \| object \| null` | | ### Submit a purchase for a decision POST `/v1/purchase-requests` API key bound to an agent OAuth: `purchase:request` Asks the Trust Engine whether this agent may make this purchase, and records the answer. All three outcomes — `APPROVED`, `REJECTED` and `REQUIRES_APPROVAL` — are successful calls. Branch on `decision.outcome`, never on the HTTP status code: the status describes whether the question could be answered, and a refusal is an answer. `REQUIRES_APPROVAL` means a person has been asked and the budget is held until `approval.expiresAt`. Poll `GET /v1/purchase-requests/{purchaseRequestId}` for what they decided. An `APPROVED` purchase carries a purchase authorization: an id to hand to whatever will make the purchase, and the time until which it can be used. Veriticity authorizes a purchase; it does not make one. Requires an API key bound to an agent, or an OAuth connection granted `purchase:request`. An organization-scoped key names no identity to attribute a purchase to and is refused. Credential: An API key bound to an agent. OAuth connections need `purchase:request`. Rate limit: 120 per minute per credential; 600 per minute per organization. Request body: at most 65536 bytes, or 655360 bytes when it states `items`. #### Parameters | Name | In | Description | | --- | --- | --- | | `Idempotency-Key` required | header | Required. Makes a retry safe: the same key with the same body replays the original decision, reserves no further budget and returns the same purchase authorization, answering 200 rather than 201. The same key with a different body, or from a different credential, is refused with 409 `idempotency_key_reuse`. A request without one is refused with 400 `invalid_request`. This is the only operation that accepts one. | #### Request body | Field | Type | Description | | --- | --- | --- | | `amountMinor` required | `string \| integer` | A whole number of ISO 4217 minor units. Send it as a string: JSON numbers lose precision above 2^53, and this is money. It is always returned as a string. | | `category` | `electronics \| software \| office-supplies \| clothing \| home-garden \| food-drink \| health-beauty \| kids-baby \| pet-supplies \| sports-outdoors \| automotive \| travel \| media-entertainment \| services \| medicines \| gift-cards \| alcohol \| tobacco-nicotine \| adult \| gambling \| crypto \| recreational-drugs \| other` | Required unless the body states `items`, and never with them. What kind of product this is: exactly one of Veriticity's categories. Choose by what the product is, not where it's sold or what it's for. `other` means you know what the product is and no category covers it; never use it for a product you can't identify. A purchase that holds different kinds of product, or a product that is also one of `medicines`, `gift-cards`, `alcohol`, `tobacco-nicotine`, `adult`, `gambling`, `crypto`, `recreational-drugs` besides the category you'd choose (a crypto casino deposit, an alcohol gift card), isn't described by one category: list its items instead, each with its own category and a risk tag for every other one of those it involves. Any other value is refused. Recorded as stated by the caller, not verified. `electronics`: Physical electronic devices, components and accessories: laptops, phones, chargers, hardware wallets. Not here: software (`software`), video games (`media-entertainment`). `software`: Software licences, apps, SaaS and cloud services, hosting, domains and productivity tools. Not here: music, film, streaming media and games (`media-entertainment`). `office-supplies`: Stationery, paper, printer supplies, and office furniture and equipment bought for work. Not here: personal electronics (`electronics`). `clothing`: Clothing, footwear, bags, jewellery and watches, for any age. Not here: sports equipment (`sports-outdoors`), dress-up toys (`kids-baby`). `home-garden`: Furniture, homeware, kitchenware, appliances, DIY tools and materials, garden products, and household cleaning and consumables such as washing-up liquid, bin bags and toilet roll. Not here: office furniture bought for an office (`office-supplies`). `food-drink`: Groceries, non-alcoholic drinks, meals, takeaway and food delivery, including baby food and formula. Not here: alcohol (`alcohol`), pet food (`pet-supplies`), vitamins and supplements (`health-beauty`). `health-beauty`: Toiletries, skincare, cosmetics, fragrance, hair and oral care, shaving, deodorant, sunscreen, sanitary and incontinence products, condoms and non-prescription contraception, vitamins and supplements, first-aid supplies, health devices and contact lenses. Not here: licensed medicines, including medicated creams (`medicines`), household cleaning (`home-garden`), nappies (`kids-baby`), CBD products (`recreational-drugs`). `kids-baby`: Toys, board games, puzzles, nursery equipment, nappies, and baby care that is neither food nor medicine. Not here: children's clothing (`clothing`), baby food (`food-drink`), video games (`media-entertainment`). `pet-supplies`: Pet food, accessories, bedding and non-prescription pet care. Not here: veterinary services (`services`), prescription pet medicines (`medicines`). `sports-outdoors`: Sports, fitness, camping and cycling equipment. Not here: sportswear (`clothing`), tickets to sports events (`media-entertainment`). `automotive`: Vehicle parts, accessories, tyres and maintenance products. Not here: fuel, parking and tolls (`travel`), repairs (`services`). `travel`: Flights, accommodation, rail, car hire, rides, parking, fuel and tolls. Not here: travel insurance (`services`). `media-entertainment`: Books and e-books, music, film, streaming media, video games and in-game items, and event tickets. Not here: betting stakes and casino credits (`gambling`). `services`: Professional, personal and booked services, repairs, insurance, education and veterinary services. Not here: software (`software`), event tickets (`media-entertainment`). `medicines`: Licensed medicines, prescription and over the counter, for people or pets: pain relief, cold and flu, allergy, medicated creams, pharmacy-only products and prescription refills. Not here: vitamins, supplements and unmedicated skincare (`health-beauty`), nicotine replacement products (`tobacco-nicotine`), CBD and other unlicensed psychoactive products (`recreational-drugs`). `gift-cards`: Gift cards, vouchers, prepaid cards, and top-ups of a merchant's or platform's wallet or account. Not here: deposits to crypto exchanges or wallets (`crypto`), goods paid for with a gift card (by what they are). `alcohol`: Alcoholic drinks of any strength, and alcohol gift sets. Not here: alcohol-free drinks (`food-drink`). `tobacco-nicotine`: Tobacco, cigarettes, cigars, e-cigarettes, vape devices and liquids, nicotine pouches and nicotine replacement products. Not here: cannabis vapes (`recreational-drugs`), lighters on their own (`home-garden`). `adult`: Sexual-wellness products, adult entertainment and age-restricted adult content. Not here: condoms and contraception (`health-beauty`). `gambling`: Lottery tickets, betting stakes and credits, casino credits including crypto casinos, and paid chance-based entries that can pay out money. Not here: in-game items with no cash value (`media-entertainment`). `crypto`: Buying cryptocurrency, stablecoins, tokens or NFTs, deposits to crypto exchanges or wallets, and crypto ATM purchases. Not here: hardware wallets (`electronics`), crypto casinos (`gambling`), crypto-themed merchandise (by what they are). `recreational-drugs`: Products containing cannabis or cannabinoids, including CBD, psilocybin, kratom and other psychoactive substances not licensed as medicine, and drug paraphernalia. Not here: licensed medicines (`medicines`), alcohol (`alcohol`), tobacco and nicotine (`tobacco-nicotine`). `other`: A product you can identify that no other category covers. Not for a product you can't identify. | | `charges` | `object[]` | Charges on the whole basket. `KNOWN` is exact: `TAX`, `DELIVERY`, `DUTY`, `FEE` or `DISCOUNT` (negative; a fee or discount says what it is). `UNEXPLAINED` is any part of the total you can't account for, and has no `kind`. `VARIABLE` is a `TAX`, `DELIVERY` or `DUTY` charge that can still change, stated with its `maximumMinor`: the purchase is decided at the known amount plus every maximum, and a use presents each one's final, which may not exceed it. | | `amountMinor` | `string` | | | `class` required | `KNOWN \| VARIABLE \| UNEXPLAINED` | | | `description` | `string` | At most 200 bytes. | | `kind` | `TAX \| DELIVERY \| DUTY \| FEE \| DISCOUNT` | | | `maximumMinor` | `string` | A variable charge's most. | | `currency` | `string \| null` | Defaults to the organisation's own currency when omitted. | | `items` | `object[]` | The items, instead of `category`, for a purchase that holds different kinds of product or a product involving more than one restricted kind. `amountMinor` must then be exactly the item lines (each `quantity` × `unitAmountMinor` less `discountMinor`) plus the known and unexplained charges. Text limits are counted in UTF-8 bytes. | | `category` | `electronics \| software \| office-supplies \| clothing \| home-garden \| food-drink \| health-beauty \| kids-baby \| pet-supplies \| sports-outdoors \| automotive \| travel \| media-entertainment \| services \| medicines \| gift-cards \| alcohol \| tobacco-nicotine \| adult \| gambling \| crypto \| recreational-drugs \| other` | The item's category. Omit it only for an item whose category is uncertain. | | `categoryUncertain` | `boolean` | `true` when what kind of product this is can't be established. Send `uncertaintyReason` and no `category`; the item is then treated as not established in every category rule and budget. | | `description` required | `string` | What the item is. At most 500 bytes. | | `discountMinor` | `string` | A discount on this line, in minor units. Defaults to 0. | | `itemId` | `string` | Your own label for the line, unique in the basket. At most 64 bytes. | | `options` | `object[]` | | | `name` required | `string` | | | `value` required | `string` | | | `possibleCategories` | `electronics \| software \| office-supplies \| clothing \| home-garden \| food-drink \| health-beauty \| kids-baby \| pet-supplies \| sports-outdoors \| automotive \| travel \| media-entertainment \| services \| medicines \| gift-cards \| alcohol \| tobacco-nicotine \| adult \| gambling \| crypto \| recreational-drugs \| other[]` | For an uncertain item: up to three categories it might be. Shown to a person; never relied on. | | `productReference` | `object` | | | `id` required | `string` | | | `provider` required | `string` | | | `productUrl` | `string` | The product's https page, on `merchantDomain` or the same website with or without its `www` label. | | `quantity` required | `integer` | | | `riskTags` | `medicines \| gift-cards \| alcohol \| tobacco-nicotine \| adult \| gambling \| crypto \| recreational-drugs[]` | Every restricted kind the item involves besides its category: a crypto casino deposit is `gambling` tagged `crypto`. A rule on a tagged category applies to the item. | | `uncertaintyReason` | `string` | Why the category can't be established. At most 200 bytes. | | `unitAmountMinor` required | `string` | The price of one, in minor units. | | `merchant` required | `string` | Who the money would go to, written as a person would name them. A label: it is never looked up and does not identify the merchant. | | `merchantDomain` | `string \| null` | The merchant's website: the host the purchase would be made on. A full URL is accepted and only its host is used. This is what identifies the merchant; without it no merchant is established, and a policy that restricts merchants refuses the purchase or holds it for a person. It identifies the merchant only where the organisation has that exact host on record for one; a host that is sent and not on record leaves the merchant unrecognised. `example.com` and `www.example.com` are the same host. Any other subdomain matches only where the organisation trusts that website's subdomains. | | `merchantReference` | `CommerceReference \| null` | A commerce platform's id for the merchant. Like the domain it is a statement by the caller. It supports `merchantDomain` and never replaces it: sent without a website it establishes no merchant, and one that is not on record, or is on record for a different merchant than the website, leaves the merchant not established. | | `id` required | `string` | The id, exactly as the provider writes it. No spaces. | | `provider` required | `string` | The platform or scheme: a short lowercase name. | | `metadata` | `object \| null` | Anything you want recorded alongside the purchase, as a JSON object of at most 16 KiB, nested at most 8 levels. No rule reads it. | | `productReference` | `CommerceReference \| null` | An id for the product: a platform's, or a scheme such as `sku` or `gtin`. Resolved within the merchant, so it means nothing until the merchant is recognised. | | `id` required | `string` | The id, exactly as the provider writes it. No spaces. | | `provider` required | `string` | The platform or scheme: a short lowercase name. | | `reason` required | `string` | Why the agent wants this purchase. Every purchase must say. | | `taxIncluded` | `boolean` | Required with `items`: whether the item prices already include tax. When `true`, tax is not stated again. | #### Responses | Status | Meaning | | --- | --- | | `200` | An idempotent replay. The stored decision, returned again; no new budget was reserved. `replayed` is true. | | `201` | The purchase was evaluated and a decision recorded. | | `400` | `invalid_request` `malformed_json` | | `401` | `unauthorized` `invalid_api_key` `api_key_revoked` `api_key_expired` `wrong_environment` `invalid_token` | | `402` | `purchase_allowance_exhausted` `plan_required` `plan_limit_exceeded` `feature_not_included` | | `403` | `connection_unauthorized` `organization_suspended` `api_key_not_bound_to_agent` `insufficient_scope` `billing_suspended` | | `409` | `idempotency_key_reuse` | | `413` | `payload_too_large` | | `415` | `unsupported_media_type` | | `429` | `rate_limited` | | `500` | `internal_error` | | `503` | `temporarily_unavailable` | #### Response body | Field | Type | Description | | --- | --- | --- | | `approval` required | `object` | Whether a person still has to answer, and by when. The budget hold behind an escalation is released when the approval lapses. | | `expiresAt` required | `Timestamp \| null` | | | `required` required | `boolean` | | | `authorization` required | `PurchaseAuthorizationSummary \| null` | | | `expiresAt` required | `Timestamp` | | | `id` required | `UUID` | | | `status` required | `active \| used \| expired \| cancelled` | `active` until `expiresAt`, unless it is used or cancelled first. An expired authorization is not renewed: request the purchase again. | | `decision` required | `Decision` | | | `authority` | `WITHIN_POLICY \| AUTHORIZED \| null` | | | `engineVersion` required | `string` | | | `evaluatedAt` required | `Timestamp` | | | `explanation` required | `string` | The sentence a person reads. Not a stable contract. | | `id` required | `UUID` | | | `outcome` required | `APPROVED \| REJECTED \| REQUIRES_APPROVAL` | Branch on this, never on the HTTP status code. | | `reasonCodes` required | `ReasonCode[]` | | | `findings` required | `Finding[]` | | | `currency` | `Currency \| null` | | | `headroomMinor` | `MinorAmount \| null` | | | `limitMinor` | `MinorAmount \| null` | | | `measured` | `MeasuredWindow \| null` | | | `aggregate` required | `SUM_AMOUNT \| COUNT` | SUM_AMOUNT: a sum of money. COUNT: a number of purchases. | | `scope` required | `string` | Whose purchases the window holds: POLICY (the agents the policy binds), ORGANIZATION, EACH_AGENT, AGENT:, EACH_PRINCIPAL or PRINCIPAL:. | | `window` required | `object` | | | `message` required | `string` | | | `observedMinor` | `MinorAmount \| null` | | | `outcome` required | `PASS \| NOT_APPLICABLE \| FLAG \| REQUIRE_APPROVAL \| BLOCK \| AUTHORIZED` | PASS: the purchase came within the rule. AUTHORIZED: the rule asked for approval and a person gave it. FLAG: recorded, and changes nothing. A rule that only flags is the organisation's own record and is not returned by this API. | | `policyId` required | `UUID` | | | `policyName` required | `string` | | | `policyRuleId` | `UUID \| null` | | | `policyVersionId` required | `UUID` | | | `policyVersionNumber` required | `integer` | | | `reasonCode` required | `ReasonCode` | | | `role` required | `BINDING \| SATISFIED \| NOT_APPLICABLE` | How the finding stands against the decision. | | `rule` required | `string` | The rule's name, or its key when it has none. | | `ruleKey` | `string \| null` | | | `unit` | `MONEY \| COUNT \| null` | | | `policyVersions` required | `object[]` | | | `applicabilityReason` required | `string` | | | `policyVersionId` required | `UUID` | | | `purchaseRequest` required | `PurchaseRequestSummary` | | | `amountMinor` required | `MinorAmount` | | | `currency` required | `Currency` | | | `id` required | `UUID` | | | `maximumMinor` required | `MinorAmount \| null` | The most the purchase can cost, with every `VARIABLE` charge at its maximum: what rules and budgets judged. `amountMinor` is the known amount. Null when no charge can still vary. | | `status` required | `EVALUATING \| PENDING_APPROVAL \| APPROVED \| REJECTED \| EXPIRED \| CANCELLED` | | | `replayed` required | `boolean` | True when this is the stored answer to an earlier identical request. The decision is real and binding either way; what differs is that no new budget was reserved. | ### Read back a purchase you submitted GET `/v1/purchase-requests/{purchaseRequestId}` API key bound to an agent OAuth: `purchase:read` What happened to a purchase, including the decision in force and the approval if one was opened. This is how you find out what a person decided after a `REQUIRES_APPROVAL`. A purchase is readable by the credential that submitted it and by nothing else. Another agent's, another connection's, another tenant's and a malformed id are all the same 404. Reads nothing into the audit trail and re-evaluates nothing. Credential: An API key bound to an agent. OAuth connections need `purchase:read`. Rate limit: 600 per minute per credential. #### Parameters | Name | In | Description | | --- | --- | --- | | `purchaseRequestId` required | path | The purchase request to read. | #### Responses | Status | Meaning | | --- | --- | | `200` | The purchase, as its submitter may see it. | | `401` | `unauthorized` `invalid_api_key` `api_key_revoked` `api_key_expired` `wrong_environment` `invalid_token` | | `402` | `feature_not_included` | | `403` | `connection_unauthorized` `organization_suspended` `api_key_not_bound_to_agent` `insufficient_scope` | | `404` | `not_found` | | `429` | `rate_limited` | | `500` | `internal_error` | #### Response body | Field | Type | Description | | --- | --- | --- | | `purchase` required | `PurchaseReadback` | | | `agent` required | `object` | | | `id` required | `UUID` | | | `name` required | `string` | | | `status` required | `ENABLED \| DISABLED \| ARCHIVED` | | | `amountMinor` required | `MinorAmount` | | | `approval` | `object \| null` | | | `comment` required | `string \| null` | | | `escalationReasonCodes` required | `ReasonCode[]` | | | `expiresAt` required | `Timestamp` | | | `isPending` required | `boolean` | | | `resolvedAt` required | `Timestamp \| null` | | | `resolvedBy` required | `object \| null` | | | `state` required | `PENDING \| APPROVED \| REJECTED \| EXPIRED \| CANCELLED` | | | `authorization` | `PurchaseAuthorizationSummary \| null` | | | `expiresAt` required | `Timestamp` | | | `id` required | `UUID` | | | `status` required | `active \| used \| expired \| cancelled` | `active` until `expiresAt`, unless it is used or cancelled first. An expired authorization is not renewed: request the purchase again. | | `category` | `string \| null` | | | `currency` required | `Currency` | | | `decision` | `object \| null` | | | `authority` required | `WITHIN_POLICY \| AUTHORIZED \| null` | | | `engineVersion` required | `string` | | | `evaluatedAt` required | `Timestamp` | | | `explanation` required | `string` | | | `kind` required | `string` | INITIAL, or APPROVAL_REEVALUATION once a person answered. | | `outcome` required | `APPROVED \| REJECTED \| REQUIRES_APPROVAL` | | | `reasonCodes` required | `ReasonCode[]` | | | `id` required | `UUID` | | | `initiatedBy` | `object \| null` | | | `name` required | `string` | | | `maximumMinor` required | `MinorAmount \| null` | The most the purchase can cost, with every `VARIABLE` charge at its maximum: what rules and budgets judged. `amountMinor` is the known amount. Null when no charge can still vary. | | `merchant` required | `string` | | | `merchantDomain` | `string \| null` | | | `origin` | `DASHBOARD \| API \| MCP \| null` | | | `reason` required | `string` | | | `requestedAt` required | `Timestamp` | | | `resolvedAt` | `Timestamp \| null` | | | `status` required | `EVALUATING \| PENDING_APPROVAL \| APPROVED \| REJECTED \| EXPIRED \| CANCELLED` | | | `submittedVia` | `object \| object \| null` | | ### Cancel a purchase that is waiting for approval POST `/v1/purchase-requests/{purchaseRequestId}/cancel` API key of either kind OAuth: `purchase:request` Takes back a request nobody has approved yet. The approval is no longer asked for, the budget it was holding is released, and the purchase becomes `CANCELLED`. Only a request still waiting for approval can be cancelled. Anything else is refused with `purchase_authorization_conflict`. An approved purchase is ended by withdrawing its authorization, which an agent's credential cannot do. The credential that submitted the request may cancel it, and so may an organization-scoped key. Another credential's request is a 404. Credential: An API key of either kind. OAuth connections need `purchase:request`. Rate limit: 120 per minute per credential. Request body: at most 16384 bytes. #### Parameters | Name | In | Description | | --- | --- | --- | | `purchaseRequestId` required | path | The purchase request to cancel. | #### Request body (optional) | Field | Type | Description | | --- | --- | --- | | `reason` | `string \| null` | Shown to your organization's members in the audit trail. | #### Responses | Status | Meaning | | --- | --- | | `200` | The purchase, cancelled. | | `400` | `invalid_request` `malformed_json` | | `401` | `unauthorized` `invalid_api_key` `api_key_revoked` `api_key_expired` `wrong_environment` `invalid_token` | | `402` | `feature_not_included` | | `403` | `connection_unauthorized` `organization_suspended` `insufficient_scope` | | `404` | `not_found` | | `409` | `purchase_authorization_conflict` | | `413` | `payload_too_large` | | `429` | `rate_limited` | | `500` | `internal_error` | #### Response body | Field | Type | Description | | --- | --- | --- | | `purchaseRequest` required | `object` | | | `id` required | `UUID` | | | `status` required | `CANCELLED` | | | `replayed` required | `boolean` | | ### Report what became of an approved purchase POST `/v1/purchase-requests/{purchaseRequestId}/outcome` API key of either kind OAuth: `purchase:request` Says what happened to a purchase this credential submitted and that was authorized: `COMPLETED` for `finalAmountMinor`, `COMPLETED_AMOUNT_UNKNOWN`, `PARTIAL` for the items in `itemPositions`, `FAILED`, or `REFUNDED` by `refundMinor`. A report is the agent's word. It is recorded as reported by the assistant, never as verified. Under the organisation's default policy it is shown and changes nothing until a person confirms what was charged; an organisation that trusts assistant reports accepts it. It never approves a purchase or revives an authorization, and it makes, changes or cancels no order or payment. An amount above what was approved is recorded and flagged for review. A final for each variable charge may be sent in `variableFinal`; `finalAmountMinor` is then the known amount plus every final. Send a `reportId` with each report. The same report again returns `replayed: true` and records nothing; a different report under a used `reportId` is `report_id_reuse`. A later report with a new `reportId` replaces this credential's earlier one; a refund adds to it. Only the credential that submitted the purchase may report on it. Anything else, including an organization-scoped key, is a 404. A purchase that was never authorized is `purchase_outcome_conflict`, and so is `REFUNDED` while the authorization can still be used: report a refund once it has been used or withdrawn. Credential: An API key of either kind. OAuth connections need `purchase:request`. Rate limit: 60 per minute per credential; 300 per minute per organization. Request body: at most 16384 bytes. #### Parameters | Name | In | Description | | --- | --- | --- | | `purchaseRequestId` required | path | The purchase request the report is about. | #### Request body | Field | Type | Description | | --- | --- | --- | | `currency` required | `string` | The purchase's currency. | | `finalAmountMinor` | `string` | What was charged in total. COMPLETED and PARTIAL only, and required for both. | | `itemPositions` | `integer[]` | PARTIAL only: the bought items, by their zero-based position in the request's `items`. | | `merchantOrderReference` | `string` | At most 255 bytes in UTF-8 once trimmed, with no control characters. | | `observedAt` | `string` | When the outcome was seen. Not in the future. | | `outcome` required | `COMPLETED \| COMPLETED_AMOUNT_UNKNOWN \| PARTIAL \| FAILED \| REFUNDED` | | | `refundMinor` | `string` | REFUNDED only: the amount refunded. | | `reportId` required | `string` | | | `variableFinal` | `object` | COMPLETED only: each variable charge's kind and its final. Exactly the purchase's variable charges. | | `DELIVERY` | `string` | | | `DUTY` | `string` | | | `TAX` | `string` | | #### Responses | Status | Meaning | | --- | --- | | `200` | The report, recorded, and what the purchase is recorded at now. | | `400` | `invalid_request` `malformed_json` | | `401` | `unauthorized` `invalid_api_key` `api_key_revoked` `api_key_expired` `wrong_environment` `invalid_token` | | `402` | `feature_not_included` | | `403` | `connection_unauthorized` `organization_suspended` `insufficient_scope` | | `404` | `not_found` | | `409` | `report_id_reuse` `purchase_outcome_conflict` | | `413` | `payload_too_large` | | `415` | `unsupported_media_type` | | `429` | `rate_limited` | | `500` | `internal_error` | | `503` | `temporarily_unavailable` | #### Response body | Field | Type | Description | | --- | --- | --- | | `outcomeReport` required | `object` | | | `purchaseRequestId` required | `UUID` | | | `recorded` required | `true` | | | `reportId` required | `string` | | | `source` required | `AGENT_REPORT` | | | `purchase` required | `objectnull` | The purchase the report was recorded against, to check it was the one meant. | | `reconciliation` required | `object` | | | `countedMinor` required | `string` | What it is recorded at: exposure until an accepted outcome says otherwise. | | `currency` required | `string` | | | `exposureMinor` required | `string` | The most the purchase was approved for. | | `outcome` required | `NOT_REPORTED \| COMPLETED \| COMPLETED_AMOUNT_UNKNOWN \| PARTIAL \| FAILED \| REFUNDED \| CONFLICTING` | | | `state` required | `UNRECONCILED \| RECONCILED \| RECONCILED_WITH_EXCEPTION \| CORRECTED` | | | `replayed` required | `boolean` | | ## Purchase authorizations What an approved purchase issues: permission for one purchase on exact terms, until a stated time. Veriticity authorizes a purchase and never makes one. ### List purchase authorizations GET `/v1/purchase-authorizations` Organisation-scoped API key No OAuth caller admitted Your organization's purchase authorizations, newest first. Filter with `status`. `status=expired` is the one to act on: authorizations that were never used and have expired. An expiry releases nothing, so each of their purchases still counts towards your budgets until you withdraw it. No entry carries the purchase's terms. Requires an organization-scoped API key. Credential: An organization-scoped API key. No OAuth scope reaches this operation. Rate limit: 600 per minute per credential. #### Parameters | Name | In | Description | | --- | --- | --- | | `status` | query | Only authorizations in this state. | | `cursor` | query | `nextCursor` from the previous page. | | `limit` | query | Between 1 and 100. Defaults to 25. | #### Responses | Status | Meaning | | --- | --- | | `200` | One page of authorizations. | | `400` | `invalid_request` | | `401` | `unauthorized` `invalid_api_key` `api_key_revoked` `api_key_expired` `wrong_environment` `invalid_token` | | `403` | `connection_unauthorized` `organization_suspended` `api_key_not_organization_scoped` `oauth_not_permitted` | | `429` | `rate_limited` | | `500` | `internal_error` | #### Response body | Field | Type | Description | | --- | --- | --- | | `authorizations` required | `PurchaseAuthorization[]` | | | `cancellation` required | `object \| null` | | | `cancelledAt` required | `Timestamp` | | | `kind` required | `withdrawn \| voided` | | | `reason` required | `string \| null` | | | `expiresAt` required | `Timestamp` | | | `id` required | `UUID` | | | `issuedAt` required | `Timestamp` | | | `purchaseRequestId` required | `UUID` | | | `status` required | `active \| used \| expired \| withdrawn \| voided` | | | `usable` required | `boolean` | Whether it can be used right now: active, in an active organization, for an agent that is enabled. | | `use` required | `object \| null` | | | `executionReference` required | `string` | | | `usedAt` required | `Timestamp` | | | `nextCursor` required | `string \| null` | | ### Read the state of a purchase authorization GET `/v1/purchase-authorizations/{authorizationId}` Organisation-scoped API key No OAuth caller admitted Whether a purchase authorization is active, used, expired, withdrawn or voided, and whether it can be used right now. It returns none of the purchase's terms. An authorization from another organization and a malformed id are the same 404. Requires an organization-scoped API key. An agent reads the authorization of its own purchase on the purchase. Credential: An organization-scoped API key. No OAuth scope reaches this operation. Rate limit: 600 per minute per credential. #### Parameters | Name | In | Description | | --- | --- | --- | | `authorizationId` required | path | The purchase authorization to read. | #### Responses | Status | Meaning | | --- | --- | | `200` | The authorization, as the organization may see it. | | `401` | `unauthorized` `invalid_api_key` `api_key_revoked` `api_key_expired` `wrong_environment` `invalid_token` | | `403` | `connection_unauthorized` `organization_suspended` `api_key_not_organization_scoped` `oauth_not_permitted` | | `404` | `not_found` | | `429` | `rate_limited` | | `500` | `internal_error` | #### Response body | Field | Type | Description | | --- | --- | --- | | `authorization` required | `PurchaseAuthorization` | | | `cancellation` required | `object \| null` | | | `cancelledAt` required | `Timestamp` | | | `kind` required | `withdrawn \| voided` | | | `reason` required | `string \| null` | | | `expiresAt` required | `Timestamp` | | | `id` required | `UUID` | | | `issuedAt` required | `Timestamp` | | | `purchaseRequestId` required | `UUID` | | | `status` required | `active \| used \| expired \| withdrawn \| voided` | | | `usable` required | `boolean` | Whether it can be used right now: active, in an active organization, for an agent that is enabled. | | `use` required | `object \| null` | | | `executionReference` required | `string` | | | `usedAt` required | `Timestamp` | | ### Get an execution token for your own purchase authorization POST `/v1/purchase-authorizations/{authorizationId}/execution-token` API key bound to an agent No OAuth caller admitted Issues the token an agent's key sends to use one purchase authorization. Only the key that submitted the purchase can ask, and only while the authorization is `active`. The token is shown once and works for this authorization only. It has no lifetime of its own: it stops working when the authorization is used, withdrawn, voided or expires, whichever is first, and when another token is issued for it. It does not change the authorization's terms or its expiry, and grants nothing else. Asking again issues a new token and the earlier one stops working, so a lost response is recovered by asking again. A used authorization gets no new token: to retry a use whose response was lost, send the same token, reference and terms again. Requires an API key bound to an agent. An organization-scoped key uses authorizations without a token, and no OAuth connection can ask. Credential: An API key bound to an agent. No OAuth scope reaches this operation. Rate limit: 120 per minute per credential; 600 per minute per organization. #### Parameters | Name | In | Description | | --- | --- | --- | | `authorizationId` required | path | The purchase authorization the token is for. | #### Responses | Status | Meaning | | --- | --- | | `201` | A token, shown once. | | `401` | `unauthorized` `invalid_api_key` `api_key_revoked` `api_key_expired` `wrong_environment` `invalid_token` | | `403` | `connection_unauthorized` `organization_suspended` `api_key_not_bound_to_agent` `oauth_not_permitted` | | `404` | `not_found` | | `409` | `purchase_authorization_conflict` | | `429` | `rate_limited` | | `500` | `internal_error` | | `503` | `temporarily_unavailable` | #### Response body | Field | Type | Description | | --- | --- | --- | | `executionToken` required | `object` | | | `authorizationId` required | `UUID` | | | `expiresAt` required | `Timestamp` | The authorization's own expiry. The token stops working then at the latest, and sooner if the authorization is used, withdrawn or voided, or another token is issued for it. | | `token` required | `string` | Shown once. Send it in the `Veriticity-Execution-Token` header when using this authorization. Only a hash is kept. | | `replaced` required | `boolean` | True when an earlier token for this authorization stopped working. | ### Use a purchase authorization POST `/v1/purchase-authorizations/{authorizationId}/use` API key of either kind No OAuth caller admitted Say what you are about to do, and learn whether the authorization covers exactly that. A match uses it, in the same step, and it cannot be used again. `authorized` means the purchase authorization was verified and consumed. It does not mean a purchase was made. Veriticity does not execute the purchase and is not told its outcome. A refusal is an answer, so it is a 200: branch on `result`, never on the HTTP status code, and do not proceed unless it is `authorized`. A refusal changes nothing: the authorization is exactly as it was, whatever was presented and however many times. Terms are compared exactly. The amount must be the authorized amount to the minor unit, and every identifier the purchase stated must be presented and must match. `merchant` is compared with what was submitted; it is never looked up again. You are not told which term failed. `executionReference` is your own identifier for this execution. Repeating a successful call with the same reference and the same terms returns the original answer with `replayed: true`. Takes an API key of either kind. An agent's key uses only its own agent's authorization, and must send the execution token issued for it in `Veriticity-Execution-Token`: see `POST /v1/purchase-authorizations/{authorizationId}/execution-token`. Another agent's authorization is the same 404 as an unknown one. The organization's own key sends no token. No OAuth connection can use an authorization. An authorization is used in Live only while the organization has a plan that includes purchase requests. Once its trial has ended, or with no plan, a use is refused with `plan_required` until an owner chooses one. Credential: An API key of either kind. No OAuth scope reaches this operation. Rate limit: 120 per minute per credential; 600 per minute per organization. Request body: at most 16384 bytes, or 655360 bytes when it states `items`. #### Parameters | Name | In | Description | | --- | --- | --- | | `authorizationId` required | path | The purchase authorization to use. | | `Veriticity-Execution-Token` | header | Required with an agent's key, refused with the organization's own key. The token issued for this authorization. A missing or malformed one is a 400 `invalid_request`; one that is not the latest issued for this authorization is refused with `execution_token_invalid`. | #### Request body | Field | Type | Description | | --- | --- | --- | | `agentId` | `UUID \| null` | Optional. When you know which agent you are acting for, present it and it must match. | | `amountMinor` required | `string \| integer` | The amount about to be paid, in ISO 4217 minor units: the authorized amount exactly, or for a purchase with charges that can still vary, its known amount plus every `variableFinal`. | | `category` | `electronics \| software \| office-supplies \| clothing \| home-garden \| food-drink \| health-beauty \| kids-baby \| pet-supplies \| sports-outdoors \| automotive \| travel \| media-entertainment \| services \| medicines \| gift-cards \| alcohol \| tobacco-nicotine \| adult \| gambling \| crypto \| recreational-drugs \| other` | The category the purchase stated, when it stated one. Leave it out when it stated `items`, or none. | | `charges` | `object[]` | Charges on the whole basket. `KNOWN` is exact: `TAX`, `DELIVERY`, `DUTY`, `FEE` or `DISCOUNT` (negative; a fee or discount says what it is). `UNEXPLAINED` is any part of the total you can't account for, and has no `kind`. `VARIABLE` is a `TAX`, `DELIVERY` or `DUTY` charge that can still change, stated with its `maximumMinor`: the purchase is decided at the known amount plus every maximum, and a use presents each one's final, which may not exceed it. | | `amountMinor` | `string` | | | `class` required | `KNOWN \| VARIABLE \| UNEXPLAINED` | | | `description` | `string` | At most 200 bytes. | | `kind` | `TAX \| DELIVERY \| DUTY \| FEE \| DISCOUNT` | | | `maximumMinor` | `string` | A variable charge's most. | | `currency` required | `string` | Required. It must equal the authorized currency. | | `executionReference` required | `string` | Your own identifier for this execution, such as an order id. A retry carries the same one. | | `items` | `object[]` | The items, instead of `category`, for a purchase that holds different kinds of product or a product involving more than one restricted kind. `amountMinor` must then be exactly the item lines (each `quantity` × `unitAmountMinor` less `discountMinor`) plus the known and unexplained charges. Text limits are counted in UTF-8 bytes. | | `category` | `electronics \| software \| office-supplies \| clothing \| home-garden \| food-drink \| health-beauty \| kids-baby \| pet-supplies \| sports-outdoors \| automotive \| travel \| media-entertainment \| services \| medicines \| gift-cards \| alcohol \| tobacco-nicotine \| adult \| gambling \| crypto \| recreational-drugs \| other` | The item's category. Omit it only for an item whose category is uncertain. | | `categoryUncertain` | `boolean` | `true` when what kind of product this is can't be established. Send `uncertaintyReason` and no `category`; the item is then treated as not established in every category rule and budget. | | `description` required | `string` | What the item is. At most 500 bytes. | | `discountMinor` | `string` | A discount on this line, in minor units. Defaults to 0. | | `itemId` | `string` | Your own label for the line, unique in the basket. At most 64 bytes. | | `options` | `object[]` | | | `name` required | `string` | | | `value` required | `string` | | | `possibleCategories` | `electronics \| software \| office-supplies \| clothing \| home-garden \| food-drink \| health-beauty \| kids-baby \| pet-supplies \| sports-outdoors \| automotive \| travel \| media-entertainment \| services \| medicines \| gift-cards \| alcohol \| tobacco-nicotine \| adult \| gambling \| crypto \| recreational-drugs \| other[]` | For an uncertain item: up to three categories it might be. Shown to a person; never relied on. | | `productReference` | `object` | | | `id` required | `string` | | | `provider` required | `string` | | | `productUrl` | `string` | The product's https page, on `merchantDomain` or the same website with or without its `www` label. | | `quantity` required | `integer` | | | `riskTags` | `medicines \| gift-cards \| alcohol \| tobacco-nicotine \| adult \| gambling \| crypto \| recreational-drugs[]` | Every restricted kind the item involves besides its category: a crypto casino deposit is `gambling` tagged `crypto`. A rule on a tagged category applies to the item. | | `uncertaintyReason` | `string` | Why the category can't be established. At most 200 bytes. | | `unitAmountMinor` required | `string` | The price of one, in minor units. | | `merchant` required | `string` | The merchant, as it was named when the purchase was submitted. Compared with that; never looked up. | | `merchantDomain` | `string \| null` | Required when the purchase stated one, and it must match. Leave it out when the purchase stated none: presenting one then does not match. | | `merchantReference` | `CommerceReference \| null` | As `merchantDomain`: present it exactly when the purchase stated one. | | `id` required | `string` | The id, exactly as the provider writes it. No spaces. | | `provider` required | `string` | The platform or scheme: a short lowercase name. | | `productReference` | `CommerceReference \| null` | As `merchantDomain`: present it exactly when the purchase stated one. | | `id` required | `string` | The id, exactly as the provider writes it. No spaces. | | `provider` required | `string` | The platform or scheme: a short lowercase name. | | `taxIncluded` | `boolean` | Required with `items`: whether the item prices already include tax. When `true`, tax is not stated again. | | `variableFinal` | `object` | One final for each `VARIABLE` charge the purchase stated, by kind, in minor units from 0. A final above its `maximumMinor` is refused with `allowance_exceeded`. | | `DELIVERY` | `string` | What the variable delivery came to. | | `DUTY` | `string` | What the variable duty came to. | | `TAX` | `string` | What the variable tax came to. | #### Responses | Status | Meaning | | --- | --- | | `200` | The answer: `authorized`, or `refused` with a reason. Both are successful calls. | | `400` | `invalid_request` `malformed_json` | | `401` | `unauthorized` `invalid_api_key` `api_key_revoked` `api_key_expired` `wrong_environment` `invalid_token` | | `403` | `connection_unauthorized` `organization_suspended` `oauth_not_permitted` | | `404` | `not_found` | | `413` | `payload_too_large` | | `415` | `unsupported_media_type` | | `429` | `rate_limited` | | `500` | `internal_error` | | `503` | `temporarily_unavailable` | #### Response body | Field | Type | Description | | --- | --- | --- | | `authorization` | `object` | Present when `result` is `authorized`. | | `expiresAt` required | `Timestamp` | | | `id` required | `UUID` | | | `status` required | `used` | | | `usedAt` required | `Timestamp` | | | `reason` | `withdrawn \| voided \| already_used \| expired \| not_usable \| terms_mismatch \| allowance_exceeded \| execution_token_invalid \| plan_required` | Present when `result` is `refused`. `not_usable`: it cannot be used right now, or ever, for a purchase recorded before its terms were. `terms_mismatch`: at least one term did not match; which one is not said. `allowance_exceeded`: a variable charge's final is above its maximum; ask again. `execution_token_invalid`: the execution token is not the latest one issued for this authorization. `plan_required`: the organisation's trial has ended or it has no plan; it can be used once an owner chooses a plan, until it expires. A refusal leaves the authorization unchanged. | | `replayed` | `boolean` | Present when `result` is `authorized`. True when this execution had already used the authorization and this is the original answer again. | | `result` required | `authorized \| refused` | | ### Void a used purchase authorization POST `/v1/purchase-authorizations/{authorizationId}/void` Organisation-scoped API key No OAuth caller admitted Says an authorization was used and the purchase did not complete. Its purchase becomes `CANCELLED` and stops counting towards budgets. This can't be undone. Works only on a `used` authorization. An unused one is refused with `purchase_authorization_conflict`: withdraw it instead. Voiding is not a refund or a reversal. Veriticity moves no money and tells no payment system anything. It records what you say happened. Repeating the call returns the same authorization with `replayed: true` and changes nothing. Requires an organization-scoped API key. Credential: An organization-scoped API key. No OAuth scope reaches this operation. Rate limit: 120 per minute per credential. Request body: at most 16384 bytes. #### Parameters | Name | In | Description | | --- | --- | --- | | `authorizationId` required | path | The purchase authorization to void. | #### Request body | Field | Type | Description | | --- | --- | --- | | `reason` | `string \| null` | Shown to your organization's members in the audit trail. | #### Responses | Status | Meaning | | --- | --- | | `200` | The authorization, voided. | | `400` | `invalid_request` `malformed_json` | | `401` | `unauthorized` `invalid_api_key` `api_key_revoked` `api_key_expired` `wrong_environment` `invalid_token` | | `403` | `connection_unauthorized` `organization_suspended` `api_key_not_organization_scoped` `oauth_not_permitted` | | `404` | `not_found` | | `409` | `purchase_authorization_conflict` | | `413` | `payload_too_large` | | `429` | `rate_limited` | | `500` | `internal_error` | #### Response body | Field | Type | Description | | --- | --- | --- | | `authorization` required | `PurchaseAuthorization` | | | `cancellation` required | `object \| null` | | | `cancelledAt` required | `Timestamp` | | | `kind` required | `withdrawn \| voided` | | | `reason` required | `string \| null` | | | `expiresAt` required | `Timestamp` | | | `id` required | `UUID` | | | `issuedAt` required | `Timestamp` | | | `purchaseRequestId` required | `UUID` | | | `status` required | `active \| used \| expired \| withdrawn \| voided` | | | `usable` required | `boolean` | Whether it can be used right now: active, in an active organization, for an agent that is enabled. | | `use` required | `object \| null` | | | `executionReference` required | `string` | | | `usedAt` required | `Timestamp` | | | `replayed` required | `boolean` | True when this had already been done and nothing changed. | ### Withdraw an unused purchase authorization POST `/v1/purchase-authorizations/{authorizationId}/withdraw` API key of either kind OAuth: `purchase:request` Says an authorization that has not been used will not be. It can't be used afterwards, its purchase becomes `CANCELLED`, and the purchase stops counting towards budgets. This can't be undone. Works on an `active` or an `expired` authorization. A `used` one is refused with `purchase_authorization_conflict`: void it instead. Withdrawing is the only way an unused approval stops counting. An expiry does not release it. Repeating the call returns the same authorization with `replayed: true` and changes nothing. An organization-scoped key may withdraw any authorization. The credential that submitted a purchase may withdraw its authorization while it is unused, giving up authority it no longer needs; another credential's is a 404. Credential: An API key of either kind. OAuth connections need `purchase:request`. Rate limit: 120 per minute per credential. Request body: at most 16384 bytes. #### Parameters | Name | In | Description | | --- | --- | --- | | `authorizationId` required | path | The purchase authorization to withdraw. | #### Request body | Field | Type | Description | | --- | --- | --- | | `reason` | `string \| null` | Shown to your organization's members in the audit trail. | #### Responses | Status | Meaning | | --- | --- | | `200` | The authorization, withdrawn. | | `400` | `invalid_request` `malformed_json` | | `401` | `unauthorized` `invalid_api_key` `api_key_revoked` `api_key_expired` `wrong_environment` `invalid_token` | | `402` | `feature_not_included` | | `403` | `connection_unauthorized` `organization_suspended` `api_key_not_organization_scoped` `insufficient_scope` | | `404` | `not_found` | | `409` | `purchase_authorization_conflict` | | `413` | `payload_too_large` | | `429` | `rate_limited` | | `500` | `internal_error` | #### Response body | Field | Type | Description | | --- | --- | --- | | `authorization` required | `PurchaseAuthorization` | | | `cancellation` required | `object \| null` | | | `cancelledAt` required | `Timestamp` | | | `kind` required | `withdrawn \| voided` | | | `reason` required | `string \| null` | | | `expiresAt` required | `Timestamp` | | | `id` required | `UUID` | | | `issuedAt` required | `Timestamp` | | | `purchaseRequestId` required | `UUID` | | | `status` required | `active \| used \| expired \| withdrawn \| voided` | | | `usable` required | `boolean` | Whether it can be used right now: active, in an active organization, for an agent that is enabled. | | `use` required | `object \| null` | | | `executionReference` required | `string` | | | `usedAt` required | `Timestamp` | | | `replayed` required | `boolean` | True when this had already been done and nothing changed. | ## Simulations Ask what would happen, without anything happening. Simulations create no records and consume no budget. ### Ask what would happen right now POST `/v1/simulations/current-policies` API key of either kind OAuth: `purchase:check` Runs the same evaluation `POST /v1/purchase-requests` would run, against the policies currently in force, and changes nothing: no purchase request, no decision, no budget reservation, no audit evidence, and no budget consumed. This is how an agent finds out what it may spend before committing to asking. Both API-key kinds reach it, and they behave differently: - an organization-scoped key must send `agentId` - an agent-bound key must not, and simulates as itself - an OAuth connection must not, and simulates as its agent Credential: An API key of either kind. OAuth connections need `purchase:check`. Rate limit: 120 per minute per credential. Request body: at most 65536 bytes, or 655360 bytes when it states `items`. #### Request body | Field | Type | Description | | --- | --- | --- | | `agentId` | `UUID \| null` | Required for an organization-scoped key. Must be omitted by an agent-bound key or an OAuth connection, which simulate as themselves — a credential choosing which agent it asks about would be choosing its own authority. | | `amountMinor` required | `string \| integer` | A whole number of ISO 4217 minor units. Send it as a string: JSON numbers lose precision above 2^53, and this is money. It is always returned as a string. | | `category` | `electronics \| software \| office-supplies \| clothing \| home-garden \| food-drink \| health-beauty \| kids-baby \| pet-supplies \| sports-outdoors \| automotive \| travel \| media-entertainment \| services \| medicines \| gift-cards \| alcohol \| tobacco-nicotine \| adult \| gambling \| crypto \| recreational-drugs \| other` | Required unless the body states `items`, and never with them. What kind of product this is: exactly one of Veriticity's categories. Choose by what the product is, not where it's sold or what it's for. `other` means you know what the product is and no category covers it; never use it for a product you can't identify. A purchase that holds different kinds of product, or a product that is also one of `medicines`, `gift-cards`, `alcohol`, `tobacco-nicotine`, `adult`, `gambling`, `crypto`, `recreational-drugs` besides the category you'd choose (a crypto casino deposit, an alcohol gift card), isn't described by one category: list its items instead, each with its own category and a risk tag for every other one of those it involves. Any other value is refused. Recorded as stated by the caller, not verified. `electronics`: Physical electronic devices, components and accessories: laptops, phones, chargers, hardware wallets. Not here: software (`software`), video games (`media-entertainment`). `software`: Software licences, apps, SaaS and cloud services, hosting, domains and productivity tools. Not here: music, film, streaming media and games (`media-entertainment`). `office-supplies`: Stationery, paper, printer supplies, and office furniture and equipment bought for work. Not here: personal electronics (`electronics`). `clothing`: Clothing, footwear, bags, jewellery and watches, for any age. Not here: sports equipment (`sports-outdoors`), dress-up toys (`kids-baby`). `home-garden`: Furniture, homeware, kitchenware, appliances, DIY tools and materials, garden products, and household cleaning and consumables such as washing-up liquid, bin bags and toilet roll. Not here: office furniture bought for an office (`office-supplies`). `food-drink`: Groceries, non-alcoholic drinks, meals, takeaway and food delivery, including baby food and formula. Not here: alcohol (`alcohol`), pet food (`pet-supplies`), vitamins and supplements (`health-beauty`). `health-beauty`: Toiletries, skincare, cosmetics, fragrance, hair and oral care, shaving, deodorant, sunscreen, sanitary and incontinence products, condoms and non-prescription contraception, vitamins and supplements, first-aid supplies, health devices and contact lenses. Not here: licensed medicines, including medicated creams (`medicines`), household cleaning (`home-garden`), nappies (`kids-baby`), CBD products (`recreational-drugs`). `kids-baby`: Toys, board games, puzzles, nursery equipment, nappies, and baby care that is neither food nor medicine. Not here: children's clothing (`clothing`), baby food (`food-drink`), video games (`media-entertainment`). `pet-supplies`: Pet food, accessories, bedding and non-prescription pet care. Not here: veterinary services (`services`), prescription pet medicines (`medicines`). `sports-outdoors`: Sports, fitness, camping and cycling equipment. Not here: sportswear (`clothing`), tickets to sports events (`media-entertainment`). `automotive`: Vehicle parts, accessories, tyres and maintenance products. Not here: fuel, parking and tolls (`travel`), repairs (`services`). `travel`: Flights, accommodation, rail, car hire, rides, parking, fuel and tolls. Not here: travel insurance (`services`). `media-entertainment`: Books and e-books, music, film, streaming media, video games and in-game items, and event tickets. Not here: betting stakes and casino credits (`gambling`). `services`: Professional, personal and booked services, repairs, insurance, education and veterinary services. Not here: software (`software`), event tickets (`media-entertainment`). `medicines`: Licensed medicines, prescription and over the counter, for people or pets: pain relief, cold and flu, allergy, medicated creams, pharmacy-only products and prescription refills. Not here: vitamins, supplements and unmedicated skincare (`health-beauty`), nicotine replacement products (`tobacco-nicotine`), CBD and other unlicensed psychoactive products (`recreational-drugs`). `gift-cards`: Gift cards, vouchers, prepaid cards, and top-ups of a merchant's or platform's wallet or account. Not here: deposits to crypto exchanges or wallets (`crypto`), goods paid for with a gift card (by what they are). `alcohol`: Alcoholic drinks of any strength, and alcohol gift sets. Not here: alcohol-free drinks (`food-drink`). `tobacco-nicotine`: Tobacco, cigarettes, cigars, e-cigarettes, vape devices and liquids, nicotine pouches and nicotine replacement products. Not here: cannabis vapes (`recreational-drugs`), lighters on their own (`home-garden`). `adult`: Sexual-wellness products, adult entertainment and age-restricted adult content. Not here: condoms and contraception (`health-beauty`). `gambling`: Lottery tickets, betting stakes and credits, casino credits including crypto casinos, and paid chance-based entries that can pay out money. Not here: in-game items with no cash value (`media-entertainment`). `crypto`: Buying cryptocurrency, stablecoins, tokens or NFTs, deposits to crypto exchanges or wallets, and crypto ATM purchases. Not here: hardware wallets (`electronics`), crypto casinos (`gambling`), crypto-themed merchandise (by what they are). `recreational-drugs`: Products containing cannabis or cannabinoids, including CBD, psilocybin, kratom and other psychoactive substances not licensed as medicine, and drug paraphernalia. Not here: licensed medicines (`medicines`), alcohol (`alcohol`), tobacco and nicotine (`tobacco-nicotine`). `other`: A product you can identify that no other category covers. Not for a product you can't identify. | | `charges` | `object[]` | Charges on the whole basket. `KNOWN` is exact: `TAX`, `DELIVERY`, `DUTY`, `FEE` or `DISCOUNT` (negative; a fee or discount says what it is). `UNEXPLAINED` is any part of the total you can't account for, and has no `kind`. `VARIABLE` is a `TAX`, `DELIVERY` or `DUTY` charge that can still change, stated with its `maximumMinor`: the purchase is decided at the known amount plus every maximum, and a use presents each one's final, which may not exceed it. | | `amountMinor` | `string` | | | `class` required | `KNOWN \| VARIABLE \| UNEXPLAINED` | | | `description` | `string` | At most 200 bytes. | | `kind` | `TAX \| DELIVERY \| DUTY \| FEE \| DISCOUNT` | | | `maximumMinor` | `string` | A variable charge's most. | | `currency` | `string \| null` | Defaults to the organisation's own currency when omitted. | | `items` | `object[]` | The items, instead of `category`, for a purchase that holds different kinds of product or a product involving more than one restricted kind. `amountMinor` must then be exactly the item lines (each `quantity` × `unitAmountMinor` less `discountMinor`) plus the known and unexplained charges. Text limits are counted in UTF-8 bytes. | | `category` | `electronics \| software \| office-supplies \| clothing \| home-garden \| food-drink \| health-beauty \| kids-baby \| pet-supplies \| sports-outdoors \| automotive \| travel \| media-entertainment \| services \| medicines \| gift-cards \| alcohol \| tobacco-nicotine \| adult \| gambling \| crypto \| recreational-drugs \| other` | The item's category. Omit it only for an item whose category is uncertain. | | `categoryUncertain` | `boolean` | `true` when what kind of product this is can't be established. Send `uncertaintyReason` and no `category`; the item is then treated as not established in every category rule and budget. | | `description` required | `string` | What the item is. At most 500 bytes. | | `discountMinor` | `string` | A discount on this line, in minor units. Defaults to 0. | | `itemId` | `string` | Your own label for the line, unique in the basket. At most 64 bytes. | | `options` | `object[]` | | | `name` required | `string` | | | `value` required | `string` | | | `possibleCategories` | `electronics \| software \| office-supplies \| clothing \| home-garden \| food-drink \| health-beauty \| kids-baby \| pet-supplies \| sports-outdoors \| automotive \| travel \| media-entertainment \| services \| medicines \| gift-cards \| alcohol \| tobacco-nicotine \| adult \| gambling \| crypto \| recreational-drugs \| other[]` | For an uncertain item: up to three categories it might be. Shown to a person; never relied on. | | `productReference` | `object` | | | `id` required | `string` | | | `provider` required | `string` | | | `productUrl` | `string` | The product's https page, on `merchantDomain` or the same website with or without its `www` label. | | `quantity` required | `integer` | | | `riskTags` | `medicines \| gift-cards \| alcohol \| tobacco-nicotine \| adult \| gambling \| crypto \| recreational-drugs[]` | Every restricted kind the item involves besides its category: a crypto casino deposit is `gambling` tagged `crypto`. A rule on a tagged category applies to the item. | | `uncertaintyReason` | `string` | Why the category can't be established. At most 200 bytes. | | `unitAmountMinor` required | `string` | The price of one, in minor units. | | `merchant` required | `string` | Who the money would go to, written as a person would name them. A label: it is never looked up and does not identify the merchant. | | `merchantDomain` | `string \| null` | The merchant's website: the host the purchase would be made on. A full URL is accepted and only its host is used. This is what identifies the merchant; without it no merchant is established, and a policy that restricts merchants refuses the purchase or holds it for a person. It identifies the merchant only where the organisation has that exact host on record for one; a host that is sent and not on record leaves the merchant unrecognised. `example.com` and `www.example.com` are the same host. Any other subdomain matches only where the organisation trusts that website's subdomains. | | `merchantReference` | `CommerceReference \| null` | A commerce platform's id for the merchant. Like the domain it is a statement by the caller. It supports `merchantDomain` and never replaces it: sent without a website it establishes no merchant, and one that is not on record, or is on record for a different merchant than the website, leaves the merchant not established. | | `id` required | `string` | The id, exactly as the provider writes it. No spaces. | | `provider` required | `string` | The platform or scheme: a short lowercase name. | | `productReference` | `CommerceReference \| null` | An id for the product: a platform's, or a scheme such as `sku` or `gtin`. Resolved within the merchant, so it means nothing until the merchant is recognised. | | `id` required | `string` | The id, exactly as the provider writes it. No spaces. | | `provider` required | `string` | The platform or scheme: a short lowercase name. | | `reason` required | `string` | Why the agent wants this purchase. Every purchase must say. | | `taxIncluded` | `boolean` | Required with `items`: whether the item prices already include tax. When `true`, tax is not stated again. | #### Responses | Status | Meaning | | --- | --- | | `200` | What would have happened. Nothing was changed. | | `400` | `invalid_request` `malformed_json` | | `401` | `unauthorized` `invalid_api_key` `api_key_revoked` `api_key_expired` `wrong_environment` `invalid_token` | | `402` | `feature_not_included` | | `403` | `connection_unauthorized` `organization_suspended` `insufficient_scope` | | `404` | `not_found` | | `413` | `payload_too_large` | | `415` | `unsupported_media_type` | | `429` | `rate_limited` | | `500` | `internal_error` | #### Response body | Field | Type | Description | | --- | --- | --- | | `agent` required | `object \| null` | | | `id` | `UUID` | | | `name` | `string` | | | `status` | `ENABLED \| DISABLED \| ARCHIVED` | | | `budgets` required | `SimulatedBudget[]` | | | `aggregate` | `SUM_AMOUNT \| COUNT` | SUM_AMOUNT: the figures are minor units. COUNT: a number of purchases. | | `currency` | `Currency \| null` | | | `current` | `object` | | | `committedMinor` | `MinorAmount` | | | `heldMinor` | `MinorAmount` | | | `reservedMinor` | `MinorAmount` | | | `limits` | `object[]` | | | `effect` | `BLOCK \| REQUIRE_APPROVAL \| FLAG` | | | `limitMinor` | `MinorAmount` | | | `policyId` | `UUID` | | | `policyName` | `string` | | | `policyRuleId` | `UUID \| null` | | | `policyVersionId` | `UUID` | | | `remainingAfterMinor` | `MinorAmount` | Negative reads as "over by". | | `remainingBeforeMinor` | `MinorAmount` | | | `ruleKey` | `string \| null` | | | `wouldExceed` | `boolean` | | | `periodEnd` | `Timestamp` | | | `periodKey` | `string` | | | `periodKind` | `string` | DAY, WEEK, MONTH, QUARTER or YEAR for a calendar window; ROLLING otherwise. | | `periodStart` | `Timestamp` | | | `rollingSeconds` | `integer \| null` | | | `scope` | `string` | Whose purchases the window holds: ORGANIZATION, POLICY:, AGENT: or PRINCIPAL:. | | `simulated` | `object` | | | `projectedMinor` | `MinorAmount` | | | `requestedMinor` | `MinorAmount` | | | `timeZone` | `string \| null` | | | `wouldExceed` | `boolean` | | | `findings` required | `Finding[]` | | | `currency` | `Currency \| null` | | | `headroomMinor` | `MinorAmount \| null` | | | `limitMinor` | `MinorAmount \| null` | | | `measured` | `MeasuredWindow \| null` | | | `aggregate` required | `SUM_AMOUNT \| COUNT` | SUM_AMOUNT: a sum of money. COUNT: a number of purchases. | | `scope` required | `string` | Whose purchases the window holds: POLICY (the agents the policy binds), ORGANIZATION, EACH_AGENT, AGENT:, EACH_PRINCIPAL or PRINCIPAL:. | | `window` required | `object` | | | `message` required | `string` | | | `observedMinor` | `MinorAmount \| null` | | | `outcome` required | `PASS \| NOT_APPLICABLE \| FLAG \| REQUIRE_APPROVAL \| BLOCK \| AUTHORIZED` | PASS: the purchase came within the rule. AUTHORIZED: the rule asked for approval and a person gave it. FLAG: recorded, and changes nothing. A rule that only flags is the organisation's own record and is not returned by this API. | | `policyId` required | `UUID` | | | `policyName` required | `string` | | | `policyRuleId` | `UUID \| null` | | | `policyVersionId` required | `UUID` | | | `policyVersionNumber` required | `integer` | | | `reasonCode` required | `ReasonCode` | | | `role` required | `BINDING \| SATISFIED \| NOT_APPLICABLE` | How the finding stands against the decision. | | `rule` required | `string` | The rule's name, or its key when it has none. | | `ruleKey` | `string \| null` | | | `unit` | `MONEY \| COUNT \| null` | | | `policiesConsidered` required | `object[]` | Every candidate policy version. One whose participation is NOT_APPLICABLE governs the agent but is not about this purchase: it changed nothing, and omitting it would make a NO_APPLICABLE_POLICY answer impossible to act on. | | `applicabilityReason` | `string` | | | `participation` | `PARTICIPATING \| NOT_APPLICABLE` | | | `policyId` | `UUID` | | | `policyName` | `string` | | | `policyStatus` | `ACTIVE \| DISABLED \| ARCHIVED` | | | `policyVersionId` | `UUID` | | | `versionNumber` | `integer` | | | `versionStatus` | `DRAFT \| ACTIVE \| ARCHIVED` | | | `purchase` required | `object` | | | `amountMinor` | `MinorAmount` | | | `category` | `string \| null` | | | `currency` | `Currency` | | | `currencyExponent` | `integer` | | | `maximumMinor` | `MinorAmount \| null` | The most the purchase can cost, with every `VARIABLE` charge at its maximum: what rules and budgets judged. `amountMinor` is the known amount. Null when no charge can still vary. | | `merchant` | `string` | | | `merchantDomain` | `string \| null` | | | `merchantKey` | `string` | | | `simulatedDecision` required | `object` | | | `advance` required | `COMPLETE \| PARTIAL \| NOT_DECIDED` | How much of the decision this answer is. COMPLETE: every rule was checked. PARTIAL: some rules are checked on request, and the others already refuse, so the refusal stands. NOT_DECIDED: some rules are checked on request and no outcome is given. | | `authority` | `WITHIN_POLICY \| AUTHORIZED \| null` | | | `deferredRules` required | `object[]` | The rules checked when a purchase is requested, not in advance. Empty when advance is COMPLETE. | | `policyId` required | `string` | | | `policyName` required | `string` | | | `rule` required | `string` | | | `ruleKey` required | `string` | | | `explanation` required | `string` | | | `outcome` required | `APPROVED \| REJECTED \| REQUIRES_APPROVAL \| NOT_DECIDED_IN_ADVANCE` | What a purchase request would be decided as. NOT_DECIDED_IN_ADVANCE when some rules are checked only when a purchase is requested and the others do not already refuse: request the purchase to find out. | | `reasonCodes` required | `ReasonCode[]` | | | `wouldRequireApproval` required | `boolean` | False when the outcome is NOT_DECIDED_IN_ADVANCE: it is not known. | | `simulation` required | `object` | | | `disclaimer` required | `string` | | | `engineVersion` required | `string` | | | `isSimulation` required | `true` | Always true. The field exists so its absence is a signal. | | `mode` required | `string` | | | `simulatedAt` required | `Timestamp` | | ### Ask what a draft policy version would decide POST `/v1/simulations/draft-policy` Organisation-scoped API key No OAuth caller admitted Evaluates a purchase against one `DRAFT` policy version as it is currently written — the question you ask before activating it. Organization-scoped API keys only. An agent may not read or simulate the guardrails being drafted for it, and no OAuth scope reaches this. Credential: An organization-scoped API key. No OAuth scope reaches this operation. Rate limit: 120 per minute per credential. Request body: at most 65536 bytes, or 655360 bytes when it states `items`. #### Request body | Field | Type | Description | | --- | --- | --- | | `amountMinor` required | `string \| integer` | A whole number of ISO 4217 minor units. Send it as a string: JSON numbers lose precision above 2^53, and this is money. It is always returned as a string. | | `category` | `electronics \| software \| office-supplies \| clothing \| home-garden \| food-drink \| health-beauty \| kids-baby \| pet-supplies \| sports-outdoors \| automotive \| travel \| media-entertainment \| services \| medicines \| gift-cards \| alcohol \| tobacco-nicotine \| adult \| gambling \| crypto \| recreational-drugs \| other` | Required unless the body states `items`, and never with them. What kind of product this is: exactly one of Veriticity's categories. Choose by what the product is, not where it's sold or what it's for. `other` means you know what the product is and no category covers it; never use it for a product you can't identify. A purchase that holds different kinds of product, or a product that is also one of `medicines`, `gift-cards`, `alcohol`, `tobacco-nicotine`, `adult`, `gambling`, `crypto`, `recreational-drugs` besides the category you'd choose (a crypto casino deposit, an alcohol gift card), isn't described by one category: list its items instead, each with its own category and a risk tag for every other one of those it involves. Any other value is refused. Recorded as stated by the caller, not verified. `electronics`: Physical electronic devices, components and accessories: laptops, phones, chargers, hardware wallets. Not here: software (`software`), video games (`media-entertainment`). `software`: Software licences, apps, SaaS and cloud services, hosting, domains and productivity tools. Not here: music, film, streaming media and games (`media-entertainment`). `office-supplies`: Stationery, paper, printer supplies, and office furniture and equipment bought for work. Not here: personal electronics (`electronics`). `clothing`: Clothing, footwear, bags, jewellery and watches, for any age. Not here: sports equipment (`sports-outdoors`), dress-up toys (`kids-baby`). `home-garden`: Furniture, homeware, kitchenware, appliances, DIY tools and materials, garden products, and household cleaning and consumables such as washing-up liquid, bin bags and toilet roll. Not here: office furniture bought for an office (`office-supplies`). `food-drink`: Groceries, non-alcoholic drinks, meals, takeaway and food delivery, including baby food and formula. Not here: alcohol (`alcohol`), pet food (`pet-supplies`), vitamins and supplements (`health-beauty`). `health-beauty`: Toiletries, skincare, cosmetics, fragrance, hair and oral care, shaving, deodorant, sunscreen, sanitary and incontinence products, condoms and non-prescription contraception, vitamins and supplements, first-aid supplies, health devices and contact lenses. Not here: licensed medicines, including medicated creams (`medicines`), household cleaning (`home-garden`), nappies (`kids-baby`), CBD products (`recreational-drugs`). `kids-baby`: Toys, board games, puzzles, nursery equipment, nappies, and baby care that is neither food nor medicine. Not here: children's clothing (`clothing`), baby food (`food-drink`), video games (`media-entertainment`). `pet-supplies`: Pet food, accessories, bedding and non-prescription pet care. Not here: veterinary services (`services`), prescription pet medicines (`medicines`). `sports-outdoors`: Sports, fitness, camping and cycling equipment. Not here: sportswear (`clothing`), tickets to sports events (`media-entertainment`). `automotive`: Vehicle parts, accessories, tyres and maintenance products. Not here: fuel, parking and tolls (`travel`), repairs (`services`). `travel`: Flights, accommodation, rail, car hire, rides, parking, fuel and tolls. Not here: travel insurance (`services`). `media-entertainment`: Books and e-books, music, film, streaming media, video games and in-game items, and event tickets. Not here: betting stakes and casino credits (`gambling`). `services`: Professional, personal and booked services, repairs, insurance, education and veterinary services. Not here: software (`software`), event tickets (`media-entertainment`). `medicines`: Licensed medicines, prescription and over the counter, for people or pets: pain relief, cold and flu, allergy, medicated creams, pharmacy-only products and prescription refills. Not here: vitamins, supplements and unmedicated skincare (`health-beauty`), nicotine replacement products (`tobacco-nicotine`), CBD and other unlicensed psychoactive products (`recreational-drugs`). `gift-cards`: Gift cards, vouchers, prepaid cards, and top-ups of a merchant's or platform's wallet or account. Not here: deposits to crypto exchanges or wallets (`crypto`), goods paid for with a gift card (by what they are). `alcohol`: Alcoholic drinks of any strength, and alcohol gift sets. Not here: alcohol-free drinks (`food-drink`). `tobacco-nicotine`: Tobacco, cigarettes, cigars, e-cigarettes, vape devices and liquids, nicotine pouches and nicotine replacement products. Not here: cannabis vapes (`recreational-drugs`), lighters on their own (`home-garden`). `adult`: Sexual-wellness products, adult entertainment and age-restricted adult content. Not here: condoms and contraception (`health-beauty`). `gambling`: Lottery tickets, betting stakes and credits, casino credits including crypto casinos, and paid chance-based entries that can pay out money. Not here: in-game items with no cash value (`media-entertainment`). `crypto`: Buying cryptocurrency, stablecoins, tokens or NFTs, deposits to crypto exchanges or wallets, and crypto ATM purchases. Not here: hardware wallets (`electronics`), crypto casinos (`gambling`), crypto-themed merchandise (by what they are). `recreational-drugs`: Products containing cannabis or cannabinoids, including CBD, psilocybin, kratom and other psychoactive substances not licensed as medicine, and drug paraphernalia. Not here: licensed medicines (`medicines`), alcohol (`alcohol`), tobacco and nicotine (`tobacco-nicotine`). `other`: A product you can identify that no other category covers. Not for a product you can't identify. | | `charges` | `object[]` | Charges on the whole basket. `KNOWN` is exact: `TAX`, `DELIVERY`, `DUTY`, `FEE` or `DISCOUNT` (negative; a fee or discount says what it is). `UNEXPLAINED` is any part of the total you can't account for, and has no `kind`. `VARIABLE` is a `TAX`, `DELIVERY` or `DUTY` charge that can still change, stated with its `maximumMinor`: the purchase is decided at the known amount plus every maximum, and a use presents each one's final, which may not exceed it. | | `amountMinor` | `string` | | | `class` required | `KNOWN \| VARIABLE \| UNEXPLAINED` | | | `description` | `string` | At most 200 bytes. | | `kind` | `TAX \| DELIVERY \| DUTY \| FEE \| DISCOUNT` | | | `maximumMinor` | `string` | A variable charge's most. | | `currency` | `string \| null` | Defaults to the organisation's own currency when omitted. | | `items` | `object[]` | The items, instead of `category`, for a purchase that holds different kinds of product or a product involving more than one restricted kind. `amountMinor` must then be exactly the item lines (each `quantity` × `unitAmountMinor` less `discountMinor`) plus the known and unexplained charges. Text limits are counted in UTF-8 bytes. | | `category` | `electronics \| software \| office-supplies \| clothing \| home-garden \| food-drink \| health-beauty \| kids-baby \| pet-supplies \| sports-outdoors \| automotive \| travel \| media-entertainment \| services \| medicines \| gift-cards \| alcohol \| tobacco-nicotine \| adult \| gambling \| crypto \| recreational-drugs \| other` | The item's category. Omit it only for an item whose category is uncertain. | | `categoryUncertain` | `boolean` | `true` when what kind of product this is can't be established. Send `uncertaintyReason` and no `category`; the item is then treated as not established in every category rule and budget. | | `description` required | `string` | What the item is. At most 500 bytes. | | `discountMinor` | `string` | A discount on this line, in minor units. Defaults to 0. | | `itemId` | `string` | Your own label for the line, unique in the basket. At most 64 bytes. | | `options` | `object[]` | | | `name` required | `string` | | | `value` required | `string` | | | `possibleCategories` | `electronics \| software \| office-supplies \| clothing \| home-garden \| food-drink \| health-beauty \| kids-baby \| pet-supplies \| sports-outdoors \| automotive \| travel \| media-entertainment \| services \| medicines \| gift-cards \| alcohol \| tobacco-nicotine \| adult \| gambling \| crypto \| recreational-drugs \| other[]` | For an uncertain item: up to three categories it might be. Shown to a person; never relied on. | | `productReference` | `object` | | | `id` required | `string` | | | `provider` required | `string` | | | `productUrl` | `string` | The product's https page, on `merchantDomain` or the same website with or without its `www` label. | | `quantity` required | `integer` | | | `riskTags` | `medicines \| gift-cards \| alcohol \| tobacco-nicotine \| adult \| gambling \| crypto \| recreational-drugs[]` | Every restricted kind the item involves besides its category: a crypto casino deposit is `gambling` tagged `crypto`. A rule on a tagged category applies to the item. | | `uncertaintyReason` | `string` | Why the category can't be established. At most 200 bytes. | | `unitAmountMinor` required | `string` | The price of one, in minor units. | | `merchant` required | `string` | Who the money would go to, written as a person would name them. A label: it is never looked up and does not identify the merchant. | | `merchantDomain` | `string \| null` | The merchant's website: the host the purchase would be made on. A full URL is accepted and only its host is used. This is what identifies the merchant; without it no merchant is established, and a policy that restricts merchants refuses the purchase or holds it for a person. It identifies the merchant only where the organisation has that exact host on record for one; a host that is sent and not on record leaves the merchant unrecognised. `example.com` and `www.example.com` are the same host. Any other subdomain matches only where the organisation trusts that website's subdomains. | | `merchantReference` | `CommerceReference \| null` | A commerce platform's id for the merchant. Like the domain it is a statement by the caller. It supports `merchantDomain` and never replaces it: sent without a website it establishes no merchant, and one that is not on record, or is on record for a different merchant than the website, leaves the merchant not established. | | `id` required | `string` | The id, exactly as the provider writes it. No spaces. | | `provider` required | `string` | The platform or scheme: a short lowercase name. | | `policyVersionId` required | `UUID` | The draft version to evaluate. Name the version, not the policy. | | `productReference` | `CommerceReference \| null` | An id for the product: a platform's, or a scheme such as `sku` or `gtin`. Resolved within the merchant, so it means nothing until the merchant is recognised. | | `id` required | `string` | The id, exactly as the provider writes it. No spaces. | | `provider` required | `string` | The platform or scheme: a short lowercase name. | | `reason` required | `string` | Why the agent wants this purchase. Every purchase must say. | | `taxIncluded` | `boolean` | Required with `items`: whether the item prices already include tax. When `true`, tax is not stated again. | #### Responses | Status | Meaning | | --- | --- | | `200` | What that draft would have decided. | | `400` | `invalid_request` `malformed_json` | | `401` | `unauthorized` `invalid_api_key` `api_key_revoked` `api_key_expired` `wrong_environment` `invalid_token` | | `403` | `connection_unauthorized` `organization_suspended` `api_key_not_organization_scoped` `oauth_not_permitted` | | `404` | `not_found` | | `413` | `payload_too_large` | | `415` | `unsupported_media_type` | | `429` | `rate_limited` | | `500` | `internal_error` | #### Response body | Field | Type | Description | | --- | --- | --- | | `agent` required | `object \| null` | | | `id` | `UUID` | | | `name` | `string` | | | `status` | `ENABLED \| DISABLED \| ARCHIVED` | | | `budgets` required | `SimulatedBudget[]` | | | `aggregate` | `SUM_AMOUNT \| COUNT` | SUM_AMOUNT: the figures are minor units. COUNT: a number of purchases. | | `currency` | `Currency \| null` | | | `current` | `object` | | | `committedMinor` | `MinorAmount` | | | `heldMinor` | `MinorAmount` | | | `reservedMinor` | `MinorAmount` | | | `limits` | `object[]` | | | `effect` | `BLOCK \| REQUIRE_APPROVAL \| FLAG` | | | `limitMinor` | `MinorAmount` | | | `policyId` | `UUID` | | | `policyName` | `string` | | | `policyRuleId` | `UUID \| null` | | | `policyVersionId` | `UUID` | | | `remainingAfterMinor` | `MinorAmount` | Negative reads as "over by". | | `remainingBeforeMinor` | `MinorAmount` | | | `ruleKey` | `string \| null` | | | `wouldExceed` | `boolean` | | | `periodEnd` | `Timestamp` | | | `periodKey` | `string` | | | `periodKind` | `string` | DAY, WEEK, MONTH, QUARTER or YEAR for a calendar window; ROLLING otherwise. | | `periodStart` | `Timestamp` | | | `rollingSeconds` | `integer \| null` | | | `scope` | `string` | Whose purchases the window holds: ORGANIZATION, POLICY:, AGENT: or PRINCIPAL:. | | `simulated` | `object` | | | `projectedMinor` | `MinorAmount` | | | `requestedMinor` | `MinorAmount` | | | `timeZone` | `string \| null` | | | `wouldExceed` | `boolean` | | | `findings` required | `Finding[]` | | | `currency` | `Currency \| null` | | | `headroomMinor` | `MinorAmount \| null` | | | `limitMinor` | `MinorAmount \| null` | | | `measured` | `MeasuredWindow \| null` | | | `aggregate` required | `SUM_AMOUNT \| COUNT` | SUM_AMOUNT: a sum of money. COUNT: a number of purchases. | | `scope` required | `string` | Whose purchases the window holds: POLICY (the agents the policy binds), ORGANIZATION, EACH_AGENT, AGENT:, EACH_PRINCIPAL or PRINCIPAL:. | | `window` required | `object` | | | `message` required | `string` | | | `observedMinor` | `MinorAmount \| null` | | | `outcome` required | `PASS \| NOT_APPLICABLE \| FLAG \| REQUIRE_APPROVAL \| BLOCK \| AUTHORIZED` | PASS: the purchase came within the rule. AUTHORIZED: the rule asked for approval and a person gave it. FLAG: recorded, and changes nothing. A rule that only flags is the organisation's own record and is not returned by this API. | | `policyId` required | `UUID` | | | `policyName` required | `string` | | | `policyRuleId` | `UUID \| null` | | | `policyVersionId` required | `UUID` | | | `policyVersionNumber` required | `integer` | | | `reasonCode` required | `ReasonCode` | | | `role` required | `BINDING \| SATISFIED \| NOT_APPLICABLE` | How the finding stands against the decision. | | `rule` required | `string` | The rule's name, or its key when it has none. | | `ruleKey` | `string \| null` | | | `unit` | `MONEY \| COUNT \| null` | | | `policiesConsidered` required | `object[]` | Every candidate policy version. One whose participation is NOT_APPLICABLE governs the agent but is not about this purchase: it changed nothing, and omitting it would make a NO_APPLICABLE_POLICY answer impossible to act on. | | `applicabilityReason` | `string` | | | `participation` | `PARTICIPATING \| NOT_APPLICABLE` | | | `policyId` | `UUID` | | | `policyName` | `string` | | | `policyStatus` | `ACTIVE \| DISABLED \| ARCHIVED` | | | `policyVersionId` | `UUID` | | | `versionNumber` | `integer` | | | `versionStatus` | `DRAFT \| ACTIVE \| ARCHIVED` | | | `purchase` required | `object` | | | `amountMinor` | `MinorAmount` | | | `category` | `string \| null` | | | `currency` | `Currency` | | | `currencyExponent` | `integer` | | | `maximumMinor` | `MinorAmount \| null` | The most the purchase can cost, with every `VARIABLE` charge at its maximum: what rules and budgets judged. `amountMinor` is the known amount. Null when no charge can still vary. | | `merchant` | `string` | | | `merchantDomain` | `string \| null` | | | `merchantKey` | `string` | | | `simulatedDecision` required | `object` | | | `advance` required | `COMPLETE \| PARTIAL \| NOT_DECIDED` | How much of the decision this answer is. COMPLETE: every rule was checked. PARTIAL: some rules are checked on request, and the others already refuse, so the refusal stands. NOT_DECIDED: some rules are checked on request and no outcome is given. | | `authority` | `WITHIN_POLICY \| AUTHORIZED \| null` | | | `deferredRules` required | `object[]` | The rules checked when a purchase is requested, not in advance. Empty when advance is COMPLETE. | | `policyId` required | `string` | | | `policyName` required | `string` | | | `rule` required | `string` | | | `ruleKey` required | `string` | | | `explanation` required | `string` | | | `outcome` required | `APPROVED \| REJECTED \| REQUIRES_APPROVAL \| NOT_DECIDED_IN_ADVANCE` | What a purchase request would be decided as. NOT_DECIDED_IN_ADVANCE when some rules are checked only when a purchase is requested and the others do not already refuse: request the purchase to find out. | | `reasonCodes` required | `ReasonCode[]` | | | `wouldRequireApproval` required | `boolean` | False when the outcome is NOT_DECIDED_IN_ADVANCE: it is not known. | | `simulation` required | `object` | | | `disclaimer` required | `string` | | | `engineVersion` required | `string` | | | `isSimulation` required | `true` | Always true. The field exists so its absence is a signal. | | `mode` required | `string` | | | `simulatedAt` required | `Timestamp` | | ### Ask what a specific policy version would decide POST `/v1/simulations/policy-version` Organisation-scoped API key No OAuth caller admitted Evaluates a purchase against one `ACTIVE` or `ARCHIVED` policy version exactly as it was frozen — the question you ask about a decision that was already made, or about a version you are thinking of reinstating. Organization-scoped API keys only. Credential: An organization-scoped API key. No OAuth scope reaches this operation. Rate limit: 120 per minute per credential. Request body: at most 65536 bytes, or 655360 bytes when it states `items`. #### Request body | Field | Type | Description | | --- | --- | --- | | `amountMinor` required | `string \| integer` | A whole number of ISO 4217 minor units. Send it as a string: JSON numbers lose precision above 2^53, and this is money. It is always returned as a string. | | `category` | `electronics \| software \| office-supplies \| clothing \| home-garden \| food-drink \| health-beauty \| kids-baby \| pet-supplies \| sports-outdoors \| automotive \| travel \| media-entertainment \| services \| medicines \| gift-cards \| alcohol \| tobacco-nicotine \| adult \| gambling \| crypto \| recreational-drugs \| other` | Required unless the body states `items`, and never with them. What kind of product this is: exactly one of Veriticity's categories. Choose by what the product is, not where it's sold or what it's for. `other` means you know what the product is and no category covers it; never use it for a product you can't identify. A purchase that holds different kinds of product, or a product that is also one of `medicines`, `gift-cards`, `alcohol`, `tobacco-nicotine`, `adult`, `gambling`, `crypto`, `recreational-drugs` besides the category you'd choose (a crypto casino deposit, an alcohol gift card), isn't described by one category: list its items instead, each with its own category and a risk tag for every other one of those it involves. Any other value is refused. Recorded as stated by the caller, not verified. `electronics`: Physical electronic devices, components and accessories: laptops, phones, chargers, hardware wallets. Not here: software (`software`), video games (`media-entertainment`). `software`: Software licences, apps, SaaS and cloud services, hosting, domains and productivity tools. Not here: music, film, streaming media and games (`media-entertainment`). `office-supplies`: Stationery, paper, printer supplies, and office furniture and equipment bought for work. Not here: personal electronics (`electronics`). `clothing`: Clothing, footwear, bags, jewellery and watches, for any age. Not here: sports equipment (`sports-outdoors`), dress-up toys (`kids-baby`). `home-garden`: Furniture, homeware, kitchenware, appliances, DIY tools and materials, garden products, and household cleaning and consumables such as washing-up liquid, bin bags and toilet roll. Not here: office furniture bought for an office (`office-supplies`). `food-drink`: Groceries, non-alcoholic drinks, meals, takeaway and food delivery, including baby food and formula. Not here: alcohol (`alcohol`), pet food (`pet-supplies`), vitamins and supplements (`health-beauty`). `health-beauty`: Toiletries, skincare, cosmetics, fragrance, hair and oral care, shaving, deodorant, sunscreen, sanitary and incontinence products, condoms and non-prescription contraception, vitamins and supplements, first-aid supplies, health devices and contact lenses. Not here: licensed medicines, including medicated creams (`medicines`), household cleaning (`home-garden`), nappies (`kids-baby`), CBD products (`recreational-drugs`). `kids-baby`: Toys, board games, puzzles, nursery equipment, nappies, and baby care that is neither food nor medicine. Not here: children's clothing (`clothing`), baby food (`food-drink`), video games (`media-entertainment`). `pet-supplies`: Pet food, accessories, bedding and non-prescription pet care. Not here: veterinary services (`services`), prescription pet medicines (`medicines`). `sports-outdoors`: Sports, fitness, camping and cycling equipment. Not here: sportswear (`clothing`), tickets to sports events (`media-entertainment`). `automotive`: Vehicle parts, accessories, tyres and maintenance products. Not here: fuel, parking and tolls (`travel`), repairs (`services`). `travel`: Flights, accommodation, rail, car hire, rides, parking, fuel and tolls. Not here: travel insurance (`services`). `media-entertainment`: Books and e-books, music, film, streaming media, video games and in-game items, and event tickets. Not here: betting stakes and casino credits (`gambling`). `services`: Professional, personal and booked services, repairs, insurance, education and veterinary services. Not here: software (`software`), event tickets (`media-entertainment`). `medicines`: Licensed medicines, prescription and over the counter, for people or pets: pain relief, cold and flu, allergy, medicated creams, pharmacy-only products and prescription refills. Not here: vitamins, supplements and unmedicated skincare (`health-beauty`), nicotine replacement products (`tobacco-nicotine`), CBD and other unlicensed psychoactive products (`recreational-drugs`). `gift-cards`: Gift cards, vouchers, prepaid cards, and top-ups of a merchant's or platform's wallet or account. Not here: deposits to crypto exchanges or wallets (`crypto`), goods paid for with a gift card (by what they are). `alcohol`: Alcoholic drinks of any strength, and alcohol gift sets. Not here: alcohol-free drinks (`food-drink`). `tobacco-nicotine`: Tobacco, cigarettes, cigars, e-cigarettes, vape devices and liquids, nicotine pouches and nicotine replacement products. Not here: cannabis vapes (`recreational-drugs`), lighters on their own (`home-garden`). `adult`: Sexual-wellness products, adult entertainment and age-restricted adult content. Not here: condoms and contraception (`health-beauty`). `gambling`: Lottery tickets, betting stakes and credits, casino credits including crypto casinos, and paid chance-based entries that can pay out money. Not here: in-game items with no cash value (`media-entertainment`). `crypto`: Buying cryptocurrency, stablecoins, tokens or NFTs, deposits to crypto exchanges or wallets, and crypto ATM purchases. Not here: hardware wallets (`electronics`), crypto casinos (`gambling`), crypto-themed merchandise (by what they are). `recreational-drugs`: Products containing cannabis or cannabinoids, including CBD, psilocybin, kratom and other psychoactive substances not licensed as medicine, and drug paraphernalia. Not here: licensed medicines (`medicines`), alcohol (`alcohol`), tobacco and nicotine (`tobacco-nicotine`). `other`: A product you can identify that no other category covers. Not for a product you can't identify. | | `charges` | `object[]` | Charges on the whole basket. `KNOWN` is exact: `TAX`, `DELIVERY`, `DUTY`, `FEE` or `DISCOUNT` (negative; a fee or discount says what it is). `UNEXPLAINED` is any part of the total you can't account for, and has no `kind`. `VARIABLE` is a `TAX`, `DELIVERY` or `DUTY` charge that can still change, stated with its `maximumMinor`: the purchase is decided at the known amount plus every maximum, and a use presents each one's final, which may not exceed it. | | `amountMinor` | `string` | | | `class` required | `KNOWN \| VARIABLE \| UNEXPLAINED` | | | `description` | `string` | At most 200 bytes. | | `kind` | `TAX \| DELIVERY \| DUTY \| FEE \| DISCOUNT` | | | `maximumMinor` | `string` | A variable charge's most. | | `currency` | `string \| null` | Defaults to the organisation's own currency when omitted. | | `items` | `object[]` | The items, instead of `category`, for a purchase that holds different kinds of product or a product involving more than one restricted kind. `amountMinor` must then be exactly the item lines (each `quantity` × `unitAmountMinor` less `discountMinor`) plus the known and unexplained charges. Text limits are counted in UTF-8 bytes. | | `category` | `electronics \| software \| office-supplies \| clothing \| home-garden \| food-drink \| health-beauty \| kids-baby \| pet-supplies \| sports-outdoors \| automotive \| travel \| media-entertainment \| services \| medicines \| gift-cards \| alcohol \| tobacco-nicotine \| adult \| gambling \| crypto \| recreational-drugs \| other` | The item's category. Omit it only for an item whose category is uncertain. | | `categoryUncertain` | `boolean` | `true` when what kind of product this is can't be established. Send `uncertaintyReason` and no `category`; the item is then treated as not established in every category rule and budget. | | `description` required | `string` | What the item is. At most 500 bytes. | | `discountMinor` | `string` | A discount on this line, in minor units. Defaults to 0. | | `itemId` | `string` | Your own label for the line, unique in the basket. At most 64 bytes. | | `options` | `object[]` | | | `name` required | `string` | | | `value` required | `string` | | | `possibleCategories` | `electronics \| software \| office-supplies \| clothing \| home-garden \| food-drink \| health-beauty \| kids-baby \| pet-supplies \| sports-outdoors \| automotive \| travel \| media-entertainment \| services \| medicines \| gift-cards \| alcohol \| tobacco-nicotine \| adult \| gambling \| crypto \| recreational-drugs \| other[]` | For an uncertain item: up to three categories it might be. Shown to a person; never relied on. | | `productReference` | `object` | | | `id` required | `string` | | | `provider` required | `string` | | | `productUrl` | `string` | The product's https page, on `merchantDomain` or the same website with or without its `www` label. | | `quantity` required | `integer` | | | `riskTags` | `medicines \| gift-cards \| alcohol \| tobacco-nicotine \| adult \| gambling \| crypto \| recreational-drugs[]` | Every restricted kind the item involves besides its category: a crypto casino deposit is `gambling` tagged `crypto`. A rule on a tagged category applies to the item. | | `uncertaintyReason` | `string` | Why the category can't be established. At most 200 bytes. | | `unitAmountMinor` required | `string` | The price of one, in minor units. | | `merchant` required | `string` | Who the money would go to, written as a person would name them. A label: it is never looked up and does not identify the merchant. | | `merchantDomain` | `string \| null` | The merchant's website: the host the purchase would be made on. A full URL is accepted and only its host is used. This is what identifies the merchant; without it no merchant is established, and a policy that restricts merchants refuses the purchase or holds it for a person. It identifies the merchant only where the organisation has that exact host on record for one; a host that is sent and not on record leaves the merchant unrecognised. `example.com` and `www.example.com` are the same host. Any other subdomain matches only where the organisation trusts that website's subdomains. | | `merchantReference` | `CommerceReference \| null` | A commerce platform's id for the merchant. Like the domain it is a statement by the caller. It supports `merchantDomain` and never replaces it: sent without a website it establishes no merchant, and one that is not on record, or is on record for a different merchant than the website, leaves the merchant not established. | | `id` required | `string` | The id, exactly as the provider writes it. No spaces. | | `provider` required | `string` | The platform or scheme: a short lowercase name. | | `policyVersionId` required | `UUID` | The active or archived version to evaluate. | | `productReference` | `CommerceReference \| null` | An id for the product: a platform's, or a scheme such as `sku` or `gtin`. Resolved within the merchant, so it means nothing until the merchant is recognised. | | `id` required | `string` | The id, exactly as the provider writes it. No spaces. | | `provider` required | `string` | The platform or scheme: a short lowercase name. | | `reason` required | `string` | Why the agent wants this purchase. Every purchase must say. | | `taxIncluded` | `boolean` | Required with `items`: whether the item prices already include tax. When `true`, tax is not stated again. | #### Responses | Status | Meaning | | --- | --- | | `200` | What that version would have decided. | | `400` | `invalid_request` `malformed_json` | | `401` | `unauthorized` `invalid_api_key` `api_key_revoked` `api_key_expired` `wrong_environment` `invalid_token` | | `403` | `connection_unauthorized` `organization_suspended` `api_key_not_organization_scoped` `oauth_not_permitted` | | `404` | `not_found` | | `413` | `payload_too_large` | | `415` | `unsupported_media_type` | | `429` | `rate_limited` | | `500` | `internal_error` | #### Response body | Field | Type | Description | | --- | --- | --- | | `agent` required | `object \| null` | | | `id` | `UUID` | | | `name` | `string` | | | `status` | `ENABLED \| DISABLED \| ARCHIVED` | | | `budgets` required | `SimulatedBudget[]` | | | `aggregate` | `SUM_AMOUNT \| COUNT` | SUM_AMOUNT: the figures are minor units. COUNT: a number of purchases. | | `currency` | `Currency \| null` | | | `current` | `object` | | | `committedMinor` | `MinorAmount` | | | `heldMinor` | `MinorAmount` | | | `reservedMinor` | `MinorAmount` | | | `limits` | `object[]` | | | `effect` | `BLOCK \| REQUIRE_APPROVAL \| FLAG` | | | `limitMinor` | `MinorAmount` | | | `policyId` | `UUID` | | | `policyName` | `string` | | | `policyRuleId` | `UUID \| null` | | | `policyVersionId` | `UUID` | | | `remainingAfterMinor` | `MinorAmount` | Negative reads as "over by". | | `remainingBeforeMinor` | `MinorAmount` | | | `ruleKey` | `string \| null` | | | `wouldExceed` | `boolean` | | | `periodEnd` | `Timestamp` | | | `periodKey` | `string` | | | `periodKind` | `string` | DAY, WEEK, MONTH, QUARTER or YEAR for a calendar window; ROLLING otherwise. | | `periodStart` | `Timestamp` | | | `rollingSeconds` | `integer \| null` | | | `scope` | `string` | Whose purchases the window holds: ORGANIZATION, POLICY:, AGENT: or PRINCIPAL:. | | `simulated` | `object` | | | `projectedMinor` | `MinorAmount` | | | `requestedMinor` | `MinorAmount` | | | `timeZone` | `string \| null` | | | `wouldExceed` | `boolean` | | | `findings` required | `Finding[]` | | | `currency` | `Currency \| null` | | | `headroomMinor` | `MinorAmount \| null` | | | `limitMinor` | `MinorAmount \| null` | | | `measured` | `MeasuredWindow \| null` | | | `aggregate` required | `SUM_AMOUNT \| COUNT` | SUM_AMOUNT: a sum of money. COUNT: a number of purchases. | | `scope` required | `string` | Whose purchases the window holds: POLICY (the agents the policy binds), ORGANIZATION, EACH_AGENT, AGENT:, EACH_PRINCIPAL or PRINCIPAL:. | | `window` required | `object` | | | `message` required | `string` | | | `observedMinor` | `MinorAmount \| null` | | | `outcome` required | `PASS \| NOT_APPLICABLE \| FLAG \| REQUIRE_APPROVAL \| BLOCK \| AUTHORIZED` | PASS: the purchase came within the rule. AUTHORIZED: the rule asked for approval and a person gave it. FLAG: recorded, and changes nothing. A rule that only flags is the organisation's own record and is not returned by this API. | | `policyId` required | `UUID` | | | `policyName` required | `string` | | | `policyRuleId` | `UUID \| null` | | | `policyVersionId` required | `UUID` | | | `policyVersionNumber` required | `integer` | | | `reasonCode` required | `ReasonCode` | | | `role` required | `BINDING \| SATISFIED \| NOT_APPLICABLE` | How the finding stands against the decision. | | `rule` required | `string` | The rule's name, or its key when it has none. | | `ruleKey` | `string \| null` | | | `unit` | `MONEY \| COUNT \| null` | | | `policiesConsidered` required | `object[]` | Every candidate policy version. One whose participation is NOT_APPLICABLE governs the agent but is not about this purchase: it changed nothing, and omitting it would make a NO_APPLICABLE_POLICY answer impossible to act on. | | `applicabilityReason` | `string` | | | `participation` | `PARTICIPATING \| NOT_APPLICABLE` | | | `policyId` | `UUID` | | | `policyName` | `string` | | | `policyStatus` | `ACTIVE \| DISABLED \| ARCHIVED` | | | `policyVersionId` | `UUID` | | | `versionNumber` | `integer` | | | `versionStatus` | `DRAFT \| ACTIVE \| ARCHIVED` | | | `purchase` required | `object` | | | `amountMinor` | `MinorAmount` | | | `category` | `string \| null` | | | `currency` | `Currency` | | | `currencyExponent` | `integer` | | | `maximumMinor` | `MinorAmount \| null` | The most the purchase can cost, with every `VARIABLE` charge at its maximum: what rules and budgets judged. `amountMinor` is the known amount. Null when no charge can still vary. | | `merchant` | `string` | | | `merchantDomain` | `string \| null` | | | `merchantKey` | `string` | | | `simulatedDecision` required | `object` | | | `advance` required | `COMPLETE \| PARTIAL \| NOT_DECIDED` | How much of the decision this answer is. COMPLETE: every rule was checked. PARTIAL: some rules are checked on request, and the others already refuse, so the refusal stands. NOT_DECIDED: some rules are checked on request and no outcome is given. | | `authority` | `WITHIN_POLICY \| AUTHORIZED \| null` | | | `deferredRules` required | `object[]` | The rules checked when a purchase is requested, not in advance. Empty when advance is COMPLETE. | | `policyId` required | `string` | | | `policyName` required | `string` | | | `rule` required | `string` | | | `ruleKey` required | `string` | | | `explanation` required | `string` | | | `outcome` required | `APPROVED \| REJECTED \| REQUIRES_APPROVAL \| NOT_DECIDED_IN_ADVANCE` | What a purchase request would be decided as. NOT_DECIDED_IN_ADVANCE when some rules are checked only when a purchase is requested and the others do not already refuse: request the purchase to find out. | | `reasonCodes` required | `ReasonCode[]` | | | `wouldRequireApproval` required | `boolean` | False when the outcome is NOT_DECIDED_IN_ADVANCE: it is not known. | | `simulation` required | `object` | | | `disclaimer` required | `string` | | | `engineVersion` required | `string` | | | `isSimulation` required | `true` | Always true. The field exists so its absence is a signal. | | `mode` required | `string` | | | `simulatedAt` required | `Timestamp` | | ## Policies Read the spending rules in force. Policies are authored by a person in the dashboard, never by an API credential. ### List spending policies GET `/v1/policies` Organisation-scoped API key No OAuth caller admitted Your organisation's spending policies, newest first. Organization-scoped API keys only. An agent may not read the policies it is judged against — it learns what it may spend by asking `POST /v1/simulations/current-policies` instead. No OAuth scope reaches this. Credential: An organization-scoped API key. No OAuth scope reaches this operation. Rate limit: 600 per minute per credential. #### Parameters | Name | In | Description | | --- | --- | --- | | `status` | query | Return only policies in this state. | | `search` | query | Free-text match on the policy name. Trimmed, capped at 200 characters, and treated as absent when empty. | | `page` | query | 1-based page number. A page beyond the end returns an empty page rather than a 404, because a filtered set shrinking under a paging UI is normal. A page above 10000 is refused with `invalid_request`. | | `pageSize` | query | Rows per page. Clamped to 100 rather than refused: a caller asking for everything gets the maximum. A malformed value is refused, because it means the client believes something untrue. | #### Responses | Status | Meaning | | --- | --- | | `200` | A page of policies, and where it sits in the collection. | | `400` | `invalid_request` | | `401` | `unauthorized` `invalid_api_key` `api_key_revoked` `api_key_expired` `wrong_environment` `invalid_token` | | `403` | `connection_unauthorized` `organization_suspended` `api_key_not_organization_scoped` `oauth_not_permitted` | | `429` | `rate_limited` | | `500` | `internal_error` | #### Response body | Field | Type | Description | | --- | --- | --- | | `pagination` required | `Pagination` | | | `page` required | `integer` | | | `pageSize` required | `integer` | | | `total` required | `integer` | | | `totalPages` required | `integer` | At least 1 even when nothing matched, so a client can render "1 of 1" for an empty result and never divide by zero. | | `policies` required | `PolicySummary[]` | | | `activeVersion` required | `PolicyVersionSummary \| null` | | | `activatedAt` | `Timestamp \| null` | | | `archivedAt` | `Timestamp \| null` | | | `createdAt` required | `Timestamp` | | | `id` required | `UUID` | | | `languageVersion` required | `integer` | The version of the policy language this content is written in. | | `ruleCount` required | `integer` | How many rules this version carries, without fetching them. | | `status` required | `DRAFT \| ACTIVE \| ARCHIVED` | | | `versionNumber` required | `integer` | Allocated by Veriticity. Never chosen by a caller. | | `archivedAt` | `Timestamp \| null` | | | `boundToOrganization` required | `boolean` | Whether the policy is bound to the whole organization, and so governs every agent in it. | | `createdAt` required | `Timestamp` | | | `description` | `string \| null` | | | `id` required | `UUID` | | | `inForce` required | `boolean` | Whether the Trust Engine is actually reading this policy right now. "Has a live version" and "is being enforced" are different facts: a DISABLED policy keeps its ACTIVE version but the engine skips it. | | `name` required | `string` | | | `status` required | `ACTIVE \| DISABLED \| ARCHIVED` | | | `updatedAt` required | `Timestamp` | | ### Read one policy GET `/v1/policies/{policyId}` Organisation-scoped API key No OAuth caller admitted One policy with its version history and the agents it governs. Organization-scoped API keys only. An agent may not read the policies it is judged against — it learns what it may spend by asking `POST /v1/simulations/current-policies` instead. No OAuth scope reaches this. Credential: An organization-scoped API key. No OAuth scope reaches this operation. Rate limit: 600 per minute per credential. #### Parameters | Name | In | Description | | --- | --- | --- | | `policyId` required | path | The policy to read. | #### Responses | Status | Meaning | | --- | --- | | `200` | The policy. | | `401` | `unauthorized` `invalid_api_key` `api_key_revoked` `api_key_expired` `wrong_environment` `invalid_token` | | `403` | `connection_unauthorized` `organization_suspended` `api_key_not_organization_scoped` `oauth_not_permitted` | | `404` | `not_found` | | `429` | `rate_limited` | | `500` | `internal_error` | #### Response body | Field | Type | Description | | --- | --- | --- | | `policy` required | `PolicySummary` | | | `activeVersion` required | `PolicyVersionSummary \| null` | | | `activatedAt` | `Timestamp \| null` | | | `archivedAt` | `Timestamp \| null` | | | `createdAt` required | `Timestamp` | | | `id` required | `UUID` | | | `languageVersion` required | `integer` | The version of the policy language this content is written in. | | `ruleCount` required | `integer` | How many rules this version carries, without fetching them. | | `status` required | `DRAFT \| ACTIVE \| ARCHIVED` | | | `versionNumber` required | `integer` | Allocated by Veriticity. Never chosen by a caller. | | `archivedAt` | `Timestamp \| null` | | | `boundToOrganization` required | `boolean` | Whether the policy is bound to the whole organization, and so governs every agent in it. | | `createdAt` required | `Timestamp` | | | `description` | `string \| null` | | | `id` required | `UUID` | | | `inForce` required | `boolean` | Whether the Trust Engine is actually reading this policy right now. "Has a live version" and "is being enforced" are different facts: a DISABLED policy keeps its ACTIVE version but the engine skips it. | | `name` required | `string` | | | `status` required | `ACTIVE \| DISABLED \| ARCHIVED` | | | `updatedAt` required | `Timestamp` | | | `bindings` | `PolicyBindings` | | | `agentIds` | `UUID[]` | | | `organization` | `boolean` | Bound to the whole organization: it governs every agent. | | `versions` | `PolicyVersionSummary[]` | | | `activatedAt` | `Timestamp \| null` | | | `archivedAt` | `Timestamp \| null` | | | `createdAt` required | `Timestamp` | | | `id` required | `UUID` | | | `languageVersion` required | `integer` | The version of the policy language this content is written in. | | `ruleCount` required | `integer` | How many rules this version carries, without fetching them. | | `status` required | `DRAFT \| ACTIVE \| ARCHIVED` | | | `versionNumber` required | `integer` | Allocated by Veriticity. Never chosen by a caller. | ### Read one policy version GET `/v1/policies/{policyId}/versions/{versionId}` Organisation-scoped API key No OAuth caller admitted A policy version in full, exactly as the Trust Engine reads it: what it is about, its rules, the conditions they name and its value sets. Organization-scoped API keys only. An agent may not read the policies it is judged against — it learns what it may spend by asking `POST /v1/simulations/current-policies` instead. No OAuth scope reaches this. Credential: An organization-scoped API key. No OAuth scope reaches this operation. Rate limit: 600 per minute per credential. #### Parameters | Name | In | Description | | --- | --- | --- | | `policyId` required | path | The policy the version belongs to. | | `versionId` required | path | The version to read. | #### Responses | Status | Meaning | | --- | --- | | `200` | The version. | | `401` | `unauthorized` `invalid_api_key` `api_key_revoked` `api_key_expired` `wrong_environment` `invalid_token` | | `403` | `connection_unauthorized` `organization_suspended` `api_key_not_organization_scoped` `oauth_not_permitted` | | `404` | `not_found` | | `429` | `rate_limited` | | `500` | `internal_error` | #### Response body | Field | Type | Description | | --- | --- | --- | | `version` required | `PolicyVersion` | | | `activatedAt` | `Timestamp \| null` | | | `archivedAt` | `Timestamp \| null` | | | `createdAt` required | `Timestamp` | | | `id` required | `UUID` | | | `languageVersion` required | `integer` | The version of the policy language this content is written in. | | `ruleCount` required | `integer` | How many rules this version carries, without fetching them. | | `status` required | `DRAFT \| ACTIVE \| ARCHIVED` | | | `versionNumber` required | `integer` | Allocated by Veriticity. Never chosen by a caller. | | `appliesWhen` | `PolicyExpression \| null` | | | `appliesWhenConditions` required | `PolicyCondition[]` | | | `accept` | `object \| null` | | | `atom` required | `string` | The key the expression's `atom` leaves refer to it by. | | `condition` required | `string` | The condition's type. AMOUNT_LIMIT: the amount is at or under `config.max`. AMOUNT_MINIMUM: the amount is at or over `config.min`. SPEND_LIMIT: spend in `config.window`, including this purchase, is at or under `config.max`. PURCHASE_COUNT_LIMIT: the purchases in `config.window`, including this one, number at most `config.maxPurchases`. MERCHANT_IN_SET: earlier versions only, and never satisfied, since a name in the value set `config.set` identifies no merchant. CURRENCY_IN: the currency is one of `config.currencies`. MERCHANT_IDENTIFIED: the merchant is one the organisation has on record. MERCHANT_VERIFIED: it is, and it is currently verified. MERCHANT_IS: it is one of `config.merchants`. MERCHANT_COUNTRY_IN: its country is one of `config.countries`. LEGAL_ENTITY_JURISDICTION_IN: the company behind it is registered in one of `config.countries`. PRODUCT_IDENTIFIED: the product is one the organisation has on record for that merchant. PRODUCT_CATEGORY_IN: the product's category is one of `config.categories`, or inside one. PRODUCT_ATTRIBUTE_IN: the product's `config.attribute` is one of `config.values`. ATTEMPT_COUNT_LIMIT: the purchase attempts in `config.window` number at most `config.maxAttempts`. DISTINCT_MERCHANT_LIMIT: the purchases in `config.window`, including this one, are with at most `config.maxMerchants` different merchants. DISTINCT_CATEGORY_LIMIT: they are in at most `config.maxCategories` different categories. PURCHASE_HISTORY_MINIMUM: at least `config.minPurchases` purchases were approved in `config.window` before this one. AMOUNT_DEVIATION_LIMIT: the amount is at or under `config.maxPercent` per cent of the usual amount over `config.window`. | | `config` | `object` | The condition's own configuration. Its shape belongs to the condition type. `max` and `min` are `{ amountMinor, currency }`. A monetary limit compares only amounts in its own currency: there is no conversion, and a purchase in another currency cannot be shown to be within it. `window` (SPEND_LIMIT, PURCHASE_COUNT_LIMIT) is `DAY`, `WEEK`, `MONTH`, `QUARTER` or `YEAR` for a period of the organisation's calendar in its timezone; `{ "kind": "CALENDAR", "unit": "WEEK", "weekStartsOn": "SUNDAY" }` for a week that does not start on Monday; or `{ "kind": "ROLLING", "seconds": 86400 }` for a length of elapsed time from 60 seconds to 366 days. A rolling window is never a calendar period: 86400 seconds is twenty-four hours, not a day. `scope` (SPEND_LIMIT, PURCHASE_COUNT_LIMIT) is whose purchases the window holds. Omitted, or `{ "kind": "POLICY" }`: the agents the policy binds, together. `ORGANIZATION`: every agent. `EACH_AGENT`: the agent making the purchase. `{ "kind": "AGENT", "agentId" }`: one agent. `EACH_PRINCIPAL`: the person the purchase is made for. `{ "kind": "PRINCIPAL", "userId" }`: one person. A named agent must be one of the organisation's agents and not archived, and a named person one of its members; otherwise whose spending the limit covers cannot be established and the rule's `onUnestablished` applies. A window counts purchases that were approved or are waiting on a person. A refused, expired or cancelled purchase is not counted. `selector` (SPEND_LIMIT, PURCHASE_COUNT_LIMIT) narrows the window to purchases of one kind, and combines with any `scope`. `{ "kind": "MERCHANT", "merchant": "" }`: purchases at that merchant. `{ "kind": "EACH_MERCHANT" }`: purchases at this purchase's merchant. `{ "kind": "CATEGORY", "category": "" }`: purchases in that category or inside it. A purchase is outside a selector only when its merchant or category is on record as another one: a purchase whose merchant is not recognised is tested against the limit and counted in it. `acceptStated: true` lets the caller's own statement of the merchant or category put a purchase outside. Merchants are named by the organisation's key for them and categories by `:`. Every organisation has Veriticity's categories, `veriticity:` for each `category` a purchase may state, as in `veriticity:electronics`. A stated category is the caller's claim: a rule counts it only when its `accept` takes `AGENT_ASSERTED` for `fact.state[product.category_id]`, or a selector or DISTINCT_CATEGORY_LIMIT has `acceptStated: true`. A purchase stating `mixed`, or none, has no established category. A category includes everything inside it. Matching is exact: a merchant is recognised by a domain or reference the organisation has on record for it, never by a similar one and never by its name. ATTEMPT_COUNT_LIMIT, DISTINCT_MERCHANT_LIMIT, DISTINCT_CATEGORY_LIMIT, PURCHASE_HISTORY_MINIMUM and AMOUNT_DEVIATION_LIMIT take `window` and `scope` as above, and `scope` is required. PURCHASE_HISTORY_MINIMUM and AMOUNT_DEVIATION_LIMIT take a rolling `window` only, and no `selector` but `{ "kind": "EACH_MERCHANT" }`. None of the five may be used in a rule's `when`. An attempt is a purchase request that reached a decision: approved, refused or waiting on a person. A request refused before a decision (validation, authentication, a rate limit, the plan's allowance) is not one. A request repeated with the same `Idempotency-Key` is the same attempt; with a new key or none it is a new one. A simulation is not an attempt. `counting` (ATTEMPT_COUNT_LIMIT, required) is `ALL` for every attempt including this one, `REFUSED` for earlier refusals, or `PENDING` for purchases waiting on a person. `level` (DISTINCT_CATEGORY_LIMIT, required) is `EXACT` to count each category separately, so that a category and one inside it are two, or `TOP` to count by top-level category. A purchase whose merchant or category is not recognised could be anywhere: the rule is met only if the limit holds wherever it was, and otherwise its `onUnestablished` applies. A merchant the caller named is not a recognised one unless `acceptStated` is true. `baseline` (AMOUNT_DEVIATION_LIMIT) is `MEAN` or `MAX` of the purchases approved in the window before this one, in `currency`. `maxPercent` is a whole number from 100. `minSample` is required, and is 5 or more: with fewer approved purchases there is no usual amount, and the rule's `onUnestablished` applies. PURCHASE_HISTORY_MINIMUM is not met, rather than unestablished, when there are too few. Purchases that were refused or are waiting on a person are never part of either. A credential acting as an agent is not shown the configuration of these five conditions or the figures of their findings. | | `configVersion` | `integer` | | | `rules` required | `PolicyRule[]` | | | `conditions` required | `PolicyCondition[]` | | | `effect` required | `BLOCK \| REQUIRE_APPROVAL \| FLAG` | What happens when `require` is violated. BLOCK refuses. REQUIRE_APPROVAL holds the budget and waits for a person. FLAG records the finding and changes nothing. | | `id` | `UUID` | | | `key` required | `string` | The rule's identity across versions of its policy. Keep it when editing a rule: an approval is bound to it. | | `name` | `string \| null` | | | `onUnestablished` | `BLOCK \| REQUIRE_APPROVAL \| FLAG` | What happens when `require` cannot be shown either way, for example because it rests on a merchant nobody has verified. Defaults to `effect`. FLAG exactly when `effect` is FLAG. | | `require` required | `PolicyExpression` | What must be established for the purchase to proceed. | | `when` | `PolicyExpression \| null` | | | `valueSets` required | `PolicyValueSet[]` | | | `key` required | `string` | | | `kind` required | `string` | | | `members` required | `object[]` | | | `policyId` required | `UUID` | | ### Check whether a version could be activated POST `/v1/policies/{policyId}/versions/{versionId}/validate` Organisation-scoped API key No OAuth caller admitted Runs activation's own check as a dry run, and changes nothing. Answers 200 even when the draft is unacceptable — `valid` carries the verdict and `issues` carries the reasons. A 4xx would mean the question could not be answered, and it was. `budgetImpact` is empty in the ordinary case. A non-empty one means activating this version would start a limit from a total that already includes spending nobody reserved against. This is a `POST` that writes nothing, and it is the only policy operation on `/v1` that is not a plain read. Organization-scoped API keys only. An agent may not read the policies it is judged against — it learns what it may spend by asking `POST /v1/simulations/current-policies` instead. No OAuth scope reaches this. Credential: An organization-scoped API key. No OAuth scope reaches this operation. Rate limit: 600 per minute per credential. #### Parameters | Name | In | Description | | --- | --- | --- | | `policyId` required | path | The policy the version belongs to. | | `versionId` required | path | The version to check. | #### Responses | Status | Meaning | | --- | --- | | `200` | The verdict. `valid` is false when the version could not be activated; this is still a 200. | | `401` | `unauthorized` `invalid_api_key` `api_key_revoked` `api_key_expired` `wrong_environment` `invalid_token` | | `403` | `connection_unauthorized` `organization_suspended` `api_key_not_organization_scoped` `oauth_not_permitted` | | `404` | `not_found` | | `429` | `rate_limited` | | `500` | `internal_error` | #### Response body | Field | Type | Description | | --- | --- | --- | | `budgetImpact` required | `object[]` | | | `currency` | `Currency` | | | `limits` | `object[]` | | | `effect` | `string` | | | `limitMinor` | `MinorAmount` | | | `remainingMinor` | `MinorAmount` | | | `ruleKey` | `string` | The key of the rule that sets this limit. | | `periodKey` | `string` | | | `periodKind` | `string` | | | `spentMinor` | `MinorAmount` | | | `issues` required | `PolicyIssue[]` | | | `code` required | `string` | | | `message` required | `string` | | | `path` | `string` | | | `valid` required | `boolean` | | | `version` required | `PolicyVersion` | | | `activatedAt` | `Timestamp \| null` | | | `archivedAt` | `Timestamp \| null` | | | `createdAt` required | `Timestamp` | | | `id` required | `UUID` | | | `languageVersion` required | `integer` | The version of the policy language this content is written in. | | `ruleCount` required | `integer` | How many rules this version carries, without fetching them. | | `status` required | `DRAFT \| ACTIVE \| ARCHIVED` | | | `versionNumber` required | `integer` | Allocated by Veriticity. Never chosen by a caller. | | `appliesWhen` | `PolicyExpression \| null` | | | `appliesWhenConditions` required | `PolicyCondition[]` | | | `accept` | `object \| null` | | | `atom` required | `string` | The key the expression's `atom` leaves refer to it by. | | `condition` required | `string` | The condition's type. AMOUNT_LIMIT: the amount is at or under `config.max`. AMOUNT_MINIMUM: the amount is at or over `config.min`. SPEND_LIMIT: spend in `config.window`, including this purchase, is at or under `config.max`. PURCHASE_COUNT_LIMIT: the purchases in `config.window`, including this one, number at most `config.maxPurchases`. MERCHANT_IN_SET: earlier versions only, and never satisfied, since a name in the value set `config.set` identifies no merchant. CURRENCY_IN: the currency is one of `config.currencies`. MERCHANT_IDENTIFIED: the merchant is one the organisation has on record. MERCHANT_VERIFIED: it is, and it is currently verified. MERCHANT_IS: it is one of `config.merchants`. MERCHANT_COUNTRY_IN: its country is one of `config.countries`. LEGAL_ENTITY_JURISDICTION_IN: the company behind it is registered in one of `config.countries`. PRODUCT_IDENTIFIED: the product is one the organisation has on record for that merchant. PRODUCT_CATEGORY_IN: the product's category is one of `config.categories`, or inside one. PRODUCT_ATTRIBUTE_IN: the product's `config.attribute` is one of `config.values`. ATTEMPT_COUNT_LIMIT: the purchase attempts in `config.window` number at most `config.maxAttempts`. DISTINCT_MERCHANT_LIMIT: the purchases in `config.window`, including this one, are with at most `config.maxMerchants` different merchants. DISTINCT_CATEGORY_LIMIT: they are in at most `config.maxCategories` different categories. PURCHASE_HISTORY_MINIMUM: at least `config.minPurchases` purchases were approved in `config.window` before this one. AMOUNT_DEVIATION_LIMIT: the amount is at or under `config.maxPercent` per cent of the usual amount over `config.window`. | | `config` | `object` | The condition's own configuration. Its shape belongs to the condition type. `max` and `min` are `{ amountMinor, currency }`. A monetary limit compares only amounts in its own currency: there is no conversion, and a purchase in another currency cannot be shown to be within it. `window` (SPEND_LIMIT, PURCHASE_COUNT_LIMIT) is `DAY`, `WEEK`, `MONTH`, `QUARTER` or `YEAR` for a period of the organisation's calendar in its timezone; `{ "kind": "CALENDAR", "unit": "WEEK", "weekStartsOn": "SUNDAY" }` for a week that does not start on Monday; or `{ "kind": "ROLLING", "seconds": 86400 }` for a length of elapsed time from 60 seconds to 366 days. A rolling window is never a calendar period: 86400 seconds is twenty-four hours, not a day. `scope` (SPEND_LIMIT, PURCHASE_COUNT_LIMIT) is whose purchases the window holds. Omitted, or `{ "kind": "POLICY" }`: the agents the policy binds, together. `ORGANIZATION`: every agent. `EACH_AGENT`: the agent making the purchase. `{ "kind": "AGENT", "agentId" }`: one agent. `EACH_PRINCIPAL`: the person the purchase is made for. `{ "kind": "PRINCIPAL", "userId" }`: one person. A named agent must be one of the organisation's agents and not archived, and a named person one of its members; otherwise whose spending the limit covers cannot be established and the rule's `onUnestablished` applies. A window counts purchases that were approved or are waiting on a person. A refused, expired or cancelled purchase is not counted. `selector` (SPEND_LIMIT, PURCHASE_COUNT_LIMIT) narrows the window to purchases of one kind, and combines with any `scope`. `{ "kind": "MERCHANT", "merchant": "" }`: purchases at that merchant. `{ "kind": "EACH_MERCHANT" }`: purchases at this purchase's merchant. `{ "kind": "CATEGORY", "category": "" }`: purchases in that category or inside it. A purchase is outside a selector only when its merchant or category is on record as another one: a purchase whose merchant is not recognised is tested against the limit and counted in it. `acceptStated: true` lets the caller's own statement of the merchant or category put a purchase outside. Merchants are named by the organisation's key for them and categories by `:`. Every organisation has Veriticity's categories, `veriticity:` for each `category` a purchase may state, as in `veriticity:electronics`. A stated category is the caller's claim: a rule counts it only when its `accept` takes `AGENT_ASSERTED` for `fact.state[product.category_id]`, or a selector or DISTINCT_CATEGORY_LIMIT has `acceptStated: true`. A purchase stating `mixed`, or none, has no established category. A category includes everything inside it. Matching is exact: a merchant is recognised by a domain or reference the organisation has on record for it, never by a similar one and never by its name. ATTEMPT_COUNT_LIMIT, DISTINCT_MERCHANT_LIMIT, DISTINCT_CATEGORY_LIMIT, PURCHASE_HISTORY_MINIMUM and AMOUNT_DEVIATION_LIMIT take `window` and `scope` as above, and `scope` is required. PURCHASE_HISTORY_MINIMUM and AMOUNT_DEVIATION_LIMIT take a rolling `window` only, and no `selector` but `{ "kind": "EACH_MERCHANT" }`. None of the five may be used in a rule's `when`. An attempt is a purchase request that reached a decision: approved, refused or waiting on a person. A request refused before a decision (validation, authentication, a rate limit, the plan's allowance) is not one. A request repeated with the same `Idempotency-Key` is the same attempt; with a new key or none it is a new one. A simulation is not an attempt. `counting` (ATTEMPT_COUNT_LIMIT, required) is `ALL` for every attempt including this one, `REFUSED` for earlier refusals, or `PENDING` for purchases waiting on a person. `level` (DISTINCT_CATEGORY_LIMIT, required) is `EXACT` to count each category separately, so that a category and one inside it are two, or `TOP` to count by top-level category. A purchase whose merchant or category is not recognised could be anywhere: the rule is met only if the limit holds wherever it was, and otherwise its `onUnestablished` applies. A merchant the caller named is not a recognised one unless `acceptStated` is true. `baseline` (AMOUNT_DEVIATION_LIMIT) is `MEAN` or `MAX` of the purchases approved in the window before this one, in `currency`. `maxPercent` is a whole number from 100. `minSample` is required, and is 5 or more: with fewer approved purchases there is no usual amount, and the rule's `onUnestablished` applies. PURCHASE_HISTORY_MINIMUM is not met, rather than unestablished, when there are too few. Purchases that were refused or are waiting on a person are never part of either. A credential acting as an agent is not shown the configuration of these five conditions or the figures of their findings. | | `configVersion` | `integer` | | | `rules` required | `PolicyRule[]` | | | `conditions` required | `PolicyCondition[]` | | | `effect` required | `BLOCK \| REQUIRE_APPROVAL \| FLAG` | What happens when `require` is violated. BLOCK refuses. REQUIRE_APPROVAL holds the budget and waits for a person. FLAG records the finding and changes nothing. | | `id` | `UUID` | | | `key` required | `string` | The rule's identity across versions of its policy. Keep it when editing a rule: an approval is bound to it. | | `name` | `string \| null` | | | `onUnestablished` | `BLOCK \| REQUIRE_APPROVAL \| FLAG` | What happens when `require` cannot be shown either way, for example because it rests on a merchant nobody has verified. Defaults to `effect`. FLAG exactly when `effect` is FLAG. | | `require` required | `PolicyExpression` | What must be established for the purchase to proceed. | | `when` | `PolicyExpression \| null` | | | `valueSets` required | `PolicyValueSet[]` | | | `key` required | `string` | | | `kind` required | `string` | | | `members` required | `object[]` | | | `policyId` required | `UUID` | | | `warnings` required | `PolicyIssue[]` | | | `code` required | `string` | | | `message` required | `string` | | | `path` | `string` | | ## Agents The identities that spend. ### Read the agent this credential acts as GET `/v1/agent` API key bound to an agent OAuth: `purchase:check` The agent bound to the calling credential, the currency and timezone its organisation operates in, and which environment answered. Takes no agent id: a credential acts as exactly one agent and cannot ask about another. Credential: An API key bound to an agent. OAuth connections need `purchase:check`. Rate limit: 600 per minute per credential. #### Responses | Status | Meaning | | --- | --- | | `200` | The agent, and its organisation's defaults. | | `401` | `unauthorized` `invalid_api_key` `api_key_revoked` `api_key_expired` `wrong_environment` `invalid_token` | | `402` | `feature_not_included` | | `403` | `connection_unauthorized` `organization_suspended` `api_key_not_bound_to_agent` `insufficient_scope` | | `404` | `not_found` | | `429` | `rate_limited` | | `500` | `internal_error` | #### Response body | Field | Type | Description | | --- | --- | --- | | `agent` required | `object` | | | `description` | `string \| null` | | | `id` required | `UUID` | | | `name` required | `string` | | | `status` required | `ENABLED \| DISABLED \| ARCHIVED` | | | `environment` required | `live \| sandbox` | Which Veriticity environment answered. Sandbox agents, policies, budgets, purchases and approvals are separate from Live, and nothing decided in Sandbox authorises a real purchase. | | `organization` required | `object` | | | `currency` required | `Currency` | | | `timeZone` required | `string` | IANA identifier. Budget windows are measured in it. | ### List this organisation's agents GET `/v1/agents` Organisation-scoped API key No OAuth caller admitted The agents in your organisation, with their status. Organization-scoped API keys only. An agent-bound credential is refused: a roster of an organisation's other agents is a map of the identities a compromised agent might try to impersonate. Read-only. Agents are created in the dashboard. Credential: An organization-scoped API key. No OAuth scope reaches this operation. Rate limit: 600 per minute per credential. #### Responses | Status | Meaning | | --- | --- | | `200` | Every agent in the organisation. | | `401` | `unauthorized` `invalid_api_key` `api_key_revoked` `api_key_expired` `wrong_environment` `invalid_token` | | `403` | `connection_unauthorized` `organization_suspended` `api_key_not_organization_scoped` `oauth_not_permitted` | | `429` | `rate_limited` | | `500` | `internal_error` | #### Response body | Field | Type | Description | | --- | --- | --- | | `agents` required | `object[]` | | | `createdAt` required | `Timestamp` | | | `description` | `string \| null` | | | `id` required | `UUID` | | | `name` required | `string` | | | `status` required | `ENABLED \| DISABLED \| ARCHIVED` | | | `updatedAt` required | `Timestamp` | | ### Read how much budget this agent has left GET `/v1/budget-status` API key bound to an agent OAuth: `purchase:check` Each daily and monthly budget window that applies to the calling agent, with what has been spent, what is held for purchases awaiting approval, and what remains under each limit. Changes nothing. `remainingMinor` can be negative: a limit lowered below what a window already holds reads as over by that amount. Credential: An API key bound to an agent. OAuth connections need `purchase:check`. Rate limit: 600 per minute per credential. #### Parameters | Name | In | Description | | --- | --- | --- | | `currency` | query | ISO 4217 code to answer in. Omit for the organisation's default currency. | #### Responses | Status | Meaning | | --- | --- | | `200` | The budget windows in force. | | `400` | `invalid_request` | | `401` | `unauthorized` `invalid_api_key` `api_key_revoked` `api_key_expired` `wrong_environment` `invalid_token` | | `402` | `feature_not_included` | | `403` | `connection_unauthorized` `organization_suspended` `api_key_not_bound_to_agent` `insufficient_scope` | | `404` | `not_found` | | `429` | `rate_limited` | | `500` | `internal_error` | #### Response body | Field | Type | Description | | --- | --- | --- | | `asOf` required | `Timestamp` | | | `currency` required | `Currency` | | | `windows` required | `object[]` | | | `committedMinor` required | `MinorAmount` | | | `currency` required | `Currency` | | | `heldMinor` required | `MinorAmount` | | | `limits` required | `object[]` | | | `effect` required | `BLOCK \| REQUIRE_APPROVAL` | | | `limitMinor` required | `MinorAmount` | | | `remainingMinor` required | `string` | Minor units. Negative when the window is over the limit. | | `periodEnd` required | `Timestamp` | | | `periodKey` required | `string` | Organisation-local: a date for a day, a week's first date, then `2026-10`, `2026-Q4` or `2026`. Empty for ROLLING. | | `periodKind` required | `DAY \| WEEK \| MONTH \| QUARTER \| YEAR \| ROLLING` | A period of the organisation's calendar, or ROLLING for a length of elapsed time ending now. | | `periodStart` required | `Timestamp` | | | `policyName` required | `string` | | | `reservedMinor` required | `MinorAmount` | | | `scope` | `string` | Whose spend the window holds: ORGANIZATION, POLICY:, AGENT: or PRINCIPAL:. | ### Read the rules in force for this agent GET `/v1/spending-rules` API key bound to an agent OAuth: `purchase:check` The policies that currently govern the calling agent, with their rules. Changes nothing. Each rule says what must be established for a purchase to proceed (`require`), when it applies (`when`), and what happens otherwise (`effect`). `participation` says whether a policy is about a purchase in the currency asked for. `valueSets` says which lists a policy's rules name and how long each is. The names on them are not returned. A merchant is identified by its website and never by its name, so a list of typed names allows no purchase. Credential: An API key bound to an agent. OAuth connections need `purchase:check`. Rate limit: 600 per minute per credential. #### Parameters | Name | In | Description | | --- | --- | --- | | `currency` | query | ISO 4217 code to answer in. Omit for the organisation's default currency. | #### Responses | Status | Meaning | | --- | --- | | `200` | The policies that apply, in the currency asked for. | | `400` | `invalid_request` | | `401` | `unauthorized` `invalid_api_key` `api_key_revoked` `api_key_expired` `wrong_environment` `invalid_token` | | `402` | `feature_not_included` | | `403` | `connection_unauthorized` `organization_suspended` `api_key_not_bound_to_agent` `insufficient_scope` | | `404` | `not_found` | | `429` | `rate_limited` | | `500` | `internal_error` | #### Response body | Field | Type | Description | | --- | --- | --- | | `currency` required | `Currency` | | | `policies` required | `object[]` | | | `applicabilityReason` required | `string` | | | `appliesWhen` | `objectnull` | | | `appliesWhenConditions` | `PolicyCondition[]` | | | `accept` | `object \| null` | | | `atom` required | `string` | The key the expression's `atom` leaves refer to it by. | | `condition` required | `string` | The condition's type. AMOUNT_LIMIT: the amount is at or under `config.max`. AMOUNT_MINIMUM: the amount is at or over `config.min`. SPEND_LIMIT: spend in `config.window`, including this purchase, is at or under `config.max`. PURCHASE_COUNT_LIMIT: the purchases in `config.window`, including this one, number at most `config.maxPurchases`. MERCHANT_IN_SET: earlier versions only, and never satisfied, since a name in the value set `config.set` identifies no merchant. CURRENCY_IN: the currency is one of `config.currencies`. MERCHANT_IDENTIFIED: the merchant is one the organisation has on record. MERCHANT_VERIFIED: it is, and it is currently verified. MERCHANT_IS: it is one of `config.merchants`. MERCHANT_COUNTRY_IN: its country is one of `config.countries`. LEGAL_ENTITY_JURISDICTION_IN: the company behind it is registered in one of `config.countries`. PRODUCT_IDENTIFIED: the product is one the organisation has on record for that merchant. PRODUCT_CATEGORY_IN: the product's category is one of `config.categories`, or inside one. PRODUCT_ATTRIBUTE_IN: the product's `config.attribute` is one of `config.values`. ATTEMPT_COUNT_LIMIT: the purchase attempts in `config.window` number at most `config.maxAttempts`. DISTINCT_MERCHANT_LIMIT: the purchases in `config.window`, including this one, are with at most `config.maxMerchants` different merchants. DISTINCT_CATEGORY_LIMIT: they are in at most `config.maxCategories` different categories. PURCHASE_HISTORY_MINIMUM: at least `config.minPurchases` purchases were approved in `config.window` before this one. AMOUNT_DEVIATION_LIMIT: the amount is at or under `config.maxPercent` per cent of the usual amount over `config.window`. | | `config` | `object` | The condition's own configuration. Its shape belongs to the condition type. `max` and `min` are `{ amountMinor, currency }`. A monetary limit compares only amounts in its own currency: there is no conversion, and a purchase in another currency cannot be shown to be within it. `window` (SPEND_LIMIT, PURCHASE_COUNT_LIMIT) is `DAY`, `WEEK`, `MONTH`, `QUARTER` or `YEAR` for a period of the organisation's calendar in its timezone; `{ "kind": "CALENDAR", "unit": "WEEK", "weekStartsOn": "SUNDAY" }` for a week that does not start on Monday; or `{ "kind": "ROLLING", "seconds": 86400 }` for a length of elapsed time from 60 seconds to 366 days. A rolling window is never a calendar period: 86400 seconds is twenty-four hours, not a day. `scope` (SPEND_LIMIT, PURCHASE_COUNT_LIMIT) is whose purchases the window holds. Omitted, or `{ "kind": "POLICY" }`: the agents the policy binds, together. `ORGANIZATION`: every agent. `EACH_AGENT`: the agent making the purchase. `{ "kind": "AGENT", "agentId" }`: one agent. `EACH_PRINCIPAL`: the person the purchase is made for. `{ "kind": "PRINCIPAL", "userId" }`: one person. A named agent must be one of the organisation's agents and not archived, and a named person one of its members; otherwise whose spending the limit covers cannot be established and the rule's `onUnestablished` applies. A window counts purchases that were approved or are waiting on a person. A refused, expired or cancelled purchase is not counted. `selector` (SPEND_LIMIT, PURCHASE_COUNT_LIMIT) narrows the window to purchases of one kind, and combines with any `scope`. `{ "kind": "MERCHANT", "merchant": "" }`: purchases at that merchant. `{ "kind": "EACH_MERCHANT" }`: purchases at this purchase's merchant. `{ "kind": "CATEGORY", "category": "" }`: purchases in that category or inside it. A purchase is outside a selector only when its merchant or category is on record as another one: a purchase whose merchant is not recognised is tested against the limit and counted in it. `acceptStated: true` lets the caller's own statement of the merchant or category put a purchase outside. Merchants are named by the organisation's key for them and categories by `:`. Every organisation has Veriticity's categories, `veriticity:` for each `category` a purchase may state, as in `veriticity:electronics`. A stated category is the caller's claim: a rule counts it only when its `accept` takes `AGENT_ASSERTED` for `fact.state[product.category_id]`, or a selector or DISTINCT_CATEGORY_LIMIT has `acceptStated: true`. A purchase stating `mixed`, or none, has no established category. A category includes everything inside it. Matching is exact: a merchant is recognised by a domain or reference the organisation has on record for it, never by a similar one and never by its name. ATTEMPT_COUNT_LIMIT, DISTINCT_MERCHANT_LIMIT, DISTINCT_CATEGORY_LIMIT, PURCHASE_HISTORY_MINIMUM and AMOUNT_DEVIATION_LIMIT take `window` and `scope` as above, and `scope` is required. PURCHASE_HISTORY_MINIMUM and AMOUNT_DEVIATION_LIMIT take a rolling `window` only, and no `selector` but `{ "kind": "EACH_MERCHANT" }`. None of the five may be used in a rule's `when`. An attempt is a purchase request that reached a decision: approved, refused or waiting on a person. A request refused before a decision (validation, authentication, a rate limit, the plan's allowance) is not one. A request repeated with the same `Idempotency-Key` is the same attempt; with a new key or none it is a new one. A simulation is not an attempt. `counting` (ATTEMPT_COUNT_LIMIT, required) is `ALL` for every attempt including this one, `REFUSED` for earlier refusals, or `PENDING` for purchases waiting on a person. `level` (DISTINCT_CATEGORY_LIMIT, required) is `EXACT` to count each category separately, so that a category and one inside it are two, or `TOP` to count by top-level category. A purchase whose merchant or category is not recognised could be anywhere: the rule is met only if the limit holds wherever it was, and otherwise its `onUnestablished` applies. A merchant the caller named is not a recognised one unless `acceptStated` is true. `baseline` (AMOUNT_DEVIATION_LIMIT) is `MEAN` or `MAX` of the purchases approved in the window before this one, in `currency`. `maxPercent` is a whole number from 100. `minSample` is required, and is 5 or more: with fewer approved purchases there is no usual amount, and the rule's `onUnestablished` applies. PURCHASE_HISTORY_MINIMUM is not met, rather than unestablished, when there are too few. Purchases that were refused or are waiting on a person are never part of either. A credential acting as an agent is not shown the configuration of these five conditions or the figures of their findings. | | `configVersion` | `integer` | | | `participation` required | `PARTICIPATING \| NOT_APPLICABLE` | NOT_APPLICABLE: the policy governs this agent and is not about a purchase in the currency asked for. | | `policyName` required | `string` | | | `rules` required | `PolicyRule[]` | | | `conditions` required | `PolicyCondition[]` | | | `effect` required | `BLOCK \| REQUIRE_APPROVAL \| FLAG` | What happens when `require` is violated. BLOCK refuses. REQUIRE_APPROVAL holds the budget and waits for a person. FLAG records the finding and changes nothing. | | `id` | `UUID` | | | `key` required | `string` | The rule's identity across versions of its policy. Keep it when editing a rule: an approval is bound to it. | | `name` | `string \| null` | | | `onUnestablished` | `BLOCK \| REQUIRE_APPROVAL \| FLAG` | What happens when `require` cannot be shown either way, for example because it rests on a merchant nobody has verified. Defaults to `effect`. FLAG exactly when `effect` is FLAG. | | `require` required | `PolicyExpression` | What must be established for the purchase to proceed. | | `when` | `PolicyExpression \| null` | | | `valueSets` required | `object[]` | | | `key` required | `string` | | | `kind` required | `string` | | | `memberCount` required | `integer` | | ## Webhooks Receive events when purchases are decided, budgets move, or connections change. ### List webhook endpoints GET `/v1/webhooks` Organisation-scoped API key No OAuth caller admitted Every webhook endpoint on the account, including revoked ones — the delivery history against a revoked endpoint is the answer to "where were our events going in March". Never carries a signing secret. Organization-scoped API keys only. An agent-bound credential must not be able to decide where the whole account's events go, and no OAuth scope reaches this at all — admitting a connection would let an AI assistant point your event stream at a URL of its own choosing. Credential: An organization-scoped API key. No OAuth scope reaches this operation. Rate limit: 600 per minute per credential. #### Responses | Status | Meaning | | --- | --- | | `200` | The account's endpoints. | | `401` | `unauthorized` `invalid_api_key` `api_key_revoked` `api_key_expired` `wrong_environment` `invalid_token` | | `403` | `connection_unauthorized` `organization_suspended` `api_key_not_organization_scoped` `oauth_not_permitted` | | `429` | `rate_limited` | | `500` | `internal_error` | #### Response body | Field | Type | Description | | --- | --- | --- | | `items` required | `WebhookEndpoint[]` | | | `apiVersion` required | `string` | The webhook contract version this endpoint receives. Pinned when the endpoint is created and changed only by PATCH. | | `consecutiveFailures` required | `integer` | Veriticity does not disable a failing endpoint, so this is the number that tells you a receiver has been refusing events. Any success resets it. | | `createdAt` required | `Timestamp` | | | `description` | `string \| null` | | | `disabledAt` | `Timestamp \| null` | | | `id` required | `UUID` | | | `lastFailureAt` | `Timestamp \| null` | | | `lastSuccessAt` | `Timestamp \| null` | | | `revokedAt` | `Timestamp \| null` | | | `status` required | `ACTIVE \| DISABLED \| REVOKED` | | | `subscribedTypes` required | `WebhookEventType[]` | | | `url` required | `string` | | ### Create a webhook endpoint POST `/v1/webhooks` Organisation-scoped API key No OAuth caller admitted Registers a URL to receive events, and returns the signing secret. The secret is shown once. Veriticity cannot show it again — store it before you close the response. If you lose it, rotate. The URL must be HTTPS on port 443, must be a hostname rather than an IP literal, must carry no userinfo, and must resolve to a publicly routable address. This is checked now and again before every delivery. A URL that fails answers `webhook_url_not_permitted`. New endpoints are created on the current webhook API version. Organization-scoped API keys only. An agent-bound credential must not be able to decide where the whole account's events go, and no OAuth scope reaches this at all — admitting a connection would let an AI assistant point your event stream at a URL of its own choosing. Credential: An organization-scoped API key. No OAuth scope reaches this operation. Rate limit: 120 per minute per credential. Request body: at most 16384 bytes. #### Request body | Field | Type | Description | | --- | --- | --- | | `description` | `string \| null` | For your own reference. Never sent anywhere. | | `subscribedTypes` required | `WebhookEventType[]` | Choose at least one event to receive. | | `url` required | `string` | | #### Responses | Status | Meaning | | --- | --- | | `201` | Created. **Carries the signing secret, once.** | | `400` | `invalid_request` `malformed_json` `webhook_url_not_permitted` | | `401` | `unauthorized` `invalid_api_key` `api_key_revoked` `api_key_expired` `wrong_environment` `invalid_token` | | `402` | `feature_not_included` `plan_limit_exceeded` | | `403` | `connection_unauthorized` `organization_suspended` `api_key_not_organization_scoped` `oauth_not_permitted` `billing_suspended` | | `409` | `resource_limit_reached` | | `413` | `payload_too_large` | | `415` | `unsupported_media_type` | | `429` | `rate_limited` | | `500` | `internal_error` | | `503` | `webhook_not_configured` | #### Response body | Field | Type | Description | | --- | --- | --- | | `apiVersion` required | `string` | The webhook contract version this endpoint receives. Pinned when the endpoint is created and changed only by PATCH. | | `consecutiveFailures` required | `integer` | Veriticity does not disable a failing endpoint, so this is the number that tells you a receiver has been refusing events. Any success resets it. | | `createdAt` required | `Timestamp` | | | `description` | `string \| null` | | | `disabledAt` | `Timestamp \| null` | | | `id` required | `UUID` | | | `lastFailureAt` | `Timestamp \| null` | | | `lastSuccessAt` | `Timestamp \| null` | | | `revokedAt` | `Timestamp \| null` | | | `status` required | `ACTIVE \| DISABLED \| REVOKED` | | | `subscribedTypes` required | `WebhookEventType[]` | | | `url` required | `string` | | | `secret` required | `string` | The signing secret, shown exactly once. Veriticity cannot show it again. Store it before you close the response. | ### Read a webhook endpoint GET `/v1/webhooks/{webhookId}` Organisation-scoped API key No OAuth caller admitted One endpoint, including its operational health. `consecutiveFailures` is the number that tells you a receiver has been refusing events since Tuesday; any success resets it. Never carries a signing secret. Organization-scoped API keys only. An agent-bound credential must not be able to decide where the whole account's events go, and no OAuth scope reaches this at all — admitting a connection would let an AI assistant point your event stream at a URL of its own choosing. Credential: An organization-scoped API key. No OAuth scope reaches this operation. Rate limit: 600 per minute per credential. #### Parameters | Name | In | Description | | --- | --- | --- | | `webhookId` required | path | The endpoint to read. | #### Responses | Status | Meaning | | --- | --- | | `200` | The endpoint. | | `401` | `unauthorized` `invalid_api_key` `api_key_revoked` `api_key_expired` `wrong_environment` `invalid_token` | | `403` | `connection_unauthorized` `organization_suspended` `api_key_not_organization_scoped` `oauth_not_permitted` | | `404` | `not_found` | | `429` | `rate_limited` | | `500` | `internal_error` | #### Response body | Field | Type | Description | | --- | --- | --- | | `apiVersion` required | `string` | The webhook contract version this endpoint receives. Pinned when the endpoint is created and changed only by PATCH. | | `consecutiveFailures` required | `integer` | Veriticity does not disable a failing endpoint, so this is the number that tells you a receiver has been refusing events. Any success resets it. | | `createdAt` required | `Timestamp` | | | `description` | `string \| null` | | | `disabledAt` | `Timestamp \| null` | | | `id` required | `UUID` | | | `lastFailureAt` | `Timestamp \| null` | | | `lastSuccessAt` | `Timestamp \| null` | | | `revokedAt` | `Timestamp \| null` | | | `status` required | `ACTIVE \| DISABLED \| REVOKED` | | | `subscribedTypes` required | `WebhookEventType[]` | | | `url` required | `string` | | ### Update a webhook endpoint PATCH `/v1/webhooks/{webhookId}` Organisation-scoped API key No OAuth caller admitted Changes the URL, description, subscriptions, status or API version. Send at least one field. Omitting `description` leaves it alone; sending `null` clears it. `status` accepts `ACTIVE` and `DISABLED` only. Revoking destroys key material and cannot be undone, so it is its own verb: `DELETE /v1/webhooks/{webhookId}`. The secret is never changed here — use rotate-secret. Organization-scoped API keys only. An agent-bound credential must not be able to decide where the whole account's events go, and no OAuth scope reaches this at all — admitting a connection would let an AI assistant point your event stream at a URL of its own choosing. Credential: An organization-scoped API key. No OAuth scope reaches this operation. Rate limit: 120 per minute per credential. Request body: at most 16384 bytes. #### Parameters | Name | In | Description | | --- | --- | --- | | `webhookId` required | path | The endpoint to update. | #### Request body | Field | Type | Description | | --- | --- | --- | | `apiVersion` | `string` | | | `description` | `string \| null` | | | `status` | `ACTIVE \| DISABLED` | | | `subscribedTypes` | `WebhookEventType[]` | | | `url` | `string` | | #### Responses | Status | Meaning | | --- | --- | | `200` | The updated endpoint. | | `400` | `invalid_request` `malformed_json` `webhook_url_not_permitted` | | `401` | `unauthorized` `invalid_api_key` `api_key_revoked` `api_key_expired` `wrong_environment` `invalid_token` | | `403` | `connection_unauthorized` `organization_suspended` `api_key_not_organization_scoped` `oauth_not_permitted` | | `404` | `not_found` | | `409` | `webhook_endpoint_revoked` | | `413` | `payload_too_large` | | `415` | `unsupported_media_type` | | `429` | `rate_limited` | | `500` | `internal_error` | #### Response body | Field | Type | Description | | --- | --- | --- | | `apiVersion` required | `string` | The webhook contract version this endpoint receives. Pinned when the endpoint is created and changed only by PATCH. | | `consecutiveFailures` required | `integer` | Veriticity does not disable a failing endpoint, so this is the number that tells you a receiver has been refusing events. Any success resets it. | | `createdAt` required | `Timestamp` | | | `description` | `string \| null` | | | `disabledAt` | `Timestamp \| null` | | | `id` required | `UUID` | | | `lastFailureAt` | `Timestamp \| null` | | | `lastSuccessAt` | `Timestamp \| null` | | | `revokedAt` | `Timestamp \| null` | | | `status` required | `ACTIVE \| DISABLED \| REVOKED` | | | `subscribedTypes` required | `WebhookEventType[]` | | | `url` required | `string` | | ### Revoke a webhook endpoint DELETE `/v1/webhooks/{webhookId}` Organisation-scoped API key No OAuth caller admitted Stops delivery permanently and destroys the endpoint's signing secrets. Revocation is terminal — it cannot be undone, and a revoked endpoint cannot be re-enabled. Create a new one instead. Returns the endpoint in its revoked state rather than `204`: the row is not gone, it still appears in listings, and its delivery history survives. Organization-scoped API keys only. An agent-bound credential must not be able to decide where the whole account's events go, and no OAuth scope reaches this at all — admitting a connection would let an AI assistant point your event stream at a URL of its own choosing. Credential: An organization-scoped API key. No OAuth scope reaches this operation. Rate limit: 120 per minute per credential. #### Parameters | Name | In | Description | | --- | --- | --- | | `webhookId` required | path | The endpoint to revoke. | #### Responses | Status | Meaning | | --- | --- | | `200` | The endpoint, now revoked. | | `401` | `unauthorized` `invalid_api_key` `api_key_revoked` `api_key_expired` `wrong_environment` `invalid_token` | | `403` | `connection_unauthorized` `organization_suspended` `api_key_not_organization_scoped` `oauth_not_permitted` | | `404` | `not_found` | | `409` | `webhook_endpoint_revoked` | | `429` | `rate_limited` | | `500` | `internal_error` | #### Response body | Field | Type | Description | | --- | --- | --- | | `apiVersion` required | `string` | The webhook contract version this endpoint receives. Pinned when the endpoint is created and changed only by PATCH. | | `consecutiveFailures` required | `integer` | Veriticity does not disable a failing endpoint, so this is the number that tells you a receiver has been refusing events. Any success resets it. | | `createdAt` required | `Timestamp` | | | `description` | `string \| null` | | | `disabledAt` | `Timestamp \| null` | | | `id` required | `UUID` | | | `lastFailureAt` | `Timestamp \| null` | | | `lastSuccessAt` | `Timestamp \| null` | | | `revokedAt` | `Timestamp \| null` | | | `status` required | `ACTIVE \| DISABLED \| REVOKED` | | | `subscribedTypes` required | `WebhookEventType[]` | | | `url` required | `string` | | ### Read an endpoint's delivery history GET `/v1/webhooks/{webhookId}/deliveries` Organisation-scoped API key No OAuth caller admitted Recent delivery attempts, newest first — what was sent, what your server answered, and when the next attempt is due. `hasMore` rather than a total: what a debugging session wants is "is there anything older", and counting a table that only grows costs a scan to answer a question nobody asks. This is a third pagination model, distinct from the page/pageSize used by `GET /v1/policies`. Organization-scoped API keys only. An agent-bound credential must not be able to decide where the whole account's events go, and no OAuth scope reaches this at all — admitting a connection would let an AI assistant point your event stream at a URL of its own choosing. Credential: An organization-scoped API key. No OAuth scope reaches this operation. Rate limit: 600 per minute per credential. #### Parameters | Name | In | Description | | --- | --- | --- | | `webhookId` required | path | The endpoint whose deliveries these are. | | `limit` | query | How many attempts to return. Clamped to 100 rather than refused. | #### Responses | Status | Meaning | | --- | --- | | `200` | A page of delivery attempts. | | `400` | `invalid_request` | | `401` | `unauthorized` `invalid_api_key` `api_key_revoked` `api_key_expired` `wrong_environment` `invalid_token` | | `403` | `connection_unauthorized` `organization_suspended` `api_key_not_organization_scoped` `oauth_not_permitted` | | `404` | `not_found` | | `429` | `rate_limited` | | `500` | `internal_error` | #### Response body | Field | Type | Description | | --- | --- | --- | | `hasMore` required | `boolean` | | | `items` required | `WebhookDelivery[]` | | | `attemptCount` required | `integer` | | | `createdAt` required | `Timestamp` | | | `deliveredAt` | `Timestamp \| null` | | | `eventId` required | `UUID` | The logical event, and your deduplication key. Identical to the Veriticity-Webhook-Id header, and identical at every endpoint subscribed to this event. | | `eventType` required | `DeliverableWebhookEventType` | | | `id` required | `UUID` | This delivery: one endpoint's attempt stream for one event. | | `lastAttemptAt` | `Timestamp \| null` | | | `lastDurationMs` | `integer \| null` | | | `lastErrorClass` | `string \| null` | | | `lastResponseSnippet` | `string \| null` | | | `lastStatusCode` | `integer \| null` | | | `nextAttemptAt` | `Timestamp \| null` | | | `occurredAt` required | `Timestamp` | When it happened in the domain, not when it was sent. | | `status` required | `string` | | ### Rotate an endpoint's signing secret POST `/v1/webhooks/{webhookId}/rotate-secret` Organisation-scoped API key No OAuth caller admitted Issues a new signing secret and returns it once. By default the old secret keeps signing for 24 hours alongside the new one, so every delivery in that window carries two signatures and a receiver verifying with either succeeds. That is what lets you deploy the new secret without coordinating a cutover. Send `{"retireImmediately": true}` to kill the old secret at once, which is what a leak calls for. The body is optional: an absent body means the defaults. This is the only operation on the API that does not require a JSON body when it takes one. Organization-scoped API keys only. An agent-bound credential must not be able to decide where the whole account's events go, and no OAuth scope reaches this at all — admitting a connection would let an AI assistant point your event stream at a URL of its own choosing. Credential: An organization-scoped API key. No OAuth scope reaches this operation. Rate limit: 120 per minute per credential. Request body: at most 16384 bytes. #### Parameters | Name | In | Description | | --- | --- | --- | | `webhookId` required | path | The endpoint to rotate. | #### Request body (optional) | Field | Type | Description | | --- | --- | --- | | `retireImmediately` | `boolean` | Retire the old secret now instead of after 24 hours. Deliveries signed with it stop verifying immediately. | #### Responses | Status | Meaning | | --- | --- | | `200` | Rotated. **Carries the new signing secret, once.** | | `400` | `invalid_request` `malformed_json` | | `401` | `unauthorized` `invalid_api_key` `api_key_revoked` `api_key_expired` `wrong_environment` `invalid_token` | | `403` | `connection_unauthorized` `organization_suspended` `api_key_not_organization_scoped` `oauth_not_permitted` | | `404` | `not_found` | | `409` | `webhook_endpoint_revoked` | | `429` | `rate_limited` | | `500` | `internal_error` | | `503` | `webhook_not_configured` | #### Response body | Field | Type | Description | | --- | --- | --- | | `apiVersion` required | `string` | The webhook contract version this endpoint receives. Pinned when the endpoint is created and changed only by PATCH. | | `consecutiveFailures` required | `integer` | Veriticity does not disable a failing endpoint, so this is the number that tells you a receiver has been refusing events. Any success resets it. | | `createdAt` required | `Timestamp` | | | `description` | `string \| null` | | | `disabledAt` | `Timestamp \| null` | | | `id` required | `UUID` | | | `lastFailureAt` | `Timestamp \| null` | | | `lastSuccessAt` | `Timestamp \| null` | | | `revokedAt` | `Timestamp \| null` | | | `status` required | `ACTIVE \| DISABLED \| REVOKED` | | | `subscribedTypes` required | `WebhookEventType[]` | | | `url` required | `string` | | | `secret` required | `string` | The signing secret, shown exactly once. Veriticity cannot show it again. Store it before you close the response. | ### Send a test event POST `/v1/webhooks/{webhookId}/test` Organisation-scoped API key No OAuth caller admitted Queues a real `webhook.test` event through the real pipeline: signed with your real secret, recorded as a real delivery, retried on the real schedule. There is no verification handshake — this is better, because it proves the thing you actually care about on every request rather than once. Answers 202, not 200: the event is queued rather than delivered. Use the returned ids to find it in the delivery history. Delivered regardless of what the endpoint subscribes to. Organization-scoped API keys only. An agent-bound credential must not be able to decide where the whole account's events go, and no OAuth scope reaches this at all — admitting a connection would let an AI assistant point your event stream at a URL of its own choosing. Credential: An organization-scoped API key. No OAuth scope reaches this operation. Rate limit: 5 per minute per webhook endpoint; 30 per 15 minutes per organization. #### Parameters | Name | In | Description | | --- | --- | --- | | `webhookId` required | path | The endpoint to send a test to. | #### Responses | Status | Meaning | | --- | --- | | `202` | Queued. Look in the delivery history for what happened. | | `401` | `unauthorized` `invalid_api_key` `api_key_revoked` `api_key_expired` `wrong_environment` `invalid_token` | | `403` | `connection_unauthorized` `organization_suspended` `api_key_not_organization_scoped` `oauth_not_permitted` | | `404` | `not_found` | | `409` | `webhook_endpoint_revoked` | | `429` | `rate_limited` | | `500` | `internal_error` | | `503` | `webhook_not_configured` `temporarily_unavailable` | #### Response body | Field | Type | Description | | --- | --- | --- | | `deliveryId` required | `UUID` | | | `eventId` required | `UUID` | | | `status` required | `"queued"` | | ## Using the OpenAPI document The document is OpenAPI 3.1, so its schemas are JSON Schema 2020-12 and can be used directly by a validator. It is generated from the application’s own constants and regenerated on every build, so it cannot describe an endpoint that does not exist or omit an error code the API can return. **Fetch the document** ```bash curl -s https://docs.veriticity.com/openapi.json -o veriticity.json ``` --- Source: https://docs.veriticity.com/api-reference --- # Errors The Veriticity error contract: one envelope, stable machine-readable codes, and what to do about each. Includes the three permanent refusals a client must not retry. ## One envelope Every failure on the REST API, and every HTTP-level refusal at `/mcp`, uses the same shape. `code` is stable and safe to branch on. `message` is written for a person and may be reworded without notice — do not match on it. A tool that refuses inside a successful MCP call answers differently; see [MCP](https://docs.veriticity.com/mcp.md). **400 Bad Request** ```json { "error": { "code": "invalid_request", "message": "The purchase request is not valid.", "issues": [ { "path": "amountMinor", "code": "AMOUNT_NOT_POSITIVE", "message": "amountMinor must be greater than zero. Veriticity evaluates purchases, not refunds." }, { "path": "agentId", "code": "UNKNOWN_FIELD", "message": "agentId is not accepted here. The agent is determined by the API key, never by the request body." } ] } } ``` > **The status describes the call, never the decision** > > A rejected purchase is `201`, not `402`. Veriticity was asked a question and answered it. The statuses on this page mean the question could not be answered at all. The OAuth endpoints are the one exception: `/oauth/token` and `/oauth/revoke` answer RFC 6749’s `{error, error_description}`, because that is what every OAuth library in the world parses. Every response from `/v1` and `/mcp`, success or failure, carries an `X-Request-Id` header. It is the id Veriticity recorded the request under. Log it, and include it when you contact [Veriticity support](https://app.veriticity.com/support). The SDK puts it on every error as `requestId`. ## Validation issues `issues[]` appears on `invalid_request` and `draft_invalid` only, and it always carries **every** problem found rather than the first — so four mistakes are fixed in one round trip instead of four. A purchase request lists at most 100; when there are more, the last entry has the code `TOO_MANY_ISSUES` and says how many were left out. Unknown fields are refused rather than ignored, with guidance saying where authority actually comes from. A body containing `agentId` is a caller who believes they are choosing the agent, and silently dropping it would leave them believing it. **Handling errors** ```typescript type ApiError = { error: { code: string; message: string; issues?: { path: string; code: string; message: string }[]; }; }; // These three mean "stop". Retrying, refreshing or re-authorising changes // nothing, and a client that loops on them will loop forever. const PERMANENT = new Set([ "api_key_not_permitted", "oauth_not_permitted", "connection_unauthorized", ]); if (!response.ok) { const { error } = (await response.json()) as ApiError; if (PERMANENT.has(error.code)) { await alertAnOperator(error); return; } if (error.code === "insufficient_scope") { // This one *is* fixable by asking for more. return reauthorize(); } if (error.code === "invalid_request") { // Every problem at once, so four fields are fixed in one round trip. for (const issue of error.issues ?? []) { log(`${issue.path}: ${issue.message}`); } } throw new Error(`${error.code}: ${error.message}`); } ``` ## Every error code 38 codes. This table is generated from the same table the API answers from, so it cannot fall behind. | Status | Code | What to do | | --- | --- | --- | | `400` | `invalid_request` | The body was understood and is not acceptable. Read issues[]: it carries every problem found, not just the first. | | `400` | `malformed_json` | The body is not valid JSON, or gives the same field twice in one object. | | `400` | `webhook_url_not_permitted` | The address is not one Veriticity will send to — it must be HTTPS on port 443, a hostname rather than an IP literal, and must resolve to a publicly routable address. Editing your JSON will not fix it. | | `401` | `api_key_expired` | The key expired. Issue a new one in the dashboard. | | `401` | `api_key_revoked` | The key was revoked. Issue a new one in the dashboard. | | `401` | `invalid_api_key` | The key does not exist, or the secret did not match. Both give this same answer deliberately — telling them apart would confirm which keys exist. | | `401` | `invalid_token` | The access token is unknown, malformed, expired or revoked — or the connection behind it was revoked. Obtaining a new one may help; if it was a revoked connection, a person must reconnect. | | `401` | `unauthorized` | No usable Authorization: Bearer header was presented. Check the header name and that the value is not empty. | | `401` | `wrong_environment` | The key belongs to the other environment: cmp_test_ keys work only at https://sandbox.veriticity.com, cmp_live_ keys only at https://app.veriticity.com. Change the base URL, not the key. Retrying will not help. | | `402` | `feature_not_included` | The organisation's plan does not include this feature — details.feature names it. Upgrade the plan to use it. Retrying will not help. | | `402` | `plan_limit_exceeded` | The agent or organisation is inactive: after a plan change there are more of them than the plan allows. An owner chooses which stay active, or upgrades. Retrying will not help. | | `402` | `plan_required` | The organisation has no plan that includes purchase requests in Live: its trial has ended, or it has no plan. Nothing was evaluated. An owner chooses a plan in the dashboard. Retrying will not help until then. | | `402` | `purchase_allowance_exhausted` | The organisation has used every purchase request its plan includes this month, or every one its trial includes. Nothing was evaluated. details carries used and included, and periodEnd when the allowance resets; a trial's never does. Retrying will not help until then or until the plan is upgraded. | | `403` | `api_key_not_bound_to_agent` | You presented an organisation-scoped key to an operation that must act as an agent. Use a key bound to one — issue one key per agent. | | `403` | `api_key_not_organization_scoped` | You presented an agent-bound key to an operation that acts for the organisation. It needs an organisation-scoped key, which the dashboard does not issue; do it in the dashboard instead. To use your own purchase's authorization, send its execution token. | | `403` | `api_key_not_permitted` | No API key of either kind may do this, and none ever will. Policy authoring is a human operation performed in the dashboard. At /mcp, use the OAuth access token of an application a person connected, or call /v1 with the key instead. Retrying with another key will not help. | | `403` | `billing_suspended` | The account is suspended. Paying will not lift it; contact Veriticity support with the X-Request-Id. Retrying will not help. | | `403` | `connection_unauthorized` | The token is valid and the human authority behind it is gone: the person who granted the connection has left the organisation or lost permission. Refreshing will succeed and the next call will fail identically. Stop, and tell them to reconnect. | | `403` | `insufficient_scope` | This connection was not granted the scope this operation needs. Re-authorise with a wider grant — this will work. | | `403` | `oauth_not_permitted` | No OAuth scope reaches this operation, whatever the connection holds. Reconnecting with more scopes will not help. Use an organisation-scoped API key if you have one. | | `403` | `organization_suspended` | The organisation is suspended. Nothing is evaluated for it until that is resolved. | | `404` | `not_found` | No such resource in this organisation. Another organisation's resources answer identically, so this never confirms that an id exists elsewhere. | | `409` | `idempotency_key_reuse` | This Idempotency-Key was already used for a different purchase. A retry must repeat the original request exactly; a new purchase needs a new key. | | `409` | `policy_lifecycle_conflict` | The lifecycle forbids this — activating a non-draft, editing an archived version, discarding the active version. | | `409` | `policy_name_taken` | A policy in this organisation that is not archived already has that name. | | `409` | `purchase_authorization_conflict` | | | `409` | `purchase_outcome_conflict` | | | `409` | `report_id_reuse` | | | `409` | `resource_limit_reached` | Creating this would pass the plan's limit for it — details carries current and allowed. Remove one, or upgrade the plan. | | `409` | `webhook_endpoint_revoked` | The endpoint was revoked, and revocation is terminal. Create a new one. | | `409` | `webhook_limit_reached` | No longer returned. Creating a webhook endpoint past the plan's limit returns resource_limit_reached. | | `413` | `payload_too_large` | The body is over the limit for this operation: 64 KB for purchases and simulations, 16 KB for webhooks. Nothing was read or done. Send a smaller body. | | `415` | `unsupported_media_type` | Send the body as application/json. The one exception is rotate-secret, whose body is optional. | | `422` | `draft_invalid` | The request was fine; the stored draft cannot be activated. issues[] says why. Distinct from invalid_request, where the body you just sent is at fault. | | `429` | `rate_limited` | This credential or organisation has made too many requests of this kind recently. Nothing was done. Wait the number of seconds in Retry-After, then send the same request again. | | `500` | `internal_error` | Ours. It is logged in full, under the response's X-Request-Id, and never described to a caller. Safe to retry — send an Idempotency-Key on a purchase so a retry cannot double-spend. | | `503` | `temporarily_unavailable` | Veriticity could not check a limit or the organisation's plan, which this operation requires, so it did nothing. Retry after the number of seconds in Retry-After — with the same Idempotency-Key for a purchase. | | `503` | `webhook_not_configured` | This deployment cannot currently hold webhook signing secrets. It is a configuration fault on our side; retrying after it is fixed will succeed. | Every `401` from `/v1` carries `WWW-Authenticate: Bearer realm="compass"`. A `401` from `/mcp` carries `Bearer realm="veriticity"` with a `resource_metadata` pointer to where to authenticate. ## Refusals you must not retry Three codes mean “stop”, and a client that treats them as transient will retry forever. They are kept distinct precisely so you can tell them apart. | Code | Why retrying cannot work | | --- | --- | | `api_key_not_permitted` | No API key of either kind may do this. It is a human operation. | | `oauth_not_permitted` | No OAuth scope reaches this operation. A wider grant would not help. | | `connection_unauthorized` | The person behind the grant is gone. Refreshing will succeed and the next call will fail identically. | Contrast `insufficient_scope`, which *is* fixable: re-authorise with the scope named in the message. That distinction is why these are four codes and not one. ## Plan limits Each plan includes a number of purchase requests per billing period, limits on how many agents, members, policies, API keys, connected apps and webhook endpoints an organisation may have, and a set of features. A purchase request counts once, however it was submitted and whatever the decision. Simulations, reads, approvals and other API calls never count, and nothing in Sandbox counts or is limited by a plan. An owner chooses and changes the plan in the Live dashboard. The API cannot change it. See the Veriticity pricing page for current plans, limits, features and pricing. When a plan limit is reached, the answer is `402` (or `409 resource_limit_reached` when creating something) with a `details` object naming the limit. Nothing was done, and waiting a few seconds will not help: plan limits are not rate limits. **json** ``` { "error": { "code": "purchase_allowance_exhausted", "message": "This organisation has used all the purchase requests its plan includes this month (1000 used, 1000 included). New purchase requests are refused until the allowance resets, unless an owner upgrades the plan in the dashboard, under Settings > Plan & usage.", "details": { "limit": "purchase_requests", "used": "1000", "included": "1000", "periodEnd": "2026-10-10T00:00:00.000Z" } } } ``` ## Rate limits Every REST operation is limited per credential — one API key, or one OAuth connection whichever token it presents. Purchase submission is also limited per organisation, and webhook tests per endpoint and per organisation. Live and Sandbox count separately and apply the same limits. | Operation | Limit | | --- | --- | | Submit a purchase | 120 per minute per credential; 600 per minute per organization | | Report a purchase outcome | 60 per minute per credential; 300 per minute per organization | | Simulations | 120 per minute per credential | | Reads: purchases, agents, policies, webhooks, deliveries | 600 per minute per credential | | Create, update, revoke a webhook, rotate its secret | 120 per minute per credential | | Send a webhook test event | 5 per minute per endpoint; 30 per 15 minutes per organization | | /oauth/token | 300 per minute per client address | Each limit is a fixed window that starts with the first request and lasts for its stated length. Every request counts, including refused ones, but a refused request never extends the window. Every response to a request that passed authentication carries three headers describing the tightest limit that applied: | Header | Meaning | | --- | --- | | `X-RateLimit-Limit` | Requests the window allows. | | `X-RateLimit-Remaining` | Requests left in the current window. | | `X-RateLimit-Reset` | Seconds until the window resets. A number of seconds from now, not a timestamp. | Over a limit, the answer is `429 rate_limited` with `Retry-After` in whole seconds. Nothing was done: a refused purchase was not evaluated and reserved no budget. Waiting that long and sending the same request again will work. The headers are occasionally absent from a successful response. That means the limit could not be measured for that request, and it was let through; do not treat a missing header as an unlimited allowance. Purchase submission, outcome reports and webhook tests are never let through unmeasured. When the limit cannot be checked they answer `503 temporarily_unavailable` with `Retry-After`, and nothing was done. Retry a purchase with the same `Idempotency-Key`, and an outcome with the same `reportId`. See [Purchases](https://docs.veriticity.com/purchases.md). `/oauth/token` is limited per client address and answers `429` with `Retry-After` and an OAuth error body. Over MCP, `request_purchase` shares the purchase submission limits and refuses with the tool error `rate_limited`. The SDK does not retry or wait on a `429`. It surfaces as a `RateLimitError` with `code` `rate_limited` and `status` `429`, carrying `retryAfterSeconds` and the quota it was refused by. See [SDK](https://docs.veriticity.com/sdk.md). ## Retrying safely A limit is counted before the request is read and before the Trust Engine runs, so a `429` and a `503 temporarily_unavailable` both mean nothing was submitted. Sending the same request again creates no second decision. A timed-out or dropped request is a different case, and the difference matters on purchase submission: the request may have been received and processed while the response was lost. Retry that only with the same `Idempotency-Key`. | Failure | Was anything submitted? | Retry | | --- | --- | --- | | `429 rate_limited` | No | After `Retry-After` | | `503 temporarily_unavailable` | No | Shortly | | Timeout or dropped connection | Unknown | Only with the same `Idempotency-Key` | --- Source: https://docs.veriticity.com/errors --- # Conventions The conventions every Veriticity endpoint follows: minor-unit money as strings, ISO 8601 timestamps, UUID identifiers, pagination, idempotency, body limits and versioning. Rules that hold across every endpoint. Reading this once saves discovering them one surprise at a time. ## Money Every monetary amount is a **decimal string of ISO 4217 minor units**, paired with an alpha-3 `currency`. Pence for GBP, cents for USD. **Amounts** ```json // Correct. A string, in minor units. { "amountMinor": "7500", "currency": "GBP" } // GBP 75.00 // Also accepted on input, and returned as a string anyway. { "amountMinor": 7500, "currency": "GBP" } // Wrong. There is no major-unit field, and no float anywhere. { "amount": 75.00, "currency": "GBP" } ``` Amounts are accepted as a JSON number or a string on input, and **always returned as a string** — including small ones. > **Why a string, even for GBP 75.00** > > JSON numbers are IEEE-754 doubles and lose integer precision above 253. A contract that is exact for a small purchase and lossy for a large invoice fails only in production, only on the biggest amounts, and is therefore worse than one that is uniformly exact. **Formatting safely** ```typescript /** Minor units to a display string. Never use a float in between. */ function formatMinor(amountMinor: string, currency: string): string { // BigInt, not Number: above 2^53 a Number is no longer an exact integer, // and an invoice can get there. const minor = BigInt(amountMinor); return new Intl.NumberFormat("en-GB", { style: "currency", currency, }).format(Number(minor) / 100); } ``` **Veriticity performs no currency conversion.** A purchase in a currency no applicable policy sets limits in is refused with `CURRENCY_NOT_LISTED` rather than converted at a rate nobody agreed to. ## Time Every timestamp is ISO 8601, UTC, with milliseconds: `2026-09-12T14:03:21.114Z`. Fields are **null when the thing has not happened** rather than absent, so a client’s shape is stable and no key needs probing for. > **Budget windows use the organisation's timezone** > > “Daily” and “monthly” are resolved against the organisation’s own timezone, never the server’s or the caller’s. A daily limit resets at midnight where the organisation is. ## Identifiers Bare UUIDs, with no type prefix. Identifiers that need to sort by creation time — purchase requests, decisions, webhook events — are UUIDv7, so they sort lexicographically by when they were created. Credentials are the exception and carry a namespace: `cmp_live_`, `cmp_oat_`, `cmp_ort_`, `cmp_oac_`, `cmp_whs_`. Those prefixes are permanent. ## Enumerations Shouted constants: `APPROVED`, `REQUIRES_APPROVAL`, `ACTIVE`, `DISABLED`, `ARCHIVED`, `REVOKED`. **New values may be added.** Handle an unrecognised enum value rather than crashing on it — a switch with no default is the thing that breaks when a reason code is added. ## Collections and pagination There are three pagination models on the API. This is a real inconsistency rather than a subtlety, and it is documented as it is rather than smoothed over. | Endpoint | Parameters | Response | | --- | --- | --- | | `GET /v1/policies` | `?page`, `?pageSize` | `{ policies, pagination }` | | `GET /v1/webhooks/{id}/deliveries` | `?limit` (default 25, clamped to 100) | `{ items, hasMore }` | | `GET /v1/agents` | None | `{ agents }` | | `GET /v1/webhooks` | None | `{ items }` | | MCP `list_purchases` | `limit` (1–50), `cursor` | `{ purchases, nextCursor, hasMore }` | ### Page and size, where it applies - `pageSize` defaults to 25 and is **clamped** to 100 rather than refused — asking for everything gets you the maximum. - A malformed value *is* refused, because it means your client believes something untrue. - A page beyond the end returns an empty page, not a `404`. - `page` can be at most 10000. A higher page is refused with `invalid_request`. Narrow the results with a filter instead. - `pagination` is always present, and `totalPages` is at least 1 even when nothing matched. ## Request bodies `Content-Type: application/json` on every write, or you get `415`. The one exception is `POST /v1/webhooks/{id}/rotate-secret`, whose body is optional. | Surface | Limit | | --- | --- | | Purchases, policies, simulations | 64 KB | | Webhook management | 16 KB | A body over the limit is refused with `413 payload_too_large` and nothing is done. A `Content-Length` over the limit is refused before the body is sent in full. Limits are in bytes, so multi-byte characters count for more than one. > **Unknown fields are refused, never ignored** > > A misspelt field that was silently dropped would change what Veriticity evaluates without telling you. The refusal names the field and, where it is a field you are not permitted to set, says where the value actually comes from. ## Versioning Three version vocabularies, and they mean different things. | Version | Where | What it versions | | --- | --- | --- | | `/v1` | The URL path | The REST contract. | | `2026-09-01` | `apiVersion` in a webhook envelope | The event contract, pinned per endpoint. A date, because dates sort and do not invite semver reasoning that does not apply. | | `veriticity-trust-engine@0.1.0` | `engineVersion` in a decision | Which evaluator produced this decision. Evidence, not a contract to branch on. | **Additive changes are not breaking.** Fields may be added to responses, and webhook event types may be added, at any time. Ignore what you do not recognise. ## Known inconsistencies These are documented as they are rather than quietly normalised. Changing any of them would break working integrations, so they are recorded here so you can plan around them. - **Collection envelopes differ.** `{ policies, pagination }`, `{ agents }`, `{ items }`, `{ items, hasMore }` and `{ purchase }` all appear. There is no single convention. - **Three pagination models**, plus two unpaginated collections. See the table above. - **There is no `GET /v1/purchase-requests`.** An MCP connection can enumerate its own purchases; the REST API reads one at a time by id. - **Idempotency exists on one operation only.** Webhook creation, rotation and test-send accept no idempotency key. - **Body limits differ** between 64 KB and 16 KB. - **`rotate-secret` accepts an absent body** where every other write requires a JSON one. - **Whole-body validation issues use two spellings of `path`** — `"body"` on purchases, an empty string on webhooks. Match on the issue `code`, not the path. The full contract, including these, is in the [API reference](https://docs.veriticity.com/api-reference.md). --- Source: https://docs.veriticity.com/conventions