Twilio Inbound-Webhook Signing — Concepts & Setup Flow¶
A plain-language explainer for what Floh's "Set up webhooks" button does, what
all the Twilio nouns mean, and how a real inbound SMS gets verified. This is a
conceptual overview — the source of truth for behavior is
packages/server/src/modules/connectors/handlers/twilio-support/webhook-provisioning.ts
and packages/server/src/modules/sms/twilio-signature.ts. For setup/operations
see the sibling docs twilio-inbound-webhooks.md
and twilio-inbound-webhooks-local-dev.md.
TL;DR¶
When Twilio POSTs an inbound SMS to Floh, Floh needs to prove the request
really came from Twilio and wasn't forged by some random caller hitting the
public URL. Twilio does this by signing every outbound webhook with a secret
that only Twilio and Floh share, and stamping the result into an
X-Twilio-Signature header. Floh recomputes the same signature and compares.
Setting that up requires creating three linked objects in Twilio (a
SharedKey, a Setting, and a Rule), storing the shared secret in Floh, and
pointing the phone number's SmsUrl at Floh's inbound endpoint. That's the
whole job of the "Set up webhooks" flow.
The cast of characters¶
These are Twilio cloud resources, not files in this repo. The SharedKey, Setting, and Rule below are objects that live in your Twilio account, created/reused over HTTP during provisioning. There is no local config file for them — Floh only stores their ids on the connector row (see §5). All the API calls are in one file:
packages/server/src/modules/connectors/handlers/twilio-support/webhook-provisioning.ts(ensureSharedKey/ensureSetting/ensureRule). To inspect the live objects,curlthe Twilio endpoints (GET https://webhooks.twilio.com/v1/Webhooks/{SharedKeys,Settings,Rules}) with your API Key as HTTP Basic auth.
1. SharedKey (Twilio) + its secret¶
- What: A named key object in Twilio's Webhooks Configuration API
(
https://webhooks.twilio.com/v1/Webhooks/SharedKeys). Creating one returns anid(e.g.webhooks_sharedkey_01m2n…) and asecret. - The secret is the actual HMAC key used to sign/verify webhooks.
- You get the secret exactly once — in the create response. It is unrecoverable afterward. This single fact drives most of the setup's edge-case handling:
- If Floh reuses an existing SharedKey, the secret comes back
null(can't be re-read), so Floh keeps the copy it stored the first time. - If a create somehow returns no secret, Floh fails closed rather than persist an unusable "orphan" key it could never sign with.
- Naming = ownership: Floh names the key
Floh:<connectorId>so each connector owns exactly one key and can find/reuse/retire it idempotently.
2. Setting (Twilio)¶
- What: A Twilio object (
/Webhooks/Settings) that says how to sign: algorithm + which SharedKey. - Floh always creates it with:
signature.type = SHARED_KEYsignature.algorithm = HMAC_SHA1← load-bearing. Floh's verifier is hardcoded to HMAC-SHA1.signature.sharedKeyId = <the SharedKey id>auth = { source: INLINE, value: { scheme: NONE } }(no extra auth layer; the signature is the auth)- Named
Floh:<connectorId>:sha1. - Where it lives: in your Twilio account (cloud), not a repo file — at
https://webhooks.twilio.com/v1/Webhooks/Settings/<id>. Floh creates/reuses it inensureSetting()(webhook-provisioning.ts); the only thing stored locally is the resulting id, aswebhookSettingIdon the connector row.
3. Rule (Twilio)¶
- What: A routing object (
/Webhooks/Rules) that says when to apply a Setting to outbound webhooks, based on the destination URL. - Floh creates a
PREFIX_MATCHRule whoseprefixis the deployment-wide…/api/sms/and whose single target is this connector's Setting at 100%. - Why each attribute matters:
PREFIX_MATCHis load-bearing: real inbound traffic only selectsPREFIX_MATCHRules by prefix. A Rule edited to another match type in the console keeps its prefix + Setting but stops routing live callbacks — yet a direct probe would still pass. Floh treats non-PREFIX_MATCHas "not configured".- Sole 100% target: a Rule that splits, say, 50/50 across two Settings would sign half the real callbacks with a different key and intermittently 403. Floh requires exactly one target at 100%.
- Prefix is deployment-wide, so ownership is by
friendlyName: every Floh Twilio connector on the same public base produces the identical…/api/sms/prefix, and Twilio allows only onePREFIX_MATCHRule per prefix. Matching by prefix alone would let connector B silently repoint connector A's Rule at B's Setting (A's webhooks then 403). Floh matches the Rule by itsFloh:<connectorId>name and fails closed if a different connector already claims the prefix. - Where it lives: a Twilio cloud object, not a repo file — at
https://webhooks.twilio.com/v1/Webhooks/Rules/<id>. Floh creates/repairs it inensureRule()(webhook-provisioning.ts); only the id is stored locally, aswebhookRuleIdon the connector row.
4. The phone number's SmsUrl (Twilio classic REST API)¶
- The
IncomingPhoneNumberresource (https://api.twilio.com/2010-04-01/…) has anSmsUrlfield — the URL Twilio POSTs to when that number receives a text. - Floh sets it to
https://<public-base>/api/sms/inbound/<connectorId>. - Matching is exact E.164; that's why the connector's
fromNumbermust be a valid E.164 number (the server normalizes it before provisioning). - Messaging-Service-only connectors (no
fromNumber) skip this step.
5. What Floh stores (in connector_config)¶
After a successful setup, Floh persists onto the connector row:
| Key | Meaning |
|---|---|
webhookSigningSecret |
The SharedKey secret (encrypted at rest). This is the HMAC key. |
webhookSharedKeyId |
The Twilio SharedKey id (marker, for reuse/status/teardown). |
webhookSettingId |
The Twilio Setting id. |
webhookRuleId |
The Twilio Rule id. |
webhookProvisionedAt |
Timestamp. |
(authToken is the pre-connector-v2 name for the signing secret; the reset
script strips both.)
Signing — how X-Twilio-Signature actually works¶
Twilio (and Floh's verifier) compute the signature like this
(computeTwilioSignature):
- Start with the full destination URL (scheme + host + path + query).
- Take all POST form params, sort keys alphabetically, and append
key + valuefor each — concatenated directly onto the URL string. HMAC-SHA1(signedString, secret), then base64.
Twilio puts that base64 digest in the X-Twilio-Signature header. Floh
recomputes it and compares with crypto.timingSafeEqual (constant-time — a
byte-by-byte === would leak the expected signature).
Two Floh-side subtleties that are pure security hygiene:
- URL reconstruction behind a proxy: Floh rebuilds the signed URL as
${request.protocol}://${request.host}${request.url}, which depends ontrustProxy+X-Forwarded-Proto/-Hostbeing correct. If the reconstructed URL differs from what Twilio signed (wrong host/scheme/port), the HMAC won't match → 403. - Empty-secret guard: an empty signing secret would still produce a valid HMAC against the (publicly known) empty key, so a misconfigured connector is explicitly rejected fail-closed rather than trusted.
What actually happens during "Set up webhooks"¶
Every step is idempotent — it looks for the connector-owned object by
Floh:<connectorId> name first and reuses it, only creating when absent.
- SharedKey — find
Floh:<connectorId>; reuse if present (secret isnull, Floh keeps its stored copy), else create and capture the one-timesecret. - Setting — find an HMAC-SHA1 Setting bound to that SharedKey; else create
Floh:<connectorId>:sha1. - Rule — find/repair the connector's
PREFIX_MATCHRule for…/api/sms/targeting the Setting at 100%; create if absent; fail closed if a foreign connector owns the prefix. - Phone number — set the number's
SmsUrlto…/api/sms/inbound/<connectorId>(skipped for Messaging-Service connectors). - Persist — store
webhookSigningSecret+ the id markers on the connector.
Rotation / self-heal (rotate: true): Twilio secrets are one-time, so you
can't "re-read" a lost secret. To recover, Floh retires the existing SharedKey
(renames it …:retired:…, never deletes — old in-flight callbacks keep
verifying) and mints a fresh key + secret.
The status probe — what "Configured" means on the badge¶
Floh doesn't just trust the stored markers; it asks Twilio to prove it via
POST /Webhooks/Tests. That endpoint makes a real synthetic HTTP request
through the public URL to Floh's POST /api/sms/inbound/<connectorId>, signs it
with the configured Setting, and reports the HTTP status Floh returned.
The badge reads Configured only when all of these hold, checked in order:
- A signing secret is stored.
- The SharedKey exists.
- An HMAC-SHA1 Setting bound to it exists.
- A
PREFIX_MATCHRule owned by this connector targets that Setting at 100% for the right…/api/sms/prefix. - The
Webhooks/Testsprobe returns HTTP 200 (i.e. Floh accepted the signed synthetic request).
If the probe returns non-200, the badge is Not configured with the reason
Twilio Webhooks/Tests signature probe returned HTTP ### (expected 200).
⚠️ Local-dev gotcha: the probe travels the public URL. In local dev that's your ngrok tunnel. If ngrok isn't running, Twilio's synthetic request hits a dead tunnel and gets a 404/502 → the badge stays "Not configured" even though provisioning fully succeeded. Provisioning itself is server→Twilio and needs no tunnel; verification + real inbound delivery do.
Setup flow diagram (Floh ⇄ Twilio)¶
sequenceDiagram
autonumber
actor Admin
participant Web as Floh Web (console)
participant API as Floh API (:7070)
participant TWH as Twilio Webhooks API<br/>(webhooks.twilio.com)
participant TREST as Twilio REST API<br/>(api.twilio.com)
participant DB as Floh DB<br/>(connector_config)
Admin->>Web: Click "Set up webhooks"
Web->>API: POST /connectors/:id/provision-webhooks
Note over API: Normalize fromNumber to E.164<br/>(reject early with 400 if invalid)
rect rgb(235,245,255)
Note over API,TWH: All lookups are idempotent — reuse "Floh:<connectorId>" objects
API->>TWH: GET/POST /SharedKeys (name "Floh:<id>")
TWH-->>API: { id, secret } ← secret returned ONCE
API->>TWH: GET/POST /Settings (HMAC_SHA1 + sharedKeyId)
TWH-->>API: { settingId }
API->>TWH: GET/POST /Rules (PREFIX_MATCH .../api/sms/ → setting @100%)
TWH-->>API: { ruleId }
end
API->>TREST: POST /IncomingPhoneNumbers/:sid<br/>SmsUrl = .../api/sms/inbound/<id>
TREST-->>API: 200 OK
API->>DB: store webhookSigningSecret (encrypted) + id markers
API-->>Web: result (sharedKeyCreated, signingSecretStored, …)
Note over Web,TWH: Status check proves it end-to-end
Web->>API: GET /connectors/:id/webhooks-status
API->>TWH: POST /Webhooks/Tests { url, settingId }
TWH->>API: (synthetic) POST /api/sms/inbound/<id><br/>signed with Setting → travels PUBLIC url (ngrok)
API-->>TWH: 200 (signature verified) / 403 (mismatch)
TWH-->>API: probe result { statusCode }
API-->>Web: Configured (probe 200) / Not configured (+reason)
Runtime: a real inbound SMS¶
sequenceDiagram
autonumber
actor User as Phone user
participant TW as Twilio
participant NG as ngrok tunnel<br/>(local dev only)
participant API as Floh API
participant DB as Floh DB
User->>TW: Text the Twilio number
Note over TW: Rule prefix-matches .../api/sms/<br/>→ sign with connector's Setting (HMAC-SHA1)
TW->>NG: POST SmsUrl + X-Twilio-Signature
NG->>API: POST /api/sms/inbound/:connectorId
API->>DB: look up connector, decrypt webhookSigningSecret
Note over API: reconstruct public URL (protocol+host+url)<br/>HMAC-SHA1(url + sorted params, secret) → base64<br/>timingSafeEqual vs X-Twilio-Signature
alt signature matches
API-->>TW: 200 — process inbound message
else mismatch / missing / empty secret
API-->>TW: 403 — log sms.webhook_signature_rejected
end
Common failure modes¶
| Symptom | Likely cause |
|---|---|
| Badge stuck "Not configured" right after a successful setup | ngrok not running → probe hits dead tunnel (404/502). Start ngrok, re-check. |
| Provisioning 400 before anything happens | fromNumber not valid E.164. |
| Real inbound texts 403 while the probe is green | Rule edited in console (non-PREFIX_MATCH, or split <100%, or prefix stolen by another connector). |
| 403 behind a proxy with everything "correct" | Reconstructed URL ≠ signed URL — check trustProxy, X-Forwarded-Proto/-Host, port. |
| "SharedKey create returned no secret" | Fail-closed guard — Twilio returned a key with no one-time secret; nothing persisted. |
Resetting to a clean slate¶
To demo the flow from scratch, tear down the Twilio objects + clear the Floh markers:
pnpm unset-twilio-webhooks -- --dry-run # preview
pnpm unset-twilio-webhooks # do it (creds + CONNECTOR_ID from .env)
It deletes in dependency order (Rules → Settings → SharedKeys, including
:retired: keys), blanks the number's SmsUrl, and strips
webhookSigningSecret + the markers so the badge returns to "Not configured".