Skip to content

Twilio Inbound SMS Webhooks — Local Dev

This guide shows how to exercise Twilio inbound SMS webhooks (STOP/START/HELP opt-out) against a local Floh dev server. Twilio must reach a public HTTPS URL, so the only extra piece versus a deployed instance is a tunnel (ngrok) from the public internet to your laptop.

For the conceptual and security model — how Floh verifies the signature, the credential options (SharedKey vs Auth Token), the hidden and server-managed webhookSigningSecret field, and the recommendation to keep the account Auth Token out of Floh — read Twilio Inbound SMS Webhooks (self-hosted setup) first. This page is the local-testing procedure plus a developer reference on the endpoints and tables involved.

Prerequisites

  • The Floh dev stack running over HTTPS: pnpm dev:https (the API server listens on https://localhost:7070; see Developer guide).
  • Postgres up (pnpm dev:prep if needed).
  • A Twilio account with an API Key (SK… + secret). An SMS-capable Twilio number is required only for the real end-to-end STOP test (step 8).

When do you need ngrok?

ngrok gives Twilio a public HTTPS URL that forwards to your laptop. It is only on the request path when Twilio calls Floh, never when Floh calls Twilio:

  • Provisioning ("Set up webhooks") — tunnel not on the path. Creating the SharedKey / Setting / Rule and pointing the number's SmsUrl are all outbound Floh→Twilio calls, so ngrok need not be running for them to succeed. But provisioning writes your public URL into the Rule prefix and the number's SmsUrl, so you must know the tunnel domain first and set WEBHOOK_PUBLIC_BASE_URL to it (steps 1–2) — otherwise Twilio is pointed at localhost and can never deliver.
  • Resetting (pnpm unset-twilio-webhooks) — no tunnel. Teardown is outbound Floh→Twilio deletes plus a local DB write; ngrok is irrelevant.
  • Verifying (the status badge / Webhooks/Tests probe) — ngrok must be running. The probe makes Twilio send a real synthetic signed request to your public inbound URL; with the tunnel down it hits a dead address and the badge stays Not configured even though provisioning succeeded.
  • Real STOP / START / HELP — ngrok must be running. Inbound texts are delivered Twilio→Floh over the public URL.

In short: bring the tunnel up to verify setup and to receive real inbound messages; provisioning and resetting only need to know the tunnel URL, not traverse it.

1. Expose the local server with ngrok

ngrok gives your laptop a public HTTPS URL and forwards traffic to a local port.

brew install ngrok
ngrok config add-authtoken {YOUR_NGROK_AUTHTOKEN}
ngrok http https://localhost:7070

Point ngrok at the https:// upstream because the dev server runs TLS. ngrok treats the local network as private and does not verify the upstream (self-signed) certificate by default; if you ever see an upstream TLS error, add --upstream-tls-verify=false.

ngrok prints a forwarding line like:

Forwarding  https://<subdomain>.ngrok-free.dev -> https://localhost:7070

That https://<subdomain>.ngrok-free.dev is your public base URL. Leave ngrok running; its request inspector is at http://127.0.0.1:4040.

2. Enable TRUST_PROXY and restart

Floh verifies the signature over the URL it reconstructs from the request. Behind ngrok the public host arrives in X-Forwarded-Host / X-Forwarded-Proto, and Fastify only honors those when TRUST_PROXY=true. Without it Floh reconstructs https://localhost:7070/..., which will not match the URL Twilio signed, and requests 403 with sms.webhook_signature_rejected.

Set TRUST_PROXY=true in your .env and restart the dev server (config is read once at boot). Note that config/public/base.env also sets TRUST_PROXY=true and overrides local .env in the layered config (see Configuration).

While you have .env open, also set WEBHOOK_PUBLIC_BASE_URL to your ngrok domain (e.g. WEBHOOK_PUBLIC_BASE_URL=https://<subdomain>.ngrok-free.dev). This is the public HTTPS origin Twilio is pointed at; the dev server's FRONTEND_URL is localhost, which Twilio rejects. It is optional and left blank in deployed Floh (callers fall back to FRONTEND_URL), but setting it locally lets the console "Set up webhooks" button work without a per-request override — the button has no way to pass one. It must be HTTPS. Restart the dev server after changing it (config is read once at boot).

3. Find your connectorId

The :connectorId is the Twilio connector's connector_definition.id. Easiest: open the connector in the Floh console and read the UUID from the page URL (.../connectors/<connectorId>). Or list connectors from Postgres read-only and pick yours by the name you gave it:

docker exec -e PGPASSWORD=floh_secret {postgres_container} \
  psql -U floh -d floh -c "SELECT id, name FROM connector_definition ORDER BY name;"

(Local dev DB defaults from .env: db floh, user floh, password floh_secret. There is no top-level connector table — connector instances live in connector_definition, and the connector kind is a type_id FK into connector_type, not a slug column.)

Your inbound URL is then:

https://<subdomain>.ngrok-free.dev/api/sms/inbound/<connectorId>

Fastest path: let Floh provision everything in one call with POST /api/connectors/:id/provision-webhooks (the connector must already hold accountSid + an API Key pair). The provision route resolves the public base URL in this order: the request body's publicBaseUrl (if any) → WEBHOOK_PUBLIC_BASE_URL (the env var from step 2) → FRONTEND_URL. If you set WEBHOOK_PUBLIC_BASE_URL in step 2 you can omit publicBaseUrl entirely (and the console button just works). The curl below passes it explicitly so it stands alone — the dev server's FRONTEND_URL is localhost, which Twilio rejects, so one of the two must point at the tunnel:

curl -X POST "https://<subdomain>.ngrok-free.dev/api/connectors/<connectorId>/provision-webhooks" \
     -H "Authorization: Bearer <step-up-session-token>" \
     -H "Content-Type: application/json" \
     -d '{ "publicBaseUrl": "https://<subdomain>.ngrok-free.dev" }'

On success Floh stores the signing secret into the connector's webhookSigningSecret automatically (secret: null, signingSecretStored: true), so you can skip to step 5 to verify — no copy-paste. (If the response comes back signingSecretStored: false, the field is hidden and server-managed, so just re-run Set up webhooks — it self-heals by minting + storing a fresh secret; there is no field to paste into.) Or run it from the console button: Connectors → your Twilio connector → Configuration → Inbound webhooks → Set up webhooks. Re-run after an ngrok restart to repoint the Rule (it is idempotent; a re-run leaves the stored secret untouched). Check state any time with GET /api/connectors/<connectorId>/webhooks-status.

If an earlier interrupted run left a Floh:<connectorId> SharedKey in Twilio but the connector's webhookSigningSecret is empty (common after a dev:https crash mid-provision), a plain re-run would keep reusing that secret-less key and never populate webhookSigningSecret. Floh self-heals this: it detects the missing stored secret, retires the stale key, and mints a fresh one automatically ("rotated": true in the response). You can also force this from the console's Rotate signing key button (shown once the badge reads Configured) or with {"rotate": true} in the request body — see Rotate the signing key.

Manual equivalent (only if provisioning by hand)

The automated button/endpoint above is the supported path. The curl sequence below is the by-hand equivalent — use it only as a fallback or to inspect each Twilio resource individually. It mirrors Setup A in the self-hosted guide, using your ngrok domain in the Rule prefix. All calls authenticate with -u {API_KEY_SID}:{API_KEY_SECRET}.

Use the connector-scoped friendlyNames exactly — Floh discovers these resources only by name: the SharedKey and Rule as Floh:<connectorId> and the Setting as Floh:<connectorId>:sha1 (<connectorId> is the connector's UUID). A generic name (or a missing Rule friendlyName) makes the status badge read Not configured and makes a later automated Set up webhooks run fail closed with 409 against the same-prefix, differently-named Rule.

# 1. Create the SharedKey — save the returned "secret" (shown once).
curl -X POST https://webhooks.twilio.com/v1/Webhooks/SharedKeys \
     -u {API_KEY_SID}:{API_KEY_SECRET} \
     -H 'Content-Type: application/json' \
     -d '{ "friendlyName": "Floh:<connectorId>" }'

# 2. Create a HMAC_SHA1 Setting referencing that key.
curl -X POST https://webhooks.twilio.com/v1/Webhooks/Settings \
     -u {API_KEY_SID}:{API_KEY_SECRET} \
     -H 'Content-Type: application/json' \
     -d '{
       "friendlyName": "Floh:<connectorId>:sha1",
       "auth": { "source": "INLINE", "value": { "scheme": "NONE" } },
       "signature": { "type": "SHARED_KEY", "sharedKeyId": "{SHARED_KEY_ID}", "algorithm": "HMAC_SHA1" }
     }'

# 3. Route your ngrok /api/sms/ prefix to the Setting; poll the Operation to COMPLETED.
curl -X POST https://webhooks.twilio.com/v1/Webhooks/Rules \
     -u {API_KEY_SID}:{API_KEY_SECRET} \
     -H 'Content-Type: application/json' \
     -d '{
       "friendlyName": "Floh:<connectorId>",
       "matchType": "PREFIX_MATCH",
       "prefix": "https://<subdomain>.ngrok-free.dev/api/sms/",
       "settings": [ { "settingId": "{SETTING_ID}", "percentage": 100 } ]
     }'

algorithm must be HMAC_SHA1 — Floh's verifier is SHA1-only. Because the signing field is hidden, prefer the automated Set up webhooks button/endpoint (which stores the secret for you) over the manual sequence. To store a hand-minted SharedKey secret, PUT /api/connectors/<connectorId> with the secret under config. That PUT replaces the whole config, so resend the declared fields you want to keep (accountSid, apiKeySid, apiKeySecret as the ******** mask, fromNumber) alongside it — omitting them erases the connector's REST credentials (watch for a trailing space/newline on the secret):

curl -sX PUT https://<subdomain>.ngrok-free.dev/api/connectors/<connectorId> \
     -H "Authorization: Bearer <token>" \
     -H "Content-Type: application/json" \
     -d '{
       "config": {
         "accountSid": "AC…",
         "apiKeySid": "SK…",
         "apiKeySecret": "********",
         "fromNumber": "+1…",
         "webhookSigningSecret": "<secret>"
       }
     }'

