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

# Sync a brand's prompts

> Send the prompt set you want for an own brand, and the API creates, updates, reactivates and deactivates prompts to match.

`PUT /orgs/{orgId}/brands/{brandId}/prompts` is declarative. You send the set of prompts you want active for one own brand. The API compares that set with what the brand has and makes the changes: it creates what is new, updates what differs, switches back on what was deactivated earlier and, by default, deactivates what you left out.

<Note>
  The call needs a key with the `write` scope. A partner key also needs a `read_write` link to the organisation. See [Authentication](/api/authentication).
</Note>

## When to use the sync

| You want to                                                              | Use                                                       |
| ------------------------------------------------------------------------ | --------------------------------------------------------- |
| Make a brand's prompts match a list you keep in your own system          | `PUT /orgs/{orgId}/brands/{brandId}/prompts` — this guide |
| Add one prompt and touch nothing else                                    | `POST /orgs/{orgId}/prompts`                              |
| Change one prompt's countries, platforms or tags, or switch it on or off | `PATCH /orgs/{orgId}/prompts/{promptId}`                  |
| Deactivate one prompt                                                    | `DELETE /orgs/{orgId}/prompts/{promptId}`                 |

The sync treats a returning prompt differently from `POST`, and the difference matters for history:

* The sync **reactivates**. When the text you send exists only as a deactivated prompt, the sync switches that prompt back on, so its id and its history carry on.
* `POST` **never reactivates**. A deactivated prompt with the same text does not block the create, so `POST` adds a second prompt beside it and the history starts again. To bring back one prompt without a sync, send `PATCH` with `"active": true`.

When an *active* prompt with the same text already exists, `POST` answers `409 conflict` with `details.existing_id`. The sync reports that prompt as `unchanged` or `updated` instead.

## Request body

<ParamField body="prompts" type="object[]" required>
  The prompts you want active for the brand. At most 500 rows per call; a longer list is `400 validation_error` on `prompts`.

  The list may be empty. With `deactivate_missing` on, an empty list deactivates every active prompt of the brand.
</ParamField>

<ParamField body="prompts[].text" type="string" required>
  The prompt, written the way a person would type it into an AI assistant. 1 to 500 characters. Leading and trailing whitespace is trimmed.
</ParamField>

<ParamField body="prompts[].countries" type="string[]" required>
  The markets the prompt runs in, as ISO 3166-1 alpha-2 codes such as `IE`. At least one. Case does not matter; codes are upper-cased and duplicates are dropped. A code Surfais does not scan is `400 validation_error` with `details[].code` set to `unsupported_country`.
</ParamField>

<ParamField body="prompts[].platforms" type="string[]">
  The AI platforms the prompt runs on: any of `perplexity`, `chatgpt`, `gemini`, `claude` and `ai_overviews`. At least one when you send the field.

  Leave it out of a new prompt to run on every platform. Leave it out of an existing prompt to keep its platforms as they are.
</ParamField>

<ParamField body="prompts[].tags" type="string[]">
  Free-text labels. At most 50 tags on a prompt. Each tag is 1 to 100 characters, and at most 256 bytes of UTF-8, after trimming, and holds no control characters. An empty tag is refused. Duplicates are dropped. Case is kept, so `Launch` and `launch` are two tags.

  Leave it out of a new prompt for no tags. Leave it out of an existing prompt to keep its tags as they are.
</ParamField>

