# Vabu OS > Vabu OS is the infrastructure for live experiences in Africa: events, fans, commerce, streaming, messaging and payments behind one API. --- # API reference Every endpoint, parameter and response, generated from the OpenAPI file. The full API description is the OpenAPI file: https://docs.vabu.live/openapi.json --- # Assets and recordings An **asset** is a video or audio file you can play on demand: a replay, a trailer, a lesson, a highlight. It is the on-demand side of Vabu Streams. Assets have ids that start `ast_`. ## Add an asset There are two ways in. **From a link.** Give a public HTTPS address of a video file. ```http POST /v1/streams/assets Idempotency-Key: replay-2026-10-11 { "url": "https://cdn.example.com/kasarani-night.mp4", "playback_policy": "signed", "generated_subtitles": "en", "external_id": "replay-kasarani-1011", "metadata": { "show": "Friday night" } } ``` **From a browser upload.** Create an upload, then let your user's browser send the file straight to Vabu. ```http POST /v1/streams/uploads Idempotency-Key: upload-1011 ``` The response holds a one-time `url`. `PUT` the file to it. Anyone who holds that address can upload, so treat it as a secret and set `cors_origin` to your site. When the file arrives the upload's `asset` is filled in and its `status` becomes `asset_created`. ## Status A new asset has `status: "preparing"` while the video is processed. It becomes `ready` when it can be played, or `errored` with an `error` message when it cannot. You get an event for each: `streams.asset.ready` and `streams.asset.errored`. Do not offer an asset to viewers before it is `ready`. A playback request for an asset that is not ready is refused with `409 asset_not_ready`. | Field | Meaning | |---|---| | `source` | Where the asset came from: `upload`, `url` or `clip`. `recording` will mark replays of live streams. Ignore values your code does not know. | | `duration` | Length in seconds, once the asset is ready. | | `audio_only` | `true` for sound with no picture. | | `playback_ids` | How the asset is played. See [signed playback](https://docs.vabu.live/concepts/signed-playback.md). | | `external_id`, `metadata` | Your own id and key and value pairs. Filter lists with `status`, `source`, `external_id` and `metadata[key]`. | ## Clips A clip is a short piece cut from a ready asset. It becomes an asset of its own, with `source: "clip"`, and you play it like any other. ```http POST /v1/streams/assets/ast_.../clips Idempotency-Key: clip-1011-a { "start_time": 10, "end_time": 40.5 } ``` Times are seconds from the start of the asset. `end_time` must be after `start_time` and inside the asset. Fetch a clip with `GET /v1/streams/clips/{id}`. ## Play an asset Give the customer an entitlement for the asset, with resource type `ast`, and open a session exactly as for a live stream. See [paid access](https://docs.vabu.live/guides/streams/paid-access.md) and [playback and sessions](https://docs.vabu.live/concepts/playback-and-sessions.md). ## Replays of live streams Today you add a replay yourself: record the show at the venue, or save it from your encoder, and add the file as an asset. Replays recorded automatically from a live stream, with chapters, are on [What's coming](https://docs.vabu.live/whats-coming.md). --- # Customer tokens A customer token lets your own login stay the only login. Your server knows who is signed in. It signs a short token that says "this is user 42". The browser sends that token to Vabu with a publishable key, and Vabu opens the viewing session for user 42. Vabu never asks your viewer to sign in again, and your secret key never reaches the browser. ## Make a signing key Create a key once. Only a secret key can do this. ```http POST /v1/access/customer_token_keys { "name": "backend" } ``` ```json { "data": { "id": "ctk_01J9ZQ4M8T3K6Y2E5W7N0B1C9D", "object": "customer_token_key", "name": "backend", "status": "active", "livemode": true, "secret": "cts_..." } } ``` The `secret` is shown once. Keep it in your server's secret store. Keys are per environment: a token signed for test mode does not work with a live publishable key. Rotate by making a second key, deploying it, then revoking the first with `DELETE /v1/access/customer_token_keys/{id}`. Revoking stops new sessions at once. ## The token A token is a standard JWT signed with HMAC-SHA256, so a few lines in any language make one. | Part | Value | |---|---| | Header `alg` | `HS256`. Anything else is refused. | | Header `kid` | The key's `id`, starting `ctk_`. | | `sub` | Your own id for the viewer, 1 to 255 characters. It must match the `customer` on the entitlement. | | `aud` | `vabu` | | `iat` and `exp` | Both required, at most 15 minutes apart, and `exp` at most 15 minutes ahead. A clock skew of 60 seconds is allowed. | | `ctry` | Optional. A two-letter country code you vouch for, used for country limits. | Sign a fresh token each time a page loads. Ten minutes is a good lifetime. ::: code-group ```js [Node] import { createHmac } from 'node:crypto' const b64 = (value) => Buffer.from(JSON.stringify(value)).toString('base64url') export function customerToken(customer, keyId, secret) { const now = Math.floor(Date.now() / 1000) const head = b64({ alg: 'HS256', typ: 'JWT', kid: keyId }) const body = b64({ sub: customer, aud: 'vabu', iat: now, exp: now + 600 }) const signature = createHmac('sha256', secret).update(`${head}.${body}`).digest('base64url') return `${head}.${body}.${signature}` } ``` ```php [PHP] 'HS256', 'typ' => 'JWT', 'kid' => $keyId])); $body = b64url(json_encode(['sub' => $customer, 'aud' => 'vabu', 'iat' => $now, 'exp' => $now + 600])); $signature = b64url(hash_hmac('sha256', "$head.$body", $secret, true)); return "$head.$body.$signature"; } ``` ```python [Python] import base64 import hashlib import hmac import json import time def b64url(data: bytes) -> str: return base64.urlsafe_b64encode(data).rstrip(b"=").decode() def customer_token(customer: str, key_id: str, secret: str) -> str: now = int(time.time()) head = b64url(json.dumps({"alg": "HS256", "typ": "JWT", "kid": key_id}, separators=(",", ":")).encode()) body = b64url(json.dumps({"sub": customer, "aud": "vabu", "iat": now, "exp": now + 600}, separators=(",", ":")).encode()) signature = b64url(hmac.new(secret.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest()) return f"{head}.{body}.{signature}" ``` ```go [Go] package vabu import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "time" ) // CustomerToken signs a short-lived token for one viewer. func CustomerToken(customer, keyID, secret string) string { enc := base64.RawURLEncoding now := time.Now().Unix() head, _ := json.Marshal(map[string]any{"alg": "HS256", "typ": "JWT", "kid": keyID}) body, _ := json.Marshal(map[string]any{"sub": customer, "aud": "vabu", "iat": now, "exp": now + 600}) unsigned := enc.EncodeToString(head) + "." + enc.EncodeToString(body) mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(unsigned)) return unsigned + "." + enc.EncodeToString(mac.Sum(nil)) } ``` ::: ## Use it in the browser Send the publishable key and the token. Leave `customer` out of the body: with a publishable key the customer is always the token's `sub`. ```http POST /v1/streams/playback_tokens Vabu-Publishable-Key: vpk_live_... Authorization: Bearer { "resource": "ls_...", "device_id": "browser-1" } ``` These calls accept a publishable key with a customer token: open a session (`POST /v1/streams/playback_tokens` or `POST /v1/access/sessions`), heartbeat and end. A heartbeat or an end only acts on the token's own sessions. Everything else, including entitlements and access checks, needs a secret key. **Refresh the token before it expires.** With a publishable key every call, heartbeats included, is checked against the token. When it expires, heartbeats fail with `401 invalid_customer_token` and the session lapses. Ask your server for a new token before the old one runs out, or fetch one for each call. ## Errors A rejected token returns `401 invalid_customer_token`, and `error.details.reason` says why. | Reason | Fix | |---|---| | `missing` | Send the token as `Authorization: Bearer`, beside the publishable key. | | `malformed` | The token is not a three-part HS256 token with a `kid`. | | `unknown_key` | The `kid` is not a key in this tenant and environment. A token for test mode does not work with a live key. | | `key_revoked` | The signing key was revoked. Sign with a current one. | | `invalid_signature` | The secret does not match the key. | | `not_yet_valid` | `iat` or `nbf` is in the future. Check your server's clock. | | `token_expired` | `exp` has passed. Sign a new token. | | `invalid_claims` | `aud`, `sub`, `iat` or `exp` is wrong, or the lifetime is over 15 minutes. | ## Why HMAC The signer needs no library and no key format beyond HMAC. The secret lives in two places only: your server, and Vabu, where it is encrypted at rest. If you use Vabu ID for login instead, see [sign-in options](https://docs.vabu.live/concepts/sign-in-options.md). --- # Delivery networks and capabilities Video has to travel from the venue's encoder to every viewer's phone over a delivery network. Vabu Streams sits in front of the network so that you write your code once. This page explains how to keep that code independent of the network. ## The provider field Every live stream and asset has a read-only `provider` field. It names the network that carries that video. In test mode it is `simulated`. ```json { "id": "ls_01J9ZQ4M8T3K6Y2E5W7N0B1C9D", "object": "live_stream", "provider": "simulated" } ``` Show it in a log or a support screen if you like. Do not branch on it. When Vabu adds networks or moves an account, a check on the name would break and a check on a capability would not. Today each account runs on one network that Vabu chooses for it. A second network can carry a live stream as a standby: see [Staying on air](https://docs.vabu.live/guides/streams/staying-on-air.md). Choosing a network by audience region, with Nairobi first, is on [What's coming](https://docs.vabu.live/whats-coming.md). ## Capabilities A capability is something a network can do. Networks differ, so a few capabilities are not on every stream. Check the thing itself. | You want to | How to know | If it is not there | |---|---|---| | Send video over SRT | `ingest.srt_url` is set | Use `rtmp_url` | | Send video over WHIP from a browser | `ingest.whip_url` is set | Use RTMP or SRT from an encoder | | Use a backup address | `ingest.rtmp_backup_url` is set | Rely on the reconnect window | | Run an audio-only stream | Create the stream with `audio_only: true`. The response confirms it. | A `422 provider_rejected` error says why | | Keep latency low | Create the stream with `latency_mode: "low"`. The response shows what you got. | Use `standard` | | Forward to other platforms | `POST` a simulcast target. `status` on the target says if it was accepted. | Targets with `status: "errored"` were refused | | Cut clips from an asset | `POST /v1/streams/assets/{id}/clips` | A `409 asset_not_ready` error means wait for `ready` | | Sign playback tokens yourself | `POST /v1/streams/signing_keys` | `409 dedicated_environment_required` | Two rules keep your code safe: 1. Read the response, not the plan. Look at the fields you got back and branch on them. 2. Treat a refusal as information. `422 provider_rejected` and `409` errors tell you what the network cannot do, in words you can show your team. ## Ordered sources A playback grant returns `sources`, an ordered list of links, with a `failover` rule. Play the first, and switch after `failover.max_errors` fatal errors in `failover.window_seconds`. Today the list has one item. Write the switch now, and a second source will work the day it appears. See [playback and sessions](https://docs.vabu.live/concepts/playback-and-sessions.md). ## Vabu events, not network events Networks send their own signals when an encoder connects or a file is ready. You never see them. Vabu turns them into its own events, such as `streams.live_stream.active` and `streams.asset.ready`, with the same shape whatever the network. See [stream events](https://docs.vabu.live/concepts/stream-events.md). --- # Errors Every error uses the same JSON shape, including errors from authentication and rate limiting. ```json { "error": { "type": "invalid_request_error", "code": "ticket_type_sold_out", "message": "VIP is sold out.", "param": "items[0].ticket_type", "details": { "items.0.quantity": ["Only 0 left."] }, "request_id": "req_01JB...", "doc_url": "https://docs.vabu.live/errors.html#ticket_type_sold_out" } } ``` | Field | Meaning | |---|---| | `type` | The class of error. See below. | | `code` | A stable, specific code. Branch on this. | | `message` | A sentence for people. It can change, so do not match on it. | | `param` | The request field at fault, when there is one. | | `details` | Validation messages per field. | | `request_id` | `req_...`. Include it in support requests. | | `doc_url` | A link to the matching entry on the [error codes](https://docs.vabu.live/errors.md) page. | ## Types | Type | HTTP | When | Retry? | |---|---|---|---| | `invalid_request_error` | 400, 404, 409, 422 | Bad input, missing resource, conflict or validation | No. Fix the request. | | `authentication_error` | 401 | Missing or bad key or token | No. | | `permission_error` | 403 | Scope, role or product not enabled | No. | | `rate_limit_error` | 429 | You hit a limit | Yes, after `Retry-After`. | | `idempotency_error` | 409 | Key reused with a different body, or still in flight | See [idempotency](https://docs.vabu.live/concepts/idempotency.md). | | `provider_error` | 502 | An upstream provider failed | Yes, with backoff. | | `api_error` | 500, 503 | Our fault | Yes, with backoff. | ## Retrying Retry `429`, `502`, `500` and `503` with exponential backoff and jitter, and send an [`Idempotency-Key`](https://docs.vabu.live/concepts/idempotency.md) so a retry cannot repeat the action. Do not retry other `4xx` responses without changing the request. New error codes can be added at any time. Treat an unknown `code` as its `type`. --- # Expand Many objects refer to others by ID. Add `expand[]` to get the related object inline, in one request instead of two. ```http GET /v1/events/evt_01JB...?expand[]=organization&expand[]=ticket_types ``` Without `expand[]`, `organization` is an ID string. With it, `organization` is the full object. - Expansion works up to 2 levels deep, for example `expand[]=ticket_types.event`. - You can expand on single objects and on lists. - Ask only for what you use. Expanded responses are larger and slower. - An unknown path returns `400` `invalid_request_error` with the path in `param`. --- # Idempotency Send an `Idempotency-Key` header so you can retry a request without doing the action twice. Use a unique value for each action, such as a UUID or your own order number. ```http POST /v1/orders/ord_01JB.../refund Idempotency-Key: refund-8842-attempt-1 ``` The header is accepted on every `POST`, `PATCH` and `DELETE`. It is **required** on endpoints that move money: payments, refunds, payouts and orders. ## What happens | Situation | Result | |---|---| | First request with a key | Runs normally. The status and body are stored for 24 hours. | | Same key, same method, path and body | Returns the stored status and body, with `Idempotent-Replayed: true`. | | Same key, different body | `409` `idempotency_key_reused`. | | Same key while the first request is still running | `409` `idempotency_key_in_use`. Wait a moment and retry. | Keys are scoped to your tenant and environment. A key in test mode does not match one in live mode. ## What is not stored - Validation failures (`422`) are not stored, so you can fix the request and retry with the same key. - `5xx` responses are not stored, so a retry runs again. ## Retry pattern 1. Generate the key once per action, and keep it with the action in your database. 2. Send the request. If it times out or returns `429`, `5xx` or `409 idempotency_key_in_use`, wait with backoff and send it again with the same key. 3. If you get a `409 idempotency_key_reused`, you changed the body. Use a new key for a new action. --- # IDs Public IDs are typed. The prefix tells you what the object is, and the rest is an encoded, time-ordered identifier. ```text evt_01JB3K... event tkt_01JB3K... ticket ls_01JB3K... live stream cus_01JB3K... customer ord_01JB3K... order ``` ## Rules - Treat an ID as an opaque string. Do not parse it, and do not assume its length. - Store it in a text column. - An unknown prefix returns `404`. - IDs are the same in test and live, but an object only exists in the environment where it was created. - IDs sort roughly by creation time, so newer objects tend to sort after older ones. ## Bring your own IDs You do not have to store ours. Every object accepts an `external_id`, and you can fetch it back with it. ```http GET /v1/orders/external/your_order_8842 ``` See [metadata and external_id](https://docs.vabu.live/concepts/metadata.md). --- # Ways to use Vabu OS Pick the mode that matches what you already have. Modes combine per tenant and per product. For example, a tenant can use Vabu ID for login and Vabu's checkout for tickets, and take payment its own way for a stream. | Mode | You bring | Vabu OS provides | Example | |---|---|---|---| | A. Infrastructure API | Your app, users, payments and access rules | Service infrastructure through the API | A streaming platform | | B. Own accounts | Your own login | Everything else, acting for the customers you name | A club app with existing logins | | C. Own payments | Your payment stack | Access, streams and the rest. You tell Vabu who has paid. | A platform with its own payment provider | | D. Whitelabel | Brand, domain and content | Login, checkout and the product APIs | A ticketing brand | | E. Internal | Nothing | Everything | Vabu's own apps | ## A. Infrastructure API Call the API from your server with a [secret or restricted key](https://docs.vabu.live/concepts/keys-and-scopes.md). Your users stay yours: send `external_id` on any object, and read [metadata and external_id](https://docs.vabu.live/concepts/metadata.md). You do not need to create Vabu accounts for them. ## B. Own accounts You keep your login. Your server says who the customer is, in one of two ways: - From your server, name the customer with your own id in the request. - From a browser, send a [customer token](https://docs.vabu.live/concepts/customer-tokens.md) that your server signs, with a publishable key. This works for Streams and Access today. Signing in to the other products with tokens from your own identity provider is on [What's coming](https://docs.vabu.live/whats-coming.md). See [sign-in options](https://docs.vabu.live/concepts/sign-in-options.md). ## C. Own payments You collect the money your own way, and then tell Vabu who may use what. For Streams and Access that is one call after each payment: ```http POST /v1/access/entitlements Idempotency-Key: grant-pay-8842 { "customer": "user_42", "resource": { "type": "ls", "id": "ls_..." }, "source": "tenant_grant", "source_ref": "pay-8842", "ends_at": "2026-11-02T18:30:05Z" } ``` No Vabu balance is created and there is no payout. See [paid access](https://docs.vabu.live/guides/streams/paid-access.md). For orders in Events and Commerce, Vabu collects the payment through its checkout today. Reporting payments you collected yourself, so that an order completes, is on [What's coming](https://docs.vabu.live/whats-coming.md). ## D. Whitelabel You supply a brand, a domain and content. Login is Vabu ID, payment is Vabu's checkout and the product APIs are the same ones everyone uses. Branding such as logo, colours and sender names is read from your tenant and organization. Your app calls the API and shows the result. ## E. Internal Vabu's own apps are tenants too. They use the same public API as you, with every product switched on. ## Products The products are Events, Fanbases, Commerce, Streams, Promoters, Messaging, Payments, Identity and Insights. See [products](https://docs.vabu.live/products/index.md) for each one. Each tenant has Streams switched on separately: a Streams route that is off returns `403` with the code `product_not_enabled`. Keys carry product scopes. ## What Vabu uses behind each product You do not pick these yet. They are what Vabu runs for you, so you know what to expect. | Concern | What Vabu uses | |---|---| | Identity | Vabu ID, which also accepts Payd ID tokens | | Payments | Payd checkout, with M-Pesa first | | Streams | One video network that Vabu picks for your account. The `provider` field on each stream shows it. | | Messaging | WhatsApp, SMS and email, sent by Vabu's own senders | Choosing a network for each stream is on [What's coming](https://docs.vabu.live/whats-coming.md). Whatever the mode, Vabu sends an [event](https://docs.vabu.live/concepts/webhooks.md) for what happens, so webhooks work the same in every mode. --- # Keys and scopes Every API request is authenticated by a key or a user token. The key decides the tenant, the environment and what the request may do. | Type | Prefix | Sent as | Use | |---|---|---|---| | Secret | `vk_live_`, `vk_test_` | `Authorization: Bearer` | Server calls with full access to enabled products | | Restricted | `rk_live_`, `rk_test_` | `Authorization: Bearer` | Server calls with chosen scopes | | Publishable | `vpk_live_`, `vpk_test_` | `Vabu-Publishable-Key` | Browsers and apps | | User token | JWT | `Authorization: Bearer` | A customer or staff member | ## Scopes Scopes have the form `{product}:{read|write}`, for example `events:read` or `streams:write`. The scope `platform:*` covers keys, webhooks and integrations. A secret key has every scope for the products you have enabled. A restricted key has only the scopes you choose. A request without the scope gets a `403` `permission_error`. A request for a product that is off gets `403` `product_not_enabled`. ## Principals per endpoint Each endpoint states which principals it allows. The API reference shows this under each operation. | Principal | Meaning | |---|---| | `secret` | A secret key | | `restricted:` | A restricted key that has the scope | | `publishable` | A publishable key | | `customer` | A signed-in customer token | | `member:` | A staff member with the permission | | `operator` | Vabu platform staff, on the operator API | ## Which credentials each endpoint takes Not every endpoint takes every credential. This is how the families split today. Each operation in the [API reference](https://docs.vabu.live/api.md) also lists its own principals. | Endpoint family | Secret key | Restricted key | Publishable key | Signed-in user | Test mode | |---|---|---|---|---|---| | API keys and webhook endpoints | Yes | `platform:read` or `platform:write` | No | Team members with the right role | Yes | | Customers (`/v1/customers`) | Yes | `customers:read` or `customers:write` | No | Team members with the right role | Yes | | Access: entitlements and checks | Yes | `access:read` or `access:write` | No | No | Yes | | Access: open, heartbeat and end a session | Yes | `access:write` (heartbeat and end also take `streams:write`) | Yes, with a [customer token](https://docs.vabu.live/concepts/customer-tokens.md) | No | Yes | | Streams (`/v1/streams/...`) | Yes | `streams:read` or `streams:write` | Only `POST /v1/streams/playback_tokens`, with a customer token | No | Yes, on a simulated network | | Public reads: `GET /v1/events`, `GET /v1/events/{event}`, `GET /v1/plus/contents`, `GET /v1/plus/contents/{content}` | Yes | `events:read` or `streams:read` | Yes | Not needed | Yes | | Vabu ID sign-in (`/auth/v1/...`) | Not needed | Not needed | Optional, to sign in as your customer | Some routes need the access token | Yes | | Everything else: booking, orders, tickets, organizer, creator, tenant staff and merch routes | Live keys, as before | Live keys, as before | No | Yes, with `X-Tenant-Code` | Not yet | For the last row, a request is made by a signed-in person (a Vabu ID or Payd ID token) with the tenant in the `X-Tenant-Code` header, or in `Vabu-Tenant` for a Vabu ID token. Some of these routes, such as browsing events and starting a checkout, need only the tenant header and no token. Test keys and publishable keys are refused on routes that do not list them, with `403 test_mode_unavailable` or `403 insufficient_permissions`. The product pages say which routes need what. ## Lifecycle - **Create.** The full key is shown once. Vabu OS keeps a hash and an 8-character prefix. - **Track.** Each key records its last use, its creator, an optional expiry and an optional IP allowlist. - **Roll.** Rolling creates a new key and sets an expiry on the old one: immediately, or up to 7 days later. - **Revoke.** Revoking takes effect at once, and the change is audited. Your tenant owners get an email if a key was reported as leaked. Keys are cached for up to 60 seconds for speed, and revocation clears that cache right away. --- # Live streams and ingest A **live stream** is a standing channel you create once and reuse for every broadcast from one place, for example one per venue, one per church campus or one per pitch. Its ingest address and key stay the same from show to show, so the crew sets up the encoder once. ```http POST /v1/streams/live_streams Idempotency-Key: venue-kasarani-1 ``` Every body field is optional. | Field | Default | What it does | |---|---|---| | `latency_mode` | `low` | `low` keeps the picture close to real time. `standard` trades some delay for a steadier picture. | | `reconnect_window` | `60` | Seconds the broadcast stays open after the encoder drops, from 0 to 1800. See [going live](https://docs.vabu.live/guides/streams/going-live.md#what-happens-if-the-connection-drops). | | `audio_only` | `false` | A stream with sound and no picture, for radio sets and audiences on small data plans. | | `playback_policy` | `signed` | `signed` needs a playback link that Vabu issues to a viewer with access. `public` plays for anyone who has the link. See [signed playback](https://docs.vabu.live/concepts/signed-playback.md). | | `simulcast_targets` | none | Up to 6 other platforms to forward the broadcast to. | | `external_id` | none | Your own id for the venue. Unique per account and environment. Fetch it back with `GET /v1/streams/live_streams?external_id=...`. | | `metadata` | none | Your own key and value pairs. Filter lists with `metadata[key]=value`. | The response holds the stream's `id` (starts `ls_`), its `status`, its `ingest` details and its `playback_ids`. ## Ingest The `ingest` object says where an encoder sends video. | Field | Protocol | Use it for | |---|---|---| | `rtmp_url` with `stream_key` | RTMP over TLS | OBS, hardware encoders and most phone apps. This is the usual choice. | | `rtmp_backup_url` | RTMP | A second address for the same stream, for an encoder that can switch. | | `srt_url` | SRT | Poor or long-distance networks. It recovers lost packets better than RTMP. `srt_passphrase` is set when one is needed. | | `whip_url` | WHIP | Sending from a browser or a WebRTC tool. It is `null` when the video network behind your account does not offer it. | The full address for most encoders is the `rtmp_url`, a slash and the `stream_key`. The stream key is a secret. It is encrypted at rest and is never put in an event or a webhook payload. If it leaks, call `POST /v1/streams/live_streams/{id}/reset_stream_key`. The new key works at once and the old one stops. When a field is `null`, the thing is not offered for that stream. Check the field instead of assuming. [Delivery networks and capabilities](https://docs.vabu.live/concepts/delivery-networks.md) lists the checks. ## Encoder settings Use H.264 video and AAC audio, a keyframe every 2 seconds and about 2 to 6 Mbps for 720p. The full table, with OBS and phone steps, is in [going live](https://docs.vabu.live/guides/streams/going-live.md). ## Status `status` follows the encoder. | Status | Meaning | |---|---| | `idle` | No broadcast is running. | | `connecting` | An encoder has connected and the first video is arriving. | | `active` | Video is flowing. Viewers can watch. | | `disconnected` | The encoder dropped. The broadcast is still open for the `reconnect_window`. | | `disabled` | You switched the stream off. It refuses encoders and viewers. | Each change produces an event, so you do not need to poll. See [stream events](https://docs.vabu.live/concepts/stream-events.md). ## Control the stream | Call | What it does | |---|---| | `PATCH /v1/streams/live_streams/{id}` | Change `latency_mode`, `reconnect_window`, `metadata` or `external_id`. | | `POST /v1/streams/live_streams/{id}/disable` | Refuse the encoder and new viewers. Use it to lock a stream between shows. | | `POST /v1/streams/live_streams/{id}/enable` | Let the stream take video again. | | `POST /v1/streams/live_streams/{id}/complete` | End the broadcast now, without waiting for the reconnect window. | | `DELETE /v1/streams/live_streams/{id}` | Delete the stream. Later reads return `404`. | ## Simulcast targets A simulcast target forwards your broadcast to another platform at the same time. A common use is to send the first part of a show to a free social platform with a link to watch the rest in your app. ```http POST /v1/streams/live_streams/{id}/simulcast_targets { "url": "rtmps://live.example.com/app", "stream_key": "..." } ``` The target's key is stored encrypted and never returned. `DELETE /v1/streams/live_streams/{id}/simulcast_targets/{target_id}` removes it. A target that the video network rejects is listed with `status: "errored"`, and the stream is still created. ## Test mode A test key makes a simulated stream. It stays `idle` and takes no video. Use it to build your flow, then switch to live keys to send real video. See [test mode](https://docs.vabu.live/concepts/test-mode.md). --- # Metadata and external_id Both let you keep your own data on Vabu OS objects, so you do not need a mapping table. ## external_id Every object accepts an `external_id`: the ID you use for the same thing in your system. ```json { "external_id": "your_order_8842" } ``` - It is unique per tenant, environment and resource type. - Fetch an object by it: `GET /v1/{resource}/external/{external_id}`. - You are never forced to create Vabu accounts for your users. A customer with an `external_id` is enough. ## metadata Every object accepts `metadata`, a map of text keys to text values. ```json { "metadata": { "campaign": "launch-2026", "sales_rep": "amina" } } ``` | Limit | Value | |---|---| | Keys per object | 50 | | Key length | 40 characters | | Value length | 500 characters | Metadata comes back unchanged. Filter lists with `metadata[key]=value`: ```http GET /v1/orders?metadata[campaign]=launch-2026 ``` Do not store secrets in metadata. It is returned in API responses and in webhook payloads. --- # Pagination List endpoints return one page at a time, using cursors. ```http GET /v1/events?limit=20 ``` ```json { "data": [ ], "meta": { "pagination": { "limit": 20, "has_more": true, "next_cursor": "c_eyJ...", "prev_cursor": null } } } ``` ## Parameters | Parameter | Meaning | |---|---| | `limit` | Items per page. Default 20, maximum 100. | | `cursor` | A cursor from `next_cursor` or `prev_cursor`. | | `starting_after` | An object ID. Returns items after it. | | `ending_before` | An object ID. Returns items before it. | New endpoints do not offer offset pagination. Some older endpoints still page by number until they migrate. The [API reference](https://docs.vabu.live/api.md) shows which style each endpoint uses. ## Walk every page Keep requesting while `has_more` is `true`, passing the previous `next_cursor`. ```ts let cursor: string | null = null do { const url = new URL('https://api.vabu.live/v1/events') url.searchParams.set('limit', '100') if (cursor) url.searchParams.set('cursor', cursor) const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.VABU_API_KEY}` } }) const { data, meta } = await res.json() for (const item of data) console.log(item.id) cursor = meta.pagination.has_more ? meta.pagination.next_cursor : null } while (cursor) ``` Cursors are opaque. Do not build or edit them. --- # Playback and access sessions A viewer needs two things to watch a protected stream: an **entitlement** (the right to watch) and a **session** (one viewer on one device, watching now). You create the entitlement after a payment. The session is opened every time someone presses play. ## Open a session Open a session for a live stream or an asset with `POST /v1/streams/playback_tokens`. It does the same as `POST /v1/access/sessions` and adds the playback link. From your server, name the customer: ```http POST /v1/streams/playback_tokens Authorization: Bearer vk_live_... { "resource": "ls_...", "customer": "user_42", "device_id": "living-room-tv" } ``` From a browser, send a publishable key and the [customer token](https://docs.vabu.live/concepts/customer-tokens.md), and leave `customer` out. The customer is the token's `sub`. ```http POST /v1/streams/playback_tokens Vabu-Publishable-Key: vpk_live_... Authorization: Bearer { "resource": "ls_...", "device_id": "browser-1" } ``` Vabu checks the entitlement and its limits, applies the device rules and returns the session: ```json { "data": { "id": "as_01J9ZQ7P2H5V8X1A4D6F9G3JKM", "object": "access_session", "customer": "user_42", "device_id": "browser-1", "state": "active", "expires_at": "2026-10-11T08:01:30Z", "heartbeat_interval": 30, "grant": { "sources": [{ "provider": "...", "url": "https://.../....m3u8?token=..." }], "failover": { "max_errors": 3, "window_seconds": 20 }, "heartbeat_interval": 30, "token_expires_at": "2026-10-11T08:11:30Z" } } } ``` `device_id` is a string your app makes up and keeps for each device, for example one saved in local storage. The same `device_id` opening again replaces its own earlier session. ## The grant `grant.sources` is an ordered list of playback links. Play the first. If the player reports `failover.max_errors` fatal errors within `failover.window_seconds`, switch to the next. There is one source today, so the list has one item. Code the switch anyway, so more sources work without a change. A `public` stream has a plain link with no token, and `token_expires_at` is `null`. ## Keep the session alive Send a heartbeat every `heartbeat_interval` seconds (30): ```http POST /v1/access/sessions/as_.../heartbeat ``` A session that misses two heartbeats ends, about 90 seconds after the last one. A heartbeat returns the session with a fresh `grant`. **The link in your player expires.** Its token lasts for the session's length plus 10 minutes, so about 11 minutes. Each heartbeat returns a new link with a new token. Swap the player to the newest link every 5 minutes or so. With hls.js that is `hls.loadSource(newUrl)`. The [quickstart](https://docs.vabu.live/guides/streams/quickstart.md#_8-play-it-with-hls-js) shows it. A viewer who is watching a live stream rejoins at the live edge when you swap. End a session when the viewer leaves: ```http DELETE /v1/access/sessions/as_... ``` ## Device and session limits The entitlement's `limits` decide what a second device does. See [paid access](https://docs.vabu.live/guides/streams/paid-access.md#limit-devices). | Limit | Effect | |---|---| | `max_devices` | The number of different `device_id` values that may be used. A new device beyond it gets `403 device_limit_reached`. | | `max_concurrent_sessions` with `concurrency_policy: "reject_new"` | The new session is refused with `409 concurrency_limit_reached`. | | `max_concurrent_sessions` with `concurrency_policy: "replace_oldest"` | The new session opens. The oldest one ends, and its next heartbeat gets `403 session_ended`. The latest device wins. | | `max_views` | The number of sessions the customer may open in total. Beyond it, `403 view_limit_reached`. | | `allowed_countries` | Viewers must be in these countries. | | `allowed_domains` | The page must be on one of these hosts. `example.com` does not cover `www.example.com`. | A browser cannot state its own country or domain. With a publishable key, the domain is the host of the request's `Origin` header and the country is the `ctry` claim your server put in the customer token. An entitlement that limits countries or domains refuses a browser request that carries neither. ## When a session ends A heartbeat on an ended session returns `403 session_ended`, and `error.details.reason` says why. | Reason | Meaning | |---|---| | `replaced_by_newer_session` | Another device took over under `replace_oldest`. | | `missed_heartbeats` | The page stopped sending heartbeats, for example the tab was closed or went offline. | | `entitlement_inactive` | You revoked or suspended access, or it expired. | | `client` | The viewer or your app ended it. | Show a clear message for each. "You are watching on another device" is much kinder than a frozen picture. ## Errors when opening a session | Code | HTTP | Meaning | |---|---|---| | `access_denied` | 403 | No active entitlement. `error.details.reason` says why. | | `device_limit_reached` | 403 | `max_devices` is used up. | | `view_limit_reached` | 403 | `max_views` is used up. | | `concurrency_limit_reached` | 409 | Another session is open and the policy is `reject_new`. | | `stream_disabled` | 409 | The live stream is switched off. | | `asset_not_ready` | 409 | The asset is still processing. | | `no_playback_id` | 409 | The stream or asset has no way to be played yet. | | `invalid_customer_token` | 401 | The browser's token was rejected. See [customer tokens](https://docs.vabu.live/concepts/customer-tokens.md). | | `product_not_enabled` | 403 | Streams is not switched on for your account. | A refused open does not use a view or a device slot. All codes are on the [error codes](https://docs.vabu.live/errors.md) page. --- # Rate limits Limits are counted per key with a token bucket. The defaults come from your plan. Typical defaults are 100 requests per second for secret keys and 20 for publishable keys. Unauthenticated routes, such as login, OTP and guest checkout, are limited per IP address. A few endpoints have stricter limits of their own: sending an OTP, minting a token and syncing check-ins. ## Headers | Header | Meaning | |---|---| | `RateLimit-Limit` | Requests allowed in the window | | `RateLimit-Remaining` | Requests left in the window | | `RateLimit-Reset` | Seconds until the window resets | | `Retry-After` | On `429` only: seconds to wait before you retry | ## When you hit a limit You get `429` with an [error](https://docs.vabu.live/concepts/errors.md) of type `rate_limit_error` and a `Retry-After` header. Wait for `Retry-After`, then retry. If you must retry several times, use exponential backoff with jitter, and send an [`Idempotency-Key`](https://docs.vabu.live/concepts/idempotency.md) so a retry cannot repeat the action. ## Stay under the limit - Watch `RateLimit-Remaining` and slow down before it reaches zero. - Use `limit=100` on lists, and [`expand[]`](https://docs.vabu.live/concepts/expand.md), instead of many small calls. - Use [webhooks](https://docs.vabu.live/concepts/webhooks.md) instead of polling. --- # Sign-in options Your customers sign in to your product one of two ways. You pick per product, and you can use both. | | Bring your own login | Use Vabu ID | |---|---|---| | Who runs the login | You | Vabu | | What your customers see | Your sign-in screens | Your screens, calling Vabu ID's login routes | | How Vabu learns who they are | Your server signs a short customer token | Vabu issues the access token | | Good for | An app that already has users | A new product, or a phone-first audience that signs in with WhatsApp or Google | ## Bring your own login You keep your users, your sign-in screens and your user ids. When a customer needs something from Vabu, your server says who they are. - **From your server.** Use a secret key and name the customer in the request with your own id. Send `external_id` on objects you create so you can find them again. See [metadata and external_id](https://docs.vabu.live/concepts/metadata.md). - **From a browser or an app.** Your server signs a short token for the signed-in customer. The browser sends it with a publishable key. Vabu checks the signature and acts for that customer. See [customer tokens](https://docs.vabu.live/concepts/customer-tokens.md). This is how a viewer opens a stream session without a second login. Customer tokens cover Streams and Access today: opening and keeping alive a viewing session. For the other products, signing in with tokens from your own identity provider is on [What's coming](https://docs.vabu.live/whats-coming.md). ## Use Vabu ID Vabu ID is Vabu's login service. Your app calls its routes to sign a person in, and gets back an access token and a refresh token. Ways to sign in: | Method | How it works | |---|---| | WhatsApp or SMS code | The person enters a phone number and a six-digit code. WhatsApp first, SMS as a fallback. | | Email link | The person asks for a link and opens it. | | Google, Apple or Facebook | A redirect to the provider and back to your page. | | Login with Payd | For people who already have a Payd account. | A person who signs in through your tenant becomes a **customer** of your tenant. Their token carries your tenant's id and the customer's id, so your API calls know who they are. Tokens are signed with ES256 and last 15 minutes. Check them on your server against the public keys at `/.well-known/jwks.json`. Refresh them with the refresh token. The routes, the token claims and the rules are on the [Vabu ID](https://docs.vabu.live/products/identity.md) page. ### Vabu ID and streaming A Vabu ID token does not open a stream session by itself. Playback sessions take a customer token from your server. So with Vabu ID the flow is: 1. The customer signs in with Vabu ID. Your app sends the access token to your server. 2. Your server verifies it and reads the customer's id from the token (`cus`, or `sub` for the person). 3. After a payment, your server grants an entitlement to that id. 4. Your server signs a customer token with the same id as `sub`, and your page uses it to open a session. Using the same id in steps 3 and 4 is what ties the viewer to the access you granted. ## Which to pick If you already have accounts, keep them and add customer tokens. If you do not, use Vabu ID, so you do not build OTP, magic links and social sign-in yourself. --- # Signed playback A playback link tells a player where the video is. If anyone who finds the link can play it, then the link is the only thing between a paying audience and a free one. Signed playback fixes that. The link carries a token, the token is made for one viewer's session, and it expires. ## Public and signed Every live stream and asset has one or more **playback ids** (they start `pb_`). The id has a policy. | Policy | Who can play | |---|---| | `signed` | Only someone holding a playback token that Vabu or you signed. This is the default. | | `public` | Anyone who has the link. Use it for free content such as a trailer or the free part of a show. | Add or remove one with `POST /v1/streams/playback_ids` and `DELETE /v1/streams/playback_ids/{id}`: ```http POST /v1/streams/playback_ids Idempotency-Key: trailer-public-1 { "resource": "ast_...", "policy": "public" } ``` When you open a session, Vabu prefers a signed playback id, because the token is what ties playback to that session. ## Vabu signs the link for you This is the normal way. You do nothing about tokens. A session open does it: 1. Vabu checks the viewer's entitlement and limits. 2. It signs a token for the session and puts it in the playback link. 3. Each heartbeat signs a fresh one. The token holds the playback id, an expiry and an opaque viewer id. The viewer id is a keyed hash made for Vabu's own use, such as an on-screen watermark. It never contains the customer id you chose. Read [playback and sessions](https://docs.vabu.live/concepts/playback-and-sessions.md) for the flow. ## How long a link lasts A token lives for the session's length plus 10 minutes, so about 11 minutes. That grace period keeps a stream playing through a short network drop. It also means access does not stop to the second. | What you do | What happens to a link already in a viewer's player | |---|---| | Revoke the entitlement | New sessions are refused at once. The link in use keeps working until its token expires, at most about 11 minutes. The session ends at its next heartbeat. | | Revoke a customer token key | New sessions with that key stop at once. Links already issued work until they expire. | | Share the link with a friend | It works for the rest of its token, then stops. The friend cannot get a new one without an entitlement. | Short links are the reason a shared link is not a way around payment. Combine them with [device limits](https://docs.vabu.live/guides/streams/paid-access.md#limit-devices) so one ticket means one screen. ## Sign tokens yourself Some products need to decide access on their own, with Vabu off the viewer's path. Then you sign playback tokens yourself with a **signing key**. ```http POST /v1/streams/signing_keys ``` The response holds a `key_id` and a `private_key`. The private key is shown once, and Vabu keeps only a fingerprint of it. Sign tokens with `RS256`, put the `key_id` in the token header as `kid`, set `sub` to the playback id and `aud` to `v`, and add an `exp`. List keys with `GET /v1/streams/signing_keys` and revoke one with `DELETE /v1/streams/signing_keys/{id}`. This is only for accounts with their own video environment. For everyone else the call returns `409 dedicated_environment_required`, because a key shared between customers could sign playback for another customer's streams. Ask your Vabu contact if you need it. ## Errors | Code | HTTP | Meaning | |---|---|---| | `signing_not_configured` | 409 | Playback signing is not set up for the account. Contact Vabu. | | `no_playback_id` | 409 | The stream or asset has no playback id yet. Create one. | | `dedicated_environment_required` | 409 | Signing keys are not available for this account. | | `resource_not_found` | 404 | The stream or asset is not yours. | --- # Stream events Vabu tells your server when a stream goes live, drops or ends, when an asset is ready and when someone's access changes. You get these as [webhooks](https://docs.vabu.live/concepts/webhooks.md), signed and retried like every other event, so your app does not need to poll. Register an endpoint with `POST /v1/webhook_endpoints` and ask for `streams.*` and `access.*`. The envelope, the signature check and the retry schedule are on the [webhooks](https://docs.vabu.live/concepts/webhooks.md) page. Every event type and its fields are in the [event catalogue](https://docs.vabu.live/events/index.md). The events carry the resource as the API shows it, without secrets. The stream key, the upload address and the SRT passphrase are never in an event. `data.previous_attributes` shows the old values of whatever changed. ## Live streams | Event | When | What to do | |---|---|---| | `streams.live_stream.created` | A live stream was created | Store its id next to your venue. | | `streams.live_stream.updated` | Settings, the key, simulcast targets or playback ids changed, or an encoder connected (`status: "connecting"`) | Refresh what you show. | | `streams.live_stream.active` | The encoder is sending video | Show the stream as live and send your "we are live" message. | | `streams.live_stream.disconnected` | The encoder dropped. It can come back within the `reconnect_window`. | Show "we will be right back". Alert the crew if you want to. | | `streams.live_stream.idle` | The broadcast ended | Show the stream as over. Offer the replay when you have one. | | `streams.live_stream.unhealthy` | The network a live stream runs on is struggling or down. `data.object.health` says which and why. | Viewers move to the standby by themselves. Tell the crew if you want to. | | `streams.live_stream.recovered` | That network is healthy again | Clear the alert. | | `streams.live_stream.deleted` | A live stream was deleted | Remove it from your lists. | Use these events for what the page shows. Do not use them to decide who may watch. That is the job of an entitlement and a session. ## Assets, clips and uploads | Event | When | What to do | |---|---|---| | `streams.asset.created` | An asset or a clip was created, or an upload produced one | Record the asset id. | | `streams.asset.ready` | The asset can be played | Show it and tell your customers. | | `streams.asset.errored` | The video could not be processed. `data.object.error` says why. | Ask for a new file. | | `streams.asset.updated` | Metadata, the external id or playback ids changed | Refresh what you show. | | `streams.asset.deleted` | An asset was deleted | Remove it from your lists. | | `streams.clip.created` | A clip was created | Record it. | | `streams.upload.asset_created` | An upload finished and its asset exists | Link the upload to your own record. | ## Access | Event | When | What to do | |---|---|---| | `access.entitlement.granted` | Someone was given access | Update your records. | | `access.entitlement.revoked` | Access was taken away | Update your records. | | `access.entitlement.suspended` and `access.entitlement.resumed` | Access was paused and restored | Update your records. | | `access.entitlement.expired` | The end date passed | Offer a renewal. | | `access.entitlement.extended` | A source moved the end date later | Update your records. | ## Handling events well - An event can arrive more than once and out of order. Store the event `id` and skip repeats. Compare `created_at`, or fetch the resource, when order matters. - Answer with a `2xx` fast and do slow work afterwards. - Check the signature on every delivery. See [verify the signature](https://docs.vabu.live/concepts/webhooks.md#verify-the-signature). - In test mode, events come from your test streams and test entitlements to your test endpoints only. ## Not available yet Events for viewer counts, watch time and quality are on [What's coming](https://docs.vabu.live/whats-coming.md). --- # Tenants and environments A **tenant** is your account and billing entity. Everything you create belongs to exactly one tenant, and no other tenant can read it. ```text Tenant ├── Environments: live, test ├── Members (staff with roles) ├── Organizations (brands, venues, creators) │ └── product resources (events, fanbases, products, streams) ├── Customers (your end users) └── API keys, webhook endpoints, integrations, domains ``` ## Environments Each tenant has a **live** and a **test** environment. Every object carries `livemode`: `true` for live and `false` for test. The two never mix, and the key you use decides which one you are in. Test mode routes payments to a simulator, captures messages and uses test media. See [test mode](https://docs.vabu.live/concepts/test-mode.md). ## How a request finds its tenant Vabu OS checks these in order and uses the first match. 1. A secret or restricted key in `Authorization: Bearer ...`. It carries the tenant, environment and scopes. 2. A publishable key in `Vabu-Publishable-Key`, or the `key` query parameter on public embeds. 3. A user access token. The tenant comes from the token's `tid` claim. If a staff member belongs to several tenants, send `Vabu-Tenant` to choose one. 4. A custom domain, for whitelabel pages. 5. The legacy `X-Tenant-Code` header, kept for existing tenant apps until they move to publishable keys. ## Isolation Tenant isolation is enforced in the application and again in the database, and every new endpoint is tested to prove one tenant's key cannot read or write another tenant's data. You do not need to add tenant filters to your own calls. ## Organizations and customers An **organization** is a brand, venue or creator inside your tenant. Product resources such as events and streams belong to an organization. A **customer** is one of your end users. Whether a person arrived through Vabu ID, your own tokens or a server call, they are a customer in your tenant. You can attach your own ID with [`external_id`](https://docs.vabu.live/concepts/metadata.md). ## Members Members are your staff. Membership is explicit: being signed in does not make anyone a member. Roles control what a member can do in the console and API. --- # Test mode Use test mode to build your integration without real viewers and without a bill. Every tenant has test keys from the first minute, with no approval. Use a `vk_test_`, `rk_test_` or `vpk_test_` key. Objects you create return `"livemode": false` and stay in the test environment. Test and live data never mix. ## What works in test mode | Area | Test mode behaviour | |---|---| | Keys, customers and webhook endpoints | Work as in live mode. Webhooks are delivered to your test endpoints for test events only. | | Access | Entitlements, checks, sessions, device limits and customer tokens all work. Everything you create is test data. | | Streams | Calls run on a simulated network that cannot bill. Assets are ready at once. Live streams stay `idle` and take no video. Playback links look real but do not play. | | Vabu ID | Sign-in routes work with a test publishable key. Test mode never sends a code to a real phone. | | Event browsing | `GET /v1/events` and `GET /v1/events/{event}` work with a test key. | | Other products | Routes for booking, orders, organizers, merch and messaging do not take test keys yet. They answer `403 test_mode_unavailable`. See [which credentials each endpoint takes](https://docs.vabu.live/concepts/keys-and-scopes.md#which-credentials-each-endpoint-takes). | Test mode for payments, with scripted outcomes, and a hosted test stream you can send video to are on [What's coming](https://docs.vabu.live/whats-coming.md). ## A good test run for Streams 1. Create a live stream, grant an entitlement, sign a customer token and open a session. The [quickstart](https://docs.vabu.live/guides/streams/quickstart.md) walks through it. 2. Check what your page does when access is denied, when a second device takes over and when access is revoked. 3. Check that your webhook handler [verifies signatures](https://docs.vabu.live/concepts/webhooks.md#verify-the-signature) and ignores duplicates. 4. To see a real picture, run the same steps with live keys. A live key reaches a real video network. ## Going live 1. Run your whole flow in test mode. 2. Replace `vk_test_` with `vk_live_` in your secret store, and `vpk_test_` with `vpk_live_` in your pages. 3. Make the things that belong to an environment again in live mode: customer token keys, webhook endpoints and any live streams. Create live webhook endpoints and use their new secrets. Live and test data never mix, so nothing you created in test mode carries over. --- # Versioning The major version is in the path: `/v1`. Inside `/v1`, breaking changes ship as **dated versions**, such as `2026-11-01`. ## Choose a version Send it in the `Vabu-Version` header. ```http GET /v1/events Vabu-Version: 2026-11-01 ``` If you leave it out, Vabu OS uses your tenant's default. That default is pinned when you create your first key, so a new release never changes your integration by surprise. You can upgrade it in the console. Responses include `Vabu-Version` so you can see which version answered. Webhook endpoints have their own version, so you can upgrade requests and webhooks separately. ## What is not a breaking change These ship at any time, so write your client to tolerate them: - New fields in responses - New endpoints - New event types - New values for enums that are documented as open - New error codes Ignore fields you do not know. Do not fail on an unknown enum value. ## Breaking changes A breaking change gets a new dated version, a changelog entry, and a transform layer so older versions keep working. Each older version is supported for at least 12 months. ## Spec The OpenAPI file at `/openapi/v1.json` is the source of truth for `/v1`. The [API reference](https://docs.vabu.live/api.md) is rendered from it. --- # Webhooks Webhooks tell your server when something changes, such as an order being paid, so you do not need to poll. Vabu OS sends an HTTPS `POST` with a signed JSON body to each endpoint you register. You manage endpoints at `/v1/webhook_endpoints`. Each endpoint has a URL, the event types it wants (wildcards such as `events.*` work), an API version and a signing secret that starts with `whsec_`. In live mode the URL must be HTTPS and must resolve to a public address. Private, loopback and link-local addresses are blocked. ## Event envelope Every delivery has the same envelope. The event types and their payloads are in the [event catalogue](https://docs.vabu.live/events/index.md). ```json { "id": "evt_01JB4...", "object": "event", "type": "events.order.paid", "api_version": "2026-11-01", "created_at": "2026-11-02T18:30:05Z", "livemode": true, "tenant": "ten_01J...", "subject": "ord_01J...", "data": { "object": { }, "previous_attributes": { } }, "request": { "id": "req_01J...", "idempotency_key": "order-8842" } } ``` | Field | Meaning | |---|---| | `id` | The event ID. The same event always has the same ID, including on retries. | | `type` | `{product}.{resource}.{action}`, in the past tense. | | `subject` | The ID of the resource the event is about. | | `data.object` | The resource as the API returns it. | | `data.previous_attributes` | The old values of fields that changed. | | `request` | The API request that caused the event, when there was one. | Each delivery also has the headers `Vabu-Event-Id`, `Vabu-Event-Type` and `Vabu-Signature`. ## Verify the signature Check every delivery before you trust it. The `Vabu-Signature` header looks like this: ```text Vabu-Signature: t=1762108205,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd ``` `t` is the Unix time of the delivery. `v1` is the hex HMAC-SHA256 of the string `t + "." + body`, keyed with your endpoint secret. The header can hold several `v1` values while you are rolling a secret: accept the request if any one of them matches. To verify: 1. Read the **raw** request body. Do not parse and re-serialise it first. 2. Split the header on `,` and read `t` and each `v1`. 3. Compute the HMAC-SHA256 of `t + "." + body` with your secret, as hex. 4. Compare it with each `v1` using a constant-time comparison. 5. Reject the request if `t` is more than 5 minutes from your clock, so an old request cannot be replayed. ::: code-group ```ts [TypeScript] import { createHmac, timingSafeEqual } from 'node:crypto' export function verifyVabuSignature(rawBody: string, header: string, secret: string, toleranceSeconds = 300): boolean { const parts = header.split(',').map((p) => p.trim().split('=') as [string, string]) const t = parts.find(([k]) => k === 't')?.[1] const signatures = parts.filter(([k]) => k === 'v1').map(([, v]) => v) if (!t || signatures.length === 0) return false if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest() return signatures.some((sig) => { const given = Buffer.from(sig, 'hex') return given.length === expected.length && timingSafeEqual(given, expected) }) } ``` ```php [PHP] $tolerance) { return false; } $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret); foreach ($signatures as $signature) { if (hash_equals($expected, $signature)) { return true; } } return false; } // In your controller: verifyVabuSignature(file_get_contents('php://input'), $_SERVER['HTTP_VABU_SIGNATURE'] ?? '', $secret) ``` ```python [Python] import hashlib import hmac import time def verify_vabu_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool: t = None signatures = [] for part in header.split(","): key, _, value = part.strip().partition("=") if key == "t": t = value elif key == "v1": signatures.append(value) if t is None or not signatures or abs(time.time() - int(t)) > tolerance: return False expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected, sig) for sig in signatures) ``` ```go [Go] package vabu import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "math" "strconv" "strings" "time" ) // VerifySignature checks a Vabu-Signature header against the raw request body. func VerifySignature(rawBody []byte, header, secret string, tolerance time.Duration) bool { var t string var signatures []string for _, part := range strings.Split(header, ",") { key, value, _ := strings.Cut(strings.TrimSpace(part), "=") switch key { case "t": t = value case "v1": signatures = append(signatures, value) } } ts, err := strconv.ParseInt(t, 10, 64) if err != nil || len(signatures) == 0 { return false } if math.Abs(float64(time.Now().Unix()-ts)) > tolerance.Seconds() { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(t + ".")) mac.Write(rawBody) expected := mac.Sum(nil) for _, sig := range signatures { given, err := hex.DecodeString(sig) if err == nil && hmac.Equal(given, expected) { return true } } return false } ``` ::: Return a `2xx` status quickly, and do slow work after you respond. Any other status, a timeout, or a redirect counts as a failure. ## Delivery guarantees - **At least once.** An event can arrive more than once. Store the `Vabu-Event-Id` of each event you handle and skip repeats. - **No ordering.** Events can arrive out of order. Use `created_at` and fetch the current object from the API when order matters. - **Timeout.** Your endpoint has 10 seconds to answer with a `2xx`. - **Redirects** are not followed. ## Retries When a delivery fails, Vabu OS retries with exponential backoff and jitter for up to 3 days. | Attempt | Wait after the previous failure | |---|---| | 1 | Immediate | | 2 | 1 minute | | 3 | 5 minutes | | 4 | 30 minutes | | 5 | 2 hours | | 6 | 5 hours | | 7 | 10 hours | | 8 onward | 12 hours, until 72 hours have passed | If an endpoint fails every delivery for 3 days, Vabu OS disables it, emails your tenant owners and emits `platform.webhook_endpoint.disabled`. Each endpoint has a concurrency limit, 10 by default, so one slow endpoint does not slow others. ## Replay and test - `GET /v1/events` and `GET /v1/events/{id}` list and fetch events for 30 days. - `POST /v1/webhook_endpoints/{id}/replay` re-sends events to an endpoint. Pass a time range or a list of event IDs. Use it after an outage on your side. - `POST /v1/webhook_endpoints/{id}/test` sends a test event. - Every delivery attempt is logged with its status, latency and the first 4 KB of your response, so you can see why one failed. ## Roll a secret Rolling a secret keeps the old one valid for a while. During that time each delivery carries two `v1` values, one for each secret. Because the verification code accepts any matching `v1`, you can deploy the new secret first, and nothing is dropped. --- # Error codes Every error has a `type` and a stable `code`. Branch on `code`. Read [errors](https://docs.vabu.live/concepts/errors.md) for the response shape and the retry rules. Each code below has an anchor, and `doc_url` in an error response links to it. ## Error types | Type | HTTP | When | |---|---|---| | `invalid_request_error` | 400, 404, 409, 422 | Bad input, missing resource, conflict, validation | | `authentication_error` | 401 | Missing or bad key or token | | `permission_error` | 403 | Scope, role, or product not enabled | | `rate_limit_error` | 429 | Limit hit | | `idempotency_error` | 409 | Key reused with a different body, or still in flight | | `provider_error` | 502 | An upstream provider failed | | `api_error` | 500, 503 | Our fault | ## Codes ### idempotency_key_reused Type `idempotency_error`, HTTP 409. You sent an `Idempotency-Key` you already used with a different method, path or body. Use a new key for a new action. See [idempotency](https://docs.vabu.live/concepts/idempotency.md). ### idempotency_key_in_use Type `idempotency_error`, HTTP 409. A request with this key is still running. Wait a moment, then retry with the same key. ### product_not_enabled Type `permission_error`, HTTP 403. The product behind this route is switched off for your tenant. Enable it for the environment you are calling. ## Codes by product Each product has its own codes. New codes can appear at any time. If you meet a code you do not know, treat it as its `type`. ### Platform, Access and Streams | Code | HTTP | When | |---|---|---| | `insufficient_permissions` | 403 | The key or user cannot call this endpoint. A publishable key on a secret-only route gives this. | | `test_mode_unavailable` | 403 | A test key called an endpoint that has no test mode yet. | | `access_denied` | 403 | The customer has no active entitlement. `error.details.reason` says why. | | `device_limit_reached` | 403 | The entitlement's `max_devices` is used up. | | `view_limit_reached` | 403 | The entitlement's `max_views` is used up. | | `session_ended` | 403 | A heartbeat for a session that is over. `error.details.reason` is `replaced_by_newer_session`, `missed_heartbeats`, `entitlement_inactive` or `client`. | | `concurrency_limit_reached` | 409 | Another session is open and the policy is `reject_new`. | | `entitlement_revoked` | 409 | You repeated a grant that was revoked. Grant again with a new `source_ref`. | | `invalid_state` | 409 | The entitlement cannot be suspended or resumed from its current state. | | `external_id_taken` | 409 | Another object of this type already has this `external_id`. | | `invalid_customer_token` | 401 | A customer token was rejected. `error.details.reason` says why. See [customer tokens](https://docs.vabu.live/concepts/customer-tokens.md#errors). | | `stream_disabled` | 409 | The live stream is switched off. | | `asset_not_ready` | 409 | The asset is still processing. | | `no_playback_id` | 409 | The stream or asset has no playback id. | | `signing_not_configured` | 409 | Playback signing is not set up for the account. | | `dedicated_environment_required` | 409 | Signing keys need an account with its own video environment. | | `resource_not_found` | 404 | The stream or asset is not yours. | | `provider_rejected` | 422 | The video network refused the request. `message` says why. | | `provider_error` | 502 | The video network could not be reached. Retry. | | `validation_failed` | 422 | The request is invalid. `error.details` lists each field. | Other products add their codes as they reach the API reference. --- # Event catalogue Event types are named `{product}.{resource}.{action}` in the past tense. Each one is delivered to your webhook endpoints in the [event envelope](https://docs.vabu.live/concepts/webhooks.md#event-envelope) and is listed by `GET /v1/events` for 30 days. ## access.entitlement.expired | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | | `object` | any | yes | | | `customer` | string or null | no | | | `contact_hash` | string or null | no | | | `resource` | object | yes | | | `resource.type` | string | yes | | | `resource.id` | string | yes | | | `resource.variant` | string or null | yes | | | `source` | `payment`, `tenant_grant`, `comp`, `subscription`, `membership_tier`, `ticket`, `promotion` | yes | | | `source_ref` | string or null | no | | | `starts_at` | string or null (date-time) | no | | | `ends_at` | string or null (date-time) | no | | | `limits` | object | yes | | | `limits.max_devices` | integer | no | | | `limits.max_concurrent_sessions` | integer | no | | | `limits.max_views` | integer | no | | | `limits.allowed_countries` | array of string | no | | | `limits.allowed_domains` | array of string | no | | | `limits.concurrency_policy` | `reject_new`, `replace_oldest` | no | | | `status` | `active`, `suspended`, `revoked`, `expired` | yes | | | `external_id` | string or null | no | | | `metadata` | object | no | | | `livemode` | boolean | yes | | | `created_at` | string or null (date-time) | no | | ## access.entitlement.extended | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | | `object` | any | yes | | | `customer` | string or null | no | | | `contact_hash` | string or null | no | | | `resource` | object | yes | | | `resource.type` | string | yes | | | `resource.id` | string | yes | | | `resource.variant` | string or null | yes | | | `source` | `payment`, `tenant_grant`, `comp`, `subscription`, `membership_tier`, `ticket`, `promotion` | yes | | | `source_ref` | string or null | no | | | `starts_at` | string or null (date-time) | no | | | `ends_at` | string or null (date-time) | no | | | `limits` | object | yes | | | `limits.max_devices` | integer | no | | | `limits.max_concurrent_sessions` | integer | no | | | `limits.max_views` | integer | no | | | `limits.allowed_countries` | array of string | no | | | `limits.allowed_domains` | array of string | no | | | `limits.concurrency_policy` | `reject_new`, `replace_oldest` | no | | | `status` | `active`, `suspended`, `revoked`, `expired` | yes | | | `external_id` | string or null | no | | | `metadata` | object | no | | | `livemode` | boolean | yes | | | `created_at` | string or null (date-time) | no | | ## access.entitlement.granted | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | | `object` | any | yes | | | `customer` | string or null | no | | | `contact_hash` | string or null | no | | | `resource` | object | yes | | | `resource.type` | string | yes | | | `resource.id` | string | yes | | | `resource.variant` | string or null | yes | | | `source` | `payment`, `tenant_grant`, `comp`, `subscription`, `membership_tier`, `ticket`, `promotion` | yes | | | `source_ref` | string or null | no | | | `starts_at` | string or null (date-time) | no | | | `ends_at` | string or null (date-time) | no | | | `limits` | object | yes | | | `limits.max_devices` | integer | no | | | `limits.max_concurrent_sessions` | integer | no | | | `limits.max_views` | integer | no | | | `limits.allowed_countries` | array of string | no | | | `limits.allowed_domains` | array of string | no | | | `limits.concurrency_policy` | `reject_new`, `replace_oldest` | no | | | `status` | `active`, `suspended`, `revoked`, `expired` | yes | | | `external_id` | string or null | no | | | `metadata` | object | no | | | `livemode` | boolean | yes | | | `created_at` | string or null (date-time) | no | | ## access.entitlement.resumed | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | | `object` | any | yes | | | `customer` | string or null | no | | | `contact_hash` | string or null | no | | | `resource` | object | yes | | | `resource.type` | string | yes | | | `resource.id` | string | yes | | | `resource.variant` | string or null | yes | | | `source` | `payment`, `tenant_grant`, `comp`, `subscription`, `membership_tier`, `ticket`, `promotion` | yes | | | `source_ref` | string or null | no | | | `starts_at` | string or null (date-time) | no | | | `ends_at` | string or null (date-time) | no | | | `limits` | object | yes | | | `limits.max_devices` | integer | no | | | `limits.max_concurrent_sessions` | integer | no | | | `limits.max_views` | integer | no | | | `limits.allowed_countries` | array of string | no | | | `limits.allowed_domains` | array of string | no | | | `limits.concurrency_policy` | `reject_new`, `replace_oldest` | no | | | `status` | `active`, `suspended`, `revoked`, `expired` | yes | | | `external_id` | string or null | no | | | `metadata` | object | no | | | `livemode` | boolean | yes | | | `created_at` | string or null (date-time) | no | | ## access.entitlement.revoked | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | | `object` | any | yes | | | `customer` | string or null | no | | | `contact_hash` | string or null | no | | | `resource` | object | yes | | | `resource.type` | string | yes | | | `resource.id` | string | yes | | | `resource.variant` | string or null | yes | | | `source` | `payment`, `tenant_grant`, `comp`, `subscription`, `membership_tier`, `ticket`, `promotion` | yes | | | `source_ref` | string or null | no | | | `starts_at` | string or null (date-time) | no | | | `ends_at` | string or null (date-time) | no | | | `limits` | object | yes | | | `limits.max_devices` | integer | no | | | `limits.max_concurrent_sessions` | integer | no | | | `limits.max_views` | integer | no | | | `limits.allowed_countries` | array of string | no | | | `limits.allowed_domains` | array of string | no | | | `limits.concurrency_policy` | `reject_new`, `replace_oldest` | no | | | `status` | `active`, `suspended`, `revoked`, `expired` | yes | | | `external_id` | string or null | no | | | `metadata` | object | no | | | `livemode` | boolean | yes | | | `created_at` | string or null (date-time) | no | | ## access.entitlement.suspended | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | | `object` | any | yes | | | `customer` | string or null | no | | | `contact_hash` | string or null | no | | | `resource` | object | yes | | | `resource.type` | string | yes | | | `resource.id` | string | yes | | | `resource.variant` | string or null | yes | | | `source` | `payment`, `tenant_grant`, `comp`, `subscription`, `membership_tier`, `ticket`, `promotion` | yes | | | `source_ref` | string or null | no | | | `starts_at` | string or null (date-time) | no | | | `ends_at` | string or null (date-time) | no | | | `limits` | object | yes | | | `limits.max_devices` | integer | no | | | `limits.max_concurrent_sessions` | integer | no | | | `limits.max_views` | integer | no | | | `limits.allowed_countries` | array of string | no | | | `limits.allowed_domains` | array of string | no | | | `limits.concurrency_policy` | `reject_new`, `replace_oldest` | no | | | `status` | `active`, `suspended`, `revoked`, `expired` | yes | | | `external_id` | string or null | no | | | `metadata` | object | no | | | `livemode` | boolean | yes | | | `created_at` | string or null (date-time) | no | | ## events.order.paid | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `object` | any | yes | | | `code` | string | yes | | | `status` | `paid` | yes | | | `event_id` | string (uuid) | yes | | | `ticket_id` | string or null (uuid) | no | | | `quantity` | integer | yes | | | `amount` | integer | yes | Cents. | | `currency` | string | yes | | | `channel` | string | yes | | | `checkout_id` | string or null | no | | | `promoter_id` | string or null | no | | ## platform.webhook_endpoint.created | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `object` | any | yes | | | `url` | string | yes | | | `description` | string or null | no | | | `enabled_events` | array of string | yes | | | `api_version` | string or null | no | | | `status` | `enabled`, `disabled` | yes | | | `disabled_reason` | string or null | no | | | `livemode` | boolean | yes | | | `metadata` | object | no | | | `secret_previous_expires_at` | string or null (date-time) | no | | | `created_at` | string or null (date-time) | no | | ## platform.webhook_endpoint.deleted | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `object` | any | yes | | | `url` | string | yes | | | `description` | string or null | no | | | `enabled_events` | array of string | yes | | | `api_version` | string or null | no | | | `status` | `enabled`, `disabled` | yes | | | `disabled_reason` | string or null | no | | | `livemode` | boolean | yes | | | `metadata` | object | no | | | `secret_previous_expires_at` | string or null (date-time) | no | | | `created_at` | string or null (date-time) | no | | ## platform.webhook_endpoint.disabled | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `object` | any | yes | | | `url` | string | yes | | | `description` | string or null | no | | | `enabled_events` | array of string | yes | | | `api_version` | string or null | no | | | `status` | `enabled`, `disabled` | yes | | | `disabled_reason` | string or null | no | | | `livemode` | boolean | yes | | | `metadata` | object | no | | | `secret_previous_expires_at` | string or null (date-time) | no | | | `created_at` | string or null (date-time) | no | | ## platform.webhook_endpoint.secret_rolled | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `object` | any | yes | | | `url` | string | yes | | | `description` | string or null | no | | | `enabled_events` | array of string | yes | | | `api_version` | string or null | no | | | `status` | `enabled`, `disabled` | yes | | | `disabled_reason` | string or null | no | | | `livemode` | boolean | yes | | | `metadata` | object | no | | | `secret_previous_expires_at` | string or null (date-time) | no | | | `created_at` | string or null (date-time) | no | | ## platform.webhook_endpoint.updated | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `object` | any | yes | | | `url` | string | yes | | | `description` | string or null | no | | | `enabled_events` | array of string | yes | | | `api_version` | string or null | no | | | `status` | `enabled`, `disabled` | yes | | | `disabled_reason` | string or null | no | | | `livemode` | boolean | yes | | | `metadata` | object | no | | | `secret_previous_expires_at` | string or null (date-time) | no | | | `created_at` | string or null (date-time) | no | | ## streams.asset.created | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | | `object` | any | yes | | | `provider` | string | yes | | | `source` | `upload`, `url`, `clip`, `recording` | yes | | | `status` | `preparing`, `ready`, `errored` | yes | | | `duration` | number or null | no | | | `audio_only` | boolean | yes | | | `error` | string or null | no | | | `playback_ids` | array of object | yes | | | `playback_ids[].id` | string | yes | | | `playback_ids[].object` | any | yes | | | `playback_ids[].playback_id` | string | yes | | | `playback_ids[].policy` | `public`, `signed` | yes | | | `external_id` | string or null | no | | | `metadata` | object | yes | | | `livemode` | boolean | yes | | | `created_at` | string (date-time) | no | | ## streams.asset.deleted | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | | `object` | any | yes | | | `provider` | string | yes | | | `source` | `upload`, `url`, `clip`, `recording` | yes | | | `status` | `preparing`, `ready`, `errored` | yes | | | `duration` | number or null | no | | | `audio_only` | boolean | yes | | | `error` | string or null | no | | | `playback_ids` | array of object | yes | | | `playback_ids[].id` | string | yes | | | `playback_ids[].object` | any | yes | | | `playback_ids[].playback_id` | string | yes | | | `playback_ids[].policy` | `public`, `signed` | yes | | | `external_id` | string or null | no | | | `metadata` | object | yes | | | `livemode` | boolean | yes | | | `created_at` | string (date-time) | no | | ## streams.asset.errored | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | | `object` | any | yes | | | `provider` | string | yes | | | `source` | `upload`, `url`, `clip`, `recording` | yes | | | `status` | `preparing`, `ready`, `errored` | yes | | | `duration` | number or null | no | | | `audio_only` | boolean | yes | | | `error` | string or null | no | | | `playback_ids` | array of object | yes | | | `playback_ids[].id` | string | yes | | | `playback_ids[].object` | any | yes | | | `playback_ids[].playback_id` | string | yes | | | `playback_ids[].policy` | `public`, `signed` | yes | | | `external_id` | string or null | no | | | `metadata` | object | yes | | | `livemode` | boolean | yes | | | `created_at` | string (date-time) | no | | ## streams.asset.ready | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | | `object` | any | yes | | | `provider` | string | yes | | | `source` | `upload`, `url`, `clip`, `recording` | yes | | | `status` | `preparing`, `ready`, `errored` | yes | | | `duration` | number or null | no | | | `audio_only` | boolean | yes | | | `error` | string or null | no | | | `playback_ids` | array of object | yes | | | `playback_ids[].id` | string | yes | | | `playback_ids[].object` | any | yes | | | `playback_ids[].playback_id` | string | yes | | | `playback_ids[].policy` | `public`, `signed` | yes | | | `external_id` | string or null | no | | | `metadata` | object | yes | | | `livemode` | boolean | yes | | | `created_at` | string (date-time) | no | | ## streams.asset.updated | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | | `object` | any | yes | | | `provider` | string | yes | | | `source` | `upload`, `url`, `clip`, `recording` | yes | | | `status` | `preparing`, `ready`, `errored` | yes | | | `duration` | number or null | no | | | `audio_only` | boolean | yes | | | `error` | string or null | no | | | `playback_ids` | array of object | yes | | | `playback_ids[].id` | string | yes | | | `playback_ids[].object` | any | yes | | | `playback_ids[].playback_id` | string | yes | | | `playback_ids[].policy` | `public`, `signed` | yes | | | `external_id` | string or null | no | | | `metadata` | object | yes | | | `livemode` | boolean | yes | | | `created_at` | string (date-time) | no | | ## streams.clip.created | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | | `object` | any | yes | | | `source_asset` | string | yes | | | `asset` | string | yes | | | `start_time` | number | yes | | | `end_time` | number | yes | | | `livemode` | boolean | yes | | | `created_at` | string (date-time) | no | | ## streams.content.purchased | Field | Type | Required | Description | |---|---|---|---| | `id` | string (uuid) | yes | | | `object` | any | yes | | | `content_id` | string (uuid) | yes | | | `content_item_id` | string or null (uuid) | no | | | `booking_id` | string (uuid) | yes | | | `purchase_type` | string | yes | | | `amount` | integer | yes | Cents. | | `currency` | string | yes | | | `access_expires_at` | string or null (date-time) | no | | ## streams.live_stream.active | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | | `object` | any | yes | | | `provider` | string | yes | | | `status` | `idle`, `connecting`, `active`, `disconnected`, `disabled` | yes | | | `latency_mode` | `low`, `standard` | yes | | | `reconnect_window` | integer | yes | | | `audio_only` | boolean | yes | | | `ingest` | object | yes | | | `playback_ids` | array of object | yes | | | `playback_ids[].id` | string | yes | | | `playback_ids[].object` | any | yes | | | `playback_ids[].playback_id` | string | yes | | | `playback_ids[].policy` | `public`, `signed` | yes | | | `simulcast_targets` | array of object | yes | | | `simulcast_targets[].id` | string | yes | | | `simulcast_targets[].object` | any | yes | | | `simulcast_targets[].url` | string | yes | | | `simulcast_targets[].status` | string | yes | | | `external_id` | string or null | no | | | `metadata` | object | yes | | | `livemode` | boolean | yes | | | `created_at` | string (date-time) | no | | ## streams.live_stream.created | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | | `object` | any | yes | | | `provider` | string | yes | | | `status` | `idle`, `connecting`, `active`, `disconnected`, `disabled` | yes | | | `latency_mode` | `low`, `standard` | yes | | | `reconnect_window` | integer | yes | | | `audio_only` | boolean | yes | | | `ingest` | object | yes | | | `playback_ids` | array of object | yes | | | `playback_ids[].id` | string | yes | | | `playback_ids[].object` | any | yes | | | `playback_ids[].playback_id` | string | yes | | | `playback_ids[].policy` | `public`, `signed` | yes | | | `simulcast_targets` | array of object | yes | | | `simulcast_targets[].id` | string | yes | | | `simulcast_targets[].object` | any | yes | | | `simulcast_targets[].url` | string | yes | | | `simulcast_targets[].status` | string | yes | | | `external_id` | string or null | no | | | `metadata` | object | yes | | | `livemode` | boolean | yes | | | `created_at` | string (date-time) | no | | ## streams.live_stream.deleted | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | | `object` | any | yes | | | `provider` | string | yes | | | `status` | `idle`, `connecting`, `active`, `disconnected`, `disabled` | yes | | | `latency_mode` | `low`, `standard` | yes | | | `reconnect_window` | integer | yes | | | `audio_only` | boolean | yes | | | `ingest` | object | yes | | | `playback_ids` | array of object | yes | | | `playback_ids[].id` | string | yes | | | `playback_ids[].object` | any | yes | | | `playback_ids[].playback_id` | string | yes | | | `playback_ids[].policy` | `public`, `signed` | yes | | | `simulcast_targets` | array of object | yes | | | `simulcast_targets[].id` | string | yes | | | `simulcast_targets[].object` | any | yes | | | `simulcast_targets[].url` | string | yes | | | `simulcast_targets[].status` | string | yes | | | `external_id` | string or null | no | | | `metadata` | object | yes | | | `livemode` | boolean | yes | | | `created_at` | string (date-time) | no | | ## streams.live_stream.disconnected | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | | `object` | any | yes | | | `provider` | string | yes | | | `status` | `idle`, `connecting`, `active`, `disconnected`, `disabled` | yes | | | `latency_mode` | `low`, `standard` | yes | | | `reconnect_window` | integer | yes | | | `audio_only` | boolean | yes | | | `ingest` | object | yes | | | `playback_ids` | array of object | yes | | | `playback_ids[].id` | string | yes | | | `playback_ids[].object` | any | yes | | | `playback_ids[].playback_id` | string | yes | | | `playback_ids[].policy` | `public`, `signed` | yes | | | `simulcast_targets` | array of object | yes | | | `simulcast_targets[].id` | string | yes | | | `simulcast_targets[].object` | any | yes | | | `simulcast_targets[].url` | string | yes | | | `simulcast_targets[].status` | string | yes | | | `external_id` | string or null | no | | | `metadata` | object | yes | | | `livemode` | boolean | yes | | | `created_at` | string (date-time) | no | | ## streams.live_stream.idle | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | | `object` | any | yes | | | `provider` | string | yes | | | `status` | `idle`, `connecting`, `active`, `disconnected`, `disabled` | yes | | | `latency_mode` | `low`, `standard` | yes | | | `reconnect_window` | integer | yes | | | `audio_only` | boolean | yes | | | `ingest` | object | yes | | | `playback_ids` | array of object | yes | | | `playback_ids[].id` | string | yes | | | `playback_ids[].object` | any | yes | | | `playback_ids[].playback_id` | string | yes | | | `playback_ids[].policy` | `public`, `signed` | yes | | | `simulcast_targets` | array of object | yes | | | `simulcast_targets[].id` | string | yes | | | `simulcast_targets[].object` | any | yes | | | `simulcast_targets[].url` | string | yes | | | `simulcast_targets[].status` | string | yes | | | `external_id` | string or null | no | | | `metadata` | object | yes | | | `livemode` | boolean | yes | | | `created_at` | string (date-time) | no | | ## streams.live_stream.recovered | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | | `object` | any | yes | | | `provider` | string | yes | | | `status` | `idle`, `connecting`, `active`, `disconnected`, `disabled` | yes | | | `latency_mode` | `low`, `standard` | yes | | | `reconnect_window` | integer | yes | | | `audio_only` | boolean | yes | | | `ingest` | object | yes | | | `playback_ids` | array of object | yes | | | `playback_ids[].id` | string | yes | | | `playback_ids[].object` | any | yes | | | `playback_ids[].playback_id` | string | yes | | | `playback_ids[].policy` | `public`, `signed` | yes | | | `simulcast_targets` | array of object | yes | | | `simulcast_targets[].id` | string | yes | | | `simulcast_targets[].object` | any | yes | | | `simulcast_targets[].url` | string | yes | | | `simulcast_targets[].status` | string | yes | | | `external_id` | string or null | no | | | `metadata` | object | yes | | | `livemode` | boolean | yes | | | `created_at` | string (date-time) | no | | | `health` | object | yes | | | `health.status` | `healthy`, `degraded`, `down` | yes | | | `health.plane` | string | yes | | | `health.reason` | string or null | yes | | | `health.since` | string (date-time) | yes | | | `standby` | `none`, `auto`, `mux`, `cloudflare_stream` | yes | | | `standby_state` | `none`, `ready`, `failed` | yes | | ## streams.live_stream.unhealthy | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | | `object` | any | yes | | | `provider` | string | yes | | | `status` | `idle`, `connecting`, `active`, `disconnected`, `disabled` | yes | | | `latency_mode` | `low`, `standard` | yes | | | `reconnect_window` | integer | yes | | | `audio_only` | boolean | yes | | | `ingest` | object | yes | | | `playback_ids` | array of object | yes | | | `playback_ids[].id` | string | yes | | | `playback_ids[].object` | any | yes | | | `playback_ids[].playback_id` | string | yes | | | `playback_ids[].policy` | `public`, `signed` | yes | | | `simulcast_targets` | array of object | yes | | | `simulcast_targets[].id` | string | yes | | | `simulcast_targets[].object` | any | yes | | | `simulcast_targets[].url` | string | yes | | | `simulcast_targets[].status` | string | yes | | | `external_id` | string or null | no | | | `metadata` | object | yes | | | `livemode` | boolean | yes | | | `created_at` | string (date-time) | no | | | `health` | object | yes | | | `health.status` | `healthy`, `degraded`, `down` | yes | | | `health.plane` | string | yes | | | `health.reason` | string or null | yes | | | `health.since` | string (date-time) | yes | | | `standby` | `none`, `auto`, `mux`, `cloudflare_stream` | yes | | | `standby_state` | `none`, `ready`, `failed` | yes | | ## streams.live_stream.updated | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | | `object` | any | yes | | | `provider` | string | yes | | | `status` | `idle`, `connecting`, `active`, `disconnected`, `disabled` | yes | | | `latency_mode` | `low`, `standard` | yes | | | `reconnect_window` | integer | yes | | | `audio_only` | boolean | yes | | | `ingest` | object | yes | | | `playback_ids` | array of object | yes | | | `playback_ids[].id` | string | yes | | | `playback_ids[].object` | any | yes | | | `playback_ids[].playback_id` | string | yes | | | `playback_ids[].policy` | `public`, `signed` | yes | | | `simulcast_targets` | array of object | yes | | | `simulcast_targets[].id` | string | yes | | | `simulcast_targets[].object` | any | yes | | | `simulcast_targets[].url` | string | yes | | | `simulcast_targets[].status` | string | yes | | | `external_id` | string or null | no | | | `metadata` | object | yes | | | `livemode` | boolean | yes | | | `created_at` | string (date-time) | no | | ## streams.moment.cancelled Sent once, the first time a moment is cancelled. Cancelling it again sends nothing. | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | | `object` | any | yes | | | `live_stream` | string | yes | | | `type` | `shoutout`, `request`, `dedication`, `poll`, `product`, `sponsor`, `highlight`, `custom` | yes | | | `label` | string | yes | | | `payload` | object or null | yes | | | `customer` | string or null | yes | The tenant's own reference for the fan behind the moment. Private to the tenant: it is in webhooks, never in the viewer feed. | | `amount` | integer or null | yes | Cents. | | `currency` | string or null | yes | | | `occurred_at` | string (date-time) | yes | The server's clock to the millisecond. A player shows the moment when the playhead's program date time reaches it. | | `display_ms` | integer | yes | | | `visibility` | `public`, `private` | yes | | | `clip_before_s` | integer or null | no | | | `clip_after_s` | integer or null | no | | | `status` | any | yes | | | `cancelled_at` | string or null (date-time) | yes | | | `metadata` | object | yes | | | `livemode` | boolean | yes | | | `created_at` | string (date-time) | yes | | ## streams.moment.clip_failed Sent once when no clip could be made for a moment. clip_error says why. The moment itself is unaffected. | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | | `object` | any | yes | | | `live_stream` | string | yes | | | `type` | `shoutout`, `request`, `dedication`, `poll`, `product`, `sponsor`, `highlight`, `custom` | yes | | | `label` | string | yes | | | `payload` | object or null | yes | | | `customer` | string or null | yes | The tenant's own reference for the fan behind the moment. Private to the tenant: it is in webhooks, never in the viewer feed. | | `amount` | integer or null | yes | Cents. | | `currency` | string or null | yes | | | `occurred_at` | string (date-time) | yes | The server's clock to the millisecond. A player shows the moment when the playhead's program date time reaches it. | | `display_ms` | integer | yes | | | `visibility` | `public`, `private` | yes | | | `clip_before_s` | integer or null | no | | | `clip_after_s` | integer or null | no | | | `status` | `active`, `cancelled` | yes | | | `cancelled_at` | string or null (date-time) | yes | | | `metadata` | object | yes | | | `livemode` | boolean | yes | | | `created_at` | string (date-time) | yes | | | `clip_status` | any | yes | | | `clip_error` | string or null | yes | Why the clip failed: no_recording, recording_not_ready, recording_errored, outside_recording, clip_errored or live_stream_gone. | | `clip` | string or null | yes | | | `clip_asset` | string or null | yes | The clip as an asset: play it, or sell it. The fan is in customer. | ## streams.moment.clip_ready Sent once when the clip cut around a moment can be played. Send the fan their clip: customer is your own reference for them, clip_asset is the clip. | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | | `object` | any | yes | | | `live_stream` | string | yes | | | `type` | `shoutout`, `request`, `dedication`, `poll`, `product`, `sponsor`, `highlight`, `custom` | yes | | | `label` | string | yes | | | `payload` | object or null | yes | | | `customer` | string or null | yes | The tenant's own reference for the fan behind the moment. Private to the tenant: it is in webhooks, never in the viewer feed. | | `amount` | integer or null | yes | Cents. | | `currency` | string or null | yes | | | `occurred_at` | string (date-time) | yes | The server's clock to the millisecond. A player shows the moment when the playhead's program date time reaches it. | | `display_ms` | integer | yes | | | `visibility` | `public`, `private` | yes | | | `clip_before_s` | integer or null | no | | | `clip_after_s` | integer or null | no | | | `status` | `active`, `cancelled` | yes | | | `cancelled_at` | string or null (date-time) | yes | | | `metadata` | object | yes | | | `livemode` | boolean | yes | | | `created_at` | string (date-time) | yes | | | `clip_status` | any | yes | | | `clip_error` | string or null | yes | Why the clip failed: no_recording, recording_not_ready, recording_errored, outside_recording, clip_errored or live_stream_gone. | | `clip` | string or null | yes | | | `clip_asset` | string or null | yes | The clip as an asset: play it, or sell it. The fan is in customer. | ## streams.moment.created Something to show viewers in step with the video, such as a paid shoutout. The event carries the customer and amount; the viewer feed never does. | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | | `object` | any | yes | | | `live_stream` | string | yes | | | `type` | `shoutout`, `request`, `dedication`, `poll`, `product`, `sponsor`, `highlight`, `custom` | yes | | | `label` | string | yes | | | `payload` | object or null | yes | | | `customer` | string or null | yes | The tenant's own reference for the fan behind the moment. Private to the tenant: it is in webhooks, never in the viewer feed. | | `amount` | integer or null | yes | Cents. | | `currency` | string or null | yes | | | `occurred_at` | string (date-time) | yes | The server's clock to the millisecond. A player shows the moment when the playhead's program date time reaches it. | | `display_ms` | integer | yes | | | `visibility` | `public`, `private` | yes | | | `clip_before_s` | integer or null | no | | | `clip_after_s` | integer or null | no | | | `status` | any | yes | | | `cancelled_at` | string or null (date-time) | yes | | | `metadata` | object | yes | | | `livemode` | boolean | yes | | | `created_at` | string (date-time) | yes | | ## streams.upload.asset_created | Field | Type | Required | Description | |---|---|---|---| | `id` | string | yes | | | `object` | any | yes | | | `provider` | string | yes | | | `status` | string | yes | | | `cors_origin` | string or null | no | | | `asset` | string or null | no | | | `expires_at` | string (date-time) | no | | | `livemode` | boolean | yes | | | `created_at` | string (date-time) | no | | --- # Environments and keys Your tenant has two environments: **test** and **live**. They never share data. A test key cannot read a live object, and a live key cannot read a test object. | | Test | Live | |---|---|---| | Key prefix | `vk_test_`, `rk_test_`, `vpk_test_` | `vk_live_`, `rk_live_`, `vpk_live_` | | Streams | A simulated network, no video and no bill | A real video network | | Payments and messages | Not available with test keys yet | Real checkout and real messages | | Objects return | `"livemode": false` | `"livemode": true` | Test keys are available from the first minute, with no approval. See [test mode](https://docs.vabu.live/concepts/test-mode.md) for what works with test keys today. ## Key types | Type | Prefix | Where to use it | |---|---|---| | Secret | `vk_live_`, `vk_test_` | Your server. Full access to the products you have enabled. | | Restricted | `rk_live_`, `rk_test_` | Your server, with only the scopes you pick, for example `streams:write events:read`. | | Publishable | `vpk_live_`, `vpk_test_` | Browsers and apps. Starts a checkout or a playback session and sends telemetry, nothing else. | ### Secret and restricted keys Send them as a bearer token. ```http Authorization: Bearer vk_test_... ``` Prefer restricted keys for each service you run, so a leaked key can do less. Scopes are listed in [keys and scopes](https://docs.vabu.live/concepts/keys-and-scopes.md). ### Publishable keys Send them in a header, or in the `key` query parameter on public embeds. ```http Vabu-Publishable-Key: vpk_test_... ``` A publishable key is safe to ship in client code. It only works on endpoints marked as publishable in the [API reference](https://docs.vabu.live/api.md). ## Handle keys safely - The full key is shown **once**, when you create it. Vabu OS stores only a hash and an 8-character prefix, so it cannot show the key again. - Store keys in your secret manager or environment variables. Do not commit them. - Roll a key to replace it. The old key keeps working until the expiry you choose, immediately or up to 7 days, so you can deploy the new one first. - If a key leaks, revoke it from the console or the API. Revoking takes effect right away, and your tenant owners get an email. - A key can have an expiry date and an IP allowlist. --- # Make your first request List the first three events in your test environment. Set your key as an environment variable, then run a snippet. Requests go to `https://api.vabu.live/v1`. ```sh export VABU_API_KEY="vk_test_..." ``` Each request sends: | Header | Value | Required | |---|---|---| | `Authorization` | `Bearer ` | Yes | | `Vabu-Version` | A dated version such as `2026-11-01`. Leave it out to use your tenant's pinned version. | No | | `Idempotency-Key` | A unique string, on `POST`, `PATCH` and `DELETE`. See [idempotency](https://docs.vabu.live/concepts/idempotency.md). | On requests that move money | ::: code-group ```sh [curl] curl "https://api.vabu.live/v1/events?limit=3" \ -H "Authorization: Bearer $VABU_API_KEY" ``` ```ts [TypeScript] const res = await fetch('https://api.vabu.live/v1/events?limit=3', { headers: { Authorization: `Bearer ${process.env.VABU_API_KEY}` }, }) const body = await res.json() if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message} (${body.error.request_id})`) for (const event of body.data) console.log(event.id) ``` ```php [PHP] true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('VABU_API_KEY')], ]); $body = json_decode(curl_exec($ch), true); $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); if ($status >= 400) { throw new RuntimeException($body['error']['code'] . ': ' . $body['error']['message']); } foreach ($body['data'] as $event) { echo $event['id'], PHP_EOL; } ``` ```python [Python] import os import requests res = requests.get( "https://api.vabu.live/v1/events", params={"limit": 3}, headers={"Authorization": f"Bearer {os.environ['VABU_API_KEY']}"}, timeout=30, ) body = res.json() if not res.ok: raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}") for event in body["data"]: print(event["id"]) ``` ```go [Go] package main import ( "encoding/json" "fmt" "net/http" "os" ) func main() { req, _ := http.NewRequest("GET", "https://api.vabu.live/v1/events?limit=3", nil) req.Header.Set("Authorization", "Bearer "+os.Getenv("VABU_API_KEY")) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } defer res.Body.Close() var body struct { Data []struct{ ID string `json:"id"` } `json:"data"` Error *struct{ Code, Message string } `json:"error"` } json.NewDecoder(res.Body).Decode(&body) if body.Error != nil { panic(body.Error.Code + ": " + body.Error.Message) } for _, e := range body.Data { fmt.Println(e.ID) } } ``` ::: A successful response looks like this: ```json { "data": [ { "id": "evt_01JB3K...", "object": "event", "livemode": false, "created_at": "2026-11-02T18:30:05Z" } ], "meta": { "pagination": { "limit": 3, "has_more": true, "next_cursor": "c_eyJ...", "prev_cursor": null } } } ``` If you get a `401`, check that the key starts with `vk_test_` and that you copied all of it. For anything else, see [read responses and errors](https://docs.vabu.live/get-started/responses-and-errors.md). --- # Get started You can make a working request in a few minutes, without moving any money. Do these three things in order. 1. **[Get a test key](https://docs.vabu.live/get-started/environments-and-keys.md).** Every tenant has test keys from the start. A test key can only touch test data. 2. **[Make your first request](https://docs.vabu.live/get-started/first-request.md).** Copy a snippet in curl, TypeScript, PHP, Python or Go, and list your events. 3. **[Read the response](https://docs.vabu.live/get-started/responses-and-errors.md).** Every response uses the same envelope, and every error has the same shape. When the request works, pick a product. The [products](https://docs.vabu.live/products/index.md) page lists all nine. If you want to stream video, go straight to the [Streams quickstart](https://docs.vabu.live/guides/streams/quickstart.md). Then read the [concepts](https://docs.vabu.live/concepts/integration-modes.md) that apply to how you plan to integrate, and switch to a live key. ## What you need - A Vabu OS tenant. A tenant is your account. - A secret key that starts with `vk_test_`. - The API host, `https://api.vabu.live`. ::: warning Keep secret keys on your server A secret key can read and write everything it is scoped to. Do not put one in a browser app or a mobile app. Use a [publishable key](https://docs.vabu.live/get-started/environments-and-keys.md#publishable-keys) there. ::: --- # Read responses and errors ## A single object ```json { "data": { "id": "evt_01JB...", "object": "event", "livemode": false, "created_at": "...", "updated_at": "..." } } ``` Every object has `id`, `object` (its type name), `livemode`, `created_at` and `updated_at`. Field names are `snake_case` and timestamps are ISO 8601 in UTC. ## A list ```json { "data": [ ], "meta": { "pagination": { "limit": 20, "has_more": true, "next_cursor": "c_eyJ...", "prev_cursor": null } } } ``` Pass `next_cursor` back as `cursor` to get the next page. See [pagination](https://docs.vabu.live/concepts/pagination.md). ## Money Money is an object with an integer amount in minor units and an ISO 4217 currency. It is never a float. ```json { "amount": 150000, "currency": "KES" } ``` That is KES 1,500.00. Phone numbers are always E.164, for example `+254712345678`. ## Response headers | Header | Meaning | |---|---| | `Vabu-Request-Id` | `req_...`. Quote it when you contact support. | | `Vabu-Version` | The API version that handled the request. | | `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` | Your [rate limit](https://docs.vabu.live/concepts/rate-limits.md) state. | | `Retry-After` | On `429`, seconds to wait. Readable from browser code. | | `Idempotent-Replayed` | `true` when the response is a stored replay. See [idempotency](https://docs.vabu.live/concepts/idempotency.md). | ## Errors Every error response, including from middleware, has this shape. ```json { "error": { "type": "invalid_request_error", "code": "ticket_type_sold_out", "message": "VIP is sold out.", "param": "items[0].ticket_type", "details": { "items.0.quantity": ["Only 0 left."] }, "request_id": "req_01JB...", "doc_url": "https://docs.vabu.live/errors.html#ticket_type_sold_out" } } ``` Handle errors in this order: 1. Branch on `error.code`. Codes are stable. Messages are for people and can change. 2. Use `error.type` for a fallback. `rate_limit_error` and `api_error` are worth retrying. The rest need a fix to the request. 3. Show `error.details` next to the matching form field. `error.param` names the field. 4. Log `error.request_id`. Read [errors](https://docs.vabu.live/concepts/errors.md) for the retry rules, and the [error codes](https://docs.vabu.live/errors.md) page for the list. --- # Build with AI and agents Every page in these docs is also plain Markdown, and the whole API is one OpenAPI file. An assistant that reads them writes better Vabu OS code than one that guesses. ## What to give your assistant | File | Use it for | |---|---| | [llms.txt](https://docs.vabu.live/llms.txt) | A short index of every page, grouped by product, with a one-line summary and a Markdown link. Start here. | | [llms-full.txt](https://docs.vabu.live/llms-full.txt) | Every page in one file, for assistants that take a large paste or attachment. | | [openapi.json](https://docs.vabu.live/openapi.json) | Exact paths, parameters, request bodies and responses. | | Any page with `.md` on the end | One page as clean Markdown, for example `https://docs.vabu.live/guides/streams/quickstart.md`. | Each page also has buttons at the top: **Copy page** puts its Markdown on your clipboard, **View as Markdown** opens it, and **Open in Claude** and **Open in ChatGPT** start a chat that reads the page. ## Claude Code Tell Claude Code where the docs are, once, in your repo's `CLAUDE.md` (see the snippet below). To try it on one task, ask: ```text Read https://docs.vabu.live/llms.txt, then the Streams quickstart it links to. Add a route to this app that creates a live stream and returns a customer token. Use the OpenAPI file at https://docs.vabu.live/openapi.json for field names. ``` ## Cursor and other editors In Cursor, add the docs under Settings, Indexing and Docs with the URL `https://docs.vabu.live/llms-full.txt`, then mention it in chat with `@Docs`. Other tools that accept a URL or a file can take `llms.txt`, `llms-full.txt` or `openapi.json` the same way. For tools that read an OpenAPI file, point them at `https://docs.vabu.live/openapi.json`. ## A snippet for your repo Copy this into the `AGENTS.md` (or `CLAUDE.md`, or your tool's rules file) of the repo that calls Vabu OS. ````markdown ## Vabu OS We call the Vabu OS API. Read the docs before writing code against it. - Docs index: https://docs.vabu.live/llms.txt - Every page as Markdown: add `.md` to its URL - API description: https://docs.vabu.live/openapi.json - Never guess a field, scope or error code. Look it up in the OpenAPI file. - Use test keys (`vk_test_`, `rk_test_`, `vpk_test_`) in development and in tests. Never write a `vk_live_` key into code, a test, a fixture or a prompt. - Keep keys in environment variables. Secret and restricted keys stay on the server. Only `vpk_` keys go to a browser. - Send an `Idempotency-Key` on every POST that creates something. - Verify webhook signatures before acting on an event, and ignore duplicate event ids. - Money is in cents. Phone numbers are E.164, for example `+254712345678`. ```` ## Use test keys while an assistant works An assistant can run code, and it can make mistakes quickly. Keep it on test keys. 1. Create a test key in the dashboard. Test keys work from the first minute and everything they create has `"livemode": false`. See [environments and keys](https://docs.vabu.live/get-started/environments-and-keys.md). 2. Give the assistant a restricted key (`rk_test_`) with only the scopes the task needs. See [keys and scopes](https://docs.vabu.live/concepts/keys-and-scopes.md). 3. Put the key in an environment variable or a local `.env` file that is not committed. Do not paste it into a chat. 4. Check [test mode](https://docs.vabu.live/concepts/test-mode.md) for what works with test keys. A call that is not available in test mode answers `403 test_mode_unavailable`. Do not switch to a live key to get past it. 5. Run the assistant's code against test keys first. Move to live keys yourself, in your own deploy settings, after you have read the diff. 6. If a key reaches a chat, a log or a commit, revoke it in the dashboard and create a new one. --- # Product guides Concepts cover what is the same for every product. Guides cover one product at a time: what it does, the calls to make in order, and the events to listen for. The [concepts](https://docs.vabu.live/concepts/integration-modes.md) explain how every request, key and event works, and the [API reference](https://docs.vabu.live/api.md) lists every endpoint. Each product also has a short page under [products](https://docs.vabu.live/products/index.md). ## Streams - [How Vabu Streams works](https://docs.vabu.live/guides/streams/index.md): what you do, what Vabu does and why to use it. - [Build with AI and agents](https://docs.vabu.live/guides/ai-and-agents.md): give your coding assistant the docs and API description, and keep it on test keys. - [Streams quickstart](https://docs.vabu.live/guides/streams/quickstart.md): create a stream, give a customer access and play it. - [Going live](https://docs.vabu.live/guides/streams/going-live.md): encoder settings, a checklist before showtime and what happens when the connection drops. - [Paid access](https://docs.vabu.live/guides/streams/paid-access.md): grant access after your own payment, limit devices, revoke and sell replays. - [Series, subscriptions and certificates](https://docs.vabu.live/guides/streams/series-and-subscriptions.md): on-demand video for creators. - [Player kit](https://docs.vabu.live/guides/streams/player-kit.md): keep your own player and get paid access, failover, moments in step with the video and quality data. - [Vabu player element](https://docs.vabu.live/guides/streams/player-element.md): add a ready-made player to any page with one HTML tag. - [Vabu broadcaster element](https://docs.vabu.live/guides/streams/broadcaster-element.md): let a host go live from a phone browser with one HTML tag. - [Moments](https://docs.vabu.live/guides/streams/moments.md): show a paid shoutout, a poll or a sponsor slot on screen at the moment it happens on the video. - [Clips from paid moments](https://docs.vabu.live/guides/streams/clips-from-moments.md): cut a clip around a paid moment and send the fan their link. - [Planes](https://docs.vabu.live/guides/streams/planes.md): what each media plane can do, and where they differ. - [Cloudflare Stream setup](https://docs.vabu.live/guides/streams/cloudflare-stream-setup.md): prepare your own Cloudflare account. --- # Vabu broadcaster element `` lets a DJ or host go live from a phone browser. Give it the WHIP address of a live stream and the stream key. The host picks a camera and microphone, checks the preview and the health numbers, and taps **Go live**. It sends video over WHIP, so the live stream must be on a plane that takes WHIP from a browser (see [Planes](https://docs.vabu.live/guides/streams/planes.md)). Viewers watch it in [``](https://docs.vabu.live/guides/streams/player-element.md) like any other stream. The page must be served over HTTPS, or the browser will not give it the camera. ## Add it to a page The element is not on npm yet. Build `packages/elements` and host `dist/vabu-broadcaster.js` (and the `i18n-*.js` file beside it) on your own site. ```html ``` Read the WHIP address and key from your live stream on your server, then give them to the page for the host only. Never put them in a public page. | Attribute | What it does | |---|---| | `whip-url` | The live stream's WHIP endpoint. | | `token` (property, not an attribute) | The stream key or bearer token. It goes out as `Authorization: Bearer`, only to the origin of `whip-url`. Optional on Cloudflare Stream, where the `whip-url` carries the secret. Set it as a property so it stays out of the page's HTML. | | `lang` | `en` or `sw`. Defaults to the page language. | Methods: `goLive()` and `stop()`. ## What the host sees - A live preview from the camera, with a camera picker, a microphone picker and a button that switches between the front and back camera. Switching while live does not drop the connection. - Resolution, frame rate, bitrate and connection state, once live. - **Go live** and **Stop**. If the network drops, the element reconnects on its own: after 1 second, then 2, 4, and so on up to 30 seconds, for up to 8 tries. After that it says the connection was lost, and the host can tap **Try again**. A refused stream key does not retry. ## Events | Event | `detail` | |---|---| | `vabu-broadcast-state` | `{ state }`: `idle`, `connecting`, `live`, `reconnecting` or `error`. | | `vabu-error` | `{ code, fatal }`: `permission`, `noDevice`, `unsupported`, `auth`, `rejected`, `network` or `lost`. | ## Size About 5 KB gzipped, plus Lit (about 6 KB). The build fails if the broadcaster with Lit goes over 14 KB gzipped. --- # Clips from paid moments When a fan pays for a shoutout, they want the second it was read out. Add `clip_before_s` and `clip_after_s` to the [moment](https://docs.vabu.live/guides/streams/moments.md) and Vabu cuts that clip from the live recording for you. When it is ready, you get a webhook with the fan's reference and the clip, and you send them the link. You need a live stream, a secret key on your server and a [webhook endpoint](https://docs.vabu.live/concepts/webhooks.md) subscribed to `streams.moment.clip_ready` and `streams.moment.clip_failed`. ## Ask for a clip Post the moment as usual and add the window you want, in seconds around the moment. This one cuts 15 seconds before and 30 seconds after the shoutout. ```sh curl -X POST "https://api.vabu.live/v1/streams/live_streams/$STREAM/moments" \ -H "Authorization: Bearer $VABU_API_KEY" \ -H "Idempotency-Key: shoutout-pay_123" \ -H "Content-Type: application/json" \ -d '{ "type": "shoutout", "label": "Shoutout for Amina", "customer": "cust_77", "clip_before_s": 15, "clip_after_s": 30 }' ``` The moment comes back with `clip_status: "pending"`. A moment with neither field gets no clip. ## What happens next 1. When the stream goes live, Vabu saves the recording as an asset and notes the clock time of its first frame. 2. Once the window has closed, plus a short wait for the video plane to finish its recording, Vabu cuts the window from the recording. It never cuts before the first frame or after the last. If the encoder dropped and came back, the time is placed by what was recorded, so the clip is the right moment. 3. If the recording is not ready, Vabu tries again later: after 30 seconds, then 1, 2, 4, 8 and 10 minutes. After 8 tries the moment gets `clip_status: "failed"` with a `clip_error`. 4. When the clip can be played you get `streams.moment.clip_ready`. A cancelled moment gets no clip and its `clip_status` goes back to `null`. Clips work on the standard plane and on Cloudflare Stream, and in test mode (simulated and ready at once). On the standard plane the clip is cut while the show runs. Cloudflare Stream cuts a finished recording only, so those clips arrive after the show ends, once the encoder has been gone for the reconnect window. Streams on Vabu Media cannot make clips yet. ## Send the fan their clip This handler checks the [signature](https://docs.vabu.live/concepts/webhooks.md#verify-the-signature), then sends the link. It uses Express and Node 20 or later. `phoneForCustomer` and `sendWhatsApp` stand for your own code. `customer` is the reference you posted with the moment. ```ts import express from 'express' import { verifyVabuSignature } from './verify-vabu-signature' // the function from the webhooks page const app = express() // Keep the raw body: the signature is over the exact bytes. app.post('/vabu/webhooks', express.raw({ type: 'application/json' }), async (req, res) => { const raw = req.body.toString() if (!verifyVabuSignature(raw, req.get('Vabu-Signature') ?? '', process.env.VABU_WEBHOOK_SECRET!)) { return res.sendStatus(400) } const event = JSON.parse(raw) const moment = event.data.object if (event.type === 'streams.moment.clip_ready') { const phone = await phoneForCustomer(moment.customer) const link = `https://example.com/clips/${moment.clip_asset}` await sendWhatsApp(phone, `Your shoutout clip is ready: ${link}`) } if (event.type === 'streams.moment.clip_failed') { console.warn(`No clip for ${moment.id}: ${moment.clip_error}`) } res.sendStatus(200) }) ``` Your own page at `/clips/{asset}` asks Vabu for a playback token for that asset, as in [paid access](https://docs.vabu.live/guides/streams/paid-access.md), so only the fan who paid can watch it. Events can arrive more than once. Use `event.id` to skip one you have already handled. ## Chapters on the replay The recording of the show is an asset too. List the moments inside it with their place in the video: ```sh curl "https://api.vabu.live/v1/streams/assets/$RECORDING/moments" -H "Authorization: Bearer $VABU_API_KEY" ``` Each moment has `offset_s`, the seconds from the start of the recording. Use it for chapter markers on a replay. The list is paginated like every other list and comes newest first, so sort by `offset_s`. ## A vertical clip `POST /v1/streams/assets/{id}/clips` accepts `aspect: "9:16"` for a clip to share on social. No video plane can render it yet, so the call returns `422 aspect_not_supported` and no clip is made. Leave `aspect` out for the original shape. --- # Cloudflare Stream setup On the shared plane, Vabu runs this setup for you. Use this page when you want Streams to use your own Cloudflare account, so the video, the bill and the signing keys are yours. We create the configuration from the values you prepare here. You need a Cloudflare account with a Stream subscription (or a Pro or Business zone plan, or an enterprise contract with Stream). Every call below uses your account id and an API token, shown as `$ACCOUNT_ID` and `$CLOUDFLARE_API_TOKEN`. ## 1. Get the account id and the customer code Your account id is in the Cloudflare dashboard. The customer code is the `` in `customer-.cloudflarestream.com`, the host that serves your video. It appears in every playback url Cloudflare returns. ## 2. Create an API token Create an API token on the **Account API tokens** page with these permissions: - **Stream Write**, to create and delete live inputs, videos, outputs and signing keys, and to read storage. - **Notifications Write**, to create the live input destination in step 5. You can leave this off and create the destination in the dashboard instead. ## 3. Create a signing key A signing key lets Vabu sign playback tokens without calling Cloudflare for each one. Create it once. ```bash curl -X POST "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/stream/keys" \ -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" ``` The response holds `id`, `pem` and `jwk`. Cloudflare shows them once, so store them straight away. Keep the `id` and the `pem` (it is base64 encoded, and we accept it as it is). You can have up to 1,000 keys, and deleting a key revokes every token signed with it. ## 4. Set the account webhook The account webhook tells Vabu when an upload, a URL import or a clip is ready or has failed. Cloudflare allows one webhook per account. Use the url we give you, which looks like `https://api.vabu.live/api/v1/providers/cloudflare_stream/webhooks/mpc_...`. ```bash curl -X PUT "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/stream/webhook" \ -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ -H "Content-Type: application/json" \ --data '{"notificationUrl": "https://api.vabu.live/api/v1/providers/cloudflare_stream/webhooks/mpc_..."}' ``` The response has a `secret`. Cloudflare signs every webhook with it, and we check the signature. Requests older than five minutes are refused. ## 5. Send live input events to Vabu Connect and disconnect events of live inputs do not use the account webhook. They come through Notifications, which sends a secret of your choice in the `cf-webhook-auth` header. Make a destination, then a policy that sends Stream live input events to it. ```bash curl -X POST "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/alerting/v3/destinations/webhooks" \ -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ -H "Content-Type: application/json" \ --data '{"name": "Vabu live inputs", "url": "https://api.vabu.live/api/v1/providers/cloudflare_stream/webhooks/mpc_...", "secret": ""}' ``` The response has the destination `id`. Use it in the policy: ```bash curl -X POST "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/alerting/v3/policies" \ -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ -H "Content-Type: application/json" \ --data '{"name": "Vabu live inputs", "alert_type": "stream_live_notifications", "enabled": true, "mechanisms": {"webhooks": [{"id": ""}]}}' ``` By default the policy covers every live input. Cloudflare never returns the secret again, so keep a copy. Notifications reach a public address on port 80 or 443 only. ## 6. Give us the values For the configuration we need the account id, the customer code, the API token, the signing key `id` and `pem`, the account webhook `secret` and the notification secret. They are encrypted at rest and never shown again, in the API or in events. ## Encoder settings for venues Cloudflare Stream accepts one kind of feed. Set the encoder at the venue to: - Video H.264 and audio AAC (ADTS, not LATM). - A total bitrate between 2 and 14 Mbps, constant bitrate where you can. Under 10 Mbps suits most shows. - A keyframe interval between 2 and 10 seconds, with closed GOPs. Use 4 to 8 seconds for the best balance of latency and quality. - Automatic reconnect, so a dropped feed returns inside the `reconnect_window`. A live recording is cut at 7 days. When a feed breaks these limits, Cloudflare reports it and the stream shows as `disconnected`. A browser or phone that goes live over WHIP sends VP8, VP9 or H.264 Constrained Baseline instead. That feed plays back over WebRTC only. ## Keep the cost down Cloudflare Stream bills two things: minutes stored and minutes delivered. - **Minutes stored** are prepaid, at $5 a month for each 1,000 minutes. Every broadcast is recorded and counts until its recording is deleted. Deleting an asset releases its minutes at once, whether it came from an upload, a copy or a live recording. Deleting a live stream deletes all of its recordings one reconnect window (plus a minute) later. Recordings of a stream you keep are deleted by Cloudflare 30 days after the broadcast, so delete the asset sooner if you do not need it. An upload also reserves its longest allowed length until it finishes or its link expires. - **Minutes delivered** are billed at $1 for each 1,000 minutes watched. They include preloading in the player and every simulcast target while the stream is live. Encoding and sending video to Cloudflare are free, and a live input that nobody connects to costs nothing. If the account runs low on storage, Vabu refuses to start a stream with `409 provider_storage_exhausted` instead of letting a show stop halfway. Buy more storage in the Cloudflare dashboard to clear it. --- # Going live This page is for the people at the venue or in the studio: the DJ's crew, the church's media team, the league's production staff. It tells you what to set in the encoder, what to check before the doors open and what to expect if the internet drops. Your developer gives you three things for each venue: an **ingest URL**, a **stream key** and, if you plan for trouble, a **backup ingest URL**. They come from the live stream in the API. See [live streams and ingest](https://docs.vabu.live/concepts/live-streams.md). The same stream works for every show at that venue, so the settings below only need to be set once. ## Encoder settings | Setting | Value | |---|---| | Video codec | H.264 | | Audio codec | AAC, 128 kbps, 48 kHz stereo | | Resolution | 1280 x 720 (720p) | | Frame rate | 30 frames a second | | Video bitrate | About 2 to 6 Mbps for 720p. Start at 3 or 4 Mbps. | | Bitrate mode | Constant (CBR) | | Keyframe interval | 2 seconds | | Profile | Main or High | The keyframe interval matters most. Players start and switch quality on a keyframe, so a long gap makes viewers wait. Set it to 2 seconds in the encoder, not "auto". Your upload speed should be at least twice the video bitrate. For 4 Mbps video, plan for 8 Mbps up that is truly yours on the night. Test it at the venue at the time of day of the show, because mobile data and shared Wi-Fi slow down when crowds arrive. For an audio-only show, such as a radio set or a service where the picture adds little, ask your developer for an audio-only stream. It uses far less data for viewers. ## Send from OBS 1. Open Settings, then Stream. 2. Service: **Custom**. 3. Server: the ingest URL. It starts with `rtmps://` or `rtmp://`. 4. Stream key: the stream key. 5. Open Output. Set the encoder to x264, rate control to CBR, bitrate to 3000 to 4000 Kbps and keyframe interval to 2 s. 6. Open Audio and set the audio bitrate to 128. 7. Press Start Streaming. ## Send from a phone Any app that sends RTMP or SRT works. Larix Broadcaster is a common choice on Android and iPhone. Add a connection, paste the ingest URL and the stream key, set video to H.264 at 720p, 30 frames a second and about 3000 kbps, set the keyframe interval to 2 seconds and start. Use a tripod and keep the phone plugged in. A live stream drains a battery quickly. SRT copes better than RTMP with a poor network. If your developer gave you an SRT address, use it. ## Checklist before showtime Do this the day before, with the same gear and the same internet, then repeat the short version an hour before the doors open. 1. Send for ten minutes and ask your developer to open the viewer page on a phone on mobile data. Picture and sound should both be there. 2. Check the stream shows as live in your app. Behind the scenes its status moves from `idle` to `connecting` to `active` within about 30 seconds. 3. Use a wired connection for the encoder. Wi-Fi is the second choice. 4. Keep a second way out: a phone hotspot, or a second internet line, ready to switch to. 5. Have the backup ingest URL saved on the encoder computer. 6. Set the encoder to reconnect by itself. In OBS this is under Advanced: automatically reconnect. 7. Plug in power for every device, and turn off sleep and updates. 8. Keep the stream key private. If it leaks, your developer can reset it and the old key stops working at once. 9. Watch the stream yourself on a phone, from the audience's side, while you send. Check the sound levels there, not only in the encoder. ## What happens if the connection drops Every live stream has a **reconnect window**, 60 seconds unless your developer set another value (it can be up to 30 minutes). Here is what happens: 1. The encoder drops. The stream's status becomes `disconnected` and your app gets a `streams.live_stream.disconnected` event. 2. For the length of the reconnect window the broadcast is still considered live. Viewers' players wait, and they may see a spinner. 3. If your encoder reconnects with the same stream key inside the window, the broadcast carries on. Viewers do not need to reload, and they keep the same playback link. 4. If the window passes with no video, the broadcast is over. The status goes to `idle` and a `streams.live_stream.idle` event follows. Start again, and viewers whose players stopped may need to reload the page. A shorter window frees the stream sooner. A longer one gives a flaky connection more time to recover. For a show with a paid audience, keep it at 60 seconds or longer, and make your page show a friendly "we will be right back" message when your app sees `disconnected`. If your developer ends a show on purpose, they call `complete` on the stream, which closes it at once instead of waiting for the window. Automatic switching to a second video network is on [What's coming](https://docs.vabu.live/whats-coming.md). Until then, the fastest recovery is a second internet line and an encoder that reconnects by itself. ## For your developer - The stream and its ingest details: [live streams and ingest](https://docs.vabu.live/concepts/live-streams.md). - Events to listen for: [stream events](https://docs.vabu.live/concepts/stream-events.md). - Stop a stream from taking video, and let it start again: `POST /v1/streams/live_streams/{id}/disable` and `/enable`. - Forward the broadcast to other platforms at the same time with [simulcast targets](https://docs.vabu.live/concepts/live-streams.md#simulcast-targets). --- # How Vabu Streams works Vabu Streams puts live video and replays in your own app. You keep your login, your payments and your player page. Vabu takes the video side: it receives the broadcast, prepares it for weak phones, hands out protected playback links, counts viewer sessions and tells you what happens. It suits a nightlife app that streams a DJ set, a church that streams its services, a league that sells match passes and an events company that adds a stream to a ticket. ## What you do and what Vabu does | You | Vabu | |---|---| | Keep your app, your login and your checkout | Gives each venue a stable stream to send video to (RTMP and SRT, WHIP where the network offers it) | | Make one call when a fan has paid | Checks that call every time someone presses play | | Show the video on your own page | Prepares video in several qualities so it plays on weak phones | | Listen for events and update your own records | Issues playback links that stop working when access ends | | Decide the price and who gets in | Limits devices, keeps replays and cuts clips, and sends an event for every change | ## One API, with the video network behind it You call one API. It does not matter which network carries the video: every live stream and every asset has a read-only `provider` field that tells you, and your code never needs to read it. Test mode uses a simulated network that cannot bill. Right now each account runs on one network that Vabu picks for it. A live stream can also have a second network on standby: see [Staying on air](https://docs.vabu.live/guides/streams/staying-on-air.md). Choosing a network for your audience's region, with Nairobi first, is listed on [What's coming](https://docs.vabu.live/whats-coming.md). Read [delivery networks and capabilities](https://docs.vabu.live/concepts/delivery-networks.md) to see how to write code that keeps working when more networks arrive. ## A night, step by step 1. A venue points its encoder at the live stream's ingest URL. The URL and the key stay the same for every broadcast. 2. A fan pays in your own checkout. 3. Your server tells Vabu the fan may watch: `POST /v1/access/entitlements`. See [paid access](https://docs.vabu.live/guides/streams/paid-access.md). 4. The fan opens your page. Your server signs a short customer token for them. See [customer tokens](https://docs.vabu.live/concepts/customer-tokens.md). 5. The browser asks Vabu to open a viewing session, using a publishable key and the customer token. Vabu checks the access and the device limit, and returns a protected playback link. See [playback and sessions](https://docs.vabu.live/concepts/playback-and-sessions.md). 6. The player plays the link. A heartbeat every 30 seconds keeps the session alive and returns a fresh link. 7. Your server gets events as the stream goes live, drops and ends. See [stream events](https://docs.vabu.live/concepts/stream-events.md). ## Why use Vabu Streams Here is what you get next to building the pipes yourself, and next to wiring one video provider straight into your app. ### Staying on air Built by yourself, the video travels one path that you watch through the show. With one provider you have a stronger pipe, and you still handle reconnects and alerts. With Vabu Streams each stream has a reconnect window, a backup ingest address and an event the moment the encoder drops, so your app can react. ### Viewers on weak data A pipeline you build is often one quality, and a viewer on a weak phone sees a spinner. A provider gives you a quality ladder. Vabu Streams gives you the ladder too, and audio-only streams for audiences who need the smallest data use. ### Paid access that cannot be shared by link With your own pipeline the video is often a file link that anyone can pass on. A provider gives you signed links, and you write the access rules around them. With Vabu Streams the rules come with it: entitlements, device limits and links that expire, so a shared link stops working within minutes. ### Data Your own pipeline leaves you server logs. A provider gives you video numbers that know nothing about your sales. Vabu Streams sends every grant, session and stream change to your server as an event, tied to your own customer ids. ### Your team's time Building it yourself means running video at night. Wiring a provider means an integration, plus everything around the video. With Vabu Streams it is one API and a short list of calls. ## What you still build Vabu does not replace your product. You build your login, your checkout and the page that holds the video. You choose your player: any player that plays HLS works, and the [quickstart](https://docs.vabu.live/guides/streams/quickstart.md) uses hls.js. ## Where to go next - [Quickstart](https://docs.vabu.live/guides/streams/quickstart.md): create a stream, give a customer access and play it. - [Going live](https://docs.vabu.live/guides/streams/going-live.md): encoder settings, a checklist and what happens when the connection drops. - [Paid access](https://docs.vabu.live/guides/streams/paid-access.md): grant access after payment, limit devices, revoke and sell replays. - [Series, subscriptions and certificates](https://docs.vabu.live/guides/streams/series-and-subscriptions.md): on-demand video for creators. - Concepts: [live streams and ingest](https://docs.vabu.live/concepts/live-streams.md), [assets and recordings](https://docs.vabu.live/concepts/assets-and-recordings.md), [playback and sessions](https://docs.vabu.live/concepts/playback-and-sessions.md), [customer tokens](https://docs.vabu.live/concepts/customer-tokens.md), [signed playback](https://docs.vabu.live/concepts/signed-playback.md), [delivery networks and capabilities](https://docs.vabu.live/concepts/delivery-networks.md) and [stream events](https://docs.vabu.live/concepts/stream-events.md). - [What's coming](https://docs.vabu.live/whats-coming.md). --- # 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 1. Your server creates the moment. Vabu stamps it with its own clock to the millisecond (`occurred_at`). 2. 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. 3. The player holds each moment until the playhead's program date time, the `EXT-X-PROGRAM-DATE-TIME` of the HLS stream, reaches `occurred_at`. It shows the moment for `display_ms` and 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](https://docs.vabu.live/api.md), a secret key on your server, and a [publishable key](https://docs.vabu.live/concepts/keys-and-scopes.md) 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 | null; occurred_at: string; display_ms: number } const waiting: Moment[] = [] const seen = new Set() // 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](https://docs.vabu.live/api.md) 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 `label` and `payload`, 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 gets `503 try_again`: retry with the same `Idempotency-Key`. - **Your clock is optional.** To stamp a moment with your own clock, send `occurred_at` as an ISO 8601 time with an offset. It must be within 5 minutes of the server time, or you get `422 occurred_at_out_of_range`, whose `details.server_time` shows 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. Leave `occurred_at` out 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.created` and `streams.moment.cancelled` reach your [webhooks](https://docs.vabu.live/concepts/webhooks.md), with the customer and amount. See the [event catalogue](https://docs.vabu.live/events/index.md). The full request and response for each endpoint is in the [API reference](https://docs.vabu.live/api.md). --- # Paid access You sell the pass in your own checkout, with your own payment provider. Vabu decides who gets to press play. The link between the two is one call from your server: grant an **entitlement** after the payment clears. An entitlement says that one customer may use one resource, for a period, within limits. The resource is a live stream (type `ls`) or an asset (type `ast`). ## Grant access after a payment Make this call from your server when your payment provider confirms the money. Use a secret key, never a browser key. ```http POST /v1/access/entitlements Authorization: Bearer vk_live_... Idempotency-Key: grant-pay-8842 { "customer": "user_42", "resource": { "type": "ls", "id": "ls_01J9ZQ4M8T3K6Y2E5W7N0B1C9D" }, "source": "tenant_grant", "source_ref": "pay-8842", "starts_at": "2026-10-16T16:00:00Z", "ends_at": "2026-10-17T04:00:00Z", "limits": { "max_devices": 2, "max_concurrent_sessions": 1, "concurrency_policy": "replace_oldest" }, "metadata": { "ticket": "VIP" } } ``` You get `201` and an entitlement with `"status": "active"`. | Field | Meaning | |---|---| | `customer` | Your own id for the buyer. It must match the `sub` of the customer token you sign for them. If you have no accounts, send `contact_hash` instead: a hash of a verified phone or email, salted with your own secret. | | `resource` | What they may watch. `ls` for a live stream, `ast` for a video asset. | | `source` | Why they have access, for your reports: `tenant_grant` (the default), `payment`, `comp`, `subscription`, `membership_tier`, `ticket` or `promotion`. | | `source_ref` | The payment, order or grant behind it. Your payment id is a good value. | | `starts_at`, `ends_at` | The window. Both need a time zone offset, such as `Z`. Leave them out for no limit. | | `limits` | Device and viewing rules. See below. | | `external_id`, `metadata` | Your own references. | ### Make it safe to repeat Payment providers send the same confirmation more than once, so your handler will run more than once. This is safe. Two things protect you: - The `Idempotency-Key` header is required. Send the same key and you get the stored response back. - With a `source_ref`, a second grant for the same source, reference, customer and resource changes nothing and returns the existing entitlement with `200`. A repeat of a revoked grant is refused with `409 entitlement_revoked`. If you want to give access again, use a new `source_ref`. ### Check before you show a button `POST /v1/access/check` answers whether a customer may watch right now: ```json { "data": { "allowed": false, "reason": "expired", "entitlement": { "id": "ent_...", "status": "expired" } } } ``` `reason` is `null` when allowed. Otherwise it is `no_entitlement`, `expired`, `revoked`, `suspended`, `not_started`, `country_not_allowed` or `domain_not_allowed`. Use it to choose between "Watch" and "Buy a pass". The answer follows every change at once, including a revoke. ## Limit devices The `limits` object decides what a second screen does. | You want | Set | |---|---| | One screen at a time, the latest wins (a viewer who picks up their phone moves the stream there) | `max_concurrent_sessions: 1`, `concurrency_policy: "replace_oldest"` | | One screen at a time, the first holds the seat | `max_concurrent_sessions: 1`, `concurrency_policy: "reject_new"` | | A household with up to 2 devices in all | `max_devices: 2` | | A pass that works for 3 viewings | `max_views: 3` | | Only viewers in Kenya and Uganda | `allowed_countries: ["KE", "UG"]` | | Only on your own site | `allowed_domains: ["example.com"]` | Your page sends a `device_id` when it opens a session. Use one stable id for each browser or phone, for example one saved in local storage. Vabu counts different ids as different devices. With `replace_oldest`, the older session ends at its next heartbeat, within 30 seconds, and its player gets `403 session_ended` with the reason `replaced_by_newer_session`. Show "You are watching on another device". With `reject_new`, the second device is refused with `409 concurrency_limit_reached`. How sessions and heartbeats work is in [playback and sessions](https://docs.vabu.live/concepts/playback-and-sessions.md). ## Revoke access Take access away when a payment is refunded or reversed: ```http DELETE /v1/access/entitlements/ent_... ``` This is final. The response is the entitlement with `"status": "revoked"`. For a pause you can undo, use `POST /v1/access/entitlements/{id}/suspend` and `.../resume`. What happens next: - The next `check` says no, at once. - A new session is refused at once. - A session that is playing ends at its next heartbeat. - The playback link already in the viewer's player works until its token runs out, at most about 11 minutes. See [signed playback](https://docs.vabu.live/concepts/signed-playback.md). Find a customer's entitlements with `GET /v1/access/entitlements?customer=user_42`, or by your own id with `GET /v1/access/entitlements/external/{external_id}`. ## Sell replays A replay is an asset, so it takes an entitlement of its own, with resource type `ast`. 1. Add the recording as an asset. See [assets and recordings](https://docs.vabu.live/concepts/assets-and-recordings.md). Wait for `streams.asset.ready`. 2. When someone buys the replay, grant an entitlement on the asset, with an `ends_at` that matches what you sold, for example 7 days. 3. To include the replay in a live pass, grant two entitlements in the same handler: one on the stream and one on the asset, with a later `ends_at` on the asset. The viewer opens a session on the asset exactly as on a live stream. Because the replay entitlement has its own limits, you can sell a replay at one screen per purchase. ## Tickets that include a stream If you sell tickets with Vabu Events, listen for `events.order.paid` and grant the stream in your handler, with `source: "ticket"`, the order code as `source_ref` and your own id for the buyer. See [how the products work together](https://docs.vabu.live/products/how-products-work-together.md). ## Keep your records in step Every grant, revoke, suspend, resume and expiry sends an event: `access.entitlement.granted`, `revoked`, `suspended`, `resumed`, `expired` and `extended`. Use them to update your own database. See [stream events](https://docs.vabu.live/concepts/stream-events.md). --- # Planes Streams runs your live video and on-demand video on a plane: a media provider behind one API. You call the same endpoints whichever plane carries your video. Every live stream, asset, upload and signing key shows the plane that made it in its `provider` field. Vabu sets the plane for your account. Cloudflare Stream suits audiences in Kenya and East Africa, because it serves video from Nairobi and encodes for free. The standard plane suits audiences around the world, audio-only streams and low-latency HLS. Test mode runs on a simulated plane that never reaches a provider and never bills. ## What each plane can do Code against these capabilities, not against the plane name. A request a plane cannot do is refused with `422 provider_rejected` before anything is created. | Capability | Standard plane | Cloudflare Stream | Vabu Media | |---|---|---|---| | Live streaming (`live`) | Yes | Yes | Yes | | On-demand video (`vod`) | Yes | Yes | Yes | | Audio-only live streams (`audio_only`) | Yes | No | Yes | | Low-latency HLS (`ll_hls`) | Yes | No | No | | WHIP ingest from a browser (`whip_ingest`) | No | Yes | Yes | | SRT ingest (`srt_ingest`) | Yes | Yes | Yes | | Simulcast to other platforms (`simulcast`) | Yes | Yes | No | | Clips (`clips`) | Yes | Yes | No | | DRM (`drm`) | No | No | No | | Automatic captions (`captions_auto`) | Yes | No | No | | Signed playback (`signed_playback`) | Yes | Yes | Yes | | Signing keys of your own (`delegated_signing`) | Dedicated account | Dedicated account | Dedicated account | | Program date time in manifests (`program_date_time`) | Yes | No | Yes | | Vertical clips (`vertical_clips`) | No | No | No | Signing keys of your own need a provider account that is only yours. On a shared account a key could sign for other customers' playback ids, so it is refused with `409 dedicated_environment_required`. Program date time lets a player place a moment on the exact frame it belongs to. Where a plane does not carry it, a player places moments from its own distance to the live edge instead. ## Where Cloudflare Stream behaves differently - **One playback id per resource.** Cloudflare gives each live stream and asset a single playback id, made with it from `playback_policy`. `POST /v1/streams/playback_ids` and `DELETE /v1/streams/playback_ids/{id}` answer `422 provider_rejected` on this plane. - **Playback tokens.** A token is a signed value that goes in the playback url in place of the id. It never lives more than 24 hours; the heartbeat of a viewer session keeps replacing it. - **Uploads.** Send one `POST` with `multipart/form-data` and the file in the field `file`, up to 200 MB. The standard plane takes a `PUT`. - **SRT.** Connect with `ingest.srt_url`, `ingest.srt_stream_id` and `ingest.srt_passphrase`. On the standard plane the SRT `streamid` is the stream key. - **WHIP.** `ingest.whip_url` is a publish url that holds a secret. A stream sent over WHIP is watched over WebRTC (WHEP), is not recorded and cannot forward to simulcast targets, so it has no HLS source. Vabu playback grants have no WHEP source yet, so viewers cannot play a WHIP stream through `/access` today. - **Latency.** Playback is standard HLS. `latency_mode` has no effect, and a keyframe interval of 4 to 8 seconds gives the lowest latency that stays smooth. - **Recordings.** Every broadcast is recorded, because that is how HLS playback works. Deleting a recording's asset deletes the video at once. Deleting a live stream deletes its recordings after its reconnect window. Any other live recording is deleted 30 days after the broadcast. - **Ending a broadcast.** `complete` has no effect. The recording closes once the encoder has been gone for the `reconnect_window`, and the stream then reports `idle`. - **Rotating keys.** `reset_stream_key` also changes the SRT passphrase and the WHIP url. - **Storage.** Recordings use stored minutes. When the account has too little room left to record, going live is refused with `409 provider_storage_exhausted`, and nothing is created. To use your own Cloudflare account, read [Cloudflare Stream setup](https://docs.vabu.live/guides/streams/cloudflare-stream-setup.md). For Vabu's own plane, read [Vabu Media](https://docs.vabu.live/guides/streams/vabu-media.md). --- # Vabu player element `` is a video player for Vabu Streams that you add to a page with one HTML tag. Give it a publishable key, the id of a live stream or video, and a function that fetches a customer token from your server. It does the rest: it opens the viewer's session, plays, switches to the next source when one fails, shows moments when the video reaches them and sends quality data. It works in any framework and in Android and iOS WebViews. If you want your own player and buttons, use the [player kit](https://docs.vabu.live/guides/streams/player-kit.md) instead. This element is built on it. ## Add it to a page The element is not on npm yet. Build `packages/elements` and host `dist/vabu-player.js` on your own site. Point `src` at your copy. ```html ``` Or install it and import it in your bundle: ```sh npm install @vabu/elements ``` ```ts import '@vabu/elements' ``` The file carries hls.js. It is about 20 KB gzipped for the player and Lit, plus about 113 KB for the light hls.js build. It needs a browser from 2022 or later. Your server signs the customer token. The [player kit](https://docs.vabu.live/guides/streams/player-kit.md#the-token-route-on-your-server) page has an example route. Nothing opens until the viewer presses play, so a page view does not use up a view or a device. Add `autoplay` to start at once. Browsers usually allow autoplay only for muted video, so pair it with `muted`. ## Attributes and properties | Attribute | Meaning | |---|---| | `publishable-key` | Your publishable key. | | `resource` | The id of a live stream (`ls_...`) or a video (`ast_...`). | | `api-base` | The Vabu API host. Default `https://api.vabu.live`. | | `collector-base` | Where quality data goes. Leave it out to send none. | | `poster` | An image to show before play. | | `autoplay` | Start as soon as the key, the resource and the token function are set. | | `muted` | Start muted. The mute button changes it. | | `data-saver` | Cap renditions at 360p, or the closest lower height. | | `moments` | `feed` (default for a live stream), `manual` or `none`. See below. | | `lang` | `en` or `sw`. Without it the player follows the nearest `lang` on the page. | The property `customerToken` is a function that returns a token, or a promise of one. The player calls it when it starts and again before the token expires. Setting it, the key or the resource while the player runs starts it again with the new values. Methods: `play()`, `pause()` and `addMoment(moment)`. ## Events Every event bubbles and crosses the shadow boundary, so you can listen on the tag or above it. | Event | When | `detail` | |---|---|---| | `vabu-ready` | The session is open and playback is attached. | `{ sessionId, provider }` | | `vabu-moment` | The video reached a moment. | The moment: `{ id, type, label, payload, occurred_at, display_ms }` | | `vabu-source` | Playback moved to another source. | `{ from, to, reason }` (provider names) | | `vabu-ended` | The session ended on the server. | `{ reason }`, as in the [kit's list](https://docs.vabu.live/guides/streams/player-kit.md#when-something-goes-wrong) | | `vabu-error` | Something failed. | `{ code, message, fatal, details, cause }` | ```js player.addEventListener('vabu-error', (event) => { console.warn(event.detail.code, event.detail.details) }) ``` ## Moments With `moments="feed"` the player follows the viewer feed of the live stream and shows each moment when the video reaches it. With `moments="manual"` it does not poll. Hand moments over from your own realtime channel with `addMoment`: ```js realtime.on('shoutout', (shoutout) => { player.addMoment({ id: shoutout.id, occurred_at: shoutout.paidAt, // ISO 8601 or milliseconds, on the same clock as your stream label: shoutout.title, payload: { message: shoutout.message }, display_ms: 8000, }) }) ``` `moments="none"` turns moments off. A video has no feed. The default is a small toast with the `label` and, if `payload.message` is a string, that text. It draws text only, never HTML. To draw your own, put an element in the `moment` slot. The toast hides while something is slotted, and you listen to `vabu-moment` to know what to show: ```html ``` ## What the viewer sees A play button first. Then play and pause, a live badge for a live stream, mute, a quality menu, picture in picture where the browser has it, and full screen. The quality menu has Auto and Data saver, and adds Audio only when the stream has an audio-only rendition. The controls fade out while the video plays and come back on any movement, touch or key. When playback stops, the player says why. These are the main messages: | Case | English | Kiswahili | |---|---|---| | Replaced by another device | Playing on another device | Inacheza kwenye kifaa kingine | | Access ended | Your access has ended | Ufikiaji wako umeisha | | Connection lost, or every source failed | Check your connection | Angalia muunganisho wako | There are also messages for the device limit, no views left, a video that is not available and a browser that cannot play HLS. A replaced session offers "Watch here" and a connection problem or an unexpected error offers "Try again". The others are for your page to explain, for example with a link to buy. Everything works with the keyboard. `k` or space plays and pauses, `m` mutes, `f` toggles full screen, arrow keys move in the quality menu and Escape closes it. Animation stops when the viewer prefers reduced motion. ## Make it look like yours The player is dark, because it sits on video. The coral accent and the other tokens are CSS custom properties. Set them on the tag: ```css vabu-player { --vabu-accent: #1f7a4d; --vabu-radius: 4px; } ``` | Property | Default | Used for | |---|---|---| | `--vabu-accent` | `#ff6b57` | The play button, the selected menu item and the moment toast's edge. | | `--vabu-surface` | `rgba(10, 10, 10, 0.86)` | The control bar, the menu, the toast and error panel. | | `--vabu-surface-hover` | `rgba(255, 255, 255, 0.12)` | A hovered button. | | `--vabu-text` | `#f5f5f7` | Text and icons. | | `--vabu-text-muted` | `#c7c7cc` | Second lines of text. | | `--vabu-live` | `#ef4444` | The live badge. | | `--vabu-radius` | `8px` | Corners. | | `--vabu-font` | Inter, then the system font | All text. | The tag is a block with a 16:9 shape. Set its width, and its `aspect-ratio` if you want another shape. The toast has `part="moment"`, so `vabu-player::part(moment)` styles it too. ## Try it in test mode A test key never reaches a real provider, and a test stream has no video. The player opens a session, then tells you that nothing could be played. That is enough to check your key, your token route and your event handlers. The demo page in `packages/elements/demo` has fields for the key, the id and a token, and lists every event. Run it with `pnpm --filter @vabu/elements dev` and open `/demo/`. ## In a framework The element is a plain custom element. Set `customerToken` as a property, not an attribute. ```tsx // React useEffect(() => { ref.current!.customerToken = () => fetch('/api/vabu-token').then((res) => res.text()) }, []) return ``` In a WebView, load a page of your own that holds the tag, and allow inline media playback (`allowsInlineMediaPlayback` on iOS, `mediaPlaybackRequiresUserGesture = false` on Android if you use `autoplay`). --- # Player kit `@vabu/streams-web` is a small JavaScript package for the page that plays your stream. You keep your own player and your own buttons. The kit opens the viewer's session, keeps it alive, moves to the next source when one stops working, shows moments when the video reaches them, and sends quality data. It has no UI and no framework. It plays through the hls.js you already use. If you do not have a player, use the [Vabu player element](https://docs.vabu.live/guides/streams/player-element.md). It is built on this kit. ## Before you start You need: - a [publishable key](https://docs.vabu.live/concepts/keys-and-scopes.md), `vpk_test_...` while you build and `vpk_live_...` when you go live; - the id of a live stream (`ls_...`) or a video (`ast_...`), and an entitlement for your viewer, as in [Access](https://docs.vabu.live/api.md); - a route on your server that signs a short-lived [customer token](https://docs.vabu.live/api.md) for the signed-in viewer. ```sh npm install @vabu/streams-web hls.js ``` hls.js 1.7 or later is a peer dependency. The kit attaches to your own instance, so you choose the version and the build. ## Play a stream ```ts import Hls from 'hls.js' import { attachHls, createStreamsSession } from '@vabu/streams-web' const video = document.querySelector('video')! const session = await createStreamsSession({ apiBase: 'https://api.vabu.live', publishableKey: 'vpk_live_...', resource: { type: 'live_stream', id: 'ls_...' }, // or { type: 'asset', id: 'ast_...' } customerToken: async () => { const res = await fetch('/api/vabu-token') if (!res.ok) { throw new Error(`Token request failed: ${res.status}`) // never hand an error page to the kit as a token } return res.text() }, collectorBase: 'https://collect.vabu.live', // optional: leave it out to send no quality data }) const hls = new Hls() const playback = attachHls({ session, hls, video }) await video.play() ``` `createStreamsSession` opens the viewer's session. It checks their entitlement and the limits on it, so a viewer who is already watching on another device can be refused here. `session.grant` has the playback sources in order. `attachHls` plays the first one. From then on the kit: - sends a heartbeat every `session.grant.heartbeat_interval` seconds, with a fresh token each time; - asks your `customerToken` function for a new token a minute before the old one expires, and once more if the API refuses a token. After that it stops and tells you; - ends the session when you call `session.end()` and when the page is closed. `deviceId` is `'auto'` by default: a random id kept in `localStorage`, or a new one for each page load when storage is blocked. Pass your own string if you have one. ### The token route on your server The browser never sees your signing secret. It asks your own server, which already knows who is signed in. ```ts // Node, Express style import jwt from 'jsonwebtoken' app.get('/api/vabu-token', requireLogin, (req, res) => { const token = jwt.sign({ sub: req.user.id, aud: 'vabu' }, process.env.VABU_CUSTOMER_TOKEN_SECRET!, { algorithm: 'HS256', keyid: process.env.VABU_CUSTOMER_TOKEN_KID, expiresIn: '10m', }) res.type('text/plain').send(token) }) ``` `sub` is your own reference for the viewer, the same one you used when you granted the entitlement. ## When something goes wrong Opening can fail, and a session can end later. Both give you a `StreamsError` with a `code` you can show or log. ```ts import { StreamsError, type StreamsSession } from '@vabu/streams-web' let session: StreamsSession try { session = await createStreamsSession(options) } catch (error) { if (error instanceof StreamsError) showProblem(error.code, error.reason) throw error } session.on('ended', (error) => showEnded(error.reason)) session.on('error', (error) => console.warn(error.code, error.fatal)) ``` | `code` | What happened | What to say | |---|---|---| | `concurrency_limit_reached` | The viewer is already watching and the entitlement allows one stream at a time. | Playing on another device. | | `device_limit_reached` | The entitlement allows fewer devices than this viewer has used. | They reached their device limit. | | `view_limit_reached` | They used all the views the entitlement allows. | They have no views left. | | `access_denied` | No entitlement, or it expired, was revoked or paused. `reason` says which. | Their access has ended. | | `invalid_customer_token` | Your token was refused, even after the kit asked for a new one. `reason` says why. | Check your signing key and clock. | | `token_unavailable` | Your `customerToken` function failed. The reason is in `error.cause`. | Try again. | | `network_error` | The API could not be reached. | Check the connection. | | `no_playback_source` | The stream or video has no playable source. | Not available right now. | When a session ends after it opened, `ended` carries `error.reason`: | `reason` | Meaning | |---|---| | `replaced_by_newer_session` | The viewer started watching on another device and the entitlement allows one. | | `entitlement_inactive` | The entitlement was revoked, paused or expired. | | `missed_heartbeats` | The session lapsed, usually because the connection was down. | | `token_rejected` | The customer token was refused twice. | | `client` | Your code, or the closing page, ended it. | When a session ends the kit stops loading and pauses the video, so access stops with the session. A failed heartbeat on a bad connection is reported through `error` with `fatal: false` and tried again soon, inside the time the server waits before it drops the session. ## Sources and failover A grant lists sources in order. The kit plays the first and watches for trouble. It moves to the next source: - on a fatal network error; - on a second fatal media error within a minute of a recovery attempt (the first one gets `recoverMediaError()`); - after `failover.max_errors` source errors inside `failover.window_seconds`. By default hls.js waits more than a minute before it gives up on a dead source. The kit sets `manifestLoadPolicy`, `playlistLoadPolicy` and `fragLoadPolicy` on your instance so that a dead source becomes fatal in about 10 seconds. A live stream starts again at the live edge on the next source. A video starts again where it was. If you already set those policies, the kit replaces them. When there is no other source to move to, the kit loads the same one again, with the newest token, after 2, 4, 8 and 16 seconds, and then every 16 seconds, for about a minute. While the browser is offline it waits for the network and does not use up retries. Only after that does it report `playback_failed`. Soft errors on the only source never end playback. A source is healthy again after 5 seconds of playing. With native HLS (Safari without hls.js) the kit cannot swap tokens in requests. About a minute before the token the player loaded expires, it loads the newest URL from the last heartbeat and carries on from the live edge. A native player that waits for 10 seconds without moving is treated as a source error. ```ts playback.on('source', ({ from, to, reason }) => { console.log(`${from.provider} to ${to.provider}: ${reason}`) }) playback.on('error', (error) => { // Every source failed. Show a message, then try again from the first one: playback.retry() }) ``` Each heartbeat returns a fresh grant. When only the token in a URL changed, nothing reloads and the new token goes out with the requests that follow. When Vabu reorders the sources and sets `switch` to `"now"`, the kit moves to the new first source. With `"on_error"` it remembers the order for the next failure. For this to work, keep hls.js's default loader: the kit swaps tokens in `xhrSetup` and `fetchSetup`, and calls yours after it. To treat a frozen live playlist as an error as well, set `liveMaxUnchangedPlaylistRefresh` in your hls.js config. Those errors count towards `max_errors`. ## Moments in step with the video A moment is something your server puts on the stream, such as a paid shoutout. The kit shows it when the viewer's video reaches it, not when it arrives. ```ts playback.moments.followFeed({ intervalMs: 2000 }) // polls the viewer feed with your publishable key playback.moments.add({ id, occurred_at, label, type, payload, display_ms }) // or hand one over from your own realtime channel playback.on('moment', (moment) => overlay.show(moment.label, moment.payload)) playback.on('momentend', (moment) => overlay.hide(moment.id)) ``` How it works: - The playhead's clock is `hls.playingDate`, from the `EXT-X-PROGRAM-DATE-TIME` tags in the stream. On a browser that plays HLS natively, it is `video.getStartDate()` plus `video.currentTime`. - Without those tags the kit estimates the clock as the current time minus `hls.latency`, the distance from the live edge. - A moment shows 150 ms early, so that drawing it is not late. It shows once, stays for `display_ms`, and is dropped if it is more than 2 minutes behind the playhead. - Every pipeline stamps the video with its own clock. If one source runs early or late, set `playback.source.pdt_offset_ms`, the milliseconds to add to that source's clock. It is 0 by default. Draw `label` and `payload` as text, never as HTML. The feed is public to anyone who knows the stream id. ## Data saver and audio only ```ts playback.setDataSaver(true) // renditions go no higher than 360p, or the closest lower height playback.setAudioOnly(true) // returns false when the source has no audio-only rendition ``` hls.js hides audio-only variants from its own level list, so the kit reads them from the manifest. Turning audio only on or off loads the source again, and playback stops for a moment. ## iPhone and native HLS When hls.js cannot run but the browser plays HLS itself (iPhone Safari before 17.1), the kit sets `video.src`. Sessions, moments and quality data work the same way. Failover works on the video element's `error` event. Data saver and audio only need hls.js. Leave `hls` out to use native playback only: ```ts attachHls({ session, video }) ``` ## Quality data With a `collectorBase`, the kit sends beacons to `/v1/playback` on the collector: `start` with the startup time, `heartbeat` every 30 seconds while the video plays, `rebuffer`, `error` and `end`. Counters (`watch_ms`, `rebuffer_count`, `rebuffer_ms`, `errors`) add up over the session. Each beacon also has the current and average bitrate, the provider of the source in use, the session id, the playback id, a coarse device type (`mobile`, `desktop` or `tv`) and the connection type when the browser offers one. It never sends the viewer's name, contact details or the customer token. ## Clean up ```ts playback.destroy() // detaches from the video and puts your hls.js config back await session.end() // frees the device slot hls.destroy() // yours to destroy ``` --- # Streams quickstart In about twenty minutes you will create a live stream, give one customer access, open a viewing session from a browser and play a test pattern. Part 1 uses test keys and costs nothing. Part 2 uses live keys to show real video. You need: - A tenant with Streams switched on. If a call returns `403 product_not_enabled`, ask your Vabu contact to switch it on. - `curl`, Node 18 or later, and [ffmpeg](https://ffmpeg.org/download.html) for part 2. - A secret key and a publishable key. See [environments and keys](https://docs.vabu.live/get-started/environments-and-keys.md). Set your host and your test secret key: ```sh export API="https://api.vabu.live" export SK="vk_test_..." ``` ## Part 1: build and check with test keys A test key never reaches a real video network, so it never bills. Streams you create are `simulated`: they stay `idle`, nothing accepts a broadcast and the playback link does not play. Everything else is real, so you can build and check your whole flow. ### 1. Create a publishable key The browser uses a publishable key. Create one with your secret key. The full key is shown once. ```sh curl -X POST "$API/v1/keys" \ -H "Authorization: Bearer $SK" \ -H "Idempotency-Key: quickstart-key-1" \ -H "Content-Type: application/json" \ -d '{ "type": "publishable", "name": "Quickstart page" }' ``` Copy `key` from the response into `PK`: ```sh export PK="vpk_test_..." ``` ### 2. Create a live stream ```sh curl -X POST "$API/v1/streams/live_streams" \ -H "Authorization: Bearer $SK" \ -H "Idempotency-Key: quickstart-stream-1" \ -H "Content-Type: application/json" \ -d '{ "external_id": "quickstart-venue", "metadata": { "venue": "Kasarani" } }' ``` ```json { "data": { "id": "ls_01J9ZQ4M8T3K6Y2E5W7N0B1C9D", "object": "live_stream", "provider": "simulated", "status": "idle", "latency_mode": "low", "reconnect_window": 60, "stream_key": "sim-...", "ingest": { "rtmp_url": "...", "srt_url": "..." }, "playback_ids": [{ "id": "pb_...", "policy": "signed" }], "livemode": false } } ``` Keep the `id`: ```sh export STREAM="ls_01J9ZQ4M8T3K6Y2E5W7N0B1C9D" ``` ### 3. Give a customer access Your own checkout would make this call after a payment. Use your own id for the customer. Here it is `user_42`. ```sh curl -X POST "$API/v1/access/entitlements" \ -H "Authorization: Bearer $SK" \ -H "Idempotency-Key: quickstart-grant-1" \ -H "Content-Type: application/json" \ -d '{ "customer": "user_42", "resource": { "type": "ls", "id": "'"$STREAM"'" }, "source": "tenant_grant", "source_ref": "quickstart-grant-1", "limits": { "max_devices": 2, "max_concurrent_sessions": 1, "concurrency_policy": "replace_oldest" } }' ``` You get `201` and an entitlement with `"status": "active"`. The resource type for a live stream is `ls`, and for a video asset it is `ast`. ### 4. Create a customer token key Your server signs a short token for each viewer. The key that signs it is made once: ```sh curl -X POST "$API/v1/access/customer_token_keys" \ -H "Authorization: Bearer $SK" \ -H "Content-Type: application/json" \ -d '{ "name": "quickstart" }' ``` The response holds `id` (starts `ctk_`) and `secret` (starts `cts_`). The secret is shown once. Keep it on your server. Sign a token for `user_42`. This needs no packages: ```js // sign-token.mjs import { createHmac } from 'node:crypto' const [keyId, secret, customer] = process.argv.slice(2) const b64 = (value) => Buffer.from(JSON.stringify(value)).toString('base64url') const now = Math.floor(Date.now() / 1000) const head = b64({ alg: 'HS256', typ: 'JWT', kid: keyId }) const body = b64({ sub: customer, aud: 'vabu', iat: now, exp: now + 600 }) const signature = createHmac('sha256', secret).update(`${head}.${body}`).digest('base64url') console.log(`${head}.${body}.${signature}`) ``` ```sh export TOKEN=$(node sign-token.mjs ctk_... cts_... user_42) ``` The token lives for 10 minutes. Real apps sign one each time the page loads. See [customer tokens](https://docs.vabu.live/concepts/customer-tokens.md) for PHP, Python and Go. ### 5. Open a session from the browser The browser sends the publishable key and the customer token. It never sees your secret key. Paste this into the console of any web page, after setting the three values: ```js const res = await fetch('https://api.vabu.live/v1/streams/playback_tokens', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Vabu-Publishable-Key': 'vpk_test_...', Authorization: 'Bearer ', }, body: JSON.stringify({ resource: 'ls_...', device_id: 'browser-1' }), }) console.log(await res.json()) ``` ```json { "data": { "id": "as_01J9ZQ7P2H5V8X1A4D6F9G3JKM", "object": "access_session", "customer": "user_42", "device_id": "browser-1", "state": "active", "heartbeat_interval": 30, "grant": { "sources": [{ "provider": "simulated", "url": "https://.../....m3u8?token=..." }], "failover": { "max_errors": 3, "window_seconds": 20 }, "token_expires_at": "2026-10-12T20:11:30Z" } } } ``` That is the whole viewer path: access checked, device counted, a playback link issued. Run it again with another `device_id` and, with `replace_oldest`, the first session ends at its next heartbeat, which answers `403 session_ended` with the reason `replaced_by_newer_session`. Remove the entitlement with `DELETE /v1/access/entitlements/{id}` and the next call is refused with `403 access_denied`. ## Part 2: see real video with live keys Live keys use a real video network, so you can watch a picture. Stop ffmpeg when you are done. ### 6. Repeat steps 1 to 4 with live keys Use your `vk_live_...` secret key. Test and live data never mix, so the publishable key, the stream, the entitlement and the token key all need to be made again, and the new publishable key starts with `vpk_live_`. This time the live stream comes with a real ingest address: ```json { "data": { "id": "ls_...", "status": "idle", "stream_key": "...", "ingest": { "rtmp_url": "rtmps://...", "rtmp_backup_url": "rtmp://...", "srt_url": "srt://...", "whip_url": null } } } ``` ### 7. Send a test pattern with ffmpeg This command builds a moving test picture and a tone, encodes them the way a venue encoder would and sends them to your stream. Put the `rtmp_url` and the `stream_key` from step 6 in the two variables. ```sh RTMP_URL="rtmps://..." STREAM_KEY="..." ffmpeg -re \ -f lavfi -i "testsrc2=size=1280x720:rate=30" \ -f lavfi -i "sine=frequency=440:sample_rate=48000" \ -c:v libx264 -preset veryfast -tune zerolatency -pix_fmt yuv420p \ -b:v 2500k -maxrate 2500k -bufsize 5000k \ -g 60 -keyint_min 60 -sc_threshold 0 \ -c:a aac -b:a 128k -ar 48000 \ -f flv "$RTMP_URL/$STREAM_KEY" ``` `-g 60` at 30 frames a second gives a keyframe every 2 seconds, which is what the player needs. Leave it running. Within about 30 seconds the stream moves from `idle` to `connecting` to `active`: ```sh curl "$API/v1/streams/live_streams/$STREAM" -H "Authorization: Bearer $SK" ``` You can also hear about it by webhook: `streams.live_stream.active`. See [stream events](https://docs.vabu.live/concepts/stream-events.md). ### 8. Play it with hls.js Save this as `watch.html` and serve it from your own site (any static server works). It opens a session, plays the link and keeps the session alive. The page gets its customer token from your own server: `/customer-token` is a route you write that signs a token for the signed-in viewer, as in step 4. ```html Watch

