Appearance
Moments
A moment is something that happens on your live stream that viewers should see on screen as it happens: a paid shoutout, a song request, a dedication, a poll, a product drop or a sponsor slot. You post it from your server. Each viewer's player shows it when the video reaches that second, so a fan who paid for a shoutout sees it when the DJ reads it, not ten seconds before or after.
How it stays in step
- Your server creates the moment. Vabu stamps it with its own clock to the millisecond (
occurred_at). - Each viewer's player polls the moments feed every 2 seconds. The feed can be cached for one second, so a CDN in front of the API answers for thousands of viewers.
- The player holds each moment until the playhead's program date time, the
EXT-X-PROGRAM-DATE-TIMEof the HLS stream, reachesoccurred_at. It shows the moment fordisplay_msand drops it.
Because the player compares the video's own clock with one server stamp, viewers on slow connections see the moment later in wall time and at the same point in the video.
You need a live stream, a secret key on your server, and a publishable key in the page.
Post a moment from your server
Call this when your payment provider confirms a shoutout. The Idempotency-Key makes the post safe to retry: a second call with the same key returns the first moment, so one payment shows once.
ts
// Node 20 or later.
const API = 'https://api.vabu.live/v1'
export async function showShoutout(payment: { id: string; fan: string; name: string; message: string; cents: number }) {
const res = await fetch(`${API}/streams/live_streams/${process.env.VABU_LIVE_STREAM_ID}/moments`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.VABU_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': `shoutout-${payment.id}`,
},
body: JSON.stringify({
type: 'shoutout',
label: `Shoutout from ${payment.name}`,
payload: { from: payment.name, message: payment.message },
customer: payment.fan,
amount: payment.cents,
currency: 'KES',
metadata: { payment: payment.id },
}),
})
const body = await res.json()
if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message} (${body.error.request_id})`)
// Keep the id. POST /streams/moments/{id}/cancel takes the moment off screen if the payment is reversed.
return body.data.id as string
}What goes where:
| Field | Seen by viewers | Use it for |
|---|---|---|
type, label, payload | Yes | What the player draws. label is up to 140 characters and payload is a JSON object up to 4 KB. |
display_ms | Yes | How long it stays on screen. Default 8000. |
customer, amount, currency, metadata | No | Your own records. They are in your webhooks and never in the feed. |
visibility: "private" | No | A moment only you see, for example a note for a clip. |
Show moments in the browser
Use the publishable key and the stream ID. The example uses hls.js, whose hls.playingDate is the program date time of the frame on screen. Any player that gives you that time works the same way.
ts
// Browser. `hls` is your hls.js instance. `draw` puts a moment on your overlay for `ms` milliseconds.
const API = 'https://api.vabu.live/v1'
const STREAM = 'ls_...'
const KEY = 'vpk_live_...'
type Moment = { id: string; type: string; label: string; payload: Record<string, unknown> | null; occurred_at: string; display_ms: number }
const waiting: Moment[] = []
const seen = new Set<string>() // ids already queued, so starting the feed again never shows a moment twice
let after: string | undefined
async function poll() {
const url = new URL(`${API}/streams/live_streams/${STREAM}/moments/feed`)
url.searchParams.set('key', KEY)
// Every viewer in the same 2 second window asks for the same URL, which lets a CDN answer for all of them.
url.searchParams.set('w', String(Math.floor(Date.now() / 2000)))
if (after) url.searchParams.set('after', after)
const res = await fetch(url)
if (res.status === 422) after = undefined // the marker is not valid any more: ask for the newest moments again
if (!res.ok) return
for (const moment of (await res.json()).data as Moment[]) {
after = moment.id
if (seen.has(moment.id)) continue
seen.add(moment.id)
waiting.push(moment)
}
}
function tick() {
const playhead = hls.playingDate?.getTime()
if (!playhead) return
for (const moment of waiting.splice(0)) {
const start = Date.parse(moment.occurred_at)
if (start > playhead) waiting.push(moment) // not on screen yet
else if (playhead - start < moment.display_ms) draw(moment, start + moment.display_ms - playhead)
}
}
setInterval(poll, 2000)
setInterval(tick, 200)
poll()The first poll returns the newest 100 moments, so tick skips any whose time has already passed. After that, after asks only for what was saved later, in the order it was saved. If Vabu answers 422 because after is not a moment of the stream, the example asks again without it. The seen set keeps that from showing a moment twice.
Take a moment off screen
sh
curl -X POST "https://api.vabu.live/v1/streams/moments/mom_01J9.../cancel" \
-H "Authorization: Bearer $VABU_API_KEY"A cancelled moment leaves the feed within a second. A player that already drew it cannot take it back. Cancelling twice is safe.
Show how many are watching
GET /v1/streams/live_streams/{id}/stats/public takes the same publishable key and returns { "data": { "viewers_now": 1284 } }. It can be cached for 5 seconds, so poll it about that often. Anyone who holds the page's publishable key and the stream ID can read it, and there is no setting to switch it off yet, so call it only if the count may be public. It counts viewers with an open playback session, so use playback tokens to start playback if you want them counted. Your own dashboard can read the full numbers, including the peak, from GET /v1/streams/live_streams/{id}/stats with a secret key.
Things to know
- The feed is public. Anyone who knows the stream ID can read it, like the video itself when its playback policy is public, and a CDN may answer without checking the key. Put only text that is safe to show anyone in
labelandpayload, and draw it as text, never as HTML. - Moments can be queued. You can post to a stream that is not live yet. Players show the moment once the video reaches it. A disabled stream refuses them with
409 live_stream_disabled. If two posts for one stream collide for 3 seconds, the second gets503 try_again: retry with the sameIdempotency-Key. - Your clock is optional. To stamp a moment with your own clock, send
occurred_atas an ISO 8601 time with an offset. It must be within 5 minutes of the server time, or you get422 occurred_at_out_of_range, whosedetails.server_timeshows the server clock. Moments reach players in the order you post them, whatever time you stamp, so a moment stamped ahead of the clock never hides later ones. Leaveoccurred_atout unless your clock is the better one. - Test mode. A test key makes test moments that only test keys can read, so you can try the whole flow on a test stream first.
- Events.
streams.moment.createdandstreams.moment.cancelledreach your webhooks, with the customer and amount. See the event catalogue.
The full request and response for each endpoint is in the API reference.