# Async Image Generation

> Return immediately and receive the finished image by webhook instead of holding a connection open for the whole render.

Image generation is synchronous by default: `POST /v1/images/generate` blocks until the image exists and returns its URL. That is the simplest thing to call, and nothing about it has changed.

It is a poor fit for one specific situation — a runtime that cannot hold a request open for 30 seconds. Cloudflare Workers, Vercel functions, Lambda behind API Gateway, and anything fronted by a proxy with a short idle timeout will kill the connection before a large render finishes, and you lose the result even though the credits were spent.

For those cases both image endpoints accept a **delivery target**. Supply one and the API stops waiting: it answers `202 Accepted` with a generation id and POSTs the finished image to your URL when it is ready.

> [!NOTE]
> This is opt-in per request. Send no delivery target and you get the identical synchronous `200` you always got. There is no account setting and no migration.

## Choosing a mode

| | Synchronous | Async + webhook |
| --- | --- | --- |
| Request body | no delivery target | `callback_url` or `webhook` |
| Response | `200` with the image | `202` with a generation id |
| Result arrives | in the response | POSTed to your URL |
| Connection held | for the whole render | milliseconds |
| Good for | scripts, CLIs, backends with long timeouts | serverless, queue workers, batch fan-out |
| Extra work | none | a public HTTPS endpoint + signature check |

Batch work is the other reason to reach for it. Ten synchronous generations mean ten open connections for the duration; ten async submits mean ten quick POSTs and ten callbacks.

## Submitting an async generation

Add `callback_url` to any `POST /v1/images/generate` or `POST /v1/images/edit` call.

**JavaScript SDK** (`picx-ai` ≥ 0.3.0)

```js
import { PicX, GenerationJob } from "picx-ai";

const picx = new PicX(process.env.PICX_API_KEY);

const job = await picx.images.generate({
  prompt: "a cute cat on a windowsill, soft morning light",
  size: "2K",
  aspect_ratio: "16:9",
  callback_url: "https://your-server.com/hooks/picx",
});

console.log(job instanceof GenerationJob); // true
console.log(job.id);                       // persist this to correlate the callback
console.log(job.status);                   // "pending"
```

**Python SDK** (`picx-ai` ≥ 0.3.0)

```python
import os
from picx import PicX

picx = PicX(os.environ["PICX_API_KEY"])

job = picx.images.generate(
    "a cute cat on a windowsill, soft morning light",
    size="2K",
    aspect_ratio="16:9",
    callback_url="https://your-server.com/hooks/picx",
)

print(job.id)      # persist this to correlate the callback
print(job.status)  # "pending"
```

**curl**

```bash
curl -X POST https://api.picxstudio.com/v1/images/generate \
  -H "Authorization: Bearer pxsk_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "a cute cat on a windowsill, soft morning light",
    "size": "2K",
    "aspect_ratio": "16:9",
    "callback_url": "https://your-server.com/hooks/picx"
  }'
```

### The 202 response

```json
{
  "id": "d3bfa107-6475-4f5b-ad32-c65c3e1b0377",
  "status": "pending",
  "type": "image",
  "model": "gemini-3.1-flash-image-preview",
  "poll_url": "/v1/generations/d3bfa107-6475-4f5b-ad32-c65c3e1b0377",
  "events_url": "/v1/generations/d3bfa107-6475-4f5b-ad32-c65c3e1b0377/events",
  "webhook": {
    "mode": "legacy_callback",
    "id": null,
    "url": "https://your-server.com/hooks/picx",
    "events": ["generation.completed", "generation.failed"]
  }
}
```

`webhook` echoes back where the result will be sent, resolved once at submit time. If it does not say what you expected, fix it now rather than waiting for a delivery that goes somewhere else.

This is the same envelope `POST /v1/videos/generate` has always returned, so a client that can consume one async generation can consume both.

## Return types in the SDKs

Both SDKs change what they hand back based on whether you passed a delivery target. This is the part to get right when adopting it.

| Call | JavaScript | Python |
| --- | --- | --- |
| no target | `ImageResult` (has `.url`) | `ImageAsset` (has `.url`) |
| with target | `GenerationJob` (has `.id`) | `GenerationJob` (has `.id`) |

In TypeScript this is expressed with overloads, so an existing call keeps its `Promise<ImageResult>` type and only a call that actually passes a target widens to `Promise<GenerationJob>`. No existing code needs a cast.

> [!WARNING]
> On `picx-ai` below 0.3.0 the `callback_url` field is silently dropped and you get a normal synchronous image back. Upgrade before relying on async mode.

## Receiving the delivery

### Headers

```http
POST /hooks/picx HTTP/1.1
Content-Type: application/json
X-PicX-Event: generation.completed
X-PicX-Delivery: evt_e7e4a0602e364e26ba574c4d0ca03027
X-PicX-Attempt: 1
X-PicX-Generation: d3bfa107-6475-4f5b-ad32-c65c3e1b0377
X-PicX-Signature: t=1787654321,v1=6f1c2a9d8e...
```

