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 onhttps://localhost:7070; see Developer guide). - Postgres up (
pnpm dev:prepif 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
SmsUrlare all outbound Floh→Twilio calls, so ngrok need not be running for them to succeed. But provisioning writes your public URL into the Ruleprefixand the number'sSmsUrl, so you must know the tunnel domain first and setWEBHOOK_PUBLIC_BASE_URLto it (steps 1–2) — otherwise Twilio is pointed atlocalhostand 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/Testsprobe) — 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:
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:
4. Create the SharedKey signing config (recommended)¶
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— connectorwebhookSigningSecretempty (run Set up webhooks). The log id is spelledauth_tokenfor alert stability.sms.webhook_signature_rejected— HMAC mismatch (SHA256 setting, wrong secret, orTRUST_PROXY/host mismatch).sms.webhook_inbound_classified— success; look foraction(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:
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;"