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

> Erhalte einen signierten Callback, wenn ein Job fertig ist, verifiziere die Signatur und handhabe Retries.

Jeder Generierungs-Endpoint ist asynchron. Statt den `GET`-Endpoint abzufragen,
kannst du eine **`webhook_url`** übergeben, und Samsa sendet per `POST` ein
signiertes Event dorthin, sobald der Job einen terminalen Status erreicht.

## Einen Webhook anfordern

Füge `webhook_url` zu einer beliebigen Generierungsanfrage hinzu. Sie gilt **pro
Anfrage** — verschiedene Jobs können auf verschiedene URLs zielen.

```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 sind eine **Bequemlichkeit, nicht die Source of Truth**. Wenn jeder
  Zustellversuch fehlschlägt, ist das Job-Ergebnis weiterhin über seinen
  `GET`-Endpoint verfügbar. Frage als Fallback für alles Kritische ab.
</Note>

## Events

Webhooks feuern nur bei **terminalen** Übergängen — `completed` oder `failed`.
Jobs, die `cancelled` sind, emittieren **keinen** Webhook.

| Event                                                    | Feuert, wenn                                            |
| -------------------------------------------------------- | ------------------------------------------------------- |
| `image.generation.completed` / `image.generation.failed` | Eine Bildgenerierung einen terminalen Status erreicht.  |
| `image.edit.completed` / `image.edit.failed`             | Ein Magic Edit einen terminalen Status erreicht.        |
| `video.generation.completed` / `video.generation.failed` | Eine Videogenerierung einen terminalen Status erreicht. |
| `model.completed` / `model.failed`                       | Eine Modellerstellung einen terminalen Status erreicht. |

## Payload

Der Request-Body ist JSON. Das `data`-Objekt hat dieselbe Form, die du vom
`GET`-Status-Endpoint des Jobs erhältst.

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

Bei einem `failed`-Event ist `status` gleich `"failed"` und `data` trägt ein
`error`-Objekt mit derselben inneren Form wie das
[Error-Envelope](/de/guides/errors#das-error-envelope).

## Signatur-Header

Jede Zustellung trägt drei Header:

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

| Header              | Beschreibung                                                                                                          |
| ------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `webhook-id`        | Eindeutige id für das Event, **stabil über Retries hinweg** (nutze sie zum Deduplizieren).                            |
| `webhook-timestamp` | Unix-Sekunden, wann das Event gesendet wurde. Lehne ab, wenn es mehr als 300s von jetzt entfernt ist (Replay-Schutz). |
| `webhook-signature` | Durch Leerzeichen getrennte Liste von `v1,<base64>`-Signaturen. Verifiziere gegen **jeden** `v1,`-Kandidaten.         |

Das Schema ist Svix-kompatibel: HMAC-SHA256 über
`{webhook-id}.{webhook-timestamp}.{raw-body}`, base64-kodiert, mit `v1,`
präfixiert.

```
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>
  Signiere und verifiziere über die **rohen Request-Bytes** genau so, wie sie
  empfangen wurden — nie über eine neu serialisierte Kopie. Neu-Serialisieren
  (Keys umordnen, Whitespace ändern) verändert die Bytes und bricht die Signatur.
</Warning>

## Die Signatur verifizieren

Dein `webhook_secret` pro Key liegt in der App unter **Settings → API Keys** (jeder
Key hat sein eigenes Secret; rotiere es unabhängig vom Key). Die Snippets unten
sind gegen einen **festen Test-Vektor** verifiziert — führe sie unverändert aus,
und sie geben `true` zurück, sodass du bestätigen kannst, dass deine
Implementierung die Signatur Byte für Byte reproduziert, bevor du ein echtes
Secret einbindest.

<Note>
  Der Body des Test-Vektors ist ein fester Referenz-String, sodass seine exakten
  Bytes sich nie ändern und die Signatur reproduzierbar bleibt — er ist
  absichtlich nicht das Live-Event-Payload, das [oben](#payload) gezeigt wird. Die
  Signaturverifizierung läuft immer über die **exakten rohen Bytes, die du
  empfängst**, welche Form sie auch haben, das ist also rein ein Selbsttest für
  deinen Verifier. Ebenso ist `webhook-id` ein opaker, pro Event stabiler String —
  behandle ihn wie ein Token, parse ihn nie.
</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>

Erzwinge in Produktion immer auch das **Zeitstempel-Fenster**: lehne die
Zustellung ab, wenn `|now − webhook-timestamp| > 300` Sekunden.

## Retries und Zustellregeln

* **Erfolg** ist jede `2xx`-Antwort, die innerhalb eines **10-Sekunden**-Timeouts
  zurückkommt (Connect-Timeout 5s). Ein `3xx` wird als Fehlschlag behandelt —
  Samsa folgt **keinen** Redirects.
* Bei einem Fehlschlag wiederholt Samsa mit dem ersten Versuch plus **5 Retries** —
  **6 Zustellversuche insgesamt** — mit Back-off `5s → 30s → 2m → 15m → 1h`.
* Nach dem letzten Versuch wird die Zustellung verworfen (und geloggt). Das
  Job-Ergebnis bleibt über seinen `GET`-Endpoint abfragbar.
* Endpoints müssen **HTTPS** sein. Antworte schnell (`2xx`) und erledige schwere
  Verarbeitung asynchron, damit du das 10-Sekunden-Fenster nie überschreitest.

<Tip>
  Nutze die stabile `webhook-id`, um deinen Handler **idempotent** zu machen. Eine
  wiederholte Zustellung verwendet dieselbe `webhook-id`, sodass du ein bereits
  verarbeitetes Event sicher ignorieren kannst.
</Tip>
