> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.mindrift.art/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.mindrift.art/_mcp/server.

# Webhooks

Asynchronous generation endpoints can deliver the finished result to your server instead of making you poll. Pass `webhook_url` with your request and Mindrift sends a signed JSON `POST` to that URL once the generation reaches a terminal state (`completed` or `failed`).

---

## Subscribing

Add `webhook_url` (public `https` URL, default port 443, no redirects) to either endpoint:

* `POST /v1/text-to-image/subscribe/{model}`: returns `200` with the task descriptor as usual.
* `POST /v1/images/generations`: returns **`202 Accepted`** immediately instead of waiting:

```json
{
  "object": "image.generation",
  "id": "e6a0d2f8-4b1a-4638-9b88-51e8201bfb7c",
  "status": "queued",
  "created": 1757930400,
  "model": "blackforestlabs/flux-1-schnell",
  "webhook_url": "https://your-server.example.com/hooks/mindrift"
}
```

Use the returned `id` as `generation_id` to correlate the delivery (or keep polling `GET /v1/tasks/{task_id}` as a fallback).

```bash
curl https://api.mindrift.art/v1/images/generations \
  -H "Authorization: Bearer $MINDRIFT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "blackforestlabs/flux-1-schnell",
    "prompt": "A glass pavilion in Nordic pine woods at sunset",
    "size": "1024x1024",
    "webhook_url": "https://your-server.example.com/hooks/mindrift"
  }'
```

## What Mindrift delivers

The body mirrors the synchronous response, plus the task envelope: the same handler can process both:

```json
{
  "event_id": "9f3b2c1d-4b1a-4638-9b88-51e8201bfb7c",
  "generation_id": "e6a0d2f8-4b1a-4638-9b88-51e8201bfb7c",
  "status": "completed",
  "created": "2026-09-15T10:00:00.000Z",
  "model": "blackforestlabs/flux-1-schnell",
  "prompt": "A glass pavilion in Nordic pine woods at sunset",
  "data": [{ "url": "https://cdn.mindrift.art/..." }],
  "cost": 0.0048,
  "expires_at": "2026-09-16T10:00:00.000Z",
  "error": null
}
```

`cost` is the total billed for the generation in USD (`0` when nothing was billed, e.g. refused prompt).

`expires_at` tells you when the hosted files disappear (`null` = kept indefinitely). When it is set, archive the file before that date — after expiry the URLs return `404`. See [Image Retention](/docs/core/image-retention).

Failed generations are delivered too, with `status: "failed"` and `error: { "code": "moderation_blocked", "message": "..." }`, so a refused prompt never leaves you hanging.

## Request headers

| Header                             | Value                                                                |
| :--------------------------------- | :------------------------------------------------------------------- |
| `X-Mindrift-Webhook-Generation-Id` | ID of the generation. Matches `generation_id` in the body.           |
| `X-Mindrift-Webhook-Timestamp`     | Unix seconds (decimal) at which the request was signed.              |
| `X-Mindrift-Webhook-Key-Id`        | The `kid` of the signing key. Try it first, fall back to the others. |
| `X-Mindrift-Webhook-Signature`     | Ed25519 signature, lowercase hex.                                    |
| `Content-Type`                     | `application/json`                                                   |

## Verifying the signature

1. **Fetch the public keys** with `GET https://api.mindrift.art/v1/.well-known/jwks.json`. Each key's `x` field is the 32-byte Ed25519 public key, base64url-encoded with no padding. Cache the set; refresh it if verification fails (key rotation).
2. **Rebuild the signed message** by joining these four values with single `\n` newlines, UTF-8 encoded: `{generation-id}`, `{timestamp}`, `{sha256_hex(raw request body bytes)}`. Hash the **raw body bytes exactly as received** (never parse and re-serialize first).
3. **Verify** the hex signature against each published key. Accept on the first success, reject otherwise.

```python
import base64
import hashlib

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

JWKS_URL = "https://api.mindrift.art/v1/.well-known/jwks.json"


def _b64url_decode(value: str) -> bytes:
    return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))


def mindrift_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 Mindrift signed this delivery. `body` is the raw request bytes."""
    body_hash = hashlib.sha256(body).hexdigest()
    message = (
        f"{headers['X-Mindrift-Webhook-Generation-Id']}\n"
        f"{headers['X-Mindrift-Webhook-Timestamp']}\n"
        f"{body_hash}"
    ).encode("utf-8")
    signature = bytes.fromhex(headers["X-Mindrift-Webhook-Signature"])

    for public_key in mindrift_public_keys():
        try:
            public_key.verify(signature, message)
            return True
        except InvalidSignature:
            continue
    return False
```

```python
from flask import Flask, request

app = Flask(__name__)


@app.post("/hooks/mindrift")
def mindrift_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
    # ... process payload["data"], keyed on payload["generation_id"] ...
    return {"ok": True}
```

## Retries, idempotency, and polling

* Return `2xx` to acknowledge. Anything else (or a timeout after 10s) is retried with backoff (roughly `1m → 5m → 30m → 2h → 8h`), then dropped.
* **Make your handler idempotent**: the same `generation_id` can arrive more than once. Treat an already-processed id as a no-op and still return `2xx`.
* **Delivery is best-effort**: if your endpoint stays down, the delivery is eventually dropped. Keep polling `GET /v1/tasks/{task_id}` as the source of truth.