Twilio Inbound SMS Webhooks (self-hosted setup)¶
This guide covers configuring Twilio inbound SMS webhooks against a
self-hosted Floh instance: the channel that lets recipients reply STOP /
START / HELP to your Twilio number and have Floh honor the opt-out, plus
the delivery-status callbacks Twilio posts back for each message.
It is the operator-facing companion to
SMS Provider Connectors (connector creation, Verify, A2P
10DLC) and to Twilio Inbound Webhooks — Local Dev
(testing the same flow on a laptop with a tunnel). Read sms-providers.md
first to create the connector; this page assumes it exists.
What inbound webhooks do¶
flowchart LR
userPhone["Recipient phone"] -->|"texts STOP"| twilio["Twilio (owns your number)"]
twilio -->|"POST + X-Twilio-Signature"| inbound["Floh POST /api/sms/inbound/:connectorId"]
inbound --> verify["Verify HMAC-SHA1 signature"]
verify --> classify["Classify keyword"]
classify --> optout["Opt-out store (sms_opt_out)"]
Two routes are involved, both under the CSRF-exempt /api/sms prefix:
POST /api/sms/inbound/:connectorId— inbound messages (STOP/START/HELP keyword routing).POST /api/sms/webhook/:connectorId— delivery-status callbacks. Floh sets this URL automatically on each outbound send; you do not configure it on the number.
:connectorId is the Twilio connector's UUID (visible in the Floh console
connector URL, e.g. .../connectors/<connectorId>).
How Floh verifies a Twilio webhook¶
Every inbound request must carry a valid X-Twilio-Signature. Floh recomputes
the signature and rejects anything that does not match with 403 Forbidden.
The scheme is Twilio's legacy request-validation algorithm:
- HMAC-SHA1 over the reconstructed public URL plus the alphabetically
sorted form parameters, base64-encoded, compared with
crypto.timingSafeEqual(packages/server/src/modules/sms/twilio-signature.ts). - The HMAC key is the connector's
webhookSigningSecretfield (packages/server/src/modules/sms/twilio-connector-resolver.ts). It is hidden and server-managed — written only by webhook provisioning / rotation and read only by the signature verifier, so it never appears as an editable field in a connector-config form. - The URL is reconstructed as
`${request.protocol}://${request.host}${request.url}`(packages/server/src/modules/sms/webhook-helpers.ts), which is why a TLS-terminating reverse proxy requiresTRUST_PROXY— see Reverse proxy and TRUST_PROXY below.
Failure modes surface as distinct structured log events, which are the fastest
way to diagnose a 403:
Log event (msg) |
Meaning |
|---|---|
sms.webhook_connector_auth_token_missing |
The connector's webhookSigningSecret is empty. Floh rejects before checking the signature — there is no key to verify against. (The log id is spelled auth_token for operator-alert stability.) |
sms.webhook_signature_rejected |
webhookSigningSecret is set but the computed HMAC did not match (wrong secret, wrong algorithm, or a URL/host mismatch). |
sms.webhook_inbound_classified |
Success — the signature verified and the keyword was classified. |
Credential model: choose what Floh holds¶
The connector's webhookSigningSecret field is the HMAC key Floh uses to verify
inbound webhooks. It is populated automatically by provisioning/rotation. The
credentials below have very different blast radii.
| Credential | What it can do in Twilio | Role in Floh |
|---|---|---|
| Account Auth Token | Everything: send, buy/release numbers, read billing, create sub-accounts, mint/revoke API Keys. It is the master account credential. | Signs webhooks only — not a REST credential (cannot send). |
API Key (SID SK… + secret) |
Scoped, independently revocable. Can send SMS; cannot reach account-management endpoints. | Required for sending, Verify, and webhook provisioning. |
| SharedKey secret | Nothing except signing webhooks. Scoped to webhook signature validation, independently rotatable. | Webhook signature verification only (auto-stored in webhookSigningSecret). |
Recommended (least privilege): API Key + SharedKey¶
Send with an API Key, and verify inbound webhooks with a SharedKey whose
secret Floh auto-stores in webhookSigningSecret. With this pairing the account
Auth Token — the credential that can read billing and manage the whole account —
never touches Floh's database. If Floh's connector store or
CONNECTOR_ENCRYPTION_KEY were ever compromised, the exposure is a send-capable
API Key and a webhook-signing secret, both revocable in isolation, not the master
account.
Configure this with a single call to the automated provisioning endpoint, which mints the SharedKey and stores its secret for you. Because the signing field is hidden, the manual Setup A sequence ends with the automated store rather than pasting the secret into a field.
Signing with the account Auth Token (signing-only)¶
Twilio signs inbound webhooks with the account Auth Token by default, so a
connector whose webhookSigningSecret holds the account Auth Token verifies
webhooks with zero SharedKey setup. This covers signing only — sending
requires an API Key — and, because the field is hidden, the account Auth Token
must be written via the connector-config API rather than typed into the console.
Prefer the automated API Key + SharedKey path above.
Least-privilege warning. The Auth Token is the master Twilio credential. Storing it in Floh means Floh's connector store holds a webhook-signing key that is also full control of the Twilio account. Prefer the API Key + SharedKey pairing above for anything beyond a throwaway trial.
SharedKey caveats (read before choosing Setup A)¶
The SharedKey path relies on Twilio's Webhooks configuration API, and Floh's verifier has specific constraints:
- Twilio Public Beta. The Webhooks Settings/Rules/SharedKeys API is a Public Beta product with no SLA; its shape may change.
- HMAC-SHA1 only. Floh's verifier is hardcoded to SHA1 (
createHmac("sha1", …)). When you create the Webhook Setting you must choosealgorithm: "HMAC_SHA1". Twilio's preferredHMAC_SHA256will not verify and every request will403. - Single stored secret. Floh keys off the one
webhookSigningSecretvalue and ignores theX-Twilio-Signature-Key-Sidheader, so it cannot mix account-token and shared-key signing or run a dual-key rotation. You must route all/api/sms/webhooks to the shared key with a Rule (below). If the Rule is missing, Twilio falls back to signing with the account Auth Token — which you are deliberately not storing — and Floh will403.
Automated setup: one API call (recommended)¶
Floh can perform the entire SharedKey → Setting → Rule → number-webhook sequence for you, idempotently, and store the resulting signing secret into the connector automatically — no copy-paste. This is the supported path. The by-hand Setup A, Setup B, and manual rotation sections below exist only for provisioning without the console/endpoint, or for inspecting and troubleshooting an individual Twilio resource — they are not needed in a normal setup.
The quickest way to run it is the console button — see From the console below. The endpoint is also callable directly, as follows.
Prerequisites (set on the Twilio connector first, per SMS Provider Connectors):
accountSid(AC…).- An API Key pair:
apiKeySid(SK…) +apiKeySecret. Provisioning requires an API Key — it authenticates the Twilio Webhooks admin calls — and the account Auth Token is deliberately not usable here. fromNumber(E.164) ormessagingServiceSid. WhenfromNumberis set, Floh also points that number'sSmsUrlat the inbound route.
Provision with POST /api/connectors/:id/provision-webhooks (requires the
connector:manage permission and a step-up / MFA session, because a fresh
signing secret may be returned once on the fallback path):
curl -X POST "https://<your-floh-domain>/api/connectors/<connectorId>/provision-webhooks" \
-H "Authorization: Bearer <step-up-session-token>" \
-H "Content-Type: application/json" \
-d '{}'
The base URL Twilio is pointed at resolves in this order: the optional body
field publicBaseUrl (must be https) → the WEBHOOK_PUBLIC_BASE_URL env var
(must be https; null/blank when unset) → the server's configured
FRONTEND_URL. Floh derives the inbound URL
<base>/api/sms/inbound/<connectorId> and signs the <base>/api/sms/ prefix.
In a normal deployment where Floh is served from its public origin, leave
WEBHOOK_PUBLIC_BASE_URL blank so the base falls back to FRONTEND_URL. Set it
only when the provider-facing host differs from FRONTEND_URL — most commonly
local dev behind an ngrok tunnel.
Public-config deployments: when the server loads a public-config bundle (
PUBLIC_CONFIG_DIRset withALLOW_LEGACY_ENV_NON_SECRETunset/false, outsideNODE_ENV=development), non-secret keys are read only from the bundle — aWEBHOOK_PUBLIC_BASE_URLplaced inprocess.envis ignored (you will see a "Ignoring non-secret process.env keys" startup warning) and the base silently falls back toFRONTEND_URL. In that mode setWEBHOOK_PUBLIC_BASE_URLin your public bundle (config/public/<APP_ENV>.env, alongsideFRONTEND_URL), not the process environment. Only the URL path-free scheme+host origin is accepted (no path, userinfo, query, or fragment), and the host must be public.
On success Floh stores the freshly-minted SharedKey secret directly into the
connector's encrypted webhookSigningSecret, so the response's secret is null
and signingSecretStored is true — nothing to copy:
{
"sharedKeyId": "webhooks_sharedkey_…",
"settingId": "webhooks_setting_…",
"ruleId": "webhooks_rule_…",
"secret": null,
"signingSecretStored": true,
"sharedKeyCreated": true,
"rotated": false,
"numberConfigured": true,
"inboundUrl": "https://<your-floh-domain>/api/sms/inbound/<connectorId>"
}
Auto-store fallback. In the rare case Floh mints the secret but cannot
persist it (e.g. a database write failure), the response returns
signingSecretStored: false. Because the signing field is hidden and
server-managed, there is nothing to paste — recover by running Set up
webhooks again (or Rotate signing key), which retires the unstored key and
mints + stores a fresh one. (The response also returns the raw secret once as a
last-resort diagnostic, but the supported recovery is to re-run, not to store it
by hand.)
Idempotent. A second call reuses the SharedKey, Setting, and Rule that Floh
created (matched by a deterministic Floh:<connectorId> friendly name), so it
never duplicates them. Because Twilio discloses a SharedKey secret only when the
key is created, a re-run returns secret: null, signingSecretStored: false,
and sharedKeyCreated: false; the already-stored webhookSigningSecret is left untouched.
Self-heal. The one exception to plain reuse: if a re-run finds the
Floh:<connectorId> SharedKey but the connector has no usable stored signing
secret (empty webhookSigningSecret), reusing the key would return secret: null
forever — inbound verification could never work. In that case provisioning
automatically rotates — it retires the stale key and mints a fresh one so
the secret can be auto-stored — exactly as if you had passed {"rotate": true}.
The response reports "rotated": true. This recovers the orphaned-key state
that a provisioning run interrupted before it persisted the secret would
otherwise leave behind.
Check status any time with GET /api/connectors/:id/webhooks-status
(requires connector:read):
curl -s "https://<your-floh-domain>/api/connectors/<connectorId>/webhooks-status" \
-H "Authorization: Bearer <session-token>"
It returns {"status":"configured", …} only when BOTH (a) the connector's
own PREFIX_MATCH Rule (friendlyName Floh:<connectorId>) is present and
currently targets the connector's HMAC_SHA1 Setting, and (b) an authoritative
Twilio Webhooks/Tests probe verifies the signature (HTTP 200 at Floh);
otherwise {"status":"not_configured", …} with a reason and the observed
probeStatus. The Rule check runs first because Webhooks/Tests signs with the
Setting directly and would return 200 even when no Rule (or a stale Rule
pointing at an older Setting) routes real inbound traffic through it — so the
probe alone can be a false positive. It re-derives the Setting from Twilio when
the stored marker is absent, and probes the exact inbound URL persisted at
provision time (webhookInboundUrl), so it never reports configured on the
strength of a stale marker alone or a base URL that diverges from what was
provisioned. A not_configured result is the signal that setup has not been
completed for a connector before it sends SMS.
From the console (button + badge)¶
For Twilio connectors, the Floh console surfaces this endpoint directly on the
connector detail page — no curl needed. Open Connectors → your Twilio
connector → Configuration and look for the Inbound webhooks section:
- A status badge (Configured / Not configured) reflects
GET /api/connectors/:id/webhooks-status, refreshed on load and after each provisioning run. - Set up webhooks calls the provisioning endpoint. On success the signing
secret is stored automatically and the badge flips to Configured after the
status re-probes; the button is disabled with an explanation until the
connector has the prerequisites above (
accountSid, an API Key pair, and afromNumberormessagingServiceSid). If you just edited those fields, Save the connector first — provisioning reads the stored config, not the unsaved form. - Rotate signing key (shown only once the badge reads Configured) mints a
fresh signing secret and retires the current one, behind a confirmation
dialog. Use it when a secret may have leaked, or to recover the rare state
where a
Floh:<connectorId>SharedKey exists in Twilio but the connector has no usable stored secret. See Cleanup and rotation for what happens server-side and the propagation window.
Both buttons require the connector:manage permission and trigger the same
step-up (MFA) prompt as saving a connector. In the rare auto-store fallback, the
console shows a dialog directing you to Set up webhooks again (there is no
field to paste into — the signing secret is hidden and managed automatically).
Setup A: SharedKey (manual)¶
This is the by-hand equivalent of the automated endpoint above — use it as a fallback, or to understand or troubleshoot each Twilio resource individually.
Prerequisite: an API Key SID + secret (SK…) with sending capability, used both
for these admin calls and as the connector's sending credential. All commands
authenticate with -u {API_KEY_SID}:{API_KEY_SECRET}.
Use the connector-scoped
friendlyNames exactly. Floh's status and provisioning paths discover these resources only by name — the SharedKey and Rule asFloh:<connectorId>and the Setting asFloh:<connectorId>:sha1(<connectorId>is the connector's UUID). Substitute your connector's id below. A generic name (or a missing RulefriendlyName) makes the status badge read Not configured even though signing works, and a later automated Set up webhooks run treats a same-prefix but differently-named Rule as foreign and fails closed with409instead of repairing it.
1. Create a SharedKey¶
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>" }'
The response includes id (webhooks_sharedkey_…) and secret. The secret
is returned only once — capture it into your secrets manager immediately; it
cannot be recovered later. (The Content-Type: application/json header is
required; without it the API returns 415.)
2. Create a Setting that signs with HMAC_SHA1¶
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"
}
}'
algorithm must be HMAC_SHA1 (see caveats). The algorithm cannot be
changed after creation — to switch you create a new Setting. Note the returned
id (webhooks_setting_…).
3. Apply the Setting with a Rule¶
The Rule is what makes Twilio sign your real inbound traffic (and status
callbacks) with the shared key. Without it, Twilio signs with the account Auth
Token and Floh 403s.
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://<your-floh-domain>/api/sms/",
"settings": [ { "settingId": "{SETTING_ID}", "percentage": 100 } ]
}'
The /api/sms/ prefix covers both the inbound route and the status-callback
route. Because that prefix is deployment-wide (every Twilio connector on the
same public base URL produces the identical prefix) and Twilio permits only one
PREFIX_MATCH Rule per prefix, two connectors on the same Twilio account and
public base URL cannot each own inbound signing. Floh matches its Rule by the
connector-scoped friendlyName (Floh:<connectorId>) and fails closed with a
409 Conflict rather than repointing a Rule another connector already owns —
which would silently break the first connector's signing. Give each connector a
distinct public base URL if you need to provision more than one under a single
account. This returns an Operation; poll it until status is COMPLETED
(propagation takes 30–60s) before testing:
curl -s https://webhooks.twilio.com/v1/Webhooks/Operations/{OPERATION_ID} \
-u {API_KEY_SID}:{API_KEY_SECRET}
4. Store the SharedKey secret in the connector¶
The signing field (webhookSigningSecret) is hidden and server-managed, so
there is no console field to paste into. Populate it one of two ways:
- Recommended: run the automated provisioning endpoint instead of this manual sequence — it mints the SharedKey and stores its secret for you.
- API write: the connector's only update route is
PUT /api/connectors/<connectorId>(requiresconnector:manage+ step-up MFA), and the secret goes underconfig. This PUT replaces the whole config, so you must resend every declared field you want to keep —accountSid,apiKeySid,apiKeySecret, andfromNumber(ormessagingServiceSid) — alongside the new secret. Use the********mask to retain a stored secret (apiKeySecret) without re-sending its value:
curl -sX PUT https://<your-floh-domain>/api/connectors/<connectorId> \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"config": {
"accountSid": "AC…",
"apiKeySid": "SK…",
"apiKeySecret": "********",
"fromNumber": "+1…",
"webhookSigningSecret": "<SharedKey secret from step 1>"
}
}'
Send the signing value exactly — a trailing space or newline changes the HMAC
key and every request will 403. Only the hidden server-managed secret and
the provisioning markers are preserved when omitted; the visible declared
fields above are dropped if you leave them out, which would erase the
connector's REST credentials and break sending, Verify, and status.
5. Point the number's inbound webhook at Floh¶
See Configure the number.
Setup B: Account Auth Token (signing-only, manual)¶
This signs webhooks only — sending requires an API Key — and the signing field is hidden, so the account Auth Token cannot be typed into the console; write it via the connector-config API.
- Write the connector config via the connector's only update route,
PUT /api/connectors/<connectorId>(requiresconnector:manage+ step-up MFA), with the secret underconfig. This PUT replaces the whole config, so resend every declared field you want to keep (accountSid,apiKeySid,apiKeySecret,fromNumberormessagingServiceSid) alongside the secret; use the********mask to retain a storedapiKeySecretwithout re-sending it:
curl -sX PUT https://<your-floh-domain>/api/connectors/<connectorId> \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"config": {
"accountSid": "AC…",
"apiKeySid": "SK…",
"apiKeySecret": "********",
"fromNumber": "+1…",
"webhookSigningSecret": "<your Twilio account Auth Token>"
}
}'
Omitting the visible declared fields drops them and erases the connector's REST credentials. (Auth Token: Twilio Console → Account → API keys & tokens.) Re-read the least-privilege warning first.
- Point the number's inbound webhook at Floh — see Configure the number.
No SharedKey, Setting, or Rule is needed: Twilio signs inbound webhooks with the account Auth Token by default, which is what Floh will verify against.
Configure the number¶
Point your SMS-capable Twilio number's inbound webhook at Floh. In the Twilio Console → Phone Numbers → Manage → Active numbers → your number → Messaging → "A message comes in": set Webhook, HTTP POST, URL:
If the number belongs to a Messaging Service, the number's own "A message comes in" setting is ignored. Configure the inbound request URL on the Messaging Service instead (Messaging → Services → your service → Integration).
You can also set it via the REST API (the Account SID AC… goes in the URL even
when authenticating with an API Key):
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://<your-floh-domain>/api/sms/inbound/<connectorId>" \
--data-urlencode "SmsMethod=POST"
Reverse proxy and TRUST_PROXY¶
Floh verifies the signature over the URL it reconstructs from the request:
`${request.protocol}://${request.host}${request.url}`. Twilio signs the
exact public URL it delivered to, so the reconstructed URL must equal the public
one. When Floh runs behind a TLS-terminating reverse proxy or ingress (the
normal deployment), the app sees the proxy's connection, not the public
scheme/host — it must trust the X-Forwarded-Proto and X-Forwarded-Host
headers to rebuild the public URL.
- Set
TRUST_PROXY=trueso Fastify honorsX-Forwarded-*(packages/server/src/config/index.ts; see the configuration map in Configuration, which notesbase.envsetsTRUST_PROXY=trueand overrides a local.env). - Ensure the proxy sets
X-Forwarded-Proto/X-Forwarded-Hostto the public values and sanitizes them from inbound client requests (do not blindly trust these headers from the internet). See Scaling and performance and Security for the trust-boundary rationale.
If TRUST_PROXY is off behind a proxy, Floh reconstructs an internal URL (e.g.
https://localhost:7070/...), the HMAC will not match, and inbound requests
403 with sms.webhook_signature_rejected.
Verify it works¶
The quickest signature check is
GET /api/connectors/:id/webhooks-status,
which runs the Twilio Webhooks/Tests probe below for you and reports
configured / not_configured. To run the probe by hand:
- (Optional) Signature-only check with Twilio's test endpoint — it signs a synthetic request with your Setting and posts it to the URL:
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://<your-floh-domain>/api/sms/inbound/<connectorId>",
"settingId": "{SETTING_ID}",
"method": "POST"
}'
A "response":{"statusCode":200} means the signature verified. The synthetic
payload has no From/Body, so Floh logs a 200 with
sms.webhook_inbound_unparseable_sender — that is expected and still proves
verification passed.
- Real end-to-end test. Text
STOPfrom a phone to your Twilio number. In the server logs you should seesms.webhook_inbound_classifiedwithaction: "opt_out", and a subsequent send to that number is blocked (recipient_opted_out). TextSTARTto opt back in.
If you see a 403, use the log-event table
to identify which stage failed, then the troubleshooting table.
Opt-out / opt-in behavior¶
Floh classifies each inbound message by a whole-string, case-insensitive
match (packages/server/src/modules/sms/inbound-keyword-router.ts):
| Reply (any case, trimmed) | Action | Effect |
|---|---|---|
STOP, STOPALL, UNSUBSCRIBE, END, QUIT, CANCEL |
opt-out | Records the recipient in the opt-out store; later sends return blocked / recipient_opted_out. |
START, UNSTOP, YES |
opt-in | Removes the opt-out; sends resume. |
HELP, INFO |
help | Acknowledged with 200; no state change, no reply sent. |
Anything else (e.g. Happy Birthday, STOP IT) |
no-op | 200 ack, logged as classified noop; no opt-out, no reply, no workflow triggered, body not stored. |
Notes:
- Matching is intentionally whole-string:
STOP ITis a no-op, not an opt-out, so conversational replies do not silently unsubscribe recipients. - Floh does not send a reply SMS for any of these (it returns JSON, not TwiML). Twilio's own Advanced Opt-Out, if enabled on the Messaging Service, may send carrier-level STOP/HELP confirmations independently of Floh.
- Free-text handling and inbound-triggered workflows are not supported
today; the keyword router is a closed set. See
SMS Provider Connectors → Opt-Out Compliance
for the compliance policy (per-connector scope,
critical_alertbypass).
Troubleshooting¶
| Symptom | Likely cause | Fix |
|---|---|---|
403 + sms.webhook_connector_auth_token_missing |
Connector webhookSigningSecret is empty |
Run Set up webhooks (automated) so Floh mints + stores a signing secret; or write it via the config API (Setup A/B). |
403 + sms.webhook_signature_rejected |
Setting uses HMAC_SHA256 |
Floh is SHA1-only; create a HMAC_SHA1 Setting and repoint the Rule. |
403 + sms.webhook_signature_rejected |
Stored secret ≠ the key Twilio signed with | Rotate signing key to mint + store a fresh matching secret; confirm the Rule points at the Setting whose sharedKeyId matches the stored secret. |
403 + sms.webhook_signature_rejected |
URL/host mismatch behind a proxy | Set TRUST_PROXY=true; confirm the proxy sends X-Forwarded-Proto: https and the public X-Forwarded-Host. |
Real texts 403 but Webhooks/Tests passed |
Rule missing/not COMPLETED; Twilio still signing with the account token |
Create the Rule with the /api/sms/ prefix and wait for the Operation to reach COMPLETED. |
409 Conflict deleting a SharedKey |
A Setting still references it | Delete the Setting first, then the SharedKey. |
Cleanup and rotation¶
From a repository checkout that can reach the connector's database (Twilio
API-key credentials and the connector id supplied via .env or flags — the same
inputs the other repo scripts use), the automated reset performs the entire
teardown below in one command:
It deletes in dependency order (Rules → Settings → SharedKeys, including
:retired: keys), blanks the number's SmsUrl, and strips webhookSigningSecret
plus the id markers, so the connector's status badge returns to Not configured.
To retire the configuration by hand instead, tear down in dependency order (a SharedKey cannot be deleted while a Setting references it, and a Setting is in use while a Rule points at it):
- Delete the Rule, then the Setting, then the SharedKey
(
DELETE https://webhooks.twilio.com/v1/Webhooks/{Rules|Settings|SharedKeys}/{id}). - Blank the number's inbound webhook if desired
(
--data-urlencode "SmsUrl="on theIncomingPhoneNumbers/{PN_SID}.jsonresource). - Clear the connector's
webhookSigningSecretvia the config API (the field is hidden in the console).
Rotate the signing key (automated)¶
Floh automates the whole rotation so you never touch curl. Click Rotate
signing key in the console's Inbound webhooks section (or call
POST /api/connectors/:id/provision-webhooks with {"rotate": true}). It:
- Retires the current
Floh:<connectorId>SharedKey by renaming itsfriendlyNametoFloh:<connectorId>:retired:<ISO8601>— aPATCH, never aDELETE, so it can't hit Twilio's409delete-dependency cascade. - Mints a fresh SharedKey +
HMAC_SHA1Setting and repoints the Rule at the new Setting. - Auto-stores the new secret into the connector's encrypted
webhookSigningSecret(returning it once only if the store fails, same fallback as provisioning).
The response reports "rotated": true and "retiredSharedKeyId" (the id of the
key that was renamed, or null if none existed), and the action emits a
connector.webhooks_key_rotated audit entry carrying ids only — never the
secret.
Propagation window. Floh verifies against a single webhookSigningSecret, so
for Twilio's ~30–60s Rule-propagation window the old secret may still be the one
Twilio signs with while webhookSigningSecret already holds the new one — inbound
signatures can transiently 403. The webhooks-status
probe reflects live state; wait for it to read Configured before treating
rotation as complete. This is a brief maintenance operation, not zero-downtime.
Rotation is also what self-heals an orphaned key: when provisioning reuses a SharedKey but the connector has no usable stored secret, it runs this same rotation automatically (see Self-heal).
Rotate the signing key (manual)¶
To rotate by hand instead: create a new SharedKey + HMAC_SHA1 Setting, repoint
the Rule (PATCH the existing Rule's settings at the new settingId), wait
for the Operation to reach COMPLETED, then write the new secret to the
connector's webhookSigningSecret via the config API in the same window (the
field is hidden in the console). The same propagation caveat above applies.