Skip to navigation

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:
{
"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).

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:

{
"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.

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

Request headers

HeaderValue
X-Mindrift-Webhook-Generation-IdID of the generation. Matches generation_id in the body.
X-Mindrift-Webhook-TimestampUnix seconds (decimal) at which the request was signed.
X-Mindrift-Webhook-Key-IdThe kid of the signing key. Try it first, fall back to the others.
X-Mindrift-Webhook-SignatureEd25519 signature, lowercase hex.
Content-Typeapplication/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.
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
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.