Webhooks
Get a signed POST to your endpoint when assets finalize, fail, or hit any other event.
Webhooks
Motioness uses Standard Webhooks — predictable headers, HMAC-SHA256 signatures, idempotency keys, retry schedule. Swap an existing Svix/Stripe verifier and it'll work.
Configure
Three controls, all in Project → Settings → Webhooks (or via API):
bashcurl -X PUT "https://motioness.com/api/projects/$PROJECT_ID/webhooks" \ -H 'Content-Type: application/json' -b cookie.txt \ -d '{ "webhook_url": "https://your-app.com/webhooks/motioness", "webhook_events": ["asset.completed", "asset.failed", "asset.retry_scheduled"], "webhooks_enabled": true }'
Response includes a webhook_secret_preview (first 12 chars + ellipsis). The full secret was set on first save — read it from the dashboard once, store it as MOTIONESS_WEBHOOK_SECRET.
To rotate:
bashcurl -X POST "https://motioness.com/api/projects/$PROJECT_ID/webhooks/rotate-secret" -b cookie.txt# → { "webhook_secret": "whsec_..." } ← shown once
Send a test event
bashcurl -X POST "https://motioness.com/api/projects/$PROJECT_ID/webhooks/test" -b cookie.txt
The first delivered test auto-flips webhooks_enabled to true if it wasn't already. Use this to confirm your endpoint is reachable + verifying signatures correctly.
Event types
Subscribe to any subset:
| Event | When |
|---|---|
asset.queued | Generation accepted |
asset.stage_completed | One pipeline stage finished (vision, stage1, stage2, merge, r2_write) |
asset.completed | Terminal success — mp4 is hot |
asset.failed | Terminal failure — error_code field tells you why |
asset.retry_scheduled | Transient stage error, retry queued (observational) |
Default subscription is [asset.completed, asset.failed].
Payload shape
json{ "id": "evt_01HXY3K…", "type": "asset.completed", "ts": 1714801234567, "asset_id": "a25b330a71e2bc6b", "project_id": "proj_abc", "request_id": "req_xyz", "data": { "mp4_url": "https://motioness.com/r2/assets/a25b330a71e2bc6b.mp4", "bytes": 845321, "loop_mode": "native" }}
data varies by event type:
json{ "mp4_url": "https://motioness.com/r2/...", "bytes": 845321, "loop_mode": "native" }
json{ "error_code": "vision_blocked", "error_msg": "Source image rejected by vision model" }
json{ "stage": "stage1", "latency_ms": 4820 }
json{ "stage": "merge", "attempt": 2, "next_at_ms": 1714801234567 }
Headers
textcontent-type: application/jsonwebhook-id: evt_01HXY3K… ← idempotency keywebhook-timestamp: 1714801234 ← unix secondswebhook-signature: v1,<base64-hmac> ← HMAC-SHA256 of `${id}.${ts}.${body}`
Standard Webhooks scheme. webhook-signature may carry multiple versions space-separated (v1,A v1,B) during secret rotation; verify any matches.
Verify
tsimport crypto from 'node:crypto';export function verify( secret: string, headers: Record<string, string>, body: string,): boolean { const id = headers['webhook-id']; const ts = headers['webhook-timestamp']; const sigHeader = headers['webhook-signature']; if (!id || !ts || !sigHeader) return false; // Reject events older than 5 minutes if (Math.abs(Math.floor(Date.now() / 1000) - Number(ts)) > 5 * 60) return false; const raw = secret.startsWith('whsec_') ? Buffer.from(secret.slice(6), 'base64') : Buffer.from(secret); const expected = 'v1,' + crypto.createHmac('sha256', raw).update(`${id}.${ts}.${body}`).digest('base64'); return sigHeader .split(' ') .some((s) => crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));}
pythonimport base64import hashlibimport hmacimport timedef verify(secret: str, headers: dict, body: str) -> bool: evt_id = headers.get('webhook-id') ts = headers.get('webhook-timestamp') sig_header = headers.get('webhook-signature') if not (evt_id and ts and sig_header): return False if abs(int(time.time()) - int(ts)) > 5 * 60: return False raw = base64.b64decode(secret[6:]) if secret.startswith('whsec_') else secret.encode() msg = f'{evt_id}.{ts}.{body}'.encode() expected = 'v1,' + base64.b64encode(hmac.new(raw, msg, hashlib.sha256).digest()).decode() return any(hmac.compare_digest(s, expected) for s in sig_header.split(' '))
Idempotency
webhook-id is the idempotency key. Retries of the same logical event always carry the same id. Store seen ids for ~24h and skip duplicates:
tsif (await seenIds.has(id)) return res.status(200).end();await seenIds.put(id, true, { ttl: 24 * 60 * 60 });
Retry schedule
Failed deliveries (non-2xx, timeout > 10s) retry on:
text30s, 2m, 10m, 30m, 2h
After 5 failed attempts the delivery is marked dead. Inspect via GET /api/projects/:id/webhooks/deliveries and re-enqueue with POST /api/projects/:id/webhooks/deliveries/:deliveryId/redeliver.
Replay-attack window
Reject events older than 5 minutes (timestamp comparison). The verify recipes above already do this.
Local development
Use ngrok / cloudflared / Tailscale to forward your localhost:
bashngrok http 3000# → https://abc123.ngrok.io# Then in dashboard, set webhook_url to https://abc123.ngrok.io/webhooks/motioness
Run POST /webhooks/test from the dashboard to fire a test event into your local server.