webhooks
get a POST when your generation finishes — no polling.
overview
a webhook is a single HTTP POST we send to your server the moment a generation reaches a
terminal state — DONE or FAILED. nothing fires while it's queued or running. one
generation, one POST.
use them instead of polling GET /gens/{uuid}/ every
few seconds. lower latency, lower request volume, fewer rate-limit headaches.
quick start
add a callback_url when you create the generation. that's the
only knob. the full endpoint schema lives in the api reference.
curl -X POST https://app.maginary.ai/api/gens/ \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "a cinematic portrait of a cyberpunk samurai --ar 16:9",
"callback_url": "https://your-app.example.com/webhooks/maginary"
}' the URL must be https:// and reachable from the public internet.
private / loopback / metadata IPs are rejected at delivery time.
the payload
the request body is the exact JSON you'd get from GET /gens/{uuid}/. for most clients, image_urls is the only field you need on a done generation. for the full schema (every field, every variant),
see the api reference.
{
"id": 8421,
"uuid": "0c8c1f3a-1a2b-4d8e-9f01-1234567890ab",
"prompt": "a cinematic portrait of a cyberpunk samurai --ar 16:9",
"action_type": "txt2img",
"parent_image_index": null,
"parent_gen": null,
"parent_gen_uuid": null,
"parent_expected_output_count": null,
"created_at": "2026-06-01T12:34:50.112Z",
"processing_state": "DONE",
"processing_started_at": "2026-06-01T12:34:51.004Z",
"processing_ended_at": "2026-06-01T12:35:18.776Z",
"processing_result": {
"job_id": "mj_4f1c...",
"slots": [
{ "index": 0, "status": "success", "url": "https://s.maginary.ai/.../0.png", "charged": true },
{ "index": 1, "status": "success", "url": "https://s.maginary.ai/.../1.png", "charged": true },
{ "index": 2, "status": "success", "url": "https://s.maginary.ai/.../2.png", "charged": true },
{ "index": 3, "status": "success", "url": "https://s.maginary.ai/.../3.png", "charged": true }
],
"error_message": null,
"available_actions": {
"0": ["upscale", "vary", "vary_strong", "zoom_out", "pan_left", "pan_right", "pan_up", "pan_down"],
"1": ["upscale", "vary", "vary_strong", "zoom_out", "pan_left", "pan_right", "pan_up", "pan_down"],
"2": ["upscale", "vary", "vary_strong", "zoom_out", "pan_left", "pan_right", "pan_up", "pan_down"],
"3": ["upscale", "vary", "vary_strong", "zoom_out", "pan_left", "pan_right", "pan_up", "pan_down"],
"global": ["reroll"]
}
},
"processing_progress": null,
"tokens": 3,
"url": "https://app.maginary.ai/api/gens/0c8c1f3a-1a2b-4d8e-9f01-1234567890ab/",
"image_urls": [
"https://s.maginary.ai/.../0.png",
"https://s.maginary.ai/.../1.png",
"https://s.maginary.ai/.../2.png",
"https://s.maginary.ai/.../3.png"
],
"is_demo_owned": false,
"expected_output_count": 4,
"recipe_metadata": null,
"url_slug": null,
"owner_username": "max",
"resolved_slug": null,
"resolved_slot_index": null
} failed generations carry the same envelope but with processing_state: "FAILED", an error_message string inside processing_result, and slots marked status: "failed".
headers
| header | meaning |
|---|---|
X-Maginary-Event | gen.done or gen.failed. |
X-Maginary-Event-Id | stable per-event id, equal to the gen uuid. use this
to dedupe across retries. also mirrored at body.uuid. |
X-Maginary-Delivery-Attempt | integer counter (1, 2, …). debug / log-correlation only — do not use for dedupe. |
X-Maginary-Signature | sha256=<hex> HMAC of the raw body. verify before
trusting anything in the payload. |
Content-Type is always application/json. user-agent is maginary-webhook/1.
verify the signature
the signature is HMAC-SHA256 of the raw request body bytes, keyed by your webhook_secret. fetch the secret from GET /api/api-keys/ (response includes a webhook_secret field) or grab it from your api keys dashboard.
the secret is per-account, not per-key — rotating an API key does not invalidate it.
always use a constant-time comparison (hmac.compare_digest / crypto.timingSafeEqual / hmac.Equal). a plain == leaks timing information and lets an attacker recover the signature
byte-by-byte. this is non-negotiable.
import hmac, hashlib
from flask import request, abort
WEBHOOK_SECRET = "..." # from GET /api/api-keys/ → webhook_secret
@app.post("/webhooks/maginary")
def maginary_webhook():
# 1. Hash the RAW request body bytes — NOT a re-serialized JSON.
body = request.get_data() # bytes
received = request.headers.get("X-Maginary-Signature", "")
expected = "sha256=" + hmac.new(
WEBHOOK_SECRET.encode("utf-8"), body, hashlib.sha256
).hexdigest()
# 2. Constant-time compare. Don't use == — leaks timing info.
if not hmac.compare_digest(expected, received):
abort(401)
# 3. Dedupe by X-Maginary-Event-Id (= gen uuid). Same across retries.
event_id = request.headers["X-Maginary-Event-Id"]
if already_processed(event_id):
return "", 200
payload = request.get_json()
handle_generation(payload)
return "", 200import crypto from "node:crypto";
import express from "express";
const WEBHOOK_SECRET = process.env.MAGINARY_WEBHOOK_SECRET;
const app = express();
// IMPORTANT: capture the raw body BEFORE express.json() parses it.
// Re-serializing req.body to JSON will produce different bytes and break HMAC.
app.post(
"/webhooks/maginary",
express.raw({ type: "application/json" }),
(req, res) => {
const received = req.header("X-Maginary-Signature") ?? "";
const expected =
"sha256=" +
crypto.createHmac("sha256", WEBHOOK_SECRET).update(req.body).digest("hex");
// Constant-time compare. Buffers must be same length, hence the try/catch.
const ok =
received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!ok) return res.sendStatus(401);
const eventId = req.header("X-Maginary-Event-Id");
if (alreadyProcessed(eventId)) return res.sendStatus(200);
const payload = JSON.parse(req.body.toString("utf-8"));
handleGeneration(payload);
res.sendStatus(200);
}
);package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
)
var webhookSecret = []byte(os.Getenv("MAGINARY_WEBHOOK_SECRET"))
func maginaryWebhook(w http.ResponseWriter, r *http.Request) {
// Read the RAW body. Don't json.Decode and re-encode — bytes won't match.
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
mac := hmac.New(sha256.New, webhookSecret)
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
received := r.Header.Get("X-Maginary-Signature")
// hmac.Equal is constant-time. Don't use ==.
if !hmac.Equal([]byte(expected), []byte(received)) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
eventID := r.Header.Get("X-Maginary-Event-Id")
if alreadyProcessed(eventID) {
w.WriteHeader(http.StatusOK)
return
}
handleGeneration(body)
w.WriteHeader(http.StatusOK)
}# Sanity check from the shell — reproduce the signature locally.
SECRET="your-webhook-secret"
BODY="$(cat received-body.json)"
echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print "sha256="$2}'
# Compare against the X-Maginary-Signature header value byte-for-byte.retry behavior
we retry on 5xx, 408, 429, and network / timeout errors. other 4xx responses mean the receiver rejected the payload deliberately
— we record it and stop.
per-attempt timeout is 10 seconds. respond with any 2xx within that window and we mark it delivered.
| attempt | wait before attempt |
|---|---|
| 1 | (immediate, on terminal state) |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 8 hours |
total budget is ~10h45m across 6 attempts. after that we give up; fall back to polling GET /gens/{uuid}/ — the data is still there.
idempotency
retries are normal. design your receiver to handle the same event arriving multiple times.
dedupe by X-Maginary-Event-Id — same value on every retry of
the same generation, also present at body.uuid. a tiny
"seen-events" table or redis set keyed by that uuid is enough.
do not dedupe by X-Maginary-Delivery-Attempt — that counter changes per attempt and is debug-only.
data retention
webhook delivery state (timestamps, attempt counts, last status code, last error) is retained
for 30 days after successful delivery, then nulled. once nulled, the webhook field on GET /api/gens/{uuid}/ returns null for that gen.
undelivered gens and those whose retries exhausted without ever reaching a 2xx are kept indefinitely — those are the ones you might ask us about, and we want the forensics intact.
the callback_url itself is also cleared at the 30-day mark, so
re-querying an old gen will not show which endpoint we POSTed to. persist anything you need long-term
on your side.
local testing
point callback_url at an ngrok tunnel (or cloudflared / tailscale funnel / etc.) to receive real webhooks against your laptop.
loopback and private IPs are blocked at delivery, so a public tunnel is the simplest way to iterate.
troubleshooting
i'm not receiving anything
- confirm your
callback_urlaccepts POST (not just GET). - confirm the receiver returns a
2xxwithin 10 seconds. - confirm a 401 from your signature-verify code isn't quietly dropping us — check your access
logs for inbound POSTs from
maginary-webhook/1. - the gen has to reach
DONEorFAILED. queued / running states don't fire.
signatures are mismatching
- hash the raw request body bytes, not a re-serialized JSON object.
middleware that parses then re-stringifies (e.g. default
express.json()before your handler) will produce different bytes and break HMAC. - use the exact same secret as the one returned by
GET /api/api-keys/. no quotes, no trailing newline. - compare the full header value (
sha256=…) — don't strip the prefix on one side and forget on the other.
i rotated my api key — do i need to update my receiver?
no. the webhook_secret lives on your account, not on the api key.
rotating an api key never changes it.