# Queue and congestion

> **TL;DR** — Submit answers tell you how many of your runs are running and waiting, and your limit. `GET /v2/queue` shows the same numbers live. `GET /v2/queue/log` gives them per minute or per hour, `GET /v2/queue/refusals` lists refused submits, and `GET /v2/queue/episodes` lists congestion episodes. Subscribe to `queue.congestion_started` and `queue.congestion_ended` to switch to your fallback early.

You only ever see your own product's numbers. Test keys and live keys count apart.

## Words
| Word | Meaning |
|---|---|
| Concurrency slots | How many runs of your product run at once. The default is 20; Wandura raises it per product on request. |
| Queue | Priority runs that were accepted and wait for a slot. It holds as many runs as you have slots. |
| Refusal | A submit answered `429` because your product was at its limits. The reason is `product_busy` (slots and queue full) or `rate_limited` (over the per-minute run limit, answered `gateway_rate_limited`). No run was made and nothing was charged. |
| Congestion episode | A stretch of time your product was in one kind of congestion: `refusing`, `near_full` or `slow`. It has a start and an end. |

A per-end-user daily cap (`end_user_cap_reached`) is not a refusal: it is about one person, not your product.

## Headers on every submit
Answers of `POST /v2/styles/{style}`, `POST /v2/runs` and `POST /v2/jobs` carry three headers: accepted, replayed and refused submits alike.

| Header | Meaning |
|---|---|
| `X-Concurrency-Limit` | Your concurrency slots. Your queue holds as many again. |
| `X-Concurrency-Running` | Runs of your product using a slot right now. |
| `X-Concurrency-Waiting` | Priority runs of your product waiting for a slot. |

```http
HTTP/1.1 202
X-Concurrency-Limit: 20
X-Concurrency-Running: 20
X-Concurrency-Waiting: 6
```

When Running and Waiting both reach Limit, a new priority run is refused: `429 product_busy` (Jobs API: `PRODUCT_BUSY`) with `Retry-After: 5`. Relax runs are never refused for this; they wait.

- A submit refused before it reaches your limits (a bad body, an unknown style, a bad key) has no such headers.
- Treat the headers as optional: if the numbers cannot be read for a moment, the answer comes without them.

## Live queue
`GET /v2/queue` returns your product's numbers right now, for the product and mode of your key.

```bash
curl https://api.wandura.ai/v2/queue -H "Authorization: Bearer $WANDURA_KEY"
```

```json
{
  "object": "queue_state",
  "product": "prod_…",
  "livemode": true,
  "concurrency_limit": 20,
  "running": 20,
  "waiting": 6,
  "queue_limit": 20,
  "accepted_this_minute": 41,
  "per_minute_limit": 60,
  "refusals": { "last_15m": 0, "last_24h": 3 },
  "at": "2026-10-15T09:30:12.000Z"
}
```

| Field | Meaning |
|---|---|
| `concurrency_limit` | Your concurrency slots. |
| `running` | Runs using a slot now. |
| `waiting` | Priority runs waiting for a slot now. |
| `queue_limit` | How many priority runs may wait. The same number as your slots. |
| `per_minute_limit` | Runs per minute for gateway keys and the Jobs API. Over it: `429 gateway_rate_limited` (Jobs API: `RATE_LIMITED`). |
| `accepted_this_minute` | Runs counted against that limit since the start of the current UTC minute. |
| `refusals` | Refused submits in the last 15 minutes and the last 24 hours. |

## Queue log
`GET /v2/queue/log` returns your numbers over time, one bucket per minute or per hour, oldest first.

| Parameter | Meaning |
|---|---|
| `granularity` | `minute` (default) or `hour`. |
| `from` | Start, included. A time inside a bucket includes that bucket. Default: 24 hours before `to`. |
| `to` | End, excluded. Default: now. |

