An Ideogram webhook is not a subscription. There is no event name to register and no shared secret to paste into a settings page. You attach a webhook_url query parameter to one asynchronous request, and Ideogram posts the finished generation back once every image in that request is done. One delivery, one request, and a signature you verify yourself.

How the callback actually works

The asynchronous endpoint is POST /v1/ideogram-v4/async/generate. It takes the same generation request as the synchronous route, returns immediately with a URL-safe base64 generation_id, and hands the result to webhook_url once the images exist. The target must be HTTPS, and private hosts, loopback hosts and the cloud metadata service are rejected.

A cloud outline sending a signed request to a server box, with a callback arrow looping back below

Nothing in that description is an event type, because there is none. The body is the generation result itself, so one handler serves both the callback and a synchronous response.

# webhook_url is a query parameter, not a body field and not a dashboard setting.
# Api-Key is the documented authentication header.
curl -s -X POST \
  "https://api.ideogram.ai/v1/ideogram-v4/async/generate?webhook_url=https%3A%2F%2Fexample.com%2Fideogram-webhook" \
  -H "Api-Key: ${IDEOGRAM_API_KEY}" \
  -F 'text_prompt=A poster on a wall that reads: "Everything you can imagine is real."' \
  -F "resolution=1024x1024" \
  -o accepted.json

# Keep this id: it correlates the callback and drives the polling fallback.
jq -r '.generation_id' accepted.json

What the callback carries

The body mirrors the synchronous generation response: generation_id, created, and a data array whose entries hold url, prompt, resolution, seed and is_image_safe. Every delivery also carries a fixed header set; three of those are load-bearing.

HeaderWhat it carries
X-Ideogram-Webhook-Generation-Idbase64 generation id, matches the body field
X-Ideogram-Webhook-User-Idbase64 account id that placed the request
X-Ideogram-Webhook-TimestampUnix seconds, decimal, at signing time
X-Ideogram-Webhook-Key-IdHint at which signing key to try first
X-Ideogram-Webhook-SignatureEd25519 signature, lowercase hex
Content-Typeapplication/json

One official page contradicts another here. The webhooks page shows a top-level generation_id in the body, while the async endpoint reference describes that body as mirroring the synchronous response and names its correlation field request_id. Both sit under developer.ideogram.ai. Key on the header instead: both agree X-Ideogram-Webhook-Generation-Id matches the generation_id your submit call returned.

Verifying that the delivery is genuine

Verification rests on a published key set rather than a shared secret, and on raw bytes rather than a parsed object.

Fetch the public keys

Send GET to https://api.ideogram.ai/v1/.well-known/jwks.json. It lists Ed25519 public keys as JWKs, each carrying an x field: the 32-byte public key, base64url encoded and unpadded. The missing padding is where most implementations fail first. Cache the set and refresh it whenever a signature fails against the cached copy, in case the keys rotated.

Rebuild the signed message

Four values joined by a single newline, in this order: the generation id header, the user id header, the timestamp header, and the SHA-256 hex digest of the raw body. Encode as UTF-8 and check it against each published key. If any key verifies, the delivery is authentic.

The digest is where incidents come from. Ideogram signs the bytes it sent. Parse and re-serialise them first, and key order or whitespace can shift, the digest changes, and a genuine webhook fails. Read the raw body: request.get_data() in Flask, await request.body() in FastAPI and Starlette, request.body in Django, express.raw() in Express.

import base64
import hashlib
import requests
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

JWKS_URL = "https://api.ideogram.ai/v1/.well-known/jwks.json"
SIGNED = (
    "X-Ideogram-Webhook-Generation-Id",
    "X-Ideogram-Webhook-User-Id",
    "X-Ideogram-Webhook-Timestamp",
)

def b64url(value: str) -> bytes:
    # The JWK x field is unpadded base64url; restore the padding first.
    return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))

def public_keys() -> list[Ed25519PublicKey]:
    jwks = requests.get(JWKS_URL, timeout=5).json()
    return [Ed25519PublicKey.from_public_bytes(b64url(jwk["x"])) for jwk in jwks["keys"]]

