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

# Asynchronous requests

> Submit long-running edits as jobs, poll for the result, and receive optional signed callbacks

By default the image endpoints (`/edit`, `/generate`, `/enhance`, `/remix`) are
**synchronous**: the connection stays open until the result is ready. That's the
simplest option for fast, interactive requests.

**Asynchronous mode** decouples the result from the HTTP connection. Add
`async: true` and the call returns immediately with a `job_id`; the work runs in
the background and you fetch the result by polling — with an optional signed
callback when it finishes. Your client submits the job and moves on instead of
holding a connection open for the full duration of the edit. That means less
overhead on your side and no dependency on a long-lived connection staying up —
a natural fit for serverless functions, job queues, and other environments where
keeping a request in flight is costly or impractical. Async jobs also get a
larger runtime budget than the synchronous path, so longer edits have room to
finish.

<Info>
  **Polling is authoritative.** `GET /v1/phota/jobs/{job_id}` is always the
  source of truth for a job's outcome. A `callback_url` is a best-effort
  convenience on top of polling, not a replacement for it.
</Info>

## When to use it

* **You don't want to hold a connection open** for the duration of the work —
  submit the job and poll (or receive a callback) when it's ready, with no
  in-flight request to keep alive.
* **You're integrating from a serverless, queued, or otherwise
  connection-constrained environment** where a long-lived request is costly or
  impractical.
* **You want a durable handle** to poll or be notified on completion, decoupled
  from any single request.
* **Long-running edits** that run in the background instead of tying up a
  connection while they finish.

For fast, interactive requests the synchronous path remains simpler — keep using
it without `async`.

## Request fields

These optional fields are accepted on every image endpoint (`/edit`,
`/generate`, `/enhance`, `/remix`):

| Field               | Type   | Notes                                                                                                          |
| ------------------- | ------ | -------------------------------------------------------------------------------------------------------------- |
| `async`             | bool   | Opt in to asynchronous mode. Default `false` (unchanged synchronous behavior).                                 |
| `callback_url`      | string | Optional. HTTPS URL POSTed once the job finishes. Only valid when `async: true`.                               |
| `client_request_id` | string | Optional idempotency key — a retried submit with the same value returns the original job instead of a new one. |

<Note>
  Async results are always delivered as download URLs, so `async: true` requires
  `response_mode: "urls"`. Combining `async: true` with `response_mode: "bytes"`
  returns `422`. A `callback_url` without `async: true` also returns `422`.
</Note>

