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

# Request a scan and wait for results

> Queue an on-demand scan for an own brand, handle each refusal, find out when the scan has finished, and read the new numbers.

Surfais scans every own brand automatically, on the [schedule](/scans/scan-schedule) its plan sets. Request a scan yourself when you need a fresh reading sooner: straight after you [provision a property](/api/guides/provision-a-property) or [sync its prompts](/api/guides/sync-prompts), for example.

A scan requested through the API is the same scan as the **Run Visibility Scan** button in the dashboard. It runs every active prompt of the brand, on the platforms and in the countries each prompt is set to, and it draws on the same [manual-scan allowance](/scans/manual-scans).

## Before you start

* **A key with the `write` scope.** A partner key also needs a `read_write` link to the organisation. See [Authentication](/api/authentication).
* **An own brand that is not archived, with at least one active prompt.** Competitors cannot be scanned on their own.
* **Scan allowance.** An accepted request uses one manual scan from the organisation's monthly allowance. When the allowance is used up, it uses one purchased scan credit instead. Plans with unlimited manual scans skip the check. [Manual scans & allowances](/scans/manual-scans) lists the allowance for each plan. This includes a brand’s first scan: the free baseline scan described there is a dashboard feature and does not apply to scans requested through the API.

The short samples read the organisation and brand ids from `SURFAIS_ORG_ID` and `SURFAIS_BRAND_ID`. The longer scripts take them as arguments.

## Request the scan

Call `POST /orgs/{orgId}/brands/{brandId}/scan-requests`. It takes no request body.

