Webhooks
Receive a signed HTTP callback when an asynchronous task finishes — and verify its signature.
Instead of polling, you can have Fotovid POST a signed event to your server when
a task reaches a terminal state. Pass a webhook URL when you
create a task.
By default you receive completed and failed events.
The request
Fotovid sends a POST with a JSON body and these headers:
| Header | Example | Purpose |
|---|---|---|
webhook-id | msg_01JAB... | Unique message id; part of the signed content. |
webhook-timestamp | 1719999999 | Unix timestamp (seconds) when signed. |
webhook-signature | v1,K5oc… | HMAC signature(s), space-separated. |
X-Webhook-Event | completed | The event type — completed or failed. |
The event type is only in the X-Webhook-Event header, not in the body. Note
the naming: a completed event carries a payload whose status is succeeded.
The payload
The body is the task result — the same shape you'd get from
GET /v1/tasks/{id}, minus the event type:
{
"id": "01JAB...",
"status": "succeeded",
"created_at": "2026-06-03T12:00:00Z",
"started_at": "2026-06-03T12:00:01Z",
"completed_at": "2026-06-03T12:00:07Z",
"urls": { "get": "https://api.fotovid.co/v1/tasks/01JAB...", "cancel": "…" },
"outputs": [{ "kind": "video", "url": "https://tempfile.fotovid.co/v/<hex>.mp4" }],
"metrics": { "total_time": 6.1 }
}On a failed event, status is failed and an error object
({ code, message, request_id }) is present instead of outputs.
Verifying the signature
Every delivery is signed with HMAC-SHA256 using your webhook signing secret.
Get (or rotate) the secret from the dashboard — it looks like whsec_….
The signature is computed over {webhook-id}.{webhook-timestamp}.{raw_body}:
- Strip the
whsec_prefix from your secret and Base64-decode the rest to get the key bytes. - Build the signed content: the
webhook-idheader, a., thewebhook-timestampheader, a., then the raw request body. - Compute
base64(HMAC_SHA256(key, signed_content)). - Compare it against each space-separated token in
webhook-signature(each isv1,<signature>) using a constant-time comparison. - Reject the request if the
webhook-timestampis more than 5 minutes from now.
Always HMAC the raw request body bytes, before any JSON parsing or
re-serialization — re-encoding will change the bytes and break verification.
During a secret rotation, webhook-signature may contain more than one token
(one per active secret); a match against any of them is valid.
import crypto from "node:crypto";
// secret: your "whsec_..." signing secret. rawBody: the exact bytes received.
export function verify(headers, rawBody, secret) {
const id = headers["webhook-id"];
const ts = headers["webhook-timestamp"];
const sigHeader = headers["webhook-signature"] ?? "";
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const signedContent = `${id}.${ts}.${rawBody}`;
const expected = crypto.createHmac("sha256", key).update(signedContent).digest("base64");
return sigHeader.split(" ").some((token) => {
const sig = token.split(",")[1];
return (
sig &&
sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
);
});
}import base64, hashlib, hmac, time
def verify(headers, raw_body: bytes, secret: str) -> bool:
msg_id = headers["webhook-id"]
ts = headers["webhook-timestamp"]
sig_header = headers.get("webhook-signature", "")
if abs(time.time() - int(ts)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed_content = f"{msg_id}.{ts}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed_content, hashlib.sha256).digest()).decode()
for token in sig_header.split(" "):
_, _, sig = token.partition(",")
if sig and hmac.compare_digest(sig, expected):
return True
return FalseDelivery behavior
- Deliveries are at-least-once — the same event may arrive more than once, so
make your handler idempotent (dedupe on
webhook-id). - Failed deliveries are retried with exponential backoff (up to 5 attempts).
- Return a
2xxquickly to acknowledge; anything else is treated as a failure and retried.