- A range spans at most 7 days in minutes and 90 days in hours. Minutes are kept 7 days, hours 90 days.
- Buckets with no activity are left out.
- A minute shows up about a minute after it ends. The current hour fills as it goes.
- A wrong value is `400 invalid_request`, and the message says which.

```bash
curl "https://api.wandura.ai/v2/queue/log?granularity=minute&from=2026-10-15T09:00:00Z" \
  -H "Authorization: Bearer $WANDURA_KEY"
```

```json
{
  "object": "list",
  "product": "prod_…",
  "livemode": true,
  "granularity": "minute",
  "from": "2026-10-15T09:00:00.000Z",
  "to": "2026-10-15T09:30:00.000Z",
  "data": [
    {
      "object": "queue_log_bucket",
      "bucket": "2026-10-15T09:29:00.000Z",
      "accepted": 38,
      "refusals": { "product_busy": 2, "rate_limited": 0 },
      "max_running": 20,
      "max_waiting": 20,
      "concurrency_limit": 20,
      "ended": 17,
      "median_wait_ms": 41000,
      "median_duration_ms": 96000
    }
  ]
}
```

| Field | Meaning |
|---|---|
| `bucket` | Start of the minute or hour (UTC). |
| `accepted` | Runs accepted. |
| `refusals` | Refused submits, by reason. |
| `max_running` | The most runs using a slot at once. |
| `max_waiting` | The most priority runs waiting for a slot at once. |
| `concurrency_limit` | Your concurrency slots. |
| `ended` | Runs that ended after running. A run canceled while queued is not counted. |
| `median_wait_ms` | Median wait from submit to start, for the priority runs that started in the bucket. |
| `median_duration_ms` | Median run time, start to end (the wait not included), for the runs that ended in the bucket. |

## Refusals
`GET /v2/queue/refusals` lists your refused submits, newest first. They are kept 30 days.

| Parameter | Meaning |
|---|---|
| `limit` | 1 to 200. Default 50. |
| `before` | The `id` of the last item of the previous page. |

```json
{
  "object": "list",
  "data": [
    {
      "object": "queue_refusal",
      "id": "5821",
      "at": "2026-10-15T09:29:41.204Z",
      "reason": "product_busy",
      "style": "sty_…",
      "end_user_id": "user_42",
      "retry_after_s": 5,
      "livemode": true
    }
  ],
  "has_more": false
}
```

`reason` is `product_busy` (slots and queue full) or `rate_limited` (the per-minute run limit). `end_user_id` is `null` when the call sent none. Use it to find which of your users were affected.

## Congestion episodes
Wandura checks your product every minute. Each kind of congestion opens its own episode, so several can be open at once.

| `kind` | Starts when | Ends when |
|---|---|---|
| `refusing` | One or more refusals in the last 5 minutes. | 10 minutes with no refusal. |
| `near_full` | 80% or more of your concurrency slots are in use for 1 minute. | Below 60% for 5 minutes. |
| `slow` | At least 3 runs ended in the last 15 minutes, and their median run time is over 10 minutes. | The median is back under 8 minutes, or nothing ended in 15 minutes and nothing runs or waits. |

- The same kind does not start again within 30 minutes of its end.
- Live keys only: test keys never start an episode.
- Run time is from start to end. The wait before it is not included.

`GET /v2/queue/episodes` lists your episodes, newest first. It takes the same `limit` and `before` as refusals.

```json
{
  "object": "list",
  "data": [
    {
      "object": "congestion_episode",
      "id": "184",
      "product": "prod_…",
      "livemode": true,
      "kind": "refusing",
      "status": "ended",
      "started_at": "2026-10-15T09:31:00.000Z",
      "ended_at": "2026-10-15T09:52:00.000Z",
      "duration_s": 1260,
      "start": { "running": 20, "waiting": 20, "concurrency_limit": 20, "refusals_5m": 3, "refusals_10m": 3, "runs_ended_15m": 24, "median_duration_ms": 95000 },
      "end": { "running": 9, "waiting": 0, "concurrency_limit": 20, "refusals_5m": 0, "refusals_10m": 0, "runs_ended_15m": 30, "median_duration_ms": 88000 },
      "affected": { "refusals": 7, "runs_accepted": 212, "runs_ended": 205 }
    }
  ],
  "has_more": false
}
```

