Every slow operation is a job. Long-polling works well from scripts; for servers, register a webhook and Lumorig will POST to you when any job in the workspace finishes. Deliveries are signed with a per-webhook secret so you can prove they came from us.
Register an endpoint#
POST/webhooks
| Name | Type | Description |
|---|---|---|
urlrequired | string (URL) | Where to POST. Must be reachable from the internet. |
events | string[] | Any of job.succeeded, job.failed, job.canceled, or * for all. Default ["*"]. |
curl -X POST https://lumorig.com/api/v1/webhooks -H "Authorization: Bearer $LUMORIG_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://your.app/hooks/lumorig", "events": ["job.succeeded", "job.failed"] }'
# 201 → { "id": "whk_…", "object": "webhook", "url": "…", "events": [ … ], "secret": "whsec_…" }Payload#
Each delivery is a POST with a JSON body: the event type, when it was sent, and the full job, exactly as GET /jobs/{id} would return it.
POST https://your.app/hooks/lumorig
content-type: application/json
lumorig-signature: t=1790000000,v1=5f2c9a…
{
"type": "job.succeeded",
"created_at": "2026-09-25T10:04:12.000Z",
"data": {
"id": "job_…",
"object": "job",
"type": "export",
"status": "succeeded",
"mascot_id": "msc_…",
"result": { "file": { "id": "file_…", "url": "/api/v1/files/file_…", "mime": "application/json", "bytes": 48213 } },
"error": null,
"cost_usd": 0,
"created_at": "…",
"started_at": "…",
"finished_at": "…"
}
}data.type tells you what finished (generate, reconstruct, edit, variant, animate, export, analyze, suggest, scene) and data.result has the same shape the job result has everywhere else. For failures, data.error holds the message.
Verifying signatures#
The signature header has a Unix timestamp and a hex HMAC-SHA256, keyed with your webhook secret, over the timestamp, a dot, and the raw body:
Lumorig-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, t + "." + body)>Verify against the raw request body (before any JSON parsing), and reject old timestamps to stop replays. For now each delivery also carries the same value under the pre-rename name, MascotForge-Signature, so receivers written before the rename keep working.
import { verifyWebhook } from "@lumorig/sdk";
export async function POST(req: Request) {
const body = await req.text();
const ok = await verifyWebhook(body, req.headers.get("lumorig-signature") ?? "", process.env.LUMORIG_WEBHOOK_SECRET!);
if (!ok) return new Response("bad signature", { status: 400 });
const event = JSON.parse(body);
if (event.type === "job.succeeded" && event.data.type === "export") {
// event.data.result.file.id → download with GET /files/{id}
}
return new Response(null, { status: 204 });
}verifyWebhook uses Web Crypto, so it runs in Node 18+, Bun, Deno and edge runtimes. Its default tolerance is five minutes; pass a fourth argument in seconds to change it.
Retries#
| Behaviour | Value |
|---|---|
| Attempts per delivery | 3 |
| Timeout per attempt | 10 seconds |
| Wait after a failed attempt | 1 s, then 4 s, then 16 s |
| Counts as delivered | Any 2xx response |
All attempts of one delivery carry the same timestamp and signature, so answer fast (queue the work, return 2xx) and make your handler idempotent on data.id. After the third failure the delivery is dropped; the job itself is unaffected, and GET /jobs/{id} still has the result.
Testing locally#
Point a webhook at a tunnel (for example cloudflared or ngrok) to your dev server, then run a free job such as an export to trigger a delivery without spending credits.
Managing webhooks#
| Endpoint | Does |
|---|---|
GET/webhooks | Every webhook in the workspace: id, url, events, created_at (no secret). |
DELETE/webhooks/{id} | Stop deliveries to that endpoint. |
const hooks = await lumorig.webhooks.list();
await lumorig.webhooks.delete(hooks[0]!.id);Webhooks belong to the workspace, not to an API key, so revoking a key doesn't stop them. Every job in the workspace is delivered, whichever surface (studio, API, MCP, CLI) started it.