Webhooks
A webhook tells your system when a job made through an API key has ended, so you do not have to poll. voao pro sends an HTTPS POST to an endpoint you register in the console.
Jobs made in the web app produce no events; only jobs made with an API key do.
Endpoints
An owner or admin registers an endpoint in the console: an HTTPS address, optionally one workspace to limit it to, and the event types it wants. Each endpoint has a signing secret, shown once when it is created (and again only when you replace it).
The address must be:
- HTTPS (plain HTTP is accepted only for a local development address when the platform itself runs in development);
- free of credentials (
https://user:pass@...is refused); - a public address. Private, loopback, link-local, multicast and cloud-metadata addresses are refused, both when you register and on every delivery (the address the name resolves to is checked when connecting).
You can switch an endpoint off and on, change its events, replace its secret (the old one stops signing at once) and delete it. The console shows each endpoint's recent deliveries for 30 days, lets you send one again, and can send a test event.
Events
| Type | Sent when a job made with an API key ... |
|---|---|
job.succeeded | succeeded |
job.failed | failed |
job.blocked | was blocked |
job.cancelled | was cancelled |
An endpoint limited to a workspace receives only that workspace's events. More event types may be added later; ignore types you do not handle.
The request
POST /hooks/voao HTTP/1.1
Content-Type: application/json
Voao-Event-Id: 6f0c3c1e-0b0e-4a0e-9a53-7a2f6f1f0f11
Voao-Event-Type: job.succeeded
Voao-Delivery: 3b0f4c2a-9d1e-4d6b-8c0a-1a2b3c4d5e6f
Voao-Signature: t=1761300000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd{
"id": "6f0c3c1e-0b0e-4a0e-9a53-7a2f6f1f0f11",
"type": "job.succeeded",
"created_at": "2026-10-24T09:30:00Z",
"data": {
"job": {
"id": "0b7d8f9e-1c2a-4b3d-8e4f-5a6b7c8d9e0f",
"model": "fake-image",
"status": "succeeded",
"price": 4,
"input": { "prompt": "a red bicycle" },
"result_asset_ids": ["c1a2b3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d"],
"created_at": "2026-10-24T09:29:40Z",
"updated_at": "2026-10-24T09:30:00Z"
}
}
}data.job has the same shape as the PublicJob returned by GET /v1/jobs/{jobId}. The result is an asset id: fetch the file with GET /v1/assets/{assetId}/download. The body carries no file content.
| Header | Meaning |
|---|---|
Voao-Event-Id | The event's id (same as id in the body). Identical on every attempt. |
Voao-Event-Type | The event type |
Voao-Delivery | The id of this delivery |
Voao-Signature | t=<unix seconds>,v1=<hex signature> |
Verifying the signature
The signature proves the request came from voao pro and was not changed. It is the hex HMAC-SHA256, keyed with your endpoint's secret, of the string
<t>.<raw request body>where t is the timestamp from the header and the body is the exact bytes received (do not parse and re-serialize it first).
To verify:
- Read the
Voao-Signatureheader and split it intotandv1. - Reject the request if
tis more than 5 minutes from your clock (this stops replays). - Compute the HMAC of
"<t>.<body>"with your secret. - Compare with
v1in constant time. Reject on mismatch with400. - Only then parse the JSON.
Node.js
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.VOAO_WEBHOOK_SECRET;
const app = express();
// The signature is over the raw bytes, so keep the body raw.
app.post("/hooks/voao", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("Voao-Signature") ?? "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
if (!Number.isFinite(t) || !parts.v1) return res.sendStatus(400);
if (Math.abs(Date.now() / 1000 - t) > 300) return res.sendStatus(400); // older than 5 minutes
const expected = crypto.createHmac("sha256", SECRET).update(`${t}.`).update(req.body).digest();
const given = Buffer.from(parts.v1, "hex");
if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) return res.sendStatus(400);
const event = JSON.parse(req.body.toString("utf8"));
// ... ignore it if event.id was already handled, then act on event.type
res.sendStatus(200);
});
app.listen(3000);Python
import hashlib
import hmac
import json
import os
import time
from flask import Flask, request, abort
SECRET = os.environ["VOAO_WEBHOOK_SECRET"].encode()
app = Flask(__name__)
@app.post("/hooks/voao")
def voao_hook():
header = request.headers.get("Voao-Signature", "")
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
try:
t = int(parts["t"])
given = parts["v1"]
except (KeyError, ValueError):
abort(400)
if abs(time.time() - t) > 300: # older than 5 minutes
abort(400)
body = request.get_data() # the raw bytes
expected = hmac.new(SECRET, f"{t}.".encode() + body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, given):
abort(400)
event = json.loads(body)
# ignore it if event["id"] was already handled, then act on event["type"]
return "", 200curl and openssl (to check a delivery by hand)
SECRET='whsec...' # your endpoint's signing secret
T=1761300000 # t from the header
BODY='{"id":"6f0c3c1e-..."}' # the exact body that was sent
printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //'
# compare the output with v1 from the headerTo exercise your own receiver locally, sign a body the same way and send it:
BODY='{"id":"test-1","type":"job.succeeded","created_at":"2026-10-24T09:30:00Z","data":{"job":{}}}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -i -X POST http://localhost:3000/hooks/voao \
-H "Content-Type: application/json" \
-H "Voao-Signature: t=$T,v1=$SIG" \
--data-binary "$BODY"Answering and retrying
Answer with any 2xx status within 10 seconds. Do the real work after answering (queue it); a slow receiver is a failing receiver.
Anything else (another status, a connection error, a timeout) is a failure, and the delivery is tried again after growing waits:
| Attempt | Wait before it |
|---|---|
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
| 7 | 24 hours |
After the last attempt fails the delivery is marked failed and no more attempts are made; you can still send it again from the console.
Other rules:
- Redirects are not followed. Register the final address. A
3xxcounts as a failure. - Only the status and the first 500 characters of your answer are kept in the delivery log; never put secrets in your answer.
- When an endpoint has failed 100 deliveries in a row, it is switched off and the console shows why. Fix the receiver and switch it back on.
- Delivery never slows the job itself; events are written in the same step in which the job ends, so an event exists if and only if the job's end was kept.
Handle repeats
Delivery is at least once. The same event can arrive more than once (a retry after a timeout you did answer, a redelivery from the console). Every attempt carries the same Voao-Event-Id, so store the ids you have handled and ignore a repeat. Do not assume events arrive in order; if it matters, fetch the job with GET /v1/jobs/{jobId} and use its status.