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