> ## 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

> Reçois un callback signé quand un job se termine, vérifie la signature et gère les réessais.

Chaque endpoint de génération est asynchrone. Plutôt que d'interroger le endpoint
`GET`, tu peux passer un **`webhook_url`** et Samsa y enverra un `POST` avec un
événement signé dès l'instant où le job atteint un statut terminal.

## Demander un webhook

Ajoute `webhook_url` à n'importe quelle requête de génération. C'est **par
requête** — des jobs différents peuvent viser des URLs différentes.

```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>
  Les webhooks sont une **commodité, pas la source de vérité**. Si toutes les
  tentatives de livraison échouent, le résultat du job reste disponible depuis son
  endpoint `GET`. Interroge en secours pour tout ce qui est critique.
</Note>

## Événements

Les webhooks ne se déclenchent que sur les transitions **terminales** — `completed`
ou `failed`. Les jobs qui sont `cancelled` n'émettent **pas** de webhook.

| Événement                                                | Se déclenche quand                                  |
| -------------------------------------------------------- | --------------------------------------------------- |
| `image.generation.completed` / `image.generation.failed` | Une génération d'image atteint un statut terminal.  |
| `image.edit.completed` / `image.edit.failed`             | Un Magic Edit atteint un statut terminal.           |
| `video.generation.completed` / `video.generation.failed` | Une génération de vidéo atteint un statut terminal. |
| `model.completed` / `model.failed`                       | Une création de modèle atteint un statut terminal.  |

## Payload

Le corps de la requête est du JSON. L'objet `data` a la même forme que celle que tu
obtiens du endpoint de statut `GET` du job.

```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
  }
}
```

Pour un événement `failed`, `status` vaut `"failed"` et `data` porte un objet
`error` utilisant la même forme interne que l'[enveloppe d'erreur](/fr/guides/errors#l-enveloppe-d-erreur).

## Headers de signature

Chaque livraison porte trois headers :

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

| Header              | Description                                                                                                                       |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `webhook-id`        | Id unique de l'événement, **stable entre les réessais** (utilise-le pour dédupliquer).                                            |
| `webhook-timestamp` | Secondes Unix auxquelles l'événement a été envoyé. Rejette-le s'il est à plus de 300s de maintenant (protection contre le rejeu). |
| `webhook-signature` | Liste séparée par des espaces de signatures `v1,<base64>`. Vérifie contre **chaque** candidat `v1,`.                              |

Le schéma est compatible Svix : HMAC-SHA256 sur
`{webhook-id}.{webhook-timestamp}.{raw-body}`, encodé en base64, préfixé par `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>
  Signe et vérifie sur les **octets bruts de la requête** exactement tels que reçus
  — jamais une copie re-sérialisée. Re-sérialiser (réordonner les clés, changer les
  espaces) change les octets et casse la signature.
</Warning>

## Vérifier la signature

Ton `webhook_secret` par clé vit dans l'app sous **Settings → API Keys** (chaque clé
a son propre secret ; fais-le tourner indépendamment de la clé). Les snippets
ci-dessous sont vérifiés contre un **vecteur de test fixe** — exécute-les tels quels
et ils renvoient `true`, pour que tu puisses confirmer que ton implémentation
reproduit la signature octet par octet avant de câbler un vrai secret.

<Note>
  Le corps du vecteur de test est une chaîne de référence fixe, donc ses octets
  exacts ne changent jamais et la signature reste reproductible — ce n'est
  intentionnellement pas le payload de l'événement live montré [ci-dessus](#payload).
  La vérification de signature tourne toujours sur les **octets bruts exacts que tu
  reçois**, quelle que soit leur forme, donc c'est purement un auto-test pour ton
  vérificateur. De même, `webhook-id` est une chaîne opaque, stable par événement —
  traite-la comme un token, ne l'analyse jamais.
</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>

En production, applique toujours aussi la **fenêtre de timestamp** : rejette la
livraison si `|now − webhook-timestamp| > 300` secondes.

## Réessais et règles de livraison

* Un **succès** est toute réponse `2xx` renvoyée dans un timeout de **10 secondes**
  (timeout de connexion 5s). Un `3xx` est traité comme un échec — Samsa ne suit
  **pas** les redirections.
* En cas d'échec, Samsa réessaie avec la tentative initiale plus **5 réessais** —
  **6 tentatives de livraison au total** — en ralentissant `5s → 30s → 2m → 15m →
  1h`.
* Après la dernière tentative, la livraison est abandonnée (et loggée). Le résultat
  du job reste interrogeable depuis son endpoint `GET`.
* Les endpoints doivent être en **HTTPS**. Réponds rapidement (`2xx`) et fais le
  traitement lourd de façon asynchrone pour ne jamais dépasser la fenêtre de 10
  secondes.

<Tip>
  Utilise le `webhook-id` stable pour rendre ton handler **idempotent**. Une
  livraison réessayée réutilise le même `webhook-id`, donc tu peux ignorer en toute
  sécurité un événement que tu as déjà traité.
</Tip>
