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. Both keys authenticate during that window — rotation is never a swap-and-break, and there is no cap on how many keys an organization holds.

Scopes

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

read:eventsread:fieldsread:recordingsread:purchasesread:scheduleread:analyticsread:viewersread:streamersread:packageswrite:eventswrite:fieldswrite:recordingswrite:schedulewrite:ingestmanage:webhooks

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

A key's scopes are fixed for its lifetime. There is no endpoint that adds a scope to an existing key, and rotating copies the original scopes verbatim — by design, so a leaked key can never gain reach. If you need a scope you did not select at creation, create a new key and retire the old one.

Worth checking before launch week: a key issued before you adopted encoder streaming will not carry write:ingest, so POST /v1/fields/{id}/ingest_credentials returns 403 with required_scope naming what is missing.

Every write body — creates included — is 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. The full field list for each endpoint is in openapi.json.

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.
  • Test keys are limited to 60 requests/minute rather than 600.

Worth knowing while you build: a test-key write returns 200, not an error — so a create appears to succeed while nothing is persisted. Check for object: "test_mode_response", or use a live key if you expect data to stick.

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"
  }
}

Validation failures carry a details array naming every field that failed, so you never have to guess which key a message refers to.

POST /v1/events/{id}/schedule with an empty body
{
  "error": {
    "type": "invalid_request_error",
    "code": "INVALID_REQUEST",
    "message": "Request body failed validation on 3 fields. See details.",
    "status": 400,
    "request_id": "req-168",
    "details": [
      { "field": "title",      "message": "Required" },
      { "field": "start_time", "message": "Required" },
      { "field": "end_time",   "message": "Required" }
    ]
  }
}

When exactly one field fails, message is prefixed with it ("start_time: Required").

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 — see details[]
CONFLICT409Duplicate slug or external_ref, or a reused Idempotency-Key
RATE_LIMIT_EXCEEDED429Too many requests

Idempotency

Send an Idempotency-Key on any POST. If the response never reaches you, retrying with the same key returns the original result instead of creating a second object.

bash
curl -X POST https://api.watchlive.org/v1/events \
  -H "Authorization: Bearer wl_live_..." \
  -H "Idempotency-Key: tournament-4821-create" \
  -H "Content-Type: application/json" \
  -d '{ "name": "County Finals", "start_date": "2026-08-06", "end_date": "2026-08-07" }'
  • Keys are honored for 24 hours.
  • A replayed response carries idempotent-replayed: true.
  • Reusing a key with a different body is a 409 — it means two different requests collided on one key.
  • Only successful responses are stored, so a genuine failure stays retryable.

Your own IDs

Put your identifier on any event or schedule item with external_ref, then look it up by that value. This is the clean recovery path when a create fails ambiguously.

bash
# Did my create actually land?
curl "https://api.watchlive.org/v1/events?external_ref=tournament-4821" \
  -H "Authorization: Bearer wl_live_..."

external_ref is unique per organization on events, so a duplicate is a 409 rather than a silent second copy.

Schedule items additionally take home_team_ref and away_team_ref. We store and return these untouched — WatchLive does not model teams, divisions or rosters, so use them to link a game back to your own team records. Display names alone can't do that: they break on a rename and can't tell an 8U side from a 12U one.

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 or external_ref.

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. Pass slug to choose the public URL yourself.

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

Update an event. Setting status to published emits event.published. Reversible, with no preconditions.

A slug is fixed once created — renaming an event does not change its URL, so links you have already published keep working.

Fields

A field is a camera feed within an event. An event needs at least one to stream.

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

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

POST/v1/events/{id}/fieldswrite:fields

Create a feed. Takes a name, and optionally prefer_srt to request SRT ingest alongside RTMP (see Encoder streaming).

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 on these endpoints. Retrieve them explicitly from POST /v1/fields/{id}/ingest_credentials with the write:ingest scope (see Encoder streaming), or let your streamers go live through the streamer portal without touching a key at all.

An empty list means no feed has been created yet — it is not related to the event's draft status.

Schedule

Schedule items are the individual games or sessions inside an event. Each carries a full local start and end time, so two games in the same venue on the same day stay distinct.

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

List schedule items, ordered by start time.

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

Add a schedule item.

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

Remove a schedule item.

bash
curl -X POST https://api.watchlive.org/v1/events/45a45b4e-.../schedule \
  -H "Authorization: Bearer wl_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Tomball vs Klein",
    "start_time": "2026-08-06T19:00:00",
    "end_time": "2026-08-06T21:00:00",
    "home_team": "Tomball",
    "away_team": "Klein",
    "home_team_ref": "team_8821",
    "away_team_ref": "team_9143",
    "sport": "Football",
    "field_id": "9b804156-..."
  }'

