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_… |
Webhook registration (create, list, test, delete) lives on the console's session-authenticated API under
/api/webhooks, not on the public/v1surface. An API key alone cannot register a webhook. If you provision programmatically, usecallback_urlorwebhook: {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}/deliveriesandPOST /v1/webhooks/deliveries/{id}/redeliverare published under/v1and work with an API key. In the SDKs that isgenerations.deliveries(),webhooks.deliveries()andwebhooks.redeliver(), available frompicx-ai0.3.1.
For images specifically, 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:
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
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",
)
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. 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:
const job = await picx.video.create({
prompt: "a cat jumping off a table",
duration: 5,
resolution: "480p",
webhook: { url: "https://your-server.com/webhook" },
});
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 |
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.
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 }]
}
log = picx.generations.deliveries(job.id)
for d in log.deliveries:
print(d.event_type, d.outcome, d.status_code, d.target_url)
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:
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:
const result = await picx.webhooks.redeliver(deliveryId);
console.log(result.outcome, result.status_code);
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:
const job = await picx.images.generate({
prompt: "a cat",
webhook: { id: "0b9c1e42-…", events: ["generation.completed"] },
});
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
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
{
"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.
A
callback_urltarget receives a superset body for backwards compatibility: the envelope above plus thedatafields flattened onto the top level (generation_id,status,output_url, …). Readingdata.output_urlworks 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.
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
httporhttps; usehttpsin 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.
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
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
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 });
});
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.