justonair
v0.4.0
Published
Official Node.js and TypeScript SDK for the JustOnAir live video API
Maintainers
Readme
JustOnAir for Node.js
The official TypeScript SDK for the JustOnAir live video API: create a stream, send video over RTMP or WHIP, and viewers watch through the hosted player or your own.
npm install justonairNode 20 or newer. No dependencies. ESM and CommonJS. Also runs on Bun, Deno and edge runtimes.
Quick start
Create an API key in the dashboard under API keys, and set it as JOA_API_KEY.
import JustOnAir from 'justonair';
const joa = new JustOnAir(); // reads JOA_API_KEY
const stream = await joa.streams.create({ name: 'Town hall' });
// For OBS or ffmpeg. Returned only here: store it.
console.log(stream.rtmp_url, stream.stream_key);
// For a browser or any WebRTC encoder.
console.log(stream.whip_url);
// The hosted player: share the link or put it in an iframe.
console.log(stream.embed_url);Send video to it (a test picture, no camera needed):
ffmpeg -re -f lavfi -i "testsrc2=size=1280x720:rate=30" -f lavfi -i "sine=frequency=440" \
-c:v libx264 -preset veryfast -b:v 2500k -g 60 -keyint_min 60 -sc_threshold 0 -pix_fmt yuv420p \
-c:a aac -b:a 128k -f flv "$RTMP_URL/$STREAM_KEY"and open embed_url. The Quickstart has the details, including OBS settings.
Streams
const stream = await joa.streams.create({
name: 'Yoga, Tuesday 18:00',
profile: 'abr_basic', // several qualities for viewers (default), or 'passthrough'
max_resolution: 720, // the tallest video you will send: 480, 720 (default), 1080 (trusted accounts)
record: true, // an MP4 after the stream ends (default)
recording_retention_days: 30, // null keeps it until you delete it
metadata: { class_id: 'c_42' },// your own JSON, never shown to viewers
player: { show_name: true, max_height: 720, watch_minutes_limit: 60_000 },
});
await joa.streams.get(stream.id); // status, ingest, recording, cost, viewers
await joa.streams.update(stream.id, { name: 'Yoga (moved)' });
await joa.streams.end(stream.id); // ends a live stream, cancels a pending one
const page = await joa.streams.list({ status: 'live', limit: 20 });
for await (const s of joa.streams.listAll()) console.log(s.id, s.status);
await joa.streams.replaceKey(stream.id); // lost the key before going live
await joa.streams.playbackToken(stream.id, { expires_in: 600 }); // a short signed HLS URL
await joa.streams.viewers(stream.id); // viewers per minute and by countryA stream waits up to 30 minutes for video (pending), then is live, and finally ended, expired (nothing connected in time) or cancelled. Field-by-field meaning: API reference.
Recordings
One MP4 of the top quality, ready a few minutes after the stream ends.
await joa.recordings.waitUntilReady(stream.id); // polls; throws if it will never be ready
const { url } = await joa.recordings.download(stream.id); // signed link, 1 hour by default
await joa.recordings.delete(stream.id);Webhooks
Get stream.live, stream.ended, recording.ready, credit.low and more pushed to your server instead of polling.
const endpoint = await joa.webhooks.endpoints.create({
url: 'https://example.com/webhooks/justonair',
events: ['stream.live', 'stream.ended', 'recording.ready'], // leave out for every event
});
// endpoint.secret is whsec_…: store it as JOA_WEBHOOK_SECRETVerify each delivery against the raw body, before any JSON parsing:
// Express
app.post('/webhooks/justonair', express.raw({ type: 'application/json' }), async (req, res) => {
let event;
try {
event = await joa.webhooks.verify(req.body, req.headers); // uses JOA_WEBHOOK_SECRET
} catch {
return res.sendStatus(400);
}
res.sendStatus(204); // answer within 10 s, then do the work
if (event.type === 'recording.ready') await importRecording(event.data.id);
});// Next.js route handler, or anything with a Fetch API Request
export async function POST(req: Request) {
const event = await joa.webhooks.verify(await req.text(), req.headers);
// …
return new Response(null, { status: 204 });
}verify throws WebhookVerificationError for a bad signature, a wrong secret, a message older than 5 minutes, or missing headers. It is the Standard Webhooks format, and verifyWebhook(body, headers, secret) is exported on its own too. More: joa.webhooks.endpoints.list / get / update / delete / rotateSecret / test / deliveries / retryDelivery.
Balance and usage
const usage = await joa.usage.get({ days: 30 });
console.log(usage.available_usd, usage.days.map((d) => [d.date, d.cost_usd]));
await joa.account.notifications.update({ low_balance_threshold_usd: '20.00' });Amounts are decimal strings ("12.345000"), so nothing is lost to floating point.
The hosted player
joa.embed.iframe(stream.embed_url, { title: 'Town hall' }); // the <iframe> snippet
await joa.embed.get(stream.id); // the player's public read; needs no API keyChat and reactions (beta)
Turn chat on per stream, then read and moderate it from your server. Details: Chat and reactions.
const stream = await joa.streams.create({ name: 'Friday show', chat: { enabled: true, reactions: true } });
// Every message once, oldest first, polling the feed (deleted, filtered and shadow-banned ones too).
for await (const m of joa.chat.watch(stream.id)) {
console.log(m.nickname, m.text, m.country, m.visible ? '' : '(hidden)');
if (/buy followers/i.test(m.text)) await joa.chat.ban(stream.id, { message_id: m.id, delete_messages: true });
}
await joa.chat.post(stream.id, { text: 'Welcome!' }); // with the Host badge
await joa.chat.update(stream.id, { slow_mode_seconds: 10 }); // also paused, pinned_message_id
await joa.chat.setWords(stream.id, ['spoiler']);
// Someone else moderates from the chat page, with no API key: send them the link (works once).
const { invite_url } = await joa.chat.inviteModerator(stream.id, { name: 'Mert', active_days: 7 });
// Only your sites may embed the player and chat, or post to it.
await joa.streams.update(stream.id, { player: { allowed_domains: ['example.com', '*.example.com'] } });Building your own chat UI? The public calls need no API key and work in a browser (new JustOnAir() without a key):
const viewer = new JustOnAir();
const session = await viewer.embed.session(streamId); // once per browser and stream
await viewer.embed.postMessage(streamId, { token: session.token, nickname: 'Ayşe', text: 'Merhaba!' });
const chat = await viewer.embed.chat(streamId); // or poll https://play.joacdn.com/api/embed/{id}/chat
await viewer.embed.react(streamId, session.token, { heart: 3 }); // batched; see reaction_samplingTo float reactions over your own video player (Video.js, hls.js, Shaka, Plyr, a YouTube iframe), see Reactions over your own player: a copy-paste script that takes your player's element or a selector such as '.video-wrapper'.
Errors
Every error is a JustOnAirError. When the API answers with an error you get an APIError subclass with the API's stable code: program against the code, show the message to people.
import { RateLimitError, InsufficientCreditError } from 'justonair';
try {
await joa.streams.create();
} catch (err) {
if (err instanceof RateLimitError && err.code === 'limit_pending_streams') {
// err.details: { limit: 2, current: 2 }
} else if (err instanceof InsufficientCreditError) {
// add credit
} else throw err;
}| Class | Status | Codes |
|---|---|---|
| BadRequestError | 400, 422 | invalid_request, idempotency_key_reused |
| AuthenticationError | 401 | unauthorized |
| InsufficientCreditError | 402 | insufficient_credit |
| PermissionDeniedError | 403 | account_pending, tenant_suspended |
| NotFoundError | 404 | not_found |
| ConflictError | 409 | stream_not_pending, stream_ended, recording_not_ready |
| RateLimitError | 429 | limit_*, rate_limited |
| InternalServerError | 5xx | no_capacity, … |
| APIConnectionError / APITimeoutError | — | no answer |
All codes: Errors.
Retries, timeouts, idempotency
- Network errors, 5xx (including
no_capacity) andrate_limitedare retried twice with backoff. Account limits (limit_*) are not: waiting a second doesn't lift them. streams.createsends anIdempotency-Keyfor you, so a retry never makes a second stream. Pass your own ({ idempotencyKey }) to keep that true across restarts of your process, for example your own order or event id.- A POST without an idempotency key is never retried.
- Every request times out after 30 s.
const joa = new JustOnAir({ maxRetries: 0, timeoutMs: 8_000 });
await joa.streams.create(params, { idempotencyKey: `event-${eventId}`, timeoutMs: 5_000, signal });Configuration
| Option | Default | |
|---|---|---|
| apiKey | JOA_API_KEY | Not needed for embed and webhooks.verify. |
| webhookSecret | JOA_WEBHOOK_SECRET | For webhooks.verify. |
| baseUrl | JOA_BASE_URL, then https://api.justonair.com | |
| timeoutMs | 30000 | Per attempt. |
| maxRetries | 2 | |
| fetch | global fetch | For tests or a proxy. |
| defaultHeaders | | Sent with every request. |
Keep API keys on your server. The SDK refuses to start with a key in a browser, where anyone could read it and spend your credit (dangerouslyAllowBrowser: true overrides that).
Types
Every request and response is typed from the OpenAPI spec, and the types are exported:
import type { Stream, CreatedStream, WebhookEvent, Usage } from 'justonair';Links
Docs · API reference · Pricing · For AI agents · Dashboard
Security reports: [email protected]. Everything else: issues or [email protected].
License
MIT
