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

# Errors

> The error envelope, every error code grouped by HTTP status, and which errors are safe to retry.

The API answers with conventional HTTP status codes and one JSON error shape. A status tells you the class of problem. The `code` inside the body tells you exactly what happened.

## The error envelope

Every error response has the same body:

```json theme={null}
{
  "error": {
    "code": "validation_error",
    "message": "Request validation failed.",
    "details": [
      {
        "path": "from",
        "code": "window_inverted",
        "message": "`from` must not be after `to`"
      }
    ]
  },
  "request_id": "0b8f6c2e-5d1a-4f3b-9c7e-2a4d6e8f0b13"
}
```

<ResponseField name="error.code" type="string" required>
  The stable machine value. Branch on this.
</ResponseField>

<ResponseField name="error.message" type="string" required>
  A sentence for people. The wording can change, so never match on it.
</ResponseField>

<ResponseField name="error.details" type="object | array">
  Extra data for some codes: the fields that failed validation, the id of the resource that already exists, the numbers behind a plan limit. Its shape depends on `code`. Treat it as optional, even on a code that normally carries it.
</ResponseField>

<ResponseField name="request_id" type="string" required>
  The id of this request. It equals the `X-Request-Id` response header.
</ResponseField>

A status is not a code. Several codes share one status, and `409` alone covers many different situations. Always branch on `error.code`.

## Request ids

Every response carries an `X-Request-Id` header, whether it succeeded or failed. On an error, the same value is `request_id` in the body.

Log the request id with every call you make. When you [contact support](/api/support), quote it. It lets us find the exact request.

## Validation errors

`400 validation_error` means a query parameter, a header or a body field failed validation. `details` is a list with one entry per problem:

<ResponseField name="path" type="string">
  The field that failed. Nested fields are joined with dots, as in `competitors.0.domain`. For a header it is the header's name, as in `Idempotency-Key`. It can be empty when the problem is the body as a whole, and it names the pair `from/to` for `invalid_date`. Branch on `code`; use `path` for display.
</ResponseField>

<ResponseField name="code" type="string">
  What was wrong with it.
</ResponseField>

<ResponseField name="message" type="string">
  The same, as a sentence.
</ResponseField>

These detail codes are stable:

| `details[].code`      | Meaning                                                                                 |
| --------------------- | --------------------------------------------------------------------------------------- |
| `unsupported_country` | A country code names a market Surfais does not scan. See [Countries](/setup/countries). |
| `invalid_domain`      | A domain is not a registrable host name such as `hotel-aurora.example`.                 |
| `competitor_is_self`  | A competitor's domain is the own brand's own domain.                                    |
| `empty_patch`         | A prompt update carried none of `countries`, `platforms`, `tags` or `active`.           |
| `own_brand_only`      | The endpoint is defined for own brands, and the id belongs to a competitor.             |
| `window_inverted`     | `from` is after `to`.                                                                   |
| `window_too_wide`     | The date window is wider than the endpoint allows.                                      |
| `invalid_date`        | `from` or `to` is not a real calendar date.                                             |
| `invalid_header`      | The `Idempotency-Key` header is outside its length bounds.                              |

Other checks report a generic code for the kind of rule that failed, such as `too_big` for a `limit` over the maximum. Webhook endpoint and subscription requests add field-level codes of their own, such as `url_not_public`. Rely on `path` and on the codes above.

