---
title: Polling Runs
description: "How to poll async pipeline runs, read stage progress, handle failures, and avoid duplicate runs."
---

> **For AI agents:** the complete documentation index is at [llms.txt](https://docs.boringmarketing.com/llms.txt). Append `.md` to any page URL for its markdown version.

All pipeline endpoints (`/analyze`, `/discover`, `/enrich/brand`, `/enrich/technical`, `/enrich/llm`, `/synthesize`, `/score`, `/brief`, `/execute`) return **202 Accepted** with a `run_id`. The work happens in the background. You poll for status.

## The pattern

```mermaid
sequenceDiagram
    participant Client
    participant API
    Client->>API: POST /brands/{id}/analyze
    API-->>Client: 202 { run_id, status: "queued" }
    loop Every 10-15s
        Client->>API: GET /brands/{id}/runs/{run_id}
        API-->>Client: { status: "running", meta: { current_stage, stage_detail } }
    end
    API-->>Client: { status: "done" }
    Client->>API: GET /brands/{id}/opportunities
    API-->>Client: Ranked results
```

## Run lifecycle

```mermaid
stateDiagram-v2
    [*] --> queued
    queued --> running
    running --> done
    running --> failed
    done --> [*]
    failed --> [*]
```

| Status | Meaning |
|--------|---------|
| `queued` | Run accepted, waiting to start |
| `running` | Pipeline actively processing |
| `done` | All stages completed successfully |
| `failed` | An error occurred — check the `error` field |

## Reading stage progress

Stage progress is exposed under `meta.current_stage` on the run object, **not** at the top level:

```json
{
  "id": "run-uuid",
  "brand_id": "brand-uuid",
  "kind": "full",
  "status": "running",
  "error": null,
  "started_at": "2026-04-07T10:00:00Z",
  "finished_at": null,
  "meta": {
    "current_stage": "evidence_collection",
    "stage_detail": "collecting evidence for 12 families (concurrency=5)",
    "stage_started_at": "2026-04-07T10:06:42Z"
  }
}
```

When `/analyze` is running, stages progress through these values in order:

1. `brand_enrichment` — crawling brand site + extracting identity
2. `competitor_discovery` — finding competitors via Tavily research (**only emitted when the brand has no competitors set** — skipped otherwise)
3. `family_discovery` — building keyword universe (Haiku-validated 10-step pipeline)
4. `evidence_collection` — SERP + DataForSEO evidence per family (synthesis happens inside this stage)
5. `leverage_scoring` — cross-referencing brand gaps with opportunities
6. `queue_building` — assembling the ranked action queue

<Warning>
**Only `/analyze` emits stage updates.** Every other pipeline trigger — `/discover`, `/enrich/brand`, `/enrich/llm`, `/enrich/technical`, `/synthesize`, `/score`, `/brief`, `/execute`, `/discover-competitors` — runs with `meta.current_stage` set to `null` for the entire lifetime of the run. To know whether a single-purpose run finished, poll `status`, not `current_stage`.
</Warning>

## Polling implementation

<CodeGroup>
```bash cURL
# Poll every 10 seconds until done
while true; do
  RESP=$(curl -s \
    -H "X-API-Key: $BM_API_KEY" \
    "https://api.boringmarketing.com/brands/$BRAND_ID/runs/$RUN_ID")

  STATUS=$(echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['status'])")
  STAGE=$(echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('meta',{}).get('current_stage','?'))")
  DETAIL=$(echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('meta',{}).get('stage_detail',''))")

  echo "[$STATUS] $STAGE — $DETAIL"
  [ "$STATUS" = "done" ] || [ "$STATUS" = "failed" ] && break
  sleep 10
done
```

```python Python
import httpx, time

def poll_run(brand_id: str, run_id: str, api_key: str) -> dict:
    url = f"https://api.boringmarketing.com/brands/{brand_id}/runs/{run_id}"
    headers = {"X-API-Key": api_key}

    while True:
        resp = httpx.get(url, headers=headers).json()
        meta = resp.get("meta") or {}
        print(f"Stage: {meta.get('current_stage', '?')} — {meta.get('stage_detail', '')}")
        if resp["status"] in ("done", "failed"):
            return resp
        time.sleep(10)
```

```javascript Node.js
async function pollRun(brandId, runId, apiKey) {
  const url = `https://api.boringmarketing.com/brands/${brandId}/runs/${runId}`;
  const headers = { "X-API-Key": apiKey };

  while (true) {
    const resp = await fetch(url, { headers }).then(r => r.json());
    const stage = resp.meta?.current_stage ?? "?";
    const detail = resp.meta?.stage_detail ?? "";
    console.log(`Stage: ${stage} — ${detail}`);
    if (resp.status === "done" || resp.status === "failed") return resp;
    await new Promise(r => setTimeout(r, 10_000));
  }
}
```
</CodeGroup>

<Tip>
**Recommended intervals:** 10-15 seconds for full pipeline runs. 5 seconds for single-stage runs (brief, execute, enrich/*).
</Tip>

## Handling duplicate runs

If you trigger a pipeline while one is already running, the API returns **409 Conflict**. The body is templated per-kind and is **not** a stable API contract — it may say things like:

```
A discovery run is already in progress for this brand
```
```
A full pipeline run is already in progress (blocks all other pipeline-modifying runs)
```

The stable signal is the `X-Running-Run-Id` response header — it always carries the existing run's UUID. Read the header, then poll that run instead of starting a new one. Active runs older than 15 minutes are auto-expired before the 409 check, so you should not need to manually cancel a stuck run to retrigger.

```bash
# Check existing runs before starting a new one
curl -s \
  -H "X-API-Key: $BM_API_KEY" \
  "https://api.boringmarketing.com/brands/$BRAND_ID/runs"
```

## Cancelling stuck runs

If a run appears stuck (no `stage_started_at` progress for more than 15 minutes), mark it as failed and re-trigger:

```bash
curl -s -X POST \
  -H "X-API-Key: $BM_API_KEY" \
  "https://api.boringmarketing.com/brands/$BRAND_ID/runs/$RUN_ID/cancel"
```

## Handling failures

When a run fails, the `error` field contains the reason:

```json
{
  "id": "run-uuid",
  "brand_id": "brand-uuid",
  "kind": "full",
  "status": "failed",
  "error": "DataForSEO rate limit exceeded — retry in 60 seconds",
  "started_at": "2026-04-07T10:00:00Z",
  "finished_at": "2026-04-07T10:12:00Z",
  "meta": {
    "current_stage": "evidence_collection"
  }
}
```

Retry by triggering the same pipeline endpoint again. Pipeline data is append-only and idempotent — re-running does not create duplicate data.

## Listing all runs

View run history for a brand:

```bash
curl -s \
  -H "X-API-Key: $BM_API_KEY" \
  "https://api.boringmarketing.com/brands/$BRAND_ID/runs"
```

```json
{
  "brand_id": "brand-uuid",
  "total": 3,
  "runs": [
    { "id": "run-1", "kind": "full", "status": "done", "started_at": "...", "finished_at": "..." },
    { "id": "run-2", "kind": "discovery", "status": "done", "started_at": "...", "finished_at": "..." },
    { "id": "run-3", "kind": "scoring", "status": "running", "started_at": "...", "finished_at": null }
  ]
}
```

The `kind` field tells you what type of run it was: `full`, `brand_enrichment`, `technical_enrichment`, `llm_enrichment`, `discovery`, `scoring`, `competitor_discovery`, `synthesize`, `brief`, or `execution`.
