Skip to content

Read inbound texts the way the relay sends them, and only from the relay - #25

Merged
rvegajr merged 1 commit into
developfrom
feat/inbound-sms
Oct 2, 2026
Merged

rvegajr merged 1 commit into
developfrom
feat/inbound-sms

Conversation

@rvegajr

@rvegajr rvegajr commented Oct 2, 2026

Copy link
Copy Markdown
Member

The app half of texting in. It is safe to ship before the Coachatron number exists: with no secret and no relay route, nothing changes.

Why

/webhooks/sms read lowercase JSON (from, body). The relay forwards Twilio's own fields (From, To, Body, …) urlencoded, signed with x-relay-signature = base64 HMAC-SHA256(secret, url + raw body) (noctusoft-relay/inbound.js:258-273, inbound-secrets.js:44). So a real text would have hit "Missing from or body".

What changes

  • /webhooks/sms keeps the raw body (express.raw, the same pattern as /webhooks/store). It reads the relay's form fields, plus the JSON shape that tests and local tools use.
  • src/lib/inboundSms.ts checks the relay signature with timingSafeEqual, over INBOUND_SMS_URL plus the raw body. The URL defaults to ${APP_BASE_URL}/webhooks/sms and must equal the manifest URL exactly.
    • When RELAY_INBOUND_SECRET is set, an unsigned request, a wrong secret, or a signature for a different URL gets 401.
    • When the secret isn't set, requests are accepted. The server logs a warning at boot if it runs on https without one.
  • The webhook keeps answering by sending a text. The relay treats a non-XML response as an empty acknowledgement, so no TwiML is needed.

One number for every environment

Sending: every environment texts from the one number.
Receiving: the relay routes a number to a single URL, so incoming texts point at uat while you test. At launch, one manifest line moves them to production. Full steps are in docs/PLATFORM.md §3 and ROADMAP M14.

Still to do outside this repo:

  1. You: buy one US number in Twilio and add it to the messaging service that has the registered A2P 10DLC campaign.
  2. Relay manifest: set sms: { numbers: [...] } and point inbound.sms at uat. Mint the secret with relay-keys.js inbound-secret --product coachatron --rotate, then point the Twilio number at the relay.
  3. Railway uat: set RELAY_INBOUND_SECRET, INBOUND_SMS_URL=https://uat.coachatron.com/webhooks/sms and COACHATRON_SMS_NUMBER.

Tests

test/inbound-sms.test.ts (3 tests):

  • the form fields and the JSON shape parse the same;
  • signed requests pass, while unsigned, wrong-secret and wrong-URL requests get 401;
  • a stranger's texted week in the real format gets a /start/ link, with no model call.

npm test 121/121 on three consecutive runs. Typecheck, lint (0 errors) and build pass.

🤖 Generated with Claude Code

…lay.

The relay forwards Twilio's own form fields (From, Body), signed with
x-relay-signature over the raw body. The webhook only read lowercase
JSON, so a real text would have been rejected. It now reads the raw body
in either shape and, once RELAY_INBOUND_SECRET is set, refuses anything
not signed by the relay for this exact URL.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant