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.
{
"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 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. 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.
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.
{
"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.
/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.
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 |

