Skip to content

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 webhookSigningSecret field (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 requires TRUST_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).

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 choose algorithm: "HMAC_SHA1". Twilio's preferred HMAC_SHA256 will not verify and every request will 403.
  • Single stored secret. Floh keys off the one webhookSigningSecret value and ignores the X-Twilio-Signature-Key-Sid header, 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 will 403.

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) or messagingServiceSid. When fromNumber is set, Floh also points that number's SmsUrl at 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_DIR set with ALLOW_LEGACY_ENV_NON_SECRET unset/false, outside NODE_ENV=development), non-secret keys are read only from the bundle — a WEBHOOK_PUBLIC_BASE_URL placed in process.env is ignored (you will see a "Ignoring non-secret process.env keys" startup warning) and the base silently falls back to FRONTEND_URL. In that mode set WEBHOOK_PUBLIC_BASE_URL in your public bundle (config/public/<APP_ENV>.env, alongside FRONTEND_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 a fromNumber or messagingServiceSid). 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 as Floh:<connectorId> and the Setting as Floh:<connectorId>:sha1 (<connectorId> is the connector's UUID). Substitute your connector's id below. A generic name (or a missing Rule friendlyName) 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 with 409 instead 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> (requires connector:manage + step-up MFA), and the secret goes under config. This PUT replaces the whole config, so you must resend every declared field you want to keep — accountSid, apiKeySid, apiKeySecret, and fromNumber (or messagingServiceSid) — 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.

  1. Write the connector config via the connector's only update route, PUT /api/connectors/<connectorId> (requires connector:manage + step-up MFA), with the secret under config. This PUT replaces the whole config, so resend every declared field you want to keep (accountSid, apiKeySid, apiKeySecret, fromNumber or messagingServiceSid) alongside the secret; use the ******** mask to retain a stored apiKeySecret without 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.

  1. 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:

https://<your-floh-domain>/api/sms/inbound/<connectorId>

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=true so Fastify honors X-Forwarded-* (packages/server/src/config/index.ts; see the configuration map in Configuration, which notes base.env sets TRUST_PROXY=true and overrides a local .env).
  • Ensure the proxy sets X-Forwarded-Proto/X-Forwarded-Host to 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:

  1. (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.

  1. Real end-to-end test. Text STOP from a phone to your Twilio number. In the server logs you should see sms.webhook_inbound_classified with action: "opt_out", and a subsequent send to that number is blocked (recipient_opted_out). Text START to 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 IT is 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_alert bypass).

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:

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

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):

  1. Delete the Rule, then the Setting, then the SharedKey (DELETE https://webhooks.twilio.com/v1/Webhooks/{Rules|Settings|SharedKeys}/{id}).
  2. Blank the number's inbound webhook if desired (--data-urlencode "SmsUrl=" on the IncomingPhoneNumbers/{PN_SID}.json resource).
  3. Clear the connector's webhookSigningSecret via 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:

  1. Retires the current Floh:<connectorId> SharedKey by renaming its friendlyName to Floh:<connectorId>:retired:<ISO8601> — a PATCH, never a DELETE, so it can't hit Twilio's 409 delete-dependency cascade.
  2. Mints a fresh SharedKey + HMAC_SHA1 Setting and repoints the Rule at the new Setting.
  3. 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.