Probably
Docs
Guides

Events and webhooks

Probably's event types and payload, reading events by cursor, and signed webhooks with retries, pausing and replay.

Events say what happened to your organization's videos: a run started, it is waiting for a slot, a stage was accepted, it waits on you, it was delivered, it failed or it was cancelled. Read them by cursor with an API key, or have Probably post each one to your endpoint as a signed webhook.

Event types

TypeWhendata
run.startedA run was admitted: a video started, or a new version, variant or change began.label
run.waiting_for_slotA run's next work is waiting behind the organization's share of work in flight; it starts when a slot frees.label
stage.completedA specialist's step was accepted, such as the script or the storyboard.label
stage.deliveredA stage run (research, brainstorm, write_script, review) delivered its result: stage names the stage and revisionId the revision get_stage reads (for a review, what it judged).label, stage, revisionId
video.waiting_on_youA video waits on a person: a delivery to approve, a held change or note, a question, or swaps to confirm.label
video.deliveredA video's run finished with a delivery.label
run.failedA run stopped on a failure it couldn't recover from.label
run.cancelledA run was stopped by a person or a connection.label
audience_research.startedA Space's audience research started: researchId names the run get_audience_research follows. videoId and runId are null.label, researchId
audience_research.pausedAudience research paused: until a provider's daily limit lifts, or a spend cap lowered below it is raised (a run from before research had a ceiling also pauses for credits a top-up brings). It continues from where it stopped.label, researchId
audience_research.readyAudience research finished: its segments are saved and ready to use, unless it found too few remarks. Reviewing them is optional.label, researchId
audience_research.failedAudience research stopped on a failure it couldn't recover from.label, researchId
campaign.batch_madeA campaign's batch was made: each segment's body video started. campaignId names the campaign get_campaign reads; videoId and runId are null, and each body's own events follow.label, campaignId
campaign.results_importedAn Ads Manager export's results came in for a campaign: campaignId names it, and get_campaign reads its results and any learnings they propose.label, campaignId

Payload

Every event has the same shape, in version 1, whether you read it or receive it:

FieldTypeRequiredNotes
idstring (uuid)Yes
type"run.started" | "run.waiting_for_slot" | "stage.completed" | "stage.delivered" | "video.waiting_on_you" | "video.delivered" | "run.failed" | "run.cancelled" | "audience_research.started" | "audience_research.paused" | "audience_research.ready" | "audience_research.failed" | "campaign.batch_made" | "campaign.results_imported"Yes
version1Yes
createdAtstring (date-time)Yes
cursorstringYes
organizationIdstring (uuid)Yes
videoIdstring (uuid) | nullYes
runIdstring (uuid) | nullYes
spaceIdstring (uuid) | nullYes
dataobjectYes
data.labelstringYes
data.stage"research" | "brainstorm" | "write_script" | "review" | "generate_clip" | "voice" | "storyboard"No
data.revisionIdstring (uuid)No
data.researchIdstring (uuid)No
data.campaignIdstring (uuid)No
An event
{
  "id": "3f6c2b0e-8a41-4c55-9d0e-2a7b9c1d4e5f",
  "type": "video.delivered",
  "version": 1,
  "createdAt": "2026-10-24T09:41:07.000Z",
  "cursor": "1842",
  "organizationId": "0b7e7c55-4a0e-4f3e-9a51-3d2c1b0a9f88",
  "videoId": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
  "runId": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9",
  "spaceId": "7d1f4c9a-2b3e-4d5f-8a6b-9c0d1e2f3a4b",
  "data": {
    "label": "Delivered"
  }
}

In version 1, data carries the label Probably wrote and the fields its type adds, which the table above lists for each type: stage.delivered adds the stage and the revisionId to read with get_stage. Text that people or the crew wrote is never in an event: read the video with get_video, or a stage with get_stage, for its details. New fields and event types can be added without notice, so ignore what you don't know (see Versioning).

Reading events by cursor

GET /api/v1/events answers with the events after after, oldest first, and the cursor to read on from. Filter with types (comma-separated) and page with limit. A key limited to one Space reads only that Space's events.

Your environment
export PROBABLY_API_URL="https://studio.probably.co"
export PROBABLY_API_KEY="prb_..."
read-events.sh
#!/usr/bin/env bash
# Read your organization's events from the start, ten at a time. Pass the
# page's "cursor" as "after" to read the next page.
set -euo pipefail

curl --silent --show-error --fail-with-body \
  "$PROBABLY_API_URL/api/v1/events?after=0&limit=10" \
  --header "Authorization: Bearer $PROBABLY_API_KEY"

