Skip to content

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, curl the 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 an id (e.g. webhooks_sharedkey_01m2n…) and a secret.
  • 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_KEY
  • signature.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 in ensureSetting() (webhook-provisioning.ts); the only thing stored locally is the resulting id, as webhookSettingId on 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_MATCH Rule whose prefix is the deployment-wide …/api/sms/ and whose single target is this connector's Setting at 100%.
  • Why each attribute matters:
  • PREFIX_MATCH is load-bearing: real inbound traffic only selects PREFIX_MATCH Rules 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_MATCH as "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 one PREFIX_MATCH Rule 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 its Floh:<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 in ensureRule() (webhook-provisioning.ts); only the id is stored locally, as webhookRuleId on the connector row.

4. The phone number's SmsUrl (Twilio classic REST API)

  • The IncomingPhoneNumber resource (https://api.twilio.com/2010-04-01/…) has an SmsUrl field — 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 fromNumber must 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):

  1. Start with the full destination URL (scheme + host + path + query).
  2. Take all POST form params, sort keys alphabetically, and append key + value for each — concatenated directly onto the URL string.
  3. 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 on trustProxy + X-Forwarded-Proto/-Host being 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.

  1. SharedKey — find Floh:<connectorId>; reuse if present (secret is null, Floh keeps its stored copy), else create and capture the one-time secret.
  2. Setting — find an HMAC-SHA1 Setting bound to that SharedKey; else create Floh:<connectorId>:sha1.
  3. Rule — find/repair the connector's PREFIX_MATCH Rule for …/api/sms/ targeting the Setting at 100%; create if absent; fail closed if a foreign connector owns the prefix.
  4. Phone number — set the number's SmsUrl to …/api/sms/inbound/<connectorId> (skipped for Messaging-Service connectors).
  5. 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:

  1. A signing secret is stored.
  2. The SharedKey exists.
  3. An HMAC-SHA1 Setting bound to it exists.
  4. A PREFIX_MATCH Rule owned by this connector targets that Setting at 100% for the right …/api/sms/ prefix.
  5. The Webhooks/Tests probe 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:&lt;connectorId&gt;" 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".