Prefer this over storing your account Auth Token locally: the SharedKey secret only signs webhooks, whereas the Auth Token controls the whole Twilio account.

5. Signature-only check with Webhooks/Tests

Webhooks/Tests signs a synthetic request with your Setting and posts it to the URL — a fast way to confirm verification without a phone:

curl -X POST https://webhooks.twilio.com/v1/Webhooks/Tests \
     -u {API_KEY_SID}:{API_KEY_SECRET} \
     -H 'Content-Type: application/json' \
     -d '{
       "url": "https://<subdomain>.ngrok-free.dev/api/sms/inbound/<connectorId>",
       "settingId": "{SETTING_ID}",
       "method": "POST"
     }'

Read the "response":{"statusCode":...} field: 200 means the signature verified. The synthetic payload carries no From/Body, so the server logs a 200 with sms.webhook_inbound_unparseable_sender — expected, and still proof the signature passed.

6. Read the server logs

The pnpm dev:https terminal prints structured pino lines. Map them to stages:

  • sms.webhook_connector_auth_token_missing — connector webhookSigningSecret empty (run Set up webhooks). The log id is spelled auth_token for alert stability.
  • sms.webhook_signature_rejected — HMAC mismatch (SHA256 setting, wrong secret, or TRUST_PROXY/host mismatch).
  • sms.webhook_inbound_classified — success; look for action (opt_out / opt_in / help / noop).

