Signature verification
Every inbound provider webhook is verified before anything is persisted. There is no single scheme,
because there is no single provider — each adapter implements verifyWebhook(rawBody, headers) in
whatever way its vendor actually signs. This page is the exact per-adapter behaviour, so you can tell
a misconfigured secret from an unsupported scheme without reading the adapters.
Two things are true of all of them: verification is over the raw body string, and a failure means
the delivery is rejected with invalid_api_key and never reaches the database.
The nine integration keys
| Key | Header(s), in order tried | Scheme | Prefix |
|---|---|---|---|
instagram.native |
x-hub-signature-256 |
HMAC-SHA256 over raw body, appSecret |
sha256= required |
facebook.native |
x-hub-signature-256 |
HMAC-SHA256 over raw body, appSecret |
sha256= required |
aggregator |
x-late-signature → x-hub-signature-256 → x-signature |
HMAC-SHA256 over raw body, webhookSecret |
optional |
whatsapp |
x-infobip-auth, or x-hub-signature-256 |
shared-secret equality, or HMAC-SHA256 | sha256= required on the HMAC path |
whatsapp_session |
x-openwa-signature → x-webhook-signature → x-signature |
HMAC-SHA256 over raw body, webhookSecret |
optional |
sms |
x-signature |
HMAC-SHA256 over raw body, webhookSecret |
optional |
email |
x-infobip-auth → x-axis-signature |
plain shared-secret equality | n/a |
livechat |
— | always { ok: false } |
— |
ai |
— | always { ok: false } |
— |
Every HMAC comparison is length-checked before timingSafeEqual, which throws on a length mismatch
rather than returning false.
Meta — instagram.native, facebook.native
Both keys are the same MetaAdapter class constructed twice. Verification is strict:
if (!this.cfg.appSecret) return { ok: false };
const sig = headers['x-hub-signature-256'];
if (!sig || !sig.startsWith('sha256=')) return { ok: false };
The sha256= prefix is mandatory here, unlike every other HMAC adapter. A signature sent without
it is rejected even when the hex digest is correct.
Meta also uses the subscription handshake, which is the only challenge these adapters answer:
GET /webhooks/facebook.native?hub.mode=subscribe&hub.verify_token=<token>&hub.challenge=<value>
All three must be present, hub.mode must be exactly subscribe, and hub.verify_token must equal
the configured webhookVerifyToken. The challenge is echoed verbatim as text/plain; anything else
returns null, which the controller turns into a 404.
aggregator
Three headers tried in order, first one present wins. The sha256= prefix is stripped if present and
tolerated if absent. verifyChallenge always returns null — the aggregator has no handshake.
whatsapp
Two accepted schemes, checked in this order:
- Shared secret. If
providerReportSecretis configured andx-infobip-authmatches it in constant time, the delivery is accepted. This is the delivery-report path. - HMAC. Otherwise
x-hub-signature-256is verified as HMAC-SHA256 over the raw body withwebhookSecret, with thesha256=prefix required.
The body is parsed before either check, and a body that is not JSON is rejected outright.
The challenge is Meta-style, because the Cloud API vendors proxy it verbatim — but note the token it compares against:
token === this.cfg.webhookSecret
The verify token is webhookSecret, the same value used for the HMAC. There is no separate
webhookVerifyToken on this adapter, which is a real difference from the Meta adapters.
whatsapp_session
const raw = headers['x-openwa-signature']
?? headers['x-webhook-signature']
?? headers['x-signature'];
The scheme is chosen by which header arrived, not by configuration. That is what lets OpenWA and
WASender-style gateways share one endpoint: whichever the gateway sends is the one verified, against
the same webhookSecret. Adding a third gateway is a header name, not a config branch.
The prefix is optional. verifyWebhook returns { ok: true } with no payload on success, so the
controller's fallback parse is what populates payloadJson — see
Provider webhooks.
Connect-time enforcement is stricter than most: whatsapp_session refuses to connect without a
webhook signing secret, and requires it to be 16–255 characters, because the gateway rejects the
registration otherwise and a session that links but receives nothing is worse than a loud failure.
sms
HMAC-SHA256 on x-signature, prefix optional. One extra rule:
if (!sig && isCorrelatableDeliveryReport(payload)) return { ok: true, payload };
An unsigned delivery report is accepted if it is correlatable — that is, if it carries a provider message id that can be matched against a message inbox already sent. Africa's Talking posts flat form delivery receipts with no signature, and refusing them would mean no SMS delivery status at all.
The safety comes from what such an event is allowed to do. normalizeWebhook deliberately blanks the
account attribution on these:
// Unsigned provider reports are authorized by opaque message-id correlation, not sender
// attribution. Keep this blank so they cannot mutate inbox state through a forged handle.
externalAccountId: '',
So an unsigned report can only update a message whose id the sender already knew — it cannot claim to be from an account.
email — the asymmetry to watch
Email does not use HMAC:
const expected = this.cfg.webhookSecret;
const actual = headers['x-infobip-auth'] ?? headers['x-axis-signature'];
if (!expected || !value || value !== expected) return { ok: false };
It is a plain shared-secret string equality on either header. There is no signature, no
timestamp, and nothing tied to the body: anyone who has the secret can post any payload. It is also a
plain !== rather than a constant-time compare.
Flag this when reviewing: an email webhook secret is a bearer credential, not a signing key. Treat it
with the same care as an API key, rotate it if it is ever logged, and do not assume that a valid
x-infobip-auth says anything about the body's integrity.
livechat and ai
Both return { ok: false } unconditionally, and normalizeWebhook returns []. This is inert by
design, not unimplemented.
LiveChat messages arrive through POST /v1/livechat/:widgetKey/*, where the controller builds the
envelope itself. The AI channel has no vendor at all — messages arrive on the customer's real channel
and replies are generated by dm.sendMessage, never received. There is no payload shape to map,
and ingesting anything down this path would mean trusting an unverified request.
If you are seeing 401s on /webhooks/livechat, nothing is misconfigured; that endpoint is not meant
to work.
The Accounts webhook
Separate from the provider path, and under /v1:
POST /v1/accounts/webhook
@Public(). Verification is x-webhook-signature, HMAC-SHA256 over the raw body with
INBOX_ACCOUNTS_WEBHOOK_SECRET, delegated to @~lyre/auth. The prefix is optional, the digest must
be 64 lowercase hex characters, and the compare is constant-time. A bad signature is a 401.
The gotcha: with no secret configured, the route only parses. It does not reject:
if (secret) { /* verify, 401 on failure */ }
// no secret → parse only, ignore unparseable payloads
That branch exists for local development against a mock Accounts. In any deployment reachable from
the internet, an unset INBOX_ACCOUNTS_WEBHOOK_SECRET means anyone who finds the URL can post
whatever they like. Always set it outside dev.
Only one event type is acted on. Everything else is parsed and dropped:
if (event.type !== 'session.revoked') return;
session.revoked is republished onto the tenant's realtime bus with targetUserId, so the
WebSocket gateway delivers it to exactly that operator's own sockets and
their open tabs log out immediately.
The global bypass
INBOX_SKIP_WEBHOOK_VERIFY=true accepts a delivery whose signature failed, logging:
<integrationKey> signature UNVERIFIED — accepted because INBOX_SKIP_WEBHOOK_VERIFY=true
Several adapters also honour a per-adapter skipVerify flag, which short-circuits inside
verifyWebhook itself.
Never enable this in production. With it on, anyone who knows a webhook URL can inject messages, delivery receipts and status changes into any workspace on the deployment. It exists for local development against a provider whose secret you cannot obtain.
x-axis-signature means two different things
The same header name is used for two unrelated purposes, in opposite directions, with different secrets:
| Direction | Where | Secret | Scheme |
|---|---|---|---|
| Inbound | POST /webhooks/email |
the email adapter's webhookSecret |
plain string equality |
| Outbound | Deliveries inbox sends you | your endpoint's registered secret | sha256= HMAC over ${timestamp}.${rawBody} |
They never appear on the same request, but the name collision is worth knowing before you go looking for one implementation and find the other. Outbound signing is documented on Outbound webhooks.