# Beacon API

This document covers the **currently implemented** Beacon HTTP API. It does not describe planned features.

For the machine-readable contract, use `GET /openapi.yaml` on your Beacon install.

## Base conventions

### Authentication

Beacon is team-scoped.

Preferred auth header:

```http
X-API-Key: beacon_...
```

Also supported by the current app:

- `Authorization: Bearer <api-key>`
- `api_key` query parameter
- `api_key` request body field

Test mode is enabled by appending `_test` to a valid API key. In test mode, Beacon bypasses license validation and simulates provider-side delivery without requiring live Twilio credentials.

When authentication fails:

```json
{
  "success": false,
  "error": "missing_team_context",
  "message": "The provided API key is missing or invalid."
}
```

### MCP endpoint

Beacon also exposes a web MCP server at `/api/mcp`.

In practice, the MCP transport uses `POST /api/mcp` for client requests, and the server may also negotiate related MCP transport methods on the same path.

It uses the same tenant API key authentication model as the HTTP API and accepts the same auth options described above.

### Middleware used on business endpoints

Most `/api/*` endpoints use:

- `tenant.api_key`
- `rate.limit`
- `idempotency`
- `tenant.required`
- `ValidateLicenseOrTestMode`

### Idempotency

Write requests support the `Idempotency-Key` header.

- scoped per API key
- empty keys rejected
- max length: `255`
- successful JSON responses cached for `24 hours`
- replayed responses include `X-Idempotency-Replayed: true`
- reusing a key with a different payload returns `idempotency_key_mismatch`

### Rate limits

Current defaults:

- burst: `50`
- refill: `10 requests / second`
- scoped per API key
- env-configurable

### Cursor pagination

Cursor-paginated endpoints use:

- `per_page` — default `15`, max `100`
- `cursor` — opaque cursor from the previous response

Typical shape:

```json
{
  "hasMore": true,
  "nextCursor": "opaque-cursor-value"
}
```

### Error shape

All API errors use:

```json
{
  "success": false,
  "error": "error_code",
  "message": "Human-readable description."
}
```

---

## Endpoint groups

## Spec

### `GET /openapi.yaml`

Returns the current OpenAPI 3.1 spec for the implemented API.

---

## Messaging

### `POST /api/text`

Send an outbound SMS.

Provide either `phone_number` to start or reuse a thread by phone, or `threadId` to reply within an existing thread.

#### Request fields

| Field             | Required                                   | Notes                                        |
|-------------------|--------------------------------------------|----------------------------------------------|
| `phone_number`    | Required unless `threadId` is provided     | E.164 format                                 |
| `threadId`        | Required unless `phone_number` is provided | UUID                                         |
| `message`         | Yes                                        | 1–160 characters                             |
| `sender`          | No                                         | Only allowed when starting by `phone_number` |
| `replyWebhookUrl` | No                                         | Valid URL                                    |
| `webhookData`     | No                                         | Max 100 chars                                |

#### Success response

Returns the send result as JSON. The response includes the created message record ID as `textId`.

#### Common errors

- `invalid_phone`
- `invalid_thread`
- `message_required_or_empty`
- `invalid_sender`
- `thread_not_found`
- `sending_number_not_resolved`
- `recipient_opted_out`
- `invalid_idempotency_key`
- `idempotency_key_mismatch`
- `message_failed`
- `rate_limited`
- `server_error`

---

## Threads

Threads are the current conversation abstraction used by Beacon.

### `GET /api/threads`

List thread summaries for the authenticated team.

Query parameters:

- `per_page`
- `cursor`

Response shape:

```json
{
  "threads": [
    {
      "id": "uuid",
      "contactName": "Jamie Doe",
      "contactPhone": "+15555555555",
      "senderName": "Sales",
      "senderPhone": "+15551234567",
      "lastDirection": "inbound",
      "lastBody": "Can you send me pricing?",
      "lastMessageAt": "2026-04-03T10:12:00.000000Z",
      "tags": []
    }
  ],
  "hasMore": false,
  "nextCursor": null
}
```

