> ## Documentation Index
> Fetch the complete documentation index at: https://docs.samsa.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive a signed callback when a job finishes, verify the signature, and handle retries.

Every generation endpoint is asynchronous. Instead of polling the `GET` endpoint, you
can pass a **`webhook_url`** and Samsa will `POST` a signed event to it the moment the
job reaches a terminal status.

## Requesting a webhook

Add `webhook_url` to any generation request. It is **per request** — different jobs
can target different URLs.

```bash theme={null}
curl -X POST https://api.samsa.ai/public/v1/images/generations \
  -H "Authorization: Bearer $SAMSA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "A ceramic mug on linen, soft daylight",
    "webhook_url": "https://api.example.com/hooks/samsa"
  }'
```

<Note>
  Webhooks are a **convenience, not the source of truth**. If every delivery attempt
  fails, the job result is still available from its `GET` endpoint. Poll as a fallback
  for anything critical.
</Note>

## Events

Webhooks fire only on **terminal** transitions — `completed` or `failed`. Jobs that
are `cancelled` do **not** emit a webhook.

| Event                                                    | Fires when                                     |
| -------------------------------------------------------- | ---------------------------------------------- |
| `image.generation.completed` / `image.generation.failed` | An image generation reaches a terminal status. |
| `image.edit.completed` / `image.edit.failed`             | A Magic Edit reaches a terminal status.        |
| `video.generation.completed` / `video.generation.failed` | A video generation reaches a terminal status.  |
| `model.completed` / `model.failed`                       | A model creation reaches a terminal status.    |

## Payload

The request body is JSON. The `data` object is the same shape you get from the job's
`GET` status endpoint.

```json theme={null}
{
  "event": "image.generation.completed",
  "id": "7f9c0e2a-1b3d-4c5e-8f6a-9b0c1d2e3f40",
  "status": "completed",
  "organization_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
  "api_key_id": "9c8d7e6f-5a4b-4c3d-2e1f-0a9b8c7d6e5f",
  "created_at": "2026-07-02T12:00:00Z",
  "data": {
    "id": "7f9c0e2a-1b3d-4c5e-8f6a-9b0c1d2e3f40",
    "status": "completed",
    "created_at": "2026-07-02T12:00:00Z",
    "credits_used": 5,
    "images": [
      {
        "id": "d4c3b2a1-6f5e-4b3a-9d8c-1e0f2a3b4c5d",
        "url": "https://cdn.samsa.ai/user-.../7f9c0e2a.png?X-Amz-Signature=...",
        "width": 1024,
        "height": 1024,
        "seed": 128390
      }
    ],
    "error": null
  }
}
```

