Skip to main content
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.
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"
  }'
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.

Events

Webhooks fire only on terminal transitions — completed or failed. Jobs that are cancelled do not emit a webhook.
EventFires when
image.generation.completed / image.generation.failedAn image generation reaches a terminal status.
image.edit.completed / image.edit.failedA Magic Edit reaches a terminal status.
video.generation.completed / video.generation.failedA video generation reaches a terminal status.
model.completed / model.failedA 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.
{
  "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.

Signature headers

Every delivery carries three headers:
webhook-id: msg_2n0jJ2mCw4T5qX1aB3cD4e
webhook-timestamp: 1782043200
webhook-signature: v1,F9epTMwALBtPM7ghiLNEmdmVN7TizCpZ+zAHLPwip9A=
HeaderDescription
webhook-idUnique id for the event, stable across retries (use it to deduplicate).
webhook-timestampUnix seconds when the event was sent. Reject if it is more than 300s from now (replay protection).
webhook-signatureSpace-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) )
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.

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.
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. 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.
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=",
)
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 retries6 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.
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.