### `GET /api/threads/{id}`

Get one thread summary.

Errors:

- `thread_not_found`
- `server_error`

### `GET /api/threads/{id}/messages`

Get message history for a thread.

Query parameters:

- `per_page`
- `cursor`

Messages are returned newest first.

Response shape:

```json
{
  "messages": [
    {
      "id": "uuid",
      "direction": "inbound",
      "body": "Newest message",
      "status": "received",
      "createdAt": "2026-04-03T11:00:00.000000Z"
    }
  ],
  "nextCursor": null
}
```

### `GET /api/threads/{id}/replies?since=<timestamp>`

Poll for inbound replies after a timestamp.

Query parameters:

- `since` — required, parseable timestamp
- `per_page`
- `cursor`

Replies are returned oldest first.

Response shape:

```json
{
  "replies": [
    {
      "id": "uuid",
      "direction": "inbound",
      "body": "First new inbound reply",
      "status": "received",
      "createdAt": "2026-04-03T11:00:00.000000Z"
    }
  ],
  "nextCursor": null
}
```

Common errors:

- `invalid_since`
- `thread_not_found`
- `server_error`

---

## Contacts

### Contact endpoints

| Method   | Endpoint                                     | Purpose                                       |
|----------|----------------------------------------------|-----------------------------------------------|
| `GET`    | `/api/contacts`                              | List contacts                                 |
| `POST`   | `/api/contacts`                              | Create a contact                              |
| `GET`    | `/api/contacts/{id}`                         | Get one contact                               |
| `PATCH`  | `/api/contacts/{id}`                         | Update a contact                              |
| `POST`   | `/api/contacts/upsert`                       | Create or update by id, external ID, or phone |
| `GET`    | `/api/contacts/by-phone/{phone}`             | Find a contact by phone                       |
| `GET`    | `/api/contacts/duplicates`                   | List duplicate groups                         |
| `POST`   | `/api/contacts/merge`                        | Merge two contacts                            |
| `POST`   | `/api/contacts/{id}/phone-numbers`           | Add a phone number                            |
| `PATCH`  | `/api/contacts/{id}/phone-numbers/{phoneId}` | Update a contact phone number                 |
| `DELETE` | `/api/contacts/{id}/phone-numbers/{phoneId}` | Delete a contact phone number                 |

### `GET /api/contacts`

Supported filters:

- `query`
- `external_id`
- `tag`
- `updated_after`
- `per_page`
- `cursor`

Response shape:

```json
{
  "contacts": [
    {
      "id": "uuid",
      "name": "Jamie Doe",
      "company": "Acme",
      "role": "Buyer",
      "primaryPhone": "+15555555555",
      "tags": ["lead"],
      "externalId": "ext_123",
      "metadata": {"source": "crm"},
      "createdAt": "2026-04-03T10:00:00.000000Z",
      "updatedAt": "2026-04-03T10:00:00.000000Z"
    }
  ],
  "hasMore": false,
  "nextCursor": null
}
```

### `POST /api/contacts`

Create a contact.

#### Request fields

| Field        | Required | Notes             |
|--------------|----------|-------------------|
| `name`       | Yes      | Max 255 chars     |
| `company`    | No       | Max 255 chars     |
| `role`       | No       | Max 255 chars     |
| `phones`     | Yes      | Array, at least 1 |
| `tags`       | No       | Array of strings  |
| `externalId` | No       | Max 255 chars     |
| `metadata`   | No       | Object            |
| `notes`      | No       | Array of strings  |

Returns `201` with:

```json
{
  "success": true,
  "contact": {}
}
```

### `GET /api/contacts/{id}`

Returns the full contact record, including:

- summary fields
- `phones`
- `notes`

If not found:

- `contact_not_found`

### `PATCH /api/contacts/{id}`