def is_authentic(headers: dict, raw_body: bytes) -> bool:
    """raw_body must be the bytes as received, never a re-serialised dict."""
    digest = hashlib.sha256(raw_body).hexdigest()
    message = "\n".join([*(headers[name] for name in SIGNED), digest]).encode("utf-8")
    signature = bytes.fromhex(headers["X-Ideogram-Webhook-Signature"])
    # Key-Id is a hint, not a requirement: try every key so rotation is survivable.
    for key in public_keys():
        try:
            key.verify(signature, message)
            return True
        except InvalidSignature:
            continue
    return False

A handler that survives retries

Return 2xx to acknowledge a delivery. Anything else, or a timeout, and Ideogram retries. That sentence dictates the shape of the endpoint: idempotent, and correct for a delivery it has already answered.

Key the work on the generation id

The same generation_id can arrive more than once, because a slow endpoint gets retried. Key completion state on that id, treat an already-processed id as a no-op, and still return 2xx so the retries stop. A unique key in whatever table already holds your jobs is enough.

import os
import time

import requests
from flask import Flask, request

app = Flask(__name__)
BASE = "https://api.ideogram.ai/v1"
HEADERS = {"Api-Key": os.environ["IDEOGRAM_API_KEY"]}
DONE: set[str] = set()

def finished(generation_id: str) -> dict:
    while True:
        payload = requests.get(
            f"{BASE}/generations/{generation_id}", headers=HEADERS, timeout=30
        ).json()
        if payload.get("status") != "pending":
            return payload
        time.sleep(5)

@app.post("/ideogram-webhook")
def ideogram_webhook():
    raw = request.get_data()  # raw bytes; parsing first would break the digest
    if not is_authentic(dict(request.headers), raw):
        return {"error": "invalid signature"}, 400

    generation_id = request.headers["X-Ideogram-Webhook-Generation-Id"]
    if generation_id in DONE:
        return {"ok": True, "duplicate": True}
    DONE.add(generation_id)

    payload = request.get_json()
    for index, image in enumerate(payload["data"]):
        if not image["is_image_safe"]:
            continue
        # Image links expire, so the bytes come down inside this request.
        with open(f"{generation_id}-{index}.png", "wb") as handle:
            handle.write(requests.get(image["url"], timeout=60).content)
    return {"ok": True}

if __name__ == "__main__":
    finished(os.environ["GENERATION_ID"])

When the webhook never arrives

Delivery is not guaranteed. Retries are capped, and after that the delivery is dropped, so an endpoint that stays down long enough never hears about a generation that succeeded. Nothing raises an error.

Poll the same generation id

Keep the id from the submit call and fetch GET /v1/generations/{generation_id} on a schedule. It returns the same data payload once the generation finishes, so one handler serves both routes.

What Ideogram does not publish

Three official pages describe this mechanism and none puts a number on the operational details. Where a third-party guide offers one, that is the guide’s estimate.

  • Retry count and schedule. Described as limited, with no figure attached.
  • Timestamp tolerance. The timestamp is inside the signed message, so replay matters, yet no clock skew is published.
  • Key rotation period. The set is cacheable and refreshable, with no interval given.
  • Callback timeout. A non-2xx response or a timeout triggers a retry; the threshold is unstated.
  • Link lifetime. Image links expire and must be downloaded, with no published TTL.
  • Source addresses. The only stated filter is HTTPS plus rejection of private, loopback and metadata hosts.

The API endpoint reference covers the rest of the surface, and the Ideogram 4.0 prompt guide covers the json_prompt object this route accepts. Two facts both pages agree on: text_prompt and json_prompt are mutually exclusive, and rendering_speed=FLASH returns a 400. Async billing is per image, as the pricing breakdown lists.

Frequently asked questions

Is there an event type field? No. The documentation defines no event name, and the body is the generation result rather than an envelope.

Do I share a secret with Ideogram? No. Verification uses Ed25519 public keys from the JWKS endpoint, so there is no secret to rotate or leak.

Can I verify after parsing the JSON? No. The signature covers the digest of the raw bytes, and re-serialising changes them even for a genuine delivery.

What if my endpoint is down? You lose the delivery. Poll GET /v1/generations/{generation_id} with the id from the submit call, or follow the getting-started walkthrough to see the model work in a browser first.

Build the polling path first, add the callback second, and verify before you parse. Those three decisions cover what the specification actually promises.