WatchLive API

Automate your events, pull recordings into your own systems, and react to streams and sales in real time.

Introduction

The WatchLive API is a REST interface over your organization's events, camera feeds, recordings and sales. It speaks JSON, authenticates with an API key, and sends webhooks when things happen.

The base URL is https://api.watchlive.org/v1. Every endpoint is scoped to the organization that owns the key — there is no way to read another organization's data.

Getting access

The API requires:

  • An Enterprise WatchLive organization with an active subscription.
  • An API key, created from your dashboard.

Enterprise is priced per contract. Talk to us about upgrading. Once you are on Enterprise, create keys at Dashboard → Developers.

Calling the API without Enterprise returns 402 PLATFORM_ACCESS_REQUIRED.

Quickstart

List your events:

bash
curl https://api.watchlive.org/v1/events \
  -H "Authorization: Bearer wl_live_your_key_here"
response
{
  "object": "list",
  "data": [
    {
      "object": "event",
      "id": "45a45b4e-c865-454b-9df3-ce48b1c69b96",
      "name": "County Finals",
      "slug": "county-finals",
      "status": "live",
      "visibility": "public",
      "ticket_price": "12.00",
      "created_at": "2026-07-28T00:46:52.402Z"
    }
  ],
  "has_more": false,
  "next_cursor": null
}

Authentication

Pass your key as a bearer token. An x-api-key header is also accepted.

bash
# Either of these works
curl https://api.watchlive.org/v1/events -H "Authorization: Bearer wl_live_..."
curl https://api.watchlive.org/v1/events -H "x-api-key: wl_live_..."

Keys are shown once, when you create them. We store only a hash, so a lost key cannot be recovered — rotate it instead.

Rotating issues a new key and keeps the old one working for 24 hours, so you can roll it through a deploy without downtime.

Scopes

Each key carries an explicit list of scopes. A call outside them returns 403.

read:eventsread:fieldsread:recordingsread:purchasesread:scheduleread:analyticswrite:eventswrite:fieldswrite:recordingswrite:schedulemanage:webhooks

Grant the narrowest set that works. A key that only reads recordings cannot touch your sales data even if it leaks.

Write bodies are validated strictly: an unrecognised or wrong-case field returns 400 rather than being quietly ignored, so a typo can never look like a successful save.

Test mode

Keys are either wl_live_ or wl_test_.

  • Test keys read your real data, so responses look exactly like production.
  • Writes with a test key are accepted and discarded — you get a test_mode_response and nothing changes.

Pagination

List endpoints are cursor paginated. Pass limit (default 20, max 100) and cursor. Keep going while has_more is true.

bash
curl "https://api.watchlive.org/v1/recordings?limit=50" \
  -H "Authorization: Bearer wl_live_..."

# then
curl "https://api.watchlive.org/v1/recordings?limit=50&cursor=eyJjcmVhdGVkQXQi..." \
  -H "Authorization: Bearer wl_live_..."

Cursors are keyset-based, so pages stay stable even while new records are being created.

Errors

Failures return a stable machine-readable code. Branch on the code, not the message.

json
{
  "error": {
    "type": "permission_error",
    "code": "INSUFFICIENT_SCOPE",
    "message": "This API key is missing the required \"read:purchases\" scope.",
    "status": 403,
    "request_id": "req-14",
    "required_scope": "read:purchases"
  }
}
CodeStatusMeaning
API_KEY_REQUIRED401No key supplied
INVALID_API_KEY401Unknown, revoked or expired key
PLATFORM_ACCESS_REQUIRED402Organization is not on Enterprise
SUBSCRIPTION_REQUIRED402Subscription is not active
INSUFFICIENT_SCOPE403Key lacks the required scope
NOT_FOUND404No such object in your organization
INVALID_REQUEST400Malformed parameters
RATE_LIMIT_EXCEEDED429Too many requests

Rate limits

Limits are per key: 600 requests/minute for live keys, 60/minute for test keys.

Every response carries x-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-reset. On a 429, honor retry-after and back off.

Events

GET/v1/eventsread:events

List your events. Filter with status.

GET/v1/events/{'{id}'}read:events

Retrieve one event.

GET/v1/events/{'{id}'}/scheduleread:schedule

The event's schedule items.

POST/v1/eventswrite:events