Update any subset of:

- `name`
- `company`
- `role`
- `externalId`
- `metadata`
- `tags`
- `notes`

Returns:

```json
{
  "success": true,
  "contact": {}
}
```

### `POST /api/contacts/upsert`

Create or update a contact by matching on one of:

- `id`
- `externalId`
- `phones`

Behavior:

- creates when no match exists
- updates when exactly one contact matches
- returns `matchedBy` when updated
- returns `created: true|false`
- returns `ambiguous_contact_match` if multiple contacts match the supplied phone number
- returns `contact_identity_conflict` if lookup inputs resolve to different contacts

### `GET /api/contacts/by-phone/{phone}`

Returns:

- `{ "found": false, "contact": null }` when no match exists
- `{ "found": true, "contact": { ... } }` when one match exists

Errors:

- `invalid_phone`
- `multiple_contacts_found`

### `GET /api/contacts/duplicates`

Returns duplicate groups detected by:

- same phone
- same external ID
- fuzzy name match within the same company

Response shape:

```json
{
  "duplicateGroups": [
    {
      "reason": "same_phone",
      "contacts": []
    }
  ]
}
```

### `POST /api/contacts/merge`

Merge one contact into another.

#### Request fields

| Field             | Required | Notes                                                         |
|-------------------|----------|---------------------------------------------------------------|
| `sourceContactId` | Yes      | UUID                                                          |
| `targetContactId` | Yes      | UUID                                                          |
| `strategy`        | No       | `prefer_target`, `prefer_source`, `prefer_non_null`, `append` |
| `fields`          | No       | Per-field merge strategy overrides                            |

Returns:

```json
{
  "success": true,
  "contact": {},
  "mergedFrom": "uuid",
  "movedThreads": 2
}
```

Common errors:

- `cannot_merge_same_contact`
- `contact_not_found`
- `invalid_merge_strategy`
- `external_id_conflict`

### Contact phone number sub-resources

#### `POST /api/contacts/{id}/phone-numbers`

Add a phone number to a contact.

Fields:

- `phoneNumber` — required
- `label` — optional
- `isPrimary` — optional boolean

#### `PATCH /api/contacts/{id}/phone-numbers/{phoneId}`

Update:

- `label`
- `isPrimary`

#### `DELETE /api/contacts/{id}/phone-numbers/{phoneId}`

Deletes a phone number unless it is the contact's last phone number.

Common errors across contact phone endpoints:

- `contact_not_found`
- `contact_phone_not_found`
- `duplicate_contact_phone`
- `invalid_phone`
- `cannot_delete_last_contact_phone`

---

## OTP

### `POST /api/otp/generate`

Generate and send an OTP over SMS.

#### Request fields

| Field          | Required | Notes                           |
|----------------|----------|---------------------------------|
| `phone`        | Yes      | E.164 format                    |
| `recipient_id` | Yes      | Max 255 chars                   |
| `message`      | No       | 1–160 chars, may include `$OTP` |
| `lifetime`     | No       | 30–600 seconds                  |
| `length`       | No       | 6–8 digits                      |

Behavior:

- does not return the plaintext OTP
- invalidates older active OTPs for the same `recipient_id`
- blocks sends to opted-out recipients

### `POST /api/otp/verify`

Verify a submitted OTP.

#### Request fields

| Field          | Required | Notes         |
|----------------|----------|---------------|
| `recipient_id` | Yes      | Max 255 chars |
| `otp`          | Yes      | 6–8 digits    |

Common OTP errors:

- `invalid_phone`
- `invalid_recipient_id`
- `invalid_message`
- `invalid_lifetime`
- `invalid_length`
- `invalid_otp_format`
- `recipient_opted_out`
- `sending_number_not_resolved`
- `message_failed`
- `otp_expired`
- `otp_invalid`
- `otp_already_verified`
- `otp_max_attempts`

---

## Business numbers

### Business number endpoints

