# Async

## Introduction

Pools can accept work **asynchronously**. The caller gets `202 Accepted` with a
job id and poll URL. The job still competes for the pool's concurrency limit.
When the upstream finishes, nthbouncer posts a signed webhook and the poll
endpoint returns the result.

Async needs at least one active webhook destination. Enable async on
**Pools → Concurrency**, then attach a destination from
[Webhooks](/docs/webhooks). Sync proxy stays the default. Team and Business
plans only.

## Admission modes

| Mode | How it triggers | `reason` on poll / webhook |
|------|-----------------|----------------------------|
| Header | `X-Nthpool-Async: 1` on a normal pool request | `requested` |
| On open wait | Request would wait for a slot → accept as a job instead of holding the connection | `busy` |
| On queue timeout | Open wait hits queue timeout → accept as a job instead of `504 queue_timeout` | `wait_timeout` |

All three share one job pipeline. Slot timing: the concurrency slot is held only
while the upstream fetch runs (not while the job waits in the queue).

Async bypasses the shared response cache. Locks stay sync-only.

## No active destination

A pool with async on but no active destination falls back to sync. That covers
both cases: nothing attached yet, and every attached webhook deactivated after
repeated delivery failures.

In that state the pool behaves exactly as it would with async off:

- `X-Nthpool-Async: 1` returns the upstream response, not `202`.
- On open wait and on queue timeout do not promote. A request waits for a slot
  as usual and gets `429` or `504 queue_timeout` on its own terms.

No jobs are created while a pool is in this state, so nothing sits unclaimed.
Attach a working destination (or re-enable a deactivated one) and the next
request goes back through the async path.

A pool that never enabled async still rejects `X-Nthpool-Async: 1` with
`400 async_disabled`.

## Accept response

```json
{
  "jobId": "job_…",
  "status": "accepted",
  "pollUrl": "https://api.nthpool.cloud/pools/your-pool/jobs/job_…",
  "webhook": { "destinations": 2 }
}
```

`pollUrl` is
`https://api.nthpool.cloud/pools/{pool}/jobs/{jobId}`, the same API host as
[concurrency locks](/docs/api/locks), not your pool hostname or a custom
domain. `{pool}` is your pool subdomain. Job ids are `job_` followed by hex.
Database pools are the exception: their handles poll on the pool's own
hostname — see [Database pools](/docs/pools/database).

## Poll

`GET` (or `HEAD`) the `pollUrl` with the same pool auth as a normal request or
a lock call. Poll is there for callers that cannot receive a webhook. When destinations are configured,
the webhook remains the primary completion signal.

Accept is **202** with `status: "accepted"`. Poll always returns **200** for a
known job, with `status` set to the current lifecycle value:

`accepted` → `queued` → `running` → terminal `completed` | `failed` | `expired`

There is no job-level `pending`.

| Case | HTTP | Body |
|------|------|------|
| Unknown or wrong-pool job | **404** | `{ "error": "job_not_found" }` |
| Past `expiresAt`, still non-terminal | **200** | `status: "expired"` |
| Terminal job still in store | **200** | that terminal status |

Every poll body includes `jobId`, `status`, `reason`, `createdAt`,
`updatedAt`, and `expiresAt`. Terminal jobs may include `result` (upstream
status, headers, size-capped body). When destinations are on the job,
`webhook` reports `destinations`, `delivered`, and `failed` counts.

### Retention

Job retention defaults to **86400** seconds (24 hours).
Configurable per pool from **60** seconds to **604800** (7 days).

## Webhooks

See [Webhooks](/docs/webhooks) for destinations, signing, and retries.

## Limitations

- On proxy pools, async requires at least one active webhook destination;
  without one the pool serves sync. Database pools return handles with async
  enabled alone.
- Max request body retained for a job defaults to 1 MiB (configurable per pool).
- Job retention defaults to 24 hours (configurable from 60 seconds to 7 days).
- Webhook delivery is at-least-once; receivers must be idempotent on `jobId`.
- Destination count per pool is plan-capped.

## Next steps

- [Webhooks](/docs/webhooks)
- [Concurrency](/docs/pools/concurrency)
- [Long NetSuite writes without client timeouts](/docs/knowledge-base/async-netsuite-writes)