Starting...

``` You should see the test pattern within a few seconds. Close the tab and the session lapses within 90 seconds. Open the page on a second device and, with `replace_oldest`, the first one stops at its next heartbeat. If you get an error, read the code in the message. The usual ones are `access_denied` (no active entitlement for that customer), `invalid_customer_token` (see the `reason` in the details) and `stream_disabled`. All codes are on the [error codes](https://docs.vabu.live/errors.md) page. ## What you built You created a stream, granted access after a payment of your own, signed a customer token on your server and played a protected link in the browser. The same calls run a real night. Next: - [Going live](https://docs.vabu.live/guides/streams/going-live.md) covers encoder settings and the checklist before showtime. - [Paid access](https://docs.vabu.live/guides/streams/paid-access.md) covers device limits, revoking and replays. --- # Series, subscriptions and certificates Use this to sell video people watch on their own time. Creators publish single videos, series of episodes and courses. Viewers buy a video, a whole series or one episode, rent a video for a set time, or pay for a monthly plan. Your player gets a short-lived token for each video, your app tracks how far each viewer has watched, and a viewer who finishes a course earns a certificate anyone can check. Live broadcasts are the other side of Streams: see [Streams](https://docs.vabu.live/guides/streams/index.md) and [paid access](https://docs.vabu.live/guides/streams/paid-access.md). ## How the pieces fit | Content type | What it is | |---|---| | `standalone_video` | One video, with an optional free trailer. | | `playlist` | A series. Episodes in order, each with its own price and release date if you want. | | `course` | Chapters with video and text lessons. Finishing every lesson earns a certificate. | A client uses them in this order: 1. A creator makes a draft with a proposed price, adds episodes or lessons, uploads each video and adds a trailer. 2. The creator asks for approval. Tenant staff review the content and publish it. 3. Your app lists the catalogue. Anyone can watch the trailer for free. 4. A viewer buys with `POST /v1/plus/orders`. The `purchase_type` is `lifetime`, `rental` (with `rental_hours`) or `episode`. The response holds a `checkout_url`, and a guest only needs a name and email. A viewer can also start a plan. 5. To play, your app asks for a playback token for the item, then reports progress as the viewer watches. 6. When a viewer completes every required lesson in a course, a certificate is issued. A plan costs a set price each month and covers either one organization's catalogue or every organization in the tenant. Tenant staff create plans. Each month the subscriber pays for the next cycle with `POST /v1/plus/subscriptions/{subscription}/renew`, which returns a checkout link. Nothing is charged automatically. A cancel ends the plan at the end of the period, or at once with `at_period_end` set to `false`. A playback token lasts about ten minutes. The response also gives a `session_id`, `refresh_before_expiry_seconds` and `heartbeat_interval_seconds`. Refresh the token before it expires and send a heartbeat as often as the response says. One device can watch an item at a time. Asking for a token on a second device moves playback there, and the first device gets `403` `SESSION_TAKEN_OVER` on its next refresh or heartbeat. Access is checked again on every refresh and progress ping, so a refund or an ended rental stops playback. An episode before its release date returns `ITEM_NOT_RELEASED`. A video counts as complete at 95 percent. Text lessons are completed with `POST /v1/plus/watch/items/{item}/complete`. A certificate has a serial number. The public check shows the course title and the holder's first name only. Content prices and order amounts are decimal KES, such as `500.00`. Payout and fee calls use cents. ## What you can call today ### Creators | Method | Path | What it does | |---|---|---| | GET | `/v1/organizer/plus/contents` | List your content with status counts. | | POST | `/v1/organizer/plus/contents` | Create a draft video, series or course. | | GET | `/v1/organizer/plus/contents/{content}` | Get one with its items, trailer and review notes. | | PUT | `/v1/organizer/plus/contents/{content}` | Update a draft or rejected content. | | DELETE | `/v1/organizer/plus/contents/{content}` | Delete a draft, rejected or retired content. | | POST | `/v1/organizer/plus/contents/{content}/request-approval` | Send it to tenant staff for review. | | POST | `/v1/organizer/plus/contents/{content}/retire` | End it. Past purchases keep working. | | GET | `/v1/organizer/plus/contents/{content}/items` | List episodes or lessons. | | POST | `/v1/organizer/plus/contents/{content}/items` | Add an episode, lesson or chapter heading. | | POST | `/v1/organizer/plus/contents/{content}/items/reorder` | Change the order of items. | | GET | `/v1/organizer/plus/contents/{content}/items/{item}` | Get one item. | | PUT | `/v1/organizer/plus/contents/{content}/items/{item}` | Edit an item's title, price or release date. | | DELETE | `/v1/organizer/plus/contents/{content}/items/{item}` | Delete an item nobody has bought. | | POST | `/v1/organizer/plus/contents/{content}/items/{item}/upload` | Start an upload for an item's video. | | GET | `/v1/organizer/plus/contents/{content}/items/{item}/upload` | Check the upload. | | DELETE | `/v1/organizer/plus/contents/{content}/items/{item}/upload` | Cancel the upload. | | GET | `/v1/organizer/plus/contents/{content}/trailer` | Get the trailer and its upload state. | | POST | `/v1/organizer/plus/contents/{content}/trailer` | Add a trailer and start its upload. | | POST | `/v1/organizer/plus/contents/{content}/trailer/replace` | Replace the trailer. | | DELETE | `/v1/organizer/plus/contents/{content}/trailer` | Remove the trailer. | | GET | `/v1/organizer/plus/contents/{content}/fee-preview` | See what you keep from a gross amount. | | POST | `/v1/organizer/plus/payout-requests` | Ask to be paid out. Owners only. | ### Viewers and the public (sign-in optional) | Method | Path | What it does | |---|---|---| | GET | `/v1/plus/contents` | Browse the catalogue. Filter by `category`, `type` or `search`. | | GET | `/v1/plus/contents/categories/top` | List the top categories. | | GET | `/v1/plus/contents/{content}` | Get one content with its items. | | GET | `/v1/plus/contents/{content}/trailer` | Get the trailer details. | | POST | `/v1/plus/contents/{content}/trailer/playback-token` | Get a token to play the trailer. | | POST | `/v1/plus/orders` | Buy content. Returns the order and a `checkout_url`. | | GET | `/v1/plus/orders/{code}` | Check an order by its code. | | GET | `/v1/plus/subscription-plans` | List active plans. | | POST | `/v1/plus/contents/{content}/items/{item}/playback-token` | Get a token to play an item. Send the `order_code` or `buyer_email` if the viewer is a guest. | | POST | `/v1/plus/playback-tokens/refresh` | Get a fresh token for a `session_id`. | | POST | `/v1/plus/playback-tokens/heartbeat` | Keep the session alive and learn if another device took over. | | GET | `/v1/plus/certificates/verify/{serial}` | Check a certificate by its serial number. | ### Signed-in viewers | Method | Path | What it does | |---|---|---| | POST | `/v1/plus/subscriptions/checkout` | Start a plan. Returns a `checkout_url`. | | POST | `/v1/plus/subscriptions/{subscription}/renew` | Pay for the next month. | | GET | `/v1/plus/my-subscriptions` | List the viewer's plans. | | POST | `/v1/plus/my-subscriptions/{subscription}/cancel` | Cancel a plan. | | GET | `/v1/plus/my-library` | List the viewer's active purchases. | | POST | `/v1/plus/watch/ping` | Report position and time watched. | | POST | `/v1/plus/watch/items/{item}/complete` | Mark a text lesson done. | | GET | `/v1/plus/watch/items/{item}/progress` | Get the viewer's progress on an item. | | GET | `/v1/plus/my-certificates` | List the viewer's certificates. | | GET | `/v1/plus/my-certificates/{certificate}` | Get one certificate by serial number. | Tenant staff review content and manage plans from the tenant admin API. Full parameters and responses are in the [API reference](https://docs.vabu.live/api.md). Here is a guest buying a video: ```sh curl -X POST https://api.vabu.live/v1/plus/orders \ -H "X-Tenant-Code: $TENANT_CODE" \ -H "Content-Type: application/json" \ -d '{ "content_id": "550e8400-e29b-41d4-a716-446655440001", "purchase_type": "lifetime", "customer": { "name": "Amina Wairimu", "email": "amina@example.co.ke", "phone": "+254712345678" } }' ``` The order comes back `pending` with a `code` such as `PLS-A1B2-C3D4` and a `checkout_url`. Once the buyer pays, that code lets them play the video. ## Calling these endpoints The viewer and public routes marked sign-in optional take only `X-Tenant-Code`, and the certificate check needs nothing. `GET /v1/plus/contents` and `GET /v1/plus/contents/{content}` also accept a secret, restricted (`streams:read`) or publishable key. Every other route takes a signed-in user token (`Authorization: Bearer ...`, a Vabu ID or Payd ID token) plus `X-Tenant-Code`, in live mode. Creator routes also accept an optional `X-Organization-Id` header to pick which organization you act for. Test keys and publishable keys are refused on these routes today, apart from the two catalogue reads. See [which credentials each endpoint takes](https://docs.vabu.live/concepts/keys-and-scopes.md#which-credentials-each-endpoint-takes). On creator routes, any active member can read, and changes need the Owner, Admin or Editor role. For paging and error shapes, see [pagination](https://docs.vabu.live/concepts/pagination.md) and [errors](https://docs.vabu.live/concepts/errors.md). ## Events you can subscribe to | Event | When it fires | |---|---| | `streams.content.purchased` | A viewer paid for a video, series or episode and now has access. | | `access.entitlement.granted` | A purchase gave a viewer access. | | `access.entitlement.extended` | A viewer's access end date changed. | | `access.entitlement.revoked` | A viewer's access was taken away, for example after a refund. | | `access.entitlement.expired` | A viewer's access ran out, for example a finished rental. | See [events](https://docs.vabu.live/events/index.md) for the full payloads and [webhooks](https://docs.vabu.live/concepts/webhooks.md) for delivery and signatures. ## Related - [Streams](https://docs.vabu.live/guides/streams/index.md) for live broadcasts. - [Paid access](https://docs.vabu.live/guides/streams/paid-access.md) for the access model behind these purchases. - [Payments](https://docs.vabu.live/products/payments.md) for checkout and payouts. - [Fanbases](https://docs.vabu.live/products/fanbases.md) for the community around a creator. --- # Staying on air A live stream can have a second network ready to take over. The venue keeps one encoder and one ingest URL. If the first network stops serving video, viewers move to the second one without a reload. This page is for the developer who sets up the stream and builds the player. The venue's crew does not need to do anything. ## Turn it on Add `standby` when you create the live stream, or later with a `PATCH`. Use `auto` to get the other network, or name one (`mux` or `cloudflare_stream`). ```bash curl -X POST https://api.vabu.live/v1/streams/live_streams \ -H "Authorization: Bearer $VABU_SECRET_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "standby": "auto" }' ``` The response has `standby` (what you asked for) and `standby_state`: | `standby_state` | Meaning | |---|---| | `none` | No standby | | `ready` | The second network is set up and the stream forwards to it | | `failed` | The second network could not be set up. The stream itself works. Send `standby` again to retry. | A standby problem never stops you from going live. Check `standby_state` before a big show. A standby cannot be added, changed or removed while the stream is live: the request returns `409 stream_live`, so change it before you go on air. If removing a standby fails, `standby_state` is `failed` and the standby stays on record. Send `standby` again to finish. Deleting the stream does the same: if the standby will not come down, the delete returns `502 standby_teardown_failed`, the stream stays, and you send the delete again. Some networks forward the standby through one of the stream's 6 simulcast slots, which leaves 5 for your own targets. Naming the network the stream is already on returns `422 standby_same_plane`. A Cloudflare Stream standby needs that network set up for your account, like any stream on it. ## What viewers get When a stream has a ready standby, the playback grant lists two sources: the primary, then the standby. Each has its own link and token. ```json { "sources": [ { "provider": "mux", "url": "https://stream.mux.com/...m3u8?token=...", "pdt_offset_ms": 0 }, { "provider": "cloudflare_stream", "url": "https://customer-...cloudflarestream.com/.../manifest/video.m3u8", "pdt_offset_ms": 0 } ], "switch": "none" } ``` `switch` tells the player what to do: | `switch` | When | What the player does | |---|---|---| | `none` | Both networks are fine | Play the first source. Move on after repeated errors, as before. | | `on_error` | The first network looks unhealthy | Play the first source and move to the second on the first error. | | `now` | The first network is down | The standby is already first. Play it. | Every heartbeat returns the current order, so a viewer is moved while watching. The [player kit](https://docs.vabu.live/guides/streams/player-kit.md) and the [player element](https://docs.vabu.live/guides/streams/player-element.md) do this for you. If you write your own player, apply the new order and `switch` from each heartbeat. ## How Vabu decides a network is unhealthy Once a minute Vabu fetches the playlist of each active stream on each network it runs on and checks that the video keeps advancing. A stream whose playlist does not move for two checks in a row is stuck. Only streams with a standby count, and only when the other network is still moving: a stream stuck on both is a problem with the encoder, not the network. One stuck stream marks its network degraded. When half or more of at least three streams are stuck, the network is down. A network is healthy again after three good checks in a row. When a network changes status, every live stream on it gets an event: - `streams.live_stream.unhealthy`, with `data.object.health` showing the status, the network and the reason. - `streams.live_stream.recovered`, when the network is healthy again. Use them to tell the crew or your support team. See [stream events](https://docs.vabu.live/concepts/stream-events.md). ### Get a message when it happens Set `alert_contact` on a live stream to a phone number in international format, for example `+254712345678`. When the stream's network turns degraded or down, Vabu sends that number one WhatsApp message, or an SMS if WhatsApp does not go through. You get one message per incident, not one per check, and a new one only after the network has recovered and failed again. Streams in test mode never send. Send `null` to turn it off. ```bash curl -X PATCH https://api.vabu.live/v1/streams/live_streams/ls_123 \ -H "Authorization: Bearer $VABU_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"alert_contact": "+254712345678"}' ``` ## Operators Vabu operators can force a network down or healthy, for example during a provider incident, and hand it back to the checks afterwards. Each change is recorded in the audit log. If you need this done for your account, tell your Vabu contact. --- # Vabu Media Vabu Media is Vabu's own plane. Your encoder sends video to Vabu, Vabu makes a quality ladder from it, stores it, and serves it from the edge. You call the same Streams endpoints as on any other plane, and the `provider` field of a live stream says `vabu_media`. Vabu sets the plane for your account. Test mode runs on the simulated plane and never reaches Vabu Media. ## What it can do | Capability | Vabu Media | |---|---| | Live streaming (`live`) | Yes | | Recordings of live streams (`vod`) | Yes | | Audio-only live streams (`audio_only`) | Yes | | SRT ingest (`srt_ingest`) | Yes | | WHIP ingest from a browser (`whip_ingest`) | Yes | | Signed playback (`signed_playback`) | Yes, always | | Program date time in playlists (`program_date_time`) | Yes | | Signing keys of your own (`delegated_signing`) | Dedicated environment only | | Simulcast to other platforms (`simulcast`) | No | | Low-latency HLS (`ll_hls`) | No | A request this plane cannot do is refused with `422 provider_rejected` before anything is created. ## Going live Create a live stream as usual. The response carries `stream_key` and `ingest`. Send RTMP to `ingest.rtmp_url` with the stream key, or use `ingest.srt_url` or `ingest.whip_url`. Send a keyframe every 2 seconds. Vabu builds the ladder from what you send: with the default `economy` ladder a 720p H.264 source plays as it is, next to a 360p picture and an audio-only rendition. The `standard` ladder adds 480p and 240p. `reset_stream_key` gives a new key and disconnects the encoder that was using the old one. `disable` refuses the encoder and ends the broadcast. ## Playback A playback grant returns one source. Its url looks like this: ``` https://media.vabu.live/t///live/master.m3u8 ``` The token is part of the path, so the segment and playlist addresses inside the playlists carry it without any change in your player. Play the url in any HLS player. A token that is wrong, expired or made for another stream is refused with `403`. A recording plays the same way, from a url that ends in `/recordings//master.m3u8`. A playback url is a bearer url: anyone who has it can play until the token expires, so do not post it. The viewer is not bound to the token. Known limit: a token is made for a channel, not for one recording. A grant for a recording also plays that channel's live stream and its other recordings, because recordings share the live segments. If you sell recordings apart from the live event, do not rely on the token to keep them apart yet. Tokens last as long as the viewer session plus a short grace period, and never more than 24 hours. The heartbeat of a viewer session keeps replacing them. ## Recordings Every broadcast is recorded. When the encoder leaves and the last segments are stored, Vabu creates an asset with `source: "recording"`, `status: "ready"` and one signed playback id, and sends `streams.asset.created` and `streams.asset.ready`. A broadcast shorter than a second leaves no recording. An encoder that drops and reconnects starts a new broadcast, so it makes a new recording. ## Where it differs - **Always signed.** A live stream created with `playback_policy: public` answers `422 provider_rejected`. - **One playback id per resource.** `POST /v1/streams/playback_ids` and `DELETE /v1/streams/playback_ids/{id}` answer `422 provider_rejected`. Playback is always signed. - **No uploads or imports.** Assets on this plane are recordings. `POST /v1/streams/uploads` and `POST /v1/streams/assets` answer `422 provider_rejected`. - **No clips yet.** `POST /v1/streams/assets/{id}/clips` answers `422 provider_rejected`. - **Ladder.** The ladder is set for the account. It cannot be changed on a live stream that exists. - **Ending a broadcast.** `complete` has no effect. The broadcast ends when the encoder leaves. - **Deleting.** Deleting a live stream or an asset removes it from the API. The stored media stays in storage for now. See [Delivery networks and capabilities](https://docs.vabu.live/concepts/delivery-networks.md) for how to write code that does not depend on the plane. --- # Vabu OS Docs Vabu OS is the infrastructure for live experiences in Africa. One API for events, fans, commerce, streaming, messaging and payments. ## Who it is for Vabu OS is for the people who build and run live experiences: a nightlife app that streams its DJs, a church that streams its services, a sports league that sells match passes, an events company that sells tickets and sends reminders. If your product has a crowd, a ticket, a stream or a fan, there is a piece here for it. ## Bring your own login and payments, or use ours You choose how much of the platform to take. - **Keep your own login.** Your server tells Vabu who is signed in, with a short token. Or use Vabu ID, with phone codes on WhatsApp and SMS, email links and Google. - **Keep your own payments.** For streams, you take payment your way and make one call to give the customer access. For tickets and shop orders, Vabu's checkout takes M-Pesa and cards. - **Use it all.** Login, payments and every product on one set of keys, with one customer across them. Read [ways to use Vabu OS](https://docs.vabu.live/concepts/integration-modes.md) and [sign-in options](https://docs.vabu.live/concepts/sign-in-options.md). ## The products work together A ticket sale earns fan points. A check-in earns more. A message brings people back. A stream reaches the people who could not come. Every interaction is an event your server can hear about, so one product feeds the next. See [how the products work together](https://docs.vabu.live/products/how-products-work-together.md). ## Start building 1. **[Get a test key](https://docs.vabu.live/get-started/environments-and-keys.md).** Test keys come with every tenant from the first minute. 2. **[Make your first request](https://docs.vabu.live/get-started/first-request.md).** Copy a snippet in curl, TypeScript, PHP, Python or Go. 3. **[Pick a product](https://docs.vabu.live/products/index.md).** Read what it does and which calls exist today. 4. **Streaming? Try the [Streams quickstart](https://docs.vabu.live/guides/streams/quickstart.md).** Create a live stream, give a customer access and play it in about twenty minutes. 5. **Switch to live keys** when your test run works. The [API reference](https://docs.vabu.live/api.md) lists every endpoint. Features that are not in the API yet are listed on [What's coming](https://docs.vabu.live/whats-coming.md). --- # Commerce and shops Use Commerce to give a creator, club or church a merch shop. Fans browse products on a storefront, you price their cart with shipping and tax, and an order records what they bought. Your organizers add products, submit them for review and move orders through fulfilment. ## How the pieces fit | Resource | What it is | |---|---| | Storefront | The list of a creator's published products. It hangs off a fanbase or a creator page (a capsule). Both URLs return the same products. | | Product | Something to sell: `physical`, `digital`, `bundle` or `external`. It has a price, optional shipping and tax, and optional variants with stock. | | Cart | A priced list of items with shipping and tax. It is priced on request and not saved. | | Order | A buyer's purchase with a code like `MCH-ABC123DEF4`. It starts `pending` and `unfulfilled`. | | Fulfilment status | Where the order is for the organizer: `unfulfilled`, `processing`, `fulfilled` or `cancelled`. | A client uses them in this order: 1. An organizer creates a product. It starts as a `draft`. 2. The organizer submits it. The product moves to `pending_review` and a tenant admin approves or rejects it. 3. An approved product is `published`. A published, public product shows on the storefront. 4. Your app lists the storefront and prices a cart with `POST /v1/merch/cart`. 5. Your app creates the order with the same items and the buyer's details. 6. The organizer works the order from `unfulfilled` to `fulfilled`. Product `price`, `shipping_fee` and `tax_amount` are set in KES, for example `1500`. Carts and orders return integer cents in fields such as `total_cents`. Shipping and tax are flat amounts set on each product. One cart or order can only hold products from one creator, and a sold-out variant is refused with a `422`. ## What is not available yet Paying for a merch order is not available through the API today. Creating an order records it as `pending` with its totals and `checkout_url` set to `null`, and nothing marks a merch order `paid`. A refund request records the request on the order. It does not return money. See [what's coming](https://docs.vabu.live/whats-coming.md) for merch checkout. ## What you can call today ### Browse a storefront (no sign-in) | Method | Path | What it does | |---|---|---| | GET | `/v1/public/capsules/{slug}/storefront` | List a creator's published products, 12 to a page. | | GET | `/v1/public/capsules/{slug}/storefront/products/{product}` | Get one product with its variants. | | GET | `/v1/public/fanbases/{slug}/storefront` | The same list, by fanbase slug. | | GET | `/v1/public/fanbases/{slug}/storefront/products/{product}` | The same product, by fanbase slug. | If the creator also sells somewhere else, the storefront response carries `external_storefront_url`. A fanbase that is hidden answers `404`. ### Cart and orders | Method | Path | What it does | |---|---|---| | POST | `/v1/merch/cart` | Price a cart. Returns line items, `subtotal_cents`, `shipping_cents`, `tax_cents` and `total_cents`. No sign-in. | | POST | `/v1/merch/orders` | Create an order from the same items and the buyer's `customer` details. No sign-in. | | GET | `/v1/merch/orders/{code}` | Get an order and its status by code. No sign-in. | | POST | `/v1/merch/orders/{code}/refund-request` | Record a refund request, with an optional `reason`. Needs a signed-in user. | Here is a cart priced for two of one product: ```sh curl -X POST "https://api.vabu.live/v1/merch/cart" \ -H "X-Tenant-Code: your_tenant_code" \ -H "Content-Type: application/json" \ -d '{"items":[{"product_id":"550e8400-e29b-41d4-a716-446655440000","quantity":2}],"customer":{"name":"Amina Wairimu"}}' ``` ### Organizer: products and orders | Method | Path | What it does | |---|---|---| | GET, POST | `/v1/organizer/merch/products` | List products with a count for each status, or create a draft. | | GET, PATCH | `/v1/organizer/merch/products/{product}` | Read or update a product. | | POST | `/v1/organizer/merch/products/{product}/publish` | Submit a product for tenant review. | | POST | `/v1/organizer/merch/products/{product}/media` | Upload an image, up to 10 MB, to the `poster` or `gallery` collection. | | GET | `/v1/organizer/merch/orders` | List your orders. Filter by `status`. | | PATCH | `/v1/organizer/merch/orders/{order}` | Set the fulfilment status of an order. | The full parameters for each call are in the [API reference](https://docs.vabu.live/api.md). ## Calling these endpoints The storefront, cart and order routes take only `X-Tenant-Code`. The refund request needs a signed-in user token (`Authorization: Bearer ...`, a Vabu ID or Payd ID token) plus `X-Tenant-Code`. Every `/v1/organizer/*` route takes that same token and header, in live mode, and accepts an optional `X-Organization-Id` header to pick which organization you act for. Creating or changing products needs the Owner, Admin or Editor role. Test keys and publishable keys are refused on these routes today. See [which credentials each endpoint takes](https://docs.vabu.live/concepts/keys-and-scopes.md#which-credentials-each-endpoint-takes). For paging and error shapes, see [pagination](https://docs.vabu.live/concepts/pagination.md) and [errors](https://docs.vabu.live/concepts/errors.md). ## Related - [Fanbases](https://docs.vabu.live/products/fanbases.md) for the community a storefront belongs to. - [Payments](https://docs.vabu.live/products/payments.md) for how checkout works on events today. - [How the products work together](https://docs.vabu.live/products/how-products-work-together.md) for how the products connect. - [What's coming](https://docs.vabu.live/whats-coming.md) for merch checkout. --- # Events and ticketing Use Events to put shows, services, matches and meetups in your app, sell tickets, give every guest their own ticket, and check people in at the door. Your organizers get tools to create and publish events, set ticket types and sessions, add coupon codes, manage attendees and read orders. Buyers pay with M-Pesa or card through Payd. Free events skip payment. ## How the pieces fit | Resource | What it is | |---|---| | Event | A show, service, match or meetup with dates, a venue and a status: `draft`, `pending` review or `active`. | | Ticket type | One kind of entry, such as Early Bird or VIP, with a price and stock. It can be fixed price or name your price, and can allow a deposit with pay later. | | Slot | A session or showtime inside an event, with its own capacity. | | Data field | An extra question for each guest, such as T-shirt size. | | Coupon | A code that takes money off one or more ticket types. | | Order | One buyer's purchase of one or more ticket types. It has a code like `ABCD-EFGH` and stays `pending` until paid. | | Attendee | A person who holds a ticket in an order. Each attendee has a code, a QR check-in code and an optional wallet pass. | | Manager | A person you let help with one event, with a role such as `check_in` or `finance`. | A client uses them in this order: 1. An organizer creates an event with ticket types, adds slots, data fields and coupons if needed, and publishes it. On a tenant with several users, publishing sends the event to a tenant admin for review first. 2. Your app lists events and shows ticket types. 3. A buyer places an order. The response holds a `checkout_url`. The buyer pays there, and a callback from Payd marks the order `paid`. Free orders skip this step. See [Payments](https://docs.vabu.live/products/payments.md). 4. Each attendee gets a ticket by email, SMS or WhatsApp. The ticket page can also offer a wallet pass. 5. At the door, staff scan the QR code to check people in. 6. After the event, the organizer reads orders, payments and settlements. ## What you can call today ### Browse events (no sign-in) | Method | Path | What it does | |---|---|---| | GET | `/v1/events` | List the tenant's events, newest first, 20 to a page. | | GET | `/v1/events/{event}` | Get one event with its ticket types, slots and images. | | GET | `/v1/public/events` | Browse upcoming events. Filter by `category`, `type`, `status`, `community_slug` or `search`. | | GET | `/v1/public/events/{slug}` | Get an event by its slug for a public event page. | | GET | `/v1/public/events/categories/top` | List the categories with the most events. | | GET | `/v1/events/{event}/tickets` | List the ticket types for an event. | | GET | `/v1/events/{event}/slots` | List the sessions for an event. | | GET | `/v1/events/{event}/social-proof` | Get tickets sold, recent buyers and a sample of people going. | ### Sell tickets (no sign-in) | Method | Path | What it does | |---|---|---| | GET | `/v1/events/{event}/data` | List the extra questions to ask each guest. | | GET | `/v1/events/{event}/book/payload` | Get the booking form fields for a ticket type. | | POST | `/v1/orders` | Create an order for one or more ticket types. Returns the order and a `checkout_url`. | | POST | `/v1/events/{event}/book` | Book one ticket type. Returns the booking and a payment URL. | | GET | `/v1/orders/{id}` | Get an order by id or code. A paid order shows its tickets and attendees. | | POST | `/v1/orders/{id}/attendees` | Name the attendees on an order, or send them an invite. | | POST | `/v1/orders/{code}/pay-balance` | Start a checkout for the balance of an order paid by deposit. | | POST | `/v1/coupons/validate` | Check a coupon code for an event. | | GET | `/v1/events/{event}/coupon/availability` | Check that a coupon works for a ticket type. | | GET | `/v1/events/{event}/coupon/discount` | Get the discount a coupon gives for a ticket type and quantity. | ### Tickets and invites | Method | Path | What it does | |---|---|---| | GET | `/v1/public/tickets/{attendeeCode}` | Get a ticket page from an attendee code (no sign-in). | | GET | `/v1/public/tickets/{attendeeCode}/wallet` | Redirect to Apple Wallet or Google Wallet, or to the ticket page (no sign-in). | | GET | `/v1/invites/{token}` | Look up a ticket invite before accepting it (no sign-in). | | POST | `/v1/invites/{token}/accept` | Accept an invite with name, email and phone (no sign-in). | | GET | `/v1/tickets` | List the signed-in user's tickets. | | GET | `/v1/tickets/{id}` | Get one ticket for the signed-in user. | | POST | `/v1/tickets/{id}/invite` | Invite someone to a place on a group ticket. | | GET | `/v1/attendee/dashboard` | Get counts of upcoming and past events and tickets for the signed-in user. | ### Organizer: events and setup | Method | Path | What it does | |---|---|---| | GET, POST | `/v1/organizer/events` | List your events with a summary, or create one. | | GET, PUT, DELETE | `/v1/organizer/events/{event}` | Read, update or delete a draft event. | | POST | `/v1/organizer/events/{event}/publish` | Publish a draft event. | | POST | `/v1/organizer/events/{event}/duplicate` | Copy an event to new dates. | | GET, POST | `/v1/organizer/events/{event}/tickets` | List or create ticket types. | | PUT | `/v1/organizer/events/{event}/tickets/{ticket}` | Update a ticket type. | | POST | `/v1/organizer/events/{event}/tickets/{ticket}/sold-out` | Turn sold out on or off for a ticket type. | | GET, POST | `/v1/organizer/events/{event}/slots` | List or create sessions. | | GET, POST | `/v1/organizer/events/{event}/coupons` | List coupons with redemption counts, or create one. | | GET, POST | `/v1/organizer/events/{event}/data-fields` | List or create extra guest questions. | | GET, POST | `/v1/organizer/events/{event}/managers` | List or add people who help run the event. | ### Organizer: guests, door and orders | Method | Path | What it does | |---|---|---| | GET | `/v1/organizer/events/{event}/attendees` | List attendees. | | GET | `/v1/organizer/events/{event}/attendees/export` | Export the attendee list. | | POST | `/v1/organizer/events/{event}/attendees/{attendee}/approve` | Approve an attendee on an event that needs approval. | | POST | `/v1/organizer/events/{event}/attendees/{attendee}/reject` | Reject an attendee. | | POST | `/v1/organizer/events/{event}/attendees/{attendee}/resend` | Send the ticket again. | | POST | `/v1/organizer/events/{event}/check-in` | Check in a guest from a scanned QR code or a typed ticket code. | | GET | `/v1/organizer/events/{event}/check-in/stats` | Get check-in counts. | | POST | `/v1/organizer/events/{event}/check-in/{attendeeId}/undo` | Undo a check-in. | | GET | `/v1/organizer/events/{event}/orders` | List orders for an event. | | GET | `/v1/organizer/events/{event}/orders/{order}` | Get one order. | | POST | `/v1/organizer/events/{event}/orders/{order}/remind` | Remind a buyer to pay the balance. | | POST | `/v1/organizer/events/{event}/orders/{order}/cancel` | Cancel an order. | Update and delete calls for slots, coupons, data fields and managers, attendee creation, order resend, message blasts to attendees and manager invites are in the [API reference](https://docs.vabu.live/api.md). Money calls for events (payments, settlements, payouts and refunds) are on [Payments](https://docs.vabu.live/products/payments.md). Here is a first call. It lists the next five music events: ```sh curl "https://api.vabu.live/v1/public/events?category=music&per_page=5" \ -H "X-Tenant-Code: your_tenant_code" ``` ## Calling these endpoints The browse and sell routes, and the ticket and invite routes marked no sign-in, take only `X-Tenant-Code`. `GET /v1/events` and `GET /v1/events/{event}` also accept a secret, restricted (`events:read`) or publishable key. Every other route takes a signed-in user token (`Authorization: Bearer ...`, a Vabu ID or Payd ID token) plus `X-Tenant-Code`, in live mode. Organizer routes also accept an optional `X-Organization-Id` header to pick which organization you act for. Test keys and publishable keys are refused on these routes today, apart from the two event reads. See [which credentials each endpoint takes](https://docs.vabu.live/concepts/keys-and-scopes.md#which-credentials-each-endpoint-takes). Writes need the Owner, Admin or Editor role in the organization. Check-in, attendee and order calls also follow the event manager's role. For paging and error shapes, see [pagination](https://docs.vabu.live/concepts/pagination.md) and [errors](https://docs.vabu.live/concepts/errors.md). ## Events you can subscribe to | Event | When it fires | |---|---| | `events.order.paid` | An order is fully paid. A deposit order fires it when the last balance payment lands. | See [events](https://docs.vabu.live/events/index.md) for the full payload and [webhooks](https://docs.vabu.live/concepts/webhooks.md) for delivery and signatures. ## Related - [Payments](https://docs.vabu.live/products/payments.md) for checkout, deposits, refunds and settlements. - [Promoters](https://docs.vabu.live/products/promoters.md) for coupon codes you can hand to promoters. - [Messaging](https://docs.vabu.live/products/messaging.md) for ticket delivery and blasts to attendees. - [Fanbases](https://docs.vabu.live/products/fanbases.md) to reward people who come. --- # Fanbases and loyalty Use Fanbases to turn people who follow, buy and show up into a community you can see and reward. A fanbase belongs to an organization. Fans join by following it or by buying a ticket, they earn points for what they do, and the points move them up tiers you set. You get a member list, an activity feed and stats, and you can message members from the same place. ## How the pieces fit | Resource | What it is | |---|---| | Fanbase | The community for one organization. It is created the first time you read it, and with the organization. | | Member | A person in the fanbase, with `total_points`, `lifetime_points` and a tier. | | Tier | A level with a name, a threshold in lifetime points and a list of perks. A fanbase has 1 to 5. | | Activity | One thing a member did, with the points it moved. | A new fanbase starts with auto-enroll on, a points multiplier of 1, a welcome bonus of 50 points and five tiers: Fan (0), Supporter (500), Superfan (1,500), VIP (5,000) and Legend (15,000). You can change all of it. A client uses them in this order: 1. A fan finds the fanbase on a public page and follows the organization. 2. Following adds the fan as a member and awards the welcome bonus. With auto-enroll on, buying a ticket for any of the organization's events adds the buyer too. Organizers can also invite people by email or import past attendees. 3. Each activity adds points, as the table below shows. 4. When lifetime points pass a tier threshold, the member moves up. Vabu sends a welcome message on WhatsApp when someone joins (if they have a phone number) and a congratulation message when they reach a new tier. 5. The organizer reads members, activity and stats, and sends messages. See [Messaging](https://docs.vabu.live/products/messaging.md). | What the fan does | Points | |---|---| | Buys a ticket | 1 point for every KES 10 paid, rounded down | | Is checked in at the door | 100 points, when the ticket email matches an account | | Follows the organization | The welcome bonus | Ticket points need auto-enroll on. The points multiplier (0.1 to 10) scales all of these. Adding a ticket to a phone wallet shows in the activity feed and earns no points. `lifetime_points` never goes down and decides the tier. Points cannot be spent through the API today. See [what's coming](https://docs.vabu.live/whats-coming.md). ## What you can call today ### Public pages and following | Method | Path | What it does | |---|---|---| | GET | `/v1/public/fanbases` | List public fanbases. Search, and sort by `popular`, `recent` or `events`. | | GET | `/v1/public/fanbases/{slug}` | Get one fanbase with links and counts. Send a user token to get `is_following`. | | GET | `/v1/public/fanbases/{slug}/events` | List the organization's events, with live, upcoming and past counts. | | POST | `/v1/creator/orgs/{org}/follow` | Follow an organization. This joins its fanbase. | | DELETE | `/v1/creator/orgs/{org}/follow` | Unfollow. The member becomes inactive. | | GET | `/v1/creator/orgs/{org}/followers` | List an organization's followers. Owners and Admins only. | The fanbase storefront is part of [Commerce](https://docs.vabu.live/products/commerce.md). ### Fanbase profile, settings and tiers | Method | Path | What it does | |---|---|---| | GET | `/v1/organizer/fanbase` | Get the fanbase profile with member, event and tier counts. | | PUT | `/v1/organizer/fanbase` | Update the name, description, contact email and links. | | POST | `/v1/organizer/fanbase/logo` | Upload a logo. | | POST | `/v1/organizer/fanbase/cover` | Upload a cover image. | | GET | `/v1/organizer/fanbase/settings` | Read `enabled`, `auto_enroll`, `points_multiplier` and `welcome_bonus_points`. | | PUT | `/v1/organizer/fanbase/settings` | Change any of those. Send only what changes. | | GET | `/v1/organizer/fanbase/tiers` | List tiers with thresholds and perks. | | PUT | `/v1/organizer/fanbase/tiers` | Replace the tiers. Send 1 to 5. | | GET | `/v1/organizer/fanbase/stats` | Read members, new members, points awarded and tier split for `7d`, `30d`, `90d` or `12m`. | ### Members and activity | Method | Path | What it does | |---|---|---| | GET | `/v1/organizer/fanbase/members` | List members. Search, filter by tier and sort by name, points or join date. | | GET | `/v1/organizer/fanbase/members/export` | Download members as a CSV file. | | POST | `/v1/organizer/fanbase/members/invite` | Add up to 100 people by email. They need an account already. | | POST | `/v1/organizer/fanbase/members/sync` | Add past attendees of your events who have an account. | | GET | `/v1/organizer/fanbase/members/activity` | Read the activity feed for all members. Filter by period and `activity_type`. | | GET | `/v1/organizer/fanbase/members/{member_id}/activity` | Read one member's recent activity. | Full parameters and responses are in the [API reference](https://docs.vabu.live/api.md). Here is how to double the points your fans earn: ```sh curl -X PUT https://api.vabu.live/v1/organizer/fanbase/settings \ -H "Authorization: Bearer $USER_TOKEN" \ -H "X-Tenant-Code: $TENANT_CODE" \ -H "Content-Type: application/json" \ -d '{ "points_multiplier": 2 }' ``` ## Points from Streams Fans earn points for watching a live stream or an asset and for paid moments: - A full watch is 80 percent of the live stretch (or of the asset's length). It earns 25 points once per fan and stream. Watch time adds up across sessions. - A paid moment in KES earns one point per KES 10. If you cancel the moment, the points go back. Points go to the fanbase of the stream's organization. Set `organization_id` in the live stream's `metadata`; if you leave it out, the tenant's only fanbase is used. A fan earns points only when the `customer` you pass to Streams is their Vabu user id (`usr_...`) in your tenant. A customer that is only your own reference earns nothing yet. ## Messages that bring fans back Both are off until you turn them on in your tenant settings (`streams.live_now_messages`, `streams.clip_ready_messages`). They go out in live mode only, by WhatsApp first and by text message if WhatsApp is off or fails. - **Live now.** When a live stream goes live, members of the organization's fanbase who opted in (`notifications.live_now` is `true` on the member, and `notifications.opt_out` is not) get one message per stream per day. - **Clip ready.** When the clip of a paid moment is ready, the fan who paid gets it, if they are a Vabu user in your fanbase with `notifications.clip_ready` on. Set `streams.templates.live_now` and `streams.templates.clip_ready` to your approved WhatsApp template names (defaults `streams_live_now_v1`, `streams_clip_ready_v1`). Set `streams.watch_url` and `streams.clip_url`, with `{id}` where the stream or clip id goes, to add a link. ## Calling these endpoints The three public reads take only `X-Tenant-Code`. The single fanbase read also takes an optional user token. Every other route takes a signed-in user token (`Authorization: Bearer ...`, a Vabu ID or Payd ID token) plus `X-Tenant-Code`, in live mode. Organizer routes also accept an optional `X-Organization-Id` header to pick which organization you act for. Test keys and publishable keys are refused on these routes today. See [which credentials each endpoint takes](https://docs.vabu.live/concepts/keys-and-scopes.md#which-credentials-each-endpoint-takes). Any active member of the organization can read fanbase data. Changing the profile, settings or tiers, and inviting or importing members, needs the Owner or Admin role. For paging and error shapes, see [pagination](https://docs.vabu.live/concepts/pagination.md) and [errors](https://docs.vabu.live/concepts/errors.md). ## Related - [Messaging](https://docs.vabu.live/products/messaging.md) to email, text or WhatsApp your members. - [Events](https://docs.vabu.live/products/events.md) for the tickets and check-ins that earn points. - [How products work together](https://docs.vabu.live/products/how-products-work-together.md) for the loyalty loop across products. --- # How the products work together Vabu OS is one platform, not nine separate tools. Every product sits on top of services that Vabu runs or rents, such as payment collection, message channels and video delivery. On top of those, each product adds the parts a live business needs: orders, tickets, points, access rules. And every product speaks the same platform language: the same keys, the same [events and webhooks](https://docs.vabu.live/concepts/webhooks.md), the same customer. Because every interaction becomes an event, it can feed the three products that learn from the rest: **loyalty** (points for showing up and spending), **insights** (numbers across everything) and **messaging** (a reason to write to someone at the right time). That loop is the point of using the products together. | Product | Sits on | Adds for you | |---|---|---| | [Payments](https://docs.vabu.live/products/payments.md) | M-Pesa and card collection through Payd | Checkout, settlement to organizers, refunds, payout requests | | [Messaging](https://docs.vabu.live/products/messaging.md) | WhatsApp, SMS and email channels | Blasts to attendees and fans, scheduling, message credits | | [Identity](https://docs.vabu.live/products/identity.md) | Vabu ID and Payd ID | Sign-in, customers, sessions, one customer record per tenant | | [Streams](https://docs.vabu.live/guides/streams/index.md) | Video delivery networks | Ingest, protected playback, access rules, viewer sessions | | [Events](https://docs.vabu.live/products/events.md) | Payments, Messaging, Streams | Events, ticket types, orders, check-in | | [Commerce](https://docs.vabu.live/products/commerce.md) | Payments | Storefronts, products, orders | | [Fanbases](https://docs.vabu.live/products/fanbases.md) | Events from every product | Members, points, tiers | | [Insights](https://docs.vabu.live/products/insights.md) | Events from every product | Revenue, audience and conversion numbers | ## A hybrid event, from first ticket to replay Take a Friday night show with a crowd at the venue and a paid stream for everyone else. Here is how the products carry it, and which parts happen by themselves. 1. **Create the event and its tickets.** In [Events](https://docs.vabu.live/products/events.md), create the event, add ticket types (General, VIP and a Stream pass) and publish it. 2. **A fan buys a ticket.** The order starts a checkout. The fan pays with M-Pesa. [Payments](https://docs.vabu.live/products/payments.md) confirms the money and the order moves to paid. Vabu sends an `events.order.paid` event to your webhook endpoint. 3. **Points arrive by themselves.** The purchase earns points in the organizer's [fanbase](https://docs.vabu.live/products/fanbases.md), at 1 point for each KES 10 spent. If the fanbase has auto-enroll on, which is the default, the fan joins it too. 4. **You grant the stream.** Your handler for `events.order.paid` makes one call, `POST /v1/access/entitlements`, with `source: "ticket"`, the order `code` as the `source_ref` and your own id for the buyer as `customer`. That gives the ticket holder the stream. See [paid access](https://docs.vabu.live/guides/streams/paid-access.md). 5. **A reminder goes out.** Schedule a WhatsApp reminder to ticket holders with `POST /v1/organizer/events/{event}/communications/whatsapp` and a `schedule_at` the evening before. See [Messaging](https://docs.vabu.live/products/messaging.md). 6. **The night.** The venue's encoder sends to the live stream. People at the door are checked in, and each check-in earns attendance points. Viewers at home open your page, your server signs a customer token, and Vabu opens a session and checks the device limit. See [Streams](https://docs.vabu.live/guides/streams/index.md). 7. **Afterwards.** Add the recording as an asset, grant it to ticket holders as a replay, and read the night's sales and audience numbers in [Insights](https://docs.vabu.live/products/insights.md). ## What runs by itself and what you wire Step 3 and the attendance points at the door need no work from you. Steps 4, 5 and 7 are one call each, from your own server or your organizer tools. Some of this will become automatic. Tickets that grant the stream and the replay without your handler, reminders that send themselves, and points for watch time are on [What's coming](https://docs.vabu.live/whats-coming.md). ## Use one, or all Nothing here forces you to use more than one product. A church can use only Streams and keep its own login and payments. A club can use only Events. When you add a second product, the events and the customer record connect them for you. Read [ways to use Vabu OS](https://docs.vabu.live/concepts/integration-modes.md) for the choices about login and payments. --- # Identity (Vabu ID) Vabu ID is Vabu's login service. Use it when you do not want to build sign-in yourself, or when your audience signs in with a phone number. Your app calls Vabu ID's routes, and a person comes back with a signed token that every Vabu OS product understands. If you already have your own login, you do not need Vabu ID. See [sign-in options](https://docs.vabu.live/concepts/sign-in-options.md) to choose. ## How the pieces fit - A **person** signs in once and has one Vabu ID account. - When they sign in through your tenant, they also become a **customer** of your tenant. Customers are yours: another tenant cannot see them. Customer ids start `cus_`. - Sign-in gives your app an **access token** (15 minutes) and a **refresh token** (30 days at most per session). - Your server checks the access token against Vabu ID's public keys, so it never has to call Vabu to know who is calling. ## Sign-in methods | Method | Calls | |---|---| | WhatsApp or SMS code | `POST /auth/v1/otp/start`, then `POST /auth/v1/otp/verify` | | Email link | `POST /auth/v1/magic_link/start`, then `POST /auth/v1/magic_link/verify` | | Google, Apple or Facebook | `GET /auth/v1/oauth/{provider}/redirect`, then the provider calls back to `/auth/v1/oauth/{provider}/callback` | | Login with Payd | `POST /auth/v1/payd/login` with the person's Payd token | These routes are at the API host root, not under `/v1`: `https://api.vabu.live/auth/v1/...`. They return the usual `{ "data": ... }` and error shapes. Send your publishable key, or a `tenant` field with your tenant code, so the person becomes your customer. ### Phone code ```http POST /auth/v1/otp/start Vabu-Publishable-Key: vpk_live_... { "phone": "+254712345678", "channel": "whatsapp" } ``` `channel` is `whatsapp` (the default) or `sms`. WhatsApp falls back to SMS. The code is valid for 5 minutes. Five wrong tries lock it, and a new code can be asked for after 30 seconds. Codes go to numbers in Kenya, Tanzania, Uganda, Rwanda, Burundi, South Sudan and Ethiopia. `POST /auth/v1/otp/verify` takes the `phone` and the six-digit `code` and returns a token pair: ```json { "data": { "object": "token_pair", "access_token": "eyJ...", "token_type": "Bearer", "expires_in": 900, "refresh_token": "Zk3...", "session_id": "ses_01JB3K5V8X2T4Q7W9Y1Z3A5C7E", "scope": "openid profile" } } ``` ### Linking accounts A new sign-in joins an existing account on its own only when its email or phone is verified and matches. Otherwise the response is a `link_request`, and the person confirms with `POST /auth/v1/link/confirm` after signing in to the account they already have. ## Use the tokens Send the access token as `Authorization: Bearer ...`. Refresh it with the refresh token: ```http POST /auth/v1/token/refresh { "refresh_token": "Zk3..." } ``` You get a new pair, and the old refresh token is spent. Keep only the newest one, and run one refresh at a time per session. If a spent refresh token is used again, the whole session ends. A session also ends 30 days after it started. ### Verify a token on your server Vabu ID signs access tokens with ES256 and publishes the public keys at `/.well-known/jwks.json`. Check the signature, the expiry, the issuer and the audience. The issuer is the API host, and the audience is your tenant's `ten_` id for a tenant sign-in. ```ts import { createRemoteJWKSet, jwtVerify } from 'jose' const jwks = createRemoteJWKSet(new URL('https://api.vabu.live/.well-known/jwks.json')) export async function verifyVabuToken(token: string, tenantId: string) { const { payload } = await jwtVerify(token, jwks, { issuer: 'https://api.vabu.live', audience: tenantId, // your tenant id, "ten_..." }) return { person: payload.sub, customer: payload.cus, session: payload.sid } } ``` The claims you will use: `sub` (the person, `usr_...`), `cus` (the customer in your tenant, `cus_...`), `tid` (your tenant), `sid` (the session) and `amr` (how they signed in). A token for one tenant does not work for another, and a token from a test publishable key cannot call live-only routes. A suspended account loses all its sessions at once. ## Customers A customer is one of your end users. You can also create them yourself: | Method | Path | What it does | |---|---|---| | `GET` | `/v1/customers` | Find customers by `email`, `phone`, `external_id` or `metadata[key]` | | `POST` | `/v1/customers` | Register your own user, with an `Idempotency-Key` | | `GET` | `/v1/customers/{id}` | Show one | | `GET` | `/v1/customers/external/{external_id}` | Show one by your own id | | `PATCH` | `/v1/customers/{id}` | Change `email`, `phone`, `name`, `external_id` or `metadata` | ```json { "data": { "id": "cus_01JB3K5V8X2T4Q7W9Y1Z3A5C7E", "object": "customer", "email": "amina@example.com", "phone": "+254712345678", "name": "Amina Wanjiru", "external_id": "user_42", "has_vabu_id": false, "livemode": true } } ``` Customer calls take a secret key, or a restricted key with `customers:read` or `customers:write`. Publishable keys are refused. `external_id` is unique per tenant and environment, and a second customer with the same one returns `409 external_id_taken`. ## Sessions and devices A signed-in person can see and end their own sessions: | Method | Path | What it does | |---|---|---| | `GET` | `/auth/v1/me` | The signed-in person, with their sign-in methods | | `GET` | `/auth/v1/sessions` | Their open sessions, with device and last seen | | `DELETE` | `/auth/v1/sessions/{id}` | End one | | `POST` | `/auth/v1/logout` | End the current session | A tenant token only sees and ends sessions for its own tenant. ## All the Vabu ID routes | Method | Path | |---|---| | `GET` | `/.well-known/jwks.json` | | `POST` | `/auth/v1/payd/login` | | `POST` | `/auth/v1/otp/start` and `/auth/v1/otp/verify` | | `POST` | `/auth/v1/magic_link/start` and `/auth/v1/magic_link/verify` | | `GET` | `/auth/v1/oauth/{provider}/redirect` and `/auth/v1/oauth/{provider}/callback` | | `POST` | `/auth/v1/link/confirm` | | `POST` | `/auth/v1/token/refresh` | | `POST` | `/auth/v1/logout` | | `GET` | `/auth/v1/sessions`, `/auth/v1/me` | | `DELETE` | `/auth/v1/sessions/{id}` | | `GET` | `/v1/me` | The customer routes above are in the [API reference](https://docs.vabu.live/api.md). The `/auth/v1` routes are described here. ## Related - [Sign-in options](https://docs.vabu.live/concepts/sign-in-options.md) - [Customer tokens](https://docs.vabu.live/concepts/customer-tokens.md), for playing protected video with your own login - [Keys and scopes](https://docs.vabu.live/concepts/keys-and-scopes.md) - [What's coming](https://docs.vabu.live/whats-coming.md): sign-in with your own identity provider on every product, and a ready-made sign-in component --- # Products Vabu OS has nine products. Use one, or use all of them. Each one has its own pages here, and they share the same keys, errors, webhooks and test environment. The platform pieces are on [Get started](https://docs.vabu.live/get-started/index.md) and under Concepts. | Product | What it does for you | Start here | |---|---|---| | Events and ticketing | Create events, sell tickets and passes, run the door with check-in, and see who came. | [Events and ticketing](https://docs.vabu.live/products/events.md) | | Fanbases and loyalty | Give each audience a home with members, points and tiers that reward showing up. | [Fanbases and loyalty](https://docs.vabu.live/products/fanbases.md) | | Commerce and shops | Sell merchandise and digital goods next to your events and streams. | [Commerce and shops](https://docs.vabu.live/products/commerce.md) | | Streams | Put live video and replays in your own app, with paid access and viewer limits. | [How Vabu Streams works](https://docs.vabu.live/guides/streams/index.md) | | Promoters | Let people sell for you and get paid for it. | [Promoters](https://docs.vabu.live/products/promoters.md) | | Messaging | Reach your audience on WhatsApp, SMS and email. | [Messaging](https://docs.vabu.live/products/messaging.md) | | Payments | Take M-Pesa and card payments and settle them to your organizers. | [Payments](https://docs.vabu.live/products/payments.md) | | Identity (Vabu ID) | Sign people in with a phone code, an email link, Google, Apple, Facebook or Payd. | [Identity (Vabu ID)](https://docs.vabu.live/products/identity.md) | | Insights | See sales, audience and conversion numbers for what you run. | [Insights](https://docs.vabu.live/products/insights.md) | ## Pick your way in You do not have to take the whole platform. Start with the product you need, and add others when you want them. - **A streaming app, a church or a league** starts with [Streams](https://docs.vabu.live/guides/streams/index.md). Keep your login, take payment your own way, and call Vabu to grant access. The [quickstart](https://docs.vabu.live/guides/streams/quickstart.md) takes about twenty minutes. - **An events company** starts with [Events and ticketing](https://docs.vabu.live/products/events.md), and adds [Fanbases](https://docs.vabu.live/products/fanbases.md) and [Messaging](https://docs.vabu.live/products/messaging.md) as the audience grows. Read [ways to use Vabu OS](https://docs.vabu.live/concepts/integration-modes.md) for how your own login and payments fit in. ## How they work together Every product feeds the same loop. A ticket sale earns points. A check-in earns more. A message brings people back. Streams add an audience that is not in the room. See [how the products work together](https://docs.vabu.live/products/how-products-work-together.md) for a hybrid event, from the first ticket to the replay. ## Still on the way Some pieces of these products are still on the way, such as promoter programs and payment reporting for your own checkout. They are listed in [What's coming](https://docs.vabu.live/whats-coming.md), and each product page links there for its own gaps. ## Calling the products Platform pieces such as keys, customers, access and streams work with test keys. Most other product routes work with a signed-in user and live credentials today. [Keys and scopes](https://docs.vabu.live/concepts/keys-and-scopes.md#which-credentials-each-endpoint-takes) shows which credentials each family takes. --- # Insights Use Insights to show the people running things how it is going: tickets sold, revenue, who the audience is and which events do best. Vabu records what happens in every product, such as ticket sales, check-ins, purchases and plays, so numbers can be joined across products. The calls below are the reads you can make today. ## How the pieces fit | Who reads | What they get | |---|---| | Organizers | A dashboard and analytics for their own organization: sales, revenue, events, audience and coupon use. | | Creators | A Studio summary of their work, audience and revenue. | | Tenant staff | The same kinds of numbers across every organizer in the tenant. | | Fans and visitors | A summary of their own events, and public social proof on an event page. | Organizer reads take a `period` or `range` of `7d`, `30d`, `90d` or `12m`. Time series take an `interval` of `day`, `week` or `month`. Responses are cached for a few minutes, and `meta.cache_ttl_seconds` tells you how long. Revenue is limited by role. In the organizer dashboard and analytics, revenue fields (`stats.total_revenue`, `sales_trend` and each event's `revenue`) go to Owners, Admins and platform admins. Other members get the same response without those keys, and their top events rank by `tickets_sold`. The revenue series route is closed to roles below Admin. Visits are not counted today, so `page_views`, `unique_visitors` and the conversion rate read 0. See [what's coming](https://docs.vabu.live/whats-coming.md). ## What you can call today ### Organizer dashboard and analytics | Method | Path | What it does | |---|---|---| | GET | `/v1/organizer/dashboard` | Get tickets sold, revenue, active events, fan count, recent events and recent activity. | | GET | `/v1/organizer/analytics` | Get the overview: stats, sales trend and top events. Add `compare=true` for the previous period. | | GET | `/v1/organizer/analytics/revenue` | Get revenue over time. Owners and Admins only. | | GET | `/v1/organizer/analytics/tickets` | Get tickets sold over time. Filter by `event_id`. | | GET | `/v1/organizer/analytics/events` | Rank events by revenue, tickets sold or capacity used. | | GET | `/v1/organizer/analytics/audience` | Get the ticket type mix, average ticket price and repeat attendees. | | GET | `/v1/organizer/analytics/conversions` | Get orders and coupon use. | ### Creator Studio | Method | Path | What it does | |---|---|---| | GET | `/v1/creator/studio/capabilities` | See which Studio features are on for the tenant and organization. | | GET | `/v1/creator/studio/summary` | Get work counts, video health, audience, revenue and next steps in one call. | | GET | `/v1/creator/studio/audience` | Get audience size and segment counts. | | GET | `/v1/creator/studio/audience/people` | List the people in the audience, with tier and last activity. | | GET | `/v1/creator/studio/revenue` | Get collected money, refunds and payout amounts. | ### Tenant staff | Method | Path | What it does | |---|---|---| | GET | `/v1/tenant/admin/dashboard` | Get totals, recent events and pending reviews. | | GET | `/v1/tenant/admin/analytics` | Get revenue, tickets, active events and users. Scope to one organization with `organization_id`. | | GET | `/v1/tenant/admin/analytics/revenue` | Get revenue over time. | | GET | `/v1/tenant/admin/analytics/tickets` | Get ticket sales over time. | | GET | `/v1/tenant/admin/analytics/users` | Get new and total users over time. | | GET | `/v1/tenant/admin/analytics/organizers` | Rank organizers by events, tickets and revenue. | | GET | `/v1/tenant/admin/analytics/top-events` | Rank events by `revenue` or `tickets_sold`. | | GET | `/v1/tenant/admin/analytics/by-country` | Split events, tickets and revenue by country. | | GET | `/v1/tenant/admin/analytics/by-category` | Split events, tickets and revenue by category. | ### Fans and event pages | Method | Path | What it does | |---|---|---| | GET | `/v1/attendee/dashboard` | Get a signed-in fan's upcoming and past events, active tickets and total spent. | | GET | `/v1/events/{event}/social-proof` | Get tickets sold, tickets left, recent buyers and a sample of people going. Public. | Full parameters and responses are in the [API reference](https://docs.vabu.live/api.md). Here is the organizer overview for the last 30 days, compared with the 30 before (trimmed): ```sh curl "https://api.vabu.live/v1/organizer/analytics?period=30d&compare=true" \ -H "Authorization: Bearer $USER_TOKEN" \ -H "X-Tenant-Code: $TENANT_CODE" ``` ```json { "data": { "stats": { "total_revenue": { "value": 4850000.00, "currency": "KES", "change_percentage": 22.1 }, "tickets_sold": { "value": 1250, "change_percentage": 15.3 } }, "top_events": [ { "id": "550e8400-e29b-41d4-a716-446655440302", "name": "Sol Fest 2026", "tickets_sold": 450, "revenue": 2250000.00 } ] }, "meta": { "period": "30d", "cache_ttl_seconds": 600 } } ``` ## Calling these endpoints The social proof read is public and needs no token. Every other route takes a signed-in user token (`Authorization: Bearer ...`, a Vabu ID or Payd ID token) plus `X-Tenant-Code`, in live mode. Organizer and Studio routes also accept an optional `X-Organization-Id` header to pick which organization you act for. Tenant staff routes need a tenant admin. Test keys and publishable keys are refused on these routes today. See [which credentials each endpoint takes](https://docs.vabu.live/concepts/keys-and-scopes.md#which-credentials-each-endpoint-takes). For error shapes, see [errors](https://docs.vabu.live/concepts/errors.md). ## Related - [Events](https://docs.vabu.live/products/events.md) for the tickets and check-ins behind the sales numbers. - [Fanbases](https://docs.vabu.live/products/fanbases.md) for member lists and activity. - [How products work together](https://docs.vabu.live/products/how-products-work-together.md) for numbers that span products. --- # Messaging Use Messaging to reach the people who matter to an organizer: everyone holding a ticket to an event, or the members of a fanbase. Pick email, SMS or WhatsApp, check who will get the message, then send it now or schedule it. Each send uses message credits that the organization tops up with M-Pesa or card through Payd. ## How the pieces fit | Resource | What it is | |---|---| | Credits | The organization's balance for sending messages, in KES. Sends take credits and top-ups add them. | | Audience | The attendees of one event, which you can narrow by ticket type, status, check-in and payment status. Or the active members of a fanbase. | | Channel | `email`, `sms` or `whatsapp`. You choose one for each send. | | Communication | One send. It has a `status`, a recipient count, delivered and failed counts and the credits it cost. | A client uses them in this order: 1. Read the credit balance. Ask for an estimate for the channel and recipient count, and top up if the balance is short. 2. Count the recipients, and show the message as they will see it. 3. Send. The credits come off at once. Add `schedule_at` to send later. 4. Read the send's detail for delivered and failed counts. Credits for confirmed delivery failures go back to the balance. 5. To drop a scheduled event send, cancel it. The credits for the whole send go back. Email needs a `subject`. An event SMS body must stay under 400 characters. WhatsApp messages go out as approved templates, and the render call shows the exact values. An event WhatsApp send returns `422` if WhatsApp is not set up for your tenant. A send with no matching recipients returns `422`, and one with too few credits returns `402` `insufficient_credits`. Vabu also sends messages on its own when something happens, such as a ticket purchase or a fan reaching a new tier. You do not call an endpoint for those. Login codes belong to [Identity](https://docs.vabu.live/products/identity.md). ## What you can call today ### Credits | Method | Path | What it does | |---|---|---| | GET | `/v1/organizer/credits/balance` | Get the credit balance and the low-balance level. | | POST | `/v1/organizer/credits/estimate` | Get the cost of a send for a channel and recipient count. | | POST | `/v1/organizer/credits/top-up` | Start a Payd checkout to add credits. Returns a `checkout_url`. | | GET | `/v1/organizer/credits/transactions` | List top-ups, sends and refunds. | ### Check before you send | Method | Path | What it does | |---|---|---| | GET | `/v1/organizer/events/{event}/communications/preview` | Count the attendees a send would reach. | | GET | `/v1/organizer/fanbase/communications/preview` | Count the members a send would reach for a channel. | | POST | `/v1/organizer/communications/preview-render` | Show a message as email, SMS or WhatsApp recipients will see it. | ### Event attendees | Method | Path | What it does | |---|---|---| | POST | `/v1/organizer/events/{event}/communications/{channel}` | Send to attendees. `channel` is `email`, `sms` or `whatsapp`. | | GET | `/v1/organizer/events/{event}/communications` | List an event's sends. Filter by channel and status. | | GET | `/v1/organizer/events/{event}/communications/{id}` | Get one send with its recipients and delivery results. | | DELETE | `/v1/organizer/events/{event}/communications/{id}` | Cancel a queued or scheduled send and get the credits back. | ### Fanbase members | Method | Path | What it does | |---|---|---| | POST | `/v1/organizer/fanbase/communications/email` | Send an email to active members. | | POST | `/v1/organizer/fanbase/communications/sms` | Send an SMS to members with a phone number. | | POST | `/v1/organizer/fanbase/communications/whatsapp` | Send a WhatsApp message to members with a phone number. | | GET | `/v1/organizer/fanbase/communications` | List sends, with a count for each channel. | | GET | `/v1/organizer/fanbase/communications/{id}` | Get one send with its recipients and credit cost. | ### Your own alerts | Method | Path | What it does | |---|---|---| | PUT | `/v1/organizer/settings/notifications` | Choose which alerts the organizer gets, such as ticket purchases and payouts. | Full parameters and responses are in the [API reference](https://docs.vabu.live/api.md). Here is an SMS to every attendee, scheduled for the afternoon before the show: ```sh curl -X POST https://api.vabu.live/v1/organizer/events/$EVENT_ID/communications/sms \ -H "Authorization: Bearer $USER_TOKEN" \ -H "X-Tenant-Code: $TENANT_CODE" \ -H "Content-Type: application/json" \ -d '{ "body": "Doors open at 7pm. Bring your ticket.", "recipients": { "type": "all" }, "schedule_at": "2026-11-20T15:00:00+03:00" }' ``` The response gives the send an `id`, its `status`, `recipients_count`, the credits taken and your new balance. ## Calling these endpoints Every route here takes a signed-in user token (`Authorization: Bearer ...`, a Vabu ID or Payd ID token) plus `X-Tenant-Code`, in live mode. They also accept an optional `X-Organization-Id` header to pick which organization you act for. Test keys and publishable keys are refused on these routes today. See [which credentials each endpoint takes](https://docs.vabu.live/concepts/keys-and-scopes.md#which-credentials-each-endpoint-takes). Any active member can read balances and fanbase sends. Fanbase sends need the Owner, Admin or Editor role, and top-ups and alert settings need Owner or Admin. Event calls follow the event manager's role. For paging and error shapes, see [pagination](https://docs.vabu.live/concepts/pagination.md) and [errors](https://docs.vabu.live/concepts/errors.md). ## Related - [Fanbases](https://docs.vabu.live/products/fanbases.md) for the members you message. - [Events](https://docs.vabu.live/products/events.md) for the attendees you message. - [Payments](https://docs.vabu.live/products/payments.md) for how top-ups are paid. - [Identity](https://docs.vabu.live/products/identity.md) for login codes by WhatsApp or SMS. --- # Payments You do not call a payments API directly today. An order or booking starts the payment for you: Vabu OS asks Payd to open a checkout for M-Pesa or card, and the response carries a `checkout_url`. The buyer pays there, Payd confirms by callback, and the order completes. This page covers the calls around that flow: starting a checkout, paying a balance, refunds, and the money views your organizers use. ## How the pieces fit | Resource | What it is | |---|---| | Checkout | A Payd payment page for one order. Its `checkout_url` comes back when you create the order. | | Order | What the buyer is paying for. It stays `pending` until Payd confirms the money. | | Payment | One attempt to pay for an order, with a provider and a status. Organizers can list them. | | Deposit and balance | A ticket can allow pay later. The buyer pays a deposit first and the balance later, in one or more parts. | | Refund | A request to return money for a paid booking. | | Settlement | Money moved to the organizer for an event. | | Payout request | An organizer's request for an early payout on one event. | | Credits | A prepaid KES balance an organizer spends on message blasts. It is topped up through a Payd checkout. | The flow for an event order: 1. Your app creates the order with `POST /v1/orders`. 2. You send the buyer to `checkout_url`. An unpaid order holds its tickets for a short time (10 minutes by default). 3. The buyer pays with an M-Pesa prompt on their phone or with a card. 4. Payd calls Vabu OS back. The order becomes `paid`, tickets go out, and Vabu OS fires `events.order.paid` (see [events](https://docs.vabu.live/events/index.md)). 5. Your app reads the order with `GET /v1/orders/{id}` to show the result. Do not treat the redirect back from Payd as proof of payment. Payd calls routes that only it uses. Your app never calls them. ## What is not available yet There is no endpoint to report a payment you collected yourself, so a client that runs its own payment stack cannot complete an order that way. There is also no payment simulator in test mode, and test keys cannot start a checkout. Both are on [what's coming](https://docs.vabu.live/whats-coming.md). ## Amounts and phones Currency defaults to KES. Event and stream orders return amounts in KES, such as `2400.00`. Payout requests, settlement totals and merch use integer cents in fields named `..._cents`. Credits use `amount_kes`. Phone numbers go in E.164 format, such as `+254712345678`. ## What you can call today ### Start a payment (no sign-in) | Method | Path | What it does | |---|---|---| | POST | `/v1/orders` | Create an event order. Returns the order and a `checkout_url`. A free order skips payment. | | POST | `/v1/events/{event}/book` | Book one ticket type. Returns the booking and a payment URL. | | POST | `/v1/orders/{code}/pay-balance` | Start a checkout for the balance of an order paid by deposit. Send an optional `amount` in KES, at least 1.00. | | GET | `/v1/orders/{id}` | Read an order. A part-paid order shows `paid` and `balance`. | | POST | `/v1/plus/orders` | Start a checkout to buy a video. Returns a `checkout_url`. | | POST | `/v1/plus/subscriptions/checkout` | Start a checkout for a monthly subscription. | A deposit order becomes `paid`, and its tickets go out, only when the balance reaches 0. Each part payment sends the buyer a receipt with the balance still due. ### Refunds | Method | Path | What it does | |---|---|---| | POST | `/v1/creator/refund/{code}` | Ask for a refund on a paid booking, with an optional `reason`. | The buyer can ask for everything they paid. Organizer staff can send a smaller `amount`. A refund must be asked for more than 24 hours before the event starts. The call records a pending refund and cancels the booking. A second request on the same booking is refused. ### Organizer: payments, settlements and payouts | Method | Path | What it does | |---|---|---| | GET | `/v1/organizer/payments` | List payments for your event bookings. Filter by `status` or `provider`. | | GET | `/v1/organizer/payments/{payment}` | Get one payment with its booking. | | GET | `/v1/organizer/settlements` | List settlements with totals settled and unsettled. | | GET | `/v1/organizer/settlements/by-event` | Get one row per event: processed, fees, settled and unsettled. | | GET | `/v1/organizer/settlements/{settlement}` | Get one settlement. | | GET | `/v1/organizer/events/{eventId}/money-movements` | List every money movement for an event, newest first. | | PUT | `/v1/organizer/settings/payout` | Set where payouts go: M-Pesa or a bank account. | | POST | `/v1/organizer/payout-requests` | Ask for an early payout on an event, with `event_id`, `amount_cents` and a `reason`. | | GET | `/v1/organizer/payout-requests` | List payout requests with a count for each status. | A payout request moves from `requested` to `tenant_approved` to `processing`, then ends as `completed`, `failed` or `rejected`. ### Organizer: credits | Method | Path | What it does | |---|---|---| | GET | `/v1/organizer/credits/balance` | Get the credit balance in KES. | | POST | `/v1/organizer/credits/estimate` | Estimate what a message blast will cost. | | POST | `/v1/organizer/credits/top-up` | Start a Payd checkout to add credits, from KES 10 to KES 100,000. Send `amount_kes` and a `redirect_url`. | | GET | `/v1/organizer/credits/transactions` | List top-ups and spending. | The full parameters for each call are in the [API reference](https://docs.vabu.live/api.md). ## Calling these endpoints The start-a-payment routes take only `X-Tenant-Code`, with no sign-in. The refund route and every `/v1/organizer/*` route take a signed-in user token (`Authorization: Bearer ...`, a Vabu ID or Payd ID token) plus `X-Tenant-Code`, in live mode. Organizer routes also accept an optional `X-Organization-Id` header. Payments and settlements need the Owner or Admin role. Money movements need an event manager with the finance role, or the Owner. Payout requests and payout settings need the Owner role. Test keys and publishable keys are refused on these routes today. See [which credentials each endpoint takes](https://docs.vabu.live/concepts/keys-and-scopes.md#which-credentials-each-endpoint-takes). ## Related - [Events and ticketing](https://docs.vabu.live/products/events.md) for the orders that start most payments. - [Commerce](https://docs.vabu.live/products/commerce.md) for merch orders. - [Integration modes](https://docs.vabu.live/concepts/integration-modes.md) for how your own payments fit in. - [What's coming](https://docs.vabu.live/whats-coming.md) for reporting your own payments and test-mode payments. --- # Promoters A promoter program lets people sell for you and earn from it: a street team, an influencer, a church usher or a club promoter. Each one gets a link or code to share, every sale is tied to the person who brought it, and you pay them a commission. Promoter accounts, promoter links, tracked sales and commission payouts are not offered in the API today. They are listed on [what's coming](https://docs.vabu.live/whats-coming.md). What you can use now is the coupon code on an event. You can hand one code to each promoter and count how often it was used. ## How the pieces fit | Piece | What it is | Offered today | |---|---|---| | Promoter | A person who sells for you. | No | | Link or code | What the promoter shares with buyers. | Event coupon codes | | Tracked sale | A sale tied to the promoter who brought it. | No | | Commission | What the promoter earns on each sale. | No | | Payout | Paying the commission out. | No | ## Use a coupon code per promoter A coupon is a code for one event. An organizer sets its discount as a percentage, a fixed amount or a set price. A coupon can also have a minimum spend, a cap on the discount, a limit per buyer, a total number of uses, a start and end date, and a list of ticket types it applies to. A code is unique within an event and is case sensitive. To give each promoter a code: 1. Create one coupon per promoter on the event, for example `AMINA10`. 2. Give the code to the promoter to share. 3. Your app checks a code at checkout with the calls below, and sends it as `coupon_code` when it creates the order. 4. Read the coupon list to see how often each code was used. Each coupon shows `redeemed_count` and an `analytics` block with `redeemed_count`, `orders_count` and `total_discount`. This counts how many times a code was used and how much discount it gave. It does not link the sale to a promoter account or work out a commission, so you pay promoters yourself. Coupons can be created, changed or deleted until the event starts. ## What you can call today | Method | Path | What it does | |---|---|---| | GET, POST | `/v1/organizer/events/{event}/coupons` | List coupons with redemption counts, or create one. | | PUT, DELETE | `/v1/organizer/events/{event}/coupons/{coupon}` | Update or delete a coupon. | | POST | `/v1/coupons/validate` | Check a code for an event. Returns its type and value. No sign-in. | | GET | `/v1/events/{event}/coupon/availability` | Check that a code works for a ticket type. No sign-in. | | GET | `/v1/events/{event}/coupon/discount` | Get the discount a code gives for a ticket type and quantity. No sign-in. | The full parameters for each call are in the [API reference](https://docs.vabu.live/api.md). ## Calling these endpoints The three buyer checks take only `X-Tenant-Code`, with no sign-in. The organizer coupon routes take a signed-in user token (`Authorization: Bearer ...`, a Vabu ID or Payd ID token) plus `X-Tenant-Code`, in live mode, and accept an optional `X-Organization-Id` header to pick which organization you act for. Creating or changing coupons needs the Owner, Admin or Editor role in the organization. Test keys and publishable keys are refused on these routes today. See [which credentials each endpoint takes](https://docs.vabu.live/concepts/keys-and-scopes.md#which-credentials-each-endpoint-takes). ## Events you can subscribe to | Event | When it fires | |---|---| | `events.order.paid` | An event order is fully paid. It has a `promoter_id` field, which is empty today because orders do not record a promoter yet. | See [events](https://docs.vabu.live/events/index.md) for the full payload and [webhooks](https://docs.vabu.live/concepts/webhooks.md) for delivery and signatures. ## Related - [Events and ticketing](https://docs.vabu.live/products/events.md) for events, ticket types and orders. - [Payments](https://docs.vabu.live/products/payments.md) for how a coupon order is paid. - [What's coming](https://docs.vabu.live/whats-coming.md) for promoter links, tracked sales and commissions. --- # What's coming This is what we are building next across Vabu OS. There are no dates here. Nothing on this page is in the API today, so do not build against it. When something arrives it moves into the product's own pages and the [API reference](https://docs.vabu.live/api.md). If one of these matters to your launch, tell your Vabu contact. It helps us put things in the right order. ## Streams - **Moments in sync with the video.** Show a paid shoutout, a song request, a poll or a sponsor slot on screen at the moment it happens in the video, for every viewer, however slow their connection. - **A player kit and a drop-in player.** A small library that opens sessions, refreshes playback links, switches sources on errors and offers data-saver and audio-only. Use it with your own player, or use a ready-made player that already speaks English and Kiswahili. - **Delivery by region.** Pick the video network for each stream by where your audience is, with Nairobi first, behind the same API. - **Replays and clips.** Replays of streams on the Vabu Media plane, and vertical clips to share on social. - **Going live from a phone browser.** A host or DJ goes live from a web page, with a check of the connection before the show. - **Usage dashboards.** Viewers now and at peak, watch time, quality by region and revenue per stream-hour, in one place. - **Pricing by venue and live hour.** A price based on how many venues you run and how many hours you broadcast, instead of per viewer. ## Events and ticketing - **Hybrid tickets.** A ticket that also grants the live stream and the replay, with no handler of your own. - **Offline check-in.** Scan tickets at a door with no signal and sync when the network is back. - **Transfers and capped resale.** Fans pass a ticket on, or resell it at no more than a price you set. ## Payments - **Report payments you collected yourself.** Take payment in your own checkout, tell Vabu about it, and the order completes and access is granted. No money moves through Vabu. - **Test mode for payments.** Scripted outcomes for M-Pesa and cards, such as success, failure and a late payment, so you can test your whole flow without money. - **Pay merch orders in the app.** Checkout for shop orders, so a merch order can be paid and fulfilled through the same payment flow as tickets. - **Split payouts to collaborators**, and receipts that meet Kenyan tax rules. ## Fanbases and loyalty - **Points for more things.** Watch time, moments you take part in and referrals, as well as tickets and check-ins. - **Perks across products.** A tier that gives a free replay, a priority shoutout or early access to tickets. - **A fan passport.** One fan identity across the products of a tenant, shared only with the person's consent. ## Messaging - **Journeys.** Reminders before an event, "we are live" to followers, "your clip is ready" to the fan and a replay offer after the show, on WhatsApp first and SMS as the fallback. - **Two-way conversations.** Fans reply and get answers, for example to buy a ticket inside WhatsApp. - **Templates for each tenant** and delivery receipts you can read in the API. ## Commerce and shops - **Drops during a live stream.** Show a product on screen in a moment, and sell it while the stream is on. - **Bundles.** A ticket with merchandise, or a pass with a drop. ## Promoters - **Promoter links and codes.** Give each promoter their own link, and see every sale they brought. - **Commissions and payouts.** Pay promoters their share, on a schedule. ## Identity - **Your own identity provider on every product.** Sign in with tokens from your own login, not only for streaming. - **A ready-made sign-in component** you drop into a page. - **Progressive profiles.** Ask for a name or a county only when it is needed. ## Insights - **Revenue and quality together.** See what a stream or an event earned next to how well it played. - **Forecasts and at-risk fans.** Expected ticket sales, and the fans who have gone quiet. ## Developer tools - **A console** for your keys, webhook endpoints, delivery logs, request logs and the test switch. - **SDKs** for TypeScript, PHP, Python and Go. - **A command line tool.** Listen to webhooks on your laptop, trigger test events and send a test pattern to a stream. - **Ready-made components.** Checkout, ticket wallet, fan card, sign-in and player, for any web page or app. - **A test mode on every product,** with a hosted test stream you can send to.