> ## Documentation Index
> Fetch the complete documentation index at: https://docs.motioness.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

Get a signed POST to your endpoint when assets finalize, fail, or hit any other event.

# Webhooks

Motioness uses [Standard Webhooks](https://www.standardwebhooks.com/) — 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):

```bash
curl -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:

```bash
curl -X POST "https://motioness.com/api/projects/$PROJECT_ID/webhooks/rotate-secret" -b cookie.txt
# → { "webhook_secret": "whsec_..." }   ← shown once
```

## Send a test event

```bash
curl -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:

**asset.completed**

```json
    { "mp4_url": "https://motioness.com/r2/...", "bytes": 845321, "loop_mode": "native" }
    ```

  **asset.failed**

```json
    { "error_code": "vision_blocked", "error_msg": "Source image rejected by vision model" }
    ```

  **asset.stage_completed**

```json
    { "stage": "stage1", "latency_ms": 4820 }
    ```

  **asset.retry_scheduled**

```json
    { "stage": "merge", "attempt": 2, "next_at_ms": 1714801234567 }
    ```

## Headers

```
content-type: application/json
webhook-id: evt_01HXY3K…              ← idempotency key
webhook-timestamp: 1714801234         ← unix seconds
webhook-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

**Node**

```ts
    import 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)));
    }
    ```

  **Python**

```python
    import base64
    import hashlib
    import hmac
    import time

    def 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:

```ts
if (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:

```
30s, 2m, 10m, 30m, 2h
```

After 5 failed attempts the delivery is marked `dead`. Inspect via [`GET /api/projects/:id/webhooks/deliveries`](/api/webhooks/list-deliveries) and re-enqueue with [`POST /api/projects/:id/webhooks/deliveries/:deliveryId/redeliver`](/api/webhooks/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:

```bash
ngrok 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.