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:
X-API-Key: beacon_...
Also supported by the current app:
Authorization: Bearer <api-key>api_keyquery parameterapi_keyrequest 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:
{
"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_keyrate.limitidempotencytenant.requiredValidateLicenseOrTestMode
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— default15, max100cursor— opaque cursor from the previous response
Typical shape:
{
"hasMore": true,
"nextCursor": "opaque-cursor-value"
}
Error shape
All API errors use:
{
"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_phoneinvalid_threadmessage_required_or_emptyinvalid_senderthread_not_foundsending_number_not_resolvedrecipient_opted_outinvalid_idempotency_keyidempotency_key_mismatchmessage_failedrate_limitedserver_error
Threads
Threads are the current conversation abstraction used by Beacon.
GET /api/threads
List thread summaries for the authenticated team.
Query parameters:
per_pagecursor
Response shape:
{
"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_foundserver_error
GET /api/threads/{id}/messages
Get message history for a thread.
Query parameters:
per_pagecursor
Messages are returned newest first.
Response shape:
{
"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 timestampper_pagecursor
Replies are returned oldest first.
Response shape:
{
"replies": [
{
"id": "uuid",
"direction": "inbound",
"body": "First new inbound reply",
"status": "received",
"createdAt": "2026-04-03T11:00:00.000000Z"
}
],
"nextCursor": null
}
Common errors:
invalid_sincethread_not_foundserver_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:
queryexternal_idtagupdated_afterper_pagecursor
Response shape:
{
"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:
{
"success": true,
"contact": {}
}
GET /api/contacts/{id}
Returns the full contact record, including:
- summary fields
phonesnotes
If not found:
contact_not_found
PATCH /api/contacts/{id}
Update any subset of:
namecompanyroleexternalIdmetadatatagsnotes
Returns:
{
"success": true,
"contact": {}
}
POST /api/contacts/upsert
Create or update a contact by matching on one of:
idexternalIdphones
Behavior:
- creates when no match exists
- updates when exactly one contact matches
- returns
matchedBywhen updated - returns
created: true|false - returns
ambiguous_contact_matchif multiple contacts match the supplied phone number - returns
contact_identity_conflictif 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_phonemultiple_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:
{
"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:
{
"success": true,
"contact": {},
"mergedFrom": "uuid",
"movedThreads": 2
}
Common errors:
cannot_merge_same_contactcontact_not_foundinvalid_merge_strategyexternal_id_conflict
Contact phone number sub-resources
POST /api/contacts/{id}/phone-numbers
Add a phone number to a contact.
Fields:
phoneNumber— requiredlabel— optionalisPrimary— optional boolean
PATCH /api/contacts/{id}/phone-numbers/{phoneId}
Update:
labelisPrimary
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_foundcontact_phone_not_foundduplicate_contact_phoneinvalid_phonecannot_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_phoneinvalid_recipient_idinvalid_messageinvalid_lifetimeinvalid_lengthinvalid_otp_formatrecipient_opted_outsending_number_not_resolvedmessage_failedotp_expiredotp_invalidotp_already_verifiedotp_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:
statustypeagent_profileis_primary
Response shape:
{
"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:
{
"success": true,
"businessNumber": {}
}
Common errors:
duplicate_business_number_nameduplicate_business_number_phonevalidation_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:
namefriendlyNamestatus(activeorpaused)agentProfilesettings
Common errors:
business_number_not_foundduplicate_business_number_nameinvalid_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_foundinvalid_business_number_status
DELETE /api/business-numbers/{id}
Archives a business number.
Common errors:
business_number_not_foundcannot_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