Skip to content

Webhooks

Get a signed notification when a job finishes, instead of polling.

Set up an endpoint

  1. Open Webhooks under Settings in the dashboard.
  2. Choose Add Endpoint, enter your URL, then choose Create Endpoint.
  3. Copy the whsec_... signing secret. It's shown once.
  4. Choose Send Test Event to send a webhook.test event to your endpoint.

The URL must use HTTPS on port 443, hold no username or password, and point to a public address. Otherwise creating the endpoint fails with invalid_webhook_url. The address is checked again on every delivery.

Webhooks are available to every account during early access. The limit is 1 per account for now, and past it creating an endpoint fails with webhook_limit_reached. Endpoints are set up only in the dashboard, and every enabled endpoint on your account gets every event.

A test event is tried once, and a failed one doesn't count against the endpoint. You can send one every 5 seconds per endpoint.

Events

EventSent when
job.succeededA job finished with a result.
job.failedA job failed.
batch.completedEvery item in a batch has finished, whether it worked or not.
webhook.testYou chose Send Test Event.

A job is a POST /v1/extract that returned a 202, a batch item, or an extraction from your watch list. data.source says which: api, batch or watch.

A 200 from POST /v1/extract has no job and no webhook, since you already have the result. Runs you start in the dashboard send no webhook either.

Event shape

Each request is a POST with a JSON body. It has the event's type, data, and its time twice: timestamp and created_at are the same instant.

JSON
{
  "type": "job.succeeded",
  "timestamp": "2026-11-05T09:30:00.000Z",
  "created_at": "2026-11-05T09:30:00.000Z",
  "data": {
    "job_id": "JOB_ID",
    "status": "succeeded",
    "source": "api",
    "video_id": "VIDEO_ID",
    "poll_url": "https://clypso-eta.vercel.app/v1/extract/{jobId}"
  }
}

Events are thin. They say what finished and where to read it, and never carry the result. Fetch poll_url with your API key to get it. data.video_id can be null.

job.failed adds data.error, with the stable code and a plain sentence in message:

JSON
{
  "type": "job.failed",
  "timestamp": "2026-11-05T09:30:00.000Z",
  "created_at": "2026-11-05T09:30:00.000Z",
  "data": {
    "job_id": "JOB_ID",
    "status": "failed",
    "source": "batch",
    "video_id": "VIDEO_ID",
    "error": {
      "code": "video_too_long",
      "message": "This video is longer than the maximum length we extract"
    },
    "poll_url": "https://clypso-eta.vercel.app/v1/extract/{jobId}"
  }
}

batch.completed has the batch's totals, and its poll_url is the batch:

JSON
{
  "type": "batch.completed",
  "timestamp": "2026-11-05T09:30:00.000Z",
  "created_at": "2026-11-05T09:30:00.000Z",
  "data": {
    "batch_id": "BATCH_ID",
    "status": "completed",
    "counts": {
      "total": 2,
      "succeeded": 1,
      "failed": 1
    },
    "poll_url": "https://clypso-eta.vercel.app/v1/batch/{batchId}"
  }
}

webhook.test has no job behind it and no poll_url:

JSON
{
  "type": "webhook.test",
  "timestamp": "2026-11-05T09:30:00.000Z",
  "created_at": "2026-11-05T09:30:00.000Z",
  "data": {
    "message": "This is a test event from Clypso. Your endpoint received and can verify signed webhooks."
  }
}

Headers

Requests have the content type application/json and the user agent Clypso-Webhooks/1, plus these headers:

HeaderMeaning
webhook-idThe delivery's ID, starting msg_. It stays the same across retries, so use it to ignore a repeat.
webhook-timestampWhen this attempt was sent, in whole seconds since the Unix epoch. Each attempt has its own.
webhook-signaturev1, then the base64 HMAC-SHA256 of the webhook-id, the webhook-timestamp and the raw body, joined with periods. The key is the base64-decoded part of your secret after whsec_.

Verify the signature

Signatures follow the Standard Webhooks scheme. Check the signature against the raw body, before any JSON parsing, and refuse a timestamp more than 5 minutes away from your clock:

import crypto from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

// payload is the raw request body as a string, before any JSON parsing.
// headers holds the request headers, with lowercase names.
// secret is the endpoint's whsec_... signing secret.
function verifyWebhook(payload, headers, secret) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (!id || !timestamp || !signatures) return false;

  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (Number.isNaN(age) || age > TOLERANCE_SECONDS) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = crypto
    .createHmac("sha256", key)
    .update([id, timestamp, payload].join("."))
    .digest();

  return signatures.split(" ").some((entry) => {
    const [version, value] = entry.split(",");
    if (version !== "v1" || !value) return false;
    const actual = Buffer.from(value, "base64");
    return (
      actual.length === expected.length &&
      crypto.timingSafeEqual(actual, expected)
    );
  });
}

When a function returns false, don't act on the event.

Choosing Rotate secret in the dashboard replaces the secret and shows the new one once. The old secret stops working right away, so update your code straight after.

Responses and retries

  • Reply with any 2xx within 10 seconds, and do slow work afterwards. The response body is ignored.
  • Anything else is a failure, including a 3xx, because redirects aren't followed.
  • A failing delivery is tried up to 8 times. Retries come 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 24 hours after the first attempt, each moved by up to a tenth so they don't all land together.
  • On a 429, 502, 503 or 504, a Retry-After header can push the next attempt later, but never past the last scheduled time.
  • A delivery that runs out of attempts is dropped. GET /v1/extract/:jobId and GET /v1/batch/:batchId still have the outcome.
  • A retried event can arrive after a later one, and you can see one twice, so don't rely on order, and use webhook-id to ignore a repeat.

The Webhooks page shows each endpoint's deliveries from the last 7 days.

When an endpoint gets turned off

  • A 410 turns the endpoint off right away.
  • An endpoint that keeps failing for more than 3 days is turned off.
  • Either way, its waiting deliveries are dropped and you get a notification.
  • Events that happen while an endpoint is off aren't saved for later, so poll for anything you missed.
  • Choose Re-enable Endpoint on the Webhooks page to turn it back on, which also clears its failure count.