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

# Long-running operations

> Start work that takes minutes, and poll for the result.

Four endpoints start work that takes longer than a request should be held open. They all
answer immediately with `202 Accepted` and tell you where to poll, rather than making you
wait on a connection that a proxy, a client timeout, or a gateway may close first.

| Endpoint                                                | Typical duration                         |
| ------------------------------------------------------- | ---------------------------------------- |
| `POST /v1/audit-reports`                                | 45 to 80 seconds                         |
| `POST /v1/brands/{id}/generations`                      | 1 to 4 minutes                           |
| `POST /v1/brands/{id}/generations/{generationId}/draft` | 1 to 4 minutes                           |
| `POST /v1/brands/{id}/recommendations/generate`         | 30 to 60 seconds, longer on large brands |

<Warning>
  A closed connection does not cancel the work. If your client gives up early, the run still
  finishes and still writes its result. Treat a timeout as "check the result", not as
  "nothing happened", and never retry blindly: a second call is a second full run.
</Warning>

## Status polling

The first three return an `id` and a `poll` path. Fetch that path and read `status`.

```json theme={null}
{
  "data": {
    "id": "eb8af06c-8f2c-41e7-b657-b21b6e690457",
    "brand_id": "8378af29-86b3-4943-9309-5b487511e88d",
    "status": "generating_brief",
    "poll": "/v1/brands/8378af29-86b3-4943-9309-5b487511e88d/generations/eb8af06c-8f2c-41e7-b657-b21b6e690457"
  }
}
```

```python theme={null}
import os, time, requests

BASE = "https://api.geoptie.com"
headers = {"Authorization": f"Bearer {os.environ['GEOPTIE_API_KEY']}"}

def wait_for(poll_path, done, timeout=600):
    deadline = time.time() + timeout
    while time.time() < deadline:
        r = requests.get(f"{BASE}{poll_path}", headers=headers, timeout=60)
        r.raise_for_status()
        body = r.json()["data"]
        if body["status"] in done:
            return body
        time.sleep(15)
    raise TimeoutError(poll_path)
```

Terminal states are `complete` and `failed` for an audit report, and `brief_ready`, `ready`
and `failed` for a generation. A `failed` audit report carries the reason in `error`.

## Watermark polling

`POST /v1/brands/{id}/recommendations/generate` has no row of its own to poll, because it
refreshes a whole set of recommendations rather than creating one object. It returns a
`poll_after` timestamp instead:

```json theme={null}
{
  "data": {
    "brand_id": "8378af29-86b3-4943-9309-5b487511e88d",
    "status": "generating",
    "poll_after": "2026-09-08T15:51:54.786Z",
    "poll": "/v1/brands/8378af29-86b3-4943-9309-5b487511e88d/recommendations"
  }
}
```

A run refreshes `last_seen_at` on every recommendation it finds, not only the new ones. So
the run has finished once any recommendation's `last_seen_at` is later than `poll_after`.
That distinction matters: a run that turns up nothing new still updates `last_seen_at`, so
this tells you the run is done rather than leaving you unable to separate "still working"
from "finished with no changes".

```python theme={null}
from datetime import datetime

def wait_for_recommendations(brand_id, poll_after, timeout=600):
    after = datetime.fromisoformat(poll_after.replace("Z", "+00:00"))
    deadline = time.time() + timeout
    while time.time() < deadline:
        r = requests.get(f"{BASE}/v1/brands/{brand_id}/recommendations",
                         headers=headers, params={"limit": 999}, timeout=60)
        r.raise_for_status()
        rows = r.json()["data"]
        seen = [x["last_seen_at"] for x in rows if x.get("last_seen_at")]
        if seen and datetime.fromisoformat(max(seen).replace("Z", "+00:00")) > after:
            return rows
        time.sleep(15)
    raise TimeoutError("recommendations run")
```

`poll_after` is read from the database clock, the same clock that stamps `last_seen_at`, so
you are never comparing two different clocks. Pass it back exactly as received.

## Cost and rate limits

Every endpoint on this page runs a model and costs money, and
`POST /v1/brands/{id}/recommendations/generate` is the most expensive call in the API: it
crawls your site's pages to check coverage. They carry their own per-endpoint limits on top
of the plan rate limit. Poll on an interval of 15 seconds or more rather than in a tight
loop, and let a run finish before starting another for the same brand.

## Next steps

<CardGroup cols={2}>
  <Card title="Rate limits" icon="gauge" href="/docs/rate-limits">
    Per-endpoint ceilings
  </Card>

  <Card title="Errors" icon="triangle-exclamation" href="/docs/errors">
    Handling failures
  </Card>
</CardGroup>
