Webhooks
Receive the final result as a signed POST instead of polling.
Add webhook_url to the body of any submit call (POST /v1/models/{model_id}, POST /v1/edit, POST /v1/batch). When the request reaches a final status (completed, partial, failed or cancelled), AITOPIA sends one signed POST to that URL.
{
"prompt": "a lighthouse at dusk, watercolor",
"webhook_url": "https://example.com/aitopia/webhook"
}The webhook is an addition to polling, not a replacement: GET /v1/requests/{request_id} keeps working.
#Requirements for webhook_url
httpsonly, at most 2048 characters.- No user name or password in the URL.
- The host must resolve to public addresses only (no private, loopback or internal networks).
A URL that breaks these rules is refused at submit time with 400 URL_NOT_ALLOWED (field: "webhook_url") and nothing runs. A webhook_url that is not a string is 400 INVALID_INPUT.
#The delivery
POST /aitopia/webhook HTTP/1.1
Content-Type: application/json
User-Agent: AITOPIA-Webhooks/1.0
X-AITOPIA-Signature: t=1761213614,v1=5f2b9c...e1
X-AITOPIA-Request-Id: 0d6f3c1e-5b7a-4c2e-9d1f-2a8b6e4c9f10The body is exactly the JSON that GET /v1/requests/{request_id} returns for the finished request (the request object).
| Header | Value |
|---|---|
X-AITOPIA-Signature | t=<unix seconds>,v1=<signature> (see below) |
X-AITOPIA-Request-Id | The request_id. Use it to ignore a delivery you have already processed. |
Answer with any 2xx status within 10 seconds. Anything else counts as a failed attempt: other status codes, timeouts, connection errors, and redirects (3xx; redirects are not followed).
Do slow work after answering: store the body, return 200, then process it.
#Verifying the signature
Each key has a webhook secret, shown once when the key is created (you can rotate it under profile menu > API keys). Deliveries are signed with the secret of the key that submitted the request, as it is at delivery time.
The signature is:
v1 = hex( HMAC-SHA256( webhook_secret, "<t>.<raw request body>" ) )where t is the value from the header and the raw body is the exact bytes you received (do not parse and re-serialise the JSON before checking). To verify:
- Split the header on
,and readtandv1. - Compute the HMAC-SHA256 of
t + "." + raw_bodywith your webhook secret, hex-encoded. - Compare it with
v1in constant time. - Optionally reject old timestamps (for example more than 5 minutes from your clock) to limit replays. Every attempt, retries included, is signed with a fresh
t.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.AITOPIA_WEBHOOK_SECRET;
// Keep the raw body: the signature covers the exact bytes.
app.post("/aitopia/webhook", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("X-AITOPIA-Signature") ?? "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${parts.t}.${req.body.toString("utf8")}`)
.digest("hex");
const ok =
typeof parts.v1 === "string" &&
parts.v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
if (!ok || !fresh) return res.status(400).send("bad signature");
const result = JSON.parse(req.body.toString("utf8"));
// Deduplicate on result.request_id (also in X-AITOPIA-Request-Id), then queue the work.
res.sendStatus(200);
});import hashlib, hmac, json, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["AITOPIA_WEBHOOK_SECRET"].encode()
@app.post("/aitopia/webhook")
def aitopia_webhook():
header = request.headers.get("X-AITOPIA-Signature", "")
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
raw = request.get_data() # exact bytes
signed = parts.get("t", "").encode() + b"." + raw
expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, parts.get("v1", "")):
abort(400)
if abs(time.time() - int(parts.get("t", "0"))) > 300:
abort(400)
result = json.loads(raw)
# Deduplicate on result["request_id"], then queue the work.
return "", 200#Retries
A failed attempt is retried after 10 s, 1 min, 5 min, 30 min, 2 h, 6 h and 12 h (at most 8 attempts in total), and never later than 24 hours after the request finished. After that the webhook is given up. A delivery can arrive more than once, so make your handler idempotent on request_id.
#When no webhook is sent
- The submit itself was refused: the error is in the submit answer, and no webhook is owed.
- The key was revoked before delivery.
- The key had no usable aitopia.ai session attached while the request was still open. If the session merely ended, the request keeps being checked until 24 hours after submit: sign in to aitopia.ai so the result can be fetched and delivered (Authentication).
- The request never started a run within 10 minutes, or did not finish within 24 hours of submit.
In each case the request can still be read with GET /v1/requests/{request_id}, by any working key of the same account.
#Timing
Open requests that have a webhook are checked about every 5 seconds, so a delivery usually follows the end of the run within a few seconds.