Times are local to the event's timezone — send 2026-08-06T19:00:00, not a UTC instant and no trailing Z. A 7:00 PM kickoff reads back as 7:00 PM.

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.

Viewers

The people who bought from you. Scoped to your organization — a viewer who has never purchased from you is not visible here.

GET/v1/viewersread:viewers

List viewers. Filter with event_id to get the audience for one event.

GET/v1/viewers/{id}read:viewers

Retrieve one viewer.

If you sell subscriptions on your own site, your subscribers will not appear here — they never transact with us. Use subscriber_ref on playback records instead.

Streamers

GET/v1/events/{id}/streamersread:streamers

List the camera operators assigned to an event, with their access status.

Read-only. Access codes are never returned — they are publish credentials, and are handled like stream keys.

Packages

GET/v1/packagesread:packages

List the subscription packages your organization offers.

Analytics

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

Revenue summary for one event — tickets sold, gross and net.

This covers money. For who watched, for how long, and whether video actually reached them, see Playback records.

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.

The same scope manages entitlement signing secrets (see Granting access):

GET/v1/entitlement_secretsmanage:webhooks
POST/v1/entitlement_secretsmanage:webhooks

Create a secret. Returned once, never again.

POST/v1/entitlement_secrets/{id}/rotatemanage:webhooks

Rotate, keeping the previous secret valid for 24 hours.

DELETE/v1/entitlement_secrets/{id}manage:webhooks

Revoke immediately, with no grace period.

Provisioning a stream

Four calls take you from nothing to a page a viewer can watch:

bash
# 1. Create the event (starts as a draft)
curl -X POST https://api.watchlive.org/v1/events \
  -H "Authorization: Bearer wl_live_..." \
  -H "Idempotency-Key: tournament-4821-create" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Tomball vs Klein",
    "slug": "tomball-vs-klein",
    "start_date": "2026-08-06",
    "end_date": "2026-08-06",
    "timezone": "America/Chicago",
    "ticket_price": 12,
    "external_ref": "tournament-4821"
  }'

# 2. Add a camera feed — without one there is nothing to stream
curl -X POST https://api.watchlive.org/v1/events/{id}/fields \
  -H "Authorization: Bearer wl_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "name": "Field 1" }'

# 3. Add the games
curl -X POST https://api.watchlive.org/v1/events/{id}/schedule ...

# 4. Publish
curl -X PATCH https://api.watchlive.org/v1/events/{id} \
  -H "Authorization: Bearer wl_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "status": "published" }'

Then get your operators broadcasting, whichever way suits them:

  • Browser — send them a streamer-portal link and an access code. Nothing to install, no credential to protect.
  • OBS / vMix / hardware encoder — pull the server address and stream key from POST /v1/fields/{id}/ingest_credentials and paste them in. See Encoder streaming.

Playback & embedding

Playback URLs come from the public event API, not /v1, because they are per-viewer:

bash
curl https://api.watchlive.org/public/events/tomball-vs-klein/fields

Each feed returns an hlsUrl on a watchlive.orghost. It is short-lived and tied to the viewer it was issued for — treat it as a credential, fetch it per session, and don't cache or share it. For a paywalled event it is omitted entirely unless the caller is entitled.

Playback is HLS, which every browser, phone and smart TV plays without a plugin. The tradeoff is latency: viewers are typically 5–10 seconds behind live. That is normal for event streaming and is what makes playback reliable on poor connections, but it does mean a live scoreboard or chat on your page will run ahead of the video. If you need sub-second latency for something interactive, talk to us before you build against it.

To embed a player instead, use an iframe — no API call needed:

html
<iframe
  src="https://www.watchlive.org/embed/event/tomball-vs-klein"
  allow="autoplay; fullscreen; picture-in-picture"
  allowfullscreen
  style="border:0;width:100%;aspect-ratio:16/9"
></iframe>

Embeds are framable from any origin — there is no allowlist to register and nothing to configure. The embed enforces the same paywall as the API.

Encoder streaming

Two ways your camera operators can go live. Pick per organization — you don't have to choose one for everything.

Streamer portalEncoder (RTMP/SRT)
Operator opens a link, enters an access code, broadcasts from the browser.Operator configures a hardware encoder or OBS/vMix once with a URL and key.
Nothing to install. No credential to protect. Best for volunteers and laptops.Sturdier for an all-day tournament, better bitrate control, works with gear you already own.
No API call needed.Requires the write:ingest scope.
POST/v1/fields/{id}/ingest_credentialswrite:ingest

Reveal the publish credentials for a feed.

POST/v1/fields/{id}/ingest_credentials/rotatewrite:ingest

Replace the stream key immediately.

