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

headermeaning
X-Maginary-Eventgen.done or gen.failed.
X-Maginary-Event-Idstable per-event id, equal to the gen uuid. use this to dedupe across retries. also mirrored at body.uuid.
X-Maginary-Delivery-Attemptinteger counter (1, 2, …). debug / log-correlation only — do not use for dedupe.
X-Maginary-Signaturesha256=<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 "", 200

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.

attemptwait before attempt
1(immediate, on terminal state)
21 minute
35 minutes
430 minutes
52 hours
68 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_url accepts POST (not just GET).
  • confirm the receiver returns a 2xx within 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 DONE or FAILED. 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.