Create an event. It starts as a draft; publish it with a PATCH.

PATCH/v1/events/{'{id}'}write:events

Update an event. Setting status to published emits event.published.

POST/v1/events/{'{id}'}/schedulewrite:schedule

Add a schedule item.

DELETE/v1/schedule/{'{itemId}'}write:schedule

Remove a schedule item.

Fields

A field is a camera feed within an event.

GET/v1/events/{'{id}'}/fieldsread:fields

List the feeds for an event, with live status and viewer counts.

PATCH/v1/fields/{'{id}'}write:fields

Rename a feed or change its price, sort order or viewer-count visibility.

Ingest credentials — stream keys, RTMP/SRT URLs and passphrases — are never returned by the API. They are visible only to signed-in organizers in the dashboard.

Recordings

GET/v1/recordingsread:recordings

List recordings. Filter with event_id or status.

GET/v1/recordings/{'{id}'}read:recordings

Retrieve one recording.

PATCH/v1/recordings/{'{id}'}write:recordings

Change a recording's title, visibility or VOD pricing.

Purchases

GET/v1/purchasesread:purchases

List ticket sales. Filter with event_id, status, created_after and created_before.

Read-only. Purchases must go through checkout so that fees and entitlements are applied correctly, so there is no endpoint for creating one.

Webhook endpoints

GET/v1/webhook_endpointsmanage:webhooks
POST/v1/webhook_endpointsmanage:webhooks

Register an endpoint. The signing secret is returned once.

PATCH/v1/webhook_endpoints/{'{id}'}manage:webhooks
DELETE/v1/webhook_endpoints/{'{id}'}manage:webhooks
GET/v1/events_logmanage:webhooks

Every event we generated for you, whether or not delivery succeeded.

POST/v1/events_log/{'{id}'}/replaymanage:webhooks

Re-deliver an event to your active endpoints.

GET/v1/webhook_deliveriesmanage:webhooks

Delivery attempts, with response codes and errors.

Receiving webhooks

Register an HTTPS endpoint and choose the events you want:

bash
curl -X POST https://api.watchlive.org/v1/webhook_endpoints \
  -H "Authorization: Bearer wl_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/watchlive/webhook",
    "enabled_events": ["stream.started", "stream.ended", "purchase.completed"]
  }'

Event types:

stream.startedstream.endedrecording.readyevent.publishedpurchase.completedvod_purchase.completed

Delivery is at least once. Retries run at 1m, 5m, 30m, 2h and 6h; deduplicate on the event id. An endpoint that keeps failing is disabled automatically and can be re-enabled from your dashboard.

Respond 2xx quickly and do your work asynchronously — we time out after 10 seconds.

Verifying signatures

Every delivery carries an X-WatchLive-Signature header:

text
X-WatchLive-Signature: t=1785199740,v1=98140a550dbf5b76dc0686...

v1 is HMAC-SHA256(secret, "{t}.{raw body}"). Verify against the raw request body — parsing and re-serializing the JSON changes the bytes and the signature will not match.

node.js (express)
import crypto from 'crypto';

// express.raw keeps the body as bytes — express.json() would break verification
app.post('/watchlive/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const header = req.headers['x-watchlive-signature'];
  const [tPart, v1Part] = String(header).split(',');
  const timestamp = tPart.split('=')[1];
  const signature = v1Part.split('=')[1];

  // Reject replays of an old, previously-valid delivery
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return res.status(400).send('Timestamp too old');
  }

  const expected = crypto
    .createHmac('sha256', process.env.WATCHLIVE_WEBHOOK_SECRET)
    .update(`${timestamp}.${req.body}`)
    .digest('hex');

  // Constant-time compare — a plain === leaks timing information
  const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
  if (!ok) return res.status(400).send('Bad signature');

  const event = JSON.parse(req.body);
  console.log(event.type, event.data);

  res.sendStatus(200); // ack fast, process asynchronously
});
example payload
{
  "id": "0fac1f7f-1a02-4a99-a7c8-89f8ab17718e",
  "type": "stream.started",
  "livemode": true,
  "created": 1785199740,
  "data": {
    "field": { "id": "9b804156-...", "name": "Cam 1", "status": "live" },
    "event": { "id": "45a45b4e-...", "name": "County Finals", "slug": "county-finals" }
  }
}