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-signaturex-hub-signature-256x-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-signaturex-webhook-signaturex-signature HMAC-SHA256 over raw body, webhookSecret optional
sms x-signature HMAC-SHA256 over raw body, webhookSecret optional
email x-infobip-authx-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:

  1. Shared secret. If providerReportSecret is configured and x-infobip-auth matches it in constant time, the delivery is accepted. This is the delivery-report path.
  2. HMAC. Otherwise x-hub-signature-256 is verified as HMAC-SHA256 over the raw body with webhookSecret, with the sha256= 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.