| Field | Meaning |
|---|---|
| `status` | `open` or `ended`. |
| `started_at`, `ended_at`, `duration_s` | When it started and ended. The last two are `null` while it is open. |
| `start`, `end` | Your numbers at the start and at the end (`end` is `null` while open): `running`, `waiting`, `concurrency_limit`, refusals in the last 5 and 10 minutes, runs ended in the last 15 minutes and their median run time (`null` when none ended). |
| `affected` | Once ended: refused submits (counted from 5 minutes before the start), runs accepted and runs ended during the episode. `null` while open. |

## Webhook events
Each episode is sent twice to your product's webhook:

- `queue.congestion_started` when it starts.
- `queue.congestion_ended` when it ends.

They are opt-in: your webhook gets them only when its event list names them. To turn them on, ask Wandura to add both events to your product's webhook. Your other events stay as they are.

`data` is the episode as it is when the event is made. There is no `sequence`, because the event belongs to no run. Signature, retries, `Wandura-Event-Id` and `Wandura-Event-Type` work as for run events: see [Webhooks](https://docs.wandura.ai/developers/api-v2/concepts#webhooks).

```json
{
  "id": "evt_…",
  "object": "event",
  "type": "queue.congestion_started",
  "created_at": "2026-10-15T09:31:00.412Z",
  "data": {
    "object": "congestion_episode",
    "id": "184",
    "product": "prod_…",
    "livemode": true,
    "kind": "refusing",
    "status": "open",
    "started_at": "2026-10-15T09:31:00.000Z",
    "ended_at": null,
    "duration_s": null,
    "start": { "running": 20, "waiting": 20, "concurrency_limit": 20, "refusals_5m": 3, "refusals_10m": 3, "runs_ended_15m": 24, "median_duration_ms": 95000 },
    "end": null,
    "affected": null
  }
}
```

- Match a start and its end on `data.id`. Rarely, an end can arrive before its start: trust `data.status`.
- The events are the fast signal. For the full record, read `GET /v2/queue/episodes`.

## What to do
- **Watch the headers.** When `X-Concurrency-Waiting` climbs toward `X-Concurrency-Limit`, slow down or send new work to your fallback before you are refused.
- **Act on the events.** On `queue.congestion_started`, switch to your fallback: at once for `refusing`, early for `near_full`, and for `slow` warn your users or use a faster path. On `queue.congestion_ended` of the same kind, switch back.
- **On `429`** (`product_busy`, `gateway_rate_limited`; Jobs API `PRODUCT_BUSY`, `RATE_LIMITED`): no run was made, so sending the request to your fallback cannot run it twice. To retry with Wandura, wait `Retry-After` seconds (add a little jitter) and resend with the **same** `Idempotency-Key`.
- **On `503 service_unavailable`** (Jobs API `SERVICE_UNAVAILABLE`): nothing was created or charged. Retry after `Retry-After` with the same `Idempotency-Key`.
- **Poll calmly.** While your product is congested, runs wait longer. Poll each run every 5–10 s, or better, use webhooks. Polling faster does not start a run sooner.
- **Need more room?** The queue log and the refusals show when and how often you hit your limits. Send them to Wandura, and your product's slots or per-minute limit can be raised.

When Wandura itself has trouble, it is posted on [wandura.instatus.com](https://wandura.instatus.com).

See also: [Errors](https://docs.wandura.ai/developers/api-v2/errors) · [Concepts: limits](https://docs.wandura.ai/developers/api-v2/concepts#limits) · [Jobs API: limits](https://docs.wandura.ai/developers/api-v2/jobs#limits).
