> For clean Markdown of any page, append .md to the page URL. > For a complete documentation index, see https://developer.ideogram.ai/v1/ideogram-api/webhooks/llms.txt. > For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://developer.ideogram.ai/_mcp/server. > Receive asynchronous generation results via signed webhooks — payload format, Ed25519 signature verification, and retry behavior. Asynchronous generation endpoints (for example `/v1/ideogram-v4/async/generate`) return immediately with a `generation_id` and deliver the finished result to the `webhook_url` you supply. Ideogram sends a JSON `POST` to that URL once every image for the request has finished generating. Each delivery is signed with Ed25519 so you can confirm it genuinely came from Ideogram before acting on it. ## What Ideogram delivers The request body mirrors the synchronous generation response, so the same handler can process both. It contains: * `generation_id`: URL-safe base64 ID of the generation. Use it to correlate the webhook with your original API call, and to poll the generation. * `created`: the time the generation was created. * `data`: an array of generated images, each with `url`, `prompt`, `resolution`, `seed`, and `is_image_safe`. ```json { "generation_id": "xtdZiqPwRxqY1Y7NExFmzB", "created": "2025-01-23T04:56:07Z", "data": [ { "url": "https://ideogram.ai/api/images/ephemeral/xtdZiqPwRxqY1Y7NExFmzB.png", "prompt": "A photo of a cat", "resolution": "2048x2048", "seed": 12345, "is_image_safe": true } ] } ``` ## Request headers Every delivery includes these headers: | Header | Value | | ---------------------------------- | ------------------------------------------------------------------------------------------ | | `X-Ideogram-Webhook-Generation-Id` | URL-safe base64 ID of the generation. Matches `generation_id` in the body. | | `X-Ideogram-Webhook-User-Id` | URL-safe base64 ID of the account that initiated the request. | | `X-Ideogram-Webhook-Timestamp` | Unix seconds (decimal) at which the request was signed. | | `X-Ideogram-Webhook-Key-Id` | The `kid` of the signing key. A hint for which public key to try first, not a requirement. | | `X-Ideogram-Webhook-Signature` | Ed25519 signature, lowercase hex. | | `Content-Type` | `application/json` | ## Verifying the signature 1. **Fetch the public keys.** Send a `GET` to `https://api.ideogram.ai/v1/.well-known/jwks.json`. The response lists Ed25519 public keys in JWK form. Each key's `x` field is the 32-byte public key, base64url-encoded with no padding. The key set is cacheable; refresh it if a signature ever fails to verify against your cached copy, in case the keys rotated. 2. **Rebuild the signed message.** Concatenate these four values in this exact order, joined with single newline (`\n`) separators, then encode the result as UTF-8: ``` {X-Ideogram-Webhook-Generation-Id} {X-Ideogram-Webhook-User-Id} {X-Ideogram-Webhook-Timestamp} {sha256_hex(raw request body bytes)} ``` Hash the raw body bytes exactly as received. Do not parse and re-serialize the JSON first, or the hash will not match what Ideogram signed. 3. **Verify against the published keys.** Decode `X-Ideogram-Webhook-Signature` from hex and check it against each public key in the set. If any key verifies, the webhook is authentic. If none do, reject the request. The `X-Ideogram-Webhook-Key-Id` header tells you which key to try first, but fall back to the others so signatures stay verifiable across key rotations. ### Python example ```python 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" def _b64url_decode(value: str) -> bytes: return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) def ideogram_public_keys() -> list[Ed25519PublicKey]: jwks = requests.get(JWKS_URL, timeout=5).json() return [Ed25519PublicKey.from_public_bytes(_b64url_decode(jwk["x"])) for jwk in jwks["keys"]] def verify_webhook(headers: dict[str, str], body: bytes) -> bool: """Return True if the delivery was signed by Ideogram. `body` is the raw request bytes.""" body_hash = hashlib.sha256(body).hexdigest() message = ( f"{headers['X-Ideogram-Webhook-Generation-Id']}\n" f"{headers['X-Ideogram-Webhook-User-Id']}\n" f"{headers['X-Ideogram-Webhook-Timestamp']}\n" f"{body_hash}" ).encode("utf-8") signature = bytes.fromhex(headers["X-Ideogram-Webhook-Signature"]) for public_key in ideogram_public_keys(): try: public_key.verify(signature, message) return True except InvalidSignature: continue return False ``` The `body` passed to `verify_webhook` must be the **raw request bytes exactly as Ideogram sent them**. Read them from your web framework's raw-body accessor, not from a parsed-then-re-serialized JSON object: re-serializing can reorder keys or change whitespace, which changes the bytes and makes the SHA-256 hash (and so the signature check) fail. Examples of the raw-body accessor: * Flask: `request.get_data()` * FastAPI / Starlette: `await request.body()` * Django: `request.body` * Express (Node): `express.raw()` middleware, then `req.body` (a `Buffer`) A complete Flask handler: ```python from flask import Flask, request app = Flask(__name__) @app.post("/ideogram-webhook") def ideogram_webhook(): body = request.get_data() # raw bytes as received; do NOT use request.get_json() here if not verify_webhook(dict(request.headers), body): return {"error": "invalid signature"}, 400 payload = request.get_json() # now safe to parse for your application logic # ... process payload["data"] ... return {"ok": True} ``` Cache the public keys rather than fetching them on every delivery, and refresh them on a verification failure or on a periodic interval. ## Retries, idempotency, and polling Your endpoint should return a `2xx` status to acknowledge a delivery. If it returns a non-`2xx` status or times out, Ideogram retries the delivery. Design your handler for this: * **Make it idempotent.** The same `generation_id` can arrive more than once (for example, a retry after your endpoint was briefly slow or unavailable). Key your completion state on `generation_id`, treat an already-processed `generation_id` as a no-op, and return `2xx` so the retries stop. * **Delivery is not guaranteed.** Ideogram retries a delivery only a limited number of times before dropping it, so a webhook may never arrive (for example, if your endpoint is down for an extended period). Do not rely on the webhook as your only way to get a result. * **Fall back to polling.** If you do not receive a delivery, fetch the result from the polling endpoint, `GET /v1/generations/{generation_id}`, using the `generation_id` the async endpoint returned. It returns the same `data` payload once the generation has finished. > Receive asynchronous generation results via signed webhooks — payload format, Ed25519 signature verification, and retry behavior.