<ParamField body="deactivate_missing" type="boolean" default="true">
  Deactivate every active prompt of the brand that is not in `prompts`. See [Deactivating what you left out](#deactivating-what-you-left-out).
</ParamField>

<ParamField body="dry_run" type="boolean" default="false">
  Return the planned action for every row and write nothing.
</ParamField>

Some problems refuse the whole request, and then nothing is applied:

| Status | `error.code`            | When                                                                                                                           |
| ------ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `400`  | `duplicate_prompt_text` | Two rows carry the same text, ignoring case and surrounding whitespace. `details.texts` lists the repeated texts.              |
| `400`  | `validation_error`      | A field failed validation. When `brandId` is a competitor, `details[].code` is `own_brand_only`: only own brands have prompts. |
| `404`  | `not_found`             | The brand is not in this organisation.                                                                                         |
| `409`  | `brand_archived`        | The brand is archived. `details.brand_id` names it. An empty sync is refused too.                                              |

## How rows are matched

A row matches an existing prompt by its `text`, within the brand, **ignoring case and ignoring whitespace at either end**. `Best boutique hotels in Dublin` and ` best boutique hotels in dublin` are the same prompt. Everything between the first and last character counts, including spacing and punctuation.

**Text is identity.** No operation edits a prompt's wording:

* To reword a prompt, send the new text and leave the old text out. The new text becomes a new prompt. The old prompt is deactivated and keeps the results it has collected.
* `PATCH /orgs/{orgId}/prompts/{promptId}` with a `text` field is `400 text_immutable`.
* The sync never rewrites stored text. If you send a different capitalisation of an existing prompt, the row matches and the stored spelling stays.

Once a row has matched, the API decides whether anything differs:

* `countries` are compared as a set. Order and case do not matter.
* `platforms` and `tags` are compared only when you send them.
* Only the fields that differ are written.

<Accordion title="When several active prompts share one text">
  Prompts added outside the API can differ only by whitespace at the ends, so a brand can hold several active prompts that match one text. The sync takes the **oldest** as the match. It treats the others as prompts you did not name, so `deactivate_missing` deactivates them.
</Accordion>

## The actions

Every entry of `results` carries one `action`.

| `action`      | What it means                                                                                                                                                                                                                  |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `created`     | The brand had no prompt with this text. A new prompt was created.                                                                                                                                                              |
| `updated`     | An active prompt with this text existed, and its countries — or the platforms or tags you sent — differed. The differing fields were written.                                                                                  |
| `reactivated` | This text existed only as a deactivated prompt. That prompt was switched back on with the fields you sent. It keeps its id and its history, and is never duplicated. If several deactivated prompts match, the newest is used. |
| `unchanged`   | An active prompt with this text already matched. Nothing was written.                                                                                                                                                          |
| `deactivated` | An active prompt of the brand was not in your list, so it was switched off.                                                                                                                                                    |
| `failed`      | This row could not be applied. `error` carries a `code` and a `message`. The rest of the request still ran.                                                                                                                    |
| `reasserted`  | Another writer changed this prompt's countries, platforms or tags while your sync was running. The sync put back the values you asked for, and tells you.                                                                      |
| `reinstated`  | Another writer switched this prompt off while your sync was running. The sync switched it back on with the values you asked for, and tells you.                                                                                |

"Another writer" is anyone else changing the brand's prompts at that moment: a colleague in the dashboard, or another API call.

`reasserted` and `reinstated` are rare, and they appear only when `deactivate_missing` is `true`: they come from a final check that such a sync makes before it answers. Each one is an **extra** entry in `results`. The prompt keeps its own entry for the action it was first applied as, and `summary` counts the two separately. When nothing failed, `created + updated + reactivated + unchanged` equals the number of prompts you sent.

## Deactivating what you left out

With `deactivate_missing: true`, which is the default, the brand's active set ends up equal to your list. Every active prompt you did not name is deactivated.

* **Deactivations run first.** The slots they free are available to the creates and reactivations later in the same call. An organisation at its prompt cap or country cap can therefore rotate its prompts in one request.
* **Deactivating never deletes.** The prompt and its history stay. It stops being scanned, and it stops taking up a slot under the plan's caps. The API has no call that deletes a prompt.
* **The last step checks the result.** Just before it answers, the sync reads the brand's active prompts again and switches off any that are not in your list — including one that another writer added while the sync was running. A `200` therefore describes the brand's real active set.

With `deactivate_missing: false`, the sync only creates, updates and reactivates. It switches nothing off.

<Warning>
  The default is `true`. If you send only part of a brand's prompts, every other active prompt of that brand is deactivated. When other people add prompts to the same brand in the dashboard and you want to keep them, send `"deactivate_missing": false`.
</Warning>

## Not atomic: read the response

<Warning>
  A `200` does not mean that every row was applied. Rows are applied one at a time, and a row that is refused comes back as `failed` **inside the `200`**, while the rest of the request still runs. Always check `summary.failed`.
</Warning>

A `failed` row carries its own `error`. These are the codes to expect:

| `error.code`           | What happened                                                                                                                                                                | What to do                                             |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `prompt_cap_exceeded`  | Creating or reactivating this prompt would take the organisation past its cap on active prompts, or on the Agency plan its cap on prompt-country slots.                      | Free a slot or raise the cap, then run the sync again. |
| `country_cap_exceeded` | This row's countries would take the organisation past its cap on distinct markets.                                                                                           | Drop the market or free one, then run the sync again.  |
| `brand_archived`       | The brand was archived while the sync was running.                                                                                                                           | Nothing can be added until the brand is restored.      |
| `conflict`             | Another writer created an active prompt with the same text at the same moment. Rare: the sync normally adopts the other writer’s prompt and reports it under its own action. | Run the sync again.                                    |
| `prompt_changed`       | Another writer renamed this prompt, or moved it to another brand, after the sync had matched it. Nothing was applied to it.                                                  | Run the sync again.                                    |
| `not_found`            | The prompt was deleted while the sync was running. It is not re-created in the same call.                                                                                    | Run the sync again; the prompt is created afresh.      |
| `prompt_writes_busy`   | Another request was writing this brand's prompts at that moment. Nothing was changed for this row.                                                                           | Run the sync again in a moment.                        |

Treat any other code the same way: nothing was applied to that row.

Two more rules make the outcome predictable:

* **A refused row is left exactly as it was.** If the prompt already existed and was active, it stays active with the fields it had. A failed update never deactivates the prompt, even with `deactivate_missing` on. The exceptions are `prompt_changed` and `not_found`, where the prompt is no longer the one your text matched. A renamed prompt can therefore appear twice: once as `failed` with `prompt_changed`, and once as `deactivated`, because its new text is not in your list.
* **Any other failure ends the whole request** with `500 internal_error`, or with `503 store_unavailable` when the data store did not answer in time. Rows applied before the failure stay applied, and the response never claims more than was done. Run the sync again. It is idempotent by design: the API builds the plan again from the brand's current state, and rows that were already applied come back as `unchanged`.

## The response

<ResponseField name="summary" type="object">
  Eight counters, one per action: `created`, `updated`, `reactivated`, `unchanged`, `reasserted`, `reinstated`, `deactivated` and `failed`.
</ResponseField>

<ResponseField name="results" type="object[]">
  One entry per prompt you sent, in request order, then one entry per prompt that was deactivated. Entries added by the final check come last: `reasserted`, `reinstated`, and any late `failed` or `deactivated`.

  Each entry has `text` and `action`. `prompt_id` is present whenever the entry maps to an existing prompt, and on `created` entries after a real run. `error` is present on `failed` entries. For the prompts you sent, `text` is the text you sent; on the other entries it is the stored text.
</ResponseField>

<ResponseField name="usage" type="object">
  The organisation's usage after the run. `prompts.used` is the number of active prompts in the whole organisation (on the Agency plan, the prompt-country slots they use: one for every country each prompt is tracked in), and `countries.used` lists the distinct markets those prompts hold. Each has a `cap`, which is `null` when caps are not enforced for the organisation.
</ResponseField>

This request names five prompts for a brand that also has one active prompt the list leaves out:

```json theme={null}
{
  "prompts": [
    { "text": "best boutique hotels in Dublin city centre", "countries": ["IE", "GB"] },
    { "text": "hotels near Dublin Airport with parking", "countries": ["IE"], "tags": ["airport"] },
    { "text": "romantic weekend hotel in Dublin", "countries": ["IE", "GB"] },
    { "text": "family hotels in Dublin with a pool", "countries": ["IE"] },
    { "text": "luxury hotels in Dublin for visitors from France", "countries": ["FR"] }
  ]
}
```

```json theme={null}
{
  "data": {
    "summary": {
      "created": 1,
      "updated": 1,
      "reactivated": 1,
      "unchanged": 1,
      "reasserted": 0,
      "reinstated": 0,
      "deactivated": 1,
      "failed": 1
    },
    "results": [
      {
        "text": "best boutique hotels in Dublin city centre",
        "action": "unchanged",
        "prompt_id": "a1f4c7e2-5b8d-4e1a-9c3f-6d2b8e0a4c71"
      },
      {
        "text": "hotels near Dublin Airport with parking",
        "action": "updated",
        "prompt_id": "b2e5d8f3-6c9e-4f2b-8d4a-7e3c9f1b5d82"
      },
      {
        "text": "romantic weekend hotel in Dublin",
        "action": "reactivated",
        "prompt_id": "c3f6e9a4-7d0f-4a3c-9e5b-8f4d0a2c6e93"
      },
      {
        "text": "family hotels in Dublin with a pool",
        "action": "created",
        "prompt_id": "d4a7f0b5-8e1a-4b4d-af6c-9a5e1b3d7fa4"
      },
      {
        "text": "luxury hotels in Dublin for visitors from France",
        "action": "failed",
        "error": {
          "code": "country_cap_exceeded",
          "message": "Countries cap reached."
        }
      },
      {
        "text": "cheap hostels in Dublin",
        "action": "deactivated",
        "prompt_id": "e5b8a1c6-9f2b-4c5e-b07d-0b6f2c4e8ab5"
      }
    ],
    "usage": {
      "prompts": { "used": 41, "cap": 140 },
      "countries": { "used": ["GB", "IE", "US"], "cap": 4 }
    }
  }
}
```

The numbers in this example are illustrative. Do not parse `message`; the organisation’s current caps are whatever `usage` reports here and on `GET /orgs/{orgId}`.

A `dry_run` returns the same shape with the planned actions. Its `created` entries carry no `prompt_id`, because nothing was created, and its `usage` is the usage before the run.

## Recommended flow

<Steps>
  <Step title="Dry run">
    Send your list with `"dry_run": true`. The API answers with the action it plans for every row and writes nothing.
  </Step>

  <Step title="Inspect the summary">
    Check that the counts are the ones you expect, above all `deactivated`. A list that was cut short upstream shows up here as a large number of deactivations, before any of them has happened.

    A dry run does not test the caps. A row planned as `created` can still come back `failed` when you apply it. Compare `usage` with the rows you are adding to see how much room there is.
  </Step>

  <Step title="Apply with an Idempotency-Key">
    Send the same list without `dry_run`, and with an `Idempotency-Key` header. Use a new key, not the dry run's: the two bodies differ, and one key on two different requests is `409 idempotency_key_reuse`.
  </Step>

  <Step title="Check the failed rows">
    Read `summary.failed`. For every `failed` entry, read `error.code` and decide what to do from the table above.
  </Step>

  <Step title="Run it again if you need to">
    Once the cause is fixed, send the list again **with a new key**. The same key with the same body replays the stored `200` — the response carries `Idempotent-Replayed: true` — and applies nothing. Rows that were applied the first time come back as `unchanged`.
  </Step>
</Steps>

A dry run, with no key:

```bash cURL theme={null}
ORG_ID="7b1e6c1a-3f52-4d0e-9a57-2c8f1e0b4d11"
BRAND_ID="c2a4e8f0-6b1d-4c3a-8e5f-9d7b2a1c4e60"

curl -sS -X PUT "https://api.surfais.com/v1/orgs/$ORG_ID/brands/$BRAND_ID/prompts" \
  -H "Authorization: Bearer $SURFAIS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "dry_run": true,
    "prompts": [
      { "text": "best boutique hotels in Dublin city centre", "countries": ["IE", "GB"] },
      { "text": "hotels near Dublin Airport with parking", "countries": ["IE"], "tags": ["airport"] }
    ]
  }'
```

The apply call. The Python and Node.js samples retry the failures that are safe to retry, always with the same key, and then report every `failed` row.

<CodeGroup>
  ```bash cURL theme={null}
  ORG_ID="7b1e6c1a-3f52-4d0e-9a57-2c8f1e0b4d11"
  BRAND_ID="c2a4e8f0-6b1d-4c3a-8e5f-9d7b2a1c4e60"
  # One key per sync you intend. Send the same value again if you retry this request.
  IDEMPOTENCY_KEY="$(uuidgen)"

  curl -sS -i -X PUT "https://api.surfais.com/v1/orgs/$ORG_ID/brands/$BRAND_ID/prompts" \
    -H "Authorization: Bearer $SURFAIS_API_KEY" \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
    -d '{
      "deactivate_missing": true,
      "prompts": [
        { "text": "best boutique hotels in Dublin city centre", "countries": ["IE", "GB"] },
        { "text": "hotels near Dublin Airport with parking", "countries": ["IE"], "tags": ["airport"] }
      ]
    }'
  ```

  ```python Python theme={null}
  import os
  import sys
  import time
  import uuid

  import requests

  BASE_URL = "https://api.surfais.com/v1"
  ORG_ID = "7b1e6c1a-3f52-4d0e-9a57-2c8f1e0b4d11"
  BRAND_ID = "c2a4e8f0-6b1d-4c3a-8e5f-9d7b2a1c4e60"
  URL = f"{BASE_URL}/orgs/{ORG_ID}/brands/{BRAND_ID}/prompts"

  sync_body = {
      "deactivate_missing": True,
      "prompts": [
          {"text": "best boutique hotels in Dublin city centre", "countries": ["IE", "GB"]},
          {"text": "hotels near Dublin Airport with parking", "countries": ["IE"], "tags": ["airport"]},
      ],
  }

  # Safe to retry with the same key. quota_exceeded is left out on purpose:
  # its Retry-After counts to the end of the month.
  RETRYABLE_CODES = {"rate_limited", "internal_error", "idempotency_key_in_flight"}


  def apply_sync(body, max_attempts=5):
      # One key for this sync. Every retry reuses it, so the sync is never applied twice.
      idempotency_key = str(uuid.uuid4())

      for attempt in range(max_attempts):
          try:
              response = requests.put(
                  URL,
                  headers={
                      "Authorization": f"Bearer {os.environ['SURFAIS_API_KEY']}",
                      "Idempotency-Key": idempotency_key,
                  },
                  json=body,
                  timeout=120,
              )
          except requests.RequestException:
              time.sleep(2**attempt)
              continue

          if response.status_code == 200:
              return response.json()["data"]

          try:
              payload = response.json()
              code = payload["error"]["code"]
          except (ValueError, KeyError):
              payload, code = {}, None

          if response.status_code == 503 or code in RETRYABLE_CODES:
              retry_after = response.headers.get("Retry-After")
              time.sleep(int(retry_after) if retry_after else 2**attempt)
              continue

          sys.exit(f"{response.status_code} {code} (request {payload.get('request_id')})")

      sys.exit("The sync did not complete. Run it again.")


  result = apply_sync(sync_body)
  print(result["summary"])

  failed = [row for row in result["results"] if row["action"] == "failed"]
  for row in failed:
      print("failed:", row["text"], "-", row["error"]["code"])
  if failed:
      sys.exit(1)
  ```

  ```javascript Node.js theme={null}
  // sync-prompts.mjs — run with Node 18 or later: node sync-prompts.mjs
  import { randomUUID } from "node:crypto";

  const BASE_URL = "https://api.surfais.com/v1";
  const ORG_ID = "7b1e6c1a-3f52-4d0e-9a57-2c8f1e0b4d11";
  const BRAND_ID = "c2a4e8f0-6b1d-4c3a-8e5f-9d7b2a1c4e60";
  const URL_SYNC = `${BASE_URL}/orgs/${ORG_ID}/brands/${BRAND_ID}/prompts`;

  const syncBody = {
    deactivate_missing: true,
    prompts: [
      { text: "best boutique hotels in Dublin city centre", countries: ["IE", "GB"] },
      { text: "hotels near Dublin Airport with parking", countries: ["IE"], tags: ["airport"] },
    ],
  };

  // Safe to retry with the same key. quota_exceeded is left out on purpose:
  // its Retry-After counts to the end of the month.
  const RETRYABLE_CODES = new Set(["rate_limited", "internal_error", "idempotency_key_in_flight"]);

  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

  async function applySync(body, maxAttempts = 5) {
    // One key for this sync. Every retry reuses it, so the sync is never applied twice.
    const idempotencyKey = randomUUID();

    for (let attempt = 0; attempt < maxAttempts; attempt++) {
      let response;
      try {
        response = await fetch(URL_SYNC, {
          method: "PUT",
          headers: {
            Authorization: `Bearer ${process.env.SURFAIS_API_KEY}`,
            "Content-Type": "application/json",
            "Idempotency-Key": idempotencyKey,
          },
          body: JSON.stringify(body),
          signal: AbortSignal.timeout(120_000),
        });
      } catch {
        await sleep(2 ** attempt * 1000);
        continue;
      }

      if (response.status === 200) return (await response.json()).data;

      const payload = await response.json().catch(() => ({}));
      const code = payload.error?.code;

      if (response.status === 503 || RETRYABLE_CODES.has(code)) {
        const retryAfter = Number(response.headers.get("Retry-After"));
        await sleep((retryAfter || 2 ** attempt) * 1000);
        continue;
      }

      throw new Error(`${response.status} ${code} (request ${payload.request_id})`);
    }

    throw new Error("The sync did not complete. Run it again.");
  }

  const result = await applySync(syncBody);
  console.log(result.summary);

  const failed = result.results.filter((row) => row.action === "failed");
  for (const row of failed) console.log("failed:", row.text, "-", row.error.code);
  if (failed.length > 0) process.exit(1);
  ```
</CodeGroup>

<Tip>
  Rows are applied one at a time, so give a long list a generous client timeout. If your client gives up first, the sync carries on at the server. Retry with the same `Idempotency-Key`: you get `409 idempotency_key_in_flight` while it is still running, and then the stored response. See [Idempotent requests](/api/idempotency).
</Tip>

## Caps

The sync never fails as a whole because of a plan cap. A cap refuses single rows, and they come back as `failed` with `prompt_cap_exceeded` or `country_cap_exceeded`. The single-prompt endpoints answer the same refusals as a `422` with the same codes. There, `details` can carry `tier`, `cap` and `attempted`, and each one is optional.

What the two caps count:

* **The prompt cap** counts the active prompts of the whole organisation, across all its own brands — not per brand.
* **The country cap** counts the distinct countries held by those active prompts, across the whole organisation.
* A deactivated prompt frees its slot. Reactivating one is checked against the caps exactly like a create, which is why a `reactivated` row can fail.

Read where you stand before you send a list:

* `usage` in every sync response, including a dry run;
* `usage` on `GET /orgs/{orgId}`, which also reports the brand cap and the per-brand competitor cap.

[Plans & limits](/billing/plans-limits) lists the caps of each plan. [Setting up your prompts](/setup/prompts) and [Countries & markets](/setup/countries) explain how to choose what to track.

## Next steps

<CardGroup cols={2}>
  <Card title="Run a scan" icon="radar" href="/api/guides/run-a-scan">
    Request a scan once the brand has active prompts.
  </Card>

  <Card title="Export results" icon="list" href="/api/guides/export-results">
    Read each prompt's results: which brands were mentioned, and which sources were cited.
  </Card>
</CardGroup>