<CodeGroup>
  ```bash cURL theme={null}
  curl -sS -X POST \
    "https://api.surfais.com/v1/orgs/$SURFAIS_ORG_ID/brands/$SURFAIS_BRAND_ID/scan-requests" \
    -H "Authorization: Bearer $SURFAIS_API_KEY"
  ```

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

  import requests

  API = "https://api.surfais.com/v1"
  HEADERS = {"Authorization": f"Bearer {os.environ['SURFAIS_API_KEY']}"}
  ORG_ID = os.environ["SURFAIS_ORG_ID"]
  BRAND_ID = os.environ["SURFAIS_BRAND_ID"]

  resp = requests.post(
      f"{API}/orgs/{ORG_ID}/brands/{BRAND_ID}/scan-requests",
      headers=HEADERS,
      timeout=30,
  )
  body = resp.json()
  if resp.status_code == 202:
      print("queued:", body["data"]["job_id"])
  elif resp.status_code == 409 and body["error"]["code"] == "scan_already_queued":
      print("already on its way:", body["error"]["details"]["job_id"])
  else:
      sys.exit(f"{resp.status_code} {body['error']['code']} (request {body['request_id']})")
  ```

  ```javascript Node.js theme={null}
  const API = "https://api.surfais.com/v1";
  const headers = { Authorization: `Bearer ${process.env.SURFAIS_API_KEY}` };
  const orgId = process.env.SURFAIS_ORG_ID;
  const brandId = process.env.SURFAIS_BRAND_ID;

  async function main() {
    const res = await fetch(`${API}/orgs/${orgId}/brands/${brandId}/scan-requests`, {
      method: "POST",
      headers,
    });
    const body = await res.json();
    if (res.status === 202) {
      console.log("queued:", body.data.job_id);
    } else if (res.status === 409 && body.error.code === "scan_already_queued") {
      console.log("already on its way:", body.error.details.job_id);
    } else {
      throw new Error(`${res.status} ${body.error.code} (request ${body.request_id})`);
    }
  }

  main().catch((err) => {
    console.error(err.message);
    process.exit(1);
  });
  ```
</CodeGroup>

A `202` means the scan is queued. It has not run yet.

```json Response theme={null}
{
  "data": {
    "job_id": "e3b0c442-98fc-4c14-9afb-f4c8996fb924",
    "idempotency_key": "scan:7b1e6c1a-3f52-4d0e-9a57-2c8f1e0b4d11:c2a4e8f0-6b1d-4c3a-8e5f-9d7b2a1c4e60:2026-09-22",
    "status": "queued"
  }
}
```

| Field             | Meaning                                                                                                                                            |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `job_id`          | The queued scan. No endpoint reads a job back in v1, so you follow the scan through the brand, as shown [below](#know-when-the-scan-has-finished). |
| `idempotency_key` | The queue's own key for this brand and UTC day. Treat it as opaque.                                                                                |
| `status`          | Always `queued`.                                                                                                                                   |

## One scan in flight per brand

A brand can have one scan in flight per UTC day. While a scan that covers the brand is queued or running, another request that day answers `409 scan_already_queued`. It does not matter who started the first scan: you, someone in the dashboard, or the schedule.

```json Response theme={null}
{
  "error": {
    "code": "scan_already_queued",
    "message": "A visibility scan for this brand is already queued or running today.",
    "details": {
      "job_id": "e3b0c442-98fc-4c14-9afb-f4c8996fb924",
      "idempotency_key": "scan:7b1e6c1a-3f52-4d0e-9a57-2c8f1e0b4d11:c2a4e8f0-6b1d-4c3a-8e5f-9d7b2a1c4e60:2026-09-22"
    }
  },
  "request_id": "8c1f4a7e-2d5b-4e90-b3a6-7f0c9d2e1b48"
}
```

**Treat this `409` as success.** The scan you wanted is already on its way, and the refused request used no allowance. `details.job_id` names the scan that covers the brand. It can be `null`, so do not depend on it.

One exception: a scan reads the brand’s active prompts once, when it starts. If you changed the prompts after the scan in flight began, it does not include the change. Wait for it to finish, as described below, and request again.

The rule covers scans that are still queued or running. Once the scan has finished, a new request on the same day is accepted: it queues another scan, uses another manual scan, and its results replace the earlier ones for that date.

<Note>
  There are two different keys here. `idempotency_key` in these responses belongs to the scan queue. The `Idempotency-Key` request header is yours: like every write, this endpoint accepts it, and a retry with the same value replays your first response. See [Idempotent requests](/api/idempotency).
</Note>

## Refusals

A refused request queues nothing and uses no allowance.

| Status | `error.code`              | Why                                                                                                                                                              | What to do                                                                                                                          |
| ------ | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | `validation_error`        | `brandId` is a competitor. `details[].code` is `own_brand_only`.                                                                                                 | Request scans for own brands only.                                                                                                  |
| `402`  | `no_manual_scans`         | The organisation has no manual scans left this month and no purchased scan credits. `details` holds the balance.                                                 | Wait for the monthly allowance to reset, or for the next scheduled scan. The organisation can also buy scan credits or change plan. |
| `403`  | `tier_not_entitled`       | The organisation's plan does not include on-demand scans. An organisation key also gets this code on every endpoint when its plan no longer includes API access. | The organisation needs a plan that does. See [Plans & limits](/billing/plans-limits).                                               |
| `403`  | `write_scope_required`    | The key does not carry the `write` scope.                                                                                                                        | Use a key with the `write` scope.                                                                                                   |
| `403`  | `link_scope_insufficient` | A partner key's link to the organisation is `read_only`.                                                                                                         | The link needs the `read_write` scope.                                                                                              |
| `404`  | `not_found`               | The key cannot reach the organisation, or the brand is not in it.                                                                                                | Check both ids.                                                                                                                     |
| `409`  | `brand_archived`          | The own brand is archived. `details.brand_id` names it.                                                                                                          | The request succeeds once the brand is restored. See [archived brands](/api/data-model#archived-brands).                            |
| `409`  | `no_active_prompts`       | The brand has no active prompt, so there is nothing to scan. `details.brand_id` names it.                                                                        | Add or reactivate a prompt first. See [Sync prompts](/api/guides/sync-prompts).                                                     |
| `409`  | `scan_already_queued`     | A scan that covers the brand is already queued or running today.                                                                                                 | Treat it as success, as described above.                                                                                            |

The `402` carries the organisation's balance:

```json Response theme={null}
{
  "error": {
    "code": "no_manual_scans",
    "message": "The organisation has no manual scans left this month and no purchased scan credits.",
    "details": {
      "manual_scans_remaining": 0,
      "scan_credits": 0
    }
  },
  "request_id": "8c1f4a7e-2d5b-4e90-b3a6-7f0c9d2e1b48"
}
```

[Errors](/api/errors) covers the codes every endpoint shares, such as `401 invalid_api_key`, `429 rate_limited` and the `503` family.

## Know when the scan has finished

A scan takes minutes, not seconds. [What happens in a scan](/scans/how-scans-work) gives the typical duration. There are two ways to learn that it is done.

### Poll the brand

`GET /orgs/{orgId}/brands/{brandId}` carries two fields that move while a scan runs. [Data model](/api/data-model#freshness-two-different-fields) defines both.

* **`last_scan_at`** moves when the scan's first result is finalised, and again with each later one. When it differs from the value you read before your request, results are arriving. It does not tell you that the scan is complete.
* **`last_scan_changed_at`** moves with every one of those results, and again when the scan's scores are published. Scores are published once every result is in.

So treat the scan as finished when `last_scan_at` has moved since your request **and** `last_scan_changed_at` has then stayed unchanged for several minutes. This is a signal, not a status flag: after the last result there is a quiet stage, which can last minutes, before the scores are published, and a short quiet period mistakes it for the end. The scripts below wait for ten quiet minutes, then stop. They also give up after a timeout, because a scan can wait in the queue before it starts.

For the first scan of a date there is a definite check as well: once the scores are published, `latest.date` on `GET /orgs/{orgId}/brands/summary` is the scan date. For a second scan of a date that already has scores, polling has no definite signal, so allow a longer quiet period before you read the numbers.

<CodeGroup>
  ```python Python theme={null}
  """Request an on-demand scan for one own brand, then wait for it to finish.

  Usage: python run_scan.py <org_id> <brand_id>
  """
  import os
  import sys
  import time

  import requests

  API = "https://api.surfais.com/v1"
  POLL_SECONDS = 60          # a scan takes minutes, so poll slowly
  QUIET_POLLS = 10           # quiet minutes; scores follow the last result by some minutes
  TIMEOUT_SECONDS = 60 * 60  # stop waiting after an hour
  MAX_RETRY_AFTER = 300      # never sleep longer than this on a 429 or 503

  session = requests.Session()
  session.headers["Authorization"] = f"Bearer {os.environ['SURFAIS_API_KEY']}"


  def call(method, path):
      """Send one request. On 429 or 503, wait for Retry-After and try again."""
      while True:
          resp = session.request(method, f"{API}{path}", timeout=30)
          wait = int(resp.headers.get("Retry-After", "0"))
          if resp.status_code in (429, 503) and 0 < wait <= MAX_RETRY_AFTER:
              time.sleep(wait)
              continue
          return resp


  def error_of(resp):
      """The error envelope, or an empty one when the body is not JSON."""
      try:
          body = resp.json()
          return body["error"], body["request_id"]
      except (ValueError, KeyError):
          return {"code": "unknown", "message": resp.text[:200]}, None


  def fail(resp):
      error, request_id = error_of(resp)
      sys.exit(f"{resp.status_code} {error['code']}: {error['message']} (request {request_id})")


  def read_brand(org_id, brand_id):
      resp = call("GET", f"/orgs/{org_id}/brands/{brand_id}")
      if resp.status_code != 200:
          fail(resp)
      return resp.json()["data"]


  def request_scan(org_id, brand_id):
      resp = call("POST", f"/orgs/{org_id}/brands/{brand_id}/scan-requests")
      if resp.status_code == 202:
          return resp.json()["data"]["job_id"], False
      error, _ = error_of(resp)
      if resp.status_code == 409 and error["code"] == "scan_already_queued":
          return error["details"]["job_id"], True  # a scan is already running; job_id can be None
      fail(resp)


  def wait_for_scan(org_id, brand_id, scan_time_before, already_running):
      """Return the brand once the scan has settled, or None on timeout."""
      deadline = time.monotonic() + TIMEOUT_SECONDS
      token, quiet = None, 0
      while time.monotonic() < deadline:
          time.sleep(POLL_SECONDS)
          brand = read_brand(org_id, brand_id)
          # A scan that was already running may have finalised its last result before we
          # looked, so last_scan_at need not move again: wait for the quiet period only.
          started = already_running or brand["last_scan_at"] != scan_time_before
          unchanged = brand["last_scan_changed_at"] == token
          quiet = quiet + 1 if started and unchanged else 0
          token = brand["last_scan_changed_at"]
          if quiet >= QUIET_POLLS:
              return brand
      return None


  def main():
      org_id, brand_id = sys.argv[1], sys.argv[2]
      scan_time_before = read_brand(org_id, brand_id)["last_scan_at"]
      job_id, already_running = request_scan(org_id, brand_id)
      print(f"scan on its way (job {job_id}); waiting")
      brand = wait_for_scan(org_id, brand_id, scan_time_before, already_running)
      if brand is None:
          sys.exit("timed out: the brand's data had not settled")
      print(f"scan settled; last result finalised at {brand['last_scan_at']}")


  if __name__ == "__main__":
      main()
  ```

  ```javascript Node.js theme={null}
  // run-scan.mjs — Node.js 18 or later
  // Usage: node run-scan.mjs <orgId> <brandId>
  import { setTimeout as sleep } from "node:timers/promises";

  const API = "https://api.surfais.com/v1";
  const POLL_SECONDS = 60; // a scan takes minutes, so poll slowly
  const QUIET_POLLS = 10; // quiet minutes; scores follow the last result by some minutes
  const TIMEOUT_SECONDS = 60 * 60; // stop waiting after an hour
  const MAX_RETRY_AFTER = 300; // never sleep longer than this on a 429 or 503

  const headers = { Authorization: `Bearer ${process.env.SURFAIS_API_KEY}` };

  // Send one request. On 429 or 503, wait for Retry-After and try again.
  async function call(method, path) {
    for (;;) {
      const res = await fetch(`${API}${path}`, { method, headers, signal: AbortSignal.timeout(30_000) });
      const wait = Number(res.headers.get("retry-after") ?? 0);
      if ((res.status === 429 || res.status === 503) && wait > 0 && wait <= MAX_RETRY_AFTER) {
        await sleep(wait * 1000);
        continue;
      }
      return res;
    }
  }

  // The parsed body, or an error envelope built from the text when it is not JSON.
  async function bodyOf(res) {
    const text = await res.text();
    try {
      return JSON.parse(text);
    } catch {
      return { error: { code: "unknown", message: text.slice(0, 200) }, request_id: null };
    }
  }

  function fail(res, body) {
    throw new Error(`${res.status} ${body.error.code}: ${body.error.message} (request ${body.request_id})`);
  }

  async function readBrand(orgId, brandId) {
    const res = await call("GET", `/orgs/${orgId}/brands/${brandId}`);
    const body = await bodyOf(res);
    if (res.status !== 200) fail(res, body);
    return body.data;
  }

  async function requestScan(orgId, brandId) {
    const res = await call("POST", `/orgs/${orgId}/brands/${brandId}/scan-requests`);
    const body = await bodyOf(res);
    if (res.status === 202) return { jobId: body.data.job_id, alreadyRunning: false };
    if (res.status === 409 && body.error.code === "scan_already_queued") {
      // A scan is already running; job_id can be null.
      return { jobId: body.error.details.job_id, alreadyRunning: true };
    }
    return fail(res, body);
  }

  // Resolves with the brand once the scan has settled, or null on timeout.
  async function waitForScan(orgId, brandId, scanTimeBefore, alreadyRunning) {
    const deadline = Date.now() + TIMEOUT_SECONDS * 1000;
    let token = null;
    let quiet = 0;
    while (Date.now() < deadline) {
      await sleep(POLL_SECONDS * 1000);
      const brand = await readBrand(orgId, brandId);
      // A scan that was already running may have finalised its last result before we
      // looked, so last_scan_at need not move again: wait for the quiet period only.
      const started = alreadyRunning || brand.last_scan_at !== scanTimeBefore;
      const unchanged = brand.last_scan_changed_at === token;
      quiet = started && unchanged ? quiet + 1 : 0;
      token = brand.last_scan_changed_at;
      if (quiet >= QUIET_POLLS) return brand;
    }
    return null;
  }

  async function main() {
    const [orgId, brandId] = process.argv.slice(2);
    const scanTimeBefore = (await readBrand(orgId, brandId)).last_scan_at;
    const { jobId, alreadyRunning } = await requestScan(orgId, brandId);
    console.log(`scan on its way (job ${jobId}); waiting`);
    const brand = await waitForScan(orgId, brandId, scanTimeBefore, alreadyRunning);
    if (brand === null) throw new Error("timed out: the brand's data had not settled");
    console.log(`scan settled; last result finalised at ${brand.last_scan_at}`);
  }

  main().catch((err) => {
    console.error(err.message);
    process.exit(1);
  });
  ```
</CodeGroup>

<Tip>
  To follow scans for many brands, poll `GET /orgs/{orgId}/brands/summary` instead of each brand. It returns `last_scan_at` and the change token, there named `changed_at`, for every own brand in one list.
</Tip>

If you need a definite answer for the first scan of a date, use the webhook. A second scan on the same date sends no second `scan.completed`.

### Subscribe to `scan.completed`

Platform partners can receive a signed `scan.completed` webhook instead of polling. It is sent after the scan's scores are published, and it carries the brand's new AIS Score. A scan you request through the API triggers it like any other scan. See [Webhooks](/api/webhooks/overview) and the [event reference](/api/webhooks/events).

<Warning>
  `scan.completed` is sent once per own brand per scan date. If the brand had already completed a scan that UTC day, such as its scheduled one, your on-demand scan does not produce a second event. Poll the brand in that case.
</Warning>

## Read the numbers

Once the scan has finished, these three reads give you the new state.

| Read                                                | What you get                                                                                                                                                                                     |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET /orgs/{orgId}/brands/summary`                  | For every own brand: the latest and previous AIS Score with the change between them, mean sentiment and share of voice on the latest date. The [quickstart](/api/quickstart) shows the response. |
| `GET /orgs/{orgId}/brands/{brandId}/scores`         | The brand's score series. The scan's point is dated the UTC day you requested the scan on.                                                                                                       |
| `GET /orgs/{orgId}/brands/{brandId}/share-of-voice` | The brand against its competitors over a date window.                                                                                                                                            |