Keep the last cursor you handled and pass it as after next time, and you read every event exactly once. To rebuild your state after an outage or a bug, read again from an earlier cursor: events keep their IDs.

Webhooks

An admin adds an endpoint in Settings → Webhooks: an HTTPS address, optionally limited to one Space. Probably shows its signing secret (whsec_...) once; rotating it shows the new one once. From then on, each event is posted to the endpoint as JSON, the same payload as above.

  • Endpoints must be public HTTPS addresses. Probably refuses private and special-use addresses, and connects to the address it checked.
  • Delivery is at least once. Use the webhook-id header, the event's ID, to skip an event you've already handled.
  • Each delivery has 10 seconds, including the address lookup, and must answer 2xx. Nothing of the answer is read but its status, and no redirect is followed.
  • A failed delivery is retried with exponential backoff: after 30 seconds, doubling each time, at most an hour apart.
  • Events reach an endpoint one at a time, in order: a failure holds the later events until its retry succeeds.
  • After a day of failures the endpoint pauses, and Settings → Webhooks says why. Resuming it continues from where it stopped.

Settings → Webhooks shows each endpoint's last delivery and why it is failing or paused, and lets you pause, resume, rotate the secret or delete it.

Verifying signatures

Each delivery is signed as Standard Webhooks specifies, with three headers:

  • webhook-id: the event's ID.
  • webhook-timestamp: when it was signed, in seconds since 1970.
  • webhook-signature: v1, and the base64 HMAC-SHA256 of the ID, the timestamp and the raw body joined with dots. The HMAC key is the secret's part after whsec_, base64-decoded: use those bytes, not the text.

Check the signature over the raw body before parsing it, and refuse an old timestamp. This receiver does both, with nothing but Node's own modules and zod:

webhook-receiver.ts
// A webhook receiver that checks each delivery's Standard Webhooks signature
// before trusting it. Run it with Bun or Node 23.6+, with zod installed:
//   PROBABLY_WEBHOOK_SECRET=whsec_... PORT=3000 bun webhook-receiver.ts
import { createHmac, timingSafeEqual } from "node:crypto"
import { createServer } from "node:http"

import { z } from "zod"

const secret = process.env.PROBABLY_WEBHOOK_SECRET

const port = Number(process.env.PORT ?? "3000")

if (!secret?.startsWith("whsec_")) throw new Error("Set PROBABLY_WEBHOOK_SECRET to whsec_...")

// Deliveries signed longer ago than this are refused, so a captured one can't be replayed.
const toleranceSeconds = 5 * 60

// The headers each delivery is signed with.
const signed = z.object({
  "webhook-id": z.string().min(1),
  "webhook-timestamp": z.coerce.number().int(),
  "webhook-signature": z.string().min(1),
})

// The fields of an event this receiver reads; the docs list every one.
const event = z.object({
  type: z.string(),
  videoId: z.string().nullable(),
  data: z.object({ label: z.string() }),
})

/** Whether a delivery is authentic: signed with this secret, recently, over exactly this body. */
function verify(headers: z.output<typeof signed>, body: string, signingSecret: string) {
  const { "webhook-id": id, "webhook-timestamp": timestamp } = headers

  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false

  const key = Buffer.from(signingSecret.slice("whsec_".length), "base64")

  const expected = Buffer.from(
    createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest("base64"),
  )

  // The header may carry several signatures, space-separated, each "v1,<base64>".
  return headers["webhook-signature"].split(" ").some((signature) => {
    const given = Buffer.from(signature.replace(/^v1,/, ""))

    return given.length === expected.length && timingSafeEqual(given, expected)
  })
}

const server = createServer((request, response) => {
  const chunks: Buffer[] = []

  request.on("data", (chunk: Buffer) => chunks.push(chunk))
  request.on("end", () => {
    const body = Buffer.concat(chunks).toString("utf8")
    const headers = signed.safeParse(request.headers)

    if (!headers.success || !verify(headers.data, body, secret)) {
      response.writeHead(401).end()

      return
    }

    // Deliveries are at least once: skip an event whose webhook-id you've already handled.
    const delivered = event.parse(JSON.parse(body))

    console.log(`${delivered.type} for video ${delivered.videoId}: ${delivered.data.label}`)
    response.writeHead(204).end()
  })
})

server.listen(port, () => console.log(`Listening on http://localhost:${port}`))

Any Standard Webhooks library verifies Probably's deliveries too.