# Raylight API docs --- > Raylight makes short feature videos (5 to 15 seconds) from a brief pushed over HTTP. Send one sentence of intent plus context, media links and UI source files. Raylight writes a free pitch (script plus shot list). Greenlight it over REST, MCP, the app or a signed login-free link, and the video comes back as a share link plus an editable project you can grow into a longer video in Raylight. Each video is a production (`/v1/productions`). Every production event is pushed to webhooks you register. Raylight never pulls from your systems or holds your credentials. --- Every page of https://www.raylight.app/docs in reading order. The API reference section is generated from https://api.raylight.app/v1/openapi.json. How to use these docs: every page has a markdown twin. Append `.md` to any docs URL (the overview is https://www.raylight.app/docs/index.md), or request the normal URL with `Accept: text/markdown`. --- # Raylight API > Turn a short brief into a short feature video you can keep editing, from an AI assistant, a workflow or your own code. Source: https://www.raylight.app/docs/index.md Tell Raylight what the video is about and add whatever context you have: release notes, screenshots, UI files. It writes a pitch (a script and shot list) for free. Greenlight it and Raylight makes the video. You get a share link and a project you can keep editing in Raylight. Each video is a production, listed on the Productions page in the app. Productions are 5 to 15 seconds long: a feature recap or changelog explainer, the video you would never have made for every release. It is also a head start on a bigger launch video: open the project in Raylight and keep building. Every plan can make them, paid with credits. ## Pick your path - [From Claude or ChatGPT](https://www.raylight.app/docs/mcp.md): Connect Raylight once and ask for videos in chat. No code, no API key. - [Greenlight without logging in](https://www.raylight.app/docs/greenlight-links.md): Greenlight, Send comments and Decline links in Raylight's emails, or posted anywhere by your own webhook. Whoever gets one can answer. - [On every release](https://www.raylight.app/docs/github-action.md): A GitHub workflow step pitches a video for each release and posts it as a check. - [Build on the API](https://www.raylight.app/docs/quickstart.md): REST, SDK, CLI and webhooks. Start with one prompt for your coding agent. ## How it works 1. **Send a brief.** One sentence about the video plus any context. It can come from a chat, a CI step, a deploy hook, a Zap or your own code. 2. **Read the pitch.** Raylight writes a script and shot list in the background, for free. It shows up on the Productions page, by email, or on a webhook. 3. **Greenlight it, or send comments.** Greenlight from the app, a chat, or the signed link in Raylight's email (your webhook can post the same link to Slack or anywhere else). Nothing is charged until you greenlight; a video costs 60 credits per second of video (300 to 900 credits), and the pitch quotes the exact number. Comments get a free new version of the pitch, up to 5 rounds. 4. **Get the video.** Raylight makes and exports it. You get a share link plus an editable project in your workspace's Raylight folder. Every step can also be pushed to your own systems as a signed [webhook](https://www.raylight.app/docs/webhooks.md). ## Security in one list - **Push only.** Raylight never calls into your tools and holds no repo, deploy or chat credentials. It only gets what you send. - **No repo access.** No GitHub App, no OAuth grant to your source host. The GitHub Action runs on your runner and sends the brief it builds there. - **What we store.** The brief (intent, context, media links, the UI files you chose) and the production's events. Webhook payloads and emails never carry source code or diffs. - **Open source Action.** `raylight-labs/video-action` is public, so you can read what leaves the runner. - **Signed links expire with the pitch.** A greenlight link is bound to one production and one pitch version, lasts 30 days, and dies when the pitch changes. More on [Security](https://www.raylight.app/docs/security.md). ## For agents These docs are also plain text: [llms.txt](https://www.raylight.app/llms.txt) is the index and [llms-full.txt](https://www.raylight.app/llms-full.txt) has every page in one file. The OpenAPI document is at [api.raylight.app/v1/openapi.json](https://api.raylight.app/v1/openapi.json). --- # Use it from an AI assistant > Connect Claude, ChatGPT, Codex or Cursor once, then ask for videos in chat. No code, no API key. Source: https://www.raylight.app/docs/mcp.md Raylight runs an MCP server, the standard way AI assistants connect to other apps. Add it once in Claude, ChatGPT, Codex, Cursor or another client and sign in with your Raylight account. Setup steps for each are on [the connect page](https://www.raylight.app/mcp). ## Ask for a video Then ask in plain words, like "make a 15 second feature recap for our dark mode release", and paste in release notes or links to screenshots. The assistant sends the brief, shows you the pitch, and only greenlights it when you tell it to. You can also send comments and get a new version, or answer from the Greenlight link in the Pitch ready email instead. ## Tools The MCP server at `https://api.raylight.app/mcp` has two kinds of tools: editor tools that work inside an open Raylight project (read shots, apply edits, render frames), and the server-side tools below, which run productions and webhooks without an editor tab. The full list with limits and privacy is on [/mcp/docs](https://www.raylight.app/mcp/docs). It uses OAuth, not API keys: the client signs you in once and gets a token for your account. See [Authentication](https://www.raylight.app/docs/authentication.md). ### Production tools | Tool | Does | | --- | --- | | `create_production` | Push a brief: intent, context, media URLs, code files, format, brand, greenlight mode, externalId, origin. Returns the production; the pitch arrives in the background. | | `get_production` | One production: stage, the pitch as markdown, its events, the queue status, and the share and edit links once wrapped. | | `list_productions` | A workspace's productions newest first, filterable by stage or `externalId`. `["pitch_ready"]` finds pitches waiting for a greenlight. | | `greenlight_production` | Greenlight. Charges the pitch's `estimatedCredits` once (60 credits per second of video). The tool tells the assistant to confirm with you first, and clients that honour tool annotations ask too; pass `pitchVersion` from `get_production`. | | `comment_on_production` | Send comments and get the next pitch version, free. 5 rounds of comments per production (6 versions in all). | | `decline_production` | Decline a pitch. Nothing charged. Terminal. | | `cancel_production` | Cancel before production: free before the greenlight, refunded while still waiting for a render machine. | | `code_to_layers` | Convert React with Tailwind into canvas layer ops for an open project: frames with auto layout, text, shapes, icons, image placeholders. Parsed, never run. | ### Webhook tools | Tool | Does | | --- | --- | | `list_webhooks` | The workspace's endpoints: URL, events, enabled, last attempt and success, and why one was switched off. | | `create_webhook` | Register an HTTPS URL. Shows the signing secret once. | | `update_webhook` | Change the URL, description or events, or switch an endpoint on or off. | | `delete_webhook` | Delete an endpoint and its delivery log. | | `test_webhook` | Send a signed `ping`. | | `list_webhook_deliveries` | The delivery log: status, attempts, the receiver's last status and response body. | | `roll_webhook_secret` | Mint a new signing secret; the old one verifies for 24 hours. | | `redeliver_webhook` | Send a past delivery again as a new event. | ## A prompt that runs the loop Prompt: ```text Using the Raylight MCP server (https://api.raylight.app/mcp): start a production for the feature I just built. Intent: a 10 second feature announcement for existing customers. Pass the main UI component files as code, set origin to this repo and branch, then poll get_production until the pitch is ready and show me the shots. Do not call greenlight_production until I say yes. ``` The agent reads your files, calls `create_production` with the components that render the feature, polls `get_production`, and shows you the shots. The greenlight is a separate call, and the tool tells the agent to confirm it with you first. Until then nothing is charged: the pitch is free. ## When to use MCP versus the API - **MCP** when a person is in the loop in a chat or editor: the agent has your files and your attention, and OAuth means no key to manage. - **REST, the SDK or the CLI** when nobody is watching: CI, deploy hooks, scheduled digests. Keys, idempotency and webhooks are built for that. - Both share one Productions page and one set of webhooks, so a production started from MCP can be greenlit from an email and land in Slack. --- # Greenlight links > Signed, login-free links that answer one pitch from Raylight's email, or from anywhere your webhook posts them. Source: https://www.raylight.app/docs/greenlight-links.md A greenlight link is bound to one production and one pitch version. Whoever holds it can read that pitch and answer it (greenlight, send comments, decline) without a Raylight account, until the pitch changes or the link expires. Nobody has to touch the API. ## Where they appear - **Pitch ready emails.** Every workspace owner and editor with production emails on gets Greenlight, Send comments and Decline links. Answers are recorded as `link:`. - **Webhook payloads.** `data.links.greenlight`, `data.links.comment` and `data.links.decline` on every event while the pitch is waiting for an answer. Raylight does not post them anywhere else, but your receiver can: a Slack message, a CI summary or a ticket comment. Answers are recorded as `webhook:`, so you can tell which relay a greenlight came through. Links are minted per recipient: each email and each endpoint's payload gets its own token for the same pitch. ## Lifetime A link lives 30 days or until the pitch moves on, whichever comes first. Reading a link returns its `state`: | State | Meaning | | --- | --- | | `open` | The link's pitch is current and waiting for an answer. Actions work. | | `stale` | The pitch moved on: a newer version exists, or the production was greenlit, declined or cancelled. The page still shows what it points at, and where to look instead. | | `expired` | Past the 30 days. The page still shows the pitch; actions refuse. | So a Greenlight in an old email can never greenlight a version its reader did not see: once comments produce version 2, every version 1 link is stale. A link also stops working when the person or endpoint it was minted for no longer stands behind it: the email's owner left the workspace or became a viewer, or the webhook endpoint was deleted. Those links answer 403 with `code: "revoked"`, for reading as well as acting, since the summary is workspace data. Pausing an endpoint does not revoke links it already delivered. ## The public page Links open `https://www.raylight.app/go/`, optionally with `?action=greenlight`, `comment` or `decline` to lead with that button. The page shows the workspace name, intent, origin label, format, the pitch with its price, and how many rounds of comments are left. After the production wraps it shows the share and edit links instead. It never acts on load, since mail scanners prefetch every link; each action is an explicit button press that sends a POST. A link shows only part of the production: the pitch and price, the format, the origin label, and the share links once it wraps. Never the brief's context, media URLs or code. ## The public endpoints The page is a thin client over four unauthenticated routes on `https://api.raylight.app`. The token is the whole credential; on top of the HMAC, the routes are rate limited to 30 requests a minute per IP. | Call | Does | Answers | | --- | --- | --- | | `GET /production-links/{token}` | Read the link: state, pitch, price, format, origin, delivery, `revisionsLeft`, `expiresAt`. | 200 in every state, 403 revoked, 404 for a bad token | | `POST /production-links/{token}/greenlight` | Greenlight. Charges the credits and queues the video. | 200, 402 no credits, 403 revoked, 409 stale, 410 expired, 429 cap | | `POST /production-links/{token}/comments` | Send comments. Body `{ "comment": "..." }`, up to 4,000 characters. | 200, 403 revoked, 409 stale, 410 expired, 429 out of rounds or monthly cap | | `POST /production-links/{token}/decline` | Decline. Body `{ "reason": "..." }`, optional. | 200, 403 revoked, 409 stale, 410 expired | Any of them answers 503 with `code: "disabled"` when links are switched off on the server. Every success carries `{ link }` with the summary after the action, so your own buttons can call these directly and show the result. `revisionsLeft` counts the rounds of comments still allowed: a production gets 6 pitch versions, so 5 rounds after the first. The SDK wraps them as `raylight.links.read`, `greenlight`, `comment` and `decline`. ## How they are made A token is a small signed claim set (production id, pitch version, actor label, expiry), HMAC-signed with a server secret. Nothing is stored per link. Rotating that secret kills every link ever sent; that is the emergency brake. --- # GitHub Action > A workflow step that builds a brief from your diff on your runner and posts the pitch as a check. Source: https://www.raylight.app/docs/github-action.md There is no Raylight GitHub App. The Action is a normal workflow step: it runs on your runner, reads the checkout, builds a brief, and makes the same `POST /v1/productions` call you would make by hand to start a production. Raylight never gets a token for your repository and never calls GitHub. ## Setup 1. Create an API key with the `productions` scope (Raylight API, Keys) and add it to the repository as the `RAYLIGHT_API_KEY` secret. 2. Add a workflow. Check out with `fetch-depth: 0` so the Action can diff against the previous release, or from where a pull request branched off its base. With a shallow checkout it fetches the history it needs itself. 3. Pick how to greenlight: the production's page in the app, the Pitch ready email, or a webhook relay. They all answer the same production. .github/workflows/launch-video.yml: ```yaml name: Launch video on: release: types: [published] jobs: video: runs-on: ubuntu-latest permissions: contents: read checks: write steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: raylight-labs/video-action@v1 with: api-key: ${{ secrets.RAYLIGHT_API_KEY }} intent: "Announce ${{ github.event.release.name }} to existing customers" template: launch duration-seconds: 15 aspect: "16:9" greenlight-mode: manual code-globs: "src/**/*.tsx" wait-for-pitch: true post-check: true ``` ## Inputs | Input | Meaning | Default | | --- | --- | --- | | `api-key` | The `rl_live_` key. Pass it from a secret. | required | | `intent` | The one sentence. Fill in the release or PR title with workflow expressions. | required | | `context` | Longer context: release notes, the PR body, linked issues. | the PR body, the release notes, or on a push the head commit's message | | `template` | `changelog`, `launch`, `feature`, `digest` or `custom`. | `changelog` | | `duration-seconds` | Length of the video, 5 to 15. | 10 | | `aspect` | `16:9`, `9:16`, `1:1` and the rest of the aspect list. | `16:9` | | `greenlight-mode` | `manual` waits for someone to greenlight the pitch; `auto` greenlights a video verdict and charges immediately. | `manual` | | `base-ref`, `head-ref` | The two ends of the diff. | the PR base, the previous release tag or the push's before sha; `GITHUB_SHA` | | `code-globs` | Newline separated globs of files that may be sent as UI source. Nothing outside them leaves the runner. | UI source, style and script files outside `node_modules` and build output | | `max-files` | Most changed files to send, up to 40. | 40 | | `media` | Newline separated `url::name` lines for hosted images or videos. | none | | `wait-for-pitch` | Hold the step (up to 10 minutes) until the pitch is ready and write it to the step summary. | `true` | | `post-check` | Add a neutral check run named Raylight Video to the commit with the pitch, using the workflow's own token. | `true` | | `api-url` | API origin. | `https://api.raylight.app` | | `github-token` | Used on the runner for the check run and the previous release lookup. Never sent to Raylight. | `github.token` | With `post-check` the reviewer sees the pitch in GitHub: the check shows the shots and the price and links to the production's page in Raylight, where anyone on the workspace can greenlight it, send comments or decline. Login-free [greenlight links](https://www.raylight.app/docs/greenlight-links.md) go out in the Pitch ready email and on webhooks. They are not part of the API response the Action reads, so they never appear in the check. The check only needs `checks: write` on the job's permissions; the token stays on the runner. ## Outputs | Output | Value | | --- | --- | | `production-id` | The production's id. Always set. | | `production-url` | The production's page in Raylight. Always set. | | `stage` | The stage value when the step finished. `pitch_ready` (Pitch ready) is the usual answer with `greenlight-mode: manual`. | | `pitch-markdown` | The pitch as markdown, when it was ready within the wait. | | `share-url` | The share link, when `greenlight-mode: auto` and the video wrapped within the wait. Otherwise empty; the link arrives by webhook and email. | ## What leaves the runner Only the brief: - `brief.intent` from the `intent` input. - `brief.context`: the `context` input, or else the PR body, the release notes, or on a push the head commit's message, cut to 60,000 characters. - `brief.media`: the `media` URLs, if any. - `brief.code`: for each changed file matching `code-globs`, its path, contents at the head commit, change status, line counts, diff hunks (capped at 20,000 characters), a digest and a score, all computed on the runner. Files that usually hold secrets (`.env` files, keys, `.npmrc`, cloud credentials), symlinks and submodules are never sent, and anything shaped like a key or token is masked in what is. - `brief.format` from `template`, `duration-seconds` and `aspect`. - `origin`: provider `github`, the repository and release tag or PR number as the label, the HTML URL, the head commit, and the actor's login. - `externalId`: the repository plus the event and the release tag or PR number. Before reading the diff, the Action looks that id up (`GET /v1/productions?externalId=...`, which sends nothing else); a re-run, a reopened pull request or a second label finds the first run's production and sends nothing. A pull request gets one production: runs for new pushes to it are skipped before anything is sent. - `greenlightMode` from `greenlight-mode`. No repository token, no environment, no files outside the globs, no history beyond the diff. The Action is open source at `github.com/raylight-labs/video-action`, and the request body is built in one place you can read. ## On a labelled pull request Add a `video` label to a pull request to get a feature video for it. The production's origin points at the PR, and the check lands on its head commit. .github/workflows/feature-video.yml: ```yaml name: Feature video on: pull_request: types: [labeled] jobs: video: if: github.event.label.name == 'video' runs-on: ubuntu-latest permissions: contents: read checks: write steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: raylight-labs/video-action@v1 with: api-key: ${{ secrets.RAYLIGHT_API_KEY }} intent: "${{ github.event.pull_request.title }}" template: feature code-globs: "src/**/*.tsx" wait-for-pitch: true post-check: true ``` ## After the step The workflow ends once the pitch exists; Raylight makes the video after someone greenlights it. The finished video arrives by email, on any [webhook](https://www.raylight.app/docs/webhooks.md) you registered, and on the Productions page in the app. To post the share link back to the PR, point a webhook at a small relay; the [recipes](https://www.raylight.app/docs/recipes.md) include one. --- # CLI > Log in, build a brief from a diff, run productions and manage webhooks from a terminal. Source: https://www.raylight.app/docs/cli.md The REST API for a terminal, with the brief built on your machine. Run it from a checkout, or in CI when the GitHub Action does not fit. ## Install and log in ```bash npm install -g raylight raylight login ``` That puts `raylight` on your path. To run it without installing, put `npx` in front of every command instead (`npx raylight login`, `npx raylight productions create ...`). `login` runs the [device flow](https://www.raylight.app/docs/authentication.md): open the printed URL, click Approve, and the key is saved locally together with the server it was made on. The key and server travel as a pair: a saved key is only ever sent to its own server, and if `--api-url` or `RAYLIGHT_API_URL` points somewhere else the CLI refuses and tells you to log in there. Servers must be `https`, except `localhost` for local development. `raylight whoami` shows which key and server commands would use and where each comes from, without a network call. `raylight logout` revokes the key on the server, then deletes the local copy (`--keep-key` only deletes it). When running unattended, set `RAYLIGHT_API_KEY` instead; it wins over the saved key. ## Make a video ```bash raylight productions create \ --intent "Announce dark mode in the Acme dashboard to existing customers." \ --code "src/**/*.tsx" raylight productions greenlight 6f1c2b7e-3d4a-4c8e-9a2b-1f0e5d6c7b8a ``` - `productions create` pushes a brief to start a production. `--intent` is the one sentence; `--code` takes globs and reads the matching files from the working tree. `--greenlight-mode auto` greenlights a video verdict as soon as the pitch lands, and `--external-id` makes a retried create return the same production. - `productions create` and `productions comment` wait for the pitch and print it (title, shots, price). Pass `--no-wait` to print the production's id and return right away, for example in CI. - `productions greenlight ` greenlights the pitch. It charges the price the pitch quoted (60 credits per second of video) once; `--pitch-version ` refuses if the pitch changed since you read it. - `productions comment --comment "..."` sends comments and waits for the next version. It takes `--pitch-version` too. - `productions get `, `productions list` (`--external-id` finds the production a create made), `productions decline `, `productions cancel ` and `productions usage` map one to one onto the API. - `productions watch ` follows a production until it ends, printing each stage as the app labels it. ### Exit codes Every command exits nonzero when it did not get what it waited for, so a CI step fails instead of passing quietly. `--json` prints machine readable output either way. | Code | Means | | --- | --- | | 0 | Done. | | 1 | Failed: bad flags, not logged in, refused by the server, or a network error. | | 2 | The production ended without what the command waited for: it failed, was declined, cancelled or skipped, or your comments were refused (for example the monthly cap). `watch` exits 0 only once the production wrapped. | | 3 | Timed out while the production was still going. | | 130 | Stopped with Ctrl-C. | ## Build a brief from a diff `brief build` walks a diff, keeps the UI source files, and computes each file's change status, line counts, patch, a digest of how the UI looks, and a relevance score. It writes a brief you can inspect before anything is sent. ```bash raylight brief build --diff main..HEAD --out brief.json raylight productions create --brief brief.json ``` `brief.json` is everything that leaves your machine. Edit it, drop files, add `media` URLs or a `brand`, then `productions create --brief brief.json`. Ranking rules are on [Productions](https://www.raylight.app/docs/productions.md). ## Webhooks ```bash raylight webhooks create --url https://hooks.acme.dev/raylight ``` Prints the endpoint and its secret once. `webhooks list`, `webhooks test ` and `webhooks deliveries ` cover the rest of the [webhooks surface](https://www.raylight.app/docs/webhooks.md). ## In CI Any CI works: add `RAYLIGHT_API_KEY` to the job's secrets, check out full history so `brief build --diff` has both sides, and run the two commands with `--no-wait` if the step should not hold the runner while the pitch is written. On GitHub the [Action](https://www.raylight.app/docs/github-action.md) wraps the same steps and posts the pitch as a check. --- # Recipes > Six small integrations, each with a prompt to hand your agent. Source: https://www.raylight.app/docs/recipes.md Every recipe has the same three parts: something with context pushes a brief, a webhook or email carries the pitch to whoever decides, and the share link lands where the team already looks. Each one comes with a prompt you can paste into a coding agent to build it in your stack. ## GitHub Action A release publishes, a short release video is pitched, and the pitch shows up as a check that links to the production in Raylight. The Pitch ready email carries the login-free Greenlight, Send comments and Decline links. No app install, only the workflow file. ```yaml name: Launch video on: release: types: [published] jobs: video: runs-on: ubuntu-latest permissions: contents: read checks: write steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: raylight-labs/video-action@v1 with: api-key: ${{ secrets.RAYLIGHT_API_KEY }} intent: "Announce ${{ github.event.release.name }} to existing customers" template: launch duration-seconds: 15 aspect: "16:9" greenlight-mode: manual code-globs: "src/**/*.tsx" wait-for-pitch: true post-check: true ``` Prompt: ```text Read https://www.raylight.app/llms.txt. Add a GitHub workflow to this repo that runs raylight-labs/video-action@v1 on every published release with template launch, 15 seconds, code-globs limited to our UI components, greenlight-mode manual, and post-check on. Tell me which secret to add. ``` ## Slack relay A small HTTP function receives `production.pitch_ready` (a pitch is ready), verifies the signature, and posts a message with three URL buttons through a Slack incoming webhook. On `production.delivered` (the production wrapped) it posts the share link as a new message. Nobody in Slack needs a Raylight login to greenlight. An incoming webhook cannot reply in a thread; to keep the share link under the pitch, post with a Slack app's bot token (`chat.postMessage`), save the first message's `ts` against the production id, and pass it as `thread_ts` later. What it posts: ```json { "text": "Pitch ready: Dark mode, finally", "blocks": [ { "type": "section", "text": { "type": "mrkdwn", "text": "*Dark mode, finally*\n20 s, 16:9, 600 credits\nacme/web #412" } }, { "type": "actions", "elements": [ { "type": "button", "style": "primary", "text": { "type": "plain_text", "text": "Greenlight" }, "url": "" }, { "type": "button", "text": { "type": "plain_text", "text": "Send comments" }, "url": "" }, { "type": "button", "style": "danger", "text": { "type": "plain_text", "text": "Decline" }, "url": "" } ] } ] } ``` Prompt: ```text Read https://www.raylight.app/llms.txt. Build a serverless function that receives Raylight webhooks, verifies Raylight-Signature with the secret in RAYLIGHT_WEBHOOK_SECRET, and on production.pitch_ready posts a Slack Block Kit message to SLACK_WEBHOOK_URL with the pitch title (data.production.pitch.title), duration, price and three url buttons labelled Greenlight, Send comments and Decline, wired to data.links.greenlight, data.links.comment and data.links.decline. On production.delivered post data.links.share as a new message. Answer 204 fast, reject bad signatures with 400, and answer 2xx to correctly signed events you do not handle. Then register the function URL as a Raylight webhook. ``` ## Vercel deployments Vercel sends a `deployment.succeeded` webhook when a deployment finishes (add one under the team's Settings, Webhooks, with that event). The relay checks Vercel's signature, ignores preview deployments, and turns a production deployment into a changelog production keyed by the deployment id, so Vercel's retries never double up. Pair it with the Slack relay to greenlight it. api/vercel-deploy.ts: ```ts import { createHmac, timingSafeEqual } from "node:crypto"; export const POST = async (request: Request) => { const rawBody = await request.text(); const expected = createHmac("sha1", process.env.VERCEL_WEBHOOK_SECRET ?? "") .update(rawBody) .digest("hex"); const given = request.headers.get("x-vercel-signature") ?? ""; if ( given.length !== expected.length || !timingSafeEqual(Buffer.from(given), Buffer.from(expected)) ) { return new Response("bad signature", { status: 401 }); } const event = JSON.parse(rawBody); if ( event.type !== "deployment.succeeded" || event.payload?.target !== "production" ) { return new Response(null, { status: 204 }); } const { deployment, links } = event.payload; const url = `https://${deployment.url}`; const sha = deployment.meta?.githubCommitSha ?? deployment.id; await fetch("https://api.raylight.app/v1/productions", { method: "POST", headers: { Authorization: `Bearer ${process.env.RAYLIGHT_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ externalId: `vercel:${deployment.id}`, brief: { intent: "Show what just shipped to production", context: `Deployed ${url}\n\n${deployment.meta?.githubCommitMessage ?? ""}`, format: { template: "changelog", durationMs: 10000 }, }, origin: { provider: "vercel", label: `${deployment.name} deploy`, url: links?.deployment ?? url, ref: deployment.meta?.githubCommitRef ?? sha, }, }), }); return new Response(null, { status: 204 }); }; ``` Prompt: ```text Read https://www.raylight.app/llms.txt. Write an endpoint that receives Vercel's deployment.succeeded webhook, verifies x-vercel-signature (HMAC-SHA1 of the raw body with VERCEL_WEBHOOK_SECRET), ignores deployments whose target is not production, and creates a Raylight production with template changelog, externalId set to the deployment id, the commit message as context, and origin provider vercel with the project name, the deployment's inspector URL and the branch. Use RAYLIGHT_API_KEY from the environment. ``` ## Zapier No code: a Zap with any trigger (a new sheet row, a published Notion page, a tagged Intercom article) and a Webhooks by Zapier action that POSTs to `/v1/productions` with a `Bearer` header. Set `externalId` to the trigger's record id and `origin.provider` to `zapier`. The pitch comes by email, with links to greenlight it. Prompt: ```text Read https://www.raylight.app/llms.txt. Give me the exact Webhooks by Zapier POST configuration (URL, headers, JSON body with field mappings) to create a Raylight production from a new row in a Google Sheet with columns Title, Notes and Screenshot URL. Use the row id as externalId and zapier as the origin provider. ``` ## Linear When an issue labelled `video` is completed, a Linear webhook pushes a feature production. The Raylight webhook posts the pitch, and later the share link, back as comments on the issue through Linear's API, with the signed links inline. The relay holds the Linear token, not Raylight. Prompt: ```text Read https://www.raylight.app/llms.txt. Build a relay with two routes. One receives Linear webhooks and, when an issue with the label video moves to Done, creates a Raylight production with template feature, the issue title as intent, its description as context, externalId set to the issue id and origin provider linear with the identifier and URL. The other receives Raylight webhooks, verifies the signature, and comments the pitch (data.pitchMarkdown) with its greenlight, comment and decline links on the issue; on production.delivered it comments the share link. ``` ## Claude Code No integration needed. Paste the quickstart prompt; the agent already editing your code gets a key, sends the files it touched, and shows you the pitch in the terminal. Prompt: ```text Read https://www.raylight.app/llms.txt and make a launch video for the feature I am working on: use the device flow to get a key if RAYLIGHT_API_KEY is missing, send the relevant UI files in the brief, wait for the pitch, show it to me, and greenlight it only when I say so. ``` With the [MCP server](https://www.raylight.app/docs/mcp.md) connected, the same agent uses `create_production` and `get_production` instead of raw HTTP and never needs a key. --- # Quickstart > From nothing to a finished video: one prompt for your coding agent, or the same five calls by hand. Source: https://www.raylight.app/docs/quickstart.md ## 1. Hand it to your agent Fastest path: let the agent that already has your code do it. Paste this into Claude Code, Cursor, Codex or any agent that can make HTTP calls. Prompt: ```text Read https://www.raylight.app/llms.txt and make a launch video for the feature I am working on: use the device flow to get a key if RAYLIGHT_API_KEY is missing, send the relevant UI files in the brief, wait for the pitch, show it to me, and greenlight it only when I say so. ``` The agent reads [llms.txt](https://www.raylight.app/llms.txt), gets a key through the device flow (you click approve once in the browser), builds a brief from the files it is working on, waits for the pitch and shows it to you. Nothing is charged until you greenlight it. The steps below are what the agent does. ## 2. Get a key Keys look like `rl_live_...` and go in the `Authorization: Bearer` header. You need the `productions` scope. Two ways to get one: ### In the app In the app sidebar, open Raylight API, then Keys. Name the key, pick its scopes, and copy it once; only a hash is stored. Keys belong to your account and work on every workspace you can edit. ### Device flow (no browser session needed) For tools and agents with no session. Start a request, show the approval URL to a human, and poll until the key arrives. Starting needs no key. Start: ```bash curl -X POST https://api.raylight.app/v1/keys/request \ -H "Content-Type: application/json" \ -d '{ "name": "CI on acme/web" }' ``` 201: ```json { "deviceCode": "1aSs9fWUrhEUu3hOo5TzSJfWDgradXEK_k9BIV67XV8", "userCode": "ABCD2345", "approvalUrl": "https://www.raylight.app/keys/approve?code=ABCD2345", "expiresInSeconds": 600, "pollIntervalSeconds": 3 } ``` Ask your user to open `approvalUrl` and click Approve. Then poll every `pollIntervalSeconds`. Poll: ```bash curl -X POST https://api.raylight.app/v1/keys/poll \ -H "Content-Type: application/json" \ -d '{ "deviceCode": "1aSs9fWUrhEUu3hOo5TzSJfWDgradXEK_k9BIV67XV8" }' ``` Possible answers: ```json { "status": "pending" } { "status": "approved", "apiKey": "rl_live_..." } { "status": "claimed" } { "status": "denied" } { "status": "expired" } ``` `apiKey` is returned once. Save it right away, for example as `RAYLIGHT_API_KEY=rl_live_...` in a gitignored `.env`. Device-flow keys carry every scope. If the claim response gets lost, see [Authentication and keys](https://www.raylight.app/docs/authentication.md). ## 3. Push a brief One call makes a production. Only `intent` is required; the rest makes the pitch better. `externalId` makes retries safe: a repeat create answers 200 with the same production. `origin` tells the Productions page and emails where it came from (display only; Raylight never calls the provider). brief.json: ```json { "brief": { "intent": "Announce dark mode in the Acme dashboard to existing customers.", "context": "PR #412 adds a theme toggle to Settings. Charts, tables and the sidebar all pick up the dark palette. Ships to everyone on Thursday.", "media": [ { "id": "dashboard-dark", "kind": "image", "url": "https://cdn.acme.dev/press/dashboard-dark.png", "name": "Dashboard in dark mode", "description": "The analytics overview with the new dark theme." } ], "code": [ { "path": "src/components/settings/theme-toggle.tsx", "source": "export const ThemeToggle = () => { /* ... */ }" } ], "format": { "template": "feature", "durationMs": 10000, "aspect": "16:9", "sound": { "voiceover": true, "music": true, "sfx": true } }, "brand": { "name": "Acme", "primaryColor": "#6C5CE7", "tone": "confident, plain" } }, "greenlightMode": "manual", "externalId": "acme/web#412", "origin": { "provider": "github", "label": "acme/web #412", "url": "https://github.com/acme/web/pull/412", "ref": "feat/dark-mode", "requestedBy": "jane" } } ``` ```bash curl -X POST https://api.raylight.app/v1/productions \ -H "Authorization: Bearer $RAYLIGHT_API_KEY" \ -H "Content-Type: application/json" \ -d @brief.json ``` 201: ```json { "production": { "id": "6f1c2b7e-3d4a-4c8e-9a2b-1f0e5d6c7b8a", "workspaceId": "0b9d8c7a-6e5f-4d3c-ab1a-09f8e7d6c5b4", "stage": "queued", "source": { "kind": "api", "externalId": "acme/web#412", "origin": { "provider": "github", "label": "acme/web #412", "url": "https://github.com/acme/web/pull/412", "ref": "feat/dark-mode", "requestedBy": "jane" } }, "brief": { "intent": "Announce dark mode in the Acme dashboard to existing customers.", "...": "..." }, "pitch": null, "greenlight": null, "greenlightMode": "manual", "delivery": null, "projectId": null, "creditsCharged": 0, "error": null, "createdAt": "2026-10-17T18:02:40.000Z", "updatedAt": "2026-10-17T18:02:40.000Z", "terminal": false, "pageUrl": "https://www.raylight.app/api/productions/6f1c2b7e-3d4a-4c8e-9a2b-1f0e5d6c7b8a" } } ``` `production.pageUrl` is the production's page in the app. Every brief field is explained on [Productions](https://www.raylight.app/docs/productions.md). ## 4. Read the pitch The stage moves `queued`, `writing_pitch`, `pitch_ready` (Queued, Writing pitch, Pitch ready in the app), usually within a couple of minutes. Poll the production every few seconds, or register a webhook for `production.pitch_ready`. ```bash curl https://api.raylight.app/v1/productions/6f1c2b7e-3d4a-4c8e-9a2b-1f0e5d6c7b8a \ -H "Authorization: Bearer $RAYLIGHT_API_KEY" ``` The response has the `production`, its `events`, the pitch as `pitchMarkdown`, and a `queue` status (null until the production is greenlit). At `pitch_ready`, `production.pitch` holds the pitch, a script and shot list: production.pitch: ```json { "version": 1, "verdict": "video", "verdictReason": null, "title": "Dark mode, finally", "summary": "A 10 second feature spot: the toggle flips, the dashboard follows, and an end card says it ships Thursday.", "shots": [ { "index": 0, "title": "The flip", "durationMs": 3000, "beat": "The theme toggle flips and the whole settings page goes dark.", "visual": "The ThemeToggle component rebuilt as layers at hero scale, centred, a slow push-in. On the flip the background sweeps from white to the brand dark.", "text": "Dark mode", "voiceover": "Dark mode is here.", "mediaIds": [], "codePaths": ["src/components/settings/theme-toggle.tsx"] }, { "index": 1, "title": "The dashboard", "durationMs": 4500, "beat": "The analytics overview in dark, tilted, drifting left.", "visual": "dashboard-dark on a device frame, 12 degree tilt, a lateral drift with a soft depth of field.", "text": "Every chart. Every table.", "voiceover": "Charts, tables and the sidebar all follow.", "mediaIds": ["dashboard-dark"], "codePaths": [] }, { "index": 2, "title": "Sign-off", "durationMs": 2500, "beat": "The Acme logo on the dark background with the ship date.", "visual": "Logo centred on the brand dark, the date types on underneath, a short hold before the cut.", "text": "Thursday, for everyone", "voiceover": "Turn it on in Settings, Thursday.", "mediaIds": [], "codePaths": [] } ], "music": "Low, steady, electronic. No drop.", "estimatedCredits": 600, "createdAt": "2026-10-17T18:04:11.000Z" } ``` `estimatedCredits` is what the greenlight will charge: 60 credits per second of video. A revised pitch is priced again from its own length. A `skip` verdict means Raylight judged the brief not worth a video: the production ends in `skipped`, nothing is charged, and you can rework the brief and create again. ## 5. Greenlight Greenlighting is `POST /v1/productions/{id}/greenlight`. Pass `pitchVersion` so a greenlight can never land on a pitch you did not read. This is the only call that charges credits: the pitch's `estimatedCredits`, once. Greenlight only at `pitch_ready`: a skipped production answers 409. ```bash curl -X POST https://api.raylight.app/v1/productions/6f1c2b7e-3d4a-4c8e-9a2b-1f0e5d6c7b8a/greenlight \ -H "Authorization: Bearer $RAYLIGHT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "pitchVersion": 1 }' ``` Want changes first? `POST /v1/productions/{id}/comments` with a `comment` sends the production back to `writing_pitch`; the next version arrives the same way. 6 pitch versions per production, so 5 rounds of comments. You can also greenlight from the app, MCP, or the signed links in the Pitch ready email; see [Greenlight links](https://www.raylight.app/docs/greenlight-links.md). ## 6. Receive the video After the greenlight, the stage moves `greenlit`, `making`, `exporting`, `delivered` (Greenlit, In production, Exporting, Wrapped). Keep polling, or listen for `production.delivered`: production.delivered (abridged): ```json { "id": "9a8b7c6d-5e4f-4a3b-9c2d-1e0f9a8b7c6d", "type": "production.delivered", "createdAt": "2026-10-17T18:21:05.000Z", "workspaceId": "0b9d8c7a-6e5f-4d3c-ab1a-09f8e7d6c5b4", "data": { "production": { "id": "6f1c2b7e-3d4a-4c8e-9a2b-1f0e5d6c7b8a", "stage": "delivered", "creditsCharged": 600, "delivery": { "shareUrl": "https://www.raylight.app/v/dark-mode-finally-k3x9", "projectId": "4d3c2b1a-0f9e-4d8c-b7a6-5f4e3d2c1b0a", "editUrl": "https://www.raylight.app/editor/4d3c2b1a-0f9e-4d8c-b7a6-5f4e3d2c1b0a", "exportId": "8e7d6c5b-4a39-4281-9c0b-7a6f5e4d3c2b", "deliveredAt": "2026-10-17T18:21:05.000Z" }, "...": "..." }, "pitchMarkdown": "## Dark mode, finally ...", "status": "Wrapped: https://www.raylight.app/v/dark-mode-finally-k3x9", "links": { "page": "https://www.raylight.app/api/productions/6f1c2b7e-3d4a-4c8e-9a2b-1f0e5d6c7b8a", "greenlight": null, "comment": null, "decline": null, "share": "https://www.raylight.app/v/dark-mode-finally-k3x9", "edit": "https://www.raylight.app/editor/4d3c2b1a-0f9e-4d8c-b7a6-5f4e3d2c1b0a" } } } ``` `shareUrl` is the public share page; `editUrl` opens the project in the editor. The project lands in the workspace's Raylight folder, so anyone on the team can keep editing. ## Next - [Webhooks](https://www.raylight.app/docs/webhooks.md): Stop polling. Signed events, retries, and links you can forward anywhere. - [SDK](https://www.raylight.app/docs/sdk.md): The same calls with types, plus waitForPitch and waitForDelivery. - [GitHub Action](https://www.raylight.app/docs/github-action.md): Run the whole loop from a workflow on your own runner. --- # Productions > How a production moves from brief to finished video, and every field you can send. Source: https://www.raylight.app/docs/productions.md A production is one video, from the brief you send to the share link you get back. It is the same thing whether it starts from the REST API, the SDK, the CLI, the GitHub Action, an AI assistant or the Productions page in the app. The pitch always comes before any canvas work, so a misfired call only costs a free pitch. ## Lifecycle | Stage | In the app | Meaning | Charged | | --- | --- | --- | --- | | `queued` | Queued | Created, waiting for the pitch to be written. | No | | `writing_pitch` | Writing pitch | Raylight is writing the script and shot list. | No | | `pitch_ready` | Pitch ready | Waiting for someone to greenlight it, send comments or decline. | No | | `greenlit` | Greenlit | Credits charged, project created, waiting for a render machine. | Yes | | `making` | In production | Raylight is building the video in a project in your workspace. | Yes | | `exporting` | Exporting | The video is rendering. | Yes | | `delivered` | Wrapped | Share link ready and pushed to you. | Yes | | `skipped` | Skipped | Raylight judged the brief not worth a video. Terminal. | No | | `declined` | Declined | Someone declined the pitch. Terminal. | No | | `cancelled` | Cancelled | Cancelled before production started. Terminal. | No, or refunded in full if it was greenlit | | `failed` | Failed | Production failed after two tries. Terminal; `error` says why. | Refunded in full | Show the labels in the second column to people. Code should branch on the stage values, which never change. The SDK exports the same labels as `PRODUCTION_STAGE_LABELS`, and `productionStageLabel(stage)` returns the label, or the raw value for a stage newer than your SDK. `GET /v1/productions/{id}` returns the `production`, its `events` (created, stage changes, each pitch version, comments, the greenlight, delivery) and, while greenlit, in production or exporting, a `queue` object: the `phase` it is in (`preparing` (Setting up), `planning` (Planning the video), `building` (Building scenes), `reviewing` (Checking the cut), `polishing` (Polishing), `exporting` (Exporting)) with `lastProgress` as its label to show, `startedAt` for when work on it started, and `retrying` when an earlier try failed and Raylight is trying again. Every event made by a person carries `actorName`, the name to show, next to the raw `actor`. `production.pageUrl` is the production's page in the app; link people there rather than building the URL yourself. ## The brief Everything the pitch step needs. Only `intent` is required. The quickstart example: ```json { "brief": { "intent": "Announce dark mode in the Acme dashboard to existing customers.", "context": "PR #412 adds a theme toggle to Settings. Charts, tables and the sidebar all pick up the dark palette. Ships to everyone on Thursday.", "media": [ { "id": "dashboard-dark", "kind": "image", "url": "https://cdn.acme.dev/press/dashboard-dark.png", "name": "Dashboard in dark mode", "description": "The analytics overview with the new dark theme." } ], "code": [ { "path": "src/components/settings/theme-toggle.tsx", "source": "export const ThemeToggle = () => { /* ... */ }" } ], "format": { "template": "feature", "durationMs": 10000, "aspect": "16:9", "sound": { "voiceover": true, "music": true, "sfx": true } }, "brand": { "name": "Acme", "primaryColor": "#6C5CE7", "tone": "confident, plain" } }, "greenlightMode": "manual", "externalId": "acme/web#412", "origin": { "provider": "github", "label": "acme/web #412", "url": "https://github.com/acme/web/pull/412", "ref": "feat/dark-mode", "requestedBy": "jane" } } ``` ### intent One sentence, 1 to 2,000 characters: what the video is about and who it is for. "Announce dark mode to existing customers" beats "dark mode video"; the audience changes the pacing and the copy. ### context Up to 60,000 characters: release notes, the PR body, linked issues, a diff summary, feature docs. Paste generously; the pitch only uses what the shots need. Source code goes in `code`, not here. ### media Up to 20 images or videos by URL. Each item has an `id` (what the shot list's `mediaIds` refer to), a `kind` (`image` or `video`), a `url`, a `name` and an optional `description`. Media is fetched into the project's library when the video is made, so the URL must still be reachable from Raylight's servers then: public, or signed with an expiry that outlasts the wait for a greenlight (days, if your team is slow to answer). Screenshots and screen recordings of the real product work best. ### code Up to 40 UI source files, 200 KB each. React with Tailwind in v1. Files are parsed, never run. Raylight rebuilds the component as native frames, text, shapes and icons, so the shot shows your real UI at hero scale instead of a screenshot. Each file has: | Field | Type | Notes | | --- | --- | --- | | `path` | string | Repository path. Required. | | `source` | string | The file contents. Required, up to 200,000 characters. | | `status` | added, modified, removed, renamed | How the change touched the file. Null when handed in by hand. | | `additions`, `deletions` | integer | Line counts from the diff. Default 0. | | `patch` | string | The unified diff hunks, up to 20,000 characters. Never leaves Raylight again. | | `digest` | string | A short description of how the UI looks, up to 1,500 characters. | | `score` | 0 to 1 | How relevant the file is to a product video. | **How ranking works.** Files are sorted by `score`. The top ones are read in full when the pitch is written; the rest are known only by their `digest`. The highest scoring component becomes the hero shot; helpers, hooks and stylesheets score low and do not become shots. The CLI (`raylight brief build`) and the GitHub Action compute `status`, `patch`, `digest` and `score` from the diff on your machine, so you can read the brief before it goes. A plain API call can send only `path` and `source`; Raylight derives digest and score when it writes the pitch. Send the components that render the feature, not the whole tree. ### format | Field | Values | Default | | --- | --- | --- | | `template` | `changelog`, `launch`, `feature`, `digest`, `custom` | `changelog` | | `durationMs` | 5,000 to 15,000 | 10,000 | | `aspect` | `16:9`, `4:3`, `3:2`, `21:9`, `1:1`, `9:16`, `3:4`, `2:3`, `4:5` | `16:9` | | `sound.voiceover`, `sound.music`, `sound.sfx` | boolean | all `true` | The defaults are the every-release preset: a 10 second widescreen changelog with full sound. Productions are short on purpose: a feature recap or changelog explainer, delivered as a project you can open in Raylight and grow into a full launch video. `launch` is for a named release, `feature` for one capability, `digest` for a week of small changes, `custom` when the intent says it all. ### brand `name`, `logoUrl`, `primaryColor`, `backgroundColor`, `fontFamily` and `tone` (a short voice note like "dry, technical"). All optional and nullable. Missing values come from the workspace (logo, fonts) and from the code you sent (colors), and all are editable on the production before it is greenlit. ## Pitches The script and shot list for sign-off, in the production's `pitch` field. Each round of comments makes a new version; earlier versions stay in the production's events. ```json { "version": 1, "verdict": "video", "verdictReason": null, "title": "Dark mode, finally", "summary": "A 10 second feature spot: the toggle flips, the dashboard follows, and an end card says it ships Thursday.", "shots": [ { "index": 0, "title": "The flip", "durationMs": 3000, "beat": "The theme toggle flips and the whole settings page goes dark.", "visual": "The ThemeToggle component rebuilt as layers at hero scale, centred, a slow push-in. On the flip the background sweeps from white to the brand dark.", "text": "Dark mode", "voiceover": "Dark mode is here.", "mediaIds": [], "codePaths": ["src/components/settings/theme-toggle.tsx"] }, { "index": 1, "title": "The dashboard", "durationMs": 4500, "beat": "The analytics overview in dark, tilted, drifting left.", "visual": "dashboard-dark on a device frame, 12 degree tilt, a lateral drift with a soft depth of field.", "text": "Every chart. Every table.", "voiceover": "Charts, tables and the sidebar all follow.", "mediaIds": ["dashboard-dark"], "codePaths": [] }, { "index": 2, "title": "Sign-off", "durationMs": 2500, "beat": "The Acme logo on the dark background with the ship date.", "visual": "Logo centred on the brand dark, the date types on underneath, a short hold before the cut.", "text": "Thursday, for everyone", "voiceover": "Turn it on in Settings, Thursday.", "mediaIds": [], "codePaths": [] } ], "music": "Low, steady, electronic. No drop.", "estimatedCredits": 600, "createdAt": "2026-10-17T18:04:11.000Z" } ``` - `verdict` is `video` or `skip`. A skip ends the production in `skipped` with `verdictReason` set, charges nothing, and fires `production.skipped`. Rework the brief and create again. - Each shot has a `beat` (the one line a reader skims), a `visual` (the full direction for building it), on-screen `text`, a `voiceover` line, and the `mediaIds` and `codePaths` it shows. 1 to 24 shots. - `estimatedCredits` is the price quoted with the pitch, and what the greenlight charges. - `pitchMarkdown` (on `GET /v1/productions/{id}`, in webhook payloads, on greenlight links and in MCP results) is the same pitch formatted for reading. ### Sending comments Send comments, get the next version, free. Be specific: shots to add or drop, copy to rewrite, pacing, tone. Pass `pitchVersion` so comments meant for a pitch that changed meanwhile are refused (409). A production gets 6 pitch versions in total, so 5 rounds of comments after the first pitch; after that it must be greenlit or declined, and another round answers 429 with `code: "cap"`. Each version counts as one pitch against the monthly cap. ```bash curl -X POST https://api.raylight.app/v1/productions/6f1c2b7e-3d4a-4c8e-9a2b-1f0e5d6c7b8a/comments \ -H "Authorization: Bearer $RAYLIGHT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "comment": "Open on the toggle, not the logo. Drop the pricing shot.", "pitchVersion": 1 }' ``` ## Greenlight | `greenlightMode` | Behaviour | | --- | --- | | `manual` (default) | The production waits at Pitch ready until someone greenlights it, over REST, MCP, the app or a signed link. | | `auto` | Auto-greenlight: a `video` verdict is greenlit as soon as the pitch lands, and credits are charged. A `skip` still skips. If the automatic greenlight is refused (not enough credits), the production stays at Pitch ready with a note and the normal `production.pitch_ready` event fires. | Pass `pitchVersion` when you greenlight to refuse it if the pitch changed since you read it (409 when stale). The production's `greenlight` records the mode, the `actor` (a user id, `link:` for an email link, `webhook:` for a link from a webhook payload, or `auto`), an `actorName` to show people, the time and the pitch version greenlit. Cancel before production for a full refund; a production already being made cannot be cancelled. ## Origin Where the production came from, in your words. Display only: the Productions page shows it and the emails quote it; Raylight never calls the provider. `provider` is a lowercase slug (`github`, `gitlab`, `vercel`, `linear`, `zapier`, `claude-code`), `label` is the line to show ("acme/web #412", "v2.3.0"), `url` makes it a link, `ref` is the branch, tag or commit, and `requestedBy` is the handle to credit. ## Idempotency Pass `externalId` (up to 200 characters) on create. A repeat create with the same id from the same key owner returns the existing production with 200 instead of 201, so a retried CI step or replayed webhook never makes two pitches, and you can tell a replay from a new production by the status. Use something stable per unit of work: the PR number, the release tag, the deploy id. ## Errors Every non-2xx body is `{ "error": "...", "code": "..." }`; validation failures add `issues`. | Status | Code | When | | --- | --- | --- | | 400 | | Invalid body or query; `issues` lists the fields. | | 401 | | Missing, invalid or revoked key. | | 402 | `credits` | The workspace owner's credits do not cover the video: on create or comments, the length asked for; on greenlight, the pitch's `estimatedCredits`. Nothing changed; add credits (or ask for a shorter video) and try again. | | 403 | `api_key_missing_scope` | The key lacks the `productions` scope. | | 403 | `forbidden` | View-only access to this workspace: the key's owner can read it but not change it. | | 404 | `not_found` | No such production in a workspace you belong to. | | 409 | `stage` | The production's stage does not allow the action, or `pitchVersion` is stale. | | 429 | `cap`, `rate_limited` | A monthly cap is reached, the pitch is out of rounds of comments, or more than 120 requests a minute on this key. Rate limits set `Retry-After`. | | 503 | `disabled` | Productions are switched off on this server. | ## Caps Every plan can make productions, Free included, with no monthly limit on videos. Pitches are free but capped per calendar month, so a noisy trigger cannot pile up pitches nobody reads; the plan sets that cap. Each video costs 60 credits per second of video (300 to 900 credits); the pitch quotes the exact number as `estimatedCredits`. A pitch is only written when the workspace owner's credits cover the video it asks for, so a workspace with no credits gets a 402 instead of a pitch nobody can greenlight. `GET /v1/productions/usage` returns this month's counts and the pitch cap. Numbers on [Limits and pricing](https://www.raylight.app/docs/limits.md). --- # Webhooks > Signed production events pushed to your URL, with retries, secret rolling and redelivery. Source: https://www.raylight.app/docs/webhooks.md Webhooks push production events to you: a pitch is ready, the production was greenlit, the video wrapped, and the other moments listed below. Each event is sent to every endpoint subscribed to it, signed with that endpoint's secret, retried with backoff, and logged per attempt, so you can see what Raylight sent and what your server answered. The quiet stages in between (Queued, Writing pitch) send nothing; the opt-in `production.progress` event covers the steps while the video is made. ## Register an endpoint - **In the app:** Raylight API, then Webhooks. Paste the URL, pick events, copy the secret. - **REST:** `POST /v1/webhooks` with `url`, an optional `description` and an optional `events` list. - **MCP:** ask your agent; the `create_webhook` tool makes the same call and shows you the secret. - **CLI:** `raylight webhooks create --url https://hooks.acme.dev/raylight`. ```bash curl -X POST https://api.raylight.app/v1/webhooks \ -H "Authorization: Bearer $RAYLIGHT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://hooks.acme.dev/raylight", "description": "Slack relay", "events": ["production.pitch_ready", "production.delivered", "production.failed"] }' ``` 201: ```json { "endpoint": { "id": "2e7a9c41-8b3d-4f6e-a5c2-7d1b0e9f8a63", "workspaceId": "0b9d8c7a-6e5f-4d3c-ab1a-09f8e7d6c5b4", "url": "https://hooks.acme.dev/raylight", "description": "Slack relay", "events": ["production.pitch_ready", "production.delivered", "production.failed"], "enabled": true, "disabledReason": null, "consecutiveFailures": 0, "lastAttemptAt": null, "lastSuccessAt": null, "previousSecretExpiresAt": null, "createdAt": "2026-10-17T17:55:02.000Z", "updatedAt": "2026-10-17T17:55:02.000Z", "secret": "whsec_EXAMPLE..." } } ``` The `secret` (`whsec_...`) is returned once, on create and on roll. URLs must be `https` and resolve to a public address; private and loopback ranges are refused. Ten endpoints per workspace. Errors on `/v1/webhooks` carry a `code` like the rest of the API: 404 `not_found` for an endpoint or workspace you cannot see, 403 `forbidden` with view-only access, and 503 `disabled` on every route when webhooks are switched off on the server. ## Event types | Type | Fires when | Default | | --- | --- | --- | | `production.pitch_ready` | A pitch version landed and is waiting for an answer (Pitch ready). Carries signed Greenlight, Send comments and Decline links. | Yes | | `production.skipped` | Raylight judged the brief not worth a video. Nothing charged. | Yes | | `production.greenlit` | Someone greenlit the pitch. Credits charged, project queued. | Yes | | `production.progress` | While the video is made: when work starts, each time it moves to a new phase (Planning the video, Building scenes, Checking the cut and so on), and when a failed try is retried. Chatty. | No | | `production.delivered` | The production wrapped: the video is ready. Carries the share link and the edit link. | Yes | | `production.failed` | Production failed after its retries. The charge is refunded. | Yes | | `production.declined` | Someone declined the pitch. | Yes | | `production.cancelled` | The production was cancelled. | Yes | | `ping` | You sent a test event. Never sent by the pipeline; cannot be subscribed or unsubscribed. | Always | An endpoint created without `events` gets every default. Add `production.progress` only if you show a live status somewhere. ## The payload Every production event has the same shape: `id` (stable across retries, also in the `Raylight-Event-Id` header), `type`, `createdAt`, `workspaceId`, and `data` with the full production record (`data.production`), the current pitch as markdown (`pitchMarkdown`), a one-sentence `status` when the stage has one, and `links`. Code files arrive with `source` empty, `patch` null and `sourceOmitted: true`; paths, digests and scores stay. A redelivered event also carries `redeliveryOf`. production.pitch_ready: ```json { "id": "c1d2e3f4-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "type": "production.pitch_ready", "createdAt": "2026-10-17T18:04:12.000Z", "workspaceId": "0b9d8c7a-6e5f-4d3c-ab1a-09f8e7d6c5b4", "data": { "production": { "id": "6f1c2b7e-3d4a-4c8e-9a2b-1f0e5d6c7b8a", "workspaceId": "0b9d8c7a-6e5f-4d3c-ab1a-09f8e7d6c5b4", "stage": "pitch_ready", "source": { "kind": "api", "externalId": "acme/web#412", "origin": { "provider": "github", "label": "acme/web #412", "url": "https://github.com/acme/web/pull/412", "ref": "feat/dark-mode", "requestedBy": "jane" } }, "brief": { "intent": "Announce dark mode in the Acme dashboard to existing customers.", "context": "PR #412 adds a theme toggle to Settings. Charts, tables and the sidebar all pick up the dark palette. Ships to everyone on Thursday.", "media": [ { "id": "dashboard-dark", "kind": "image", "url": "https://cdn.acme.dev/press/dashboard-dark.png", "name": "Dashboard in dark mode", "description": "The analytics overview with the new dark theme." } ], "code": [ { "path": "src/components/settings/theme-toggle.tsx", "source": "", "status": "modified", "additions": 48, "deletions": 9, "patch": null, "sourceOmitted": true, "digest": "ThemeToggle: a pill switch with sun and moon icons, label \"Appearance\", brand purple when on.", "score": 0.91 } ], "format": { "template": "feature", "durationMs": 10000, "aspect": "16:9", "sound": { "voiceover": true, "music": true, "sfx": true } }, "brand": { "name": "Acme", "logoUrl": null, "primaryColor": "#6C5CE7", "backgroundColor": null, "fontFamily": null, "tone": "confident, plain" } }, "pitch": { "version": 1, "verdict": "video", "verdictReason": null, "title": "Dark mode, finally", "summary": "A 10 second feature spot: the toggle flips, the dashboard follows, and an end card says it ships Thursday.", "shots": [ { "index": 0, "title": "The flip", "durationMs": 3000, "beat": "The theme toggle flips and the whole settings page goes dark.", "visual": "The ThemeToggle component rebuilt as layers at hero scale, centred, a slow push-in. On the flip the background sweeps from white to the brand dark.", "text": "Dark mode", "voiceover": "Dark mode is here.", "mediaIds": [], "codePaths": ["src/components/settings/theme-toggle.tsx"] }, { "index": 1, "title": "The dashboard", "durationMs": 4500, "beat": "The analytics overview in dark, tilted, drifting left.", "visual": "dashboard-dark on a device frame, 12 degree tilt, a lateral drift with a soft depth of field.", "text": "Every chart. Every table.", "voiceover": "Charts, tables and the sidebar all follow.", "mediaIds": ["dashboard-dark"], "codePaths": [] }, { "index": 2, "title": "Sign-off", "durationMs": 2500, "beat": "The Acme logo on the dark background with the ship date.", "visual": "Logo centred on the brand dark, the date types on underneath, a short hold before the cut.", "text": "Thursday, for everyone", "voiceover": "Turn it on in Settings, Thursday.", "mediaIds": [], "codePaths": [] } ], "music": "Low, steady, electronic. No drop.", "estimatedCredits": 600, "createdAt": "2026-10-17T18:04:11.000Z" }, "greenlight": null, "greenlightMode": "manual", "delivery": null, "projectId": null, "creditsCharged": 0, "error": null, "createdAt": "2026-10-17T18:02:40.000Z", "updatedAt": "2026-10-17T18:04:11.000Z", "terminal": false, "pageUrl": "https://www.raylight.app/api/productions/6f1c2b7e-3d4a-4c8e-9a2b-1f0e5d6c7b8a" }, "pitchMarkdown": "## Dark mode, finally\n\nA 10 second feature spot ...", "status": null, "links": { "page": "https://www.raylight.app/api/productions/6f1c2b7e-3d4a-4c8e-9a2b-1f0e5d6c7b8a", "greenlight": "https://www.raylight.app/go/v1.eyJqIjoiNmYxYzJiN2UiLCJ2IjoxfQ.q8Zt3VbY1mK0?action=greenlight", "comment": "https://www.raylight.app/go/v1.eyJqIjoiNmYxYzJiN2UiLCJ2IjoxfQ.q8Zt3VbY1mK0?action=comment", "decline": "https://www.raylight.app/go/v1.eyJqIjoiNmYxYzJiN2UiLCJ2IjoxfQ.q8Zt3VbY1mK0?action=decline", "share": null, "edit": null } } } ``` `data.links.page` is always there (the production's page in Raylight). `greenlight`, `comment` and `decline` appear only while the pitch is waiting for an answer. They are signed, login-free, bound to this payload's pitch version, and minted for this endpoint (anything done with them is recorded as `webhook:`). `share` and `edit` fill in when the production wraps. ## Headers and signature ```http Raylight-Signature: t=1792277052,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd Raylight-Event-Id: c1d2e3f4-5a6b-4c7d-8e9f-0a1b2c3d4e5f Raylight-Event-Type: production.pitch_ready Content-Type: application/json User-Agent: Raylight-Webhooks/1.0 (+https://www.raylight.app/docs) ``` `Raylight-Signature` is `t=,v1=.">` under the endpoint's secret. For 24 hours after a secret roll it carries two `v1` values, one per live secret; accept the delivery if any matches. Reject timestamps more than five minutes off your clock to stop replays. Sign the raw body bytes as received, never a re-serialised object. ### Verify with the SDK `constructWebhookEvent` checks the signature and timestamp, then returns the typed event or throws. A bad signature is a 400; a correctly signed event the SDK cannot read is still answered 2xx, so the endpoint is never switched off over an SDK that is behind. Details on [SDK](https://www.raylight.app/docs/sdk.md). route.ts: ```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 }); }; ``` ### Verify by hand (Node) ```ts import { createHmac, timingSafeEqual } from "node:crypto"; const TOLERANCE_SECONDS = 5 * 60; export const verifyRaylightSignature = ( rawBody: string, header: string, secret: string ): boolean => { const parts = header.split(",").map((part) => part.trim()); const timestamp = Number(parts.find((part) => part.startsWith("t="))?.slice(2)); const signatures = parts .filter((part) => part.startsWith("v1=")) .map((part) => part.slice(3)); if (!Number.isFinite(timestamp) || signatures.length === 0) { return false; } if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) { return false; } const expected = createHmac("sha256", secret) .update(`${timestamp}.${rawBody}`) .digest("hex"); return signatures.some( (candidate) => candidate.length === expected.length && timingSafeEqual(Buffer.from(candidate), Buffer.from(expected)) ); }; ``` ### Verify by hand (Python) ```python import hashlib import hmac import time TOLERANCE_SECONDS = 5 * 60 def verify_raylight_signature(raw_body: bytes, header: str, secret: str) -> bool: parts = [part.strip() for part in header.split(",")] timestamps = [part[2:] for part in parts if part.startswith("t=")] signatures = [part[3:] for part in parts if part.startswith("v1=")] if not timestamps or not signatures: return False timestamp = int(timestamps[0]) if abs(time.time() - timestamp) > TOLERANCE_SECONDS: return False signed = f"{timestamp}.".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return any(hmac.compare_digest(candidate, expected) for candidate in signatures) ``` ## Retries and auto-disable - Any 2xx within 10 seconds is a success; anything else is a failure. Answer fast and do the work after. Answer 2xx to every correctly signed delivery, including event types you do not handle yet; only a bad signature deserves a 4xx. - Failures retry after 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours: six attempts over about seven hours, then the delivery is `exhausted`. - Retries reuse the event id. Track handled ids if a duplicate is expensive. - After 20 exhausted deliveries in a row the endpoint is switched off, `disabledReason` is set, and the workspace owner gets an email. Any success resets the count. Turn it back on with `PATCH /v1/webhooks/{id}` and `{ "enabled": true }` for a clean run. ## Rolling the secret `POST /v1/webhooks/{id}/roll-secret` mints a new secret and returns it once. The old secret keeps verifying for 24 hours (`previousSecretExpiresAt` on the endpoint), and deliveries in that window carry two `v1` signatures, so you can deploy the new secret without dropping anything. If another roll of the same endpoint lands at the same moment, yours can answer 409 with code `conflict`: nothing changed from your call, so roll again. ## Testing `POST /v1/webhooks/{id}/test` queues a signed `ping`. Use it to check your receiver and verification before a real production fires. ping: ```json { "id": "e5f6a7b8-c9d0-4e1f-a2b3-c4d5e6f7a8b9", "type": "ping", "createdAt": "2026-10-17T17:58:30.000Z", "workspaceId": "0b9d8c7a-6e5f-4d3c-ab1a-09f8e7d6c5b4", "data": { "endpointId": "2e7a9c41-8b3d-4f6e-a5c2-7d1b0e9f8a63", "message": "Raylight can reach this endpoint. Real events have a production.* type and carry the production in data.production." } } ``` ## Delivery log and redelivery `GET /v1/webhooks/{id}/deliveries` lists the newest deliveries with status (`pending`, `succeeded`, `exhausted`), attempt count, next attempt time, and your server's last HTTP status and first 2 KB of its response body, or the transport error. `POST /v1/webhooks/{id}/deliveries/{deliveryId}/redeliver` sends a fresh copy as a new delivery: the payload gets a new `id` plus a `redeliveryOf` field naming the original, whose attempt history stays intact. Deliveries are kept for 30 days, and the whole log is deleted with the endpoint. ## Forward the links anywhere The signed links are the whole credential, so your receiver never calls Raylight back. A Slack message built from `production.pitch_ready` with three URL buttons is a complete greenlight flow: whoever clicks Greenlight lands on a public page, sees the pitch and price, and confirms. Same for a CI job summary, a Linear comment or your own email. Slack Block Kit: ```json { "text": "Pitch ready: Dark mode, finally", "blocks": [ { "type": "section", "text": { "type": "mrkdwn", "text": "*Dark mode, finally*\n20 s, 16:9, 600 credits\nacme/web #412" } }, { "type": "actions", "elements": [ { "type": "button", "style": "primary", "text": { "type": "plain_text", "text": "Greenlight" }, "url": "" }, { "type": "button", "text": { "type": "plain_text", "text": "Send comments" }, "url": "" }, { "type": "button", "style": "danger", "text": { "type": "plain_text", "text": "Decline" }, "url": "" } ] } ] } ``` What the link page does and refuses: [Greenlight links](https://www.raylight.app/docs/greenlight-links.md). Ready-made relays: [Recipes](https://www.raylight.app/docs/recipes.md). --- # Authentication and keys > API keys, scopes, the device flow, rate limits, and OAuth for MCP. Source: https://www.raylight.app/docs/authentication.md The REST API uses API keys. A key is a bearer token that acts as the account that made it, limited to its scopes and to `/v1`. The MCP server uses OAuth instead, so your AI client never holds a long-lived secret. ## API keys Keys start with `rl_live_` and go in the header: `Authorization: Bearer rl_live_...`. Only a hash is stored, so you see a key once. A key works on every workspace its owner can edit; pass `workspaceId` to target one other than the personal workspace. Outside `/v1` keys get a 403 with code `api_key_scope`, so a leaked key cannot read the rest of the account. A call the key's scopes do not cover gets a 403 with code `api_key_missing_scope`. ### Scopes | Scope | Unlocks | | --- | --- | | `productions` | `/v1/productions` and `/v1/webhooks`: productions and their webhooks. | | `renders` | `/v1/renders`: the older one-call Render API (images in, video out). | | `capture` | `/v1/capture`: uploads from Capture for Mac. | | `import` | `/v1/import`: After Effects imports from the panel. | App keys carry the scopes you tick. Device-flow keys carry all four, since the owner approves them for their own account and a second pairing for one more scope would add nothing. ## The device flow For CLIs, coding agents and anything else without a browser session. Starting needs no key; a human approves once in a browser. 1. **Request.** `POST /v1/keys/request` with a `name` that says who is asking (tool plus machine or project). The approver sees it. 2. **Show the approval URL.** The 201 has `approvalUrl` (`https://www.raylight.app/keys/approve?code=XXXXXXXX`) and `userCode`. Have your user open it and click Approve; they can sign in or create an account from that page. The request expires after 10 minutes (`expiresInSeconds`). 3. **Poll.** `POST /v1/keys/poll` with the `deviceCode` every `pollIntervalSeconds` (3). `pending` until approved. 4. **Claim the key.** The first poll after approval answers `approved` with `apiKey`, once, and the server wipes the plaintext. Later polls answer `claimed`. Save the key before doing anything else. 5. **Terminal states.** `denied`: the user refused. `expired`: the 10 minutes passed, or the key was approved but not claimed within 10 minutes (it is revoked and the plaintext cleared). Start a new request. Start: ```bash curl -X POST https://api.raylight.app/v1/keys/request \ -H "Content-Type: application/json" \ -d '{ "name": "CI on acme/web" }' ``` 201: ```json { "deviceCode": "1aSs9fWUrhEUu3hOo5TzSJfWDgradXEK_k9BIV67XV8", "userCode": "ABCD2345", "approvalUrl": "https://www.raylight.app/keys/approve?code=ABCD2345", "expiresInSeconds": 600, "pollIntervalSeconds": 3 } ``` Poll: ```bash curl -X POST https://api.raylight.app/v1/keys/poll \ -H "Content-Type: application/json" \ -d '{ "deviceCode": "1aSs9fWUrhEUu3hOo5TzSJfWDgradXEK_k9BIV67XV8" }' ``` Answers: ```json { "status": "pending" } { "status": "approved", "apiKey": "rl_live_..." } { "status": "claimed" } { "status": "denied" } { "status": "expired" } ``` > **Lost the claim response?** If the poll carrying the key timed out on your side, the key exists but you never saw it. Poll again with `{ "deviceCode": "...", "revokeClaimed": true }`: that key is revoked, the poll answers `expired`, and you start a fresh request. The flag only works for about 15 minutes after the claim window, so an old device code from a log cannot revoke anything later. `/v1/keys` allows 60 requests a minute per IP, plenty for one poll every three seconds. ## Key management in the app Raylight API, then Keys. Create a key with a name and scopes, see each key's prefix, scopes, creation date and last use, and revoke any key. Revocation is immediate and keeps the key's history. A key cannot be shown again; make a new one. ### Revoke the key you are using `DELETE /v1/keys/current` with the key in the `Authorization` header revokes that key and answers 204. It needs no scope (30 calls a minute per key), so any key can retire itself: a tool that is done with a machine, or that thinks its key leaked, can sign out without a person opening the app. Only an API key can revoke itself; a browser session gets 400 `not_api_key`. `raylight logout` makes this call before deleting the saved key, and the SDK has it as `raylight.keys.revokeCurrent()`. ## Rate limits Fixed one-minute windows. Over the limit you get 429 with `{ "code": "rate_limited" }` and a `Retry-After` header in seconds. | Surface | Limit | Counted per | | --- | --- | --- | | `/v1/productions` | 120 a minute | key | | `/v1/webhooks` | 60 a minute | key | | `/v1/renders` | 30 a minute | key | | `/v1/keys` | 60 a minute | IP | | `/production-links` | 30 a minute | IP | The monthly pitch cap (per workspace) is separate and return 429 with `code: "cap"`. See [Limits and pricing](https://www.raylight.app/docs/limits.md). ## MCP uses OAuth, not keys The MCP server at `https://api.raylight.app/mcp` uses OAuth 2.0 with PKCE. Your AI client opens a Raylight page that shows the app asking, the scopes, and where you will be sent back. You approve, and the client gets a token for your account that refreshes on its own. API keys do not work there, and MCP tokens do not work on `/v1`. Setup for every client is on [/mcp](https://www.raylight.app/mcp). --- # SDK > @raylight/sdk for TypeScript: typed calls, wait helpers and webhook verification. Source: https://www.raylight.app/docs/sdk.md `@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); } ``` | 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. ```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: | 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 }`. route.ts: ```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 }); }; ``` ## 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. ```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](https://www.raylight.app/docs/authentication.md) 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](https://api.raylight.app/v1/openapi.json), or write the calls you need from the [API reference](https://www.raylight.app/docs/reference.md). The Python signature check is on [Webhooks](https://www.raylight.app/docs/webhooks.md). --- # Limits and pricing > What a video costs, the monthly pitch cap, and every rate and size limit. Source: https://www.raylight.app/docs/limits.md Every plan can make productions, Free included. Pitches are free. A video costs 60 credits per second of video, charged once when it is greenlit. There is no monthly limit on videos. The plan only sets how many pitches a workspace can ask for a month, which stops a noisy trigger from piling up pitches nobody reads. ## Price and pitch caps | Plan | Pitches a month | | --- | --- | | Free | 10 | | Hobby | 20 | | Pro | 60 | | Max | 200 | | Team | 200 | - Pitch caps are per workspace per calendar month. A pitch counts when it is written. - Each video costs 60 credits per second of video, so a 5 second video is 300 credits and a 15 second one 900 credits (1 credit is 1 cent at retail). The pitch quotes the exact number as `estimatedCredits` before anyone pays, and a revised pitch is priced again. Paid plans come with monthly credits that pay for videos too, and anyone can buy credit packs; see [pricing](https://www.raylight.app/pricing). - 6 pitch versions per production, so 5 rounds of comments after the first pitch. Comments cost no credits, but each new version counts as one pitch against the monthly cap; once the cap is reached the current pitch stands. - Skipped and declined pitches cost nothing. A production cancelled after the greenlight but before a render machine picks it up is refunded in full, and so is one that fails in production. If no render machine can start on a greenlit video within 30 minutes, the production fails and the credits are refunded. - `GET /v1/productions/usage` returns `productionsThisMonth`, `pitchesThisMonth`, `pitchesCap` and `creditsCharged` for the month. ## Rate limits | Surface | Limit | Counted per | | --- | --- | --- | | `/v1/productions` | 120 requests a minute | key | | `/v1/webhooks` | 60 requests a minute | key | | `/v1/renders` | 30 requests a minute | key | | `/v1/keys` | 60 requests a minute | IP | | `/production-links` | 30 requests a minute | IP | Over a rate limit: 429 with `code: "rate_limited"` and `Retry-After` in seconds. Over the monthly pitch cap: 429 with `code: "cap"`. ## Brief limits | Field | Limit | | --- | --- | | `intent` | 2,000 characters | | `context` | 60,000 characters | | `media` | 20 items | | `code` | 40 files, 200,000 characters of source each | | `code[].patch` | 20,000 characters | | `code[].digest` | 1,500 characters | | `format.durationMs` | 5,000 to 15,000 | | Pitch shots | 1 to 24 | | Comments on a pitch | 4,000 characters | | `externalId` | 200 characters | ## Webhook limits | What | Limit | | --- | --- | | Endpoints per workspace | 10 | | Attempt timeout | 10 seconds | | Attempts per delivery | 6 (immediate, then 30s, 2m, 10m, 1h, 6h) | | Auto-disable | after 20 exhausted deliveries in a row; any success resets | | Previous secret after a roll | valid 24 hours | | Response body kept per delivery | first 2 KB | | Delivery log retention | 30 days, or until the endpoint is deleted | | URL | https only, public address, 2,000 characters | ## Keys and links | What | Limit | | --- | --- | | Device-code request | 10 minutes to approve, then 10 minutes to claim | | Poll interval | 3 seconds | | Greenlight link lifetime | 30 days, or until the pitch changes | | Comment through a link | 4,000 characters | ## Time to deliver A pitch usually lands within a couple of minutes. Making the video takes longer and depends on how busy Raylight is. While a production is greenlit, in production or exporting, its `queue` object says what it is doing (`phase`, with `lastProgress` as the label to show, null while it waits to start), when work on it started, and whether an earlier try failed and Raylight is trying again (`retrying`), so your integration can show where it stands. If no render machine can start on a greenlit video within 30 minutes, the production fails and the credits are refunded. --- # Security > What leaves your environment, what Raylight stores, and how secrets and links are protected. Source: https://www.raylight.app/docs/security.md ## Push only Raylight never pulls from your systems. There is no GitHub App, no OAuth grant to your source host, deploy platform or chat tool, and nothing in Raylight that can call them. Every production is pushed in by something you run, and every update is pushed back out through webhooks, email and signed links. On your side, the attack surface is one outbound HTTPS call with a key you can revoke. ## What leaves your environment | Integration | What is sent | | --- | --- | | REST, SDK, CLI | The brief you build: `intent`, `context`, media URLs, the code files you chose (path, source, and, if you computed them, status, line counts, patch, digest, score), format, brand, `externalId`, `origin`. Nothing from your machine that you did not put in the body. | | GitHub Action | The brief it builds on your runner: the intent input, the release notes or PR body as context (on a push, the head commit's message), changed files matching `code-globs` with their diff hunks, format inputs, an origin naming the repo and actor, and an externalId. No repository token, no environment variables, no files outside the globs. Open source at `github.com/raylight-labs/video-action`. | | MCP | Whatever the agent passes to `create_production`: intent, any context and files it chose from your workspace, media URLs. The agent runs on your side; the server only sees tool arguments. Editor tools move project content between your open tab and the client. | | Zapier and other no-code | The JSON body you map in the action step. | ## What Raylight stores - The brief as sent, including code sources and patches, so the pitch can be rewritten when you send comments and the video built. It lives on the production in your workspace and is deleted with it. - The production's events: stage changes, every pitch version, comments, who greenlit it and when, the delivery links. - Webhook endpoints, their secrets (encrypted, see below) and the delivery log: event, attempts, your server's last status and the first 2 KB of its response body. - API keys as hashes with a display prefix; plaintext is never stored once claimed. - Usage counters per workspace and month for the pitch cap. ## Code never travels back out Webhook payloads, emails and greenlight links carry the production with every code file's `source` set to an empty string, `patch` set to null and `sourceOmitted` set to true. Paths, change status, line counts, digests and scores stay, so a receiver sees which files were considered but never your source. A signed link shows even less: no context, media URLs or code. Only the authenticated API and the app, which act as you, can read the full brief. ## Signing Every delivery carries `Raylight-Signature: t=,v1=.">`. Verify against the raw bytes, compare in constant time, and reject timestamps more than five minutes off. For 24 hours after a secret roll, two `v1` values are present. `Raylight-Event-Id` is stable across retries, so you can de-duplicate. Node and Python code is on [Webhooks](https://www.raylight.app/docs/webhooks.md). ## Secret storage - Webhook secrets are 32 random bytes, prefixed `whsec_`, shown once. At rest they are encrypted with AES-256-GCM under a server key kept outside the database, so a database leak alone does not expose a usable secret. - API keys are stored as hashes. Device-flow plaintext is held only between approval and claim (10 minutes at most) and wiped on claim or expiry. - Greenlight link tokens are stateless HMACs under a dedicated server secret; rotating it invalidates every link ever sent. ## Signed link scoping A link is bound to one production and one pitch version, carries the actor it was minted for (`link:` or `webhook:`), and expires after 30 days. It stops working early if that person leaves the workspace or that endpoint is deleted. It can read and answer that pitch, nothing more: no other productions, no context, media or code, nothing else in the workspace. The page behind it acts only on an explicit POST, never on load, so link prefetchers and mail scanners cannot greenlight anything. ## Transport and keys - HTTPS everywhere. Webhook URLs must be https and public; private and loopback addresses are refused at registration and at delivery. - API keys only work on `/v1`; a key cannot read account data, projects or billing. - Keys are scoped (`productions`, `renders`, `capture`, `import`) and can be revoked instantly in the app. - MCP uses OAuth 2.0 with PKCE; the token is bound to the client that requested it and the account that approved it. ## Reporting an issue Email [cyrus@raylight.app](mailto:cyrus@raylight.app) with what you found, how to reproduce it, and whether you think anyone else's data was reachable. We read reports the same day and will tell you what we changed. --- # API reference > Every endpoint, generated from the OpenAPI document, with a curl example for each. Source: https://www.raylight.app/docs/reference.md Generated from the OpenAPI 3.1 document the server publishes, which comes from the schemas the routes validate with, so what is listed here is what the API accepts. Base URL `https://api.raylight.app`. Raw document: [https://api.raylight.app/v1/openapi.json](https://api.raylight.app/v1/openapi.json). Authenticated calls take `Authorization: Bearer rl_live_...` with the scope named on each operation; a few need no scope. Every non-2xx body is `{ "error": "...", "code": "..." }`; validation failures add `issues`. Success bodies are listed field by field under each operation. The event Raylight sends to your webhook endpoints is here too, under Webhooks: its headers, every payload field, and what your answer means. ## Productions Brief in, share link out: pitch, greenlight, video. ### GET /v1/productions **List productions** Newest first. Pass `externalId` to find the production an earlier create with that id made (the same match a repeated create replays), for example to check whether a pull request already has one before sending its brief again. Every code file in each brief has `source` and `patch` emptied and carries `sourceOmitted: true`; read one production for its full files. Auth: `Authorization: Bearer rl_live_...` with the `productions` scope. Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `workspaceId` | query | uuid | no | | | `stage` | query | string | no | | | `limit` | query | integer | no | | | `externalId` | query | string | no | | Responses: | Status | Meaning | | --- | --- | | 200 | Productions, newest first. | | 400 | Invalid request. | | 401 | Missing, invalid or revoked key. | | 403 | The key lacks the scope, or the key's owner has view-only access to the workspace (code `forbidden`). | | 404 | Not found. | | 429 | Rate limited, or a monthly cap reached. | Response body (200): | Field | Type | Required | Description | | --- | --- | --- | --- | | `productions` | object[] | yes | | | `productions[].id` | uuid | yes | | | `productions[].workspaceId` | uuid | yes | | | `productions[].stage` | enum | yes | One of: `queued`, `writing_pitch`, `pitch_ready`, `greenlit`, `making`, `exporting`, `delivered`, `skipped`, `declined`, `cancelled`, `failed`. | | `productions[].source` | object | yes | | | `productions[].source.kind` | enum | yes | One of: `app`, `api`, `mcp`. | | `productions[].source.externalId` | string \| null | yes | | | `productions[].source.origin` | object \| null | no | Default `null`. | | `productions[].source.origin.provider` | string | yes | | | `productions[].source.origin.label` | string | yes | | | `productions[].source.origin.url` | url \| null | no | Default `null`. | | `productions[].source.origin.ref` | string \| null | no | Default `null`. | | `productions[].source.origin.requestedBy` | string \| null | no | Default `null`. | | `productions[].brief` | object | yes | | | `productions[].brief.intent` | string | yes | | | `productions[].brief.context` | string \| null | no | Default `null`. | | `productions[].brief.media` | object[] | no | | | `productions[].brief.media[].id` | string | yes | | | `productions[].brief.media[].kind` | enum | yes | One of: `image`, `video`. | | `productions[].brief.media[].url` | url | yes | | | `productions[].brief.media[].name` | string | yes | | | `productions[].brief.media[].description` | string \| null | no | Default `null`. | | `productions[].brief.code` | object[] | no | | | `productions[].brief.code[].path` | string | yes | | | `productions[].brief.code[].source` | string | yes | | | `productions[].brief.code[].status` | enum \| null | no | One of: `added`, `modified`, `removed`, `renamed`. Default `null`. | | `productions[].brief.code[].additions` | integer | no | Default `0`. | | `productions[].brief.code[].deletions` | integer | no | Default `0`. | | `productions[].brief.code[].patch` | string \| null | no | Default `null`. | | `productions[].brief.code[].digest` | string \| null | no | Default `null`. | | `productions[].brief.code[].score` | number | no | Default `0`. | | `productions[].brief.code[].sourceOmitted` | boolean | no | | | `productions[].brief.format` | object | no | | | `productions[].brief.format.template` | enum | no | One of: `changelog`, `launch`, `feature`, `digest`, `custom`. Default `changelog`. | | `productions[].brief.format.durationMs` | integer | no | Default `10000`. | | `productions[].brief.format.aspect` | enum | no | One of: `16:9`, `4:3`, `3:2`, `21:9`, `1:1`, `9:16`, `3:4`, `2:3`, `4:5`. Default `16:9`. | | `productions[].brief.format.sound` | object | no | | | `productions[].brief.format.sound.voiceover` | boolean | no | Default `true`. | | `productions[].brief.format.sound.music` | boolean | no | Default `true`. | | `productions[].brief.format.sound.sfx` | boolean | no | Default `true`. | | `productions[].brief.brand` | object | no | | | `productions[].brief.brand.name` | string \| null | no | Default `null`. | | `productions[].brief.brand.logoUrl` | url \| null | no | Default `null`. | | `productions[].brief.brand.primaryColor` | string \| null | no | Default `null`. | | `productions[].brief.brand.backgroundColor` | string \| null | no | Default `null`. | | `productions[].brief.brand.fontFamily` | string \| null | no | Default `null`. | | `productions[].brief.brand.tone` | string \| null | no | Default `null`. | | `productions[].pitch` | object \| null | yes | | | `productions[].pitch.version` | integer | yes | | | `productions[].pitch.verdict` | enum | no | One of: `video`, `skip`. Default `video`. | | `productions[].pitch.verdictReason` | string \| null | no | Default `null`. | | `productions[].pitch.title` | string | yes | | | `productions[].pitch.summary` | string | yes | | | `productions[].pitch.shots` | object[] | yes | | | `productions[].pitch.shots[].index` | integer | yes | | | `productions[].pitch.shots[].title` | string | yes | | | `productions[].pitch.shots[].durationMs` | integer | yes | | | `productions[].pitch.shots[].beat` | string \| null | no | Default `null`. | | `productions[].pitch.shots[].visual` | string | yes | | | `productions[].pitch.shots[].text` | string \| null | no | Default `null`. | | `productions[].pitch.shots[].voiceover` | string \| null | no | Default `null`. | | `productions[].pitch.shots[].mediaIds` | string[] | no | | | `productions[].pitch.shots[].codePaths` | string[] | no | | | `productions[].pitch.music` | string \| null | no | Default `null`. | | `productions[].pitch.estimatedCredits` | integer | yes | | | `productions[].pitch.createdAt` | datetime | yes | | | `productions[].greenlight` | object \| null | yes | | | `productions[].greenlight.mode` | enum | yes | One of: `manual`, `auto`. | | `productions[].greenlight.actor` | string | yes | | | `productions[].greenlight.actorName` | string \| null | no | | | `productions[].greenlight.at` | datetime | yes | | | `productions[].greenlight.pitchVersion` | integer | yes | | | `productions[].greenlightMode` | enum | yes | One of: `manual`, `auto`. | | `productions[].delivery` | object \| null | yes | | | `productions[].delivery.shareUrl` | url | yes | | | `productions[].delivery.projectId` | uuid | yes | | | `productions[].delivery.editUrl` | url | yes | | | `productions[].delivery.exportId` | uuid | yes | | | `productions[].delivery.deliveredAt` | datetime | yes | | | `productions[].projectId` | uuid \| null | yes | | | `productions[].creditsCharged` | integer | yes | | | `productions[].error` | string \| null | yes | Why the production failed, in words you can show a person. Never the render machine's own diagnostics. | | `productions[].createdAt` | datetime | yes | | | `productions[].updatedAt` | datetime | yes | | | `productions[].terminal` | boolean | no | True once the stage is one the production never leaves (delivered, skipped, declined, cancelled, failed, or a terminal stage added later). Stop polling when it is true; it is computed by the server, so it stays right for stages your client does not know yet. Absent from payloads stored before the field existed. | | `productions[].pageUrl` | url | no | The production's page in the Raylight app. Absent from payloads stored before the field existed. | Example (curl): ```bash curl https://api.raylight.app/v1/productions \ -H "Authorization: Bearer $RAYLIGHT_API_KEY" ``` ### POST /v1/productions **Create a production** Push a brief. Raylight writes a free pitch (script plus shot list) in the background; poll the production or receive `production.pitch_ready` on a webhook. `externalId` makes the call idempotent per key owner and workspace: repeating it with a key from the same owner, in the same workspace, answers 200 with the production the first call made and `Idempotent-Replayed: true`, and nothing new is created. `origin` is display only (the Productions pages and emails); Raylight never calls the provider. Auth: `Authorization: Bearer rl_live_...` with the `productions` scope. Request body (application/json): | Field | Type | Required | Description | | --- | --- | --- | --- | | `workspaceId` | uuid | no | | | `brief` | object | yes | | | `brief.intent` | string | yes | | | `brief.context` | string \| null | no | Default `null`. | | `brief.media` | object[] | no | | | `brief.media[].id` | string | yes | | | `brief.media[].kind` | enum | yes | One of: `image`, `video`. | | `brief.media[].url` | url | yes | | | `brief.media[].name` | string | yes | | | `brief.media[].description` | string \| null | no | Default `null`. | | `brief.code` | object[] | no | | | `brief.code[].path` | string | yes | | | `brief.code[].source` | string | yes | | | `brief.code[].status` | enum \| null | no | One of: `added`, `modified`, `removed`, `renamed`. Default `null`. | | `brief.code[].additions` | integer | no | Default `0`. | | `brief.code[].deletions` | integer | no | Default `0`. | | `brief.code[].patch` | string \| null | no | Default `null`. | | `brief.code[].digest` | string \| null | no | Default `null`. | | `brief.code[].score` | number | no | Default `0`. | | `brief.code[].sourceOmitted` | boolean | no | | | `brief.format` | object | no | | | `brief.format.template` | enum | no | One of: `changelog`, `launch`, `feature`, `digest`, `custom`. Default `changelog`. | | `brief.format.durationMs` | integer | no | Default `10000`. | | `brief.format.aspect` | enum | no | One of: `16:9`, `4:3`, `3:2`, `21:9`, `1:1`, `9:16`, `3:4`, `2:3`, `4:5`. Default `16:9`. | | `brief.format.sound` | object | no | | | `brief.format.sound.voiceover` | boolean | no | Default `true`. | | `brief.format.sound.music` | boolean | no | Default `true`. | | `brief.format.sound.sfx` | boolean | no | Default `true`. | | `brief.brand` | object | no | | | `brief.brand.name` | string \| null | no | Default `null`. | | `brief.brand.logoUrl` | url \| null | no | Default `null`. | | `brief.brand.primaryColor` | string \| null | no | Default `null`. | | `brief.brand.backgroundColor` | string \| null | no | Default `null`. | | `brief.brand.fontFamily` | string \| null | no | Default `null`. | | `brief.brand.tone` | string \| null | no | Default `null`. | | `greenlightMode` | enum | no | One of: `manual`, `auto`. Default `manual`. | | `externalId` | string | no | | | `origin` | object | no | | | `origin.provider` | string | yes | | | `origin.label` | string | yes | | | `origin.url` | url \| null | no | Default `null`. | | `origin.ref` | string \| null | no | Default `null`. | | `origin.requestedBy` | string \| null | no | Default `null`. | Responses: | Status | Meaning | | --- | --- | | 200 | A repeat of an earlier create with the same `externalId`: the existing production. | | 201 | The new production, queued for a pitch. | | 400 | Invalid request. | | 401 | Missing, invalid or revoked key. | | 402 | The workspace owner's credits do not cover a video of the length asked for (code `credits`). A pitch is free, but it is only written when the video it asks for could be paid for. Nothing was created. | | 403 | The key lacks the scope, or the key's owner has view-only access to the workspace (code `forbidden`). | | 404 | Not found. | | 429 | Rate limited, or a monthly cap reached. | | 503 | Raylight can't write pitches right now (code `disabled`). Nothing was created. | Response body (200): | Field | Type | Required | Description | | --- | --- | --- | --- | | `production` | object | yes | | | `production.id` | uuid | yes | | | `production.workspaceId` | uuid | yes | | | `production.stage` | enum | yes | One of: `queued`, `writing_pitch`, `pitch_ready`, `greenlit`, `making`, `exporting`, `delivered`, `skipped`, `declined`, `cancelled`, `failed`. | | `production.source` | object | yes | | | `production.source.kind` | enum | yes | One of: `app`, `api`, `mcp`. | | `production.source.externalId` | string \| null | yes | | | `production.source.origin` | object \| null | no | Default `null`. | | `production.source.origin.provider` | string | yes | | | `production.source.origin.label` | string | yes | | | `production.source.origin.url` | url \| null | no | Default `null`. | | `production.source.origin.ref` | string \| null | no | Default `null`. | | `production.source.origin.requestedBy` | string \| null | no | Default `null`. | | `production.brief` | object | yes | | | `production.brief.intent` | string | yes | | | `production.brief.context` | string \| null | no | Default `null`. | | `production.brief.media` | object[] | no | | | `production.brief.media[].id` | string | yes | | | `production.brief.media[].kind` | enum | yes | One of: `image`, `video`. | | `production.brief.media[].url` | url | yes | | | `production.brief.media[].name` | string | yes | | | `production.brief.media[].description` | string \| null | no | Default `null`. | | `production.brief.code` | object[] | no | | | `production.brief.code[].path` | string | yes | | | `production.brief.code[].source` | string | yes | | | `production.brief.code[].status` | enum \| null | no | One of: `added`, `modified`, `removed`, `renamed`. Default `null`. | | `production.brief.code[].additions` | integer | no | Default `0`. | | `production.brief.code[].deletions` | integer | no | Default `0`. | | `production.brief.code[].patch` | string \| null | no | Default `null`. | | `production.brief.code[].digest` | string \| null | no | Default `null`. | | `production.brief.code[].score` | number | no | Default `0`. | | `production.brief.code[].sourceOmitted` | boolean | no | | | `production.brief.format` | object | no | | | `production.brief.format.template` | enum | no | One of: `changelog`, `launch`, `feature`, `digest`, `custom`. Default `changelog`. | | `production.brief.format.durationMs` | integer | no | Default `10000`. | | `production.brief.format.aspect` | enum | no | One of: `16:9`, `4:3`, `3:2`, `21:9`, `1:1`, `9:16`, `3:4`, `2:3`, `4:5`. Default `16:9`. | | `production.brief.format.sound` | object | no | | | `production.brief.format.sound.voiceover` | boolean | no | Default `true`. | | `production.brief.format.sound.music` | boolean | no | Default `true`. | | `production.brief.format.sound.sfx` | boolean | no | Default `true`. | | `production.brief.brand` | object | no | | | `production.brief.brand.name` | string \| null | no | Default `null`. | | `production.brief.brand.logoUrl` | url \| null | no | Default `null`. | | `production.brief.brand.primaryColor` | string \| null | no | Default `null`. | | `production.brief.brand.backgroundColor` | string \| null | no | Default `null`. | | `production.brief.brand.fontFamily` | string \| null | no | Default `null`. | | `production.brief.brand.tone` | string \| null | no | Default `null`. | | `production.pitch` | object \| null | yes | | | `production.pitch.version` | integer | yes | | | `production.pitch.verdict` | enum | no | One of: `video`, `skip`. Default `video`. | | `production.pitch.verdictReason` | string \| null | no | Default `null`. | | `production.pitch.title` | string | yes | | | `production.pitch.summary` | string | yes | | | `production.pitch.shots` | object[] | yes | | | `production.pitch.shots[].index` | integer | yes | | | `production.pitch.shots[].title` | string | yes | | | `production.pitch.shots[].durationMs` | integer | yes | | | `production.pitch.shots[].beat` | string \| null | no | Default `null`. | | `production.pitch.shots[].visual` | string | yes | | | `production.pitch.shots[].text` | string \| null | no | Default `null`. | | `production.pitch.shots[].voiceover` | string \| null | no | Default `null`. | | `production.pitch.shots[].mediaIds` | string[] | no | | | `production.pitch.shots[].codePaths` | string[] | no | | | `production.pitch.music` | string \| null | no | Default `null`. | | `production.pitch.estimatedCredits` | integer | yes | | | `production.pitch.createdAt` | datetime | yes | | | `production.greenlight` | object \| null | yes | | | `production.greenlight.mode` | enum | yes | One of: `manual`, `auto`. | | `production.greenlight.actor` | string | yes | | | `production.greenlight.actorName` | string \| null | no | | | `production.greenlight.at` | datetime | yes | | | `production.greenlight.pitchVersion` | integer | yes | | | `production.greenlightMode` | enum | yes | One of: `manual`, `auto`. | | `production.delivery` | object \| null | yes | | | `production.delivery.shareUrl` | url | yes | | | `production.delivery.projectId` | uuid | yes | | | `production.delivery.editUrl` | url | yes | | | `production.delivery.exportId` | uuid | yes | | | `production.delivery.deliveredAt` | datetime | yes | | | `production.projectId` | uuid \| null | yes | | | `production.creditsCharged` | integer | yes | | | `production.error` | string \| null | yes | Why the production failed, in words you can show a person. Never the render machine's own diagnostics. | | `production.createdAt` | datetime | yes | | | `production.updatedAt` | datetime | yes | | | `production.terminal` | boolean | no | True once the stage is one the production never leaves (delivered, skipped, declined, cancelled, failed, or a terminal stage added later). Stop polling when it is true; it is computed by the server, so it stays right for stages your client does not know yet. Absent from payloads stored before the field existed. | | `production.pageUrl` | url | no | The production's page in the Raylight app. Absent from payloads stored before the field existed. | Response body (201): | Field | Type | Required | Description | | --- | --- | --- | --- | | `production` | object | yes | | | `production.id` | uuid | yes | | | `production.workspaceId` | uuid | yes | | | `production.stage` | enum | yes | One of: `queued`, `writing_pitch`, `pitch_ready`, `greenlit`, `making`, `exporting`, `delivered`, `skipped`, `declined`, `cancelled`, `failed`. | | `production.source` | object | yes | | | `production.source.kind` | enum | yes | One of: `app`, `api`, `mcp`. | | `production.source.externalId` | string \| null | yes | | | `production.source.origin` | object \| null | no | Default `null`. | | `production.source.origin.provider` | string | yes | | | `production.source.origin.label` | string | yes | | | `production.source.origin.url` | url \| null | no | Default `null`. | | `production.source.origin.ref` | string \| null | no | Default `null`. | | `production.source.origin.requestedBy` | string \| null | no | Default `null`. | | `production.brief` | object | yes | | | `production.brief.intent` | string | yes | | | `production.brief.context` | string \| null | no | Default `null`. | | `production.brief.media` | object[] | no | | | `production.brief.media[].id` | string | yes | | | `production.brief.media[].kind` | enum | yes | One of: `image`, `video`. | | `production.brief.media[].url` | url | yes | | | `production.brief.media[].name` | string | yes | | | `production.brief.media[].description` | string \| null | no | Default `null`. | | `production.brief.code` | object[] | no | | | `production.brief.code[].path` | string | yes | | | `production.brief.code[].source` | string | yes | | | `production.brief.code[].status` | enum \| null | no | One of: `added`, `modified`, `removed`, `renamed`. Default `null`. | | `production.brief.code[].additions` | integer | no | Default `0`. | | `production.brief.code[].deletions` | integer | no | Default `0`. | | `production.brief.code[].patch` | string \| null | no | Default `null`. | | `production.brief.code[].digest` | string \| null | no | Default `null`. | | `production.brief.code[].score` | number | no | Default `0`. | | `production.brief.code[].sourceOmitted` | boolean | no | | | `production.brief.format` | object | no | | | `production.brief.format.template` | enum | no | One of: `changelog`, `launch`, `feature`, `digest`, `custom`. Default `changelog`. | | `production.brief.format.durationMs` | integer | no | Default `10000`. | | `production.brief.format.aspect` | enum | no | One of: `16:9`, `4:3`, `3:2`, `21:9`, `1:1`, `9:16`, `3:4`, `2:3`, `4:5`. Default `16:9`. | | `production.brief.format.sound` | object | no | | | `production.brief.format.sound.voiceover` | boolean | no | Default `true`. | | `production.brief.format.sound.music` | boolean | no | Default `true`. | | `production.brief.format.sound.sfx` | boolean | no | Default `true`. | | `production.brief.brand` | object | no | | | `production.brief.brand.name` | string \| null | no | Default `null`. | | `production.brief.brand.logoUrl` | url \| null | no | Default `null`. | | `production.brief.brand.primaryColor` | string \| null | no | Default `null`. | | `production.brief.brand.backgroundColor` | string \| null | no | Default `null`. | | `production.brief.brand.fontFamily` | string \| null | no | Default `null`. | | `production.brief.brand.tone` | string \| null | no | Default `null`. | | `production.pitch` | object \| null | yes | | | `production.pitch.version` | integer | yes | | | `production.pitch.verdict` | enum | no | One of: `video`, `skip`. Default `video`. | | `production.pitch.verdictReason` | string \| null | no | Default `null`. | | `production.pitch.title` | string | yes | | | `production.pitch.summary` | string | yes | | | `production.pitch.shots` | object[] | yes | | | `production.pitch.shots[].index` | integer | yes | | | `production.pitch.shots[].title` | string | yes | | | `production.pitch.shots[].durationMs` | integer | yes | | | `production.pitch.shots[].beat` | string \| null | no | Default `null`. | | `production.pitch.shots[].visual` | string | yes | | | `production.pitch.shots[].text` | string \| null | no | Default `null`. | | `production.pitch.shots[].voiceover` | string \| null | no | Default `null`. | | `production.pitch.shots[].mediaIds` | string[] | no | | | `production.pitch.shots[].codePaths` | string[] | no | | | `production.pitch.music` | string \| null | no | Default `null`. | | `production.pitch.estimatedCredits` | integer | yes | | | `production.pitch.createdAt` | datetime | yes | | | `production.greenlight` | object \| null | yes | | | `production.greenlight.mode` | enum | yes | One of: `manual`, `auto`. | | `production.greenlight.actor` | string | yes | | | `production.greenlight.actorName` | string \| null | no | | | `production.greenlight.at` | datetime | yes | | | `production.greenlight.pitchVersion` | integer | yes | | | `production.greenlightMode` | enum | yes | One of: `manual`, `auto`. | | `production.delivery` | object \| null | yes | | | `production.delivery.shareUrl` | url | yes | | | `production.delivery.projectId` | uuid | yes | | | `production.delivery.editUrl` | url | yes | | | `production.delivery.exportId` | uuid | yes | | | `production.delivery.deliveredAt` | datetime | yes | | | `production.projectId` | uuid \| null | yes | | | `production.creditsCharged` | integer | yes | | | `production.error` | string \| null | yes | Why the production failed, in words you can show a person. Never the render machine's own diagnostics. | | `production.createdAt` | datetime | yes | | | `production.updatedAt` | datetime | yes | | | `production.terminal` | boolean | no | True once the stage is one the production never leaves (delivered, skipped, declined, cancelled, failed, or a terminal stage added later). Stop polling when it is true; it is computed by the server, so it stays right for stages your client does not know yet. Absent from payloads stored before the field existed. | | `production.pageUrl` | url | no | The production's page in the Raylight app. Absent from payloads stored before the field existed. | Example (curl): ```bash curl -X POST https://api.raylight.app/v1/productions \ -H "Authorization: Bearer $RAYLIGHT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "brief": { "intent": "Announce dark mode in the Acme dashboard to existing customers.", "context": "PR #412 adds a theme toggle to Settings. Charts, tables and the sidebar all pick up the dark palette. Ships to everyone on Thursday.", "media": [ { "id": "dashboard-dark", "kind": "image", "url": "https://cdn.acme.dev/press/dashboard-dark.png", "name": "Dashboard in dark mode", "description": "The analytics overview with the new dark theme." } ], "code": [ { "path": "src/components/settings/theme-toggle.tsx", "source": "export const ThemeToggle = () => { /* ... */ }" } ], "format": { "template": "feature", "durationMs": 10000, "aspect": "16:9", "sound": { "voiceover": true, "music": true, "sfx": true } }, "brand": { "name": "Acme", "primaryColor": "#6C5CE7", "tone": "confident, plain" } }, "greenlightMode": "manual", "externalId": "acme/web#412", "origin": { "provider": "github", "label": "acme/web #412", "url": "https://github.com/acme/web/pull/412", "ref": "feat/dark-mode", "requestedBy": "jane" } }' ``` ### GET /v1/productions/usage **This month's usage and pitch cap** Auth: `Authorization: Bearer rl_live_...` with the `productions` scope. Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `workspaceId` | query | uuid | no | | Responses: | Status | Meaning | | --- | --- | | 200 | Counts and caps for the calendar month. | | 400 | Invalid request. | | 401 | Missing, invalid or revoked key. | | 403 | The key lacks the scope, or the key's owner has view-only access to the workspace (code `forbidden`). | | 404 | Not found. | | 429 | Rate limited, or a monthly cap reached. | Response body (200): | Field | Type | Required | Description | | --- | --- | --- | --- | | `plan` | string | yes | | | `productionsThisMonth` | integer | yes | | | `pitchesThisMonth` | integer | yes | | | `pitchesCap` | integer | yes | | | `creditsCharged` | integer | yes | | Example (curl): ```bash curl https://api.raylight.app/v1/productions/usage \ -H "Authorization: Bearer $RAYLIGHT_API_KEY" ``` ### GET /v1/productions/{id} **Read a production** The production with its full brief, its timeline (events and comments, each with `actorName`), and its place in the render queue. Auth: `Authorization: Bearer rl_live_...` with the `productions` scope. Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | uuid | yes | The production id. | Responses: | Status | Meaning | | --- | --- | | 200 | The production with its timeline. | | 400 | Invalid request. | | 401 | Missing, invalid or revoked key. | | 403 | The key lacks the scope, or the key's owner has view-only access to the workspace (code `forbidden`). | | 404 | Not found. | | 429 | Rate limited, or a monthly cap reached. | Response body (200): | Field | Type | Required | Description | | --- | --- | --- | --- | | `production` | object | yes | | | `production.id` | uuid | yes | | | `production.workspaceId` | uuid | yes | | | `production.stage` | enum | yes | One of: `queued`, `writing_pitch`, `pitch_ready`, `greenlit`, `making`, `exporting`, `delivered`, `skipped`, `declined`, `cancelled`, `failed`. | | `production.source` | object | yes | | | `production.source.kind` | enum | yes | One of: `app`, `api`, `mcp`. | | `production.source.externalId` | string \| null | yes | | | `production.source.origin` | object \| null | no | Default `null`. | | `production.source.origin.provider` | string | yes | | | `production.source.origin.label` | string | yes | | | `production.source.origin.url` | url \| null | no | Default `null`. | | `production.source.origin.ref` | string \| null | no | Default `null`. | | `production.source.origin.requestedBy` | string \| null | no | Default `null`. | | `production.brief` | object | yes | | | `production.brief.intent` | string | yes | | | `production.brief.context` | string \| null | no | Default `null`. | | `production.brief.media` | object[] | no | | | `production.brief.media[].id` | string | yes | | | `production.brief.media[].kind` | enum | yes | One of: `image`, `video`. | | `production.brief.media[].url` | url | yes | | | `production.brief.media[].name` | string | yes | | | `production.brief.media[].description` | string \| null | no | Default `null`. | | `production.brief.code` | object[] | no | | | `production.brief.code[].path` | string | yes | | | `production.brief.code[].source` | string | yes | | | `production.brief.code[].status` | enum \| null | no | One of: `added`, `modified`, `removed`, `renamed`. Default `null`. | | `production.brief.code[].additions` | integer | no | Default `0`. | | `production.brief.code[].deletions` | integer | no | Default `0`. | | `production.brief.code[].patch` | string \| null | no | Default `null`. | | `production.brief.code[].digest` | string \| null | no | Default `null`. | | `production.brief.code[].score` | number | no | Default `0`. | | `production.brief.code[].sourceOmitted` | boolean | no | | | `production.brief.format` | object | no | | | `production.brief.format.template` | enum | no | One of: `changelog`, `launch`, `feature`, `digest`, `custom`. Default `changelog`. | | `production.brief.format.durationMs` | integer | no | Default `10000`. | | `production.brief.format.aspect` | enum | no | One of: `16:9`, `4:3`, `3:2`, `21:9`, `1:1`, `9:16`, `3:4`, `2:3`, `4:5`. Default `16:9`. | | `production.brief.format.sound` | object | no | | | `production.brief.format.sound.voiceover` | boolean | no | Default `true`. | | `production.brief.format.sound.music` | boolean | no | Default `true`. | | `production.brief.format.sound.sfx` | boolean | no | Default `true`. | | `production.brief.brand` | object | no | | | `production.brief.brand.name` | string \| null | no | Default `null`. | | `production.brief.brand.logoUrl` | url \| null | no | Default `null`. | | `production.brief.brand.primaryColor` | string \| null | no | Default `null`. | | `production.brief.brand.backgroundColor` | string \| null | no | Default `null`. | | `production.brief.brand.fontFamily` | string \| null | no | Default `null`. | | `production.brief.brand.tone` | string \| null | no | Default `null`. | | `production.pitch` | object \| null | yes | | | `production.pitch.version` | integer | yes | | | `production.pitch.verdict` | enum | no | One of: `video`, `skip`. Default `video`. | | `production.pitch.verdictReason` | string \| null | no | Default `null`. | | `production.pitch.title` | string | yes | | | `production.pitch.summary` | string | yes | | | `production.pitch.shots` | object[] | yes | | | `production.pitch.shots[].index` | integer | yes | | | `production.pitch.shots[].title` | string | yes | | | `production.pitch.shots[].durationMs` | integer | yes | | | `production.pitch.shots[].beat` | string \| null | no | Default `null`. | | `production.pitch.shots[].visual` | string | yes | | | `production.pitch.shots[].text` | string \| null | no | Default `null`. | | `production.pitch.shots[].voiceover` | string \| null | no | Default `null`. | | `production.pitch.shots[].mediaIds` | string[] | no | | | `production.pitch.shots[].codePaths` | string[] | no | | | `production.pitch.music` | string \| null | no | Default `null`. | | `production.pitch.estimatedCredits` | integer | yes | | | `production.pitch.createdAt` | datetime | yes | | | `production.greenlight` | object \| null | yes | | | `production.greenlight.mode` | enum | yes | One of: `manual`, `auto`. | | `production.greenlight.actor` | string | yes | | | `production.greenlight.actorName` | string \| null | no | | | `production.greenlight.at` | datetime | yes | | | `production.greenlight.pitchVersion` | integer | yes | | | `production.greenlightMode` | enum | yes | One of: `manual`, `auto`. | | `production.delivery` | object \| null | yes | | | `production.delivery.shareUrl` | url | yes | | | `production.delivery.projectId` | uuid | yes | | | `production.delivery.editUrl` | url | yes | | | `production.delivery.exportId` | uuid | yes | | | `production.delivery.deliveredAt` | datetime | yes | | | `production.projectId` | uuid \| null | yes | | | `production.creditsCharged` | integer | yes | | | `production.error` | string \| null | yes | Why the production failed, in words you can show a person. Never the render machine's own diagnostics. | | `production.createdAt` | datetime | yes | | | `production.updatedAt` | datetime | yes | | | `production.terminal` | boolean | no | True once the stage is one the production never leaves (delivered, skipped, declined, cancelled, failed, or a terminal stage added later). Stop polling when it is true; it is computed by the server, so it stays right for stages your client does not know yet. Absent from payloads stored before the field existed. | | `production.pageUrl` | url | no | The production's page in the Raylight app. Absent from payloads stored before the field existed. | | `events` | object[] | yes | | | `events[].id` | uuid | yes | | | `events[].productionId` | uuid | yes | | | `events[].kind` | enum | yes | One of: `created`, `stage`, `pitch`, `comment`, `greenlit`, `declined`, `cancelled`, `delivered`, `failed`, `note`. | | `events[].actor` | string | yes | | | `events[].actorName` | string \| null | no | The name a person reads for `actor`: the user's name or email, the email behind a link, "Auto-greenlight", "Raylight", or null when unknown. | | `events[].payload` | object | yes | The fields that kind of event carries for a reader: the stage, the comment, the credits charged or refunded, the error, your own `externalId` on `created`, and on a progress `note` the `phase` the production moved to. Nothing else is included. | | `events[].createdAt` | datetime | yes | | | `queue` | object \| null | yes | | | `queue.phase` | enum \| any | no | Default `null`. | | `queue.startedAt` | datetime \| null | yes | | | `queue.retrying` | boolean | no | Default `false`. | | `queue.lastProgress` | string \| null | yes | | | `pitchMarkdown` | string \| null | yes | | Example (curl): ```bash curl https://api.raylight.app/v1/productions/$ID \ -H "Authorization: Bearer $RAYLIGHT_API_KEY" ``` ### POST /v1/productions/{id}/greenlight **Greenlight the pitch** Charges the production credits once, creates the project and queues the video for a render machine. Pass `pitchVersion` to refuse the greenlight if the pitch changed since you read it. Auth: `Authorization: Bearer rl_live_...` with the `productions` scope. Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | uuid | yes | The production id. | Request body (application/json): | Field | Type | Required | Description | | --- | --- | --- | --- | | `pitchVersion` | integer | no | | Responses: | Status | Meaning | | --- | --- | | 200 | The greenlit production. | | 400 | Invalid request. | | 401 | Missing, invalid or revoked key. | | 402 | Not enough credits (code `credits`). | | 403 | The key lacks the scope, or the key's owner has view-only access to the workspace (code `forbidden`). | | 404 | Not found. | | 409 | The pitch is not ready, or its version is out of date (code `stage`). | | 429 | Rate limited, or a monthly cap reached. | Response body (200): | Field | Type | Required | Description | | --- | --- | --- | --- | | `production` | object | yes | | | `production.id` | uuid | yes | | | `production.workspaceId` | uuid | yes | | | `production.stage` | enum | yes | One of: `queued`, `writing_pitch`, `pitch_ready`, `greenlit`, `making`, `exporting`, `delivered`, `skipped`, `declined`, `cancelled`, `failed`. | | `production.source` | object | yes | | | `production.source.kind` | enum | yes | One of: `app`, `api`, `mcp`. | | `production.source.externalId` | string \| null | yes | | | `production.source.origin` | object \| null | no | Default `null`. | | `production.source.origin.provider` | string | yes | | | `production.source.origin.label` | string | yes | | | `production.source.origin.url` | url \| null | no | Default `null`. | | `production.source.origin.ref` | string \| null | no | Default `null`. | | `production.source.origin.requestedBy` | string \| null | no | Default `null`. | | `production.brief` | object | yes | | | `production.brief.intent` | string | yes | | | `production.brief.context` | string \| null | no | Default `null`. | | `production.brief.media` | object[] | no | | | `production.brief.media[].id` | string | yes | | | `production.brief.media[].kind` | enum | yes | One of: `image`, `video`. | | `production.brief.media[].url` | url | yes | | | `production.brief.media[].name` | string | yes | | | `production.brief.media[].description` | string \| null | no | Default `null`. | | `production.brief.code` | object[] | no | | | `production.brief.code[].path` | string | yes | | | `production.brief.code[].source` | string | yes | | | `production.brief.code[].status` | enum \| null | no | One of: `added`, `modified`, `removed`, `renamed`. Default `null`. | | `production.brief.code[].additions` | integer | no | Default `0`. | | `production.brief.code[].deletions` | integer | no | Default `0`. | | `production.brief.code[].patch` | string \| null | no | Default `null`. | | `production.brief.code[].digest` | string \| null | no | Default `null`. | | `production.brief.code[].score` | number | no | Default `0`. | | `production.brief.code[].sourceOmitted` | boolean | no | | | `production.brief.format` | object | no | | | `production.brief.format.template` | enum | no | One of: `changelog`, `launch`, `feature`, `digest`, `custom`. Default `changelog`. | | `production.brief.format.durationMs` | integer | no | Default `10000`. | | `production.brief.format.aspect` | enum | no | One of: `16:9`, `4:3`, `3:2`, `21:9`, `1:1`, `9:16`, `3:4`, `2:3`, `4:5`. Default `16:9`. | | `production.brief.format.sound` | object | no | | | `production.brief.format.sound.voiceover` | boolean | no | Default `true`. | | `production.brief.format.sound.music` | boolean | no | Default `true`. | | `production.brief.format.sound.sfx` | boolean | no | Default `true`. | | `production.brief.brand` | object | no | | | `production.brief.brand.name` | string \| null | no | Default `null`. | | `production.brief.brand.logoUrl` | url \| null | no | Default `null`. | | `production.brief.brand.primaryColor` | string \| null | no | Default `null`. | | `production.brief.brand.backgroundColor` | string \| null | no | Default `null`. | | `production.brief.brand.fontFamily` | string \| null | no | Default `null`. | | `production.brief.brand.tone` | string \| null | no | Default `null`. | | `production.pitch` | object \| null | yes | | | `production.pitch.version` | integer | yes | | | `production.pitch.verdict` | enum | no | One of: `video`, `skip`. Default `video`. | | `production.pitch.verdictReason` | string \| null | no | Default `null`. | | `production.pitch.title` | string | yes | | | `production.pitch.summary` | string | yes | | | `production.pitch.shots` | object[] | yes | | | `production.pitch.shots[].index` | integer | yes | | | `production.pitch.shots[].title` | string | yes | | | `production.pitch.shots[].durationMs` | integer | yes | | | `production.pitch.shots[].beat` | string \| null | no | Default `null`. | | `production.pitch.shots[].visual` | string | yes | | | `production.pitch.shots[].text` | string \| null | no | Default `null`. | | `production.pitch.shots[].voiceover` | string \| null | no | Default `null`. | | `production.pitch.shots[].mediaIds` | string[] | no | | | `production.pitch.shots[].codePaths` | string[] | no | | | `production.pitch.music` | string \| null | no | Default `null`. | | `production.pitch.estimatedCredits` | integer | yes | | | `production.pitch.createdAt` | datetime | yes | | | `production.greenlight` | object \| null | yes | | | `production.greenlight.mode` | enum | yes | One of: `manual`, `auto`. | | `production.greenlight.actor` | string | yes | | | `production.greenlight.actorName` | string \| null | no | | | `production.greenlight.at` | datetime | yes | | | `production.greenlight.pitchVersion` | integer | yes | | | `production.greenlightMode` | enum | yes | One of: `manual`, `auto`. | | `production.delivery` | object \| null | yes | | | `production.delivery.shareUrl` | url | yes | | | `production.delivery.projectId` | uuid | yes | | | `production.delivery.editUrl` | url | yes | | | `production.delivery.exportId` | uuid | yes | | | `production.delivery.deliveredAt` | datetime | yes | | | `production.projectId` | uuid \| null | yes | | | `production.creditsCharged` | integer | yes | | | `production.error` | string \| null | yes | Why the production failed, in words you can show a person. Never the render machine's own diagnostics. | | `production.createdAt` | datetime | yes | | | `production.updatedAt` | datetime | yes | | | `production.terminal` | boolean | no | True once the stage is one the production never leaves (delivered, skipped, declined, cancelled, failed, or a terminal stage added later). Stop polling when it is true; it is computed by the server, so it stays right for stages your client does not know yet. Absent from payloads stored before the field existed. | | `production.pageUrl` | url | no | The production's page in the Raylight app. Absent from payloads stored before the field existed. | Example (curl): ```bash curl -X POST https://api.raylight.app/v1/productions/$ID/greenlight \ -H "Authorization: Bearer $RAYLIGHT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "pitchVersion": 1 }' ``` ### POST /v1/productions/{id}/comments **Send comments** Send comments on the pitch and get the next pitch version, free. A production gets up to 6 pitch versions, so 5 rounds of comments; past that the answer is 429 with code `cap`. Pass `pitchVersion` to refuse the comments if the pitch changed meanwhile. If the next version cannot be written (the monthly pitch cap, or pitches being unavailable), the current pitch stands and a `note` event carries `revisionRefused` and `message`. Auth: `Authorization: Bearer rl_live_...` with the `productions` scope. Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | uuid | yes | The production id. | Request body (application/json): | Field | Type | Required | Description | | --- | --- | --- | --- | | `comment` | string | yes | | | `pitchVersion` | integer | no | | Responses: | Status | Meaning | | --- | --- | | 200 | The production, writing its next pitch. | | 400 | Invalid request. | | 401 | Missing, invalid or revoked key. | | 402 | The workspace owner's credits do not cover the video (code `credits`). The current pitch stands. | | 403 | The key lacks the scope, or the key's owner has view-only access to the workspace (code `forbidden`). | | 404 | Not found. | | 409 | The pitch is not ready, or its version is out of date (code `stage`). | | 429 | Rate limited, or a monthly cap reached. | | 503 | Raylight can't write pitches right now (code `disabled`). The current pitch stands. | Response body (200): | Field | Type | Required | Description | | --- | --- | --- | --- | | `production` | object | yes | | | `production.id` | uuid | yes | | | `production.workspaceId` | uuid | yes | | | `production.stage` | enum | yes | One of: `queued`, `writing_pitch`, `pitch_ready`, `greenlit`, `making`, `exporting`, `delivered`, `skipped`, `declined`, `cancelled`, `failed`. | | `production.source` | object | yes | | | `production.source.kind` | enum | yes | One of: `app`, `api`, `mcp`. | | `production.source.externalId` | string \| null | yes | | | `production.source.origin` | object \| null | no | Default `null`. | | `production.source.origin.provider` | string | yes | | | `production.source.origin.label` | string | yes | | | `production.source.origin.url` | url \| null | no | Default `null`. | | `production.source.origin.ref` | string \| null | no | Default `null`. | | `production.source.origin.requestedBy` | string \| null | no | Default `null`. | | `production.brief` | object | yes | | | `production.brief.intent` | string | yes | | | `production.brief.context` | string \| null | no | Default `null`. | | `production.brief.media` | object[] | no | | | `production.brief.media[].id` | string | yes | | | `production.brief.media[].kind` | enum | yes | One of: `image`, `video`. | | `production.brief.media[].url` | url | yes | | | `production.brief.media[].name` | string | yes | | | `production.brief.media[].description` | string \| null | no | Default `null`. | | `production.brief.code` | object[] | no | | | `production.brief.code[].path` | string | yes | | | `production.brief.code[].source` | string | yes | | | `production.brief.code[].status` | enum \| null | no | One of: `added`, `modified`, `removed`, `renamed`. Default `null`. | | `production.brief.code[].additions` | integer | no | Default `0`. | | `production.brief.code[].deletions` | integer | no | Default `0`. | | `production.brief.code[].patch` | string \| null | no | Default `null`. | | `production.brief.code[].digest` | string \| null | no | Default `null`. | | `production.brief.code[].score` | number | no | Default `0`. | | `production.brief.code[].sourceOmitted` | boolean | no | | | `production.brief.format` | object | no | | | `production.brief.format.template` | enum | no | One of: `changelog`, `launch`, `feature`, `digest`, `custom`. Default `changelog`. | | `production.brief.format.durationMs` | integer | no | Default `10000`. | | `production.brief.format.aspect` | enum | no | One of: `16:9`, `4:3`, `3:2`, `21:9`, `1:1`, `9:16`, `3:4`, `2:3`, `4:5`. Default `16:9`. | | `production.brief.format.sound` | object | no | | | `production.brief.format.sound.voiceover` | boolean | no | Default `true`. | | `production.brief.format.sound.music` | boolean | no | Default `true`. | | `production.brief.format.sound.sfx` | boolean | no | Default `true`. | | `production.brief.brand` | object | no | | | `production.brief.brand.name` | string \| null | no | Default `null`. | | `production.brief.brand.logoUrl` | url \| null | no | Default `null`. | | `production.brief.brand.primaryColor` | string \| null | no | Default `null`. | | `production.brief.brand.backgroundColor` | string \| null | no | Default `null`. | | `production.brief.brand.fontFamily` | string \| null | no | Default `null`. | | `production.brief.brand.tone` | string \| null | no | Default `null`. | | `production.pitch` | object \| null | yes | | | `production.pitch.version` | integer | yes | | | `production.pitch.verdict` | enum | no | One of: `video`, `skip`. Default `video`. | | `production.pitch.verdictReason` | string \| null | no | Default `null`. | | `production.pitch.title` | string | yes | | | `production.pitch.summary` | string | yes | | | `production.pitch.shots` | object[] | yes | | | `production.pitch.shots[].index` | integer | yes | | | `production.pitch.shots[].title` | string | yes | | | `production.pitch.shots[].durationMs` | integer | yes | | | `production.pitch.shots[].beat` | string \| null | no | Default `null`. | | `production.pitch.shots[].visual` | string | yes | | | `production.pitch.shots[].text` | string \| null | no | Default `null`. | | `production.pitch.shots[].voiceover` | string \| null | no | Default `null`. | | `production.pitch.shots[].mediaIds` | string[] | no | | | `production.pitch.shots[].codePaths` | string[] | no | | | `production.pitch.music` | string \| null | no | Default `null`. | | `production.pitch.estimatedCredits` | integer | yes | | | `production.pitch.createdAt` | datetime | yes | | | `production.greenlight` | object \| null | yes | | | `production.greenlight.mode` | enum | yes | One of: `manual`, `auto`. | | `production.greenlight.actor` | string | yes | | | `production.greenlight.actorName` | string \| null | no | | | `production.greenlight.at` | datetime | yes | | | `production.greenlight.pitchVersion` | integer | yes | | | `production.greenlightMode` | enum | yes | One of: `manual`, `auto`. | | `production.delivery` | object \| null | yes | | | `production.delivery.shareUrl` | url | yes | | | `production.delivery.projectId` | uuid | yes | | | `production.delivery.editUrl` | url | yes | | | `production.delivery.exportId` | uuid | yes | | | `production.delivery.deliveredAt` | datetime | yes | | | `production.projectId` | uuid \| null | yes | | | `production.creditsCharged` | integer | yes | | | `production.error` | string \| null | yes | Why the production failed, in words you can show a person. Never the render machine's own diagnostics. | | `production.createdAt` | datetime | yes | | | `production.updatedAt` | datetime | yes | | | `production.terminal` | boolean | no | True once the stage is one the production never leaves (delivered, skipped, declined, cancelled, failed, or a terminal stage added later). Stop polling when it is true; it is computed by the server, so it stays right for stages your client does not know yet. Absent from payloads stored before the field existed. | | `production.pageUrl` | url | no | The production's page in the Raylight app. Absent from payloads stored before the field existed. | Example (curl): ```bash curl -X POST https://api.raylight.app/v1/productions/$ID/comments \ -H "Authorization: Bearer $RAYLIGHT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "comment": "Open on the toggle, not the logo. Drop the pricing shot.", "pitchVersion": 1 }' ``` ### POST /v1/productions/{id}/decline **Decline the pitch** Auth: `Authorization: Bearer rl_live_...` with the `productions` scope. Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | uuid | yes | The production id. | Request body (application/json): | Field | Type | Required | Description | | --- | --- | --- | --- | | `reason` | string | no | | Responses: | Status | Meaning | | --- | --- | | 200 | The declined production. | | 400 | Invalid request. | | 401 | Missing, invalid or revoked key. | | 403 | The key lacks the scope, or the key's owner has view-only access to the workspace (code `forbidden`). | | 404 | Not found. | | 409 | The production is already greenlit (code `stage`). | | 429 | Rate limited, or a monthly cap reached. | Response body (200): | Field | Type | Required | Description | | --- | --- | --- | --- | | `production` | object | yes | | | `production.id` | uuid | yes | | | `production.workspaceId` | uuid | yes | | | `production.stage` | enum | yes | One of: `queued`, `writing_pitch`, `pitch_ready`, `greenlit`, `making`, `exporting`, `delivered`, `skipped`, `declined`, `cancelled`, `failed`. | | `production.source` | object | yes | | | `production.source.kind` | enum | yes | One of: `app`, `api`, `mcp`. | | `production.source.externalId` | string \| null | yes | | | `production.source.origin` | object \| null | no | Default `null`. | | `production.source.origin.provider` | string | yes | | | `production.source.origin.label` | string | yes | | | `production.source.origin.url` | url \| null | no | Default `null`. | | `production.source.origin.ref` | string \| null | no | Default `null`. | | `production.source.origin.requestedBy` | string \| null | no | Default `null`. | | `production.brief` | object | yes | | | `production.brief.intent` | string | yes | | | `production.brief.context` | string \| null | no | Default `null`. | | `production.brief.media` | object[] | no | | | `production.brief.media[].id` | string | yes | | | `production.brief.media[].kind` | enum | yes | One of: `image`, `video`. | | `production.brief.media[].url` | url | yes | | | `production.brief.media[].name` | string | yes | | | `production.brief.media[].description` | string \| null | no | Default `null`. | | `production.brief.code` | object[] | no | | | `production.brief.code[].path` | string | yes | | | `production.brief.code[].source` | string | yes | | | `production.brief.code[].status` | enum \| null | no | One of: `added`, `modified`, `removed`, `renamed`. Default `null`. | | `production.brief.code[].additions` | integer | no | Default `0`. | | `production.brief.code[].deletions` | integer | no | Default `0`. | | `production.brief.code[].patch` | string \| null | no | Default `null`. | | `production.brief.code[].digest` | string \| null | no | Default `null`. | | `production.brief.code[].score` | number | no | Default `0`. | | `production.brief.code[].sourceOmitted` | boolean | no | | | `production.brief.format` | object | no | | | `production.brief.format.template` | enum | no | One of: `changelog`, `launch`, `feature`, `digest`, `custom`. Default `changelog`. | | `production.brief.format.durationMs` | integer | no | Default `10000`. | | `production.brief.format.aspect` | enum | no | One of: `16:9`, `4:3`, `3:2`, `21:9`, `1:1`, `9:16`, `3:4`, `2:3`, `4:5`. Default `16:9`. | | `production.brief.format.sound` | object | no | | | `production.brief.format.sound.voiceover` | boolean | no | Default `true`. | | `production.brief.format.sound.music` | boolean | no | Default `true`. | | `production.brief.format.sound.sfx` | boolean | no | Default `true`. | | `production.brief.brand` | object | no | | | `production.brief.brand.name` | string \| null | no | Default `null`. | | `production.brief.brand.logoUrl` | url \| null | no | Default `null`. | | `production.brief.brand.primaryColor` | string \| null | no | Default `null`. | | `production.brief.brand.backgroundColor` | string \| null | no | Default `null`. | | `production.brief.brand.fontFamily` | string \| null | no | Default `null`. | | `production.brief.brand.tone` | string \| null | no | Default `null`. | | `production.pitch` | object \| null | yes | | | `production.pitch.version` | integer | yes | | | `production.pitch.verdict` | enum | no | One of: `video`, `skip`. Default `video`. | | `production.pitch.verdictReason` | string \| null | no | Default `null`. | | `production.pitch.title` | string | yes | | | `production.pitch.summary` | string | yes | | | `production.pitch.shots` | object[] | yes | | | `production.pitch.shots[].index` | integer | yes | | | `production.pitch.shots[].title` | string | yes | | | `production.pitch.shots[].durationMs` | integer | yes | | | `production.pitch.shots[].beat` | string \| null | no | Default `null`. | | `production.pitch.shots[].visual` | string | yes | | | `production.pitch.shots[].text` | string \| null | no | Default `null`. | | `production.pitch.shots[].voiceover` | string \| null | no | Default `null`. | | `production.pitch.shots[].mediaIds` | string[] | no | | | `production.pitch.shots[].codePaths` | string[] | no | | | `production.pitch.music` | string \| null | no | Default `null`. | | `production.pitch.estimatedCredits` | integer | yes | | | `production.pitch.createdAt` | datetime | yes | | | `production.greenlight` | object \| null | yes | | | `production.greenlight.mode` | enum | yes | One of: `manual`, `auto`. | | `production.greenlight.actor` | string | yes | | | `production.greenlight.actorName` | string \| null | no | | | `production.greenlight.at` | datetime | yes | | | `production.greenlight.pitchVersion` | integer | yes | | | `production.greenlightMode` | enum | yes | One of: `manual`, `auto`. | | `production.delivery` | object \| null | yes | | | `production.delivery.shareUrl` | url | yes | | | `production.delivery.projectId` | uuid | yes | | | `production.delivery.editUrl` | url | yes | | | `production.delivery.exportId` | uuid | yes | | | `production.delivery.deliveredAt` | datetime | yes | | | `production.projectId` | uuid \| null | yes | | | `production.creditsCharged` | integer | yes | | | `production.error` | string \| null | yes | Why the production failed, in words you can show a person. Never the render machine's own diagnostics. | | `production.createdAt` | datetime | yes | | | `production.updatedAt` | datetime | yes | | | `production.terminal` | boolean | no | True once the stage is one the production never leaves (delivered, skipped, declined, cancelled, failed, or a terminal stage added later). Stop polling when it is true; it is computed by the server, so it stays right for stages your client does not know yet. Absent from payloads stored before the field existed. | | `production.pageUrl` | url | no | The production's page in the Raylight app. Absent from payloads stored before the field existed. | Example (curl): ```bash curl -X POST https://api.raylight.app/v1/productions/$ID/decline \ -H "Authorization: Bearer $RAYLIGHT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "reason": "Not this release." }' ``` ### POST /v1/productions/{id}/cancel **Cancel a production** Free before the greenlight. Refunded if greenlit but still waiting for a render machine. A production being made cannot be cancelled. Auth: `Authorization: Bearer rl_live_...` with the `productions` scope. Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | uuid | yes | The production id. | Responses: | Status | Meaning | | --- | --- | | 200 | The cancelled production. | | 400 | Invalid request. | | 401 | Missing, invalid or revoked key. | | 403 | The key lacks the scope, or the key's owner has view-only access to the workspace (code `forbidden`). | | 404 | Not found. | | 409 | The production is being made (code `stage`). | | 429 | Rate limited, or a monthly cap reached. | Response body (200): | Field | Type | Required | Description | | --- | --- | --- | --- | | `production` | object | yes | | | `production.id` | uuid | yes | | | `production.workspaceId` | uuid | yes | | | `production.stage` | enum | yes | One of: `queued`, `writing_pitch`, `pitch_ready`, `greenlit`, `making`, `exporting`, `delivered`, `skipped`, `declined`, `cancelled`, `failed`. | | `production.source` | object | yes | | | `production.source.kind` | enum | yes | One of: `app`, `api`, `mcp`. | | `production.source.externalId` | string \| null | yes | | | `production.source.origin` | object \| null | no | Default `null`. | | `production.source.origin.provider` | string | yes | | | `production.source.origin.label` | string | yes | | | `production.source.origin.url` | url \| null | no | Default `null`. | | `production.source.origin.ref` | string \| null | no | Default `null`. | | `production.source.origin.requestedBy` | string \| null | no | Default `null`. | | `production.brief` | object | yes | | | `production.brief.intent` | string | yes | | | `production.brief.context` | string \| null | no | Default `null`. | | `production.brief.media` | object[] | no | | | `production.brief.media[].id` | string | yes | | | `production.brief.media[].kind` | enum | yes | One of: `image`, `video`. | | `production.brief.media[].url` | url | yes | | | `production.brief.media[].name` | string | yes | | | `production.brief.media[].description` | string \| null | no | Default `null`. | | `production.brief.code` | object[] | no | | | `production.brief.code[].path` | string | yes | | | `production.brief.code[].source` | string | yes | | | `production.brief.code[].status` | enum \| null | no | One of: `added`, `modified`, `removed`, `renamed`. Default `null`. | | `production.brief.code[].additions` | integer | no | Default `0`. | | `production.brief.code[].deletions` | integer | no | Default `0`. | | `production.brief.code[].patch` | string \| null | no | Default `null`. | | `production.brief.code[].digest` | string \| null | no | Default `null`. | | `production.brief.code[].score` | number | no | Default `0`. | | `production.brief.code[].sourceOmitted` | boolean | no | | | `production.brief.format` | object | no | | | `production.brief.format.template` | enum | no | One of: `changelog`, `launch`, `feature`, `digest`, `custom`. Default `changelog`. | | `production.brief.format.durationMs` | integer | no | Default `10000`. | | `production.brief.format.aspect` | enum | no | One of: `16:9`, `4:3`, `3:2`, `21:9`, `1:1`, `9:16`, `3:4`, `2:3`, `4:5`. Default `16:9`. | | `production.brief.format.sound` | object | no | | | `production.brief.format.sound.voiceover` | boolean | no | Default `true`. | | `production.brief.format.sound.music` | boolean | no | Default `true`. | | `production.brief.format.sound.sfx` | boolean | no | Default `true`. | | `production.brief.brand` | object | no | | | `production.brief.brand.name` | string \| null | no | Default `null`. | | `production.brief.brand.logoUrl` | url \| null | no | Default `null`. | | `production.brief.brand.primaryColor` | string \| null | no | Default `null`. | | `production.brief.brand.backgroundColor` | string \| null | no | Default `null`. | | `production.brief.brand.fontFamily` | string \| null | no | Default `null`. | | `production.brief.brand.tone` | string \| null | no | Default `null`. | | `production.pitch` | object \| null | yes | | | `production.pitch.version` | integer | yes | | | `production.pitch.verdict` | enum | no | One of: `video`, `skip`. Default `video`. | | `production.pitch.verdictReason` | string \| null | no | Default `null`. | | `production.pitch.title` | string | yes | | | `production.pitch.summary` | string | yes | | | `production.pitch.shots` | object[] | yes | | | `production.pitch.shots[].index` | integer | yes | | | `production.pitch.shots[].title` | string | yes | | | `production.pitch.shots[].durationMs` | integer | yes | | | `production.pitch.shots[].beat` | string \| null | no | Default `null`. | | `production.pitch.shots[].visual` | string | yes | | | `production.pitch.shots[].text` | string \| null | no | Default `null`. | | `production.pitch.shots[].voiceover` | string \| null | no | Default `null`. | | `production.pitch.shots[].mediaIds` | string[] | no | | | `production.pitch.shots[].codePaths` | string[] | no | | | `production.pitch.music` | string \| null | no | Default `null`. | | `production.pitch.estimatedCredits` | integer | yes | | | `production.pitch.createdAt` | datetime | yes | | | `production.greenlight` | object \| null | yes | | | `production.greenlight.mode` | enum | yes | One of: `manual`, `auto`. | | `production.greenlight.actor` | string | yes | | | `production.greenlight.actorName` | string \| null | no | | | `production.greenlight.at` | datetime | yes | | | `production.greenlight.pitchVersion` | integer | yes | | | `production.greenlightMode` | enum | yes | One of: `manual`, `auto`. | | `production.delivery` | object \| null | yes | | | `production.delivery.shareUrl` | url | yes | | | `production.delivery.projectId` | uuid | yes | | | `production.delivery.editUrl` | url | yes | | | `production.delivery.exportId` | uuid | yes | | | `production.delivery.deliveredAt` | datetime | yes | | | `production.projectId` | uuid \| null | yes | | | `production.creditsCharged` | integer | yes | | | `production.error` | string \| null | yes | Why the production failed, in words you can show a person. Never the render machine's own diagnostics. | | `production.createdAt` | datetime | yes | | | `production.updatedAt` | datetime | yes | | | `production.terminal` | boolean | no | True once the stage is one the production never leaves (delivered, skipped, declined, cancelled, failed, or a terminal stage added later). Stop polling when it is true; it is computed by the server, so it stays right for stages your client does not know yet. Absent from payloads stored before the field existed. | | `production.pageUrl` | url | no | The production's page in the Raylight app. Absent from payloads stored before the field existed. | Example (curl): ```bash curl -X POST https://api.raylight.app/v1/productions/$ID/cancel \ -H "Authorization: Bearer $RAYLIGHT_API_KEY" ``` ## Webhooks Production events pushed to your URL. ### GET /v1/webhooks **List webhook endpoints** Auth: `Authorization: Bearer rl_live_...` with the `productions` scope. Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `workspaceId` | query | uuid | no | | Responses: | Status | Meaning | | --- | --- | | 200 | The workspace's endpoints. | | 400 | Invalid request. | | 401 | Missing, invalid or revoked key. | | 403 | The key lacks the `productions` scope (code `api_key_missing_scope`), or its owner has view-only access to the workspace (code `forbidden`). | | 404 | No such endpoint or workspace for this key (code `not_found`). | | 429 | Rate limited, or a monthly cap reached. | | 503 | Webhooks are not switched on for this server (code `disabled`). Every webhook route answers this while they are off. | Response body (200): | Field | Type | Required | Description | | --- | --- | --- | --- | | `endpoints` | object[] | yes | | | `endpoints[].id` | uuid | yes | | | `endpoints[].workspaceId` | uuid | yes | | | `endpoints[].url` | url | yes | | | `endpoints[].description` | string \| null | yes | | | `endpoints[].events` | enum[] | yes | | | `endpoints[].enabled` | boolean | yes | | | `endpoints[].disabledReason` | string \| null | yes | | | `endpoints[].consecutiveFailures` | integer | yes | | | `endpoints[].lastAttemptAt` | datetime \| null | yes | | | `endpoints[].lastSuccessAt` | datetime \| null | yes | | | `endpoints[].previousSecretExpiresAt` | datetime \| null | yes | | | `endpoints[].createdAt` | datetime | yes | | | `endpoints[].updatedAt` | datetime | yes | | Example (curl): ```bash curl https://api.raylight.app/v1/webhooks \ -H "Authorization: Bearer $RAYLIGHT_API_KEY" ``` ### POST /v1/webhooks **Create a webhook endpoint** Registers an https URL. The signing secret is returned once and cannot be read again. Every delivery is signed with `Raylight-Signature: t=,v1=.">` and carries `Raylight-Event-Id` (stable across retries) and `Raylight-Event-Type`. Failed deliveries retry at 30s, 2m, 10m, 1h and 6h; after 20 exhausted deliveries in a row the endpoint is switched off and the workspace owner emailed. Auth: `Authorization: Bearer rl_live_...` with the `productions` scope. Request body (application/json): | Field | Type | Required | Description | | --- | --- | --- | --- | | `workspaceId` | uuid | no | | | `url` | url | yes | | | `description` | string | no | | | `events` | enum[] | no | | Responses: | Status | Meaning | | --- | --- | | 201 | The endpoint with its secret. | | 400 | Invalid request. | | 401 | Missing, invalid or revoked key. | | 403 | The key lacks the `productions` scope (code `api_key_missing_scope`), or its owner has view-only access to the workspace (code `forbidden`). | | 404 | No such endpoint or workspace for this key (code `not_found`). | | 409 | The workspace has reached its endpoint limit. | | 429 | Rate limited, or a monthly cap reached. | | 503 | Webhooks are not switched on for this server (code `disabled`). Every webhook route answers this while they are off. | Response body (201): | Field | Type | Required | Description | | --- | --- | --- | --- | | `endpoint` | object | yes | | | `endpoint.id` | uuid | yes | | | `endpoint.workspaceId` | uuid | yes | | | `endpoint.url` | url | yes | | | `endpoint.description` | string \| null | yes | | | `endpoint.events` | enum[] | yes | | | `endpoint.enabled` | boolean | yes | | | `endpoint.disabledReason` | string \| null | yes | | | `endpoint.consecutiveFailures` | integer | yes | | | `endpoint.lastAttemptAt` | datetime \| null | yes | | | `endpoint.lastSuccessAt` | datetime \| null | yes | | | `endpoint.previousSecretExpiresAt` | datetime \| null | yes | | | `endpoint.createdAt` | datetime | yes | | | `endpoint.updatedAt` | datetime | yes | | | `endpoint.secret` | string | yes | | Example (curl): ```bash curl -X POST https://api.raylight.app/v1/webhooks \ -H "Authorization: Bearer $RAYLIGHT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://hooks.acme.dev/raylight", "description": "Slack relay", "events": ["production.pitch_ready", "production.delivered", "production.failed"] }' ``` ### GET /v1/webhooks/{id} **Read a webhook endpoint** Auth: `Authorization: Bearer rl_live_...` with the `productions` scope. Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | uuid | yes | The endpoint id. | Responses: | Status | Meaning | | --- | --- | | 200 | The endpoint. | | 400 | Invalid request. | | 401 | Missing, invalid or revoked key. | | 403 | The key lacks the `productions` scope (code `api_key_missing_scope`), or its owner has view-only access to the workspace (code `forbidden`). | | 404 | No such endpoint or workspace for this key (code `not_found`). | | 429 | Rate limited, or a monthly cap reached. | | 503 | Webhooks are not switched on for this server (code `disabled`). Every webhook route answers this while they are off. | Response body (200): | Field | Type | Required | Description | | --- | --- | --- | --- | | `endpoint` | object | yes | | | `endpoint.id` | uuid | yes | | | `endpoint.workspaceId` | uuid | yes | | | `endpoint.url` | url | yes | | | `endpoint.description` | string \| null | yes | | | `endpoint.events` | enum[] | yes | | | `endpoint.enabled` | boolean | yes | | | `endpoint.disabledReason` | string \| null | yes | | | `endpoint.consecutiveFailures` | integer | yes | | | `endpoint.lastAttemptAt` | datetime \| null | yes | | | `endpoint.lastSuccessAt` | datetime \| null | yes | | | `endpoint.previousSecretExpiresAt` | datetime \| null | yes | | | `endpoint.createdAt` | datetime | yes | | | `endpoint.updatedAt` | datetime | yes | | Example (curl): ```bash curl https://api.raylight.app/v1/webhooks/$ID \ -H "Authorization: Bearer $RAYLIGHT_API_KEY" ``` ### PATCH /v1/webhooks/{id} **Update a webhook endpoint** Auth: `Authorization: Bearer rl_live_...` with the `productions` scope. Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | uuid | yes | The endpoint id. | Request body (application/json): | Field | Type | Required | Description | | --- | --- | --- | --- | | `url` | url | no | | | `description` | string \| null | no | | | `events` | enum[] | no | | | `enabled` | boolean | no | | Responses: | Status | Meaning | | --- | --- | | 200 | The updated endpoint. | | 400 | Invalid request. | | 401 | Missing, invalid or revoked key. | | 403 | The key lacks the `productions` scope (code `api_key_missing_scope`), or its owner has view-only access to the workspace (code `forbidden`). | | 404 | No such endpoint or workspace for this key (code `not_found`). | | 429 | Rate limited, or a monthly cap reached. | | 503 | Webhooks are not switched on for this server (code `disabled`). Every webhook route answers this while they are off. | Response body (200): | Field | Type | Required | Description | | --- | --- | --- | --- | | `endpoint` | object | yes | | | `endpoint.id` | uuid | yes | | | `endpoint.workspaceId` | uuid | yes | | | `endpoint.url` | url | yes | | | `endpoint.description` | string \| null | yes | | | `endpoint.events` | enum[] | yes | | | `endpoint.enabled` | boolean | yes | | | `endpoint.disabledReason` | string \| null | yes | | | `endpoint.consecutiveFailures` | integer | yes | | | `endpoint.lastAttemptAt` | datetime \| null | yes | | | `endpoint.lastSuccessAt` | datetime \| null | yes | | | `endpoint.previousSecretExpiresAt` | datetime \| null | yes | | | `endpoint.createdAt` | datetime | yes | | | `endpoint.updatedAt` | datetime | yes | | Example (curl): ```bash curl -X PATCH https://api.raylight.app/v1/webhooks/$ID \ -H "Authorization: Bearer $RAYLIGHT_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "enabled": true }' ``` ### DELETE /v1/webhooks/{id} **Delete a webhook endpoint** Auth: `Authorization: Bearer rl_live_...` with the `productions` scope. Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | uuid | yes | The endpoint id. | Responses: | Status | Meaning | | --- | --- | | 204 | Deleted. | | 400 | Invalid request. | | 401 | Missing, invalid or revoked key. | | 403 | The key lacks the `productions` scope (code `api_key_missing_scope`), or its owner has view-only access to the workspace (code `forbidden`). | | 404 | No such endpoint or workspace for this key (code `not_found`). | | 429 | Rate limited, or a monthly cap reached. | | 503 | Webhooks are not switched on for this server (code `disabled`). Every webhook route answers this while they are off. | Example (curl): ```bash curl -X DELETE https://api.raylight.app/v1/webhooks/$ID \ -H "Authorization: Bearer $RAYLIGHT_API_KEY" ``` ### POST /v1/webhooks/{id}/roll-secret **Roll the signing secret** Mints a new secret. The old one keeps verifying for 24 hours, during which deliveries carry two `v1` signatures and `previousSecretExpiresAt` says when it stops. If Raylight could no longer read the old secret (the delivery log says so), it is dropped at once instead: deliveries carry only the new signature and `previousSecretExpiresAt` is null. Auth: `Authorization: Bearer rl_live_...` with the `productions` scope. Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | uuid | yes | The endpoint id. | Responses: | Status | Meaning | | --- | --- | | 200 | The endpoint with its new secret. | | 400 | Invalid request. | | 401 | Missing, invalid or revoked key. | | 403 | The key lacks the `productions` scope (code `api_key_missing_scope`), or its owner has view-only access to the workspace (code `forbidden`). | | 404 | No such endpoint or workspace for this key (code `not_found`). | | 409 | The secret was rolled somewhere else at the same moment (code `conflict`). Nothing changed from this call; try again. | | 429 | Rate limited, or a monthly cap reached. | | 503 | Webhooks are not switched on for this server (code `disabled`). Every webhook route answers this while they are off. | Response body (200): | Field | Type | Required | Description | | --- | --- | --- | --- | | `endpoint` | object | yes | | | `endpoint.id` | uuid | yes | | | `endpoint.workspaceId` | uuid | yes | | | `endpoint.url` | url | yes | | | `endpoint.description` | string \| null | yes | | | `endpoint.events` | enum[] | yes | | | `endpoint.enabled` | boolean | yes | | | `endpoint.disabledReason` | string \| null | yes | | | `endpoint.consecutiveFailures` | integer | yes | | | `endpoint.lastAttemptAt` | datetime \| null | yes | | | `endpoint.lastSuccessAt` | datetime \| null | yes | | | `endpoint.previousSecretExpiresAt` | datetime \| null | yes | | | `endpoint.createdAt` | datetime | yes | | | `endpoint.updatedAt` | datetime | yes | | | `endpoint.secret` | string | yes | | Example (curl): ```bash curl -X POST https://api.raylight.app/v1/webhooks/$ID/roll-secret \ -H "Authorization: Bearer $RAYLIGHT_API_KEY" ``` ### POST /v1/webhooks/{id}/test **Send a test event** Queues a signed `ping` to test your receiver and signature check. Auth: `Authorization: Bearer rl_live_...` with the `productions` scope. Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | uuid | yes | The endpoint id. | Responses: | Status | Meaning | | --- | --- | | 202 | The queued delivery. | | 400 | Invalid request. | | 401 | Missing, invalid or revoked key. | | 403 | The key lacks the `productions` scope (code `api_key_missing_scope`), or its owner has view-only access to the workspace (code `forbidden`). | | 404 | No such endpoint or workspace for this key (code `not_found`). | | 409 | The endpoint is switched off (code `endpoint_disabled`), or points at localhost on a server that cannot reach it (code `wrong_server`). | | 429 | Rate limited, or a monthly cap reached. | | 503 | Webhooks are not switched on for this server (code `disabled`). Every webhook route answers this while they are off. | Response body (202): | Field | Type | Required | Description | | --- | --- | --- | --- | | `delivery` | object | yes | | | `delivery.id` | uuid | yes | | | `delivery.endpointId` | uuid | yes | | | `delivery.productionId` | uuid \| null | yes | | | `delivery.event` | enum | yes | One of: `production.pitch_ready`, `production.skipped`, `production.greenlit`, `production.progress`, `production.delivered`, `production.failed`, `production.declined`, `production.cancelled`, `ping`. | | `delivery.status` | enum | yes | One of: `pending`, `succeeded`, `exhausted`. | | `delivery.attempts` | integer | yes | | | `delivery.nextAttemptAt` | datetime \| null | yes | | | `delivery.lastResponseStatus` | integer \| null | yes | | | `delivery.lastResponseBody` | string \| null | yes | | | `delivery.lastError` | string \| null | yes | | | `delivery.lastAttemptAt` | datetime \| null | yes | | | `delivery.deliveredAt` | datetime \| null | yes | | | `delivery.createdAt` | datetime | yes | | Example (curl): ```bash curl -X POST https://api.raylight.app/v1/webhooks/$ID/test \ -H "Authorization: Bearer $RAYLIGHT_API_KEY" ``` ### GET /v1/webhooks/{id}/deliveries **List deliveries** Auth: `Authorization: Bearer rl_live_...` with the `productions` scope. Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | uuid | yes | The endpoint id. | | `limit` | query | integer | no | | Responses: | Status | Meaning | | --- | --- | | 200 | Deliveries, newest first. | | 400 | Invalid request. | | 401 | Missing, invalid or revoked key. | | 403 | The key lacks the `productions` scope (code `api_key_missing_scope`), or its owner has view-only access to the workspace (code `forbidden`). | | 404 | No such endpoint or workspace for this key (code `not_found`). | | 429 | Rate limited, or a monthly cap reached. | | 503 | Webhooks are not switched on for this server (code `disabled`). Every webhook route answers this while they are off. | Response body (200): | Field | Type | Required | Description | | --- | --- | --- | --- | | `deliveries` | object[] | yes | | | `deliveries[].id` | uuid | yes | | | `deliveries[].endpointId` | uuid | yes | | | `deliveries[].productionId` | uuid \| null | yes | | | `deliveries[].event` | enum | yes | One of: `production.pitch_ready`, `production.skipped`, `production.greenlit`, `production.progress`, `production.delivered`, `production.failed`, `production.declined`, `production.cancelled`, `ping`. | | `deliveries[].status` | enum | yes | One of: `pending`, `succeeded`, `exhausted`. | | `deliveries[].attempts` | integer | yes | | | `deliveries[].nextAttemptAt` | datetime \| null | yes | | | `deliveries[].lastResponseStatus` | integer \| null | yes | | | `deliveries[].lastResponseBody` | string \| null | yes | | | `deliveries[].lastError` | string \| null | yes | | | `deliveries[].lastAttemptAt` | datetime \| null | yes | | | `deliveries[].deliveredAt` | datetime \| null | yes | | | `deliveries[].createdAt` | datetime | yes | | Example (curl): ```bash curl https://api.raylight.app/v1/webhooks/$ID/deliveries \ -H "Authorization: Bearer $RAYLIGHT_API_KEY" ``` ### POST /v1/webhooks/{id}/deliveries/{deliveryId}/redeliver **Redeliver an event** Queues a copy of a delivery's event with a new `id` and `redeliveryOf` set to the original event's id. Auth: `Authorization: Bearer rl_live_...` with the `productions` scope. Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | uuid | yes | The endpoint id. | | `deliveryId` | path | uuid | yes | The delivery to copy. | Responses: | Status | Meaning | | --- | --- | | 202 | The new delivery. | | 400 | Invalid request. | | 401 | Missing, invalid or revoked key. | | 403 | The key lacks the `productions` scope (code `api_key_missing_scope`), or its owner has view-only access to the workspace (code `forbidden`). | | 404 | No such endpoint or workspace for this key (code `not_found`). | | 409 | The endpoint is switched off (code `endpoint_disabled`), or points at localhost on a server that cannot reach it (code `wrong_server`). | | 429 | Rate limited, or a monthly cap reached. | | 503 | Webhooks are not switched on for this server (code `disabled`). Every webhook route answers this while they are off. | Response body (202): | Field | Type | Required | Description | | --- | --- | --- | --- | | `delivery` | object | yes | | | `delivery.id` | uuid | yes | | | `delivery.endpointId` | uuid | yes | | | `delivery.productionId` | uuid \| null | yes | | | `delivery.event` | enum | yes | One of: `production.pitch_ready`, `production.skipped`, `production.greenlit`, `production.progress`, `production.delivered`, `production.failed`, `production.declined`, `production.cancelled`, `ping`. | | `delivery.status` | enum | yes | One of: `pending`, `succeeded`, `exhausted`. | | `delivery.attempts` | integer | yes | | | `delivery.nextAttemptAt` | datetime \| null | yes | | | `delivery.lastResponseStatus` | integer \| null | yes | | | `delivery.lastResponseBody` | string \| null | yes | | | `delivery.lastError` | string \| null | yes | | | `delivery.lastAttemptAt` | datetime \| null | yes | | | `delivery.deliveredAt` | datetime \| null | yes | | | `delivery.createdAt` | datetime | yes | | Example (curl): ```bash curl -X POST https://api.raylight.app/v1/webhooks/$ID/deliveries/$DELIVERY_ID/redeliver \ -H "Authorization: Bearer $RAYLIGHT_API_KEY" ``` ### POST your endpoint URL **A production event, delivered to your endpoint** Raylight POSTs one event per delivery to each subscribed endpoint. Verify `Raylight-Signature` against the raw body before parsing (`t=,v1=.">`, two `v1` entries while a rolled secret is live, 5 minute tolerance). Dedupe on `Raylight-Event-Id`. `data.production` is the production as the API returns it, except that every code file in the brief has `source` and `patch` emptied and carries `sourceOmitted: true`; read the production over the API for the full files. A redelivered event has its own `id` and `redeliveryOf` naming the original. New event types and enum values are added without a version bump; acknowledge types you do not know with a 2xx. Auth: Raylight signs the request with your endpoint's secret in `Raylight-Signature`. Verify it before trusting the body. Headers: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `raylight-signature` | header | string | yes | t=,v1=[,v1=...] | | `raylight-event-id` | header | string | yes | Stable across retries; dedupe on it. | | `raylight-event-type` | header | string | yes | The payload's `type`. | Payload (application/json): | Field | Type | Required | Description | | --- | --- | --- | --- | | `id` | uuid | yes | | | `type` | enum | yes | One of: `production.pitch_ready`, `production.skipped`, `production.greenlit`, `production.progress`, `production.delivered`, `production.failed`, `production.declined`, `production.cancelled`, `ping`. | | `createdAt` | datetime | yes | | | `workspaceId` | uuid | yes | | | `redeliveryOf` | uuid | no | | | `data` | object | yes | | | `data.production` | object | no | | | `data.production.id` | uuid | yes | | | `data.production.workspaceId` | uuid | yes | | | `data.production.stage` | enum | yes | One of: `queued`, `writing_pitch`, `pitch_ready`, `greenlit`, `making`, `exporting`, `delivered`, `skipped`, `declined`, `cancelled`, `failed`. | | `data.production.source` | object | yes | | | `data.production.source.kind` | enum | yes | One of: `app`, `api`, `mcp`. | | `data.production.source.externalId` | string \| null | yes | | | `data.production.source.origin` | object \| null | no | Default `null`. | | `data.production.source.origin.provider` | string | yes | | | `data.production.source.origin.label` | string | yes | | | `data.production.source.origin.url` | url \| null | no | Default `null`. | | `data.production.source.origin.ref` | string \| null | no | Default `null`. | | `data.production.source.origin.requestedBy` | string \| null | no | Default `null`. | | `data.production.brief` | object | yes | | | `data.production.brief.intent` | string | yes | | | `data.production.brief.context` | string \| null | no | Default `null`. | | `data.production.brief.media` | object[] | no | | | `data.production.brief.media[].id` | string | yes | | | `data.production.brief.media[].kind` | enum | yes | One of: `image`, `video`. | | `data.production.brief.media[].url` | url | yes | | | `data.production.brief.media[].name` | string | yes | | | `data.production.brief.media[].description` | string \| null | no | Default `null`. | | `data.production.brief.code` | object[] | no | | | `data.production.brief.code[].path` | string | yes | | | `data.production.brief.code[].source` | string | yes | | | `data.production.brief.code[].status` | enum \| null | no | One of: `added`, `modified`, `removed`, `renamed`. Default `null`. | | `data.production.brief.code[].additions` | integer | no | Default `0`. | | `data.production.brief.code[].deletions` | integer | no | Default `0`. | | `data.production.brief.code[].patch` | string \| null | no | Default `null`. | | `data.production.brief.code[].digest` | string \| null | no | Default `null`. | | `data.production.brief.code[].score` | number | no | Default `0`. | | `data.production.brief.code[].sourceOmitted` | boolean | no | | | `data.production.brief.format` | object | no | | | `data.production.brief.format.template` | enum | no | One of: `changelog`, `launch`, `feature`, `digest`, `custom`. Default `changelog`. | | `data.production.brief.format.durationMs` | integer | no | Default `10000`. | | `data.production.brief.format.aspect` | enum | no | One of: `16:9`, `4:3`, `3:2`, `21:9`, `1:1`, `9:16`, `3:4`, `2:3`, `4:5`. Default `16:9`. | | `data.production.brief.format.sound` | object | no | | | `data.production.brief.brand` | object | no | | | `data.production.brief.brand.name` | string \| null | no | Default `null`. | | `data.production.brief.brand.logoUrl` | url \| null | no | Default `null`. | | `data.production.brief.brand.primaryColor` | string \| null | no | Default `null`. | | `data.production.brief.brand.backgroundColor` | string \| null | no | Default `null`. | | `data.production.brief.brand.fontFamily` | string \| null | no | Default `null`. | | `data.production.brief.brand.tone` | string \| null | no | Default `null`. | | `data.production.pitch` | object \| null | yes | | | `data.production.pitch.version` | integer | yes | | | `data.production.pitch.verdict` | enum | no | One of: `video`, `skip`. Default `video`. | | `data.production.pitch.verdictReason` | string \| null | no | Default `null`. | | `data.production.pitch.title` | string | yes | | | `data.production.pitch.summary` | string | yes | | | `data.production.pitch.shots` | object[] | yes | | | `data.production.pitch.shots[].index` | integer | yes | | | `data.production.pitch.shots[].title` | string | yes | | | `data.production.pitch.shots[].durationMs` | integer | yes | | | `data.production.pitch.shots[].beat` | string \| null | no | Default `null`. | | `data.production.pitch.shots[].visual` | string | yes | | | `data.production.pitch.shots[].text` | string \| null | no | Default `null`. | | `data.production.pitch.shots[].voiceover` | string \| null | no | Default `null`. | | `data.production.pitch.shots[].mediaIds` | string[] | no | | | `data.production.pitch.shots[].codePaths` | string[] | no | | | `data.production.pitch.music` | string \| null | no | Default `null`. | | `data.production.pitch.estimatedCredits` | integer | yes | | | `data.production.pitch.createdAt` | datetime | yes | | | `data.production.greenlight` | object \| null | yes | | | `data.production.greenlight.mode` | enum | yes | One of: `manual`, `auto`. | | `data.production.greenlight.actor` | string | yes | | | `data.production.greenlight.actorName` | string \| null | no | | | `data.production.greenlight.at` | datetime | yes | | | `data.production.greenlight.pitchVersion` | integer | yes | | | `data.production.greenlightMode` | enum | yes | One of: `manual`, `auto`. | | `data.production.delivery` | object \| null | yes | | | `data.production.delivery.shareUrl` | url | yes | | | `data.production.delivery.projectId` | uuid | yes | | | `data.production.delivery.editUrl` | url | yes | | | `data.production.delivery.exportId` | uuid | yes | | | `data.production.delivery.deliveredAt` | datetime | yes | | | `data.production.projectId` | uuid \| null | yes | | | `data.production.creditsCharged` | integer | yes | | | `data.production.error` | string \| null | yes | | | `data.production.createdAt` | datetime | yes | | | `data.production.updatedAt` | datetime | yes | | | `data.production.terminal` | boolean | no | | | `data.production.pageUrl` | url | no | | | `data.pitchMarkdown` | string \| null | no | | | `data.status` | string \| null | no | | | `data.links` | object | no | | | `data.links.page` | url | yes | | | `data.links.greenlight` | url \| null | yes | | | `data.links.comment` | url \| null | yes | | | `data.links.decline` | url \| null | yes | | | `data.links.share` | url \| null | yes | | | `data.links.edit` | url \| null | yes | | | `data.endpointId` | uuid | no | | | `data.message` | string | no | | Your answer: | Status | Meaning | | --- | --- | | 200 | Any 2xx acknowledges the delivery. Anything else is retried at 30s, 2m, 10m, 1h and 6h. | Example (production.pitch_ready): ```json { "id": "c1d2e3f4-5a6b-4c7d-8e9f-0a1b2c3d4e5f", "type": "production.pitch_ready", "createdAt": "2026-10-17T18:04:12.000Z", "workspaceId": "0b9d8c7a-6e5f-4d3c-ab1a-09f8e7d6c5b4", "data": { "production": { "id": "6f1c2b7e-3d4a-4c8e-9a2b-1f0e5d6c7b8a", "workspaceId": "0b9d8c7a-6e5f-4d3c-ab1a-09f8e7d6c5b4", "stage": "pitch_ready", "source": { "kind": "api", "externalId": "acme/web#412", "origin": { "provider": "github", "label": "acme/web #412", "url": "https://github.com/acme/web/pull/412", "ref": "feat/dark-mode", "requestedBy": "jane" } }, "brief": { "intent": "Announce dark mode in the Acme dashboard to existing customers.", "context": "PR #412 adds a theme toggle to Settings. Charts, tables and the sidebar all pick up the dark palette. Ships to everyone on Thursday.", "media": [ { "id": "dashboard-dark", "kind": "image", "url": "https://cdn.acme.dev/press/dashboard-dark.png", "name": "Dashboard in dark mode", "description": "The analytics overview with the new dark theme." } ], "code": [ { "path": "src/components/settings/theme-toggle.tsx", "source": "", "status": "modified", "additions": 48, "deletions": 9, "patch": null, "sourceOmitted": true, "digest": "ThemeToggle: a pill switch with sun and moon icons, label \"Appearance\", brand purple when on.", "score": 0.91 } ], "format": { "template": "feature", "durationMs": 10000, "aspect": "16:9", "sound": { "voiceover": true, "music": true, "sfx": true } }, "brand": { "name": "Acme", "logoUrl": null, "primaryColor": "#6C5CE7", "backgroundColor": null, "fontFamily": null, "tone": "confident, plain" } }, "pitch": { "version": 1, "verdict": "video", "verdictReason": null, "title": "Dark mode, finally", "summary": "A 10 second feature spot: the toggle flips, the dashboard follows, and an end card says it ships Thursday.", "shots": [ { "index": 0, "title": "The flip", "durationMs": 3000, "beat": "The theme toggle flips and the whole settings page goes dark.", "visual": "The ThemeToggle component rebuilt as layers at hero scale, centred, a slow push-in. On the flip the background sweeps from white to the brand dark.", "text": "Dark mode", "voiceover": "Dark mode is here.", "mediaIds": [], "codePaths": ["src/components/settings/theme-toggle.tsx"] }, { "index": 1, "title": "The dashboard", "durationMs": 4500, "beat": "The analytics overview in dark, tilted, drifting left.", "visual": "dashboard-dark on a device frame, 12 degree tilt, a lateral drift with a soft depth of field.", "text": "Every chart. Every table.", "voiceover": "Charts, tables and the sidebar all follow.", "mediaIds": ["dashboard-dark"], "codePaths": [] }, { "index": 2, "title": "Sign-off", "durationMs": 2500, "beat": "The Acme logo on the dark background with the ship date.", "visual": "Logo centred on the brand dark, the date types on underneath, a short hold before the cut.", "text": "Thursday, for everyone", "voiceover": "Turn it on in Settings, Thursday.", "mediaIds": [], "codePaths": [] } ], "music": "Low, steady, electronic. No drop.", "estimatedCredits": 600, "createdAt": "2026-10-17T18:04:11.000Z" }, "greenlight": null, "greenlightMode": "manual", "delivery": null, "projectId": null, "creditsCharged": 0, "error": null, "createdAt": "2026-10-17T18:02:40.000Z", "updatedAt": "2026-10-17T18:04:11.000Z", "terminal": false, "pageUrl": "https://www.raylight.app/api/productions/6f1c2b7e-3d4a-4c8e-9a2b-1f0e5d6c7b8a" }, "pitchMarkdown": "## Dark mode, finally\n\nA 10 second feature spot ...", "status": null, "links": { "page": "https://www.raylight.app/api/productions/6f1c2b7e-3d4a-4c8e-9a2b-1f0e5d6c7b8a", "greenlight": "https://www.raylight.app/go/v1.eyJqIjoiNmYxYzJiN2UiLCJ2IjoxfQ.q8Zt3VbY1mK0?action=greenlight", "comment": "https://www.raylight.app/go/v1.eyJqIjoiNmYxYzJiN2UiLCJ2IjoxfQ.q8Zt3VbY1mK0?action=comment", "decline": "https://www.raylight.app/go/v1.eyJqIjoiNmYxYzJiN2UiLCJ2IjoxfQ.q8Zt3VbY1mK0?action=decline", "share": null, "edit": null } } } ``` ## Greenlight links Login-free Greenlight / Send comments / Decline. ### GET /production-links/{token} **Read a signed greenlight link** Public. What the link points at and whether it can still act (`state`: open, stale, expired). An expired link still reads with 200 and `state: expired`. Links arrive in emails and in webhook payloads under `data.links`. Auth: none (public). Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `token` | path | string | yes | The signed token from the link. | Responses: | Status | Meaning | | --- | --- | | 200 | The pitch behind the link. | | 403 | The link was sent to someone who can no longer greenlight in this workspace, or to a webhook endpoint that was deleted (code `revoked`). | | 404 | Not a valid link (code `not_found`). | | 429 | Rate limited (30 a minute per address), or the workspace reached a monthly cap (code `cap`). | | 503 | Greenlight links are not switched on for this server (code `disabled`). | Response body (200): | Field | Type | Required | Description | | --- | --- | --- | --- | | `link` | object | yes | | | `link.productionId` | uuid | yes | | | `link.workspaceName` | string | yes | | | `link.stage` | enum | yes | One of: `queued`, `writing_pitch`, `pitch_ready`, `greenlit`, `making`, `exporting`, `delivered`, `skipped`, `declined`, `cancelled`, `failed`. | | `link.state` | enum | yes | One of: `open`, `stale`, `expired`. | | `link.pitchVersion` | integer | yes | | | `link.currentPitchVersion` | integer | yes | | | `link.intent` | string | yes | | | `link.origin` | object \| null | yes | | | `link.origin.provider` | string | yes | | | `link.origin.label` | string | yes | | | `link.origin.url` | url \| null | no | Default `null`. | | `link.origin.ref` | string \| null | no | Default `null`. | | `link.origin.requestedBy` | string \| null | no | Default `null`. | | `link.format` | object | yes | | | `link.format.template` | enum | no | One of: `changelog`, `launch`, `feature`, `digest`, `custom`. Default `changelog`. | | `link.format.durationMs` | integer | no | Default `10000`. | | `link.format.aspect` | enum | no | One of: `16:9`, `4:3`, `3:2`, `21:9`, `1:1`, `9:16`, `3:4`, `2:3`, `4:5`. Default `16:9`. | | `link.format.sound` | object | no | | | `link.format.sound.voiceover` | boolean | no | Default `true`. | | `link.format.sound.music` | boolean | no | Default `true`. | | `link.format.sound.sfx` | boolean | no | Default `true`. | | `link.pitch` | object \| null | yes | | | `link.pitch.version` | integer | yes | | | `link.pitch.verdict` | enum | no | One of: `video`, `skip`. Default `video`. | | `link.pitch.verdictReason` | string \| null | no | Default `null`. | | `link.pitch.title` | string | yes | | | `link.pitch.summary` | string | yes | | | `link.pitch.shots` | object[] | yes | | | `link.pitch.shots[].index` | integer | yes | | | `link.pitch.shots[].title` | string | yes | | | `link.pitch.shots[].durationMs` | integer | yes | | | `link.pitch.shots[].beat` | string \| null | no | Default `null`. | | `link.pitch.shots[].visual` | string | yes | | | `link.pitch.shots[].text` | string \| null | no | Default `null`. | | `link.pitch.shots[].voiceover` | string \| null | no | Default `null`. | | `link.pitch.shots[].mediaIds` | string[] | no | | | `link.pitch.shots[].codePaths` | string[] | no | | | `link.pitch.music` | string \| null | no | Default `null`. | | `link.pitch.estimatedCredits` | integer | yes | | | `link.pitch.createdAt` | datetime | yes | | | `link.pitchMarkdown` | string \| null | yes | | | `link.delivery` | object \| null | yes | | | `link.delivery.shareUrl` | url | yes | | | `link.delivery.projectId` | uuid | yes | | | `link.delivery.editUrl` | url | yes | | | `link.delivery.exportId` | uuid | yes | | | `link.delivery.deliveredAt` | datetime | yes | | | `link.pageUrl` | url | yes | | | `link.revisionsLeft` | integer | yes | | | `link.expiresAt` | datetime | yes | | | `link.channel` | enum | no | One of: `email`, `webhook`. | Example (curl): ```bash curl https://api.raylight.app/production-links/$TOKEN ``` ### POST /production-links/{token}/greenlight **Greenlight through a link** Auth: none (public). Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `token` | path | string | yes | The signed token from the link. | Responses: | Status | Meaning | | --- | --- | | 200 | Greenlit. | | 402 | Not enough credits (code `credits`). | | 403 | The link was sent to someone who can no longer greenlight in this workspace, or to a webhook endpoint that was deleted (code `revoked`). | | 404 | Not a valid link (code `not_found`). | | 409 | The pitch moved on since the link was minted, or was already answered (code `stale`). | | 410 | The link expired (code `expired`). Reading it still works and says `state: expired`. | | 429 | Rate limited (30 a minute per address), or the workspace reached a monthly cap (code `cap`). | | 503 | Greenlight links are not switched on for this server (code `disabled`). | Response body (200): | Field | Type | Required | Description | | --- | --- | --- | --- | | `link` | object | yes | | | `link.productionId` | uuid | yes | | | `link.workspaceName` | string | yes | | | `link.stage` | enum | yes | One of: `queued`, `writing_pitch`, `pitch_ready`, `greenlit`, `making`, `exporting`, `delivered`, `skipped`, `declined`, `cancelled`, `failed`. | | `link.state` | enum | yes | One of: `open`, `stale`, `expired`. | | `link.pitchVersion` | integer | yes | | | `link.currentPitchVersion` | integer | yes | | | `link.intent` | string | yes | | | `link.origin` | object \| null | yes | | | `link.origin.provider` | string | yes | | | `link.origin.label` | string | yes | | | `link.origin.url` | url \| null | no | Default `null`. | | `link.origin.ref` | string \| null | no | Default `null`. | | `link.origin.requestedBy` | string \| null | no | Default `null`. | | `link.format` | object | yes | | | `link.format.template` | enum | no | One of: `changelog`, `launch`, `feature`, `digest`, `custom`. Default `changelog`. | | `link.format.durationMs` | integer | no | Default `10000`. | | `link.format.aspect` | enum | no | One of: `16:9`, `4:3`, `3:2`, `21:9`, `1:1`, `9:16`, `3:4`, `2:3`, `4:5`. Default `16:9`. | | `link.format.sound` | object | no | | | `link.format.sound.voiceover` | boolean | no | Default `true`. | | `link.format.sound.music` | boolean | no | Default `true`. | | `link.format.sound.sfx` | boolean | no | Default `true`. | | `link.pitch` | object \| null | yes | | | `link.pitch.version` | integer | yes | | | `link.pitch.verdict` | enum | no | One of: `video`, `skip`. Default `video`. | | `link.pitch.verdictReason` | string \| null | no | Default `null`. | | `link.pitch.title` | string | yes | | | `link.pitch.summary` | string | yes | | | `link.pitch.shots` | object[] | yes | | | `link.pitch.shots[].index` | integer | yes | | | `link.pitch.shots[].title` | string | yes | | | `link.pitch.shots[].durationMs` | integer | yes | | | `link.pitch.shots[].beat` | string \| null | no | Default `null`. | | `link.pitch.shots[].visual` | string | yes | | | `link.pitch.shots[].text` | string \| null | no | Default `null`. | | `link.pitch.shots[].voiceover` | string \| null | no | Default `null`. | | `link.pitch.shots[].mediaIds` | string[] | no | | | `link.pitch.shots[].codePaths` | string[] | no | | | `link.pitch.music` | string \| null | no | Default `null`. | | `link.pitch.estimatedCredits` | integer | yes | | | `link.pitch.createdAt` | datetime | yes | | | `link.pitchMarkdown` | string \| null | yes | | | `link.delivery` | object \| null | yes | | | `link.delivery.shareUrl` | url | yes | | | `link.delivery.projectId` | uuid | yes | | | `link.delivery.editUrl` | url | yes | | | `link.delivery.exportId` | uuid | yes | | | `link.delivery.deliveredAt` | datetime | yes | | | `link.pageUrl` | url | yes | | | `link.revisionsLeft` | integer | yes | | | `link.expiresAt` | datetime | yes | | | `link.channel` | enum | no | One of: `email`, `webhook`. | Example (curl): ```bash curl -X POST https://api.raylight.app/production-links/$TOKEN/greenlight ``` ### POST /production-links/{token}/comments **Send comments through a link** Auth: none (public). Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `token` | path | string | yes | The signed token from the link. | Request body (application/json): | Field | Type | Required | Description | | --- | --- | --- | --- | | `comment` | string | yes | | Responses: | Status | Meaning | | --- | --- | | 200 | Writing the next pitch. | | 400 | The comment is missing or too long. | | 402 | The workspace owner's credits do not cover the video (code `credits`). The current pitch stands. | | 403 | The link was sent to someone who can no longer greenlight in this workspace, or to a webhook endpoint that was deleted (code `revoked`). | | 404 | Not a valid link (code `not_found`). | | 409 | The pitch moved on since the link was minted, or was already answered (code `stale`). | | 410 | The link expired (code `expired`). Reading it still works and says `state: expired`. | | 429 | Rate limited (30 a minute per address), or the workspace reached a monthly cap (code `cap`). | | 503 | Greenlight links are not switched on for this server (code `disabled`). | Response body (200): | Field | Type | Required | Description | | --- | --- | --- | --- | | `link` | object | yes | | | `link.productionId` | uuid | yes | | | `link.workspaceName` | string | yes | | | `link.stage` | enum | yes | One of: `queued`, `writing_pitch`, `pitch_ready`, `greenlit`, `making`, `exporting`, `delivered`, `skipped`, `declined`, `cancelled`, `failed`. | | `link.state` | enum | yes | One of: `open`, `stale`, `expired`. | | `link.pitchVersion` | integer | yes | | | `link.currentPitchVersion` | integer | yes | | | `link.intent` | string | yes | | | `link.origin` | object \| null | yes | | | `link.origin.provider` | string | yes | | | `link.origin.label` | string | yes | | | `link.origin.url` | url \| null | no | Default `null`. | | `link.origin.ref` | string \| null | no | Default `null`. | | `link.origin.requestedBy` | string \| null | no | Default `null`. | | `link.format` | object | yes | | | `link.format.template` | enum | no | One of: `changelog`, `launch`, `feature`, `digest`, `custom`. Default `changelog`. | | `link.format.durationMs` | integer | no | Default `10000`. | | `link.format.aspect` | enum | no | One of: `16:9`, `4:3`, `3:2`, `21:9`, `1:1`, `9:16`, `3:4`, `2:3`, `4:5`. Default `16:9`. | | `link.format.sound` | object | no | | | `link.format.sound.voiceover` | boolean | no | Default `true`. | | `link.format.sound.music` | boolean | no | Default `true`. | | `link.format.sound.sfx` | boolean | no | Default `true`. | | `link.pitch` | object \| null | yes | | | `link.pitch.version` | integer | yes | | | `link.pitch.verdict` | enum | no | One of: `video`, `skip`. Default `video`. | | `link.pitch.verdictReason` | string \| null | no | Default `null`. | | `link.pitch.title` | string | yes | | | `link.pitch.summary` | string | yes | | | `link.pitch.shots` | object[] | yes | | | `link.pitch.shots[].index` | integer | yes | | | `link.pitch.shots[].title` | string | yes | | | `link.pitch.shots[].durationMs` | integer | yes | | | `link.pitch.shots[].beat` | string \| null | no | Default `null`. | | `link.pitch.shots[].visual` | string | yes | | | `link.pitch.shots[].text` | string \| null | no | Default `null`. | | `link.pitch.shots[].voiceover` | string \| null | no | Default `null`. | | `link.pitch.shots[].mediaIds` | string[] | no | | | `link.pitch.shots[].codePaths` | string[] | no | | | `link.pitch.music` | string \| null | no | Default `null`. | | `link.pitch.estimatedCredits` | integer | yes | | | `link.pitch.createdAt` | datetime | yes | | | `link.pitchMarkdown` | string \| null | yes | | | `link.delivery` | object \| null | yes | | | `link.delivery.shareUrl` | url | yes | | | `link.delivery.projectId` | uuid | yes | | | `link.delivery.editUrl` | url | yes | | | `link.delivery.exportId` | uuid | yes | | | `link.delivery.deliveredAt` | datetime | yes | | | `link.pageUrl` | url | yes | | | `link.revisionsLeft` | integer | yes | | | `link.expiresAt` | datetime | yes | | | `link.channel` | enum | no | One of: `email`, `webhook`. | Example (curl): ```bash curl -X POST https://api.raylight.app/production-links/$TOKEN/comments \ -H "Content-Type: application/json" \ -d '{ "comment": "Shorter opening, keep the dashboard shot." }' ``` ### POST /production-links/{token}/decline **Decline through a link** Auth: none (public). Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `token` | path | string | yes | The signed token from the link. | Request body (application/json): | Field | Type | Required | Description | | --- | --- | --- | --- | | `reason` | string | no | | Responses: | Status | Meaning | | --- | --- | | 200 | Declined. | | 400 | The reason is too long. | | 403 | The link was sent to someone who can no longer greenlight in this workspace, or to a webhook endpoint that was deleted (code `revoked`). | | 404 | Not a valid link (code `not_found`). | | 409 | The pitch moved on since the link was minted, or was already answered (code `stale`). | | 410 | The link expired (code `expired`). Reading it still works and says `state: expired`. | | 429 | Rate limited (30 a minute per address), or the workspace reached a monthly cap (code `cap`). | | 503 | Greenlight links are not switched on for this server (code `disabled`). | Response body (200): | Field | Type | Required | Description | | --- | --- | --- | --- | | `link` | object | yes | | | `link.productionId` | uuid | yes | | | `link.workspaceName` | string | yes | | | `link.stage` | enum | yes | One of: `queued`, `writing_pitch`, `pitch_ready`, `greenlit`, `making`, `exporting`, `delivered`, `skipped`, `declined`, `cancelled`, `failed`. | | `link.state` | enum | yes | One of: `open`, `stale`, `expired`. | | `link.pitchVersion` | integer | yes | | | `link.currentPitchVersion` | integer | yes | | | `link.intent` | string | yes | | | `link.origin` | object \| null | yes | | | `link.origin.provider` | string | yes | | | `link.origin.label` | string | yes | | | `link.origin.url` | url \| null | no | Default `null`. | | `link.origin.ref` | string \| null | no | Default `null`. | | `link.origin.requestedBy` | string \| null | no | Default `null`. | | `link.format` | object | yes | | | `link.format.template` | enum | no | One of: `changelog`, `launch`, `feature`, `digest`, `custom`. Default `changelog`. | | `link.format.durationMs` | integer | no | Default `10000`. | | `link.format.aspect` | enum | no | One of: `16:9`, `4:3`, `3:2`, `21:9`, `1:1`, `9:16`, `3:4`, `2:3`, `4:5`. Default `16:9`. | | `link.format.sound` | object | no | | | `link.format.sound.voiceover` | boolean | no | Default `true`. | | `link.format.sound.music` | boolean | no | Default `true`. | | `link.format.sound.sfx` | boolean | no | Default `true`. | | `link.pitch` | object \| null | yes | | | `link.pitch.version` | integer | yes | | | `link.pitch.verdict` | enum | no | One of: `video`, `skip`. Default `video`. | | `link.pitch.verdictReason` | string \| null | no | Default `null`. | | `link.pitch.title` | string | yes | | | `link.pitch.summary` | string | yes | | | `link.pitch.shots` | object[] | yes | | | `link.pitch.shots[].index` | integer | yes | | | `link.pitch.shots[].title` | string | yes | | | `link.pitch.shots[].durationMs` | integer | yes | | | `link.pitch.shots[].beat` | string \| null | no | Default `null`. | | `link.pitch.shots[].visual` | string | yes | | | `link.pitch.shots[].text` | string \| null | no | Default `null`. | | `link.pitch.shots[].voiceover` | string \| null | no | Default `null`. | | `link.pitch.shots[].mediaIds` | string[] | no | | | `link.pitch.shots[].codePaths` | string[] | no | | | `link.pitch.music` | string \| null | no | Default `null`. | | `link.pitch.estimatedCredits` | integer | yes | | | `link.pitch.createdAt` | datetime | yes | | | `link.pitchMarkdown` | string \| null | yes | | | `link.delivery` | object \| null | yes | | | `link.delivery.shareUrl` | url | yes | | | `link.delivery.projectId` | uuid | yes | | | `link.delivery.editUrl` | url | yes | | | `link.delivery.exportId` | uuid | yes | | | `link.delivery.deliveredAt` | datetime | yes | | | `link.pageUrl` | url | yes | | | `link.revisionsLeft` | integer | yes | | | `link.expiresAt` | datetime | yes | | | `link.channel` | enum | no | One of: `email`, `webhook`. | Example (curl): ```bash curl -X POST https://api.raylight.app/production-links/$TOKEN/decline \ -H "Content-Type: application/json" \ -d '{ "reason": "Already covered in the launch video." }' ``` ## API keys Device flow for tools without a browser. ### POST /v1/keys/request **Start a device-code key request** Public. Returns a device code to poll with and an approval URL to show the user. Pick a `name` that says who is asking. Auth: none (public). Request body (application/json): | Field | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | Default `API key`. | Responses: | Status | Meaning | | --- | --- | | 201 | The pairing. | | 400 | Invalid body. | | 429 | Rate limited: 60 requests a minute per address. Carries Retry-After. | Response body (201): | Field | Type | Required | Description | | --- | --- | --- | --- | | `deviceCode` | string | yes | | | `userCode` | string | yes | | | `approvalUrl` | url | yes | | | `expiresInSeconds` | integer | yes | | | `pollIntervalSeconds` | integer | yes | | Example (curl): ```bash curl -X POST https://api.raylight.app/v1/keys/request \ -H "Content-Type: application/json" \ -d '{ "name": "CI on acme/web" }' ``` ### POST /v1/keys/poll **Poll a key request** Public. `pending` until the user approves, then `approved` with the key, once. `denied` and `expired` are terminal. If the claim response was lost, poll again with `revokeClaimed: true` to revoke the key you never received. Auth: none (public). Request body (application/json): | Field | Type | Required | Description | | --- | --- | --- | --- | | `deviceCode` | string | yes | | | `revokeClaimed` | boolean | no | | Responses: | Status | Meaning | | --- | --- | | 200 | The request's state. | | 400 | Invalid body. | | 404 | Unknown device code. | | 429 | Rate limited: 60 requests a minute per address. Carries Retry-After. | Response body (200): | Field | Type | Required | Description | | --- | --- | --- | --- | | `status` | enum | yes | One of: `pending`, `approved`, `claimed`, `denied`, `expired`. | | `apiKey` | string | no | | Example (curl): ```bash curl -X POST https://api.raylight.app/v1/keys/poll \ -H "Content-Type: application/json" \ -d '{ "deviceCode": "1aSs9fWUrhEUu3hOo5TzSJfWDgradXEK_k9BIV67XV8" }' ``` ### DELETE /v1/keys/current **Revoke the key making the call** Signs a tool out: the API key in the `Authorization` header stops working at once. Needs no scope. Usage history for the key is kept. Auth: `Authorization: Bearer rl_live_...`, any key (no scope needed). Responses: | Status | Meaning | | --- | --- | | 204 | Revoked. | | 400 | The call was not made with an API key (code `not_api_key`). | | 401 | Missing, invalid or revoked key. | | 404 | The key is already revoked (code `not_found`). | | 429 | Rate limited: 30 requests a minute per key. Carries Retry-After. | Example (curl): ```bash curl -X DELETE https://api.raylight.app/v1/keys/current \ -H "Authorization: Bearer $RAYLIGHT_API_KEY" ``` ## Renders Images in, video out. ### POST /v1/renders **Render images into a video** Multipart: 1 to 10 image files as repeated `images` fields plus optional form fields. With `wait=true` (default) the request holds until the video is ready (about 1 to 3 minutes) and answers 200; past the wait budget it answers 202 while the render continues. Needs the `renders` scope. Auth: `Authorization: Bearer rl_live_...` with the `renders` scope. Request body (multipart/form-data): | Field | Type | Required | Description | | --- | --- | --- | --- | | `resolution` | enum | no | One of: `480p`, `720p`, `1080p`, `4K`. Default `1080p`. | | `background` | enum | no | One of: `black`, `white`. Default `black`. | | `appName` | string | no | | | `accent` | string | no | | | `wait` | enum | no | One of: `true`, `false`. Default `true`. | | `images` | file[] | yes | | Responses: | Status | Meaning | | --- | --- | | 200 | The finished render with a signed video URL. | | 202 | Still rendering; poll it. | | 400 | Invalid request. | | 401 | Missing, invalid or revoked key. | | 403 | The key lacks the scope, or the key's owner has view-only access to the workspace (code `forbidden`). | | 404 | Not found. | | 429 | Rate limited, or a monthly cap reached. | Response body (200): | Field | Type | Required | Description | | --- | --- | --- | --- | | `render` | object | yes | | | `render.id` | uuid | yes | | | `render.status` | enum | yes | One of: `queued`, `rendering`, `done`, `failed`. | | `render.progress` | number | yes | | | `render.projectId` | uuid \| null | yes | | | `render.resolution` | enum | yes | One of: `480p`, `720p`, `1080p`, `4K`. | | `render.error` | string \| null | yes | | | `render.videoUrl` | url | no | | Response body (202): | Field | Type | Required | Description | | --- | --- | --- | --- | | `render` | object | yes | | | `render.id` | uuid | yes | | | `render.status` | enum | yes | One of: `queued`, `rendering`, `done`, `failed`. | | `render.progress` | number | yes | | | `render.projectId` | uuid \| null | yes | | | `render.resolution` | enum | yes | One of: `480p`, `720p`, `1080p`, `4K`. | | `render.error` | string \| null | yes | | | `render.videoUrl` | url | no | | Example (curl): ```bash curl -X POST https://api.raylight.app/v1/renders \ -H "Authorization: Bearer $RAYLIGHT_API_KEY" \ -F "images=@screenshots/home.png" \ -F "images=@screenshots/editor.png" \ -F "appName=Acme" \ -F "resolution=1080p" ``` ### GET /v1/renders/{id} **Read a render** Auth: `Authorization: Bearer rl_live_...` with the `renders` scope. Parameters: | Name | In | Type | Required | Description | | --- | --- | --- | --- | --- | | `id` | path | uuid | yes | | Responses: | Status | Meaning | | --- | --- | | 200 | The render, with a fresh video URL once done. | | 400 | Invalid request. | | 401 | Missing, invalid or revoked key. | | 403 | The key lacks the scope, or the key's owner has view-only access to the workspace (code `forbidden`). | | 404 | Not found. | | 429 | Rate limited, or a monthly cap reached. | Response body (200): | Field | Type | Required | Description | | --- | --- | --- | --- | | `render` | object | yes | | | `render.id` | uuid | yes | | | `render.status` | enum | yes | One of: `queued`, `rendering`, `done`, `failed`. | | `render.progress` | number | yes | | | `render.projectId` | uuid \| null | yes | | | `render.resolution` | enum | yes | One of: `480p`, `720p`, `1080p`, `4K`. | | `render.error` | string \| null | yes | | | `render.videoUrl` | url | no | | Example (curl): ```bash curl https://api.raylight.app/v1/renders/$ID \ -H "Authorization: Bearer $RAYLIGHT_API_KEY" ```