voao pro

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

TypeSent when a job made with an API key ...
job.succeededsucceeded
job.failedfailed
job.blockedwas blocked
job.cancelledwas 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.

HeaderMeaning
Voao-Event-IdThe event's id (same as id in the body). Identical on every attempt.
Voao-Event-TypeThe event type
Voao-DeliveryThe id of this delivery
Voao-Signaturet=<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:

  1. Read the Voao-Signature header and split it into t and v1.
  2. Reject the request if t is more than 5 minutes from your clock (this stops replays).
  3. Compute the HMAC of "<t>.<body>" with your secret.
  4. Compare with v1 in constant time. Reject on mismatch with 400.
  5. 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 "", 200

curl 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 header

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

AttemptWait before it
21 minute
35 minutes
430 minutes
52 hours
66 hours
724 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 3xx counts 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.

Webhooks - voao pro developers