Dokumentation
AnleitungenWebhooks

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:

HeaderExamplePurpose
webhook-idmsg_01JAB...Unique message id; part of the signed content.
webhook-timestamp1719999999Unix timestamp (seconds) when signed.
webhook-signaturev1,K5oc…HMAC signature(s), space-separated.
X-Webhook-EventcompletedThe 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}:

  1. Strip the whsec_ prefix from your secret and Base64-decode the rest to get the key bytes.
  2. Build the signed content: the webhook-id header, a ., the webhook-timestamp header, a ., then the raw request body.
  3. Compute base64(HMAC_SHA256(key, signed_content)).
  4. Compare it against each space-separated token in webhook-signature (each is v1,<signature>) using a constant-time comparison.
  5. Reject the request if the webhook-timestamp is 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 False

Delivery 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 2xx quickly to acknowledge; anything else is treated as a failure and retried.