LOTGEN
Developers

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:

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.

ScopeWhat it buysRoutes
readSee lots, takes, products, usage and prices.69, such as get_project, get_job, search_takes, get_usage
writeCreate and edit lots, shots and products. Spends nothing.88, such as create_project, add_scene, update_scene, save_product
renderRender, run post passes and voice. Spends on your provider keys.19, such as render_scene, upscale_take, lipsync_take, clean_plate_take
adminThe 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.

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:

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();
});

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.