---
title: Webhooks
description: Receive a POST notification when a generation finishes, verified with HMAC-SHA256 and retried automatically.
---

Pass a `webhook_url` when you submit a generation, and Clipia will `POST` the result to that URL once it completes — no polling required. Every delivery is signed with HMAC-SHA256 (the `X-Clipia-Signature` header), and failed deliveries are retried up to 6 times with exponential backoff.

## Payload

On success:

```json
{
  "request_id": "764cabcf-b745-4b3e-ae38-1200304cf45b",
  "status": "OK",
  "payload": {
    "model": "nano-banana-2",
    "output": { "images": [{ "url": "https://media.clipia.ai/works/result.png" }] },
    "cost": 12
  }
}
```

On error:

```json
{
  "request_id": "764cabcf-b745-4b3e-ae38-1200304cf45b",
  "status": "ERROR",
  "error": { "code": "GENERATION_FAILED", "message": "..." }
}
```

The `output` field takes one of three shapes depending on the model type: `images: [{ url }]`, `video: { url }`, or `audio: { url }` for speech synthesis.

<Callout type="warn" title="The webhook payload is slimmer than the REST response">
Two differences that trip integrations up most often:

1. `original_url` (the full-quality original) is **not included** in the webhook — fetch `GET /v1/requests/:id` when you need it.
2. For multi-image results, only the **first** URL is delivered; the full list lives at the same `request_id`.
</Callout>

### Payload fields

<TypeTable
  type={{
    request_id: {
      type: 'string (UUID)',
      description: 'The generation id — matches the request_id from the submit response.',
      required: true,
    },
    status: {
      type: '"OK" | "ERROR"',
      description: 'The terminal outcome of the delivery.',
      required: true,
    },
    'payload.model': {
      type: 'string',
      description: 'Model slug (present only when status is "OK").',
    },
    'payload.output': {
      type: 'object',
      description: 'The generation result with media links (present only when status is "OK").',
    },
    'payload.cost': {
      type: 'number',
      description: 'Charged cost in credits (present only when status is "OK").',
    },
    error: {
      type: 'object',
      description: 'A code and message pair (present only when status is "ERROR").',
    },
  }}
/>

## Signature verification

Each delivery carries three headers:

```http
X-Clipia-Webhook-Id: 1f2e3d4c-5b6a-7890-abcd-ef0123456789
X-Clipia-Timestamp: 1717243200
X-Clipia-Signature: t=1717243200,v1=5257a869e7...
```

The signature is `HMAC_SHA256(secret, "{timestamp}.{raw_body}")`, where `secret` is the webhook signing secret from your developer console. Compute it over the **raw** request body (before JSON parsing) and compare with a constant-time function.

<Tabs items={['Node.js', 'Python']}>

<Tab value="Node.js">

```js
import crypto from "node:crypto";

const secret = process.env.CLIPIA_WEBHOOK_SECRET;

function verify(req) {
  const sig = req.headers["x-clipia-signature"];        // "t=...,v1=..."
  const parts = Object.fromEntries(sig.split(",").map(p => p.split("=")));
  const signed = `${parts.t}.${req.rawBody}`;
  const expected = crypto.createHmac("sha256", secret).update(signed).digest("hex");
  const ok = crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300; // 5-min window
  return ok && fresh;
}
```

</Tab>

<Tab value="Python">

```python
import hashlib
import hmac
import os
import time

SECRET = os.environ["CLIPIA_WEBHOOK_SECRET"].encode()

def verify(headers: dict, raw_body: bytes) -> bool:
    sig = headers["X-Clipia-Signature"]               # "t=...,v1=..."
    parts = dict(p.split("=", 1) for p in sig.split(","))
    signed = f"{parts['t']}.".encode() + raw_body
    expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
    ok = hmac.compare_digest(parts["v1"], expected)
    fresh = abs(time.time() - int(parts["t"])) < 300   # 5-minute window
    return ok and fresh
```

</Tab>

</Tabs>

<Callout type="warn" title="Check freshness">
Reject a delivery if `X-Clipia-Timestamp` differs from the current time by more than 5 minutes. This protects against replay of old requests.
</Callout>

## Delivery and retries

- Respond with a `2xx` status within 10 seconds.
- On a non-`2xx` response or timeout, delivery is retried with exponential backoff, up to 6 attempts.
- Delivery history is visible in the developer console, which is handy for debugging.

<Callout type="info" title="Handle deliveries idempotently">
The same delivery may arrive more than once. Deduplicate by `request_id` (or `X-Clipia-Webhook-Id`) before running any side effects.
</Callout>