[Pagination and date windows](/api/pagination#date-windows) covers the three window codes.

## When part of a write happened

<Warning>
  Two responses mean that **some of the write was applied**. Handle them before anything else.

  **Provisioning a property.** `POST /orgs/{orgId}/properties` creates the own brand first, then links each competitor in turn. A `422 competitor_cap_exceeded`, or a `409` whose `details` carries `brand_id`, means **the brand was created** and one or more competitors were not linked. Do not provision again. Read `details`, then link the missing competitors with `POST /orgs/{orgId}/brands/{brandId}/competitors`. See [Provision a property](/api/guides/provision-a-property).

  **Syncing prompts.** `PUT /orgs/{orgId}/brands/{brandId}/prompts` applies rows one at a time. A row that is refused comes back as `"action": "failed"` with its own `error`, **inside a `200`**. The rest of the request was still applied. Always check `summary.failed`. See [Sync prompts](/api/guides/sync-prompts).
</Warning>

On the partial provisioning responses, `details` has three fields:

<ResponseField name="details.brand_id" type="string">
  The id of the own brand that was created.
</ResponseField>

<ResponseField name="details.brand" type="object">
  The created property, in the same shape as `data` in the `201` response.
</ResponseField>

<ResponseField name="details.competitors" type="array">
  One entry per competitor domain you sent, each with `name`, `domain` and a `status`: `applied`, `failed` or `not_attempted`. An applied entry carries `competitor_id` and `reused`. A failed entry carries `error` with its own `code` and `message`. `not_attempted` means an earlier competitor hit the plan limit, so this one was never tried.
</ResponseField>

The other refusals on that endpoint leave nothing behind: `409 brand_exists`, `409 external_ref_exists`, `422 country_cap_exceeded` and `422 brand_cap_exceeded`.

## Error codes by status

### 400 Bad Request

| Code                    | When it happens                                                                                                                                                                         | What to do                                                                                                   |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `validation_error`      | A query parameter, a header or a body field failed validation. `details` lists each problem.                                                                                            | Fix the fields named in `details[].path`.                                                                    |
| `invalid_cursor`        | The `cursor` does not fit this request. It came from another list or collection, the walk's filters or date window changed, or the data behind it changed. `details.reason` says which. | Start again from the first page. See [what a cursor is bound to](/api/pagination#what-a-cursor-is-bound-to). |
| `invalid_json`          | The request body is not valid JSON.                                                                                                                                                     | Fix the JSON.                                                                                                |
| `invalid_body`          | The body could not be read at all, for example because of an unsupported charset or content encoding.                                                                                   | Send the body as uncompressed UTF-8 JSON.                                                                    |
| `body_not_allowed`      | A `GET` or `HEAD` request declared a body.                                                                                                                                              | Remove the body. Reads take none.                                                                            |
| `text_immutable`        | `PATCH /orgs/{orgId}/prompts/{promptId}` carried `text`. A prompt's text is its identity.                                                                                               | Deactivate the prompt and create a new one, or use the prompt sync.                                          |
| `duplicate_prompt_text` | `PUT /orgs/{orgId}/brands/{brandId}/prompts` carried the same text twice. Matching ignores case and surrounding whitespace. `details.texts` lists the duplicates.                       | Send each prompt once.                                                                                       |

### 401 Unauthorized

| Code              | When it happens                                                                                                                                                                                                                     | What to do                                                                               |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| `invalid_api_key` | Every authentication failure, deliberately uniform. The header is missing or malformed, or the key is unknown, revoked or expired, or its owner's account is no longer active. `sfs_test_` keys are rejected by the production API. | Check the `Authorization` header and the key. See [Authentication](/api/authentication). |

### 402 Payment Required

| Code              | When it happens                                                                                                                                                                                         | What to do                                                                                               |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `no_manual_scans` | `POST /orgs/{orgId}/brands/{brandId}/scan-requests`: the organisation has no manual scans left this month and no purchased scan credits. `details` carries `manual_scans_remaining` and `scan_credits`. | Wait for the next scheduled scan or the next month's allowance. See [Manual scans](/scans/manual-scans). |

### 403 Forbidden

| Code                      | When it happens                                                                                                                                                                          | What to do                                                                  |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `tier_not_entitled`       | The organisation's plan does not include API access. This is checked on every request, so it can start after a plan change. On a scan request, it means the plan has no on-demand scans. | Move the organisation to a plan that includes the feature.                  |
| `write_scope_required`    | A write was sent with a key that does not carry the `write` scope.                                                                                                                       | Use a key with the `write` scope.                                           |
| `link_scope_insufficient` | A partner key with the `write` scope wrote to an organisation whose link to the partner is `read_only`.                                                                                  | Writes need a `read_write` link. See [Authentication](/api/authentication). |
| `partner_only`            | `PATCH /orgs/{orgId}/link`, or a webhook management endpoint, was called with an organisation key.                                                                                       | Use a partner key.                                                          |

### 404 Not Found

| Code        | When it happens                                                                                                                                                                                                                                                                                            | What to do                                                |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
| `not_found` | The organisation is not reachable with this key, or a brand, prompt or competitor id does not belong to it, or the path does not exist. For partners it is also the answer for a webhook endpoint, subscription or delivery that belongs to another partner. The API never confirms that something exists. | Check the ids and that your key reaches the organisation. |

### 405 Method Not Allowed

| Code            | When it happens                                                                | What to do                                                     |
| --------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------- |
| `cors_disabled` | A browser sent a CORS preflight (`OPTIONS`). The API is server-to-server only. | Call the API from your backend. Never ship a key to a browser. |

### 409 Conflict

| Code                        | When it happens                                                                                                                                                                                                                                                                                                                                                                                                                                                      | What to do                                                                                                                                                      |
| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `conflict`                  | A uniqueness rule refused the write. Either an active prompt with the same text already exists for the brand (`details.existing_id`), or the competitor is already tracked for the brand (`details.competitor_id`). On `POST /orgs/{orgId}/properties` with `details.brand_id`, see [When part of a write happened](#when-part-of-a-write-happened): the code there can also be the failing competitor's own code, so branch on `details.brand_id`, not on the code. | Use the existing resource.                                                                                                                                      |
| `brand_exists`              | `POST /orgs/{orgId}/properties`: an own brand with this domain already exists. `details.existing_id` names it. Nothing was created.                                                                                                                                                                                                                                                                                                                                  | Use the existing brand.                                                                                                                                         |
| `external_ref_exists`       | `POST /orgs/{orgId}/properties`: a brand with this `external_ref` already exists. `details.existing_id` names it. Nothing was created.                                                                                                                                                                                                                                                                                                                               | Use the existing brand.                                                                                                                                         |
| `brand_archived`            | The write would add tracking to an archived own brand, one whose `sunset_at` is set. `details.brand_id` names it. Until the brand is restored it accepts only deactivating a prompt and unlinking a competitor.                                                                                                                                                                                                                                                      | The same request succeeds once the brand is restored.                                                                                                           |
| `no_active_prompts`         | A scan was requested for an own brand with no active prompt. Nothing was queued and no scan allowance was used. `details.brand_id` names the brand.                                                                                                                                                                                                                                                                                                                  | Add or reactivate a prompt first.                                                                                                                               |
| `scan_already_queued`       | A scan for this brand is already queued or running today. `details.job_id` names it and may be `null`.                                                                                                                                                                                                                                                                                                                                                               | Treat it as success. Do not retry.                                                                                                                              |
| `scan_in_progress`          | A page of results, mentions or sources could not be read while a scan of that brand was finalising. It can happen on any such page; larger pages are more exposed. Carries `Retry-After`.                                                                                                                                                                                                                                                                            | Wait `Retry-After` seconds, then send the same request. See [reading results while a scan is running](/api/pagination#reading-results-while-a-scan-is-running). |
| `prompt_writes_busy`        | Another request is writing this brand's prompts right now. Nothing was changed.                                                                                                                                                                                                                                                                                                                                                                                      | Retry in a moment, under a **new** `Idempotency-Key`: this answer is stored against the key you sent.                                                           |
| `idempotency_key_reuse`     | The `Idempotency-Key` was already used for a different request.                                                                                                                                                                                                                                                                                                                                                                                                      | Use a new key for a new request. See [Idempotent requests](/api/idempotency).                                                                                   |
| `idempotency_key_in_flight` | A request with this `Idempotency-Key` is still running.                                                                                                                                                                                                                                                                                                                                                                                                              | Retry shortly with the same key.                                                                                                                                |
| `delivery_in_flight`        | A webhook delivery replay was requested while an attempt was being made.                                                                                                                                                                                                                                                                                                                                                                                             | Retry once the attempt has settled, under a **new** `Idempotency-Key`.                                                                                          |
| `endpoint_disabled`         | A test ping was requested for a webhook endpoint whose status is `disabled`. Only the Surfais team sets that status.                                                                                                                                                                                                                                                                                                                                                 | [Contact support](/api/support).                                                                                                                                |
| `link_revoked`              | A webhook delivery replay was requested for an event whose organisation is no longer linked to your partner account.                                                                                                                                                                                                                                                                                                                                                 | Do not retry. The delivery cannot be sent while the organisation is not linked to you.                                                                          |

Inside a prompt sync, `prompt_writes_busy` is reported per row, as one `failed` result in the `200`.

### 413 Payload Too Large

| Code                | When it happens                       | What to do                |
| ------------------- | ------------------------------------- | ------------------------- |
| `payload_too_large` | The request body is larger than 1 MB. | Send less in one request. |

### 422 Unprocessable Entity

A plan limit refused the write. `details` can carry `tier`, `cap` and `attempted`. Every one of those fields is optional.

| Code                      | When it happens                                                                                                                                                                                               | What to do                                                     |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
| `prompt_cap_exceeded`     | The write would take the organisation over its plan's limit on active prompts. On the Agency plan the limit is on prompt-country slots: each active prompt uses one for every country it is tracked in.       | Deactivate prompts you no longer need, or change plan.         |
| `country_cap_exceeded`    | The write would take the organisation over its plan's limit on countries. On `POST /orgs/{orgId}/properties` nothing was created, and `details` carries `cap`, `attempted`, `currently_used` and `requested`. | Use countries the organisation already tracks, or change plan. |
| `competitor_cap_exceeded` | The brand would go over its plan's limit on competitors. On `POST /orgs/{orgId}/properties` the brand **was created**. See [When part of a write happened](#when-part-of-a-write-happened).                   | Unlink a competitor, or change plan.                           |
| `brand_cap_exceeded`      | The organisation would go over its plan's limit on own brands. Nothing was created.                                                                                                                           | Change plan.                                                   |

Read `usage` on `GET /orgs/{orgId}` to see what is used against the prompt, own-brand and country limits, and the per-brand competitor limit, before you write. Only active prompts count, so deactivating a prompt frees its place. [Plans and limits](/billing/plans-limits) lists the limits by plan.

### 429 Too Many Requests

Both codes carry `Retry-After`. See [Rate limits and quotas](/api/rate-limits).

| Code             | When it happens                                                                                                                                                       | What to do                                            |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| `rate_limited`   | The key's per-minute allowance is used up. It is also the answer when one address has made too many failed authentication attempts in a minute, even for a valid key. | Wait `Retry-After` seconds.                           |
| `quota_exceeded` | The key's monthly quota is used up. `Retry-After` counts down to the end of the calendar month in UTC.                                                                | Stop until the quota resets, or ask for a higher one. |

### 500 Internal Server Error

| Code              | When it happens                                                                                                                                               | What to do                                                                                                                    |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `internal_error`  | An unexpected failure on our side.                                                                                                                            | Retry, with the same `Idempotency-Key` if the request was a write. If it persists, contact support with the `request_id`.     |
| `field_too_large` | `GET /orgs/{orgId}/brands/{brandId}/share-of-voice` only: the brand tracks too many competitors for its share of voice to be measured in one consistent read. | Do not retry. Reduce the brand's tracked competitors, or read each brand through `GET /orgs/{orgId}/brands/{brandId}/scores`. |

### 503 Service Unavailable

Every `503` carries `Retry-After`.

| Code                     | When it happens                                                                                          | What to do                  |
| ------------------------ | -------------------------------------------------------------------------------------------------------- | --------------------------- |
| `rate_limit_unavailable` | The API could not check your key or count the request, so it refused it instead of serving it unmetered. | Wait `Retry-After` seconds. |
| `store_unavailable`      | The request was admitted, but the data store did not answer in time.                                     | Wait `Retry-After` seconds. |
| `api_unavailable`        | The API is switched off. Every request gets this answer, whatever its method or body.                    | Wait `Retry-After` seconds. |

## Which errors to retry

| Retry?                   | Responses                                                                                                         | How                                                                                                                                                                                         | On a write, which `Idempotency-Key`?                                                                                                                       |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Yes, after `Retry-After` | `429 rate_limited`, `429 quota_exceeded`, every `503`, `409 scan_in_progress`                                     | Wait the number of seconds in the `Retry-After` header, then send the same request. For `quota_exceeded` that wait runs to the end of the month, so schedule the retry instead of sleeping. | The same key. None of these is stored against it.                                                                                                          |
| Yes, shortly             | `409 idempotency_key_in_flight`                                                                                   | No `Retry-After`. The first request with this key is still running: back off for a moment and send the same request.                                                                        | The same key.                                                                                                                                              |
| Yes, shortly             | `409 prompt_writes_busy`, `409 delivery_in_flight`                                                                | No `Retry-After`. Nothing was changed. Back off for a moment and send the request again.                                                                                                    | A **new** key. These answers come from processing the request, so they are stored against the key: the same key replays the `409` instead of trying again. |
| Yes, with care           | `500 internal_error`                                                                                              | Retry with backoff. On a write, send the same `Idempotency-Key`. A `5xx` is never stored against the key, so the retry runs the request again.                                              | The same key.                                                                                                                                              |
| No                       | Every `400`, `401`, `402`, `403`, `404`, `405`, `413` and `422`, the other `409` codes, and `500 field_too_large` | The same request gets the same answer. Change the request, or the state it conflicts with, first.                                                                                           | A **new** key once you have fixed the cause. The old key replays the refusal.                                                                              |

One `400` has an automatic remedy: on `invalid_cursor`, start the walk again from the first page.

A `5xx` on a write does not tell you whether the write was applied. Provisioning a property and syncing prompts apply their rows one at a time, so a failure part-way leaves the earlier rows in place. Send an `Idempotency-Key` with every write, and after a timeout or a `5xx` retry with the same key. A create that did land the first time then answers `409` with the id of the existing resource. See [Idempotent requests](/api/idempotency).

## Request bodies

Writes take a JSON body of at most 1 MB. A larger body answers `413 payload_too_large`, malformed JSON answers `400 invalid_json`, and a body on a `GET` answers `400 body_not_allowed`.

Unknown body fields are ignored, with two documented exceptions. `text` on `PATCH /orgs/{orgId}/prompts/{promptId}` answers `400 text_immutable`. The bodies of `POST /webhook-endpoints` and `POST /webhook-endpoints/{endpointId}/subscriptions` are strict, so an unknown field there answers `400 validation_error`.

A request body must arrive in full within 10 seconds. After that the connection is closed without a response.

## Handling errors in code

Read the envelope once, keep the `code`, the `details` and the request id, and branch on the code.

<CodeGroup>
  ```bash cURL theme={null}
  # -i prints the status line and the headers, including X-Request-Id.
  # Set ORG_ID and BRAND_ID to ids your key can reach.
  curl -i -G "https://api.surfais.com/v1/orgs/$ORG_ID/brands/$BRAND_ID/scores" \
    --data-urlencode "from=2026-09-10" \
    --data-urlencode "to=2026-09-01" \
    -H "Authorization: Bearer $SURFAIS_API_KEY"
  ```

  ```python Python theme={null}
  import os

  import requests

  BASE_URL = "https://api.surfais.com/v1"
  API_KEY = os.environ["SURFAIS_API_KEY"]
  ORG_ID = "7b1e6c1a-3f52-4d0e-9a57-2c8f1e0b4d11"
  BRAND_ID = "c2a4e8f0-6b1d-4c3a-8e5f-9d7b2a1c4e60"


  class SurfaisApiError(Exception):
      """An error envelope: the status, the stable code, its details and the request id."""

      def __init__(self, response):
          body = response.json()
          self.status = response.status_code
          self.code = body["error"]["code"]
          self.details = body["error"].get("details")
          self.request_id = body["request_id"]
          super().__init__(
              f'{self.status} {self.code}: {body["error"]["message"]} '
              f"(request {self.request_id})"
          )


  def get(path, params=None):
      response = requests.get(
          f"{BASE_URL}{path}",
          headers={"Authorization": f"Bearer {API_KEY}"},
          params=params,
          timeout=30,
      )
      if not response.ok:
          raise SurfaisApiError(response)
      return response.json()


  try:
      scores = get(
          f"/orgs/{ORG_ID}/brands/{BRAND_ID}/scores",
          {"from": "2026-09-10", "to": "2026-09-01"},  # from is after to
      )
      print(len(scores["data"]), "points")
  except SurfaisApiError as error:
      if error.code == "validation_error":
          for problem in error.details:
              print(f'{problem["path"]}: {problem["code"]} ({problem["message"]})')
      elif error.code == "not_found":
          print(f"This key cannot reach that brand (request {error.request_id})")
      else:
          raise
  ```

  ```javascript Node.js theme={null}
  // Node 18+. Save as read-scores.mjs and run: node read-scores.mjs
  const BASE_URL = "https://api.surfais.com/v1";
  const API_KEY = process.env.SURFAIS_API_KEY;
  const ORG_ID = "7b1e6c1a-3f52-4d0e-9a57-2c8f1e0b4d11";
  const BRAND_ID = "c2a4e8f0-6b1d-4c3a-8e5f-9d7b2a1c4e60";

  // An error envelope: the status, the stable code, its details and the request id.
  class SurfaisApiError extends Error {
    constructor(status, body) {
      super(`${status} ${body.error.code}: ${body.error.message} (request ${body.request_id})`);
      this.status = status;
      this.code = body.error.code;
      this.details = body.error.details;
      this.requestId = body.request_id;
    }
  }

  async function get(path, params = {}) {
    const response = await fetch(`${BASE_URL}${path}?${new URLSearchParams(params)}`, {
      headers: { Authorization: `Bearer ${API_KEY}` },
    });
    const body = await response.json();
    if (!response.ok) throw new SurfaisApiError(response.status, body);
    return body;
  }

  try {
    const scores = await get(`/orgs/${ORG_ID}/brands/${BRAND_ID}/scores`, {
      from: "2026-09-10", // from is after to
      to: "2026-09-01",
    });
    console.log(scores.data.length, "points");
  } catch (error) {
    if (!(error instanceof SurfaisApiError)) throw error;
    if (error.code === "validation_error") {
      for (const problem of error.details) {
        console.log(`${problem.path}: ${problem.code} (${problem.message})`);
      }
    } else if (error.code === "not_found") {
      console.log(`This key cannot reach that brand (request ${error.requestId})`);
    } else {
      throw error;
    }
  }
  ```
</CodeGroup>