bash
curl -X POST https://api.watchlive.org/v1/fields/{id}/ingest_credentials \
  -H "Authorization: Bearer wl_live_..."
response
{
  "object": "ingest_credentials",
  "field_id": "9b804156-...",
  "field_name": "Field 1",
  "rtmp_url": "rtmp://rtmp.watchlive.org/live",
  "stream_key": "8e5e-f5y5-6fh0-naxf",
  "srt_url": null,
  "srt_stream_id": null,
  "srt_passphrase": null,
  "whip_url": "https://.../webrtc/8e5e-f5y5-6fh0-naxf",
  "masked": true
}

In OBS or vMix

It works the way Twitch does — one server address, plus a stream key per camera. In OBS: Settings → Stream, service Custom…, then:

text
Server:      rtmp://rtmp.watchlive.org/live
Stream Key:  8e5e-f5y5-6fh0-naxf

Same two fields in vMix, Wirecast, or any hardware encoder. The key is stable for the life of the feed, so an operator configures a camera once and it keeps working across events — no re-keying between games. Take both values from the API response for that specific feed: most return our own hostname, and SRT-enabled feeds return a direct one.

rtmp.watchlive.orgis our own hostname, not a provider's. That matters for encoders in the field: if we ever move a feed to different infrastructure, the server address your crews already typed in stays correct. Nothing to reconfigure at the venue.

SRT for lossy connections

Venue wifi and bonded cellular drop packets. RTMP stalls when that happens; SRT recovers. If your crews run vMix or hardware encoders on connections you don't control, ask for SRT when you create the feed:

bash
curl -X POST https://api.watchlive.org/v1/events/{id}/fields \
  -H "Authorization: Bearer wl_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "name": "Field 1", "prefer_srt": true }'

The credentials response then carries an SRT set alongside RTMP — point the encoder at whichever it supports:

with prefer_srt
{
  "rtmp_url": "rtmp://rtmp.watchlive.org/live",
  "stream_key": "582962e7e722df7e...",
  "srt_url": "srt://live.cloudflare.com:778",
  "srt_stream_id": "5b4a555037e9292085d570...",
  "srt_passphrase": "...",
  "masked": true
}

prefer_srt is a preference, not a guarantee — it routes the feed to infrastructure that issues SRT credentials, and falls back rather than failing if that is unavailable. Check whether srt_url came back non-null before telling a crew to use it. RTMP is always available.

Always use the address the API returns for that feed. Feeds created with prefer_srt run on different infrastructure, so their rtmp_url is a direct address rather than our alias, and masked comes back falseto say so. Both work — they are simply not the same hostname, so don't assume one server address covers every camera. Treat an SRT address the same way, and re-read it from the API rather than hardcoding either into an encoder profile you plan to keep for a season.

A stream key is a password: anyone holding it can broadcast to your feed. Keep it in your encoder config or a secret manager — never in client-side code, a URL, or a browser. That is also why this is a POST and not a GET, and why it needs its own scope: a key that manages schedules cannot also hand out publish credentials. Every retrieval is logged.

Browser publishing (WHIP)

If you want operators going live from a web page in your own app rather than an encoder, use whip_url from the same response. It is a standard WHIP endpoint: POST an SDP offer, get an SDP answer back, and the browser publishes over WebRTC — no software for the operator to install.

Treat it exactly like the stream key: it is a publish credential — the key is embedded in the URL. Never ship it to a browser without checking first that the person is allowed to broadcast, and never put it in client-side source. Fetch it server-side, per session, for an operator you have already authorised.

Not every feed gets one, and it is not behind our hostname — WebRTC negotiates against whichever host answers the SDP offer, so an alias in front of it would break the session rather than hide anything. Check for null before offering it as an option.

If a key leaks

bash
curl -X POST https://api.watchlive.org/v1/fields/{id}/ingest_credentials/rotate \
  -H "Authorization: Bearer wl_live_..."

The old key stops working immediately — this is a hard cutover, not an overlap window like API keys. Two valid publish keys would let two sources push to one feed and viewers would get whichever won, which is worse than a short reconnect. So rotate between events, not mid-broadcast, and update every encoder pointed at that feed.

Granting access

If you sell access on your own site — your subscription, your ticket — there is no WatchLive purchase to check. Sign a short-lived token per viewer per play and we will admit them.

Create a signing secret once (it is returned only at creation):

bash
curl -X POST https://api.watchlive.org/v1/entitlement_secrets \
  -H "Authorization: Bearer wl_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "label": "production" }'
mint a token when a viewer presses play
import jwt from 'jsonwebtoken';

const token = jwt.sign(
  {
    sub: yourUserId,        // your identifier, returned to you in analytics
    event_id: watchliveEventId,
    exp: Math.floor(Date.now() / 1000) + 300,  // 5 minutes is plenty
  },
  process.env.WATCHLIVE_ENTITLEMENT_SECRET,   // HS256
);

