From 8f7d3cff33445ded4d3c94f0fb8ac5060d790148 Mon Sep 17 00:00:00 2001 From: KKKK Date: Fri, 31 Jul 2026 23:29:46 +0800 Subject: [PATCH] add realtime video examples --- README.md | 45 ++++- examples/browser/realtime-video.ts | 33 ++++ examples/curl/realtime-session.sh | 18 ++ examples/node/lib/beatapi.mjs | 24 +++ examples/node/realtime-session.mjs | 33 ++++ examples/python/beatapi.py | 34 +++- examples/python/realtime_session.py | 41 +++++ fixtures/realtime-session-created.json | 20 +++ openapi/beatapi.yaml | 220 +++++++++++++++++++++++-- package-lock.json | 6 +- package.json | 2 +- tests/fixtures-contract.test.mjs | 1 + tests/node-client.test.mjs | 59 +++++++ tests/repository-contract.test.mjs | 5 + tests/test_python_client.py | 69 ++++++++ 15 files changed, 589 insertions(+), 21 deletions(-) create mode 100644 examples/browser/realtime-video.ts create mode 100644 examples/curl/realtime-session.sh create mode 100644 examples/node/realtime-session.mjs create mode 100644 examples/python/realtime_session.py create mode 100644 fixtures/realtime-session-created.json diff --git a/README.md b/README.md index c5c29ca..0bbf37a 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,19 @@ # BeatAPI -Official runnable examples for the BeatAPI async AI video API. +Official runnable examples for BeatAPI async workflows and Realtime Video sessions. [![Verify examples](https://github.com/BeatAPI/beatapi-examples/actions/workflows/verify.yml/badge.svg)](https://github.com/BeatAPI/beatapi-examples/actions/workflows/verify.yml) [Website](https://beatapi.io/) · [API documentation](https://docs.beatapi.io/) · +[Realtime Video documentation](https://docs.beatapi.io/realtime-video) · [Music Video Playground](https://beatapi.io/music-video-api) · [Ecommerce Video Playground](https://beatapi.io/ecommerce-video-api) -BeatAPI gives product teams one workflow API for creating AI music videos and -ecommerce video ads. Submit media and creative direction, receive a task ID, -then poll or use a webhook until the hosted MP4 is ready. +BeatAPI gives product teams one server-side API key for two integration shapes: +async Music Video and Ecommerce Video workflows that return hosted output, and +short-lived Realtime Video sessions that connect browser media through +`@beatapi/realtime`. The primary launch route is `POST /v1/music-video/tasks`. @@ -80,6 +82,30 @@ curl https://api.beatapi.io/v1/tasks/task_8K2qA \ Stop polling when the task is `succeeded` or `failed`. Successful output URLs are available in `data.output.media`. +### Realtime session quickstart + +Create Realtime sessions only from trusted server code. The browser must never +receive the permanent `sk_...` API key. It receives only the returned, +short-lived `client_secret`: + +```bash +curl https://api.beatapi.io/v1/realtime/sessions \ + -X POST \ + -H "Authorization: Bearer $BEATAPI_API_KEY" \ + -H "Idempotency-Key: customer-call-123" \ + -H "Content-Type: application/json" \ + -d '{ + "max_duration_seconds": 60, + "allowed_origins": ["https://app.example.com"] + }' +``` + +Use `GET /v1/realtime/sessions/{session_id}` to inspect the session and +`DELETE` on the same path to close it idempotently. Camera capture and WebRTC +belong in the browser SDK; the server examples manage only session lifecycle. +Realtime production access and package availability remain limited until the +published launch checks are complete. + ## Examples | Example | cURL | Node.js | Python | @@ -89,6 +115,10 @@ are available in `data.output.media`. | Poll a task | [`poll-task.sh`](examples/curl/poll-task.sh) | reference client | reference client | | Upload a file | [`upload-file.sh`](examples/curl/upload-file.sh) | [`upload-file.mjs`](examples/node/upload-file.mjs) | [`upload_file.py`](examples/python/upload_file.py) | | Receive webhooks | — | [`webhook-server.mjs`](examples/node/webhook-server.mjs) | — | +| Realtime session lifecycle | [`realtime-session.sh`](examples/curl/realtime-session.sh) | [`realtime-session.mjs`](examples/node/realtime-session.mjs) | [`realtime_session.py`](examples/python/realtime_session.py) | + +The browser-side SDK handoff is shown in +[`examples/browser/realtime-video.ts`](examples/browser/realtime-video.ts). ### Node.js @@ -98,6 +128,7 @@ verification requires Node.js 20.19+ or 22.12+. ```bash node examples/node/music-video.mjs node examples/node/ecommerce-video.mjs +node examples/node/realtime-session.mjs ``` The dependency-free reference client is at @@ -112,6 +143,7 @@ Requires Python 3.11 or newer and uses only the standard library. ```bash python3 examples/python/music_video.py python3 examples/python/ecommerce_video.py +python3 examples/python/realtime_session.py ``` The matching reference client is at @@ -126,6 +158,8 @@ The matching reference client is at | `POST` | `/v1/ecommerce-video/tasks` | Create an Ecommerce Video task | | `GET` | `/v1/tasks/{task_id}` | Poll task status and output | | `GET` | `/v1/usage` | Read usage, credits, and concurrency | +| `POST` | `/v1/realtime/sessions` | Create a short-lived Realtime Video session | +| `GET/DELETE` | `/v1/realtime/sessions/{session_id}` | Inspect or close a Realtime Video session | | `POST` | `/v1/files` | Upload local workflow inputs | | `GET/POST` | `/v1/webhooks` | List or create webhook endpoints | | `GET/PATCH/DELETE` | `/v1/webhooks/{id}` | Manage a webhook endpoint | @@ -213,6 +247,9 @@ node examples/node/webhook-server.mjs - Never commit `.env` files or paste keys into browser code. - Never include credentials in screenshots, exported workflow JSON, or issues. - Rotate a key immediately if it is exposed. +- For Realtime, create sessions on the server and give the browser only the + returned short-lived `client_secret`. +- Use exact HTTPS `allowed_origins`; wildcards are rejected. ## Repository scope diff --git a/examples/browser/realtime-video.ts b/examples/browser/realtime-video.ts new file mode 100644 index 0000000..f9a703e --- /dev/null +++ b/examples/browser/realtime-video.ts @@ -0,0 +1,33 @@ +import { createRealtimeClient } from "@beatapi/realtime"; + +type SessionResponse = { + data: { + client_secret: string; + }; +}; + +const response = await fetch("/api/realtime/session", { method: "POST" }); +if (!response.ok) throw new Error("Unable to create a BeatAPI Realtime Session."); +const session = (await response.json()) as SessionResponse; + +const input = await navigator.mediaDevices.getUserMedia({ video: true }); +const output = document.querySelector("#realtime-output"); +if (!output) throw new Error("Missing #realtime-output video element."); + +const client = createRealtimeClient({ + clientSecret: session.data.client_secret, +}); +const connection = await client.connect({ + input, + output, + initial: { prompt: "Transform the scene while preserving motion." }, +}); + +await connection.set({ + prompt: "Keep the movement and change the scene to watercolor.", +}); + +window.addEventListener("pagehide", () => { + void connection.disconnect(); + input.getTracks().forEach((track) => track.stop()); +}); diff --git a/examples/curl/realtime-session.sh b/examples/curl/realtime-session.sh new file mode 100644 index 0000000..c4950f5 --- /dev/null +++ b/examples/curl/realtime-session.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${BEATAPI_API_KEY:?Set BEATAPI_API_KEY before running this example.}" +BASE_URL="${BEATAPI_BASE_URL:-https://api.beatapi.io}" +IDEMPOTENCY_KEY="${1:-realtime-$(date +%s)}" + +curl --fail-with-body --silent --show-error \ + "${BASE_URL}/v1/realtime/sessions" \ + -X POST \ + -H "Authorization: Bearer ${BEATAPI_API_KEY}" \ + -H "Idempotency-Key: ${IDEMPOTENCY_KEY}" \ + -H "Content-Type: application/json" \ + --data '{ + "max_duration_seconds": 60, + "allowed_origins": ["https://app.example.com"], + "metadata": {"example": "curl"} + }' diff --git a/examples/node/lib/beatapi.mjs b/examples/node/lib/beatapi.mjs index 300ffe1..2a35549 100644 --- a/examples/node/lib/beatapi.mjs +++ b/examples/node/lib/beatapi.mjs @@ -86,6 +86,30 @@ export class BeatAPIClient { }); } + createRealtimeSession(input, { idempotencyKey } = {}) { + if (!idempotencyKey) { + throw new TypeError("idempotencyKey is required."); + } + return this.request("/v1/realtime/sessions", { + method: "POST", + body: input, + headers: { "idempotency-key": idempotencyKey }, + }); + } + + getRealtimeSession(sessionId) { + return this.request( + `/v1/realtime/sessions/${encodeURIComponent(sessionId)}`, + ); + } + + closeRealtimeSession(sessionId) { + return this.request( + `/v1/realtime/sessions/${encodeURIComponent(sessionId)}`, + { method: "DELETE" }, + ); + } + getTask(taskId) { return this.request(`/v1/tasks/${encodeURIComponent(taskId)}`); } diff --git a/examples/node/realtime-session.mjs b/examples/node/realtime-session.mjs new file mode 100644 index 0000000..fe3823c --- /dev/null +++ b/examples/node/realtime-session.mjs @@ -0,0 +1,33 @@ +import { randomUUID } from "node:crypto"; + +import { BeatAPIClient, BeatAPIError } from "./lib/beatapi.mjs"; + +const client = new BeatAPIClient(); + +try { + const session = await client.createRealtimeSession( + { + max_duration_seconds: 60, + allowed_origins: ["https://app.example.com"], + metadata: { example: "node" }, + }, + { idempotencyKey: randomUUID() }, + ); + + console.log(JSON.stringify({ + id: session.id, + status: session.status, + expires_at: session.expires_at, + client_secret_received: Boolean(session.client_secret), + }, null, 2)); + console.log("Return client_secret only to the exact allowed browser origin; do not log it."); +} catch (error) { + if (error instanceof BeatAPIError) { + console.error( + `[${error.status}] ${error.code}: ${error.message} (${error.requestId || "no request id"})`, + ); + } else { + console.error(error); + } + process.exitCode = 1; +} diff --git a/examples/python/beatapi.py b/examples/python/beatapi.py index 0ab1c47..1c0ce5e 100644 --- a/examples/python/beatapi.py +++ b/examples/python/beatapi.py @@ -64,19 +64,21 @@ def request( *, method: str = "GET", body: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, ) -> Any: data = json.dumps(body).encode() if body is not None else None - headers = { + request_headers = { "Accept": "application/json", "Authorization": f"Bearer {self.api_key}", + **(headers or {}), } if data is not None: - headers["Content-Type"] = "application/json" + request_headers["Content-Type"] = "application/json" request = urllib.request.Request( f"{self.base_url}{path}", data=data, - headers=headers, + headers=request_headers, method=method, ) @@ -119,6 +121,32 @@ def create_ecommerce_video_task( body=input_data, ) + def create_realtime_session( + self, + input_data: dict[str, Any], + *, + idempotency_key: str, + ) -> dict[str, Any]: + if not idempotency_key: + raise ValueError("idempotency_key is required") + return self.request( + "/v1/realtime/sessions", + method="POST", + body=input_data, + headers={"Idempotency-Key": idempotency_key}, + ) + + def get_realtime_session(self, session_id: str) -> dict[str, Any]: + encoded = urllib.parse.quote(session_id, safe="") + return self.request(f"/v1/realtime/sessions/{encoded}") + + def close_realtime_session(self, session_id: str) -> dict[str, Any]: + encoded = urllib.parse.quote(session_id, safe="") + return self.request( + f"/v1/realtime/sessions/{encoded}", + method="DELETE", + ) + def get_task(self, task_id: str) -> dict[str, Any]: return self.request(f"/v1/tasks/{urllib.parse.quote(task_id, safe='')}") diff --git a/examples/python/realtime_session.py b/examples/python/realtime_session.py new file mode 100644 index 0000000..a770ae2 --- /dev/null +++ b/examples/python/realtime_session.py @@ -0,0 +1,41 @@ +import json +import uuid + +from beatapi import BeatAPIClient, BeatAPIError + + +def main() -> None: + client = BeatAPIClient() + session = client.create_realtime_session( + { + "max_duration_seconds": 60, + "allowed_origins": ["https://app.example.com"], + "metadata": {"example": "python"}, + }, + idempotency_key=str(uuid.uuid4()), + ) + print( + json.dumps( + { + "id": session["id"], + "status": session["status"], + "expires_at": session.get("expires_at"), + "client_secret_received": bool(session.get("client_secret")), + }, + indent=2, + ) + ) + print( + "Return client_secret only to the exact allowed browser origin; do not log it." + ) + + +if __name__ == "__main__": + try: + main() + except BeatAPIError as error: + print( + f"[{error.status}] {error.code}: {error} " + f"({error.request_id or 'no request id'})" + ) + raise SystemExit(1) from error diff --git a/fixtures/realtime-session-created.json b/fixtures/realtime-session-created.json new file mode 100644 index 0000000..7aab5e3 --- /dev/null +++ b/fixtures/realtime-session-created.json @@ -0,0 +1,20 @@ +{ + "data": { + "id": "rts_01JEXAMPLE", + "object": "realtime.session", + "status": "ready", + "client_secret": "brt_live_example", + "expires_at": "2026-07-31T15:30:00.000Z", + "max_duration_seconds": 60, + "allowed_origins": ["https://app.example.com"], + "credits": { + "reserved": 60, + "settled": 0, + "refunded": 0 + }, + "request_id": "req_realtime_example", + "created_at": "2026-07-31T15:29:00.000Z", + "connected_at": null, + "closed_at": null + } +} diff --git a/openapi/beatapi.yaml b/openapi/beatapi.yaml index afa4701..65e1dc9 100644 --- a/openapi/beatapi.yaml +++ b/openapi/beatapi.yaml @@ -6,15 +6,20 @@ info: name: BeatAPI Terms of Service url: https://beatapi.io/terms-of-service description: | - BeatAPI is a simple async API for video workflows. Most integrations use the - same loop: create a task, poll the task until it finishes, then read the - hosted video URL from `output.media`. + BeatAPI provides async video workflows and short-lived Realtime Video + Sessions behind one BeatAPI-native API. Async integrations create a task, + poll until it finishes, then read the hosted video URL from `output.media`. + Realtime browser integrations create a Session with the same Bearer API key, + then pass only the returned BeatAPI `client_secret` to `@beatapi/realtime`. ## 5 minute Quick Start 1. Set your base URL to `https://api.beatapi.io`. - 2. Create an API key in Dashboard and send it as + 2. Create an API key in [Dashboard → API Keys](https://beatapi.io/dashboard/apikeys) + and send it as `Authorization: Bearer `. + Credit packs are available from + [Dashboard → Billing](https://beatapi.io/dashboard/billing). 3. Use public HTTPS URLs for input media. If your files are local, upload them with `POST /v1/files` first. 4. Create a workflow task. @@ -96,6 +101,8 @@ tags: description: Poll task status and read output URLs. - name: Usage description: Inspect task totals and account concurrency. + - name: Realtime Video + description: Create and manage short-lived BeatAPI browser sessions for live AI video effects. - name: Files description: Upload local assets and use the returned HTTPS URL as workflow input. - name: Webhooks @@ -444,12 +451,73 @@ components: type: integer credits_settled: type: integer + realtime: + type: object + required: [sessions, credits, active] + properties: + sessions: + type: integer + description: Total BeatAPI realtime sessions for this account. + credits: + type: integer + description: Credits settled by connected realtime sessions. + active: + type: integer + description: Realtime sessions in ready, connecting, or active state. UsageResponse: type: object required: [data] properties: data: $ref: '#/components/schemas/Usage' + RealtimeSession: + type: object + required: [id, object, status, expires_at, max_duration_seconds, allowed_origins, credits, request_id, created_at, connected_at, closed_at] + properties: + id: + type: string + pattern: '^rts_' + object: + type: string + enum: [realtime.session] + status: + type: string + enum: [ready, connecting, active, closed, failed, expired] + description: Active means BeatAPI accepted the first billing heartbeat after remote output began. + client_secret: + type: string + description: Returned only by POST. Give this short-lived BeatAPI secret to the browser SDK; never give the browser an sk_ API key. + pattern: '^brt_live_' + expires_at: + type: string + format: date-time + max_duration_seconds: + type: integer + enum: [15, 60, 300] + allowed_origins: + type: array + items: { type: string, format: uri } + credits: + type: object + required: [reserved, settled, refunded] + properties: + reserved: { type: integer } + settled: { type: integer } + refunded: { type: integer } + request_id: + type: string + created_at: { type: string, format: date-time } + connected_at: + type: [string, 'null'] + format: date-time + description: Time of the first accepted BeatAPI billing heartbeat; null before billing activation. + closed_at: { type: [string, 'null'], format: date-time } + RealtimeSessionResponse: + type: object + required: [data] + properties: + data: + $ref: '#/components/schemas/RealtimeSession' FileResponse: type: object required: [data] @@ -515,6 +583,12 @@ components: - processing_timeout - result_transfer_failed - invalid_signature + - realtime_disabled + - realtime_capacity_unavailable + - realtime_session_expired + - origin_not_allowed + - invalid_client_secret + - transport_not_allowed - internal_error message: type: string @@ -522,7 +596,7 @@ components: type: string retry_after_seconds: type: integer - description: Present on rate_limit_exceeded responses when the client should wait before retrying. + description: Present on retryable rate-limit or capacity responses when the client should wait before retrying. responses: Unauthorized: description: Missing, invalid, or inactive API key. @@ -568,6 +642,7 @@ paths: get: operationId: listWorkflows tags: [Workflows] + x-apidog-folder: Reference/API Overview summary: List launch workflows security: [] responses: @@ -596,7 +671,8 @@ paths: post: operationId: createMusicVideoTask tags: [Music Video] - summary: Create a Music Video workflow task + x-apidog-folder: Music Video API/Create Video + summary: Create Music Video security: - BearerAuth: [] description: | @@ -778,7 +854,8 @@ paths: post: operationId: editMusicVideoShot tags: [Music Video] - summary: Edit a Music Video storyboard shot + x-apidog-folder: Music Video API/Advanced Editing + summary: Edit Shot security: - BearerAuth: [] description: | @@ -852,7 +929,8 @@ paths: post: operationId: getMusicVideoShotMedia tags: [Music Video] - summary: Retrieve a Music Video storyboard shot media URL + x-apidog-folder: Music Video API/Advanced Editing + summary: Get Shot Media security: - BearerAuth: [] description: | @@ -916,7 +994,8 @@ paths: post: operationId: composeMusicVideoTask tags: [Music Video] - summary: Compose a Music Video task from selected shots + x-apidog-folder: Music Video API/Advanced Editing + summary: Compose Video security: - BearerAuth: [] description: | @@ -966,7 +1045,8 @@ paths: post: operationId: createEcommerceVideoTask tags: [Ecommerce Video] - summary: Create an Ecommerce Video workflow task + x-apidog-folder: Ecommerce Video API + summary: Create Ecommerce Video security: - BearerAuth: [] description: Ecommerce Video requires product images and an explicit output duration. @@ -1062,6 +1142,7 @@ paths: get: operationId: getTask tags: [Tasks] + x-apidog-folder: Reference/Task Status summary: Poll task status security: - BearerAuth: [] @@ -1172,10 +1253,119 @@ paths: '429': $ref: '#/components/responses/RateLimited' + /v1/realtime/sessions: + post: + operationId: createRealtimeSession + tags: [Realtime Video] + x-apidog-folder: Realtime Video API + summary: Create a realtime browser session + security: + - BearerAuth: [] + description: | + Reserve credits and allocate a short-lived BeatAPI realtime session. Send a unique + `Idempotency-Key`; retries with the same user, key, and body return the same session + and deterministic short-lived `client_secret` without reserving credits or capacity + twice. The browser receives only that BeatAPI secret and connects with + `@beatapi/realtime`. + + Billing is fixed by the selected maximum duration. The BeatAPI browser runtime sends its + first billing heartbeat only after the first remote output frame is rendered. When that + accepted BeatAPI billing heartbeat succeeds, the Session becomes `active` and the selected + duration is fully settled. A Session that closes or expires without an accepted billing + heartbeat is fully refunded. Billing activation comes from the trusted transport lifecycle, + not a caller-supplied browser event. + Production availability remains gated until the documented commercial and capacity launch + checks pass. + parameters: + - in: header + name: Idempotency-Key + required: true + schema: { type: string, maxLength: 128 } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [max_duration_seconds, allowed_origins] + properties: + max_duration_seconds: + type: integer + enum: [15, 60, 300] + allowed_origins: + type: array + minItems: 1 + maxItems: 10 + items: { type: string, format: uri } + metadata: + type: object + maxProperties: 20 + propertyNames: { maxLength: 64 } + additionalProperties: { type: string, maxLength: 256 } + example: + max_duration_seconds: 60 + allowed_origins: ["https://app.example.com"] + metadata: { customer_id: cus_123 } + responses: + '201': + description: Realtime session created + content: + application/json: + schema: { $ref: '#/components/schemas/RealtimeSessionResponse' } + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '402': + description: Insufficient credits + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } + '409': + description: Idempotency conflict + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } + '429': { $ref: '#/components/responses/RateLimited' } + '503': + description: Realtime is disabled or capacity is temporarily unavailable + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } + + /v1/realtime/sessions/{session_id}: + parameters: + - in: path + name: session_id + required: true + schema: { type: string, pattern: '^rts_' } + get: + operationId: getRealtimeSession + tags: [Realtime Video] + x-apidog-folder: Realtime Video API + summary: Get a realtime session + security: [{ BearerAuth: [] }] + responses: + '200': + description: Realtime session + content: { application/json: { schema: { $ref: '#/components/schemas/RealtimeSessionResponse' } } } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': + description: Realtime session not found + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } + delete: + operationId: closeRealtimeSession + tags: [Realtime Video] + x-apidog-folder: Realtime Video API + summary: Close a realtime session + security: [{ BearerAuth: [] }] + description: Idempotently closes the session, clears temporary credentials, and releases account capacity. + responses: + '200': + description: Realtime session closed + content: { application/json: { schema: { $ref: '#/components/schemas/RealtimeSessionResponse' } } } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': + description: Realtime session not found + content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } + /v1/usage: get: operationId: getUsage tags: [Usage] + x-apidog-folder: Reference/Usage & Limits summary: Get account usage and concurrency security: - BearerAuth: [] @@ -1196,6 +1386,10 @@ paths: concurrency: limit: 2 active: 1 + realtime: + sessions: 3 + credits: 90 + active: 1 by_workflow: - workflow: music-video tasks: 8 @@ -1210,6 +1404,7 @@ paths: post: operationId: uploadFile tags: [Files] + x-apidog-folder: Reference/Upload Files summary: Upload a file for workflow inputs security: - BearerAuth: [] @@ -1278,6 +1473,7 @@ paths: get: operationId: listWebhookEndpoints tags: [Webhooks] + x-apidog-folder: Reference/Webhooks summary: List webhook endpoints security: - BearerAuth: [] @@ -1306,6 +1502,7 @@ paths: post: operationId: createWebhookEndpoint tags: [Webhooks] + x-apidog-folder: Reference/Webhooks summary: Create a webhook endpoint security: - BearerAuth: [] @@ -1426,6 +1623,7 @@ paths: get: operationId: getWebhookEndpoint tags: [Webhooks] + x-apidog-folder: Reference/Webhooks summary: Get a webhook endpoint security: - BearerAuth: [] @@ -1465,6 +1663,7 @@ paths: patch: operationId: updateWebhookEndpoint tags: [Webhooks] + x-apidog-folder: Reference/Webhooks summary: Update a webhook endpoint security: - BearerAuth: [] @@ -1511,6 +1710,7 @@ paths: delete: operationId: deleteWebhookEndpoint tags: [Webhooks] + x-apidog-folder: Reference/Webhooks summary: Delete a webhook endpoint security: - BearerAuth: [] diff --git a/package-lock.json b/package-lock.json index 453c276..1b6e8fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -76,9 +76,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index e764421..b932099 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "beatapi-examples", "version": "0.1.0", "private": true, - "description": "Official examples for the BeatAPI async AI video API.", + "description": "Official examples for BeatAPI async workflows and Realtime Video sessions.", "type": "module", "scripts": { "test": "node --test tests/*.test.mjs", diff --git a/tests/fixtures-contract.test.mjs b/tests/fixtures-contract.test.mjs index 1289e31..98a57f8 100644 --- a/tests/fixtures-contract.test.mjs +++ b/tests/fixtures-contract.test.mjs @@ -31,6 +31,7 @@ const fixtureContracts = [ ["fixtures/task-failed.json", "TaskResponse"], ["fixtures/webhook-task-succeeded.json", "WebhookEvent"], ["fixtures/api-error.json", "Error"], + ["fixtures/realtime-session-created.json", "RealtimeSessionResponse"], ]; for (const [fixturePath, schemaName] of fixtureContracts) { diff --git a/tests/node-client.test.mjs b/tests/node-client.test.mjs index 941ac0f..7b0e251 100644 --- a/tests/node-client.test.mjs +++ b/tests/node-client.test.mjs @@ -45,6 +45,65 @@ test("creates a music video task with bearer authentication", async () => { assert.equal(captured.init.method, "POST"); }); +test("creates a realtime session with an idempotency key", async () => { + assert.ok(BeatAPIClient, "BeatAPIClient must be implemented"); + let captured; + const client = new BeatAPIClient({ + apiKey: "sk_test", + fetchImpl: async (url, init) => { + captured = { url, init }; + return jsonResponse(201, { + data: { + id: "rts_test", + object: "realtime.session", + status: "ready", + client_secret: "brt_live_test", + }, + }); + }, + }); + + const session = await client.createRealtimeSession( + { + max_duration_seconds: 60, + allowed_origins: ["https://app.example.com"], + }, + { idempotencyKey: "customer-call-123" }, + ); + + assert.equal(session.id, "rts_test"); + assert.equal(captured.url, "https://api.beatapi.io/v1/realtime/sessions"); + assert.equal(captured.init.method, "POST"); + assert.equal( + captured.init.headers["idempotency-key"], + "customer-call-123", + ); +}); + +test("gets and closes a realtime session by encoded id", async () => { + const requests = []; + const client = new BeatAPIClient({ + apiKey: "sk_test", + fetchImpl: async (url, init) => { + requests.push({ + path: new URL(url).pathname, + method: init.method, + }); + return jsonResponse(200, { + data: { id: "rts_test", object: "realtime.session", status: "closed" }, + }); + }, + }); + + await client.getRealtimeSession("rts_test/value"); + await client.closeRealtimeSession("rts_test/value"); + + assert.deepEqual(requests, [ + { path: "/v1/realtime/sessions/rts_test%2Fvalue", method: "GET" }, + { path: "/v1/realtime/sessions/rts_test%2Fvalue", method: "DELETE" }, + ]); +}); + test("preserves structured API error details", async () => { assert.ok(BeatAPIClient, "BeatAPIClient must be implemented"); const client = new BeatAPIClient({ diff --git a/tests/repository-contract.test.mjs b/tests/repository-contract.test.mjs index eab2fed..8b76814 100644 --- a/tests/repository-contract.test.mjs +++ b/tests/repository-contract.test.mjs @@ -10,8 +10,12 @@ const requiredFiles = [ "SECURITY.md", "openapi/beatapi.yaml", "examples/curl/music-video.sh", + "examples/curl/realtime-session.sh", "examples/node/music-video.mjs", + "examples/node/realtime-session.mjs", "examples/python/music_video.py", + "examples/python/realtime_session.py", + "examples/browser/realtime-video.ts", "examples/node/webhook-server.mjs", "fixtures/task-succeeded.json", "integrations/n8n/beatapi-music-video.json", @@ -40,6 +44,7 @@ test("documents the public API without internal implementation names", async () assert.match(readme, /https:\/\/api\.beatapi\.io/); assert.match(readme, /POST \/v1\/music-video\/tasks/); + assert.match(readme, /`POST` \| `\/v1\/realtime\/sessions`/); assert.doesNotMatch(readme, /ShipAny|Hyperdrive|Supabase|Upstash|Vidu/i); }); diff --git a/tests/test_python_client.py b/tests/test_python_client.py index 695d565..15765d4 100644 --- a/tests/test_python_client.py +++ b/tests/test_python_client.py @@ -59,6 +59,75 @@ def transport(request): "Bearer sk_test", ) + def test_creates_realtime_session_with_idempotency_key(self): + captured = {} + + def transport(request): + captured["request"] = request + return Response( + 201, + { + "data": { + "id": "rts_test", + "object": "realtime.session", + "status": "ready", + } + }, + ) + + client = BeatAPIClient(api_key="sk_test", transport=transport) + session = client.create_realtime_session( + { + "max_duration_seconds": 60, + "allowed_origins": ["https://app.example.com"], + }, + idempotency_key="customer-call-123", + ) + + self.assertEqual(session["id"], "rts_test") + self.assertEqual( + captured["request"].full_url, + "https://api.beatapi.io/v1/realtime/sessions", + ) + self.assertEqual( + captured["request"].get_header("Idempotency-key"), + "customer-call-123", + ) + + def test_gets_and_closes_realtime_session(self): + captured = [] + + def transport(request): + captured.append((request.full_url, request.method)) + return Response( + 200, + { + "data": { + "id": "rts_test", + "object": "realtime.session", + "status": "closed", + } + }, + ) + + client = BeatAPIClient(api_key="sk_test", transport=transport) + client.get_realtime_session("rts_test/value") + client.close_realtime_session("rts_test/value") + + self.assertEqual( + captured, + [ + ( + "https://api.beatapi.io/v1/realtime/sessions/rts_test%2Fvalue", + "GET", + ), + ( + "https://api.beatapi.io/v1/realtime/sessions/rts_test%2Fvalue", + "DELETE", + ), + ], + ) + def test_polls_until_terminal_status(self): self.assertIsNotNone(BeatAPIClient, "BeatAPIClient must be implemented") statuses = iter(["queued", "processing", "succeeded"])