# Long-running REST APIs: 202 Accepted, Location, job status polling, and webhooks in OpenAPI

A report export takes three minutes. If the endpoint blocks until the file is ready, proxies time out, workers stay pinned, and mobile clients retry and launch the export twice. The synchronous request/response shape is the wrong tool for work that outlives a request. HTTP already has a clean answer: accept the work immediately, hand back a job resource, and let the client learn the result by polling or by receiving a callback.

## Accept the work, then point at the job

The kickoff returns `202 Accepted` (not `200`, because the result does not exist yet) with a `Location` header identifying the job:

``` http
POST /api/reports/exports HTTP/1.1
Content-Type: application/json
Idempotency-Key: 9f1c...

{ "format": "csv", "date_from": "2026-09-01", "date_to": "2026-09-30" }
```

``` http
HTTP/1.1 202 Accepted
Location: /api/jobs/exp_8f31a2
Retry-After: 5
Content-Type: application/json

{
  "job_id": "exp_8f31a2",
  "status": "queued",
  "status_url": "/api/jobs/exp_8f31a2"
}
```

`Location` is the canonical pointer; echoing a `status_url` in the body helps clients that do not surface headers. `Retry-After` tells the client how long to wait before the first poll, which prevents an immediate thundering retry.

## Model the job as a state machine

A job is not a boolean. Define the states and the terminal ones explicitly, because clients build their UI around them:

| State       | Meaning                                | Client action           |
|-------------|----------------------------------------|-------------------------|
| `queued`    | Accepted, not started                  | Poll after Retry-After  |
| `running`   | In progress, progress may be available | Poll, show progress bar |
| `succeeded` | Terminal; result link present          | Download result         |
| `failed`    | Terminal; error present                | Show error, allow retry |
| `canceled`  | Terminal; client or system canceled    | Stop polling            |

Keep the same set across every async endpoint so clients learn it once. The job resource carries status, timestamps, optional progress, and, on success, the result; on failure it carries a structured Problem Detail:

``` yaml
Job:
  type: object
  required: [job_id, status, created_at]
  properties:
    job_id: { type: string }
    status:
      type: string
      enum: [queued, running, succeeded, failed, canceled]
    progress:
      type: object
      properties:
        percent: { type: integer, minimum: 0, maximum: 100 }
        message: { type: string }
    result:
      type: object
      properties:
        url: { type: string, format: uri }
        expires_at: { type: string, format: date-time }
    error:
      $ref: '#/components/schemas/ProblemDetail'
    created_at: { type: string, format: date-time }
    started_at: { type: string, format: date-time }
    finished_at: { type: string, format: date-time }
```

`result.url` should be a short-lived signed link to the artifact rather than the file itself, so completed jobs do not have to keep the entire payload in the job response.

## Document the kickoff and the status endpoint

``` yaml
paths:
  /reports/exports:
    post:
      summary: Start a report export; returns a job
      operationId: startReportExport
      responses:
        '202':
          description: Export accepted and queued.
          headers:
            Location:
              schema: { type: string, format: uri-reference }
              description: URL of the job status resource.
            Retry-After:
              schema: { type: integer }
              description: Suggested seconds before the first status poll.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Job'
        '400':
          $ref: '#/components/responses/BadRequest'

  /jobs/{jobId}:
    get:
      summary: Get an export job's status and result
      operationId: getJob
      parameters:
        - name: jobId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Current job state. Poll until status is terminal.
          headers:
            Retry-After:
              schema: { type: integer }
              description: Suggested polling interval while non-terminal.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Job'
        '404':
          description: No such job for this account.
    delete:
      summary: Cancel a queued or running job
      operationId: cancelJob
      responses:
        '200':
          description: Job canceled.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Job'
```

A `DELETE` on the job (or a `POST /cancel`) gives clients an explicit cancellation path, which is far better than abandoning a job and leaving work running server-side.

## Polling contract

-   The client polls only non-terminal states, waiting the server's `Retry-After` (falling back to a sensible default with gentle backoff).
-   The server should increase the suggested interval as the job ages so a ten-minute job is not polled every second.
-   Polling stops at a terminal state; a well-behaved client treats an unknown status as non-terminal and keeps polling rather than failing.
-   `404` on a job the client started means it expired or never belonged to the caller; distinguish "not found" from "still running."

Polling is universal and survives firewalls, but it is chatty. For high volumes offer push delivery.

## Webhook or callback for completion

Let the client register a callback URL at kickoff (or subscribe once per account), and POST the terminal job to it when work finishes:

``` yaml
paths:
  /reports/exports:
    post:
      requestBody:
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/ExportRequest'
                - type: object
                  properties:
                    webhook_url:
                      type: string
                      format: uri
                      description: If provided, the terminal job is POSTed here when finished.
```

The callback body is the same `Job` schema, so polling and push share one model. Document signature headers and retries for the webhook separately; the key OpenAPI point is that the operation's completion can arrive either by GET on the status resource or by the callback. For a single request that expects the server to call back, OpenAPI `callbacks` can describe the exact outbound request; for account-wide notifications, a webhooks section is the better fit.

## When not to use a job

Do not push every endpoint behind 202. Work that reliably finishes in tens of milliseconds should stay synchronous; introducing a job for a fast read adds a round trip and a state machine for nothing. The trigger for the async pattern is duration that approaches proxy or client timeouts, work that fans out to other systems, or work whose resource use must be queued and controlled.

## What codegen, mocks, and AI callers need

-   Generators need the `202` response, the `Location` header, and a concrete `Job` schema; without them clients cannot discover where to poll.
-   A spec-driven mock should be able to return `queued`, then `running` with progress, then `succeeded` across successive polls, so the client's polling loop and progress UI are testable without a real worker.
-   An AI agent driving an API must know to follow `Location`, honor `Retry-After`, and stop at terminal states. Encoding the state machine and the polling header in the spec is what lets the agent complete a long task instead of assuming the first response is the result.

## Checklist

1.  Return `202 Accepted` with `Location` and an initial `Retry-After` for work that outlives a request.
2.  Model a fixed job state machine with explicit terminal states and reuse it everywhere.
3.  Include progress, a short-lived signed result link, and a structured error on the job resource.
4.  Document the status GET (with Retry-After), a cancellation route, and `404` semantics.
5.  Poll only non-terminal states with server-directed intervals and gentle backoff.
6.  Offer a webhook or callback using the same Job schema, with signatures and retries documented.
7.  Keep genuinely fast operations synchronous; do not over-asynchronize.
8.  Test the full queued-to-succeeded and queued-to-failed transitions against a mock.

Get these right and a three-minute export becomes a reliable background operation instead of a timeout and a duplicate job.

You can define the job lifecycle, generate a client that follows Location and polls correctly, and simulate queued-to-succeeded transitions in one local-first workspace, [right in your browser](https://www.powerduck.com/app/?ref=powerduck.com). For the outbound completion call, see [OpenAPI callbacks vs webhooks and runtime expressions](https://www.powerduck.com/blog/openapi-callbacks-vs-webhooks-runtime-expressions/).

