Skip to main content
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.
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.

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):
FieldTypeNotes
asyncboolOpt in to asynchronous mode. Default false (unchanged synchronous behavior).
callback_urlstringOptional. HTTPS URL POSTed once the job finishes. Only valid when async: true.
client_request_idstringOptional idempotency key — a retried submit with the same value returns the original job instead of a new one.
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.
1

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:
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
  }'
The 202 response is a job handle:
{
  "job_id": "5f3c8a1e9b4d4c7e8a2f1b6d0c9e7a31",
  "status": "pending",
  "poll_url": "/v1/phota/jobs/5f3c8a1e9b4d4c7e8a2f1b6d0c9e7a31"
}
2

Poll for the result

Poll GET /v1/phota/jobs/{job_id} until status is succeeded or failed:
curl https://api.photalabs.com/v1/phota/jobs/5f3c8a1e9b4d4c7e8a2f1b6d0c9e7a31 \
  -H "X-API-Key: YOUR_API_KEY"
A succeeded job carries the same result shape as the synchronous response, with download_urls populated (images is always empty in async mode):
{
  "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"
}
statusMeaning
pendingAccepted, not started yet. Poll again shortly.
runningExecuting. Poll again shortly.
succeededDone — result.download_urls is populated.
failedFailed — error.code and error.message explain why.
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).
3

(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:
curl https://api.photalabs.com/v1/phota/webhook-secret \
  -H "X-API-Key: YOUR_API_KEY"
{ "webhook_signing_secret": "a1b2c3..." }
Verify a received callback by recomputing the HMAC over the raw request body and comparing in constant time:
Python
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)
Sign and compare over the exact bytes you received, before any JSON re-serialization — re-encoding can change the bytes and break the signature.

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

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

Next steps

Quickstart

The synchronous workflow, end to end.

API reference

Full request and response schemas for every endpoint.