# 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:

```bash
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](/streams#why-a-stream-ended)). |
| `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](/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:

```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-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](https://www.standardwebhooks.com) format, so the official libraries work as they are. Always check against the **raw** body, before any JSON parsing.

```js
// 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

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