> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://developer.ideogram.ai/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.