> ## Documentation Index
> Fetch the complete documentation index at: https://docs.botshield.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Errors

> Every error shape the BotShield API returns, the status codes for each operation, and a typed helper that unwraps the envelope.

The BotShield API reports most failures inside an HTTP 200 response. If your client checks only the HTTP status, it will treat a rejected request as a success. This page lists the four shapes a failure can take, the codes each operation returns, and a TypeScript helper that turns all of them into one typed error.

## The four shapes

| HTTP status | When | Where the error is |
| - | - | - |
| `200` | The operation ran and rejected the request (bad credential, unknown gate, conflict, not found, and so on). | `data.error` |
| `400` | The request body or query failed input validation before the operation ran. | Top level of the body |
| `403` | You exceeded a rate limit. | `errors[0]`, with `code: "RATE_LIMITED"` |
| `500` | Unhandled failure. | Top level of the body, when there is one |

## HTTP 200 with `data.error`

```json theme={null}
{
  "data": {
    "error": {
      "message": "An active or pending verification already exists for this scope and user. Wait for it to expire or complete before creating another.",
      "statusCode": 409
    }
  }
}
```

| Field | Type | Description |
| - | - | - |
| `message` | string | Human-readable explanation. Log it. Do not parse it, because wording can change. |
| `statusCode` | integer | The HTTP status the error stands for. Branch on this. |
| `code` | string, optional | Machine-readable code. Present only on the errors listed under [Machine codes](#machine-codes). |
| `min_ttl_seconds` | integer, optional | Sent with `ttl_below_floor`: the lowest `ttl_seconds` allowed. |
| `max_ttl_seconds` | integer, optional | Sent with `ttl_above_ceiling`: the highest `ttl_seconds` allowed. |
| `violations` | array, optional | Sent with `statusCode` 422. Each item has `type`, `path`, and `severity`, and points at the part of your `adaptive_card_payload` that was rejected. |

`data.error` sits directly under `data` on every operation, including the two whose success payload is not double-nested (`GET /verification/status` and `POST /sdk/logout`).

## HTTP 400: input validation

The request did not match the operation's input schema: a required field is missing, a value has the wrong type, a string is too long, or a `request_id` is not a UUID. The operation did not run.

```json theme={null}
{
  "code": "InputValidationError",
  "message": "Bad Request: Invalid input",
  "input": { "request_id": "not-a-uuid" },
  "errors": [
    {
      "propertyPath": "/request_id",
      "invalidValue": "not-a-uuid",
      "message": "Invalid uuid"
    }
  ]
}
```

| Field | Type | Description |
| - | - | - |
| `message` | string | Summary of the failure. |
| `input` | object | The input as the API received it. |
| `errors[].propertyPath` | string | Path of the field that failed. |
| `errors[].invalidValue` | any | The value that failed. |
| `errors[].message` | string | Why it failed. |

Fix the request. Retrying the same body returns the same error.

<Note>
  Some business rules also use 400, but as `data.error.statusCode` inside an HTTP 200 response: for example a gate key that does not exist, or a `ttl_seconds` outside the allowed range. Check both places.
</Note>

## HTTP 403: rate limited

```json theme={null}
{
  "errors": [
    {
      "message": "Rate limit exceeded. Bucket \"burst\" hit its cap; retry in 7s.",
      "code": "RATE_LIMITED"
    }
  ],
  "_rateLimit": {
    "scope": "dev",
    "primary": { "bucket": "burst", "limit": 30, "remaining": 0, "resetIn": 7 },
    "buckets": {
      "burst": { "limit": 30, "remaining": 0, "resetIn": 7 },
      "hourly": { "limit": 300, "remaining": 212, "resetIn": 1841 },
      "daily": { "limit": 2000, "remaining": 1704, "resetIn": 50112 }
    }
  }
}
```

The API answers **403, not 429**, and does not send a `Retry-After` header. Detect this case with `errors[0].code === "RATE_LIMITED"` and wait `_rateLimit.primary.resetIn` seconds. See [Rate limits](/api-reference/rate-limits).

<Warning>
  A 403 inside `data.error.statusCode` (HTTP 200) is a different error: an origin that is not allowed for a site key, a gate the key may not use, or an action category your agent is not registered for. Only a real HTTP 403 with `RATE_LIMITED` is a rate limit.
</Warning>

## HTTP 500: unhandled failure

Something failed outside the operation's own error handling. The body may be empty or may carry a `message`. Retry with backoff. If it persists, contact support with the time of the request and the `request_id` if you have one.

## Status codes by operation

All codes in this table arrive as `data.error.statusCode` in an HTTP 200 response. Every operation can also return HTTP 400 (input validation), HTTP 403 `RATE_LIMITED`, and HTTP 500 as described above.

### BotShield Gate

| Operation | `statusCode` | Cause |
| - | - | - |
| `POST /sdk/create-session` | `401` | No bearer token, or the API key is invalid, inactive, or expired. |
| | `403` | Site key is unknown or revoked, the request has no `Origin`, or the origin is not in the key's allowed origins. |
| | `500` | The grant could not be created. |
| `POST /sdk/create-verification-link` | `401` | No bearer token, or the grant token is invalid, expired, or already used. |
| | `400` | No Active gate has this `scope` (gate key) in the environment of the key that opened the session. Development keys (`bs_dev_…`, `pk_test_…`) resolve Development gates, and Production keys (`bs_production_…`, `pk_live_…`) resolve Production gates. Gate keys are case-sensitive, and a Draft or Archived gate is not Active. |
| | `403` | The gate is not in the API key's allowed gates. |
| | `409` | The same user already has a pending, unexpired request for this gate. See [The 409 conflict](#the-409-conflict). |
| | `500` | The request could not be created. |
| `POST /sdk/revoke-verification` | `401` | No bearer token, or the API key is invalid. |
| | `400` | No Active gate has this `scope` in the API key's environment, or you did not send `partner_user_id`. |
| | `500` | The revoke failed. Retry. |
| `POST /sdk/logout` | `401` | No bearer token, or the API key is invalid. |
| | `403` | The grant token belongs to another organization. |
| | `500` | The revoke failed. Retry. |

Two Gate operations report problems in their normal payload instead of `data.error`:

| Operation | How it reports a problem |
| - | - |
| `POST /sdk/verify-token` | `data.data.valid` is `false` and `data.data.reason` says why: the token expired (with `expired_at`) or the signature is invalid. |
| `GET /verification/status` | `data.found` is `false` and `data.status` is `not_found` (no such `request_id`) or `error` (lookup failed, with `data.message`). |

### The 409 conflict

`POST /sdk/create-verification-link` answers `statusCode` 409 in one situation only: the same user has a request for the same gate that is still `pending` and has not reached its `expires_at`.

* **The same user** means the `partner_user_ref` you send on create-verification-link. When you send none, it is the `partner_user_id` you sent to `POST /sdk/create-session`.
* **Only pending requests block.** A request that completed, failed, or expired never blocks a new one, so a user who has just verified can verify again straight away.
* **Requests without a user are never blocked.** A call that carries neither value is not checked, and neither is a call that names no `scope`.
* **The widget is not affected.** A browser request made with a site key and no `partner_user_id` on the session is not checked, so a page visitor never meets a 409.

To clear the conflict, call `POST /sdk/revoke-verification` with your API key, the same `scope`, and the user's reference as `partner_user_id`. It ends exactly the pending requests that would block a new one and leaves completed and failed requests alone. The response reports how many it ended:

```json theme={null}
{
  "data": {
    "data": {
      "success": true,
      "revoked_count": 1,
      "message": "1 pending verification(s) revoked. You may now create a new one."
    }
  }
}
```

A `revoked_count` of `0` is a success. It means nothing was pending. Then create the request again. A rejected call does not use up the grant token, so the same `bss_…` token works for the second attempt until its five minutes run out. A revoked request reads `expired` on `GET /verification/status`.

### Agents Ask

| Operation | `statusCode` | `code` | Cause |
| - | - | - | - |
| All Agents Ask operations | `401` | | No bearer token, the key is not in the form `bs_agent_<Name>__<secret>`, no agent has that name, the agent is registered for the other environment, the agent was revoked, or the secret does not match. |
| `POST /agent/bind-session` | `500` | | The link code could not be created. Retry. |
| `POST /agentlink/inquire` | `400` | | No human identifier. Send `opaque_id`. |
| | `400` | `ttl_below_floor` | `ttl_seconds` is below 60. The error includes `min_ttl_seconds`. |
| | `400` | `ttl_above_ceiling` | `ttl_seconds` is above 86400. The error includes `max_ttl_seconds`. |
| | `403` | | `action.category` is not one of the categories the agent is registered for. |
| | `404` | | No active link between this agent and that `opaque_id`. The human never linked, or unlinked. |
| | `422` | | The `adaptive_card_payload` was rejected. The error includes `violations`. |
| | `502` | | The human could not be looked up. Retry. |
| | `500` | | The proposal could not be queued. Retry with the same `request_id`. It is your idempotency key. |
| `GET /agentlink/check-status` | `404` | | No proposal with that `request_id` belongs to this agent. |
| | `500` | | Lookup failed. Retry. |
| `POST /agentlink/cancel` | `404` | | No proposal with that `request_id` belongs to this agent. |
| | `500` | | The cancel failed. Retry. |

Cancelling a proposal that already has an outcome is not an error. The response carries the current `status` and `already_resolved: true`.

### Machine codes

These are the only values of `code` a partner can receive:

| `code` | Where | Meaning |
| - | - | - |
| `ttl_below_floor` | `data.error.code`, `statusCode` 400 | `ttl_seconds` is under the minimum. |
| `ttl_above_ceiling` | `data.error.code`, `statusCode` 400 | `ttl_seconds` is over the maximum. |
| `RATE_LIMITED` | `errors[0].code`, HTTP 403 | A rate limit was exceeded. |
| `InputValidationError` | top-level `code`, HTTP 400 | The request failed input validation. |

For every other error, branch on `statusCode`. Authentication failures in particular carry only `message` and `statusCode: 401`, with no `code`. The `message` tells you which check failed (missing header, wrong key format, unknown or revoked agent, wrong environment), so log it.

<Tip>
  A 401 on every call usually means the `Authorization` header is missing the `Bearer ` prefix. The API reads the credential only from `Authorization: Bearer <credential>`.
</Tip>

## What to retry

| Error | Retry? |
| - | - |
| HTTP 403 `RATE_LIMITED` | Yes, after `_rateLimit.primary.resetIn` seconds. |
| HTTP 500, or `statusCode` 500 or 502 | Yes, with exponential backoff. Reuse the same `request_id` on `POST /agentlink/inquire`. |
| `statusCode` 409 | Not immediately. The user has a pending request for this gate. Send them to that request, wait for it to finish or expire, or clear it with `POST /sdk/revoke-verification` and create a new one. |
| `statusCode` 401 on a grant token | Create a new session. Grant tokens are single-use and live five minutes. |
| HTTP 400, or `statusCode` 400, 401, 403, 404, 422 | No. Fix the request, the credential, or the Console configuration. |

## A typed unwrap helper

This helper calls any operation, handles all four shapes, knows which two operations are not double-nested, and throws one error type.

```typescript theme={null}
export type BotShieldErrorBody = {
  message: string;
  statusCode: number;
  code?: string;
  min_ttl_seconds?: number;
  max_ttl_seconds?: number;
  violations?: { type?: string; path?: string; severity?: string }[];
};

export class BotShieldApiError extends Error {
  constructor(
    message: string,
    /** The status the error stands for: data.error.statusCode, or the real HTTP status. */
    readonly statusCode: number,
    /** Machine code when there is one. */
    readonly code: string | undefined,
    /** Seconds to wait before retrying. Set on rate-limit errors. */
    readonly retryAfterSeconds: number | undefined,
    /** The parsed error body, for logging. */
    readonly details: unknown,
  ) {
    super(message);
    this.name = "BotShieldApiError";
  }

  get retryable(): boolean {
    return this.code === "RATE_LIMITED" || this.statusCode >= 500;
  }
}

const BASE_URL = "https://api.botshield.ai/operations";

// Operations whose payload sits directly under `data` instead of `data.data`.
const FLAT_OPERATIONS = new Set(["sdk/logout", "verification/status"]);

export async function callBotShield<T>(
  operation: string,
  init: { method: "GET" | "POST"; bearer?: string; body?: unknown; query?: Record<string, string> },
): Promise<T> {
  const url = new URL(`${BASE_URL}/${operation}`);
  for (const [key, value] of Object.entries(init.query ?? {})) url.searchParams.set(key, value);

  const response = await fetch(url, {
    method: init.method,
    headers: {
      Accept: "application/json",
      ...(init.body !== undefined ? { "Content-Type": "application/json" } : {}),
      ...(init.bearer ? { Authorization: `Bearer ${init.bearer}` } : {}),
    },
    body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
  });

  const text = await response.text();
  let body: any;
  try {
    body = text ? JSON.parse(text) : {};
  } catch {
    throw new BotShieldApiError(`Non-JSON response (HTTP ${response.status})`, response.status, undefined, undefined, text);
  }

  // Real HTTP 403: rate limited.
  const topLevel = Array.isArray(body.errors) ? body.errors[0] : undefined;
  if (topLevel?.code === "RATE_LIMITED") {
    const retryAfter = body._rateLimit?.primary?.resetIn;
    throw new BotShieldApiError(topLevel.message, response.status, "RATE_LIMITED", retryAfter, body);
  }

  // Real HTTP 400: the request failed input validation.
  if (response.status === 400) {
    throw new BotShieldApiError(body.message ?? "Invalid input", 400, body.code ?? "InputValidationError", undefined, body);
  }

  // Any other non-200: unhandled failure.
  if (!response.ok) {
    throw new BotShieldApiError(body.message ?? `HTTP ${response.status}`, response.status, undefined, undefined, body);
  }

  // HTTP 200 with a handler error.
  const error: BotShieldErrorBody | undefined = body.data?.error;
  if (error) {
    throw new BotShieldApiError(error.message, error.statusCode, error.code, undefined, error);
  }

  return (FLAT_OPERATIONS.has(operation) ? body.data : body.data?.data) as T;
}
```

Use it like this:

```typescript theme={null}
try {
  const session = await callBotShield<{ anchor_grant_token: string }>("sdk/create-session", {
    method: "POST",
    bearer: process.env.BOTSHIELD_API_KEY,
    body: { partner_user_id: "user_8842" },
  });

  const link = await callBotShield<{ request_id: string; web_url: string }>(
    "sdk/create-verification-link",
    { method: "POST", bearer: session.anchor_grant_token, body: { scope: "checkout" } },
  );
  console.log(link.request_id);
} catch (error) {
  if (error instanceof BotShieldApiError && error.statusCode === 409) {
    // This user still has a pending request for this gate. Revoke it, or let it finish.
  } else if (error instanceof BotShieldApiError && error.retryable) {
    // Back off (error.retryAfterSeconds when set), then try again.
  } else {
    throw error;
  }
}
```

The [TypeScript SDK](/sdk/typescript) throws its own error classes for the HTTP 400, 403, and 500 cases, and returns `data.error` to you as a value.

## Next steps

<CardGroup cols={2}>
  <Card title="Rate limits" icon="gauge" href="/api-reference/rate-limits">
    Limits by key type and a backoff example.
  </Card>

  <Card title="API overview" icon="code" href="/api-reference/overview">
    Credentials, the envelope, and the list of operations.
  </Card>

  <Card title="TypeScript SDK" icon="js" href="/sdk/typescript">
    Error classes thrown by the SDK.
  </Card>

  <Card title="Result states" icon="circle-check" href="/concepts/result-states">
    Verified and Unavailable are results, not errors.
  </Card>
</CardGroup>
