# Webhooks

> Receive signed notifications when asynchronous generations complete or fail.

Webhooks notify your server when a generation finishes, so you do not have to poll. They apply to every asynchronous generation — video, and (since API version `2026-08-01`) images submitted with a delivery target.

There are two ways to name a target, and which you can use depends on how you authenticate:

| | How you set it | Auth to set up | Signed with |
| --- | --- | --- | --- |
| **Per-request URL** | `callback_url` or `webhook: {url}` in the generation body | API key | a secret derived from your API key |
| **Registered webhook** | create in the console, then `webhook: {id}` | signed-in session | its own `whsec_…` |

> [!WARNING]
> Webhook **registration** (create, list, test, delete) lives on the console's session-authenticated API under `/api/webhooks`, **not** on the public `/v1` surface. An API key alone cannot register a webhook. If you provision programmatically, use `callback_url` or `webhook: {url}` on the generation request — it needs no setup and is signed and logged the same way.
>
> Delivery **inspection** is different: `GET /v1/generations/{id}/deliveries`, `GET /v1/webhooks/{id}/deliveries` and `POST /v1/webhooks/deliveries/{id}/redeliver` are published under `/v1` and work with an API key. In the SDKs that is `generations.deliveries()`, `webhooks.deliveries()` and `webhooks.redeliver()`, available from `picx-ai` 0.3.1.

For images specifically, [Async Image Generation](/docs/developer-tools/async-image-generation) covers the whole flow end to end.

## The simplest path: a per-request URL

No registration, no console — works with an API key alone:

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

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

const job = await picx.images.generate({
  prompt: "a cat on a windowsill",
  callback_url: "https://your-server.com/hooks/picx",
});

console.log(job.id); // persist this to correlate the delivery
```

```python
import os
from picx import PicX

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

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

```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 cat on a windowsill","callback_url":"https://your-server.com/hooks/picx"}'
```

The same field works on `POST /v1/images/edit` and `POST /v1/videos/generate`.

The secret for this mode is derived from the API key that made the request — stable, distinct per key, and readable from the key's detail view in the [developer console](https://ai.picxstudio.com). It is not returned by any `/v1` endpoint.

## Bind inline through the `webhook` field

Equivalent to `callback_url`, but recorded against the generation as an explicit binding (`mode: "inline"`) and delivered with the strict envelope rather than the legacy flat body:

```js
const job = await picx.video.create({
  prompt: "a cat jumping off a table",
  duration: 5,
  resolution: "480p",
  webhook: { url: "https://your-server.com/webhook" },
});
```

```python
job = picx.video.create(
    prompt="a cat jumping off a table",
    duration=5,
    resolution="480p",
    webhook_url="https://your-server.com/webhook",
)
```

Resolution order is first match wins: `webhook.id`, then `webhook.url`, then `callback_url`.

## Managing registered webhooks

Registration requires a signed-in console session. Paths are under `/api`, not `/v1`.

| Action | Path | API key? |
| --- | --- | --- |
| Create | `POST /api/webhooks` | no — console only |
| List | `GET /api/webhooks` | no — console only |
| Test delivery | `POST /api/webhooks/{id}/test` | no — console only |
| Delete | `DELETE /api/webhooks/{id}` | no — console only |

Delivery inspection **is** available to API keys, under `/v1`:

| Action | Path | API key? |
| --- | --- | --- |
| Deliveries for one generation | `GET /v1/generations/{id}/deliveries` | yes |
| Deliveries for one webhook | `GET /v1/webhooks/{id}/deliveries` | yes |
| Redeliver | `POST /v1/webhooks/deliveries/{id}/redeliver` | yes |

> [!NOTE]
> The signing secret (`whsec_…`) is returned only once, when you create the webhook. Store it immediately.

## Inspecting deliveries

When a result never turns up, this is the call that tells you why — it shows every event fired for a generation and every attempt made, including inline and legacy `callback_url` deliveries that have no registered webhook row.

```js
const { deliveries, total } = 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)
```

```bash
curl https://api.picxstudio.com/v1/generations/GENERATION_ID/deliveries \
  -H "Authorization: Bearer pxsk_your_key"
```

For a registered webhook, filter to just the problems:

```js
const { deliveries } = await picx.webhooks.deliveries(webhookId, {
  outcome: "exhausted",
  limit: 50,
});
```

