---
title: Webhooks
description: A POST notification to your server when a generation finishes — body format, HMAC-SHA256 signature check, response deadline and the redelivery schedule.
icon: Webhook
---

Pass a `webhook_url` when you submit a generation, and Clipia sends a `POST` to that address when the job finishes. You don't need to poll the status. Every delivery is signed with HMAC-SHA256 using your key's secret, and a failed delivery is retried for about a day.

## URL requirements

- `https://` only. Addresses on internal or private networks are rejected at submit time with `400 webhook_url_invalid`.
- Redirects aren't followed: a `3xx` response counts as a failed delivery.

## Body

Success:

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

Generation failure:

```json
{
  "request_id": "764cabcf-b745-4b3e-ae38-1200304cf45b",
  "status": "ERROR",
  "error": {
    "code": "generation_failed",
    "message": "Generation failed. Please retry or contact support."
  }
}
```

`output` depends on the model type: `images: [{ url }]`, `video: { url }` or `audio: { url, format? }` together with `audios: [{ url, format? }]` (every track variant, the first one equals `audio`). `error.code` comes from the [generation error dictionary](/en/docs/api-reference/result#failed-codes): `generation_failed`, `content_moderated`, `model_unavailable` and others; treat an unknown code as `generation_failed`.

<Callout type="warn" title="The webhook body is shorter than the REST response">
Webhooks carry no `original_url` or `thumbnail_url`, and images come with a single link. Fetch the full result with [`GET /v1/requests/:id`](/en/docs/api-reference/result).
</Callout>

<TypeTable
  type={{
    request_id: { type: 'string', description: 'The request_id from the submit response', required: true },
    status: { type: '"OK" | "ERROR"', description: 'Generation outcome', required: true },
    'payload.model': { type: 'string', description: 'Model slug, OK only' },
    'payload.output': { type: 'object', description: 'Result links, OK only' },
    'payload.cost': { type: 'integer', description: 'Credits charged, OK only' },
    error: { type: 'object', description: '{ code, message }, ERROR only; code comes from the generation error dictionary, message is neutral English text' }
  }}
/>

## Headers

```http
Content-Type: application/json
X-Clipia-Webhook-Id: 1f2e3d4c-5b6a-7890-abcd-ef0123456789
X-Clipia-Timestamp: 1760011200
X-Clipia-Signature: t=1760011200,v1=5257a869e7...
```

| Header | Value |
|--------|-------|
| `X-Clipia-Webhook-Id` | delivery ID, the same across all retries |
| `X-Clipia-Timestamp` | Unix time of this attempt, in seconds |
| `X-Clipia-Signature` | `t=<timestamp>,v1=<hex HMAC-SHA256>` |

## Signing secret

Each API key has its own secret, used to sign webhooks for jobs submitted with that key. The secret is shown once, when you create the key in the [Developer Console](/en/developer). The console has no delivery history or manual resend. If you lose the secret, create a new key and switch your integration to it — see [Managing keys](/en/docs/api-reference/keys).

## Verifying the signature

The signature is `HMAC_SHA256(secret, "{timestamp}.{raw_body}")`. Verify it against the **raw** request body before parsing JSON, compare in constant time, and reject deliveries older than 5 minutes. The easiest way is the ready-made SDK function.

<Tabs items={['TypeScript SDK', 'Python SDK', 'Node.js without SDK']}>
<Tab value="TypeScript SDK">

```ts
import { verifyWebhookSignature } from 'clipia-ai/webhook';

// rawBody is the original body bytes, not JSON.stringify(req.body)
const ok = verifyWebhookSignature({
  secret: process.env.CLIPIA_WEBHOOK_SECRET!,
  headers: req.headers,
  body: rawBody,
  toleranceSeconds: 300,
});
if (!ok) return res.status(401).end();
```

</Tab>
<Tab value="Python SDK">

```python
import os
from clipia.webhooks import verify_signature

ok = verify_signature(
    os.environ["CLIPIA_WEBHOOK_SECRET"],
    request.headers,
    raw_body,  # the original body bytes
    tolerance_seconds=300,
)
if not ok:
    return Response(status=401)
```

</Tab>
<Tab value="Node.js without SDK">

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

function verify(headers, rawBody, secret) {
  const sig = headers['x-clipia-signature'] ?? '';
  const parts = Object.fromEntries(sig.split(',').map((p) => p.split('=')));
  if (!parts.t || !parts.v1) return false;
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= 300;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${parts.t}.${rawBody}`)
    .digest('hex');
  const a = Buffer.from(parts.v1, 'hex');
  const b = Buffer.from(expected, 'hex');
  return fresh && a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

</Tab>
</Tabs>

Both SDK functions return `true` or `false` and never throw on malformed headers.

## Delivery and retries

- Respond with `2xx` within 10 seconds. Do heavy processing after you respond.
- A non-`2xx` response, a `3xx`, or a timeout counts as a failure. Delivery is attempted up to 15 times in total.
- The pause between attempts doubles, starting at 30 seconds, capped at 4 hours: 30 s, 1, 2, 4, 8, 16, 32 min, 1 h 04 min, 2 h 08 min, then 4 hours each — about 24 hours 15 minutes in total from the first attempt. Deliveries are dispatched every 30 seconds, so the actual delay can be slightly longer.
- After the last failed attempt the delivery is marked as failed and is not retried again; it cannot be requested anew. If your handler was down for longer, fetch the result with [`GET /v1/requests/:id`](/en/docs/api-reference/result) — it is stored independently of the webhook.

<Callout type="info" title="Handle deliveries idempotently">
The same delivery can arrive more than once, for example if your response got lost. Deduplicate by `X-Clipia-Webhook-Id` or `request_id` before triggering side effects.
</Callout>

In the [sandbox](/en/docs/getting-started/sandbox), webhooks are delivered and signed the same way — a convenient way to test your handler. A test key gets at most one delivery per minute: the rest within that minute are not created, and a failed delivery stops after three attempts.