// Send it with the playback request
const res = await fetch(
  `https://api.watchlive.org/public/events/${slug}/fields`,
  { headers: { 'X-WatchLive-Entitlement': token } },
);
  • HS256, and the token must name event_id — a token for one event cannot open another.
  • exp is required and capped at one hour, however far out you set it.
  • No callback to your servers, so playback never depends on your uptime.
  • Rotate with POST /v1/entitlement_secrets/{id}/rotate — the old secret stays valid for 24 hours so you can roll through a deploy.

Cancelling a subscriber

There is nothing to call. You control exp on the tokens you mint, so a cancelled subscriber simply stops receiving new ones and playback stops within your chosen TTL. A short TTL (2–5 minutes) makes cancellation, failed payments and chargebacks take effect quickly without any extra integration.

Stopping password sharing

Each secret carries a max_concurrent_streams limit (default 2, 0 disables it). We count live streams per sub — your subscriber id — so one account cannot be used by an unlimited number of people at once.

bash
curl -X PATCH https://api.watchlive.org/v1/entitlement_secrets/{id} \
  -H "Authorization: Bearer wl_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "max_concurrent_streams": 3 }'

Over the limit, the manifest request returns 409 — not 403, because the credential is valid and the request merely conflicts with the subscriber's other streams. Show "you're already watching on another device" rather than an access error.

Someone already watching is never interrupted — the viewer who arrives last is the one turned away. Ending a stream frees its slot within about 90 seconds, with no need to sign out.

The limit applies to every paid viewer, however they paid: your subscribers, and anyone who bought a ticket or package on WatchLive directly. Free events are exempt — there is nothing to share. Whoever collected the money, one credential cannot become an unlimited audience.

Playback records

Every view is recorded durably: who watched, on what device, from where, for how long, and what we actually delivered. Two things this is for — settling disputes, and seeing which streams performed.

GET/v1/playback_sessionsread:analytics

Individual viewing records. Filter by subscriber_ref, event_id, field_id, started_after, started_before.

GET/v1/playback_summaryread:analytics

Audience, watch time and delivery failures aggregated per event.

Answering a chargeback

A subscriber disputes a charge saying they never watched, or that the stream was broken. Look them up by the id you put in the token:

bash
curl "https://api.watchlive.org/v1/playback_sessions?subscriber_ref=your-user-123" \
  -H "Authorization: Bearer wl_live_..."
response (one session)
{
  "object": "playback_session",
  "subscriber_ref": "your-user-123",
  "device_id": "b0e668bf6aeb4d43...",
  "device_type": "mobile",
  "browser": "Safari",
  "os": "iOS",
  "ip_address": "203.0.113.44",
  "city": "Houston",
  "region": "TX",
  "country_name": "United States",
  "started_at": "2026-08-06T19:02:11.482Z",
  "last_seen_at": "2026-08-06T20:47:03.119Z",
  "duration_seconds": 6291,
  "heartbeats": 1420,
  "denied_attempts": 2,
  "delivery": {
    "manifests_served": 1420,
    "manifest_errors": 0,
    "segments_served": 3129,
    "segment_errors": 0,
    "verdict": "delivered"
  }
}

device_id is stable per device, so repeat sessions from the same hardware correlate — without us storing anything that identifies the person.

"The feed didn't work"

Watch time alone can't answer that — someone who sat through 40 minutes of a broken stream looks identical to someone who enjoyed 40 good ones. So each session also records what we managed to deliver, and reduces it to one verdict:

VerdictMeaning
deliveredSegments served with no upstream failures — the stream worked for this viewer.
degradedIt played, but some requests failed. Partially supports a complaint.
failedNo segment ever reached the device. The complaint is valid.

This is measured per viewer, not per stream — a feed can be healthy overall while one person on a bad connection receives nothing, and only the per-viewer record shows which happened.

denied_attemptscounts streams refused for that subscriber while they watched — useful evidence that a credential was in use on more devices than the plan allows, and the explanation when a customer asks why their third device wouldn't start.

Stream quality

The same data aggregated, to see what held an audience and what failed:

bash
curl "https://api.watchlive.org/v1/playback_summary?event_id={id}" \
  -H "Authorization: Bearer wl_live_..."

Returns sessions, unique devices and subscribers, total and median watch seconds, segments served, error counts, and failed_sessions — the viewers who never received video, which is the first population worth investigating.

Records are read-only and are not deleted on a schedule.

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...

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

Exactly one v1 value is sent per delivery. To change a signing secret today, register a second endpoint and retire the first — there is no overlapping dual-signature window.

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" }
  }
}