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 a200with the same body, so nothing distinguishes stored from dropped. Deliveries then arrive unsigned while you believe they are signed. Verify by re-readingGET /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.
| Time | Status | User Agent | |
|---|---|---|---|
Retrieving recent requests… | |||
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, or while the webhook is switched off, and the secret is
discarded — you still get a200with 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.
What we sign
Two headers are added to each delivery once a secret is set:
Elai-Timestamp— milliseconds since the epoch, as a plain integerElai-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.parsethen
JSON.stringifyround-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 alwaysundefined.
A verifier that works
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.