For a `failed` event, `status` is `"failed"` and `data` carries an `error` object
using the same inner shape as the [error envelope](/guides/errors#the-error-envelope).

## Signature headers

Every delivery carries three headers:

```
webhook-id: msg_2n0jJ2mCw4T5qX1aB3cD4e
webhook-timestamp: 1782043200
webhook-signature: v1,F9epTMwALBtPM7ghiLNEmdmVN7TizCpZ+zAHLPwip9A=
```

| Header              | Description                                                                                        |
| ------------------- | -------------------------------------------------------------------------------------------------- |
| `webhook-id`        | Unique id for the event, **stable across retries** (use it to deduplicate).                        |
| `webhook-timestamp` | Unix seconds when the event was sent. Reject if it is more than 300s from now (replay protection). |
| `webhook-signature` | Space-separated list of `v1,<base64>` signatures. Verify against **each** `v1,` candidate.         |

The scheme is Svix-compatible: HMAC-SHA256 over
`{webhook-id}.{webhook-timestamp}.{raw-body}`, base64-encoded, prefixed with `v1,`.

```
signed_input = utf8(f"{webhook_id}.{webhook_timestamp}.") + raw_body_bytes
key          = base64_decode(webhook_secret without the "whsec_" prefix)
signature    = "v1," + base64( HMAC_SHA256(key, signed_input) )
```

<Warning>
  Sign and verify over the **raw request bytes** exactly as received — never a
  re-serialized copy. Re-serializing (re-ordering keys, changing whitespace) changes
  the bytes and breaks the signature.
</Warning>

## Verify the signature

Your per-key `webhook_secret` lives in the app under **Settings → API Keys** (each key
has its own secret; rotate it independently of the key). The snippets below are
verified against a **fixed test vector** — run them as-is and they return `true`, so
you can confirm your implementation reproduces the signature byte-for-byte before
wiring in a real secret.

<Note>
  The test vector's body is a fixed reference string, so its exact bytes never change
  and the signature stays reproducible — it is intentionally not the live event
  payload shown [above](#payload). Signature verification always runs over the **exact
  raw bytes you receive**, whatever their shape, so this is purely a self-test for your
  verifier. Likewise, `webhook-id` is an opaque, stable-per-event string — treat it as
  a token, never parse it.
</Note>

<CodeGroup>
  ```python Python theme={null}
  import base64
  import hashlib
  import hmac


  def verify(secret: str, webhook_id: str, timestamp: int, body: bytes, header: str) -> bool:
      # Accept both base64 alphabets + missing padding (real secrets are urlsafe/unpadded).
      token = secret.removeprefix("whsec_").replace("+", "-").replace("/", "_")
      key = base64.urlsafe_b64decode(token + "=" * (-len(token) % 4))
      signed_input = f"{webhook_id}.{timestamp}.".encode() + body
      expected = base64.b64encode(
          hmac.new(key, signed_input, hashlib.sha256).digest(),
      ).decode("ascii")
      valid = False
      for candidate in header.split(" "):
          if candidate.startswith("v1,"):
              valid = hmac.compare_digest(candidate[3:], expected) or valid
      return valid


  # Fixed test vector — any correct implementation reproduces this signature.
  assert verify(
      "whsec_c2FtcGxlLXNlY3JldC1kby1ub3QtdXNl",
      "msg_2n0jJ2mCw4T5qX1aB3cD4e",
      1782043200,
      b'{"type":"image.generation.completed","created_at":"2026-07-02T12:00:00Z",'
      b'"data":{"id":"7f9c0e2a-1b3d-4c5e-8f6a-9b0c1d2e3f40","object":"image.generation",'
      b'"status":"completed"}}',
      "v1,F9epTMwALBtPM7ghiLNEmdmVN7TizCpZ+zAHLPwip9A=",
  )
  ```

  ```typescript TypeScript theme={null}
  import crypto from "node:crypto";

  function verify(
    secret: string,
    webhookId: string,
    timestamp: number,
    body: Buffer,
    header: string,
  ): boolean {
    // Accept both base64 alphabets + missing padding (real secrets are urlsafe/unpadded).
    const token = secret.replace(/^whsec_/, "").replace(/-/g, "+").replace(/_/g, "/");
    const key = Buffer.from(token, "base64");
    const signedInput = Buffer.concat([
      Buffer.from(`${webhookId}.${timestamp}.`, "utf8"),
      body,
    ]);
    const expected = crypto.createHmac("sha256", key).update(signedInput).digest("base64");
    let valid = false;
    for (const candidate of header.split(" ")) {
      if (!candidate.startsWith("v1,")) continue;
      const sig = Buffer.from(candidate.slice(3));
      const exp = Buffer.from(expected);
      if (sig.length === exp.length && crypto.timingSafeEqual(sig, exp)) valid = true;
    }
    return valid;
  }

  // Fixed test vector — any correct implementation reproduces this signature.
  const ok = verify(
    "whsec_c2FtcGxlLXNlY3JldC1kby1ub3QtdXNl",
    "msg_2n0jJ2mCw4T5qX1aB3cD4e",
    1782043200,
    Buffer.from(
      '{"type":"image.generation.completed","created_at":"2026-07-02T12:00:00Z",' +
        '"data":{"id":"7f9c0e2a-1b3d-4c5e-8f6a-9b0c1d2e3f40","object":"image.generation",' +
        '"status":"completed"}}',
      "utf8",
    ),
    "v1,F9epTMwALBtPM7ghiLNEmdmVN7TizCpZ+zAHLPwip9A=",
  );
  if (!ok) throw new Error("signature verification failed");
  ```
</CodeGroup>

In production, always also enforce the **timestamp window**: reject the delivery if
`|now − webhook-timestamp| > 300` seconds.

## Retries and delivery rules

* **Success** is any `2xx` response returned within a **10-second** timeout (connect
  timeout 5s). A `3xx` is treated as a failure — Samsa does **not** follow redirects.
* On failure, Samsa retries with the initial attempt plus **5 retries** — **6 delivery
  attempts total** — backing off `5s → 30s → 2m → 15m → 1h`.
* After the final attempt the delivery is dropped (and logged). The job result stays
  queryable from its `GET` endpoint.
* Endpoints must be **HTTPS**. Respond quickly (`2xx`) and do heavy processing
  asynchronously so you never exceed the 10-second window.

<Tip>
  Use the stable `webhook-id` to make your handler **idempotent**. A retried delivery
  reuses the same `webhook-id`, so you can safely ignore an event you have already
  processed.
</Tip>