`outcome` accepts `delivered`, `failed`, `pending_retry` or `exhausted`; anything else is rejected with a `400` rather than quietly matching nothing. `limit` caps at 100.

Once you have fixed your endpoint, replay a delivery:

```js
const result = await picx.webhooks.redeliver(deliveryId);
console.log(result.outcome, result.status_code);
```

```python
result = picx.webhooks.redeliver(delivery_id)
```

The replay reuses the original payload and `event_id`, so a consumer that dedupes on `X-PicX-Delivery` can safely ignore one it already handled. The recorded target URL is re-validated first — a delivery whose hostname now resolves to a private address is refused with a `400` rather than fetched.

Once registered, binding a generation to a webhook by id needs only an API key:

```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-…")
```

A bad or foreign webhook id fails the submit with `404`, before credits are spent, rather than running a generation that delivers nowhere.

## Delivery headers

```http
POST /your/webhook 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...
```

## Event payload

```json
{
  "id": "evt_e7e4a0602e364e26ba574c4d0ca03027",
  "event": "generation.completed",
  "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
  }
}
```

The event name is in **`event`**, and the delivery id in **`id`**. `data.type` is `image` or `video`.

Two event types are sent by default — `generation.completed` and `generation.failed` — and `generation.cancelled` can be subscribed to explicitly. 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.

> [!NOTE]
> A `callback_url` target receives a **superset** body for backwards compatibility: the envelope above plus the `data` fields flattened onto the top level (`generation_id`, `status`, `output_url`, …). Reading `data.output_url` works for every target type, so prefer it.

## Delivery behaviour

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

Reply `2xx` promptly and move heavy work off the request — a timeout counts as a failed attempt, and after three the delivery is marked exhausted.

Deliveries are **at-least-once**. Dedupe on `X-PicX-Delivery` (or the body's `id`) and keep handlers idempotent.

> [!NOTE]
> Never treat the webhook as your only path to the result. If a delivery is missed the generation still succeeded — read it with `GET /v1/generations/{id}` using the id from the 202. Persist that id at submit time.

## Callback URL requirements

Validated at submit time; a bad target fails with `400` before credits are spent.

- Scheme `http` or `https`; use `https` in production
- Hostname must resolve to a public address
- Loopback, private ranges and link-local (including the cloud metadata address) are rejected
- Maximum 2048 characters

The guard fails closed, so a hostname that does not resolve is rejected. For local development use a tunnel (`cloudflared tunnel --url http://localhost:3000`) rather than `localhost`.

> [!WARNING]
> Always verify the signature before trusting a delivery. The X-PicX-Signature header has the form t={timestamp},v1={signature}: compute HMAC-SHA256 over the exact string "{timestamp}." followed by the raw request body, using your webhook secret, then compare in constant time.

## Verify the signature

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

**python**

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

app = Flask(__name__)
WEBHOOK_SECRET = "whsec_your_secret"

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

@app.route("/webhook", methods=["POST"])
def handle_webhook():
    header = request.headers.get("X-PicX-Signature", "")
    if not header or not verify_signature(WEBHOOK_SECRET, header, request.get_data()):
        abort(401)
    event = request.get_json()
    if event["event"] == "generation.completed":
        print("Ready:", event["data"]["output_url"])
    elif event["event"] == "generation.failed":
        print("Failed:", event["data"]["error_message"])
    return jsonify(ok=True)
```

**javascript**

```js
import express from "express";
import crypto from "crypto";

const app = express();
const WEBHOOK_SECRET = "whsec_your_secret";

function verifySignature(secret, header, rawBody) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const signed = `${parts.t}.` + rawBody.toString();
  const expected = crypto.createHmac("sha256", secret).update(signed).digest("hex");
  const a = Buffer.from(parts.v1 || "", "hex");
  const b = Buffer.from(expected, "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.headers["x-picx-signature"] || "";
  if (!verifySignature(WEBHOOK_SECRET, header, req.body)) {
    return res.status(401).json({ error: "Invalid signature" });
  }
  const event = JSON.parse(req.body.toString());
  if (event.event === "generation.completed") {
    console.log("Ready:", event.data.output_url);
  }
  res.json({ ok: true });
});
```

> [!NOTE]
> Deliveries are retried up to 3 times with backoff on non-2xx responses. Respond quickly with a 2xx once you have accepted the event, then do heavy work asynchronously.
