The studio, as an API.
Everything the app does is a route under /v1, and the same routes are the tools an agent gets over MCP. A key carries scopes; rendering spends on the workspace's own provider keys and nothing else spends at all.
Get a key
Sign in, open Settings, API keys, and mint one. It starts lgk_, it is shown once, and it is sent as Authorization: Bearer on every call. The mint dialog offers four presets:
- Read only: read.
- Build: read, write.
- Build and render: read, write, render.
- Full access: admin.
A key can be renamed, rescoped or revoked from the same tab, and can be given an expiry. A key cannot mint keys: that is a signed-in person's job, on purpose.
Create a lot and render a shot
Five calls. Everything takes and returns JSON; errors come back as {"error":{"code","message"}} with a status that means what it says.
# One lot, one shot, a quote, then the render.
export LOTGEN_API_KEY=lgk_...
api=https://api.lotgen.ai/v1
auth="Authorization: Bearer $LOTGEN_API_KEY"
# A lot. default_provider is any provider the workspace holds a key for.
curl -s $api/projects -H "$auth" -H "content-type: application/json" \
-d '{"name":"Spring drop","default_provider":"fal"}'
# → {"project":{"id":"LOT_ID", ...}}
# A shot on it.
curl -s $api/projects/LOT_ID/scenes -H "$auth" -H "content-type: application/json" \
-d '{"prompt":"a bottle on a beach at dawn"}'
# → {"scene":{"id":"SHOT_ID", ...}}
# What it would cost. A dry run spends nothing and needs only read.
curl -s $api/scenes/SHOT_ID/render -H "$auth" -H "content-type: application/json" \
-d '{"dry_run":true}'
# → {"dry_run":true,"total_usd":0.42,"estimate":{...},"cap":{...}}
# The render. 202, and a job to poll.
curl -s $api/scenes/SHOT_ID/render -H "$auth" -H "content-type: application/json" \
-d '{}'
# → {"job":{"id":"JOB_ID","status":"queued", ...}}
curl -s $api/jobs/JOB_ID -H "$auth"
# → {"job":{"id":"JOB_ID","status":"succeeded","actual_usd":0.42, ...}}
# The take: the shot's assets, then a signed URL for the bytes.
curl -s $api/scenes/SHOT_ID/assets -H "$auth"
# → {"assets":[{"id":"ASSET_ID","kind":"video","job_id":"JOB_ID","mime":"video/mp4", ...}]}
curl -s $api/assets/ASSET_ID/url -H "$auth"
# → {"url":"https://...","expires_in":900}A render answers 202 with a job; poll it until status is succeeded or failed, then read the take off the shot. The quote is the same call with dry_run: true, and every route that renders takes it.
The SDK
The client the MCP server itself is built on, so it cannot drift from the tools. Typed end to end, with the request types the API validates against.
npm i lotgen
import { createLotgenClient } from "lotgen";
const api = createLotgenClient({ token: process.env.LOTGEN_API_KEY! });
const { project } = await api.createProject({ name: "Spring drop", default_provider: "fal" });
const { scene } = await api.addScene(project.id, { prompt: "a bottle on a beach at dawn" });
const quote = await api.renderScene(scene.id, { dry_run: true });
const { job } = await api.renderScene(scene.id, {});
console.log(quote.total_usd, job?.id, job?.status);Every failure throws LotgenApiError with status, code, message and details, the rest of the error body. Node 18 or later, or any runtime with a global fetch.
Scopes
Four words. A key that lacks the one a route needs answers 403 insufficient_scope naming it. admin keeps every power a key had before scopes existed; read is implied by the other three.
| Scope | What it buys | Routes |
|---|---|---|
read | See lots, takes, products, usage and prices. | 69, such as get_project, get_job, search_takes, get_usage |
write | Create and edit lots, shots and products. Spends nothing. | 88, such as create_project, add_scene, update_scene, save_product |
render | Render, run post passes and voice. Spends on your provider keys. | 19, such as render_scene, upscale_take, lipsync_take, clean_plate_take |
admin | The workspace itself: settings, caps, provider keys, members, billing. | 43, such as put_provider_key, create_invite, set_seats, update_workspace |
Which scope each route needs is on every operation in the contract as x-lotgen-scope. The column above is read from it.
Brand-bound keys
An agency fronting Lotgen to its own clients wants each integration to make and see one client's work, and each client's spend to roll up under that client. That is a key bound to a brand kit, and it is what the Partner add-on ($149 a month flat, on Solo or Team, unlimited brands) turns on.
- Every lot a bound key creates carries the brand, and the kit's name as its client tag, without being asked.
- It sees only that brand's lots, products and kits, and nothing unbranded. Another brand answers 403 brand_scope; a lot it cannot see answers 404.
- It cannot carry
admin, and it cannot create or archive brand kits. - Usage rolls up by brand and by key, so each client's month is one row, and
GET /v1/usage?brand=<id>or?key=<id>is one client's month on its own, every figure narrowed to it. Each key's row under Settings, API keys shows its calls, jobs and spend for the month. - The binding is a wall, not a label: a bound key that is handed another brand's ids by name, or an unbranded lot's, gets
404for each of them, shots, takes, files, packs, collections and links alike, the same answer another workspace's rows give. A face it casts and a collection it gathers are its own; a face a person casts belongs to no brand and is seen by no bound key. - A client that signs in gets the same binding when the person allowing it picks a brand on the consent screen. The grant then behaves exactly as a bound key does, and never holds
admin.
Rate limits
Per key, per minute: 600 requests, and 30 calls to routes that render. One call is one render however many jobs it queues. A dry run is a quote and counts as a read. Every response to a key carries its budget:
HTTP/1.1 429 Too Many Requests
Retry-After: 41
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 41
{"error":{"code":"rate_limited","message":"This key is over its 600 requests a minute. Try again in 41s.","limit":"requests","retry_after":41}}X-RateLimit-Reset and retry_after are seconds until a slot frees, not a clock time. A render call also carries X-RateLimit-Render-*. The window is per API replica, which is a weaker limit than a shared one and still a limit; treat the headers, not the numbers, as the truth. An MCP tool call is two requests, because the server confirms the key first.
Webhooks
A signed POST to your server when something finishes here, instead of polling. Partner add-on: registering one without it answers 402 addon_required. Register from Settings, Webhooks or with the API; the signing secret is returned once.
curl -s $api/webhooks -H "$auth" -H "content-type: application/json" \
-d '{"url":"https://example.com/hooks/lotgen","events":["job.succeeded","job.failed"]}'
# → {"webhook":{"id":"...","url":"...","events":[...],"enabled":true, ...},"secret":"lgwh_..."}
# The secret is shown once. POST $api/webhooks/ID/test sends a signed ping.The events:
job.succeeded: A take landed: the job, its cost and its assets.job.failed: A take failed, with the provider's reason.review.decided: A reviewer approved, rejected or commented on a shot.drive_export.done: A Drive delivery finished, fully, partly or not at all.assembly.done: A lot was stitched into one video: the assembly, its params, the episode asset.assembly.failed: A stitch did not finish, with the reason.
data is per event: job and assets for the job events, decision for a review, drive_export for a delivery. The envelope is the same for all of them.
POST /hooks/lotgen HTTP/1.1
Content-Type: application/json
User-Agent: lotgen-webhooks/1
Lotgen-Event: job.succeeded
Lotgen-Delivery: 8b1c... # the same id on every retry
Idempotency-Key: 8b1c...
Lotgen-Signature: t=1789000000,v1=5f3a... # HMAC-SHA256 of "<t>.<body>" with the secret
{
"id": "8b1c...",
"event": "job.succeeded",
"created_at": "2026-09-14T12:00:00.000Z",
"workspace_id": "...",
"brand_kit_id": null,
"data": {
"job": { "id": "...", "status": "succeeded", "project_id": "...", "scene_id": "...",
"provider": "fal", "model": "...", "actual_usd": 0.42, "api_key_id": "...", ... },
"assets": [ { "id": "...", "kind": "video", "mime": "video/mp4", "width": 1280, "height": 720, "duration_sec": 5 } ]
}
}Verify before you trust it. The SDK ships the verifier; it checks the HMAC over the raw body and refuses a signature older than 5 minutes. Answer 2xx within ten seconds; do the work after.
import { verifyWebhookSignature } from "lotgen";
app.post("/hooks/lotgen", async (req, res) => {
const body = await rawBody(req); // the bytes as sent, not re-serialised JSON
const ok = await verifyWebhookSignature({
secret: process.env.LOTGEN_WEBHOOK_SECRET!,
header: req.headers["lotgen-signature"],
body,
});
if (!ok) return res.status(401).end();
const event = JSON.parse(body);
// event.id is the delivery id: seen it before? Drop it.
res.status(200).end();
});- Anything but a 2xx is retried: after a minute, then five, thirty, two hours, six, twelve, a day. 8 attempts, then the delivery is dead. Redirects are not followed.
- An endpoint that answers nothing but failure for a day is switched off, with the reason on its row in Settings; fix the receiver and switch it back on.
- The URL must resolve to a public address, at registration and again at every delivery.
- An endpoint registered with a brand-bound key hears only that brand's events; one registered by a person hears them all.
POST /v1/webhooks/ID/testsends a signedping.GET /v1/webhooks/ID/deliverieslists the last fifty attempts with the answer your server gave.
MCP
Claude, Cursor and any MCP client get the same routes as tools, filtered to what the key may do: a read key lists the tools it can call and none it cannot, and a key bound to a brand is not offered the two that would make another. Paste this where your client keeps its servers, or sign in when the client offers to and skip the key. A client that signs in asks for the same four scopes, the consent screen names them in the words above, and the grant is held to them; leave scope out and it asks for everything.
{
"mcpServers": {
"lotgen": {
"url": "https://mcp.lotgen.ai/mcp",
"headers": { "Authorization": "Bearer lgk_..." }
}
}
}The Academy's Connecting Claude walks the sign-in route step by step.
The contract
https://api.lotgen.ai/v1/openapi.json is OpenAPI 3.1, generated from the router: every authenticated route, its scope, its request body where the route validates one, and its success status. A route with no entry there does not exist. Point a generator at it for a client in a language the SDK is not.
Questions, or a key that does something this page did not say it would: support@lotgen.ai.