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

# Idempotent requests

> Send an Idempotency-Key with a write so you can retry it after a timeout without doing the work twice.

A write can succeed on our side while the response never reaches you: a timeout, a dropped connection, a restart of your own process. Without help, you cannot tell whether to send it again. An idempotency key settles it. Send the same key with the retry, and the API returns the first attempt's response instead of doing the work a second time.

## The `Idempotency-Key` header

Every write accepts an optional `Idempotency-Key` header: every `POST`, `PUT`, `PATCH` and `DELETE`, including the webhook management endpoints.

<ParamField header="Idempotency-Key" type="string">
  A string of 1 to 255 characters that you choose. Use one key per logical operation. A version 4 UUID is a good choice.
</ParamField>

A key outside those bounds answers `400 validation_error`. The entry in `details` has `path` set to `Idempotency-Key` and `code` set to `invalid_header`. Nothing is remembered for that request.

Without the header, every request runs.

## Replay: the same key and the same request

When a request arrives with a key the API has already seen for the *same* request, nothing runs again. The API returns the stored status and body of the first attempt, and adds one header:

```http theme={null}
HTTP/1.1 201 Created
Idempotent-Replayed: true
X-Request-Id: 9f1c3b7e-2a64-4d8f-b05a-6e7d8c9b0a12
```

Other headers are left out of this example. `Idempotent-Replayed: true` appears only on a replay. The `X-Request-Id` is new on every response. When the stored response is an error, the `request_id` in its body is replaced with the new one, so the header and the body always agree.

What is stored, and what is not:

| Outcome of the first attempt                                                                                                                                                                                                                                                         | Stored? | What a retry with the same key does |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | ----------------------------------- |
| Any `2xx`, including a `204` with no body                                                                                                                                                                                                                                            | Yes     | Replays it.                         |
| A `4xx` that came from processing the request: a validation `400`, a `404` for a brand or prompt id, a `409`, a `422`                                                                                                                                                                | Yes     | Replays the same error.             |
| Any `5xx`                                                                                                                                                                                                                                                                            | No      | Runs the request again.             |
| A refusal before the request was processed: `401`, a `403` about the key's access (`write_scope_required`, `link_scope_insufficient`, `partner_only`, or `tier_not_entitled` for an organisation without API access), the `404` for an organisation your key cannot reach, any `429` | No      | Runs the request again.             |
| A body the API could not read: `400 invalid_json`, `400 invalid_body`, `413 payload_too_large`                                                                                                                                                                                       | No      | Runs the request again.             |

A scan request is the one place where a `402` or a `403` comes from processing the request: `402 no_manual_scans`, and `403 tier_not_entitled` for a plan without on-demand scans. Both are stored and replay.

<Note>
  A stored `4xx` replays for as long as the key is remembered. Once you have fixed the cause, whether in the request or on the account, send the request under a **new** key. That includes the two conflicts that clear by themselves, `409 prompt_writes_busy` and `409 delivery_in_flight`: wait a moment, then retry under a new key, because the same key replays the `409`. The one conflict to retry with the same key is `409 idempotency_key_in_flight`.
</Note>

## Reuse: the same key and a different request

A key belongs to one request. The API compares the method, the path including its ids, the organisation and the JSON body. The order of keys in the JSON does not matter.

If any of those differ, the API answers `409 idempotency_key_reuse` and runs nothing. Deleting prompt A and then prompt B under one key is a reuse, even though both requests have an empty body.

## In flight: two requests at once

If two requests with the same key arrive together, exactly one runs. The other answers `409 idempotency_key_in_flight`. Retry it shortly with the same key. Once the first attempt has finished, the retry replays its response, or runs the request if that attempt ended in a `5xx`.

The response is stored before it is sent. If you hold a `2xx`, a retry can never be told `in_flight`.

Disconnecting does not cancel a write. If your client times out and drops the connection, the request still runs to completion and its response is stored as usual, so a retry with the same key replays it.

<Warning>
  If `idempotency_key_in_flight` persists far beyond the time the request normally takes, the first attempt was interrupted before its response could be stored, and that key will keep answering `in_flight`. Send the request again under a **new** key. That is safe for the prompt sync, which is declarative. For a create that did complete before the interruption, the new request answers `409` with the id of the existing resource.
</Warning>

## Scope and lifetime

Keys are scoped to your API key. Two API keys can use the same string without colliding. It also means a retry must be sent with the same API key as the first attempt, or it runs as a new request.

Keys are remembered for a limited time. Treat a key as single-use for one logical operation: generate it when you decide to make the write, reuse it for every retry of that write, and never use it again afterwards.

<Note>
  `POST /orgs/{orgId}/brands/{brandId}/scan-requests` returns a field named `idempotency_key`. That is the scan queue's own key for the brand and the day. It is not the `Idempotency-Key` header, and the two do not interact.
</Note>

## Rotating a webhook secret

<Warning>
  `POST /webhook-endpoints/{endpointId}/rotate-secret` shows the new secret once. If you retry it with the **same** key, the API replays the first response, including that secret, and rotates nothing. If you retry it **without** a key, every attempt rotates the secret again, and the secret from a response you never received is gone. Always send an `Idempotency-Key` with this call.
