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:
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
| Type | Sent when |
|---|---|
stream.live | The publisher connected and the stream went live. |
stream.ended | A live stream is over. data.end_reason says why (Stream lifecycle). |
stream.expired | Nothing connected within 30 minutes of creating it. |
stream.cancelled | It was deleted before going live. |
recording.ready | The MP4 can be downloaded (Recordings). |
recording.failed | No MP4 could be made. |
recording.deleted | The MP4 was deleted: by you, by retention, or at zero balance. |
webhook.test | You 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:
{
"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-id | The event id (evt_…). The same on every retry: use it to ignore duplicates. |
webhook-timestamp | Unix seconds when this attempt was sent. |
webhook-signature | v1,<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.
// 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:
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-idto drop duplicates anddata.statusto 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}/deliveriesfor 30 days. A failed one can be sent again with Retry orPOST /v1/webhooks/{id}/deliveries/{delivery_id}/retry.
Managing endpoints
| Call | Does |
|---|---|
GET /v1/webhooks | Your endpoints (without secrets) and the event types. |
POST /v1/webhooks | Add 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}/secret | A new secret. The old one keeps signing too for 24 hours, so switch your server within a day. |
POST /v1/webhooks/{id}/test | Send 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.