Skip to content
Reeltopus

Webhooks

Reeltopus webhooks: signed POSTs when clips are ready, exports finish and posts go live or fail. Events, payloads, retries and signature verification in TypeScript and Python.
On this page

Subscribe

Add an endpoint under Settings → API (owners and admins), or from code. The signing secret (whsec_…) is shown once, when the endpoint is made. Leave events empty to get every event except the opt-in ones (audit.event). Up to 10 endpoints per workspace; URLs must be public and use https.

curl -X POST https://reeltopus.com/api/v1/webhooks -H "Authorization: Bearer $REELTOPUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/hooks/reeltopus", "events": ["project.ready", "clip.exported"]}'
# The response carries "secret": "whsec_…" once. Keep it to verify deliveries.

Events

Webhook events
EventWhendata holds
project.readyA video finished processing; its clips are ready.project
project.failedA video could not be processed. Its hours were refunded.project (with error)
clip.exportedA clip's MP4 export finished; download links are in the payload.clip, export
clip.export_failedA clip's MP4 export failed.clip, export (with error)
post.publishedA post went live on the platform.post (with platformUrl)
post.failedA post could not be published.post (with error)
audit.eventSomething was recorded in the audit log (Settings → Audit log). Sent only to endpoints that list it.event

Payloads

Every event is { id, type, createdAt, data }, and data holds the same objects the API returns, so the code that reads GET /projects/{id} reads the webhook too. The samples below are shortened; every field is in the OpenAPI document under webhooks.

project.ready, with its headers:

POST /hooks/reeltopus HTTP/1.1
Content-Type: application/json
User-Agent: Reeltopus-Webhooks/1.0 (+https://reeltopus.com/docs/api#webhooks)
webhook-id: evt_3f1c9a2b7d4e5f60718293a4
webhook-timestamp: 1792854000
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

{
  "id": "evt_3f1c9a2b7d4e5f60718293a4",
  "type": "project.ready",
  "createdAt": "2026-10-24T12:00:00.000Z",
  "data": {
    "project": {
      "id": "…",
      "object": "project",
      "title": "Episode 12",
      "status": "ready",
      "mode": "standard",
      "source": { "type": "link", "url": "https://drive.google.com/file/d/…/view", "host": "google_drive" },
      "durationMs": 3127000,
      "language": "en",
      "clipCount": 14,
      "error": null,
      …
    }
  }
}

clip.exported. The links work without a key for an hour; GET /clips/{id}/export gives fresh ones.

{
  "id": "evt_9a0b…",
  "type": "clip.exported",
  "createdAt": "2026-10-24T12:04:10.000Z",
  "data": {
    "clip": { "id": "…", "object": "clip", "projectId": "…", "rank": 1, "score": 87, "title": "The one habit that doubled our retention", … },
    "export": {
      "clipId": "…",
      "status": "ready",
      "resolution": "1080p",
      "error": null,
      "renderedAt": "2026-10-24T12:04:09.000Z",
      "files": {
        "mp4": { "url": "https://…", "expiresAt": "2026-10-24T13:04:10.000Z" },
        "captions": { "srt": { "url": "…", "expiresAt": "…" }, "vtt": { … }, "ass": { … } },
        "editorTimeline": { "url": "…", "expiresAt": "…" }
      }
    }
  }
}

post.published:

{
  "id": "evt_51d2…",
  "type": "post.published",
  "createdAt": "2026-10-25T09:00:04.000Z",
  "data": {
    "post": {
      "id": "…", "object": "post", "clipId": "…", "connectionId": "…",
      "title": "The one habit that doubled our retention",
      "status": "published",
      "platformUrl": "https://www.youtube.com/shorts/…",
      "error": null,
      …
    }
  }
}

Delivery and retries

  • Answer with any 2xx within 10 seconds. Do the slow work afterwards, in a queue.
  • Anything else (a timeout, a 4xx or 5xx, a redirect) is retried with backoff for about a day, 8 retries in all. Redirects are not followed.
  • A 410 Gone deletes the endpoint: nothing more is sent to it. Zapier answers that for a deleted Zap.
  • The event id stays the same on every retry and is also sent as webhook-id. Use it to de-duplicate: a retry after a lost answer delivers the same event twice.
  • Events can arrive out of order. If order matters, compare createdAt or fetch the resource.

Verify the signature

Signatures follow Standard Webhooks, so its libraries for Go, Ruby, PHP, Java, Rust and C# work too. The signature is an HMAC-SHA256 of ${webhook-id}.${webhook-timestamp}.${body}, keyed with the base64-decoded secret (after whsec_), base64-encoded, and sent as v1,<signature>. Reject timestamps more than 5 minutes off, and always check the raw body, before any JSON parsing.

With the SDK, once it's published (Node 20+, Deno, Bun, Workers, Vercel Edge):

import { verifyWebhook, WebhookVerificationError } from "@reeltopus/sdk/webhooks";

export async function POST(request: Request) {
  try {
    // The raw body, before any JSON parsing.
    const event = await verifyWebhook(process.env.REELTOPUS_WEBHOOK_SECRET!, request.headers, await request.text());
    if (event.type === "project.ready") console.log(event.data.project.clipCount, "clips");
    if (event.type === "clip.exported") console.log(event.data.export.files.mp4?.url);
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) return new Response(error.message, { status: 400 });
    throw error;
  }
}

Without a library:

import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(secret: string, headers: Headers, body: string) {
  const id = headers.get("webhook-id");
  const ts = headers.get("webhook-timestamp");
  const signatures = headers.get("webhook-signature");
  if (!id || !ts || !signatures) return false;
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // older than 5 minutes
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${ts}.${body}`).digest();
  return signatures.split(" ").some((s) => {
    const [version, mac] = s.split(",");
    const given = Buffer.from(mac ?? "", "base64");
    return version === "v1" && given.length === expected.length && timingSafeEqual(given, expected);
  });
}

With Express, read the body with express.raw({ type: "application/json" }) and pass req.headers and req.body. In Django, pass request.body.

Test an endpoint

Send test next to an endpoint in Settings → API, or POST /webhooks/{id}/test, delivers a signed event of type webhook.test straight away and returns the status your endpoint answered. Recent deliveries, with their status codes and attempts, are listed under each endpoint in Settings → API.

curl -X POST https://reeltopus.com/api/v1/webhooks/$WEBHOOK_ID/test -H "Authorization: Bearer $REELTOPUS_API_KEY"