API

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.

JSON
{
  "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

  • https only, 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

HTTP
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-2a8b6e4c9f10

The body is exactly the JSON that GET /v1/requests/{request_id} returns for the finished request (the request object).

HeaderValue
X-AITOPIA-Signaturet=<unix seconds>,v1=<signature> (see below)
X-AITOPIA-Request-IdThe 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:

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

  1. Split the header on , and read t and v1.
  2. Compute the HMAC-SHA256 of t + "." + raw_body with your webhook secret, hex-encoded.
  3. Compare it with v1 in constant time.
  4. 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.
Node.js (Express)
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);
});
Python (Flask)
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.

AITOPIA REST API · Live https://api-live.aitopia.ai/v1 · Sandbox https://api-sandbox.aitopia.ai/v1