### Body

```json
{
  "event": "generation.completed",
  "event_id": "evt_e7e4a0602e364e26ba574c4d0ca03027",
  "created_at": "2026-08-25T16:02:47.802789+00:00",
  "api_version": "2026-08-01",
  "webhook_id": null,
  "data": {
    "generation_id": "d3bfa107-6475-4f5b-ad32-c65c3e1b0377",
    "status": "completed",
    "type": "image",
    "model": "gemini-3.1-flash-image-preview",
    "output_url": "https://cdn.picxstudio.com/api/generated/image_6830d3ca.png",
    "error_message": null,
    "credits_used": 35
  }
}
```

Read `data.output_url` for the image. Only two events are sent by default: `generation.completed` and `generation.failed`. On a failure `output_url` is `null`, `error_message` is populated, and the credits are refunded.

`api_version` is bumped only on a breaking payload change, so you can pin against it.

### Verify the signature

`X-PicX-Signature` has the form `t={unix_timestamp},v1={hex}`, where the digest is HMAC-SHA256 over the string `{timestamp}.` concatenated with the **raw** request body, keyed with your signing secret.

Verify against raw bytes, before any JSON parsing or re-serialisation — a framework that reformats the body will invalidate the digest.

**Python (Flask)**

```python
import hmac, hashlib
from flask import Flask, request, abort, jsonify

app = Flask(__name__)
WEBHOOK_SECRET = "whsec_your_secret"

def verify(secret: str, header: str, raw_body: bytes) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    signed = f"{parts['t']}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(parts["v1"], expected)

@app.post("/hooks/picx")
def hook():
    sig = request.headers.get("X-PicX-Signature", "")
    if not sig or not verify(WEBHOOK_SECRET, sig, request.get_data()):
        abort(401)

    event = request.get_json()
    if event["event"] == "generation.completed":
        save_image(event["data"]["generation_id"], event["data"]["output_url"])

    # Reply 2xx promptly; do slow work out of band or PicX will retry.
    return jsonify(ok=True)
```

**JavaScript (Hono / Workers)**

```js
const enc = new TextEncoder();

async function verify(secret, header, rawBody) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const key = await crypto.subtle.importKey(
    "raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"],
  );
  const mac = await crypto.subtle.sign("HMAC", key, enc.encode(`${parts.t}.${rawBody}`));
  const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
  // Constant-time compare.
  if (expected.length !== parts.v1.length) return false;
  let diff = 0;
  for (let i = 0; i < expected.length; i++) diff |= expected.charCodeAt(i) ^ parts.v1.charCodeAt(i);
  return diff === 0;
}

app.post("/hooks/picx", async (c) => {
  const raw = await c.req.text();               // raw body, not c.req.json()
  const sig = c.req.header("X-PicX-Signature");
  if (!sig || !(await verify(c.env.PICX_WEBHOOK_SECRET, sig, raw))) {
    return c.text("bad signature", 401);
  }
  const event = JSON.parse(raw);
  if (event.event === "generation.completed") {
    c.executionCtx.waitUntil(save(event.data.generation_id, event.data.output_url));
  }
  return c.json({ ok: true });
});
```

### Where the signing secret comes from

A one-off `callback_url` or `webhook.url` target is signed with a secret derived from the API key that made the request. It is stable across restarts and distinct per key. Read it from the key's detail view in the [developer console](https://ai.picxstudio.com) — it is not returned by any `/v1` endpoint.

A registered webhook is signed with its own `whsec_…`, shown once when you create it.

## Delivery behaviour

| Property | Value |
| --- | --- |
| Attempts | 3 |
| Backoff | 1s, then 2s |
| Per-attempt timeout | 10s |
| Redirects | not followed |
| Success | any 2xx |

Reply `2xx` quickly and move slow work off the request. A timeout or non-2xx counts as a failed attempt, and after three the delivery is marked exhausted.

Deliveries are **at-least-once**. Dedupe on `X-PicX-Delivery` (or `event_id`) and treat handlers as idempotent — a retry after your handler succeeded but its response was lost is normal.

## Always have a fallback

A webhook can be missed: your endpoint may be down, or a deploy may drop the request. Never treat the callback as the only way you learn a result.

Both fallbacks use the id from the 202:

```js
// Poll to a terminal state. The job handle does this for you.
const asset = await job.wait({ pollIntervalMs: 3000, timeoutMs: 600000 });
console.log(asset.url);

// Or read the row directly, at any time.
const gen = await picx.generations.get(job.id);
console.log(gen.status, gen.output_url);
```

```python
generation = job.wait(poll_interval=3, timeout=600)
print(generation.status, generation.output_url)

generation = picx.generations.get(job.id)
```

There is also an SSE stream at `GET /v1/generations/{id}/events` for progress in a browser, which costs nothing per update.

