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.

400 Bad Request
{
  "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.

Handling errors
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.

StatusCodeWhat to do
400invalid_requestThe body was understood and is not acceptable. Read issues[]: it carries every problem found, not just the first.
400malformed_jsonThe body is not valid JSON, or gives the same field twice in one object.
400webhook_url_not_permittedThe 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.
401api_key_expiredThe key expired. Issue a new one in the dashboard.
401api_key_revokedThe key was revoked. Issue a new one in the dashboard.
401invalid_api_keyThe key does not exist, or the secret did not match. Both give this same answer deliberately — telling them apart would confirm which keys exist.
401invalid_tokenThe 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.
401unauthorizedNo usable Authorization: Bearer header was presented. Check the header name and that the value is not empty.
401wrong_environmentThe 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.
402feature_not_includedThe organisation's plan does not include this feature — details.feature names it. Upgrade the plan to use it. Retrying will not help.
402plan_limit_exceededThe 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.
402plan_requiredThe 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.
402purchase_allowance_exhaustedThe 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.
403api_key_not_bound_to_agentYou 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.
403api_key_not_organization_scopedYou 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.
403api_key_not_permittedNo 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.
403billing_suspendedThe account is suspended. Paying will not lift it; contact Veriticity support with the X-Request-Id. Retrying will not help.
403connection_unauthorizedThe 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.
403insufficient_scopeThis connection was not granted the scope this operation needs. Re-authorise with a wider grant — this will work.
403oauth_not_permittedNo 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.
403organization_suspendedThe organisation is suspended. Nothing is evaluated for it until that is resolved.
404not_foundNo such resource in this organisation. Another organisation's resources answer identically, so this never confirms that an id exists elsewhere.
409idempotency_key_reuseThis Idempotency-Key was already used for a different purchase. A retry must repeat the original request exactly; a new purchase needs a new key.
409policy_lifecycle_conflictThe lifecycle forbids this — activating a non-draft, editing an archived version, discarding the active version.
409policy_name_takenA policy in this organisation that is not archived already has that name.
409purchase_authorization_conflict
409purchase_outcome_conflict
409report_id_reuse
409resource_limit_reachedCreating this would pass the plan's limit for it — details carries current and allowed. Remove one, or upgrade the plan.
409webhook_endpoint_revokedThe endpoint was revoked, and revocation is terminal. Create a new one.
409webhook_limit_reachedNo longer returned. Creating a webhook endpoint past the plan's limit returns resource_limit_reached.
413payload_too_largeThe 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.
415unsupported_media_typeSend the body as application/json. The one exception is rotate-secret, whose body is optional.
422draft_invalidThe 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.
429rate_limitedThis 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.
500internal_errorOurs. 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.
503temporarily_unavailableVeriticity 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.
503webhook_not_configuredThis 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.

CodeWhy retrying cannot work
api_key_not_permittedNo API key of either kind may do this. It is a human operation.
oauth_not_permittedNo OAuth scope reaches this operation. A wider grant would not help.
connection_unauthorizedThe 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.

OperationLimit
Submit a purchase120 per minute per credential; 600 per minute per organization
Report a purchase outcome60 per minute per credential; 300 per minute per organization
Simulations120 per minute per credential
Reads: purchases, agents, policies, webhooks, deliveries600 per minute per credential
Create, update, revoke a webhook, rotate its secret120 per minute per credential
Send a webhook test event5 per minute per endpoint; 30 per 15 minutes per organization
/oauth/token300 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:

HeaderMeaning
X-RateLimit-LimitRequests the window allows.
X-RateLimit-RemainingRequests left in the current window.
X-RateLimit-ResetSeconds 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.

FailureWas anything submitted?Retry
429 rate_limitedNoAfter Retry-After
503 temporarily_unavailableNoShortly
Timeout or dropped connectionUnknownOnly with the same Idempotency-Key