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 on 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.

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.

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.

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#

28 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.
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_environment
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. Use an organisation-scoped key.
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. Do not go looking for a different credential.
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.
409budget_reconciliation_inconsistentVeriticity's own records disagree about a budget, so it refused to activate a spending limit against them. Nothing was changed. Retrying will not help; report it with the counter id in the message.
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 what is live.
409policy_name_takenA live policy in this organisation already has that name.
409webhook_endpoint_revokedThe endpoint was revoked, and revocation is terminal. Create a new one.
409webhook_limit_reachedThe account already has as many webhook endpoints as it may have.
413payload_too_largeOver the limit — 64 KB for purchases, policies and simulations, 16 KB for webhooks.
415unsupported_media_typeSend the body as application/json. The one exception is rotate-secret, which accepts no body at all.
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.
500internal_errorOurs. It is logged in full and never described to a caller. Safe to retry — send an Idempotency-Key on a purchase so a retry cannot double-spend.
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 carries WWW-Authenticate: Bearer realm="compass".

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.

There are no rate limits#

Veriticity publishes no rate limits on the REST API, the OAuth endpoints or MCP today. There is no 429 in the error vocabulary and no Retry-After header anywhere.

This is stated plainly because the opposite is easy to assume. Do not write handling for a response that does not exist. If limits are introduced they will be documented here first, with notice.

Reasonable client behaviour still applies: back off on internal_error, and send an Idempotency-Key on purchases so a retry cannot double-spend. See Purchases.