Build
SDK
@raylight/sdk for TypeScript: typed calls, wait helpers and webhook verification.
@raylight/sdk is a thin typed client for the REST API. Its types come from the same schemas the server validates with, so a body the SDK accepts is a body the API accepts. Node 18 or newer (global fetch), ESM and CommonJS, one runtime dependency (zod).
npm install @raylight/sdkClient
new Raylight({ apiKey?, baseUrl?, fetch? }). apiKey defaults to RAYLIGHT_API_KEY; pass null to use only the public surface (signed links and the device flow). An explicit baseUrl always wins. Without one, RAYLIGHT_API_URL is used only when the key also came from RAYLIGHT_API_KEY, so a key you pass in code never follows a server URL someone else set on the machine; otherwise it is https://api.raylight.app. The URL must be https; plain http is accepted only for localhost, so a key is never sent in the clear. A key or URL that cannot work throws RaylightConfigError from the constructor. The key never appears in logs, errors or console.log(raylight).
Make a video
import { Raylight } from "@raylight/sdk";const raylight = new Raylight({ apiKey: process.env.RAYLIGHT_API_KEY });const production = await raylight.productions.create({ brief: { intent: "Announce dark mode in the Acme dashboard to existing customers.", code: [{ path: "src/components/settings/theme-toggle.tsx", source }], format: { template: "feature", durationMs: 10000 }, }, externalId: "acme/web#412", origin: { provider: "github", label: "acme/web #412", ref: "feat/dark-mode" },});const { production: pitched } = await raylight.productions.waitForPitch(production.id);if (pitched.stage !== "pitch_ready") { console.log("Nothing to greenlight:", pitched.stage, pitched.pitch?.verdictReason);} else { console.log(pitched.pitch?.title, pitched.pitch?.estimatedCredits, "credits"); await raylight.productions.greenlight(production.id, { pitchVersion: pitched.pitch?.version, }); const { production: done } = await raylight.productions.waitForDelivery(production.id); console.log(done.stage, done.delivery?.shareUrl, done.delivery?.editUrl);}| Method | Does |
|---|---|
productions.create(input) | Start a production from a brief. Same body as POST /v1/productions. Returns the queued production. |
productions.get(id) | { production, events, queue, pitchMarkdown }: the production, its timeline, where it stands while it is made, and the pitch as markdown. |
productions.list({ workspaceId?, stage?, externalId?, limit? }) | Productions newest first. stage takes one stage or an array; externalId finds the production a create with that id made. Code sources are left out of list results; get has them. |
productions.usage({ workspaceId? }) | This month's counts and the pitch cap. |
productions.greenlight(id, { pitchVersion? }) | Greenlight. Pass the pitch version you read to refuse it if the pitch changed since. |
productions.comment(id, { comment, pitchVersion? }) | Send comments. The next pitch version is free. |
productions.decline(id, { reason? }) | Decline the pitch. Nothing is charged. |
productions.cancel(id) | Cancel before production starts. |
productions.waitForPitch(id, { intervalMs?, timeoutMs?, signal?, onChange? }) | Polls until the stage leaves queued and writing_pitch. Usually resolves at pitch_ready, but check the stage first: it can also be skipped (nothing to greenlight), another terminal stage, or greenlit and later with auto-greenlight. |
productions.waitForDelivery(id, { ... }) | Polls until the production ends: delivered, failed, cancelled, declined or skipped. Check production.stage before trusting production.delivery. |
waitForPitch waits for the pitch and waitForDelivery for the finished video. Both throw RaylightTimeoutError (with productionId and lastStage) on timeout: 10 minutes for a pitch and 45 minutes for delivery by default. To show a stage to a person, use productionStageLabel(production.stage) (Pitch ready, Greenlit, Wrapped...); PRODUCTION_STAGE_LABELS is the full map, and productionPhaseLabel does the same for queue.phase.
Errors
API failures throw RaylightApiError with status, the server's code and message, any validation issues, and retryAfterSeconds on a 429. isAuthError, isForbidden and isRateLimited save a status check.
import { Raylight, RaylightApiError } from "@raylight/sdk";try { await raylight.productions.greenlight(productionId, { pitchVersion: 1 });} catch (error) { if (error instanceof RaylightApiError && error.code === "credits") { console.log("Top up first:", error.message); } else if (error instanceof RaylightApiError && error.isRateLimited) { console.log("Retry in", error.retryAfterSeconds, "seconds"); } else { throw error; }}Webhooks
const endpoint = await raylight.webhooks.create({ url: "https://hooks.acme.dev/raylight", description: "Slack relay", events: ["production.pitch_ready", "production.delivered", "production.failed"],});console.log(endpoint.secret);await raylight.webhooks.test(endpoint.id);const deliveries = await raylight.webhooks.deliveries(endpoint.id, { limit: 10 });webhooks.list, create, get, update(id, patch), delete, rollSecret, test, deliveries(id, { limit? }) and redeliver(id, deliveryId) map one to one onto /v1/webhooks. Only create and rollSecret return a secret.
Receiving
constructWebhookEvent(rawBody, signatureHeader, secret) checks the Raylight-Signature header against the raw body (a string or bytes, exactly as received; either v1 during a roll; five minute tolerance), then reads the payload and returns the typed event. A body your framework already parsed into an object cannot be verified and throws a TypeError. Two errors tell you how to answer:
| Error | Means | Answer |
|---|---|---|
RaylightWebhookSignatureError | The signature is wrong, missing or too old. reason is malformed, expired or mismatch. Not from Raylight, or the wrong secret. | 400 |
RaylightWebhookPayloadError | Signed by Raylight, but this SDK cannot read the body. reason is not_json, not_an_event, unknown_type or invalid_shape, with eventId, eventType and issues for your logs. | 2xx, then log it and upgrade the SDK |
Answer a signed delivery with a 2xx even when you cannot read it. A 4xx makes Raylight retry the same bytes, and after 20 exhausted deliveries in a row the endpoint is switched off. An event type newer than your SDK does not throw at all: it comes back as { type: "unknown", rawType, id, data }, so acknowledge it and move on. Pass { strict: true } to throw RaylightWebhookPayloadError for newer types and values instead. isProductionWebhookEvent(event) narrows to production events. If you only need a yes or no on the signature, verifyWebhookSignature(rawBody, header, secret) returns { ok: true, timestamp } or { ok: false, reason }.
import { constructWebhookEvent, RaylightWebhookPayloadError, RaylightWebhookSignatureError,} from "@raylight/sdk";export const POST = async (request: Request) => { const rawBody = await request.text(); const signature = request.headers.get("raylight-signature") ?? ""; const secret = process.env.RAYLIGHT_WEBHOOK_SECRET ?? ""; let event; try { event = constructWebhookEvent(rawBody, signature, secret); } catch (error) { if (error instanceof RaylightWebhookSignatureError) { return new Response("bad signature", { status: 400 }); } if (error instanceof RaylightWebhookPayloadError) { console.warn("Unreadable Raylight event", error.eventType, error.reason); return new Response(null, { status: 204 }); } throw error; } if (event.type === "production.pitch_ready") { await postToSlack(event.data.links); } return new Response(null, { status: 204 });};Greenlight links
Link endpoints need no key. links.read(token) returns the summary with its state; links.greenlight(token), links.comment(token, { comment }) and links.decline(token, { reason? }) greenlight, send comments or decline. The token is the last path segment of the link URL; it shows as [token] in error messages so it does not end up in logs.
const raylight = new Raylight({ apiKey: null });const summary = await raylight.links.read(token);if (summary.state === "open") { await raylight.links.greenlight(token);}Device flow
keys.loginWithDeviceFlow({ name, onApprovalUrl, signal? }) runs the whole device flow and resolves with the key, or throws RaylightDeviceFlowError with status denied, expired or claimed. keys.requestDeviceCode({ name }) and keys.poll(deviceCode) are the two underlying calls. keys.revokeCurrent() revokes the key the client is using (DELETE /v1/keys/current), for a tool that signs out or suspects a leak.
const raylight = new Raylight({ apiKey: null });const apiKey = await raylight.keys.loginWithDeviceFlow({ name: "my-tool on ci-runner-3", onApprovalUrl: ({ approvalUrl, userCode }) => { console.log(`Open ${approvalUrl} and confirm code ${userCode}`); },});Also exported
Every schema and type behind the API (Production, WebhookPayload, CreateProductionInputSchema, ...), renderPitchMarkdown, the API_PATHS table, and the header name and tolerance constants, so receivers and generators can use the server's own definitions.
Other languages
The API is small and plain JSON. Point a generator at the OpenAPI document, or write the calls you need from the API reference. The Python signature check is on Webhooks.