# SDK

> @raylight/sdk for TypeScript: typed calls, wait helpers and webhook verification.

`@raylight/sdk` is a thin typed client for the REST API. Its types come from the same schemas the server validates with, so a body the SDK accepts is a body the API accepts. Node 18 or newer (global `fetch`), ESM and CommonJS, one runtime dependency (`zod`).

```bash
npm install @raylight/sdk
```

## Client

`new Raylight({ apiKey?, baseUrl?, fetch? })`. `apiKey` defaults to `RAYLIGHT_API_KEY`; pass `null` to use only the public surface (signed links and the device flow). An explicit `baseUrl` always wins. Without one, `RAYLIGHT_API_URL` is used only when the key also came from `RAYLIGHT_API_KEY`, so a key you pass in code never follows a server URL someone else set on the machine; otherwise it is `https://api.raylight.app`. The URL must be `https`; plain `http` is accepted only for `localhost`, so a key is never sent in the clear. A key or URL that cannot work throws `RaylightConfigError` from the constructor. The key never appears in logs, errors or `console.log(raylight)`.

## Make a video

```ts
import { Raylight } from "@raylight/sdk";

const raylight = new Raylight({ apiKey: process.env.RAYLIGHT_API_KEY });

const production = await raylight.productions.create({
  brief: {
    intent: "Announce dark mode in the Acme dashboard to existing customers.",
    code: [{ path: "src/components/settings/theme-toggle.tsx", source }],
    format: { template: "feature", durationMs: 10000 },
  },
  externalId: "acme/web#412",
  origin: { provider: "github", label: "acme/web #412", ref: "feat/dark-mode" },
});

const { production: pitched } = await raylight.productions.waitForPitch(production.id);

if (pitched.stage !== "pitch_ready") {
  console.log("Nothing to greenlight:", pitched.stage, pitched.pitch?.verdictReason);
} else {
  console.log(pitched.pitch?.title, pitched.pitch?.estimatedCredits, "credits");

  await raylight.productions.greenlight(production.id, {
    pitchVersion: pitched.pitch?.version,
  });

  const { production: done } = await raylight.productions.waitForDelivery(production.id);

  console.log(done.stage, done.delivery?.shareUrl, done.delivery?.editUrl);
}
```

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

## More

- Previous: [Authentication and keys](https://www.raylight.app/docs/authentication.md)
- Next: [Limits and pricing](https://www.raylight.app/docs/limits.md)
- All docs: https://www.raylight.app/llms.txt
- Everything in one file: https://www.raylight.app/llms-full.txt
- OpenAPI: https://api.raylight.app/v1/openapi.json