The ngrok inspector (http://127.0.0.1:4040) shows the exact forwarded headers (confirm X-Forwarded-Host is your ngrok domain) and lets you replay a request without spending another Twilio call.

7. Point a Twilio number at the tunnel

To test real inbound texts, set your number's inbound webhook to the ngrok URL (Twilio Console → Phone Numbers → your number → Messaging → "A message comes in" → Webhook, POST), or via API:

curl -X POST "https://api.twilio.com/2010-04-01/Accounts/{ACCOUNT_SID}/IncomingPhoneNumbers/{PN_SID}.json" \
     -u {API_KEY_SID}:{API_KEY_SECRET} \
     --data-urlencode "SmsUrl=https://<subdomain>.ngrok-free.dev/api/sms/inbound/<connectorId>" \
     --data-urlencode "SmsMethod=POST"

8. Real STOP / START test

Text STOP from your phone to the Twilio number. You should see sms.webhook_inbound_classified with action: "opt_out", and a follow-up send to that number is blocked (recipient_opted_out). Text START to opt back in (action: "opt_in").

Gotcha: ngrok subdomain changes on restart

On the free plan the ngrok subdomain is random and changes every time you restart ngrok. When it changes you must update both the Webhook Rule prefix and the number's SmsUrl to the new domain, or Twilio signs/delivers to a stale URL.

Cleanup

The fastest reset is the repo script, which needs no tunnel (it is Floh→Twilio deletes plus a local DB write) and reads the connector id and Twilio creds from your .env:

pnpm unset-twilio-webhooks -- --dry-run   # preview
pnpm unset-twilio-webhooks                # execute

It deletes the Rule → Setting → SharedKey (including :retired: keys), blanks the number's SmsUrl, and strips webhookSigningSecret + the id markers. To do it by hand instead, tear down in the same dependency order (see the self-hosted cleanup section): delete the Rule → Setting → SharedKey, blank the number's SmsUrl (--data-urlencode "SmsUrl="), and clear the connector's webhookSigningSecret via the config API. Either way, revert TRUST_PROXY in .env if you only enabled it for testing.


Reference: Floh API endpoints involved

Method + path Purpose Source
POST /api/sms/inbound/:connectorId Inbound keyword routing (STOP/START/HELP) packages/server/src/modules/sms/webhook-inbound-handler.ts
POST /api/sms/webhook/:connectorId Delivery-status callbacks (set automatically on each send) packages/server/src/modules/sms/webhook-status-handler.ts
PUT /api/connectors/:id Save the connector's credentials — API Key pair + hidden webhookSigningSecret (connector:manage + step-up) packages/server/src/modules/connectors/routes.ts
POST /api/connectors/:id/provision-webhooks Idempotently provision SharedKey + HMAC_SHA1 Setting + Rule + number SmsUrl; auto-stores the signing secret into webhookSigningSecret (returns it once only if that store fails). Pass {"rotate": true} to retire the current key and mint a fresh one; also self-heals automatically when a key exists but no stored secret does (connector:manage + step-up) packages/server/src/modules/connectors/handlers/twilio-support/webhook-provisioning.ts
GET /api/connectors/:id/webhooks-status Report configured/not_configured via an authoritative Twilio Webhooks/Tests probe (connector:read) packages/server/src/modules/connectors/handlers/twilio-support/webhook-provisioning.ts
POST /api/connectors/:id/test Optional Test Connection packages/server/src/modules/connectors/routes.ts
GET /api/connectors, GET /api/connectors/:id Read connectors (connector:read) packages/server/src/modules/connectors/routes.ts

Reference: database tables written

Table When written Source
sms_opt_out INSERT on opt-out, DELETE on opt-in packages/server/src/modules/sms/opt-out-store.ts
sms_log INSERT on send, UPDATE on status callback packages/server/src/modules/sms/sms-log-repository.ts
audit_log Append-only sms.sent / sms.delivered / sms.failed / sms.opt_out / sms.opt_in packages/server/src/modules/sms/sms-audit-events.ts (via AuditService.log)
connector_definition UPDATE when credentials / webhookSigningSecret are saved (PUT /api/connectors/:id) packages/server/src/modules/connectors/repository.ts

The opt-out store hashes the recipient's E.164 number (HMAC-SHA256) before storing, so sms_opt_out holds no cleartext phone numbers — you cannot grep it by raw number. Sample read-only inspection queries:

# sms_opt_out: one row per (connector, recipient). `recorded_at` is the timestamp
# column; the recipient is only present as an encrypted blob + HMAC hash.
docker exec -e PGPASSWORD=floh_secret {postgres_container} \
  psql -U floh -d floh -c "SELECT connector_id, source, recorded_at FROM sms_opt_out ORDER BY recorded_at DESC LIMIT 10;"

docker exec -e PGPASSWORD=floh_secret {postgres_container} \
  psql -U floh -d floh -c "SELECT id, status, updated_at FROM sms_log ORDER BY updated_at DESC LIMIT 10;"