Beacon receives three Twilio webhooks: inbound SMS, outbound message status, and OTP message status. They are how reply threads, delivery confirmations, and OTP verifications flow back into the system.
If your Beacon install only sends messages and never reads replies or status, you can skip webhook configuration. Everything outbound works without it.
Endpoints
All three endpoints are public — they are authenticated by Twilio's request signature, not by an API key.
| Purpose | URL | Notes |
|---|---|---|
| Outbound message status | POST {APP_URL}/api/external/sms/status |
Set this on each business number's "Status callback URL". Updates text_messages.message_status. |
| OTP message status | POST {APP_URL}/api/external/sms/otp-status |
Same as above, but for OTPs. Routed separately so OTP delivery state stays in the OTP table. |
| Inbound SMS | POST {APP_URL}/api/external/sms/inbound |
Set this on each business number's "A message comes in" webhook. Creates or appends to threads. |
The handlers (visible in Horizon logs) are:
UpdateTextMessageStatusJobUpdateOtpStatusJobProcessInboundSmsJob
Each request is queued (Redis / Horizon) before processing, so Twilio gets a fast 200 OK regardless of downstream latency.
Configuring Twilio
For every business number you have configured in Beacon:
- Open the Twilio console → Phone Numbers → Manage → Active Numbers → click your number.
- Under Messaging Configuration:
- A message comes in →
Webhook→https://your-beacon.example.com/api/external/sms/inbound→HTTP POST - Status callback URL →
https://your-beacon.example.com/api/external/sms/status→HTTP POST
- A message comes in →
- Save.
For OTP-bearing numbers, also use /api/external/sms/otp-status as the status callback. If you only have one number serving both regular and OTP traffic, point status callbacks at /api/external/sms/status and Beacon will route by message type.
Signature verification
Beacon validates Twilio's X-Twilio-Signature header on every webhook using App\Support\Twilio\TwilioWebhookSignatureValidator. The signature is computed using the receiving Twilio Auth Token.
Set the auth token Beacon should validate against:
WEBHOOK_CLIENT_SECRET=your_twilio_auth_token
If signature validation fails, Beacon returns 401 Unauthorized and discards the request. This protects against forged webhooks.
Multi-team note: the current implementation expects a single
WEBHOOK_CLIENT_SECRETper install. If you run multiple teams with different Twilio sub-accounts, all sub-accounts must share an auth token, or you need to deploy one Beacon instance per sub-account. Multi-secret support is on the post-beta list.
Webhook storage
Every webhook is recorded in the webhook_calls table (Spatie's WebhookCall model). Records are pruned after 30 days by default.
Local development with a tunnel
Twilio cannot reach localhost. Use a tunnel during development:
# ngrok
ngrok http 8000
# or cloudflared
cloudflared tunnel --url http://localhost:8000
Take the public URL the tunnel prints (e.g. https://something.ngrok-free.app) and:
- Set
APP_URLin your environment to match — Beacon uses it to render correct webhook URLs in the dashboard. - Update your Twilio number's webhook URLs to the tunnel host.
- Set
WEBHOOK_CLIENT_SECRETto the matching Twilio Auth Token.
Send yourself a text from any phone — you should see a row appear in webhook_calls and a new message in your thread within a couple of seconds.
Verifying inbound flow end-to-end
# 1. Tail Horizon to watch the inbound job process
docker compose logs -f horizon
# 2. Text your Beacon number from a phone
# 3. Confirm the thread now has an inbound message
curl https://your-beacon.example.com/api/threads \
-H "X-API-Key: beacon_YOUR_KEY_HERE"
If nothing arrives, see troubleshooting.md.
Opt-out detection
Inbound messages matching STOP, STOPALL, UNSUBSCRIBE, CANCEL, END, or QUIT (case-insensitive) automatically suppress that contact phone for the receiving team. Subsequent send attempts to that number will be rejected with an opt-out error. This runs inside ProcessInboundSmsJob — you do not need to configure anything.