JustOnAir docs
View as Markdown

Webhooks

JustOnAir POSTs to your server when a stream goes live or ends and when its recording is ready. Signed with Standard Webhooks.

Instead of polling a stream, add an endpoint and we tell your server when something happens. Add it in the dashboard under Webhooks, or with the API:

Shell
curl https://api.justonair.com/v1/webhooks \
  -H "Authorization: Bearer $JOA_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://api.example.com/justonair/webhooks", "events": ["stream.live", "stream.ended", "recording.ready"]}'

The answer includes secret (whsec_…). Keep it on your server; it is how you know a request came from us. Leave out events to get all of them, including types added later.

Events

TypeSent when
stream.liveThe publisher connected and the stream went live.
stream.endedA live stream is over. data.end_reason says why (Stream lifecycle).
stream.expiredNothing connected within 30 minutes of creating it.
stream.cancelledIt was deleted before going live.
recording.readyThe MP4 can be downloaded (Recordings).
recording.failedNo MP4 could be made.
recording.deletedThe MP4 was deleted: by you, by retention, or at zero balance.
webhook.testYou pressed Send test or called POST /v1/webhooks/{id}/test.

A reconnect within the 60-second window is not a new stream.live: the stream never stopped being live.

What arrives

A POST with a JSON body:

JSON
{
  "type": "stream.ended",
  "timestamp": "2026-09-29T19:04:10.512Z",
  "data": {
    "object": "stream",
    "id": "str_e1bzk3dxw9z9allei6n7",
    "name": "Yoga 18:00",
    "status": "ended",
    "metadata": { "class_id": 42 },
    "created_at": "2026-09-29T17:55:02.110Z",
    "went_live_at": "2026-09-29T18:00:41.870Z",
    "ended_at": "2026-09-29T19:04:10.512Z",
    "end_reason": "disconnected",
    "recording": { "status": "processing", "bytes": null, "duration_seconds": null, "ready_at": null, "deleted_at": null }
  }
}

data is the stream at the moment of the event, with your own metadata, so you can match it to your records without another call. For anything else (cost, viewers, a download link), call GET /v1/streams/{id}.

And three headers:

Header
webhook-idThe event id (evt_…). The same on every retry: use it to ignore duplicates.
webhook-timestampUnix seconds when this attempt was sent.
webhook-signaturev1,<base64>, the HMAC-SHA256 of <webhook-id>.<webhook-timestamp>.<body> with your secret. During a rotation there are two, space separated.

Verify the signature

This is the Standard Webhooks format, so the official libraries work as they are. Always check against the raw body, before any JSON parsing.

JavaScript
// npm install standardwebhooks
import express from 'express';
import { Webhook } from 'standardwebhooks';

const wh = new Webhook(process.env.JOA_WEBHOOK_SECRET); // whsec_…
const app = express();

app.post('/justonair/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  let event;
  try {
    event = wh.verify(req.body.toString('utf8'), req.headers); // throws if forged or older than 5 minutes
  } catch {
    return res.sendStatus(400);
  }
  res.sendStatus(204); // answer first, work after
  if (event.type === 'recording.ready') queueImport(event.data.id);
});

Without a library, in Python:

Python
import base64, hashlib, hmac, time

def verify(secret: str, headers, body: bytes) -> bool:
    msg_id, ts = headers["webhook-id"], headers["webhook-timestamp"]
    if abs(time.time() - int(ts)) > 300:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    expected = base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()).decode()
    return any(hmac.compare_digest(expected, s.split(",", 1)[1]) for s in headers["webhook-signature"].split())

Answering and retries

  • Answer with any 2xx within 10 seconds. Do slow work after answering. Redirects are not followed.
  • Anything else is retried: after 5 s, 30 s, 2 min, 10 min, 30 min, 1 h, 2 h, 4 h, 8 h and 8 h. That's 11 attempts over about 24 hours, then the delivery is marked failed.
  • The order of events is not guaranteed, and an event can arrive twice. Use webhook-id to drop duplicates and data.status to see where the stream really is.
  • Every delivery, with its payload and your last answer, is in the dashboard and at GET /v1/webhooks/{id}/deliveries for 30 days. A failed one can be sent again with Retry or POST /v1/webhooks/{id}/deliveries/{delivery_id}/retry.

Managing endpoints

CallDoes
GET /v1/webhooksYour endpoints (without secrets) and the event types.
POST /v1/webhooksAdd one: url (https), optional events, description. Up to 5 per account.
GET /v1/webhooks/{id}One endpoint, with its secret.
PATCH /v1/webhooks/{id}Change url, events, description, or enabled.
DELETE /v1/webhooks/{id}Remove it and its delivery log.
POST /v1/webhooks/{id}/secretA new secret. The old one keeps signing too for 24 hours, so switch your server within a day.
POST /v1/webhooks/{id}/testSend a webhook.test.

The URL must be https:// and reachable on the public internet. Addresses in private networks are refused, when you save it and again at every send.