A durable pattern: persist `job.id` at submit time, let the webhook fill in the result, and run a periodic sweep that polls anything still `pending` past a threshold.

### When a delivery never arrives

Ask the API what it tried. `GET /v1/generations/{id}/deliveries` returns every event fired for that generation and every attempt made, including the response your endpoint gave:

```js
const { deliveries } = await picx.generations.deliveries(job.id);
for (const d of deliveries) {
  console.log(d.event_type, d.outcome, d.status_code, d.target_url);
  console.log(d.attempts); // [{ n: 1, at: ..., status: 503, ms: 812, error: null }]
}
```

```python
log = picx.generations.deliveries(job.id)
for d in log.deliveries:
    print(d.event_type, d.outcome, d.status_code, d.target_url)
```

`outcome` distinguishes the cases that matter: `delivered`, `failed`, `pending_retry`, `exhausted`. An empty list means no delivery was ever attempted, which points at the binding rather than your endpoint — re-read the `webhook` object from the 202.

Once the endpoint is fixed, replay it rather than regenerating the image:

```js
await picx.webhooks.redeliver(deliveryId);
```

Requires `picx-ai` 0.3.1 or later. See [Webhooks](/docs/developer-tools/webhooks) for filtering and the redelivery semantics.

## Callback URL requirements

The target is validated at submit time and a bad one fails the request with `400` before any credits are spent.

- Scheme must be `http` or `https`; use `https` in production
- Hostname must resolve to a public address
- Loopback (`127.0.0.1`, `::1`), private ranges (`10/8`, `172.16/12`, `192.168/16`) and link-local (`169.254/16`, including the cloud metadata address) are rejected
- Maximum 2048 characters

This is an SSRF guard, and it fails closed — a hostname that does not resolve is rejected. For local development, expose your machine with a tunnel (`cloudflared tunnel --url http://localhost:3000`) rather than pointing at `localhost`.

## Binding to a registered webhook

Instead of an inline URL you can name a webhook registered in the console, which keeps the URL out of your request bodies and gives the delivery its own `whsec_` secret:

```js
const job = await picx.images.generate({
  prompt: "a cat",
  webhook: { id: "0b9c1e42-...", events: ["generation.completed"] },
});
```

```python
job = picx.images.generate("a cat", webhook_id="0b9c1e42-...")
```

Or an inline URL through the same field, which is recorded against the generation the same way:

```python
job = picx.images.generate("a cat", webhook_url="https://your-server.com/hooks/picx")
```

Resolution order is first match wins: `webhook.id`, then `webhook.url`, then `callback_url`. A bad or foreign webhook id fails the submit with `404` rather than running a generation that delivers nowhere.

> [!WARNING]
> Registering a webhook currently requires a signed-in session in the developer console — the `/v1` API surface has no webhook-management endpoints, so an API key alone cannot create one. Use `callback_url` or `webhook.url` if you are provisioning purely programmatically.

## Idempotency

The async path debits credits before the render starts, so a retried submit must not create a second generation. Send an idempotency key and a replay returns the original generation instead:

```js
await picx.images.generate(
  { prompt: "a cat", callback_url: hook },
  { idempotencyKey: "order-4417-hero" },
);
```

```python
picx.images.generate("a cat", callback_url=hook, idempotency_key="order-4417-hero")
```

The SDKs send it as both the `Idempotency-Key` header and an `idempotency_key` body field, because the async path deduplicates on the body field. If you are calling the API directly, send both.

## Billing

Identical to the synchronous path: credits are debited at submit and refunded automatically if the model fails. A `generation.failed` delivery means you were not charged. `data.credits_used` reports the amount, and `GET /v1/generations/{id}` carries the same figure.

A `402` at submit means insufficient credits and nothing was created.

## FAQ

### Does this change my existing synchronous calls?

No. The endpoints only switch behaviour when the request contains `callback_url` or `webhook`. Omit both and the request, response, and status code are exactly what they were before.

### Is video affected?

No. `POST /v1/videos/generate` has always been asynchronous and always returns `202`; it accepts the same delivery-target fields. What changed is that images can now do the same thing.

### Can I get a webhook without switching to async?

No. A delivery target is what selects async mode. If you want the image in the response, you are holding the connection open, and there is nothing to notify you about.

### What if my endpoint is down when the image finishes?

Three attempts are made over roughly 3 seconds, then the delivery is marked exhausted. The generation itself still succeeded — fetch it with `GET /v1/generations/{id}` using the id from the 202. This is why you should persist that id.

### How do I test a webhook from my laptop?

Not with `localhost` — the SSRF guard rejects it. Run a tunnel and use the public hostname:

```bash
cloudflared tunnel --url http://localhost:3000
```

### Which is better, polling or webhooks?

Webhooks if you have a public endpoint: no wasted requests and you learn the moment it is ready. Polling if you do not, or for a one-off script where standing up an endpoint is not worth it. Doing both — webhook plus a sweep for stragglers — is the reliable production pattern.