<CodeGroup>
  ```bash cURL theme={null}
  # The score point for the scan's date
  curl -sS "https://api.surfais.com/v1/orgs/$SURFAIS_ORG_ID/brands/$SURFAIS_BRAND_ID/scores?from=2026-09-22&to=2026-09-22" \
    -H "Authorization: Bearer $SURFAIS_API_KEY"

  # Share of voice over the default window
  curl -sS "https://api.surfais.com/v1/orgs/$SURFAIS_ORG_ID/brands/$SURFAIS_BRAND_ID/share-of-voice" \
    -H "Authorization: Bearer $SURFAIS_API_KEY"
  ```

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

  import requests

  API = "https://api.surfais.com/v1"
  HEADERS = {"Authorization": f"Bearer {os.environ['SURFAIS_API_KEY']}"}
  BRAND = f"{API}/orgs/{os.environ['SURFAIS_ORG_ID']}/brands/{os.environ['SURFAIS_BRAND_ID']}"
  SCAN_DATE = "2026-09-22"  # the UTC date you requested the scan on


  def get(url, params=None):
      resp = requests.get(url, headers=HEADERS, params=params, timeout=30)
      body = resp.json()
      if not resp.ok:
          sys.exit(f"{resp.status_code} {body['error']['code']} (request {body['request_id']})")
      return body


  points = get(f"{BRAND}/scores", {"from": SCAN_DATE, "to": SCAN_DATE})["data"]
  if not points:
      sys.exit("no score for that date yet: the scan's scores are not published")
  point = points[-1]
  print("AIS Score", point["score"], "model", point["score_version"], "panel", point["panel_version"])

  for entry in get(f"{BRAND}/share-of-voice")["data"]["entries"]:
      print(entry["name"], entry["sov"], entry["mentions"])
  ```

  ```javascript Node.js theme={null}
  const API = "https://api.surfais.com/v1";
  const headers = { Authorization: `Bearer ${process.env.SURFAIS_API_KEY}` };
  const brand = `${API}/orgs/${process.env.SURFAIS_ORG_ID}/brands/${process.env.SURFAIS_BRAND_ID}`;
  const scanDate = "2026-09-22"; // the UTC date you requested the scan on

  async function get(url) {
    const res = await fetch(url, { headers });
    const body = await res.json();
    if (!res.ok) {
      throw new Error(`${res.status} ${body.error.code} (request ${body.request_id})`);
    }
    return body;
  }

  async function main() {
    const points = (await get(`${brand}/scores?from=${scanDate}&to=${scanDate}`)).data;
    if (points.length === 0) {
      throw new Error("no score for that date yet: the scan's scores are not published");
    }
    const point = points[points.length - 1];
    console.log("AIS Score", point.score, "model", point.score_version, "panel", point.panel_version);

    const { entries } = (await get(`${brand}/share-of-voice`)).data;
    for (const entry of entries) {
      console.log(entry.name, entry.sov, entry.mentions);
    }
  }

  main().catch((err) => {
    console.error(err.message);
    process.exit(1);
  });
  ```
</CodeGroup>

The score series returns one point per scan date:

```json Response theme={null}
{
  "data": [
    {
      "date": "2026-09-22",
      "score": 64.1,
      "score_version": "v2",
      "panel_version": "v3",
      "presence_pct": 54.2,
      "avg_position": 2.1,
      "avg_sentiment": 72.4,
      "source_visibility_pct": 18.5,
      "composite_score": 57.3,
      "total_mentions": 65,
      "total_runs": 120,
      "citation_count": 412
    }
  ],
  "pagination": {
    "next_cursor": null,
    "has_more": false
  }
}
```

`score` is the headline [AIS Score](/metrics/ais-score). An empty `data` list for the scan's date means the scores are not published yet, so wait and read again. Before you compare this point with an earlier one, check that `score_version` and `panel_version` match. See [Data model](/api/data-model#score_version-and-panel_version).

Share of voice returns the own brand first, then its competitors:

```json Response theme={null}
{
  "data": {
    "window": {
      "from": "2026-08-23",
      "to": "2026-09-22"
    },
    "score_version": "v2",
    "panel_version": "v3",
    "entries": [
      {
        "brand_id": "c2a4e8f0-6b1d-4c3a-8e5f-9d7b2a1c4e60",
        "name": "Hotel Aurora",
        "is_self": true,
        "sov": 58.3,
        "visibility": 54.2,
        "ais_score": 62.7,
        "mentions": 412,
        "days": 9
      },
      {
        "brand_id": "a41d9c6e-0b2f-4f3a-8c75-9e1d2b3a4c5d",
        "name": "Grand Meridian",
        "is_self": false,
        "sov": 41.7,
        "visibility": 39.8,
        "ais_score": 47.9,
        "mentions": 295,
        "days": 9
      }
    ]
  }
}
```

[Data model](/api/data-model#share-of-voice) explains each field. To pull the scan's individual results, see [Export results in bulk](/api/guides/export-results).
