# 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 Numbers** → **Manage** → **Active Numbers** → click your number.
2. 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`
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:

```bash
# 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

```bash
# 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](./troubleshooting.md#twilio-webhook-returns-401-or-nothing-arrives).

## 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.