| Method   | Endpoint                                  | Purpose                   |
|----------|-------------------------------------------|---------------------------|
| `GET`    | `/api/business-numbers`                   | List business numbers     |
| `POST`   | `/api/business-numbers`                   | Create a business number  |
| `GET`    | `/api/business-numbers/{id}`              | Get one business number   |
| `PATCH`  | `/api/business-numbers/{id}`              | Update a business number  |
| `POST`   | `/api/business-numbers/{id}/make-primary` | Make a number primary     |
| `DELETE` | `/api/business-numbers/{id}`              | Archive a business number |

### `GET /api/business-numbers`

Supported filters:

- `status`
- `type`
- `agent_profile`
- `is_primary`

Response shape:

```json
{
  "businessNumbers": [
    {
      "id": "uuid",
      "name": "Sales",
      "phoneNumber": "+15551234567",
      "friendlyName": "Main sales line",
      "type": "Local",
      "status": "active",
      "isPrimary": true,
      "capabilities": {"sms": true},
      "agentProfile": "sales-agent",
      "createdAt": "2026-04-03T10:00:00.000000Z",
      "updatedAt": "2026-04-03T10:00:00.000000Z"
    }
  ]
}
```

### `POST /api/business-numbers`

Create a business number.

#### Request fields

| Field            | Required | Notes                         |
|------------------|----------|-------------------------------|
| `name`           | Yes      | Unique within the team        |
| `phoneNumber`    | Yes      | E.164 format, globally unique |
| `phoneNumberSid` | Yes      | Provider SID                  |
| `friendlyName`   | No       | Max 255 chars                 |
| `type`           | No       | Current enum-backed type      |
| `isPrimary`      | No       | Boolean                       |
| `agentProfile`   | No       | Max 255 chars                 |
| `settings`       | No       | Object                        |
| `capabilities`   | No       | Object                        |

Returns `201` with:

```json
{
  "success": true,
  "businessNumber": {}
}
```

Common errors:

- `duplicate_business_number_name`
- `duplicate_business_number_phone`
- `validation_error`

### `GET /api/business-numbers/{id}`

Returns the full business number, including `settings`.

Errors:

- `business_number_not_found`

### `PATCH /api/business-numbers/{id}`

Update any subset of:

- `name`
- `friendlyName`
- `status` (`active` or `paused`)
- `agentProfile`
- `settings`

Common errors:

- `business_number_not_found`
- `duplicate_business_number_name`
- `invalid_business_number_status`

### `POST /api/business-numbers/{id}/make-primary`

Makes an active business number the team's primary sending number.

Common errors:

- `business_number_not_found`
- `invalid_business_number_status`

### `DELETE /api/business-numbers/{id}`

Archives a business number.

Common errors:

- `business_number_not_found`
- `cannot_delete_primary_business_number`

---

## External webhooks

These endpoints are implemented for Twilio / provider callbacks.

| Method | Endpoint                       | Purpose                      |
|--------|--------------------------------|------------------------------|
| `POST` | `/api/external/sms/status`     | Outbound SMS delivery status |
| `POST` | `/api/external/sms/otp-status` | OTP delivery status          |
| `POST` | `/api/external/sms/inbound`    | Inbound SMS webhook          |

### `POST /api/external/sms/inbound`

Behavior:

- creates or appends to a thread
- records inbound messages
- detects opt-out keywords: `STOP`, `STOPALL`, `UNSUBSCRIBE`, `CANCEL`, `END`, `QUIT`
- detects opt-in keywords: `START`, `UNSTOP`
- blocks future outbound SMS and OTP sends to opted-out recipients with `recipient_opted_out`

---

## Current API surface at a glance

Implemented today:

- messaging
- threads and reply polling
- contacts, merge, duplicates, notes, tags, phone numbers
- OTP generation and verification
- business number management
- inbound and status webhooks
- OpenAPI spec delivery at `/openapi.yaml`