</Warning>

## Create a prompt, safely

Each sample creates a prompt for the own brand Hotel Aurora. It generates one key and sends it with every attempt.

<CodeGroup>
  ```bash cURL theme={null}
  ORG_ID="7b1e6c1a-3f52-4d0e-9a57-2c8f1e0b4d11"
  IDEMPOTENCY_KEY="$(uuidgen)"

  # --retry repeats the request on a timeout, a dropped connection or a 5xx.
  # Every attempt carries the same Idempotency-Key, so the prompt is created once.
  curl --retry 3 --retry-all-errors --max-time 30 \
    -X POST "https://api.surfais.com/v1/orgs/$ORG_ID/prompts" \
    -H "Authorization: Bearer $SURFAIS_API_KEY" \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
    -d '{
      "brand_id": "c2a4e8f0-6b1d-4c3a-8e5f-9d7b2a1c4e60",
      "text": "best boutique hotels in Lisbon",
      "countries": ["GB", "IE"]
    }'
  ```

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

  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"
  MAX_ATTEMPTS = 5
  MAX_WAIT_SECONDS = 120


  def create_prompt(text, countries):
      # One key for this logical operation. Every retry below reuses it.
      idempotency_key = str(uuid.uuid4())
      body = {"brand_id": BRAND_ID, "text": text, "countries": countries}

      for attempt in range(MAX_ATTEMPTS):
          try:
              response = requests.post(
                  f"{BASE_URL}/orgs/{ORG_ID}/prompts",
                  headers={
                      "Authorization": f"Bearer {API_KEY}",
                      "Idempotency-Key": idempotency_key,
                  },
                  json=body,
                  timeout=30,
              )
          except (requests.ConnectionError, requests.Timeout):
              # The request may or may not have run. The key makes the retry safe.
              time.sleep(2**attempt)
              continue

          if response.ok:
              if response.headers.get("Idempotent-Replayed") == "true":
                  print("An earlier attempt had already created this prompt")
              return response.json()["data"]

          error = response.json()
          code = error["error"]["code"]
          # 5xx is never stored, 429 is refused before the write, and in_flight
          # means an earlier attempt is still running. All three are safe to retry.
          retryable = (
              response.status_code in (429, 500, 503)
              or code == "idempotency_key_in_flight"
          )
          wait = int(response.headers.get("Retry-After", 2**attempt))
          if not retryable or wait > MAX_WAIT_SECONDS:
              raise RuntimeError(
                  f'{response.status_code} {code} (request {error["request_id"]})'
              )
          time.sleep(wait)

      raise RuntimeError("Gave up after repeated failures")


  if __name__ == "__main__":
      prompt = create_prompt("best boutique hotels in Lisbon", ["GB", "IE"])
      print(prompt["id"], prompt["active"])
  ```

  ```javascript Node.js theme={null}
  // Node 18+. Save as create-prompt.mjs and run: node create-prompt.mjs
  import { randomUUID } from "node:crypto";

  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";
  const MAX_ATTEMPTS = 5;
  const MAX_WAIT_SECONDS = 120;

  const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));

  async function createPrompt(text, countries) {
    // One key for this logical operation. Every retry below reuses it.
    const idempotencyKey = randomUUID();
    const body = JSON.stringify({ brand_id: BRAND_ID, text, countries });

    for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
      let response;
      try {
        response = await fetch(`${BASE_URL}/orgs/${ORG_ID}/prompts`, {
          method: "POST",
          headers: {
            Authorization: `Bearer ${API_KEY}`,
            "Content-Type": "application/json",
            "Idempotency-Key": idempotencyKey,
          },
          body,
          signal: AbortSignal.timeout(30_000),
        });
      } catch {
        // The request may or may not have run. The key makes the retry safe.
        await sleep(2 ** attempt);
        continue;
      }

      if (response.ok) {
        if (response.headers.get("Idempotent-Replayed") === "true") {
          console.log("An earlier attempt had already created this prompt");
        }
        return (await response.json()).data;
      }

      const error = await response.json();
      const code = error.error.code;
      // 5xx is never stored, 429 is refused before the write, and in_flight
      // means an earlier attempt is still running. All three are safe to retry.
      const retryable =
        [429, 500, 503].includes(response.status) || code === "idempotency_key_in_flight";
      const wait = Number(response.headers.get("Retry-After") ?? 2 ** attempt);
      if (!retryable || wait > MAX_WAIT_SECONDS) {
        throw new Error(`${response.status} ${code} (request ${error.request_id})`);
      }
      await sleep(wait);
    }
    throw new Error("Gave up after repeated failures");
  }

  const prompt = await createPrompt("best boutique hotels in Lisbon", ["GB", "IE"]);
  console.log(prompt.id, prompt.active);
  ```
</CodeGroup>

If the prompt already exists, the API answers `409 conflict` with `details.existing_id`. That response is stored too, so a retry replays it. See [Errors](/api/errors) for every code a write can return, and [Rate limits and quotas](/api/rate-limits) for `Retry-After`.