<Steps>
  <Step title="Submit the job">
    Add `async: true` and `response_mode: "urls"` to any image request. The endpoint
    returns `202 Accepted` with a job handle instead of the image payload:

    <CodeGroup>
      ```bash curl theme={null}
      curl -X POST https://api.photalabs.com/v1/phota/edit \
        -H "X-API-Key: YOUR_API_KEY" \
        -H "Content-Type: application/json" \
        -d '{
          "prompt": "Make [[abc123]] smile wider",
          "images": ["https://example.com/photo.jpg"],
          "profile_ids": ["abc123"],
          "num_output_images": 2,
          "output_format": "jpg",
          "response_mode": "urls",
          "async": true
        }'
      ```

      ```python Python theme={null}
      import requests

      resp = requests.post(
          "https://api.photalabs.com/v1/phota/edit",
          headers={"X-API-Key": "YOUR_API_KEY", "Content-Type": "application/json"},
          json={
              "prompt": "Make [[abc123]] smile wider",
              "images": ["https://example.com/photo.jpg"],
              "profile_ids": ["abc123"],
              "num_output_images": 2,
              "output_format": "jpg",
              "response_mode": "urls",
              "async": True,
          },
      )
      job = resp.json()  # HTTP 202
      print(f"Job submitted: {job['job_id']}")
      ```
    </CodeGroup>

    The `202` response is a job handle:

    ```json theme={null}
    {
      "job_id": "5f3c8a1e9b4d4c7e8a2f1b6d0c9e7a31",
      "status": "pending",
      "poll_url": "/v1/phota/jobs/5f3c8a1e9b4d4c7e8a2f1b6d0c9e7a31"
    }
    ```
  </Step>

  <Step title="Poll for the result">
    Poll `GET /v1/phota/jobs/{job_id}` until `status` is `succeeded` or `failed`:

    <CodeGroup>
      ```bash curl theme={null}
      curl https://api.photalabs.com/v1/phota/jobs/5f3c8a1e9b4d4c7e8a2f1b6d0c9e7a31 \
        -H "X-API-Key: YOUR_API_KEY"
      ```

      ```python Python theme={null}
      import time
      import requests

      headers = {"X-API-Key": "YOUR_API_KEY"}
      job_id = "5f3c8a1e9b4d4c7e8a2f1b6d0c9e7a31"

      while True:
          resp = requests.get(
              f"https://api.photalabs.com/v1/phota/jobs/{job_id}",
              headers=headers,
          )
          job = resp.json()
          if job["status"] == "succeeded":
              print(job["result"]["download_urls"])
              break
          if job["status"] == "failed":
              print(f"Job failed: {job['error']['code']} — {job['error']['message']}")
              break
          time.sleep(3)
      ```
    </CodeGroup>

    A succeeded job carries the same result shape as the synchronous response, with
    `download_urls` populated (`images` is always empty in async mode):

    ```json theme={null}
    {
      "job_id": "5f3c8a1e9b4d4c7e8a2f1b6d0c9e7a31",
      "status": "succeeded",
      "operation": "edit",
      "result": {
        "images": [],
        "download_urls": [
          "https://cache-cdn.photalabs.com/20260622/abc123.jpg?token=...&expires=...",
          "https://cache-cdn.photalabs.com/20260622/def456.jpg?token=...&expires=..."
        ],
        "known_subjects": { "counts": { "abc123": 2 } }
      },
      "error": null,
      "created_at": "2026-06-22T12:00:00Z",
      "completed_at": "2026-06-22T12:00:18Z"
    }
    ```

    | `status`    | Meaning                                                |
    | ----------- | ------------------------------------------------------ |
    | `pending`   | Accepted, not started yet. Poll again shortly.         |
    | `running`   | Executing. Poll again shortly.                         |
    | `succeeded` | Done — `result.download_urls` is populated.            |
    | `failed`    | Failed — `error.code` and `error.message` explain why. |

    <Note>
      Results are pollable for **24 hours** (matching the signed-URL lifetime).
      After that the job is garbage-collected and polling returns `410 Gone`.
      Polling a job that belongs to another account returns `404` (jobs are scoped
      to the account that submitted them).
    </Note>
  </Step>

  <Step title="(Optional) Receive a signed callback">
    Pass a `callback_url` on submit and Phota POSTs the **same payload as the poll
    response** to that URL once the job reaches a terminal state. Delivery is
    best-effort with a few bounded retries — always treat polling as authoritative.

    Every callback is signed so you can verify it came from Phota. The body is
    HMAC-SHA256-signed with your per-account **webhook signing secret**, sent in the
    `X-Phota-Signature` header as `sha256=<hex digest>`. Fetch your secret once from:

    ```bash theme={null}
    curl https://api.photalabs.com/v1/phota/webhook-secret \
      -H "X-API-Key: YOUR_API_KEY"
    ```

    ```json theme={null}
    { "webhook_signing_secret": "a1b2c3..." }
    ```

    Verify a received callback by recomputing the HMAC over the **raw request body**
    and comparing in constant time:

    ```python Python theme={null}
    import hashlib
    import hmac

    def verify_callback(raw_body: bytes, signature_header: str, secret: str) -> bool:
        expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
        return hmac.compare_digest(expected, signature_header)
    ```

    <Warning>
      Sign and compare over the **exact bytes** you received, before any JSON
      re-serialization — re-encoding can change the bytes and break the signature.
    </Warning>
  </Step>
</Steps>

## Idempotency

Pass a `client_request_id` to make submits safe to retry. If a submit with the
same `client_request_id` is received again (for example after a network blip),
the API returns the **original** job handle instead of creating — or charging
for — a second job.

## Billing

Async jobs are billed exactly like their synchronous equivalents: the floor cost
is reserved at submit, settled to the final amount when the job **succeeds**, and
released (refunded) if the job **fails**. As with the synchronous path, you pay
for work performed — a job you submit and never poll still runs and settles.

## Errors

| Status | When                                                                                  |
| ------ | ------------------------------------------------------------------------------------- |
| `422`  | `async: true` with `response_mode: "bytes"`, or `callback_url` without `async: true`. |
| `402`  | Insufficient prepaid balance at submit — no job is created.                           |
| `404`  | Polling an unknown job, or one belonging to another account.                          |
| `410`  | Polling a job past its 24-hour retention window.                                      |

## Next steps

<CardGroup cols={2}>
  <Card title="Quickstart" icon="rocket" href="/api/quickstart">
    The synchronous workflow, end to end.
  </Card>

  <Card title="API reference" icon="code" href="/api-reference/introduction">
    Full request and response schemas for every endpoint.
  </Card>
</CardGroup>
