RaylightAPI

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).

bash
npm install @raylight/sdk

Client

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

ts
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);}
MethodDoes
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.

ts
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

ts
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:

ErrorMeansAnswer
RaylightWebhookSignatureErrorThe signature is wrong, missing or too old. reason is malformed, expired or mismatch. Not from Raylight, or the wrong secret.400
RaylightWebhookPayloadErrorSigned 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 }.

route.ts
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 });};

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.

ts
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.

ts
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.