---
agentTools:
  projectIndex: https://elai.readme.io/llms.txt
---

# Set secret token

Set the shared secret we sign each delivery with. Once set, every request carries `Elai-Timestamp` and `Elai-Signature`; without it, neither header is sent.

> ❗️ Subscribe first, then set the secret
>
> The secret is only stored if a webhook is currently **enabled**. Call this endpoint before `POST /webhook`, or while the webhook is switched off, and the secret is silently discarded — you still get a `200` with the same body, so nothing distinguishes stored from dropped. Deliveries then arrive unsigned while you believe they are signed. Verify by re-reading `GET /apiConfig`.

Use a random, high-entropy string. Setting a new secret replaces the old one immediately, so rotate by updating your consumer first if it only accepts one secret.

Set a shared secret and we sign every delivery, so you can prove a request came from us rather than from someone who guessed your URL.

> ❗️ Subscribe first, then set the secret
>
> The secret is stored **only if a webhook is currently enabled**. Call this endpoint before
> [`POST /webhook`](https://elai.readme.io/reference/webhook-subscribe), or while the webhook is switched off, and the secret is
> discarded — you still get a `200` with the same body, so there is nothing in the response to tell
> you it was dropped. Deliveries then arrive unsigned while you believe they are signed.
>
> Correct order: subscribe, then set the secret, then confirm with
> [`GET /apiConfig`](https://elai.readme.io/reference/retrieve-api-config).

## What we sign

Two headers are added to each delivery once a secret is set:

* `Elai-Timestamp` — milliseconds since the epoch, as a plain integer
* `Elai-Signature` — HMAC-SHA256, hex encoded

The signed message is the timestamp and the **raw request body**, joined with a single dot:

```
<Elai-Timestamp> + "." + <raw request body>
```

> 🚧 Use the raw body, not the parsed object
>
> We sign the body exactly as we serialise it on the wire, so the raw bytes are the only input
> guaranteed to reproduce our message.
>
> Two mistakes to avoid. Interpolating the **parsed object** produces the string `[object Object]`
> and never matches. Re-serialising the parsed object usually does match — `JSON.parse` then
> `JSON.stringify` round-trips a plain payload — but it is not something to rely on: any middleware
> that adds, drops or reorders a field, or a serialiser that formats differently, silently breaks
> every signature. Capture the raw bytes before a body parser touches them and the question does not
> arise.
>
> Header names are also case-insensitive and Node lowercases them, so read
> `req.headers['elai-signature']` — `req.headers['Elai-Signature']` is always `undefined`.

## A verifier that works

```javascript
const crypto = require('crypto')
const express = require('express')

const app = express()
const SECRET = process.env.ELAI_WEBHOOK_SECRET

// Keep the raw body around: express.json() gives it to the verify callback
// before parsing, which is the only place it is still byte-exact.
app.use(
  express.json({
    verify: (req, _res, buf) => {
      req.rawBody = buf.toString('utf8')
    },
  })
)

const isFromElai = (req) => {
  const timestamp = req.headers['elai-timestamp']
  const received = req.headers['elai-signature']
  if (!timestamp || !received) return false

  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(`${timestamp}.${req.rawBody}`)
    .digest('hex')

  // Compare in constant time. timingSafeEqual throws on a length mismatch, so
  // check that first rather than letting it raise.
  if (received.length !== expected.length) return false
  return crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected))
}

app.post('/elai-webhook', (req, res) => {
  if (!isFromElai(req)) return res.sendStatus(401)

  // Answer immediately: we wait 10 seconds for a 2xx, and a slow consumer looks
  // exactly like a broken one. Do the real work after responding.
  res.sendStatus(200)
  handleEvent(req.body).catch(console.error)
})
```

## Recommended, but not enforced by us

Reject deliveries whose `Elai-Timestamp` is far from your own clock — a few minutes of tolerance is
plenty. We do not expire signatures, so without a freshness check a captured request stays replayable
forever.

Rotating the secret takes effect immediately and there is no overlap window, so if your consumer only
holds one secret, expect a short gap where in-flight deliveries fail signature checks. Those are lost
events — see [delivery is a single attempt](https://elai.readme.io/reference/webhooks-overview).

# OpenAPI definition

````json
{
  "openapi": "3.1.0",
  "info": {
    "title": "Elai Public API",
    "version": "1.0",
    "description": "Every endpoint below is served under `https://apis.elai.io/api/v1`.\n\n## Authentication\n\nSend your workspace API token as a bearer token on every request:\n\n```\nAuthorization: Bearer <your-api-token>\n```\n\nGenerate and rotate the token in the Elai web app under **Settings → API**, or through\n`POST /generateToken`. The token is scoped to one **workspace** and authenticates as that\nworkspace's admin user — it is not tied to an individual person, and it carries admin rights\ninside that workspace.\n\nA request with a missing, unknown or revoked token gets a bare `401` with no response body.\n\n> **Organizations on the Unlimited plan cannot use the API.** Their requests are rejected with the\n> same bare `401` as an invalid token, with nothing to distinguish the two. If a valid, freshly\n> generated token returns `401` on every endpoint, check the plan first.\n\n## Errors\n\nFailures carry a JSON body of the shape `{ \"message\": \"...\" }`. Validation failures on the newer\nendpoints may add an `errors` array naming the offending fields. `401` is the exception: it has no\nbody at all.\n\nBoth render endpoints are **fire-and-forget** — they queue work and return immediately, so a\n`200` means \"accepted\", not \"rendered\". Render failures surface only through the `video_error`\nwebhook, never as an HTTP error on the call that started the render.\n\n## Scope\n\nThis reference is generated from the running code rather than maintained by hand, so it cannot\nquietly fall behind the API it describes. It documents 96 operations across the\n`/api/v1` surface.\n\nEndpoints are grouped by subject in the sidebar. If something you need is missing, or an endpoint\ndoes not behave as described here, tell us.\n\n---\n\n_Generated from `chore/web-4760/api-v1-reference-build` at `5289745d` on 2026-09-04._"
  },
  "servers": [
    {
      "url": "https://apis.elai.io/api/v1"
    }
  ],
  "components": {
    "securitySchemes": {
      "sec0": {
        "type": "apiKey",
        "in": "header",
        "name": "Authorization",
        "x-bearer-format": "bearer",
        "x-default": "SECRET_KEY"
      }
    }
  },
  "security": [
    {
      "sec0": []
    }
  ],
  "paths": {
    "/webhook/secret": {
      "post": {
        "summary": "Set secret token",
        "description": "Set the shared secret we sign each delivery with. Once set, every request carries `Elai-Timestamp` and `Elai-Signature`; without it, neither header is sent.\n\n> ❗️ Subscribe first, then set the secret\n>\n> The secret is only stored if a webhook is currently **enabled**. Call this endpoint before `POST /webhook`, or while the webhook is switched off, and the secret is silently discarded — you still get a `200` with the same body, so nothing distinguishes stored from dropped. Deliveries then arrive unsigned while you believe they are signed. Verify by re-reading `GET /apiConfig`.\n\nUse a random, high-entropy string. Setting a new secret replaces the old one immediately, so rotate by updating your consumer first if it only accepts one secret.",
        "operationId": "webhook-set-secret-token",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "secret": {
                    "type": "string",
                    "description": "Your own secret for signature checking. Use a random string with high entropy."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "200",
            "content": {
              "application/json": {
                "examples": {
                  "Result": {
                    "value": {
                      "enabled": true,
                      "secret": "secret_token"
                    }
                  }
                },
                "schema": {
                  "type": "object",
                  "properties": {
                    "enabled": {
                      "type": "boolean",
                      "example": true,
                      "default": true
                    },
                    "secret": {
                      "type": "string",
                      "example": "secret_token"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "400",
            "content": {
              "application/json": {
                "examples": {
                  "Result": {
                    "value": {}
                  }
                },
                "schema": {
                  "type": "object",
                  "properties": {}
                }
              }
            }
          }
        },
        "deprecated": false,
        "tags": [
          "Webhooks"
        ]
      }
    }
  },
  "x-readme": {
    "headers": [],
    "explorer-enabled": true,
    "proxy-enabled": true
  },
  "tags": [
    {
      "name": "Webhooks",
      "description": "Subscribe to `video_ready` and `video_error` callbacks, and set the HMAC signing secret."
    }
  ],
  "_id": {
    "buffer": {
      "0": 106,
      "1": 154,
      "2": 229,
      "3": 101,
      "4": 201,
      "5": 178,
      "6": 243,
      "7": 252,
      "8": 223,
      "9": 58,
      "10": 69,
      "11": 151
    }
  }
}
````