Documentation

Reference

Webhooks

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:

  • UpdateTextMessageStatusJob
  • UpdateOtpStatusJob
  • ProcessInboundSmsJob

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:

  1. Open the Twilio console → Phone NumbersManageActive Numbers → click your number.
  2. Under Messaging Configuration:
    • A message comes inWebhookhttps://your-beacon.example.com/api/external/sms/inboundHTTP POST
    • Status callback URLhttps://your-beacon.example.com/api/external/sms/statusHTTP POST
  3. 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_SECRET per 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:

  1. Set APP_URL in your environment to match — Beacon uses it to render correct webhook URLs in the dashboard.
  2. Update your Twilio number's webhook URLs to the tunnel host.
  3. Set WEBHOOK_CLIENT_SECRET to 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.

Raw markdown · llms.txt