DEVELOPER TOOLSImageEditing

Async Image Generation

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

POST/v1/images/generate

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.

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)

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)

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

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

{
  "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.

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

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

{
  "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)

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)

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 — 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:

// 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);
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:

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 }]
}
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:

await picx.webhooks.redeliver(deliveryId);

Requires picx-ai 0.3.1 or later. See 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:

const job = await picx.images.generate({
  prompt: "a cat",
  webhook: { id: "0b9c1e42-...", events: ["generation.completed"] },
});
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:

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.

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:

await picx.images.generate(
  { prompt: "a cat", callback_url: hook },
  { idempotencyKey: "order-4417-hero" },
);
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:

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.