# Blue Reacher documentation (/docs)
Blue Reacher sends and receives iMessage (with SMS fallback) through a REST API and native CRM integrations, on dedicated iMessage lines with no A2P registration required. Attach files and voice memos, send tapbacks and typing indicators, and catch every reply on a webhook.
## Start here [#start-here]
## What you can build [#what-you-can-build]
| Operation | Method | Reference |
| ----------------------------- | ---------------------------------------------- | -------------------------------------------------------- |
| Send a message | `POST /v1/messages` | [Send a message](/docs/api-reference/send-message) |
| Send to a group | `POST /v1/messages` with a group target | [Groups](/docs/api-reference/send-group-message) |
| Send a native voice memo | `POST /v1/voice-memos` | [Rich messaging](/docs/api-reference/rich-messaging) |
| Track delivery, read a thread | `GET /v1/status/{id}`, `GET /v1/conversations` | [Status and conversations](/docs/api-reference/messages) |
| Check iMessage availability | `POST /v1/capability` | [Check iMessage](/docs/api-reference/check-imessage) |
| Manage contacts and opt-outs | `/v1/contacts`, `/v1/opt-out` | [Contacts](/docs/api-reference/contacts) |
| List lines with live capacity | `GET /v1/devices` | [Lines](/docs/api-reference/lines) |
| Tapbacks, typing, read state | `/v1/reactions`, `/v1/typing`, `/v1/read` | [Rich messaging](/docs/api-reference/rich-messaging) |
| Receive events | Signed webhooks | [Webhooks](/docs/api-reference/webhooks) |
## How it works [#how-it-works]
**Get a line.** Your dedicated iMessage number is provisioned during
white-glove setup. It is yours alone, never shared and never recycled.
**Authenticate.** Every request carries
`Authorization: Bearer brk_your_api_key`. Keys are created under
**Developer → API Keys** in your app and can be rotated at any time.
**Send.** One `POST` with a `to` and a `message`. iMessage first, SMS
fallback when the number cannot take a blue bubble.
**Listen.** Replies, delivery states and opt-outs arrive on your webhook as
signed events with a stable `event_id`.
```bash title="Send your first message (runs as-is on the free sandbox key)"
curl https://api.bluereacher.com/v1/messages \
-H "Authorization: Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2" \
-H "Content-Type: application/json" \
-d '{
"to": "+14155550142",
"message": "Thursday 10am still work for the walkthrough?"
}'
```
```json title="The reply, on your webhook"
{
"event": "message.received",
"event_id": "9f3c1b7e-2a64-4d8f-b0e5-c7a92d4f8e13",
"timestamp": "2026-08-31T07:41:22.310Z",
"data": {
"phone_number": "+14155550142",
"content": "Works. Send the invite.",
"direction": "incoming",
"service_type": "iMessage"
}
}
```
Lines, threads, delivery channels and keys are defined once in [Key concepts](/docs/guides/key-concepts).
Opt-outs are read on every inbound message, honoured account-wide within
seconds, and recorded in an exportable audit trail. You do not have to
implement suppression yourself, and you cannot accidentally send around it.
## Connect your stack [#connect-your-stack]
## Build a campaign [#build-a-campaign]
## Going to production [#going-to-production]
# API keys (/docs/api-reference/api-keys)
The same key management you have under **Developer → API Keys** in the app, as endpoints, so a deploy pipeline can rotate its own credentials. Every call is scoped to the workspace of the key that makes it: you can never see or change another workspace's keys, and a key you create here reaches only this workspace. See [Authentication](/docs/guides/authentication) for permission levels and rotation rules.
A key's value is returned once, in the response that creates it. Every other response describes a key without its value: a `hint` with the prefix and last four characters is enough to tell keys apart.
## The key object [#the-key-object]
```json
{
"id": "key_3f9c2a71b04d8e56",
"name": "HubSpot sync",
"hint": "brk_live_...9f2e",
"mode": "live",
"permission": "write",
"device_id": null,
"status": "active",
"created_at": "2026-09-27T10:02:11.000Z",
"created_by": "dashboard",
"last_used_at": "2026-09-27T14:40:00.000Z",
"expires_at": null,
"revoked_at": null,
"rotated_from": null,
"rotated_to": null
}
```
| Field | Meaning |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `device_id` | The line this key is limited to, or `null` for a key that works on every line; see [Keys limited to one line](#keys-limited-to-one-line) |
| `status` | `active`, `rotating` (replaced, still working until `expires_at`), `expired`, `revoked` or `disabled` |
| `created_by` | `dashboard` (Developer → API Keys in the app), `api` (these endpoints) or `blue_reacher` (created with you at onboarding) |
| `last_used_at` | Updated at most once an hour, so a key used in the last hour may show an earlier time |
## List keys [#list-keys]
```
GET https://api.bluereacher.com/v1/keys
```
Requires **read**. Returns the workspace's active and rotating keys, newest first. The key making the call carries `"current": true`.
```json
{ "data": [ { "id": "key_3f9c2a71b04d8e56", "name": "HubSpot sync", "current": true, "...": "..." } ], "managed": true }
```
The shared sandbox key belongs to no workspace, so it gets `"data": []` and `"managed": false`.
## Create a key [#create-a-key]
```
POST https://api.bluereacher.com/v1/keys
```
Requires **admin**. Body: `name` (up to 64 characters) and `permission` (`read`, `write` or `admin`, default `write`). Returns `201` with the key object plus `key`, the full value, shown this once. A workspace holds up to 25 active keys.
```bash
curl -X POST https://api.bluereacher.com/v1/keys \
-H "Authorization: Bearer $BLUE_REACHER_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "Reporting job", "permission": "read" }'
```
## Rotate a key [#rotate-a-key]
```
POST https://api.bluereacher.com/v1/keys/{key_id}/rotate
```
Requires **admin**. Body (optional): `grace_hours`, one of `0`, `1`, `24`, `72`, `168`; default `24`. Returns `201` with the replacement (same name and permission) including its `key` value, plus `previous`, the old key, now `rotating` with its `expires_at`. With `grace_hours: 0` the old key is revoked at once.
## Rename or change permission [#rename-or-change-permission]
```
PATCH https://api.bluereacher.com/v1/keys/{key_id}
```
Requires **admin**. Body: `name`, `permission`, or both.
## Revoke a key [#revoke-a-key]
```
DELETE https://api.bluereacher.com/v1/keys/{key_id}
```
Requires **admin**. Permanent, and idempotent. Requests with the key fail with `401 invalid_api_key` (`"reason": "revoked"`) within a minute. The workspace's last active key cannot be revoked (`409 last_key`); create or rotate to a replacement first.
## Keys limited to one line [#keys-limited-to-one-line]
A key can be tied to a single sending line, so a tool or a teammate can only send from that number. Create one in the app under **Developer → Lines & Connections**: pick the line, then **Create key on this line**. Rotating it keeps the line.
With a line-limited key:
* Sends without a `device_id` go out on the key's line. You never need to pass it.
* A request naming another line, in the body, in `?device_id=` or in an MCP tool call, is refused with `403 line_not_allowed` before anything is sent.
* On message, voice memo, typing, read receipt, calling, reaction and group routes, send one JSON object (or `multipart/form-data` for a voice memo file). Other body types get `415`; a JSON array gets `400`.
* Reads (contacts, conversations, lines, usage) still cover the whole workspace.
Keys created through these endpoints work on every line.
## Errors [#errors]
| Status | Code | Meaning |
| ------ | ---------------------------- | --------------------------------------------------------- |
| 400 | `invalid_request` | Bad `permission`, bad `grace_hours`, or nothing to change |
| 403 | `insufficient_permission` | The calling key is not `admin` |
| 403 | `key_management_unavailable` | The shared sandbox key cannot manage keys |
| 404 | `not_found` | No key with that id in this workspace |
| 409 | `key_limit` | 25 active keys already; revoke one first |
| 409 | `last_key` | Revoking would leave the workspace with no active key |
| 409 | `already_rotating` | The key already has a replacement |
| 409 | `key_inactive` | The key is revoked or expired |
# Calls (/docs/api-reference/calls)
{/* availability-2026-10-22 */}
Calling is not available on the API yet. Every endpoint on this page answers `501 feature_unavailable` while it moves to new infrastructure.
The Calls API places FaceTime audio calls from your lines. It never dials on its own: every outbound call is prepared by the API and started by a person who presses **Start call**. The MCP calling tools (`list_calling_lines`, `prepare_call`, `get_call_status`, `end_call`) run on the same resource.
Live keys need Calling switched on for the workspace, which your account
manager does, and a key with `calls:write`. Test keys
(`brk_test_`) get simulated calls on every endpoint except call settings.
## What it covers [#what-it-covers]
| Capability | Today |
| -------------------------------------------------------------- | ---------------------------------- |
| Prepare, confirm, read and end an outbound FaceTime audio call | Yes |
| Read a ringing incoming call, and decline it | Yes |
| Hand an answered call to a teammate | Yes |
| Recording and transcript on an answered call | Yes, when recording is switched on |
| Answer an incoming call | No, a person answers in the app |
| Hold | No, FaceTime audio has no hold |
| Dial without a person present | Never, by design |
## List calling lines [#list-calling-lines]
```
GET https://api.bluereacher.com/v1/calling-lines
```
```json
{
"object": "list",
"data": [
{
"id": "4b30a74e-34e0-49f7-9df2-ecab5df05e87",
"object": "calling_line",
"label": "Line 2",
"phone_number": "+14155550142",
"provider": "facetime",
"readiness": "ready"
}
]
}
```
`readiness` is one of `ready`, `offline`, `busy` or `not_ready`.
## Prepare a call [#prepare-a-call]
```bash
curl -X POST https://api.bluereacher.com/v1/calls \
-H "Authorization: Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: crm-call-48391" \
-d '{
"to": "+13035550199",
"line_id": "sandbox_line",
"contact_name": "Alex Rivera",
"client_reference": "crm-48391"
}'
```
| Field | Required | Meaning |
| ------------------ | -------- | ---------------------------------------------------------------- |
| `to` | yes | Destination, E.164 |
| `line_id` | no | A calling line id. Optional when the key has a default line |
| `contact_name` | no | Shown on the confirmation page |
| `client_reference` | no | Your own reference, echoed on the call and on every call webhook |
```json
{
"id": "54cf78b7-1f0e-4c2a-9b7d-3e8a6c1d2f40",
"object": "call",
"provider": "facetime",
"status": "prepared",
"to": "+13035550199",
"line_id": "sandbox_line",
"client_reference": "crm-48391",
"confirmation_required": true,
"confirmation_url": "https://app.bluereacher.com/api/v1/call-session?token=...",
"confirmation_expires_at": "2026-09-10T17:08:00.000Z",
"created_at": "2026-09-10T17:03:00.000Z",
"confirmed_at": null,
"started_at": null,
"answered_at": null,
"completed_at": null,
"duration_seconds": null,
"failure_code": null
}
```
A person signed in to Blue Reacher opens `confirmation_url`, checks the line and the masked destination, and presses **Start call**. Nothing dials before that. The session expires after five minutes.
Send an `Idempotency-Key` on every prepare. A retry with the same key and body returns the original call; a different body answers `409 idempotency_conflict`. One live session per line at a time: while a prepared call waits, another prepare on that line answers `409 line_busy` until the first is started, cancelled or expires. Fields outside the four above are refused with `400 invalid_request`.
For a reply-YES flow, AI voice agents and every limit checked against the live API, read [Calling and AI agents](/docs/guides/calling).
## Lifecycle [#lifecycle]
```
prepared -> confirmed -> connecting -> ringing -> answered -> completed
prepared -> expired (nobody confirmed within five minutes)
ringing -> failed (the call could not be placed)
ringing -> cancelled (ended before it was answered)
```
`failure_code` is one of `recipient_busy`, `recipient_declined`, `no_answer`, `line_unavailable`, `cancelled`, `call_not_started` or `call_failed`. Subscribe to the [call webhooks](/docs/api-reference/webhooks#calls) to follow it without polling.
## Read a call [#read-a-call]
```
GET https://api.bluereacher.com/v1/calls?call_id=54cf78b7-1f0e-4c2a-9b7d-3e8a6c1d2f40
```
Returns the call object above. For an incoming call the object carries `direction: "inbound"`, `from`, `contact_id`, a `status` of `ringing`, `answered`, `completed` or `missed`, `missed_reason`, and `recording_available`. `recording_url` is returned on this read and on the `call.transcribed` webhook only; no other call event carries it.
List recent incoming calls, newest first:
```
GET https://api.bluereacher.com/v1/calls?direction=inbound&line_id=...&limit=20
```
`limit` runs 1 to 50.
## End, cancel or decline [#end-cancel-or-decline]
```
DELETE https://api.bluereacher.com/v1/calls?call_id=54cf78b7-1f0e-4c2a-9b7d-3e8a6c1d2f40
```
Safe to repeat. A prepared call becomes `cancelled`, a live call is ended, and a ringing incoming call is declined (`call.missed` fires with `missed_reason: "declined"`). A call that already finished returns as it is.
## Hand a call to a teammate [#hand-a-call-to-a-teammate]
```bash
curl -X POST "https://api.bluereacher.com/v1/calls/transfer?call_id=54cf78b7-1f0e-4c2a-9b7d-3e8a6c1d2f40" \
-H "Authorization: Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2" \
-H "Content-Type: application/json" \
-d '{ "to_email": "sam@yourcompany.com" }'
```
Name the teammate with `to_email` or `to_user_id`; they must be the workspace owner or an active team member. Their Blue Reacher screen rings for 45 seconds. When they take it they join the live call, the first person drops off, the customer hears nothing change, and `call.transferred` fires. If they decline or the offer runs out, the first person keeps the call and nothing fires.
| Status | Code | Meaning |
| ------ | ---------------------------------- | ---------------------------------------------------------------------------------------------- |
| 404 | `teammate_not_found` | Not the owner or an active team member |
| 409 | `call_not_answered` / `call_ended` | The call must be answered and still live |
| 409 | `transfer_in_progress` | Another offer is already ringing |
| 409 | `bridge_update_required` | The line's sync software needs an update for transfers; contact support |
| 409 | `transfer_unsupported_transport` | This call is connected in a way that cannot be handed to a teammate; the first person keeps it |
## Recording and transcripts [#recording-and-transcripts]
```
GET https://api.bluereacher.com/v1/call-settings
PATCH https://api.bluereacher.com/v1/call-settings
```
Two switches for the key's own workspace, `recording_enabled` and `transcription_enabled`, plus `summary_provider` (`openai` or `anthropic`), the AI that writes call summaries; without a working Claude key the summary falls back to OpenAI. The transcript itself always runs on the workspace's OpenAI key. There is no workspace parameter, so a key can only change its own workspace. These work on any live key, whether or not Calling is switched on; test keys answer `403 live_key_required`.
```bash
curl -X PATCH https://api.bluereacher.com/v1/call-settings \
-H "Authorization: Bearer brk_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "transcription_enabled": false }'
```
Recording can always be switched off. It can be switched on only after the call recording notice has been accepted in the app for the workspace; before that the answer is `409 recording_consent_required`. With transcripts off, recordings are kept and no transcript or summary is made. When both are on, `call.transcribed` delivers the transcript, a summary and the recording link about a minute after the call ends.
## Call history [#call-history]
```
GET https://api.bluereacher.com/v1/call-logs
GET https://api.bluereacher.com/v1/call-logs/{call_log_id}
```
Read the calls the workspace already holds, newest first, whoever placed them. Both routes are read-only and work whether or not Calling is switched on. The list needs a key with `write` permission. The MCP tools `list_call_logs` and `get_call_transcript` read the same history.
```bash
curl "https://api.bluereacher.com/v1/call-logs?transcribed_only=true&limit=10" \
-H "Authorization: Bearer brk_your_api_key"
```
Each call carries `direction`, `phone_number`, `contact_name`, `duration_seconds`, `transcript_status`, `has_transcript`, the AI `summary` and `recording_url` (null when there is no recording). The list carries no transcript text: read one call by its `id` to get `transcript`, which stays null until `transcript_status` is `completed`. Filter with `phone` (E.164) and `transcribed_only`, set `limit` from 1 to 100 (default 25), and page with `before` set to the `next_cursor` of the previous page while `has_more` is true. A call outside the key's workspace answers `404 call_log_not_found`.
## Rate limits [#rate-limits]
| Route | Limit |
| ------------------------------------------------------------------------------ | --------------------------------------------------------- |
| `GET /v1/calling-lines`, `GET` and `DELETE /v1/calls`, `GET /v1/call-settings` | 60/min per key, one shared bucket |
| `POST /v1/calls`, `POST /v1/calls/transfer`, `PATCH /v1/call-settings` | 10/min per key, one shared bucket; refused requests count |
## Errors [#errors]
| Status | Code | Meaning |
| ------ | -------------------------------------- | --------------------------------------------------------- |
| 400 | `invalid_destination` | `to` missing or not E.164 |
| 400 | `invalid_request` | A field the route does not take |
| 400 | `invalid_call_id` / `call_id_required` | `call_id` malformed or missing |
| 404 | `line_not_found` | `line_id` is not a calling line in this workspace |
| 404 | `call_not_found` | No call with that id for this key |
| 409 | `line_busy` | A prepared call is already waiting on this line |
| 409 | `idempotency_conflict` | Same `Idempotency-Key`, different body |
| 429 | `rate_limited` | Over 10 prepares, transfers and settings changes a minute |
# Campaigns and sequencing (/docs/api-reference/campaigns)
Campaigns (multi-step sequences with schedules, exit conditions and per-campaign caps) are a platform feature you configure in the dashboard, or that we build for you as part of the done-for-you setup. **There are no campaign REST objects in `/v1`.** An earlier revision of these docs sketched campaign endpoints that never shipped; they are gone, and this page describes what to build instead.
## Why there is no campaign API [#why-there-is-no-campaign-api]
A campaign is orchestration: who gets which message, when, and when to stop. Your system (a CRM, a sequencer, an agent) already owns that logic. What the API gives you is the two primitives orchestration actually needs, with all the deliverability machinery attached:
1. **Paced sends.** `POST /v1/messages` in drip mode inherits capacity caps, warm-up, quiet hours and human-like spacing. You decide *what* and roughly *when*; the platform decides the exact safe moment.
2. **Signals back.** Webhooks tell you what happened: `message.sent`, `message.delivered`, `message.read`, `message.failed`, `message.received`, `contact.opted_out`.
## The sequence pattern [#the-sequence-pattern]
A three-step follow-up sequence over the API:
1. **Step send.** Drip-send step 1 with an `Idempotency-Key` derived from your own sequence-step id, and your CRM id in `metadata`.
2. **Listen.** On `message.received` for that contact, mark the sequence replied and stop it. This is the exit condition that matters; on iMessage, reply rates make it the common exit.
3. **Advance.** No reply after your chosen delay: drip-send step 2. The `delay_minutes` field covers same-day pacing; multi-day steps are your scheduler's job.
4. **Stop conditions.** Stop on `contact.opted_out` always. Consider stopping on `message.failed` twice in a row, which usually means the number is dead.
Because drip sends are capacity-aware, a sequence that tries to start more new conversations than your lines can safely absorb queues rather than burns: the excess simply schedules later. Watch `available_today` on [`GET /v1/devices`](/docs/api-reference/lines) if you want to see headroom before pushing a batch.
## Where the platform campaign feature fits [#where-the-platform-campaign-feature-fits]
If you would rather not run orchestration at all, the platform's own campaign engine does structured sequences with per-campaign daily limits, and both limits (campaign and line) are enforced independently. Ask us to set it up during onboarding. Everything it sends is visible to the API afterwards: history in `GET /v1/conversations`, events on your webhooks.
# Check iMessage availability (/docs/api-reference/check-imessage)
{/* availability-2026-10-22 */}
`POST /v1/capability` is not available yet and answers `501 feature_unavailable`.
```
POST https://api.bluereacher.com/v1/capability
```
Batch lookup, up to 100 phone numbers per call. Tells you whether each number can take an iMessage before you spend a send on it. Requires **read** permission. Test keys work here: the simulator returns realistic `imessage` and `sms` answers marked `"sandbox": true` and never queues a real probe.
Numbers the platform already knows answer instantly from observed delivery state. Unknown numbers can queue a live probe (`probe: true`, the default) when the workspace has probes enabled and an online line with probe quota; probes are silent, the person on the other end sees nothing. Probe quota is 4,000/day per line. Pass `probe: false` to answer from observed state only.
## Why check before you send [#why-check-before-you-send]
* **You stop burning sends.** An iMessage aimed at an Android number falls back to SMS. Knowing first lets you route or skip.
* **Your reply rate stays honest.** Filter the never-deliverables out and campaign metrics describe real conversations.
* **Your lines stay healthy.** Only messaging people who can receive keeps delivery signals clean.
* **Lists rot.** People switch phones. A check the morning of a send catches the number that left iMessage in July.
## Request [#request]
| Field | Type | Required | Description |
| -------- | ------- | -------- | ----------------------------------------------------- |
| `phones` | array | yes | Up to 100 E.164 numbers |
| `probe` | boolean | no | Queue live probes for unknown numbers. Default `true` |
```bash
curl -X POST https://api.bluereacher.com/v1/capability \
-H "Authorization: Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2" \
-H "Content-Type: application/json" \
-d '{ "phones": ["+16465550119", "+13035550101"] }'
```
## Response [#response]
```json
{
"results": [
{
"phone": "+16465550119",
"status": "imessage",
"confidence": "confirmed",
"last_checked": "2026-08-30T14:41:53Z",
"probe_queued": false
},
{
"phone": "+13035550101",
"status": "pending",
"confidence": null,
"last_checked": null,
"probe_queued": true
}
],
"probes_enabled": true,
"probes_queued": 1
}
```
| `status` | Meaning |
| ---------- | ------------------------------ |
| `imessage` | Delivers as a blue bubble |
| `sms` | Falls back to SMS |
| `unknown` | Not known and no probe queued |
| `pending` | Probe queued; re-check shortly |
`confidence` distinguishes `confirmed` (observed delivery) from `inferred`. The practical pattern: run the check over your list before a campaign, send the `imessage` group with confidence, and decide per use case whether the `sms` group is worth green-bubble outreach.
## Errors [#errors]
| Status | Code | Meaning |
| ------ | ------------------------ | -------------------------------------------------------------------------------------------------------- |
| 400 | `invalid_json` | The body is malformed, empty or not a JSON object |
| 503 | `capability_unavailable` | The lookup could not run right now; retry shortly. The body repeats the code in `code` for older clients |
# Contacts, opt-out and the AI assistant (/docs/api-reference/contacts)
{/* availability-2026-10-22 */}
Available now: list (paged), create, read and update contacts. Not available yet: the `phone`, `email`, `tag`, `created_after` and `search` filters, the opt-out endpoint, `opted_out` on update, and the AI assistant controls. Creating a contact whose number already exists answers `409 contact_exists`.
## List and search contacts [#list-and-search-contacts]
```
GET https://api.bluereacher.com/v1/contacts
```
Requires **read**. Test keys work here too: the simulator answers with the live shapes, marked `"sandbox": true`, and is bound to no workspace, so a sandbox can never read real data.
| Query param | Meaning |
| ------------------ | ------------------------------------------ |
| `phone` | Exact E.164 match |
| `email` | Substring match |
| `tag` | Contacts carrying this tag |
| `search` | Free text across name and phone |
| `created_after` | ISO timestamp |
| `page`, `per_page` | Page/offset pagination, `per_page` max 100 |
```json
{
"contacts": [
{
"id": "c9b41d68-3a2f-4e07-b5d1-6f8e2a9c4d17",
"phone_number": "+16465550119",
"first_name": "Dana",
"last_name": "Reyes",
"email": "dana@northline.co",
"company": "Northline",
"tags": ["speed-to-lead"],
"custom_fields": { "crm_id": "0067Y00000..." },
"is_imessage": true,
"opted_out": false,
"last_contacted_at": "2026-08-30T14:02:11Z",
"last_message_received_at": "2026-08-30T14:41:53Z",
"created_at": "2026-08-21T09:12:44Z",
"updated_at": "2026-08-30T14:41:53Z"
}
],
"page": 1,
"per_page": 25,
"total": 1,
"has_more": false
}
```
## Create (upsert by phone) [#create-upsert-by-phone]
```
POST https://api.bluereacher.com/v1/contacts
```
Requires **write**. Creates the contact, or updates the existing one with the same `phone_number`. `201` on create, `200` on update; the response carries `created: true|false`. Fields: `phone_number` (required, E.164), `first_name`, `last_name`, `email`, `company`, `tags`, `notes`, `custom_fields`.
## Update [#update]
```
PATCH https://api.bluereacher.com/v1/contacts/{contact_id}
```
Requires **write**. Partial update; `custom_fields` shallow-merges with the existing map. `opted_out` follows the same rules as the opt-out route below. `opted_out: true` runs the shared opt-out path: future platform sends are suppressed, `opted_out_at` is stamped, CRM do-not-disturb syncs and `contact.opted_out` fires. On a contact who is already opted out it keeps the original `opted_out_at` and fires nothing. `opted_out: false` needs `confirm_resubscribe: true`; with it, CRM do-not-disturb is lifted and `contact.resubscribed` fires. No confirmation text is sent to the contact.
| Status | Code | Meaning |
| ------ | ----------------------------------- | ----------------------------------------------------------------------- |
| 400 | `invalid_json` | The body is not a JSON object |
| 400 | `invalid_opted_out` | `opted_out` is not a boolean |
| 400 | `resubscribe_confirmation_required` | `opted_out: false` without `confirm_resubscribe: true` |
| 500 | `update_failed` | The consent change could not be saved. Read the contact before retrying |
A refused request changes nothing.
## Opt-out state [#opt-out-state]
```
GET https://api.bluereacher.com/v1/opt-out?phone=+16465550119
POST https://api.bluereacher.com/v1/opt-out
```
GET returns `opted_out`, `opted_out_at` and `known_contact` for an exact E.164 match. POST with `opted_out: true` runs the same enforcement path as an inbound STOP reply: the contact is suppressed everywhere and the `contact.opted_out` webhook fires. Idempotent.
Re-subscribing is deliberately harder: `opted_out: false` requires `confirm_resubscribe: true` (`400 resubscribe_confirmation_required` otherwise) and should only be used when the contact explicitly asked to hear from you again. It runs the shared resubscribe path: CRM do-not-disturb is lifted, `resubscribed_at` is stamped, and `contact.resubscribed` fires when the contact was actually opted out. No message is sent to the contact. Unknown phones return `404 contact_not_found` rather than creating a contact.
STOP, UNSTOP and START in inbound messages are honored automatically; you never need to parse them yourself. A recognized re-subscribe phrase from a genuinely opted-out contact emits `contact.resubscribed`, the same event a confirmed API re-subscribe emits.
## AI assistant control [#ai-assistant-control]
```
GET https://api.bluereacher.com/v1/bot?phone=+16465550119
POST https://api.bluereacher.com/v1/bot
```
Controls whether the AI assistant replies to one contact. The assistant runs on your own AI provider key, added under Integrations in the app, and your provider bills you for it directly. GET returns the stored setting plus an `effective` block computed from the reply engine's own gate chain, so you can distinguish "switched on and replying" from "switched on but something upstream is off".
POST `status` values:
| Value | Effect |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `inactive` | Mute the assistant for this contact |
| `snooze` | Pause for `snooze_minutes` (default 60, max 90 days). The right call when a human takes the conversation over |
| `active` | Return the contact to the assistant's own schedule, clearing any snooze |
| `active_always` | Reply 24/7 to this contact, overriding configured business hours. Requires `confirm_always_on: true`, because it can reach a real person at 3am |
The state is always persisted, but read `effective` in the response rather than assuming: a workspace-level or account-level gate can mean the assistant still will not reply, and `warnings` will say so.
# Inbound webhooks (/docs/api-reference/inbound-webhooks)
{/* availability-2026-10-22 */}
Inbound webhook endpoints are not available yet. They answer `501 feature_unavailable` while they move to new infrastructure.
An inbound webhook is a URL you give to a form, a landing page builder or any tool that can send a webhook. Each post creates the contact in your workspace, or updates it if the phone number already exists. Tags you set on the endpoint are added, so a tag-based automation can pick the contact up straight away.
Outbound events (a message was received, a send failed) are the other direction: see [Webhooks](/docs/api-reference/webhooks).
## Create an endpoint [#create-an-endpoint]
In the app, open **Developer → Inbound Webhooks** and click **New endpoint**. You get a URL like this:
```
https://api.bluereacher.com/v1/receive-webhook?token=5f0c2b1e-8d7a-4c3e-9b21-6a4f0e7d9c18
```
The token is created for you and cannot be chosen. Treat the URL like a password: anyone who has it can add contacts to your workspace. If it leaks, delete the endpoint and create a new one.
Each endpoint has:
| Setting | What it does |
| --------------- | ----------------------------------------------------------------------------------------- |
| Name | Your label, shown in the endpoint list |
| Field mapping | Which incoming field fills which contact field, for example `cell` to Phone |
| Unmapped fields | Save them as custom fields (the default), add them to the contact's notes, or ignore them |
| Default tags | Added to every contact this endpoint creates or updates |
| Active | Paused endpoints refuse posts with `403` |
A workspace can hold 25 endpoints.
## Send a post [#send-a-post]
Send a `POST` with JSON, form fields (`application/x-www-form-urlencoded`) or `multipart/form-data`. No API key is needed: the token in the URL is the credential. Browsers can post directly, because the endpoint answers cross-origin requests.
```bash
curl -X POST "https://api.bluereacher.com/v1/receive-webhook?token=YOUR_TOKEN" \
-H "content-type: application/json" \
-d '{"phone": "(415) 555-0100", "name": "Pat Lee", "email": "pat@example.com", "company": "Lee Roofing"}'
```
A phone number is required. US numbers without a country code are read as `+1`.
### Fields recognised without a mapping [#fields-recognised-without-a-mapping]
| Contact field | Incoming names it picks up |
| ------------- | ----------------------------------------------------------------------- |
| Phone | `phone`, `phone_number`, `mobile`, `cell`, `tel`, `telephone`, `number` |
| Email | `email`, `email_address` |
| First name | `first_name`, `firstname`, `fname`, `given_name` |
| Last name | `last_name`, `lastname`, `lname`, `surname`, `family_name` |
| Full name | `name`, `full_name`, `contact_name` (split into first and last) |
| Company | `company`, `company_name`, `business`, `organization` |
| Notes | `notes`, `message`, `comments` |
Names are matched without regard to case, spaces or dashes. Nested JSON is read too: `{"lead": {"phone": "..."}}` finds the phone. A mapping you set always wins over these defaults.
## Responses [#responses]
```json
{ "success": true, "contact_id": "c_8f2a...", "is_new_contact": true }
```
| Status | Meaning |
| ------ | -------------------------------------------------------------- |
| `200` | Contact created or updated |
| `400` | Body is not JSON or form fields, or no phone number was found |
| `403` | The endpoint is paused |
| `404` | Unknown token |
| `405` | Not a `POST` |
| `413` | Body over 256 KB |
| `429` | Over 120 posts a minute on this endpoint; retry after a minute |
| `502` | The contact could not be saved; retry shortly |
## Limits [#limits]
* 120 posts a minute per endpoint.
* Bodies up to 256 KB.
* Up to 50 unmapped fields are saved per post as custom fields; the rest are dropped.
* Nested JSON is read up to five levels deep.
## See every hit [#see-every-hit]
**Developer → Inbound Webhooks** shows each endpoint's recent posts with the status, the time taken and the body received, so you can check a form is wired correctly without leaving the app.
# Lines (/docs/api-reference/lines)
A line is one managed iMessage sending number. In API field names a line is called a device (`device_id`, `device_name`); the two words mean the same thing everywhere in these docs.
## List lines [#list-lines]
```
GET https://api.bluereacher.com/v1/devices
```
Returns every line in the key's workspace with online state, today's cold-outreach usage against its effective daily cap (warm-up aware), and queue depth. Use it to pick a `device_id` for a send, or to check available capacity before a batch.
```bash
curl https://api.bluereacher.com/v1/devices \
-H "Authorization: Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2"
```
```json
{
"success": true,
"devices": [
{
"id": "8c9a4f2e-1d3b-4a6c-9e8f-2b7c5d1a9e3f",
"name": "Line 2",
"number": "+14155550142",
"line_type": "mac",
"status": "online",
"sent_today": 212,
"cold_outreach_today": 38,
"daily_cap": 50,
"queue_depth": 6,
"available_today": 12,
"last_seen": "2026-08-31T07:58:12Z",
"warmup": null
}
]
}
```
| Field | Meaning |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `line_type` | What the line can send. `mac` and `web` send iMessage; `android` sends SMS only. Any other value, or `null`, cannot send |
| `status` | `online` or `offline`. Offline lines cannot take instant sends |
| `sent_today` | Same value as `cold_outreach_today`; kept for older clients |
| `cold_outreach_today` | New cold contacts reached today; the only number the cap counts |
| `daily_cap` | Effective new-contact cap today. Warm-up aware: a ramping line shows its current tier, a graduated line shows its full cap |
| `available_today` | `daily_cap` minus `cold_outreach_today` |
| `queue_depth` | Drip sends waiting on this line |
| `warmup` | `null` once graduated; during ramp: `{ mode, started_at, duration_days, active_days }` |
Route around any line where `available_today` is 0 or `status` is `offline`; or skip routing entirely by omitting `device_id` on sends and letting load-balancing pick.
The cap counts brand-new cold contacts only. Replies, engaged contacts and
returning contacts are unlimited and never consume capacity. The full model
is in [Rate limits and sending capacity](/docs/guides/rate-limits).
## Warm-up [#warm-up]
A new line earns capacity over roughly four weeks of **active days**, not calendar days: a day banks toward the ramp when the line actually does meaningful sending, and idle days pause progress rather than resetting it. The ramp climbs from a handful of new contacts per day to the line's full cap. `warmup.active_days` against `warmup.duration_days` tells you where a line is. Blue Reacher manages the schedule; you never have to pace a ramping line yourself, because `daily_cap` already reflects its current tier and the drip queue respects it.
## Health lifecycle [#health-lifecycle]
Deliverability enforcement on iMessage happens at Apple's end, per number. Blue Reacher's job is to keep your lines under those thresholds and to absorb the damage when one gets hit anyway.
* **Healthy.** The steady state. The drip queue paces sends, quiet hours clamp cold deliveries to the recipient's local daytime, and caps hold new-contact volume in the safe range.
* **Cooldown.** If Apple rate-limits a number (the typical trigger is too many new cold contacts too fast, or a spike in spam reports), our system pauses cold sends on that line automatically and lets it rest, typically 24 to 72 hours, then re-ramps it. Replies to engaged contacts continue. You will see the pause reflected in `daily_cap` and, if you subscribe, a `device.health_changed` webhook.
* **De-registration and replacement.** In the worst case Apple de-registers the number from iMessage entirely. The number still works for SMS, but we do not run lines that way: we are an iMessage-first platform, so we replace the line. Replacement is free (once per line per month), typically live within one business day. Conversation history stays in the platform under the line; the new number is a new sender from the recipient's side, so re-opened threads count as new conversations and the replacement line ramps like a new one. Apple bans the number itself, not the account or the platform, which is exactly why the fix is a number swap.
`device.status_changed` (online/offline) and `device.health_changed` (can-it-actually-send states like `send_outage`, `imessage_collapse`) are separate webhook events, so a line powered down on purpose never shows up as a health incident.
# Status and conversations (/docs/api-reference/messages)
{/* availability-2026-10-22 */}
Available now: message status and one-to-one conversation history. Not available yet: group threads, filtering by `device_id`, and `before` / `after` on contact history (page with `page` and `per_page`).
## Message status [#message-status]
```
GET https://api.bluereacher.com/v1/status/{message_id}
```
Looks the id up in the drip queue first (text sends), then in the messages table (instant sends and voice memos).
```bash
curl https://api.bluereacher.com/v1/status/sim_d_1788116691413_milvzs \
-H "Authorization: Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2"
```
```json
{
"success": true,
"message_id": "sim_d_1788116691413_milvzs",
"type": "drip_queue",
"status": "sent",
"scheduled_for": "2026-08-31T09:14:51.413Z",
"sent_at": "2026-08-31T09:15:02.101Z",
"device_name": "Line 2",
"error": null
}
```
| Field | Meaning |
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | `drip_queue`, `message`, or `voice_memo` |
| `status` | A drip id (`type: drip_queue`) reports the queue's own state: `pending`, `processing` (briefly, while a line takes it), `sent`, `failed`, `cancelled` or `skipped`. It stays `sent` once the item is handed to a line, so follow delivery with the `message.delivered` and `message.read` webhooks. An instant or voice-memo id reports the message's state: `pending`, `sent`, `delivered`, `read`, `failed`. Other states may appear; treat unknown values as in-flight |
| `error` | Human-readable failure reason when `status` is `failed`, else `null`, on both lanes. A drip item put back in the queue does not show its reschedule note here |
Prefer webhooks over polling: `message.sent` fires at send confirmation and is usually the definitive event, because US SMS and many iMessage sends never produce a delivery receipt. `message.delivered` and `message.read` fire on the first receipt of each kind when the recipient produces one.
With a `brk_test_` key, simulated ids progress on a timer: drip `pending` to `sent` after about 5 seconds, instant `sent` to `delivered` after about 10 seconds.
## Conversation history [#conversation-history]
```
GET https://api.bluereacher.com/v1/conversations
```
Requires **write** permission. Chronological history, oldest first, with per-message delivery channel, tapback reactions and AI attribution. Pass exactly one of `phone` (1:1 thread) or `group_chat_id` (group thread).
| Query param | Default | Meaning |
| --------------- | ------- | ------------------------------------------------ |
| `phone` | | E.164 number for a 1:1 thread |
| `group_chat_id` | | Group id from `GET /v1/groups` |
| `limit` | 50 | Max 200 |
| `before` | | ISO timestamp; return messages created before it |
| `device_id` | | Filter to one line (1:1 only) |
```bash
curl "https://api.bluereacher.com/v1/conversations?phone=%2B16465550119" \
-H "Authorization: Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2"
```
```json
{
"success": true,
"phone": "+16465550119",
"messages": [
{
"id": "m_311",
"direction": "outbound",
"message": "Hey Dana, saw you took over ops at Northline. Worth a quick chat?",
"type": "text",
"sent_at": "2026-08-30T14:02:11Z",
"device_name": "Line 2",
"status": "delivered",
"delivery_channel": "imessage",
"reactions": [],
"ai_generated": false
},
{
"id": "m_312",
"direction": "inbound",
"message": "Sure, Thursday works",
"type": "text",
"sent_at": "2026-08-30T14:41:53Z",
"device_name": "Line 2",
"status": "received",
"delivery_channel": "imessage",
"reactions": [
{ "emoji": "❤️", "from_contact": false, "delivered": true, "delivered_at": "2026-08-30T14:42:30Z", "created_at": "2026-08-30T14:42:29Z" }
],
"ai_generated": false
}
],
"has_more": false,
"next_cursor": null
}
```
`delivery_channel` tells you what actually carried each message: `imessage` (blue bubble) or `sms` (green bubble). Paginate by passing the `next_cursor` value as `before` until `has_more` is `false`.
Group reads swap `phone` for `group_chat_id`, add a `group` summary object, and each message carries `from`, the sender's phone (`null` for your own outbound rows).
# OpenAPI spec and Postman (/docs/api-reference/openapi-spec)
## The spec [#the-spec]
The Blue Reacher API is published as an OpenAPI 3.1 document:
```
https://docs.bluereacher.com/openapi.json
```
It covers the full API surface: messages and group sends, voice memos, conversations and groups, lines, delivery status, signals (tapbacks, typing, read state), contacts, opt-out, AI assistant control, capability lookup, usage metering, the MCP endpoint, and every webhook event, with request and response schemas, the auth scheme, and the error codes each endpoint returns. The spec, these prose pages, and the live API are generated against the same contract; a disagreement between them is a bug, and we want to hear about it.
## Import into Postman [#import-into-postman]
1. Postman, Import, Link.
2. Paste `https://docs.bluereacher.com/openapi.json`, Continue, Import.
3. Postman generates a collection with every endpoint and an example body per request.
4. Open the collection's Authorization tab, type Bearer Token, and paste your key. Every request inherits it.
Set the collection variable `baseUrl` to `https://api.bluereacher.com` if your Postman version does not pick up the server block automatically; paths in the spec carry the `/v1` prefix.
## Import into Insomnia or Bruno [#import-into-insomnia-or-bruno]
Both accept the same URL. Insomnia: Create, Import From, URL. Bruno: Import Collection, OpenAPI. Auth setup is identical, a bearer token at collection level.
## Generate a client [#generate-a-client]
```bash
# TypeScript
npx openapi-typescript https://docs.bluereacher.com/openapi.json -o bluereacher.d.ts
# Python
openapi-python-client generate --url https://docs.bluereacher.com/openapi.json
# Go, Java, Ruby, PHP, and others
openapi-generator-cli generate \
-i https://docs.bluereacher.com/openapi.json \
-g go \
-o ./bluereacher-go
```
A generated client gives you types and request shapes, not judgment. The rules that keep a program working, an `Idempotency-Key` on every send, capacity awareness before a batch, signature verification on every webhook, are behavioral and live in [key concepts](/docs/guides/key-concepts), [rate limits](/docs/guides/rate-limits), and [webhooks](/docs/api-reference/webhooks).
## For AI agents and assistants [#for-ai-agents-and-assistants]
The whole documentation set is available in machine-readable form:
```
https://docs.bluereacher.com/llms.txt # index
https://docs.bluereacher.com/llms-full.txt # every page, full text
```
Any page also returns markdown when you request it with a `.md` suffix, so `https://docs.bluereacher.com/docs/guides/quickstart.md` gives the raw source instead of the rendered page.
If you want an assistant operating the account rather than reading about it, use the [MCP server](/docs/guides/mcp-server), which exposes the same operations as tools.
## Keeping in sync [#keeping-in-sync]
The spec is versioned with the documentation and updated alongside it. Pin a copy in your repository if reproducible builds matter to you, and diff against the live URL when you upgrade rather than regenerating blind.
# Overview (/docs/api-reference/overview)
The Blue Reacher API is JSON over HTTPS. Anything that can make an HTTP request can drive it, and there is no SDK you are required to install. If your agent runs on Claude Code, Claude Desktop or Cursor, the [MCP server](/docs/guides/mcp-server) exposes the same surface as callable tools with one install command.
The machine-readable contract lives at [`/openapi.json`](https://docs.bluereacher.com/openapi.json) (OpenAPI 3.1). This reference, that spec, and the live API are generated against the same contract; if you ever catch them disagreeing, tell us and we will treat it as a bug.
The `curl` examples in this reference carry the free public [sandbox](/docs/guides/sandbox) key, `brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2`. Paste any of them into a terminal and you get a real simulated response. Nothing is ever sent. Swap in your own key when you build.
## Base URL [#base-url]
```
https://api.bluereacher.com/v1
```
Every path in this reference is relative to that base. There is no separate sandbox host: test mode is selected by the key you use, not the URL.
## Authentication [#authentication]
Every request carries a bearer token.
```bash
Authorization: Bearer brk_your_api_key
```
Live keys are prefixed `brk_live_`, test keys `brk_test_`. Keys carry `read`, `write` or `admin` permission, and each key is bound to one workspace, so an agent running in staging can hold a key that cannot reach the lines your customers know. Keys are created in your Blue Reacher app under **Developer → API Keys**, or with the [API keys endpoints](/docs/api-reference/api-keys); the full value is shown once.
Keep keys server-side. Anyone holding a live key can send from your lines. A
leaked key is revoked in Developer → API Keys and stops working within a minute.
## Test mode [#test-mode]
`brk_test_` keys are answered by a simulator: identical request validation and identical response shapes, but no message is ever sent and no contact, line or queue row is touched. Every sandbox response carries `"sandbox": true` in the body plus an `X-BlueReacher-Sandbox: true` header, so it is impossible to mistake simulated traffic for real traffic.
Simulated `sim_` message ids progress on a timer, so polling behaves realistically: a drip send goes `pending` then `sent` after about 5 seconds, an instant send goes `sent` then `delivered` after about 10 seconds. Test keys cover messaging, `/v1/conversations`, `/v1/devices`, `/v1/status`, signals, opt-out, bot, MCP, `/v1/contacts` and `/v1/capability` (all answered by the simulator with the live shapes) and `/v1/usage` (a fixed sample). Calling (`/v1/calling-lines`, `/v1/calls`) and group management are simulated too. Two surfaces need a live key: conversation history (`GET /v1/conversations/{contact_id}/messages` returns `403 sandbox_key_not_supported_here` on a test key) and call settings (`403 live_key_required`). A test key is bound to no workspace, so it can never read or write anything real.
## How sending works [#how-sending-works]
`POST /v1/messages` has two lanes, and the default is the safe one.
**Drip (default).** The send is inserted into the paced pipeline the platform itself uses: scheduled about 10 minutes out, spaced against everything else the line is doing, and counted against the line's daily new-contact cap. There is no sending-hour limit unless you pass one: `window_start_hour` and `window_end_hour` (whole hours, 0 to 24, start before end, in your organization's local time) hold the send inside that window. `delay_minutes` is tunable per request too. This is the lane for outreach.
**Instant (`send_mode: "instant"`).** Dispatches through the live pipeline right now. Built for conversational replies, not blasts: velocity-capped at 10/min per key, with no per-key daily cap: each line's daily cap on new cold contacts still applies (`429 device_daily_cap_exceeded` when the line, and every line the send could move to, is full for the day), and it requires an online line (`409 no_online_device` otherwise). Group sends and message effects require instant.
Media attaches on either lane with `media_urls` (1 to 10 HTTPS URLs). A successful send means accepted, not delivered; track the outcome with webhooks (preferred) or by polling `GET /v1/status/{message_id}`.
## Idempotency [#idempotency]
Pass an `Idempotency-Key` header (up to 255 characters) on `POST /v1/messages`. A duplicate request within 24 hours replays the original response with an `Idempotent-Replay: true` header instead of sending twice. A failed attempt releases the key so a retry can go through; a concurrent duplicate gets `409 idempotency_conflict`, and the same key with a different body gets `409 idempotency_payload_mismatch`. Set it from something stable in your own system and a retry after a timeout can never double-send.
## Errors [#errors]
Errors return JSON with a human message and a machine-readable code:
```json
{
"error": "No contact with this phone in the workspace",
"error_code": "contact_not_found",
"help": "Create the contact first with POST /v1/contacts"
}
```
`400`, `401`, `403`, `404` and `422` mean the same request will fail again, so do not retry blindly. `409` usually means the work is already done or in flight. `429` and `5xx` are retryable with backoff. The full code list is in [Errors and status codes](/docs/guides/errors).
## Rate limits [#rate-limits]
Two different limits, and mixing them up is the most common integration bug.
| | API rate limits | Sending capacity |
| ------ | ----------------------- | --------------------------------------------- |
| Counts | HTTP requests | New contacts reached |
| Scope | Per API key | Per line |
| Signal | `429` with `retryAfter` | Drip queue paces automatically |
| Fix | Back off and retry | Add lines, or let the queue spread the volume |
API limits: 60/min across all `/v1` routes, 30/min on `POST /v1/messages`, 10/min on instant sends (no per-key daily cap; each line's daily new-contact cap applies), 300/min on contacts reads. Rate-limited responses return `429` with `retryAfter` in seconds and a `Retry-After` header. The full table and the capacity model are in [Rate limits and sending capacity](/docs/guides/rate-limits).
## Webhooks [#webhooks]
Events are pushed as signed HTTPS POSTs: `message.received`, `message.sent`, `message.delivered`, `message.read`, `message.failed`, `reaction.received`, `contact.created`, `contact.opted_out`, `contact.resubscribed`, `device.status_changed`, `device.health_changed`, plus the `call.*` events. Every delivery carries `X-BlueReacher-Signature: sha256=`; verify by recomputing the HMAC over the raw body before parsing. Deliveries are at-least-once with a stable `event_id` to deduplicate on. Details and payloads are in [Webhooks](/docs/api-reference/webhooks).
## Conventions [#conventions]
| Rule | Detail |
| ----------------- | --------------------------------------------------------------------------------- |
| Field names | `snake_case`, in both directions. |
| Phone numbers | E.164 only, for example `+14155550142`. Normalise before you send. |
| Timestamps | ISO 8601 strings, UTC. |
| Ids | Opaque strings. Sandbox ids are prefixed `sim_`. Do not parse ids for meaning. |
| Empty values | `null` for "no value", never an empty string. |
| Lines and devices | The same object. Prose says line; API field names say `device_id`, `device_name`. |
## Versioning [#versioning]
The path carries the version. `/v1` is current and additive: new fields and new event types can appear, so parse defensively and ignore what you do not recognise. Anything that would break an existing integration ships behind a new version.
These are part of the `/v1` contract and do not change while `/v1` exists:
| Contract | Stays |
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| Base URL | `https://api.bluereacher.com/v1` |
| Authentication | `Authorization: Bearer brk_...` with `brk_live_` and `brk_test_` keys. Keys you hold keep working until you rotate or revoke them. |
| Paths and fields | Every documented path, request field and response field keeps its name, type and meaning. |
| Errors | The error body shape and its `error` codes. |
| Webhooks | Event names, the envelope, `event_id` and the `X-BlueReacher-Signature` scheme. |
| MCP | The endpoint `https://api.bluereacher.com/v1/mcp` and each tool's name and inputs. |
Build against this contract and nothing else. Anything outside it, such as an internal hostname in a response header or a key format not listed here, is not part of the API and can change.
# Rich messaging and native signals (/docs/api-reference/rich-messaging)
{/* availability-2026-10-22 */}
Available now: one media attachment per message. Not available yet: message effects, voice memos, tapbacks, the typing bubble and read receipts (`501 feature_unavailable`, or `400 parameter_unavailable` for `message_effect`).
Everything on this page is what makes a Blue Reacher line feel like a person on an iPhone rather than a gateway.
## Media [#media]
Attach 1 to 10 files to any send with `media_urls` on [`POST /v1/messages`](/docs/api-reference/send-message): public `https://` URLs, max 2048 characters each. Works on both drip and instant. `message` becomes optional when media is present.
## Message effects [#message-effects]
Instant sends can carry a native iMessage effect via `message_effect`:
| Full screen | Bubble |
| ----------------------------------------------------------------------------------------- | ---------------------------------------- |
| `confetti`, `balloons`, `love`, `lasers`, `fireworks`, `celebration`, `echo`, `spotlight` | `slam`, `loud`, `gentle`, `invisibleink` |
Effects require `send_mode: "instant"`; a drip send with an effect returns `400 effect_requires_instant` rather than quietly delivering plain. Effects degrade by design: a message carrying media sends plain, and an SMS recipient always gets plain text.
## Voice memos [#voice-memos]
```
POST https://api.bluereacher.com/v1/voice-memos
```
Requires **write**. Fetches your audio (public HTTPS URL, up to 25 MB, mp3 / m4a / wav / aac) and delivers it as a native Apple voice bubble when possible, falling back to a media attachment otherwise. The response tells you which happened.
```bash
curl -X POST https://api.bluereacher.com/v1/voice-memos \
-H "Authorization: Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2" \
-H "Content-Type: application/json" \
-d '{ "to": "+16465550119", "audio_url": "https://example.com/clips/intro.m4a" }'
```
```json
{ "success": true, "message_id": "vm_882", "queued": true, "memo_format": "native_bubble" }
```
`422 audio_url_unreachable` means the URL did not resolve to a fetchable audio file. Rate limit: 10/min per key.
## Tapback reactions [#tapback-reactions]
```
POST https://api.bluereacher.com/v1/reactions
DELETE https://api.bluereacher.com/v1/reactions
```
Send a native tapback on a message you identify by `message_id`, or by `phone` plus `external_id` (the iMessage GUID carried on inbound webhook events). The six classic tapbacks are supported: ❤️ 👍 👎 😂 ‼️ ❓. Any other emoji, or a word or phrase, is rejected `422` `not_deliverable`.
```bash
curl -X POST https://api.bluereacher.com/v1/reactions \
-H "Authorization: Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2" \
-H "Content-Type: application/json" \
-d '{ "emoji": "❤️", "message_id": "m_312" }'
```
POST is idempotent: re-sending an already-delivered tapback returns `already_delivered: true` without re-sending. DELETE has two outcomes and says which occurred: a still-queued tapback is cancelled before the recipient ever sees it (`cancelled_before_delivery: true`), and an already-delivered one is genuinely taken back off the recipient's device (`removal_queued: true`). SMS bubbles cannot take tapbacks. Rate limit: 30/min.
Inbound reactions from contacts arrive as the `reaction.received` webhook.
## Typing indicator [#typing-indicator]
```
POST https://api.bluereacher.com/v1/typing
```
Lights the native "…" bubble on the recipient's device from your line. Window defaults to about 10 seconds; override with `duration_ms` (3000 to 60000). The response returns `expires_at` and `keepalive_interval_ms`; re-POST at that cadence to hold the bubble while composing. `is_typing: false` clears immediately.
```bash
curl -X POST https://api.bluereacher.com/v1/typing \
-H "Authorization: Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2" \
-H "Content-Type: application/json" \
-d '{ "phone": "+16465550119", "device_id": "8c9a4f2e-1d3b-4a6c-9e8f-2b7c5d1a9e3f", "is_typing": true }'
```
The natural agent pattern: typing on, wait a human-plausible beat, send instant, typing clears itself.
## Read state [#read-state]
```
POST https://api.bluereacher.com/v1/read
```
Clears the connected CRM conversation's unread badge for a contact (graceful no-op without a CRM). With `native: true` plus `device_id`, a native mark-thread-read is queued for the line, which shows the contact their message was read.
```bash
curl -X POST https://api.bluereacher.com/v1/read \
-H "Authorization: Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2" \
-H "Content-Type: application/json" \
-d '{ "phone": "+16465550119", "native": true, "device_id": "8c9a4f2e-1d3b-4a6c-9e8f-2b7c5d1a9e3f" }'
```
# Groups (/docs/api-reference/send-group-message)
{/* availability-2026-10-22 */}
Group chats are not available on the API yet. The endpoints on this page answer `501 feature_unavailable`, and group fields on `POST /v1/messages` answer `400 parameter_unavailable`.
Group iMessage threads are first-class: you can list them, read them, send into them, create new ones, and manage them. All group sends run `send_mode: "instant"`; the drip queue paces 1:1 threads only.
## List groups [#list-groups]
```
GET https://api.bluereacher.com/v1/groups
```
Requires **write** permission. Returns every group thread in the key's workspace, most recent activity first.
| Query param | Default | Meaning |
| ------------- | ------- | ------------------------------- |
| `limit` | 50 | Max 200 |
| `active_only` | false | Only groups still marked active |
```json
{
"success": true,
"groups": [
{
"id": "b7f3e9c1-52d8-4f0a-9c6e-8a1d4b2f7e5c",
"display_name": "Northline deal room",
"participant_phones": ["+16465550119", "+13035550101"],
"participant_count": 2,
"last_message_at": "2026-08-30T16:22:41Z",
"last_message_preview": "Works for me, send the doc",
"is_active": true,
"device_id": "8c9a4f2e-1d3b-4a6c-9e8f-2b7c5d1a9e3f",
"created_at": "2026-08-12T10:03:55Z"
}
],
"count": 1
}
```
`device_id` is the line that owns the thread. A group always sends from its owning line, so `device_id` cannot be combined with `group_chat_id` on the send call.
## Send into an existing group [#send-into-an-existing-group]
Use the group's `id` as `group_chat_id` on [`POST /v1/messages`](/docs/api-reference/send-message):
```bash
curl -X POST https://api.bluereacher.com/v1/messages \
-H "Authorization: Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2" \
-H "Content-Type: application/json" \
-d '{
"group_chat_id": "b7f3e9c1-52d8-4f0a-9c6e-8a1d4b2f7e5c",
"message": "Doc attached, page 3 has the numbers you asked for",
"send_mode": "instant"
}'
```
The response echoes `group_chat_id`. `device_id` comes back `null` because the owning line is resolved downstream.
## Create a new group and send [#create-a-new-group-and-send]
Pass `create_group_phones` (2 to 25 E.164 numbers) instead of `to`:
```bash
curl -X POST https://api.bluereacher.com/v1/messages \
-H "Authorization: Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2" \
-H "Content-Type: application/json" \
-d '{
"create_group_phones": ["+16465550119", "+13035550101"],
"message": "Looping you both in here so we stop forwarding emails",
"send_mode": "instant"
}'
```
The response carries `group_created: true` and `participant_count`. The new group then appears in `GET /v1/groups`.
## Read a group thread [#read-a-group-thread]
Pass `group_chat_id` to [`GET /v1/conversations`](/docs/api-reference/messages). Group responses include a `group` summary, and each message carries `from`, the sender's phone (`null` on your own outbound rows).
## Manage a group [#manage-a-group]
Rename a group, set its photo, add or remove people, or leave it. Every change runs through Apple's own group features on the line that owns the thread, so members see it exactly as if it had been made in Messages.
| Change | Call | Body |
| ------------- | ------------------------------------------- | ------------------------------------------------- |
| Rename | `PATCH /v1/groups/{group_chat_id}` | `display_name` (`null` or `""` clears it) |
| Set the photo | `POST /v1/groups/{group_chat_id}/photo` | `image_url`: https, JPEG or PNG, a few MB at most |
| Add people | `POST /v1/groups/{group_chat_id}/members` | `phones`: 1 to 24 E.164 numbers |
| Remove people | `DELETE /v1/groups/{group_chat_id}/members` | `phones`: 1 to 24 E.164 numbers |
| Leave | `POST /v1/groups/{group_chat_id}/leave` | none |
These calls are asynchronous. The API checks the request, queues it for the owning line and answers `202` with a `command_id`. The line runs it within about five seconds when it is online.
```bash
curl -X PATCH https://api.bluereacher.com/v1/groups/00000000-0000-4000-a000-000000009991 \
-H "Authorization: Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2" \
-H "Content-Type: application/json" \
-d '{ "display_name": "Northline Q4" }'
```
```json
{
"success": true,
"command_id": "5d1c0b7e-9a2f-4e61-8c3d-2f7b9e4a1c60",
"group_chat_id": "b7f3e9c1-52d8-4f0a-9c6e-8a1d4b2f7e5c",
"action": "rename",
"status": "queued",
"device_id": "8c9a4f2e-1d3b-4a6c-9e8f-2b7c5d1a9e3f",
"device_online": true,
"poll": "/v1/groups/b7f3e9c1-52d8-4f0a-9c6e-8a1d4b2f7e5c/commands/5d1c0b7e-9a2f-4e61-8c3d-2f7b9e4a1c60"
}
```
Poll `GET /v1/groups/{group_chat_id}/commands/{command_id}` for the outcome. `status` is `queued` until the line reports back, then `completed` (with `result`: the new `display_name`, the `participant_phones` after the change, or `left: true`) or `failed` (with `error`, for example `bridge_no_response` when the line did not answer three hand-offs, or `cannot_remove_participants`).
Rules worth knowing:
* A group iMessage holds at most 32 people.
* Adding numbers that are already in the group skips them; if all of them are already in, the answer is `409 already_member`. Removing works the same way with `409 not_a_member`.
* A removal must leave at least two other people. To go below that, leave the group (`400 group_too_small`).
* After leaving, the group is marked `is_active: false`. It can no longer be sent to or managed, and its history stays on the line.
## Errors [#errors]
| Status | Code | Meaning |
| ------ | --------------------------------- | ------------------------------------------------------------------------------------- |
| 400 | `group_requires_instant` | Group target without `send_mode: "instant"` |
| 404 | `group_not_found` | `group_chat_id` not in this key's workspace |
| 400 | `group_too_small` | A removal would leave fewer than two other people |
| 409 | `already_member` / `not_a_member` | Nothing to add, or nothing to remove |
| 409 | `bridge_update_required` | The owning line's sync software needs an update for group management; contact support |
# Send a message (/docs/api-reference/send-message)
{/* availability-2026-10-22 */}
Available now: one-to-one sends with text and one attachment, drip or instant, with `Idempotency-Key`. Not available yet: group sends, `message_effect`, `delay_minutes`, send windows, `device_id` and `reply_to_*`. Those fields answer `400 parameter_unavailable`.
```
POST https://api.bluereacher.com/v1/messages
```
Requires **write** permission. Sends iMessage first, with SMS fallback when the recipient cannot take an iMessage.
## Choosing the lane [#choosing-the-lane]
`send_mode` picks between two pipelines:
* **`drip`** (default): the paced pipeline. The message is scheduled about 10 minutes out (`delay_minutes` tunes it). There is no sending-hour limit unless you pass `window_start_hour` and `window_end_hour` (whole hours, 0 to 24, start before end, org-local); anything else is `400 invalid_send_window`. Drip sends are spaced, capacity-aware and warm-up aware. Use it for outreach.
* **`instant`**: dispatches now through the live pipeline. Built for conversational replies. Velocity cap: 10/min per key. There is no per-key daily cap; each line's daily cap on new cold contacts applies, and replied contacts never count. Requires an online line, otherwise `409 no_online_device`. Group sends and `message_effect` require instant.
## Send target [#send-target]
Pass exactly one of:
| Field | Meaning |
| --------------------- | -------------------------------------------------------------------------------------------------------- |
| `to` | E.164 recipient for a 1:1 send, for example `+13035550101` |
| `group_chat_id` | An existing group thread, from [`GET /v1/groups`](/docs/api-reference/send-group-message) (instant only) |
| `create_group_phones` | 2 to 25 E.164 numbers; creates a new group iMessage, then sends (instant only) |
## Request fields [#request-fields]
| Field | Type | Required | Description |
| ---------------------------------------------- | ------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `to` / `group_chat_id` / `create_group_phones` | | exactly one | See above |
| `message` | string | unless `media_urls` set | Message text |
| `media_urls` | array | no | 1 to 10 `https://` URLs to attach, max 2048 chars each |
| `send_mode` | string | no | `drip` (default) or `instant` |
| `message_effect` | string | no | iMessage effect, instant only: `confetti`, `balloons`, `love`, `lasers`, `fireworks`, `celebration`, `echo`, `spotlight` (full screen); `slam`, `loud`, `gentle`, `invisibleink` (bubble). A drip send with an effect returns `400 effect_requires_instant` |
| `device_id` | string | no | Explicit line UUID, or `auto_load_balanced` (default) / `auto_round_robin`. Not valid with `group_chat_id` |
| `delay_minutes` | number | no | Drip only. Minutes from now, default 10 |
| `window_start_hour` | integer | no | Drip only. Earliest org-local delivery hour, 0 to 23. No window unless both are passed |
| `window_end_hour` | integer | no | Drip only. Latest org-local delivery hour, 1 to 24, after the start |
| `lock` | boolean | no | Lock the contact to the sending line for future sends, default `true` |
| `metadata` | object | no | Free-form, stored with the send |
### Idempotency [#idempotency]
Send an `Idempotency-Key` header (max 255 chars). A duplicate request within 24 hours replays the original response with `Idempotent-Replay: true` instead of double-sending. A concurrent duplicate gets `409 idempotency_conflict`; the same key with a different body gets `409 idempotency_payload_mismatch`.
## Example [#example]
```bash
curl -X POST https://api.bluereacher.com/v1/messages \
-H "Authorization: Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: seq-4471-step-1" \
-d '{
"to": "+16465550119",
"message": "Hey Dana, saw you took over ops at Northline. Worth a quick chat?"
}'
```
```js
const res = await fetch("https://api.bluereacher.com/v1/messages", {
method: "POST",
headers: {
Authorization: "Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2",
"Content-Type": "application/json",
"Idempotency-Key": "seq-4471-step-1",
},
body: JSON.stringify({ to: "+16465550119", message: "Hey Dana, saw you took over ops at Northline. Worth a quick chat?" }),
});
console.log(await res.json());
```
```python
import requests
res = requests.post(
"https://api.bluereacher.com/v1/messages",
headers={
"Authorization": "Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2",
"Idempotency-Key": "seq-4471-step-1",
},
json={"to": "+16465550119", "message": "Hey Dana, saw you took over ops at Northline. Worth a quick chat?"},
)
print(res.json())
```
## Responses [#responses]
Discriminate on `mode`.
```json title="Drip queued"
{
"success": true,
"message_id": "d_1788116691413_milvzs",
"mode": "drip",
"queued": true,
"scheduled_for": "2026-08-31T09:14:51.413Z",
"estimated_send_time": "2026-08-31T09:14:51.413Z",
"queue_position": 0
}
```
```json title="Instant dispatched"
{
"success": true,
"message_id": "i_1788116692980_kulur2",
"mode": "instant",
"status": "dispatched",
"device_id": "8c9a4f2e-1d3b-4a6c-9e8f-2b7c5d1a9e3f"
}
```
Track delivery with [`GET /v1/status/{message_id}`](/docs/api-reference/messages) or, better, the `message.sent` / `message.delivered` / `message.failed` [webhooks](/docs/api-reference/webhooks).
## Errors [#errors]
| Status | Code | Meaning |
| ------ | --------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| 400 | `invalid_json` | Body malformed, empty or not a JSON object |
| 400 | `missing_to` / `invalid_phone` | `to` missing, or not E.164 |
| 400 | `message_required` | Neither `message` nor `media_urls` |
| 400 | `invalid_media_urls` / `invalid_send_mode` | Bad `media_urls`, or an unknown `send_mode` |
| 400 | `effect_requires_instant` | Effect on a drip send |
| 400 | `group_requires_instant` | Group target on a drip send |
| 400 | `invalid_send_window` | `window_start_hour` / `window_end_hour` not whole hours with 0 ≤ start \< end ≤ 24 |
| 401 | `invalid_api_key` | Bad or revoked key |
| 403 | `device_not_owned` | `device_id` outside this workspace |
| 409 | `no_online_device` | Instant with no eligible online line |
| 409 | `idempotency_conflict` | Same `Idempotency-Key` still in flight |
| 409 | `idempotency_payload_mismatch` | Same `Idempotency-Key`, different body |
| 409 | `recipient_excluded` / `message_not_admitted` | Instant only. The send was held back; review the line's sending health in the app before retrying |
| 429 | `device_daily_cap_exceeded` | The line, and every line the send could move to, has reached today's new-contact cap |
| 429 | `rate_limited` | Wait `retryAfter` seconds |
| 502 | `instant_send_failed` | The live send pipeline failed after the request was accepted |
## Test mode [#test-mode]
With a `brk_test_` key the same request validates identically, returns a `sim_` message id, sends nothing, and the response carries `"sandbox": true`.
# Video library (/docs/api-reference/videos)
{/* availability-2026-10-22 */}
The video library is not available on the API yet. These endpoints answer `501 feature_unavailable` for now.
A workspace's video library holds the videos it has shared, each with a reusable watch link and the text of its watch page: a title, a one-line subject, a summary, key points and chapters. Both routes are read-only.
In an MCP client, `list_video_library` returns the list and `get_video_text` returns one video with its transcript.
Both routes need the account owner's own live key. A sandbox key or a teammate's key answers `403 library_permission_required`. An account whose video library is not switched on answers `403 library_not_enabled`.
## List videos [#list-videos]
```
GET https://api.bluereacher.com/v1/videos
```
Returns the videos the workspace holds that are ready, unlisted and not deleted. Up to 100 are listed; `limited` is `true` when the library holds more. The list carries no transcript text.
```bash
curl https://api.bluereacher.com/v1/videos \
-H "Authorization: Bearer brk_your_api_key"
```
```json
{
"success": true,
"videos": [
{
"id": "3f1c9a7e-5b2d-4c8e-9a1f-7d6e2b4c8a90",
"object": "video",
"title": "Onboarding walkthrough",
"subject": "How a new client sets up their first line",
"summary": "A ten minute tour of the setup screens.",
"key_points": ["Pick a line", "Register a webhook"],
"chapters": [{ "start_ms": 0, "title": "Welcome" }],
"revision": "9b4e2d1c-0a7f-4e3b-8c6d-5f2a1b9e7d30",
"url": "https://example.com/watch/3f1c9a7e",
"duration_ms": 612000,
"width": 1920,
"height": 1080,
"created_at": "2026-10-08T15:20:11Z",
"can_edit": true
}
],
"limited": false
}
```
| Field | Meaning |
| -------------------------------- | ------------------------------------------------------------------------ |
| `title` | The watch page title, up to 160 characters |
| `subject` | What the video is about, one line, up to 200 characters |
| `summary` | The watch page summary, up to 4,000 characters |
| `key_points` | Short lines a viewer can skim, up to 300 characters each |
| `chapters` | `start_ms` and `title` per chapter, so a viewer can jump to it |
| `revision` | The current revision of the page text; `null` until the page was written |
| `url` | The reusable watch link |
| `duration_ms`, `width`, `height` | Length in milliseconds and the frame size |
| `can_edit` | `true` for the key owner's own Blue Reacher videos |
## Read one video [#read-one-video]
```
GET https://api.bluereacher.com/v1/videos/{video_id}
```
Returns one video with the same fields as the list, plus `transcript_status` and `transcript`. Use an `id` from the list. A video outside the workspace's library answers `404 video_not_found`.
```bash
curl https://api.bluereacher.com/v1/videos/3f1c9a7e-5b2d-4c8e-9a1f-7d6e2b4c8a90 \
-H "Authorization: Bearer brk_your_api_key"
```
```json
{
"success": true,
"video": {
"id": "3f1c9a7e-5b2d-4c8e-9a1f-7d6e2b4c8a90",
"object": "video",
"title": "Onboarding walkthrough",
"transcript_status": "ready",
"transcript": {
"locale": "en-US",
"cues": [
{ "start_ms": 0, "end_ms": 2400, "text": "Welcome to the setup tour." }
]
}
}
}
```
| Field | Meaning |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `transcript_status` | `ready` when the transcript exists; otherwise `pending`, `unavailable`, `failed` or `none` (no captions were requested) |
| `transcript` | `null` until `transcript_status` is `ready` |
| `transcript.locale` | BCP 47 tag of the captions, or empty |
| `transcript.cues` | Timed lines, each with `start_ms`, `end_ms` and `text` |
## Errors [#errors]
| Status | Code | Meaning |
| ------ | ----------------------------- | ------------------------------------------------- |
| 403 | `library_permission_required` | The key is not the account owner's own live key |
| 403 | `library_not_enabled` | The account's video library is not switched on |
| 404 | `video_not_found` | No video with that id in this workspace's library |
# Webhooks (/docs/api-reference/webhooks)
Webhooks push events to your HTTPS endpoint as they happen. They are the preferred way to track delivery and inbound replies; polling is the fallback.
## Register your endpoint [#register-your-endpoint]
Register self-serve with a live key. One endpoint per workspace; posting again replaces it. The signing secret is returned once, so store it immediately. Test keys work differently, see the sandbox section below.
```bash
curl -X POST https://api.bluereacher.com/v1/webhooks \
-H "Authorization: Bearer brk_live_your_key" \
-H "Content-Type: application/json" \
-d '{ "url": "https://your-app.com/webhooks/bluereacher" }'
```
```json
{
"registered": true,
"url": "https://your-app.com/webhooks/bluereacher",
"events": "all",
"secret": "shown once; store it now"
}
```
`GET /v1/webhooks` returns your current registration (never the secret); `DELETE /v1/webhooks` stops delivery.
## Test the whole loop in the sandbox [#test-the-whole-loop-in-the-sandbox]
A `brk_test_` key can register webhooks too, and you can fire a simulated inbound reply at one, so stop-on-reply and exit conditions are testable before a single real message goes out. The public docs key below works. Every URL gets its own slot for 30 days, so nobody else's registration can replace yours.
Register the endpoint you want to test against:
```bash
curl -X POST https://api.bluereacher.com/v1/webhooks -H "Authorization: Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2" -H "Content-Type: application/json" -d '{ "url": "https://your-app.com/webhooks/bluereacher" }'
```
```json
{
"registered": true,
"url": "https://your-app.com/webhooks/bluereacher",
"events": "all",
"secret": "shown once; store it now",
"webhook_id": "whk_sbx_3f9a0c1d7e24b86a5c10",
"sandbox": true
}
```
Then fire a reply at that slot:
```bash
curl -X POST https://api.bluereacher.com/v1/sandbox/inbound -H "Authorization: Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2" -H "Content-Type: application/json" -d '{ "webhook_id": "whk_sbx_3f9a0c1d7e24b86a5c10", "from": "+15551112222", "content": "Yes, Thursday works" }'
```
Blue Reacher signs a `message.received` event with that slot's secret and delivers it to your URL, exactly as production would. `GET` and `DELETE /v1/webhooks?webhook_id=...` read and remove the slot. Registering the same URL again keeps the `webhook_id` and issues a new secret. In the sandbox, test keys also read `/v1/contacts`, `/v1/capability` and `/v1/usage` with realistic data marked `sandbox: true`, so reads are testable without a live key.
## Envelope [#envelope]
Every delivery is an HTTPS POST with a JSON envelope:
```json
{
"event": "message.received",
"event_id": "9f3c1b7e-2a64-4d8f-b0e5-c7a92d4f8e13",
"timestamp": "2026-08-31T07:41:22.310Z",
"api_version": "2026-08-31",
"data": {
"message_id": "m_313",
"phone_number": "+16465550119",
"contact_id": "c9b41d68-3a2f-4e07-b5d1-6f8e2a9c4d17",
"contact_name": "Dana Reyes",
"content": "Sure, Thursday works",
"direction": "incoming",
"service_type": "iMessage",
"device_id": "8c9a4f2e-1d3b-4a6c-9e8f-2b7c5d1a9e3f",
"device_name": "Line 2",
"media_urls": null
}
}
```
Delivery is **at-least-once**: `event_id` is unique per event occurrence and stable across retries and dashboard replays of that occurrence, so deduplicate on it.
On the message events, `data` carries `message_id`, `phone_number`, `contact_id`, `content`, `direction`, `service_type`, `device_id`, `device_name`, `media_urls` and `organization_id`, plus per-event fields: `group_chat_id` on `message.received`, `sent_at` on `message.sent`, `delivered_at` on `message.delivered`, `read_at` on `message.read`, `error_message` on `message.failed`, and `delivery_channel` (`imessage` or `sms`) on every outbound event. `service_type` is `null` while the channel is not known yet, and `phone_number` can be `null` too.
## Signature verification [#signature-verification]
Two headers ride every delivery:
| Header | Content |
| ------------------------- | ---------------------------------------- |
| `X-BlueReacher-Event` | The event name |
| `X-BlueReacher-Signature` | `sha256=` |
Verify by recomputing the HMAC over the **raw request bytes** with your endpoint's secret, before parsing JSON. Compare in constant time.
```ts
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(sigHeader: string, rawBody: Buffer, secret: string) {
const expected =
"sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
return (
sigHeader.length === expected.length &&
timingSafeEqual(Buffer.from(sigHeader), Buffer.from(expected))
);
}
```
```python
import hashlib, hmac
def verify(sig_header: str, raw_body: bytes, secret: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(sig_header, expected)
```
Return any 2xx quickly and do heavy work asynchronously. Each delivery waits 10 seconds for your answer. A timeout, a network error, a `408`, a `429` or a `5xx` is retried with growing gaps (30 seconds up to an hour) for about a day. A redirect or any other `4xx` is not retried, because it would fail the same way. Events that could not be delivered are kept, not dropped: once your endpoint is fixed, ask support to replay them. Redirects are never followed, so register the final URL. Every delivery carries a stable `X-BlueReacher-Delivery` id; use it to ignore a repeat.
## Event catalogue [#event-catalogue]
### Messages [#messages]
| Event | Fires |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `message.received` | An inbound message hit one of your lines. The event that drives reply handling. Carries `group_chat_id` (`null` for a 1:1 chat); in a group, `phone_number` is the participant who wrote the message |
| `message.sent` | An outbound message left a line, once per message at send confirmation. For API sends this is usually the definitive event: US SMS and many iMessage sends never produce a delivery receipt, so do not wait for `message.delivered` to conclude a send went out. An API or platform send carries `sent_at` and `delivery_channel` but no `contact_name` or `group_chat_id`; a message sent from the line itself, outside the API, carries the `message.received` field set plus `sent_at` and `delivery_channel` |
| `message.delivered` | First delivery receipt only, edge-triggered. `data.delivered_at` carries the receipt timestamp |
| `message.read` | First read receipt, only when the recipient has read receipts enabled. `data.read_at` carries the receipt timestamp |
| `message.failed` | An outbound message failed to deliver. `data.error_message` carries the reason |
### Reactions and contacts [#reactions-and-contacts]
| Event | Fires |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `reaction.received` | A contact added or removed a tapback. `data`: `message_id`, `reaction` (love/like/dislike/laugh/emphasize/question), `emoji`, `action` (added/removed). Contact reactions only; your own tapbacks never fire it |
| `contact.created` | A new contact was created by a message. `data.source` is `inbound_message` or `outbound_message` |
| `contact.opted_out` | STOP reply, `POST /v1/opt-out` or `PATCH /v1/contacts/{contact_id}` with `opted_out: true`. Fires from the single enforcement chokepoint, so every path emits identically |
| `contact.resubscribed` | A genuinely opted-out contact sent a recognized START/UNSTOP phrase, or the API re-subscribed them with `confirm_resubscribe: true`. Use it to deliberately undo suppression |
### Lines [#lines]
| Event | Fires |
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `device.status_changed` | A line went online or offline. `data`: `device_id`, `device_name`, `previous_status`, `new_status` |
| `device.health_changed` | A connected line cannot actually send. `data.state` is one of `send_outage`, `setup_incomplete`, `imessage_collapse` (critical), `sms_not_configured`, `outdated_version` (warning), with a per-state `detail` object. Online/offline never arrives under this event, so a line powered down on purpose is not a health incident |
### Calls [#calls]
Call events carry the same call object as [`GET /v1/calls`](/docs/api-reference/calls), minus `confirmation_url`. No call event carries the contact name, and only `call.transcribed` carries a transcript, a summary or `recording_url`. These fire only for workspaces with Calling switched on.
| Event | Fires |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `call.prepared` | A call was prepared and is waiting for a person to confirm it |
| `call.confirmed` | A signed-in person confirmed the prepared call |
| `call.started` | The platform accepted the call and is placing it |
| `call.ringing` | The FaceTime call is ringing the recipient |
| `call.answered` | The recipient answered |
| `call.completed` | An answered call ended. `duration_seconds` is the talk time. For an outbound call on one of your lines that was not prepared through the API, `data` carries `direction: "outbound"`, `contact_id` and `recording_available` in place of the prepare fields |
| `call.failed` | The call failed before it was answered. `failure_code` says why |
| `call.cancelled` | The call was stopped before it was answered |
| `call.expired` | Nobody confirmed the prepared call within five minutes |
| `call.incoming` | A contact is ringing one of your calling lines |
| `call.missed` | An incoming call ended with nobody taking it. `missed_reason` says why, for example `declined` |
| `call.transferred` | A teammate took over a live call. Fires once per call, on the first hand-off. `data` adds `transfer_count` and `transferred_to_user_id` |
| `call.transcribed` | A recorded call's transcript, summary and recording link are ready, usually about a minute after `call.completed`. Needs call recording switched on. `data` carries `transcript` (up to 20,000 characters; `transcript_truncated` says when it was cut), `summary`, `recording_url`, `workspace_id`, `phone_number`, `direction` and the same `id` the call's other events carried. It can also arrive for a call the line observed outside Blue Reacher; those carry `recording_url: null` |
## Reliability checklist [#reliability-checklist]
1. Verify the signature over raw bytes before parsing.
2. Deduplicate on `event_id`.
3. Return 2xx in under 5 seconds; queue the real work.
4. Treat `message.sent` as the definitive send confirmation.
5. On `contact.opted_out`, stop everything for that contact in your own system too.
# Authentication (/docs/guides/authentication)
## Keys [#keys]
Every request authenticates with a bearer token:
```bash
Authorization: Bearer brk_your_api_key
```
Two kinds:
| Prefix | Behavior |
| ----------- | --------------------------------------------------------------------------------------------------------- |
| `brk_live_` | Real sends, real data |
| `brk_test_` | Simulator: identical validation and response shapes, zero real sends, `"sandbox": true` on every response |
A shared public sandbox key is on the [Sandbox](/docs/guides/sandbox) page so you can evaluate the full surface before you have an account.
## Create and manage keys [#create-and-manage-keys]
Your workspace is ready for keys as soon as your line is live. You create and manage keys yourself, in two places that do the same thing:
* **In the app.** Open **Developer** in the sidebar of your Blue Reacher app, then **API Keys**. Create live and test keys, rename them, change their permission and revoke them there, and see when each key was last used. Outbound webhooks and the MCP setup live in the same section.
* **From code.** The [API keys endpoints](/docs/api-reference/api-keys) take an `admin` key and let a deploy script rotate its own credentials.
A new key's full value is shown once, when it is created. Store it in your secret manager straight away: it cannot be shown again, only replaced.
## Permissions [#permissions]
Each key carries one permission level, chosen when it is created.
| Level | Grants |
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `read` | Contacts, capability checks, lines, message status, usage, opt-out and assistant reads, the key list |
| `write` | Everything read does, plus sends, signals, conversation and group reads, contact, opt-out and assistant writes, calling and webhook registration |
| `admin` | Everything, plus creating, rotating, renaming and revoking keys |
A key that calls an endpoint above its level gets `403 insufficient_permission`, and the request goes no further. Conversation history requires `write` rather than `read` deliberately: message content is the most sensitive thing in the account, and a read-only key that can dump every thread is not read-only in any way that matters.
The key you received at onboarding is an `admin` key. Give each integration the lowest level that works: a reporting job needs `read`, a CRM that sends needs `write`, and only the script that rotates keys needs `admin`.
## Workspace binding [#workspace-binding]
Every key is bound to one workspace, and the boundary is enforced identically across REST, MCP, sends, reads, capability checks and usage reporting. A key cannot see or touch lines, contacts, conversations or other keys outside its workspace, and a key created in a workspace can only ever reach that workspace. Give staging a key bound to a staging workspace and it physically cannot reach the lines your customers know.
## Rotating a key [#rotating-a-key]
Rotation gives you a replacement with the same name and permission, and keeps the old key working for a grace period you choose: none, 1 hour, 24 hours (the default), 3 days or 7 days. Deploy the new key, then let the old one lapse. Once its grace period ends, the old key fails with `401 invalid_api_key` and `"reason": "rotated"`.
## Revoking a key [#revoking-a-key]
Revoking is permanent. Requests with a revoked key fail with `401 invalid_api_key` and `"reason": "revoked"` within a minute, everywhere. A workspace always keeps at least one active key, so you cannot lock yourself out: create or rotate to a replacement before revoking the last one.
## Handling keys [#handling-keys]
* Server-side only. A key in browser or mobile code is public.
* One key per integration, named for it, so a leak has a blast radius of one revoke. **Last used** under Developer → API Keys tells you which keys are still in use.
* Leaked a key? Revoke it under Developer → API Keys, or rotate it with the API keys endpoints with no grace period, then deploy the replacement.
* Never commit keys. Environment variables or a secret manager.
## Test-first setup [#test-first-setup]
Build against a `brk_test_` key: every messaging endpoint, the status timers, and the MCP tools all behave realistically with zero risk. Swap in the live key at go-live and change nothing else. Contacts and capability lookups answer from the simulator as well, and a test key is bound to no workspace, which is the guarantee that a sandbox integration can never touch production data by accident.
# Build with AI (/docs/guides/build-with-ai)
There are two jobs an AI can do with Blue Reacher, and each has its own entry point.
## Connect an agent in one command [#connect-an-agent-in-one-command]
The MCP server is hosted, so there is nothing to install or keep running. Try it first with the public [sandbox](/docs/guides/sandbox) key:
```bash
claude mcp add --transport http bluereacher https://api.bluereacher.com/v1/mcp \
--header "Authorization: Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2"
```
Nothing real is sent on that key. Swap in your live key when you are ready. Cursor, Claude Desktop and generic client configs, the full tool list and the safety rules are on the [MCP server](/docs/guides/mcp-server) page.
## Docs for your assistant [#docs-for-your-assistant]
Point a coding assistant at these and it works from the real API instead of guessing.
| What | URL | Use it for |
| --------------- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------- |
| Docs index | `https://docs.bluereacher.com/llms.txt` | A map of every page, small enough for any context window |
| Full docs | `https://docs.bluereacher.com/llms-full.txt` | Every page in one text file |
| Any single page | Add `.md` to its URL, for example `https://docs.bluereacher.com/docs/guides/quickstart.md` | Just the page you are working on |
| OpenAPI spec | `https://docs.bluereacher.com/openapi.json` | Types, request shapes and client generation |
Requesting any `/docs` page with `Accept: text/markdown` also returns the markdown version.
Every page on this site has a Copy Markdown button under its title, and the menu next to it opens the page directly in ChatGPT, Claude and other assistants.
## A prompt that works [#a-prompt-that-works]
Paste this into your assistant with the files above attached or linked:
```text
Using the Blue Reacher API docs at https://docs.bluereacher.com/llms-full.txt,
write a function that sends an iMessage with an Idempotency-Key, and a webhook
handler that verifies X-BlueReacher-Signature before parsing. Test it against
the public sandbox key brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2.
```
## Keep agents safe [#keep-agents-safe]
* Build on a test key first. The simulator answers every tool with nothing real sent.
* Give the agent its own key, so one revoke stops it without touching your production integration.
* Keep bulk sends human-approved. Drafting and research are safe to automate; approving outbound to real people is a product decision.
* Pacing and opt-outs are enforced underneath, so an over-eager agent queues rather than burns a line, and it cannot message a contact who opted out.
# Calling and AI agents (/docs/guides/calling)
## The short version [#the-short-version]
The [Calls API](/docs/api-reference/calls) places FaceTime audio calls from your iMessage line, and a person always starts them. Your code prepares the call. Someone signed in to Blue Reacher opens the confirmation link and presses **Start call**. Nothing dials before that.
No route, request field or MCP tool dials on its own, and no route streams call audio to your server. That is by design and it is not on the roadmap to change.
| You can | You cannot |
| --------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Prepare a call from code or an AI agent and hand the link to a person | Dial without a person pressing Start call |
| Follow the whole call lifecycle on webhooks | Stream or bridge live call audio to your server (no SIP, WebSocket or WebRTC leg) |
| Get the transcript, a summary and the recording link after the call | Answer an incoming call from the API |
| Hand an answered call to a teammate | Put an AI voice agent on a FaceTime call |
| Cancel a prepared call or end a live one | Queue a second call on a line while one is waiting |
Live keys need Calling switched on for the workspace, which your account manager does, and a key with `calls:write`. Test keys (`brk_test_`) get simulated calls on every calling route.
## How a call runs, end to end [#how-a-call-runs-end-to-end]
1. Your code calls `POST /v1/calls` with the lead's number. The answer carries a `confirmation_url` that is valid for five minutes.
2. You put that link in front of a person: a Slack message, a CRM task, a push notification.
3. The person opens it, signs in to Blue Reacher if they are not already, checks the line and the masked destination, and presses **Start call**.
4. The call runs as FaceTime audio from your line, in the Blue Reacher app in their browser.
5. `call.ringing`, `call.answered`, `call.completed`, `call.missed` and `call.failed` arrive on your [webhook](/docs/api-reference/webhooks#calls), each carrying your `client_reference`.
6. With recording and transcripts switched on, `call.transcribed` delivers the transcript, a summary and the recording link about a minute after the call ends.
If nobody presses Start call within five minutes, the call becomes `expired`, `call.expired` fires, and the line is free again.
## The reply-YES pattern [#the-reply-yes-pattern]
The flow most teams want: a lead replies YES to "want a quick call?", and a rep calls them inside a minute, from the same number the lead has been texting.
1. Your inbound webhook sees the YES reply.
2. Your backend prepares the call, using the inbound message id as the `Idempotency-Key` so a retried webhook never prepares twice.
3. It posts the `confirmation_url` to your team's Slack channel with the lead's name.
4. The first rep to tap it places the call.
5. `call.answered` or `call.missed` updates your CRM. `call.transcribed` adds the notes.
6. On `call.expired`, text the lead a booking link instead, or prepare the call again.
```ts
// Inbound webhook handler: the lead replied YES
const res = await fetch("https://api.bluereacher.com/v1/calls", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BLUEREACHER_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": inbound.message_id,
},
body: JSON.stringify({
to: inbound.from,
contact_name: lead.name,
client_reference: lead.crm_id,
}),
});
const call = await res.json();
if (res.ok) {
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: `${lead.name} said YES. Call now: ${call.confirmation_url}` }),
});
} else if (call.error_code === "line_busy") {
// Another call is waiting on this line. Retry after it starts or expires,
// or prepare on a second calling line.
}
```
One prepared call holds its line until it is started, cancelled or expires, so a team taking many YES replies at once should run more than one calling line, or cancel calls nobody picked up.
## AI voice agents [#ai-voice-agents]
A FaceTime call has no audio interface for software, so an AI voice agent cannot place or speak on one through Blue Reacher.
When you want an AI agent to make the call, run it on your own telephony provider, any SIP or programmable voice platform your agent already supports, and show your Blue Reacher number as the caller ID. The lead sees the same number they have been texting with. Ask your account manager to verify the number as caller ID with your provider. That call is a regular phone call, not FaceTime.
Two rules for AI calls:
* Call only people who asked for the call. In the US, the FCC treats an AI-generated voice as an artificial voice under the TCPA, so the call needs the person's prior express consent, and prior express written consent when it is telemarketing. A YES reply to a message that says an AI assistant will call is a clean consent record.
* Keep the calling pattern conversational: one call to each person who asked, not a list dialed top to bottom.
None of this is legal advice; run your program past counsel before scaling it.
## Behaviour you can rely on [#behaviour-you-can-rely-on]
Checked against the live API on 5 October 2026.
| Behaviour | Detail |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Confirmation window | Five minutes from `created_at`, then `expired`. An unconfirmed call never rings |
| One waiting call per line | A second prepare on the same line answers `409 line_busy` until the first is started, cancelled or expires. Cancelling frees the line at once |
| Strict request body | Any field outside `to`, `line_id`, `contact_name` and `client_reference` is refused with `400 invalid_request`, naming the field |
| Idempotency | Same `Idempotency-Key` and body returns the original call. Same key, different body answers `409 idempotency_conflict` |
| Default line | `line_id` can be left out when the key has a default line |
| Cancel and end | `DELETE /v1/calls` is safe to repeat and returns the call as it stands |
| Transfer | Only on an answered, live call; otherwise `409 call_not_answered` |
| Call length | A call on hosted audio ends after 30 minutes. Prepare a new call to keep talking |
| Rate limit | `POST /v1/calls` and `POST /v1/calls/transfer` share 10 a minute per key, and refused requests count toward it |
| MCP | `list_calling_lines`, `prepare_call`, `get_call_status` and `end_call` behave exactly like the REST routes; `list_call_logs` and `get_call_transcript` read call history. No MCP tool dials |
Each line also has a daily safety stop of 100 FaceTime calls in any rolling 24 hours. Calls drop off the count 24 hours after they were placed, not at midnight. Only calls that were actually dialed count. A call that was refused because the line was busy, or whose call window never connected so nothing dialed, does not use up the line. Once a line reaches the stop, nothing more is dialed on it until older calls age out.
## Errors [#errors]
| Status | `error_code` | When |
| ------ | ---------------------- | -------------------------------------------------------- |
| 400 | `invalid_destination` | `to` is missing or not E.164 |
| 400 | `invalid_request` | A field the route does not take, such as `auto_start` |
| 400 | `invalid_call_id` | `call_id` is not a UUID |
| 400 | `call_id_required` | `GET /v1/calls` without `call_id` or `direction=inbound` |
| 404 | `line_not_found` | `line_id` is not a calling line in this workspace |
| 404 | `call_not_found` | No call with that id for this key |
| 409 | `line_busy` | A prepared call is already waiting on this line |
| 409 | `idempotency_conflict` | The `Idempotency-Key` was used with a different body |
| 409 | `call_not_answered` | Transfer on a call that is not live |
| 429 | `rate_limited` | Over 10 prepares and transfers a minute |
The full list for every route is on [Errors](/docs/guides/errors).
# CRM integrations (/docs/guides/crm-integrations)
Before building anything: check whether your CRM already has a [native integration](/docs/integrations). GoHighLevel, HubSpot, Salesforce, Close, Pipedrive, Follow Up Boss, and Jobber all connect natively, with conversations synced for you and nothing to host. This page is for everything else, and for the send-triggering direction the native connections leave to you.
Every CRM integration is the same three wires, whatever the CRM.
**Outbound.** Your CRM automation calls [`POST /v1/messages`](/docs/api-reference/send-message) when a record hits a condition. New lead created, stage changed, no reply in three days.
**Inbound.** Blue Reacher [webhooks](/docs/api-reference/webhooks) push `message.received` to your endpoint, and your handler writes the reply onto the right CRM record.
**Identity.** The contact's `custom_fields` tie the two directions together. Stamp your CRM record ID onto the Blue Reacher contact once, and every webhook resolves to the right record without fragile phone-number guessing.
## Outbound: your CRM calls the send API [#outbound-your-crm-calls-the-send-api]
```bash
curl -X POST https://api.bluereacher.com/v1/messages \
-H "Authorization: Bearer brk_your_api_key" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 0035f00000LmQ2xAAF-step1" \
-d '{
"to": "+14155550123",
"message": "Hi Dana, saw you booked a demo. Want me to send the pricing sheet?",
"metadata": { "crm_id": "0035f00000LmQ2xAAF", "sequence": "demo-follow-up" }
}'
```
Three habits that make this production-grade:
1. **`Idempotency-Key` on every send**, derived from the CRM record and step, so a workflow retry can never double-text a lead.
2. **`metadata` for your own context.** Free-form, stored with the send.
3. **Default to the drip lane.** CRM-triggered outreach belongs on drip: it inherits pacing, capacity and quiet hours. Save `send_mode: "instant"` for reply handling.
Call the API from your server or automation platform, never from browser code, since the key would be exposed.
## Identity: stamp the CRM ID on the contact [#identity-stamp-the-crm-id-on-the-contact]
Before (or right after) the first send to a lead, upsert the contact with your record ID:
```bash
curl -X POST https://api.bluereacher.com/v1/contacts \
-H "Authorization: Bearer brk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"phone_number": "+14155550123",
"first_name": "Dana",
"custom_fields": { "crm_id": "0035f00000LmQ2xAAF" }
}'
```
Upserts key on `phone_number`, so this is safe to repeat. From then on, any webhook can be resolved: the event carries `contact_id` and `phone_number`, and one `GET /v1/contacts?phone=...` returns the contact with `custom_fields.crm_id`. Most handlers skip even that lookup by keeping their own phone-to-record map and treating the contacts API as the fallback for unknown numbers.
## Inbound: replies to your endpoint [#inbound-replies-to-your-endpoint]
Register your endpoint, verify the `X-BlueReacher-Signature` HMAC over the raw body, and handle:
```json
{
"event": "message.received",
"event_id": "9f3c1b7e-2a64-4d8f-b0e5-c7a92d4f8e13",
"timestamp": "2026-08-31T07:41:22.310Z",
"data": {
"message_id": "m_313",
"phone_number": "+14155550123",
"contact_id": "c9b41d68-3a2f-4e07-b5d1-6f8e2a9c4d17",
"contact_name": "Dana Reyes",
"content": "Yes please, send it over",
"direction": "incoming",
"service_type": "iMessage",
"device_name": "Line 2"
}
}
```
The handler shape, in any runtime:
```ts
app.post("/webhooks/bluereacher", async (req, res) => {
if (!verifySignature(req)) return res.status(401).end();
res.status(200).end(); // ack fast, work async
const { event, event_id, data } = req.body;
if (await seen(event_id)) return; // at-least-once delivery
if (event === "message.received") {
const recordId = await crmIdForPhone(data.phone_number);
await crm.logReply(recordId, data.content);
}
if (event === "contact.opted_out") {
const recordId = await crmIdForPhone(data.phone_number);
await crm.setDoNotContact(recordId);
}
});
```
`message.sent` and `message.failed` complete the loop for delivery reporting, and `contact.opted_out` is the one you must not skip: mirror it into the CRM's DNC field in the same run.
## HubSpot [#hubspot]
Outbound runs from a workflow. Add a **Send a webhook** action, method POST, URL `https://api.bluereacher.com/v1/messages`, and a custom payload with `to` mapped to the contact's phone and `message` from your template. Custom payloads and headers need Operations Hub Professional; on lower tiers, use a **Custom code** action in Node and call the API with `fetch`, which also lets you branch on the response.
Inbound goes to your own endpoint, not to HubSpot directly. On `message.received`, call HubSpot's Notes API and associate the note to the contact whose phone (or stored `crm_id`) matches. Set a `last_imessage_reply` property in the same call so lists and workflows can act on it.
## Salesforce [#salesforce]
Create an External Credential with a Custom authentication header, name `Authorization`, value `Bearer brk_your_api_key`. Attach it to a Named Credential pointing at `https://api.bluereacher.com`. This keeps the key out of Flow.
Build a record-triggered Flow on Lead or Contact. Add an **HTTP Callout** action against that Named Credential, path `/v1/messages`, method POST. Map `to` from the phone field and `message` from a formula or text template.
Inbound needs an Apex REST endpoint, for example `@RestResource(urlMapping='/bluereacher/*')` exposed through a Site or Connected App. Resolve the record by phone (or your stored mapping), insert a completed Task with the reply text, and update any status field your reps watch.
## Zapier, n8n, Make [#zapier-n8n-make]
**Outbound.** Zapier: **Webhooks by Zapier**, POST action, add the `Authorization` header and a JSON payload. n8n: **HTTP Request** node, POST, Header Auth credential, JSON body. Make: **HTTP, Make a request** module, POST, header plus raw JSON body. Per-tool walkthroughs are in [integrations](/docs/integrations).
**Inbound.** Zapier: **Catch Hook** trigger. n8n: **Webhook** node. Make: **Custom webhook** module. Copy the generated URL into your webhook registration. Then add a filter for `event` equals `message.received` and a CRM update step keyed on the phone number.
## Go-live checklist [#go-live-checklist]
* Send one message with a `brk_test_` key end to end through the CRM automation and confirm the simulated response parses.
* Stamp `custom_fields.crm_id` on contact creation and confirm a reply resolves to the right record.
* Handle `contact.opted_out` into the CRM's DNC field, and test it with a STOP reply.
* Swap to the live key. Change nothing else.
# Errors and status codes (/docs/guides/errors)
## The envelope [#the-envelope]
Every error returns JSON with a human message and a stable machine-readable code, sometimes with a hint:
```json
{
"error": "No contact with this phone in the workspace",
"error_code": "contact_not_found",
"help": "Create the contact first with POST /v1/contacts"
}
```
Rate-limit errors add timing:
```json
{
"error": "Rate limit exceeded",
"error_code": "rate_limited",
"retryAfter": 12,
"resetAt": "2026-08-31T09:20:00Z"
}
```
Branch on `error_code`, never on the message text.
## Retry rules [#retry-rules]
| Status | Retry? |
| --------------------------------- | ----------------------------------------- |
| `400`, `401`, `403`, `404`, `422` | No. The same request fails again |
| `409` | Usually no; the work is done or in flight |
| `429` | Yes, after `retryAfter` seconds |
| `5xx`, `502 upstream_unavailable` | Yes, with exponential backoff |
Always send an `Idempotency-Key` on `POST /v1/messages` before adding retries; then a retry after a timeout can never double-send.
## Code reference [#code-reference]
| Status | Code | Meaning |
| ------ | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400 | `invalid_request` | A required field is missing or malformed |
| 400 | `invalid_json` | The body is malformed, empty or not a JSON object |
| 400 | `effect_requires_instant` | `message_effect` on a drip send |
| 400 | `group_requires_instant` | Group target on a drip send |
| 401 | `invalid_api_key` | Key missing, malformed, revoked, or wrong prefix. A revoked key adds `"reason": "revoked"`; a rotated key past its grace period adds `"reason": "rotated"` |
| 401 | `key_disabled` | Key switched off by Blue Reacher support |
| 403 | `insufficient_permission` | The key's permission level is below what the endpoint needs; see [Authentication](/docs/guides/authentication#permissions) |
| 403 | `key_management_unavailable` | The shared sandbox key cannot manage keys |
| 403 | `line_not_allowed` | The key is limited to one line and the request names another; see [API keys](/docs/api-reference/api-keys#keys-limited-to-one-line) |
| 403 | `device_not_owned` | The `device_id` is not a line in this key's workspace |
| 403 | `sandbox_key_not_supported_here` | Test key on conversation history, the one read the simulator does not cover |
| 404 | `not_found` | Unknown endpoint or resource outside your scope |
| 404 | `message_not_found` | No queue item or message with that id in your scope |
| 404 | `contact_not_found` | No contact with that phone; opt-out writes never auto-create |
| 404 | `group_not_found` | `group_chat_id` not in this key's workspace |
| 400 | `invalid_destination` | Call prepare with `to` missing or not E.164 |
| 400 | `invalid_call_id` / `call_id_required` | Call read or end with a malformed or missing `call_id` |
| 404 | `line_not_found` | Call prepare on a `line_id` that is not a calling line in this workspace |
| 404 | `call_not_found` | No call with that id for this key |
| 409 | `line_busy` | Call prepare while another prepared call is waiting on that line |
| 404 | `teammate_not_found` | Call transfer to someone who is not the workspace owner or an active team member |
| 404 | `call_log_not_found` | Call history read on a call that is not in this key's workspace |
| 403 | `library_permission_required` | Video library read with a key that is not the account owner's own live key |
| 403 | `library_not_enabled` | Video library read on an account whose video library is not switched on |
| 404 | `video_not_found` | No video with that id in this workspace's library |
| 400 | `group_too_small` | Removing members would leave fewer than two others; leave the group instead |
| 409 | `already_member` / `not_a_member` | Every number in the request is already in, or already out of, the group |
| 409 | `bridge_update_required` | The line's sync software is older than this feature needs; contact support to have it updated |
| 409 | `call_not_answered` / `call_ended` / `transfer_in_progress` | Call transfer on a call that is not live, or while another offer is ringing |
| 409 | `transfer_unsupported_transport` | Call transfer on a call whose connection cannot be handed to a teammate |
| 409 | `recording_consent_required` | Recording switched on before the recording notice was accepted in the app |
| 403 | `live_key_required` | Test key on call settings, which belong to a real workspace |
| 409 | `no_online_device` | Instant send with no eligible online line |
| 409 | `idempotency_conflict` | Same `Idempotency-Key` still in flight |
| 409 | `recipient_excluded` / `message_not_admitted` | Instant send held back by sending-health checks; review the line's health in the app before retrying |
| 422 | `not_deliverable` | Understood but cannot be delivered as asked |
| 422 | `audio_url_unreachable` | Voice memo URL did not fetch as audio |
| 422 | `invalid_status` | Bad enum value on a state change |
| 400 | `resubscribe_confirmation_required` | `opted_out: false` without `confirm_resubscribe: true` |
| 400 | `invalid_opted_out` | `opted_out` on a contact update is not a boolean |
| 400 | `always_on_confirmation_required` | `status: active_always` without `confirm_always_on: true` |
| 409 | `key_limit` / `last_key` / `already_rotating` / `key_inactive` | Key management conflicts; see [API keys](/docs/api-reference/api-keys#errors) |
| 400 | `invalid_send_window` | `window_start_hour` / `window_end_hour` not whole hours with 0 ≤ start \< end ≤ 24 |
| 409 | `idempotency_payload_mismatch` | `Idempotency-Key` reused with a different body |
| 429 | `device_daily_cap_exceeded` | Every line the send could use has reached today's new-contact cap; resets with the workspace day, so do not retry-loop |
| 429 | `rate_limited` | Over an API rate limit; wait `retryAfter` |
| 500 | `update_failed` | A contact consent change could not be saved; read the contact before retrying |
| 502 | `upstream_unavailable` | Transient infrastructure failure; retry shortly |
| 502 | `instant_send_failed` | The live send pipeline failed after the request was accepted |
| 503 | `capability_unavailable` | Capability lookup could not run right now; retry shortly |
New codes can appear; treat an unrecognised code on a `4xx` as non-retryable and on a `5xx` as retryable.
## The two confirmations, and why they exist [#the-two-confirmations-and-why-they-exist]
Two writes are deliberately harder than the rest. Re-subscribing an opted-out contact requires `confirm_resubscribe: true`, and switching the AI assistant to 24/7 replies requires `confirm_always_on: true`. Both actions can reach a person who asked not to be reached, or at 3am; the extra flag makes them impossible to trigger by accident from a generic client.
# GoHighLevel (/docs/guides/gohighlevel)
GoHighLevel is the one CRM that does not need the API wiring described in [CRM integrations](/docs/guides/crm-integrations). Blue Reacher connects to GHL natively: messages sync two-way into the GHL Conversations inbox, and workflows drive sends with purpose-built actions. Done-for-you setups get this connected during onboarding.
## What the native integration does [#what-the-native-integration-does]
* **Two-way sync.** Outbound iMessages appear in the contact's GHL conversation; inbound replies land there in real time, blue bubbles and all. Your team works the inbox they already know.
* **Workflow actions.** GHL workflows get iMessage-native actions, including send message, send media, send voice memo, wait-for-reply branching, and sentiment routing, so speed-to-lead and no-show flows run entirely inside GHL.
* **Contact mapping.** Contacts match on phone number, normalized to E.164. Contacts stored without a country code will not match; fix the field, not the integration.
* **Opt-out sync.** A STOP or plain-language opt-out suppresses the contact platform-wide and marks DND in GHL, so no other GHL workflow texts them either.
## Inside the GHL conversation view [#inside-the-ghl-conversation-view]
Beyond the sync, the integration adds tools directly into the GHL interface. Each one can be turned on or off per sub-account, and done-for-you setups get them configured during onboarding:
* **Delivery badges.** Delivered, Read, and Failed badges on every outgoing bubble in the conversation view, so nobody guesses whether a message landed.
* **Voice memos from the composer.** Record and send a native iMessage voice memo from the GHL conversation composer, no phone in hand. It plays back as a true waveform on the contact's iPhone.
* **Line picker.** Choose which line a conversation sends from, per sub-account or locked per contact, right in the composer. Every send goes out from the number the contact expects, even with multiple reps and lines.
* **Contact context panel.** A sidebar panel showing the thread's iMessage-vs-SMS status, its assigned line, and a call button.
* **Tapbacks and effects, readable.** Synced iMessage reactions render as compact tapback chips, and screen effects like Slam or Invisible Ink are labelled, so the CRM thread keeps the meaning of the original instead of showing relay noise.
* **Click-to-call.** One-click call buttons next to phone numbers in the CRM, so a rep calls from the same screen they message on.
* **Workflow template library.** A library of proven iMessage workflow templates across real industries, installable into a sub-account, so a niche program starts from a working flow instead of a blank canvas.
## Connecting [#connecting]
One sub-account connects to one workspace. During onboarding we authorize the connection with you (an OAuth grant from the GHL sub-account), assign lines, and wire the workflow actions into your existing automations. There is nothing to host and no webhook URL to manage.
## Send behavior [#send-behavior]
Workflow-triggered sends run through the same pipeline as everything else: paced against line capacity, quiet-hours aware, warm-up aware. A workflow that fires 300 sends at once queues them safely rather than blasting. Replies from your team in the GHL inbox go out conversationally, like an instant send.
The capacity model applies unchanged: new cold contacts count against each line's daily cap, while contacts who have replied or been messaged before are unlimited. The numbers are in [rate limits](/docs/guides/rate-limits).
### One line per rep [#one-line-per-rep]
Two people can share one sub-account and still each send from their own line, for example an agent and their assistant. Put both lines in the same workspace, since one workspace connects to one sub-account. Set the workspace's device rotation to Route by salesperson, sync the CRM users, and give each line a CRM owner. The line is then chosen by the CRM user who presses Send, and the line picker is skipped. A contact locked to a line keeps that lock: the lock wins over the sender's line.
In this mode a message from a CRM user who is not synced, or who has no line, does not go out and is not queued, so it never leaves on another rep's number. If a rep's line is offline, their message waits for that line. Sync every user who sends and give each one a line before you turn the mode on. Syncing CRM users does not create logins: team logins and which lines each person may use are set on the People tab.
## Where the API fits alongside GHL [#where-the-api-fits-alongside-ghl]
Use the [REST API](/docs/api-reference/overview) or [MCP server](/docs/guides/mcp-server) next to the native integration when you want things GHL workflows cannot express:
* Capability checks over a list before a campaign ([`POST /v1/capability`](/docs/api-reference/check-imessage))
* Native signals: tapbacks, the typing bubble, read state ([rich messaging](/docs/api-reference/rich-messaging))
* An AI agent operating the account via MCP
* Your own webhook consumers for analytics or a data warehouse
Both surfaces see the same conversations, so nothing desyncs.
## Troubleshooting [#troubleshooting]
**Replies not appearing in GHL.** Confirm the sub-account connection is still authorized and the contact's phone is E.164. A re-authorization takes a minute and fixes most cases.
**A workflow send did not go out.** Check the contact was not opted out (DND), and check line capacity on [`GET /v1/devices`](/docs/api-reference/lines) or the dashboard; a new cold contact past the daily cap waits for the next window by design.
**A message from the GHL inbox did not go out.** If the workspace routes by salesperson and the CRM user who sent it is not synced or has no line, the message does not go out and is not queued. Sync that user and give them a line.
**Messages sending from the wrong number.** Each sub-account maps to specific lines. Check the line assignment in the dashboard rather than the workflow.
# Key concepts (/docs/guides/key-concepts)
Five objects and three ideas explain the whole API.
## The objects [#the-objects]
| Object | What it is | Where it lives |
| ---------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| **Line** | One managed iMessage sending number. Called `device` in API field names | [`GET /v1/devices`](/docs/api-reference/lines) |
| **Contact** | A person, keyed by E.164 phone number, carrying opt-out state, tags and custom fields | [`/v1/contacts`](/docs/api-reference/contacts) |
| **Message** | One send or receive. Statuses commonly `pending`, `sent`, `failed`; delivery channel `imessage` or `sms` | [`POST /v1/messages`](/docs/api-reference/send-message), [`GET /v1/status/{id}`](/docs/api-reference/messages) |
| **Conversation** | The thread between a line and a contact (or a group), with reactions and channel per message | [`GET /v1/conversations`](/docs/api-reference/messages) |
| **Group** | A group iMessage thread owned by one line | [`GET /v1/groups`](/docs/api-reference/send-group-message) |
Ids are opaque strings; do not parse them for meaning. Sandbox ids are prefixed `sim_` so simulated traffic can never be mistaken for real.
## Idea one: two sending lanes [#idea-one-two-sending-lanes]
Every send picks a lane, and the default is the protected one.
**Drip** is for outreach. The platform schedules the message about 10 minutes out, holds it inside a send window when you pass one (`window_start_hour` / `window_end_hour`), spaces it against everything else the line is doing, and counts it against capacity. You hand over *what*; the platform owns *exactly when*.
**Instant** is for conversation. It dispatches now, velocity-capped at 10/min per key and counted against each line's daily new-contact cap, and it is the only lane that carries group sends and message effects. Use it when a human (or your agent) is mid-thread and a 10-minute delay would be absurd.
The lane split is why an integration built on this API is hard to burn: bulk pressure physically cannot bypass the pacing machinery.
## Idea two: engaged is unlimited, cold is capped [#idea-two-engaged-is-unlimited-cold-is-capped]
The capacity model counts one thing: brand-new cold contacts per line per day. A contact who has ever replied is engaged and unlimited. A contact you have messaged before is returning and unlimited. Inbound never counts. So the economics of the channel reward exactly what you want anyway: winning replies. The full model, the 50 opted-in / 30 cold policy caps, warm-up and quiet hours are in [Rate limits and sending capacity](/docs/guides/rate-limits).
## Idea three: events over polling [#idea-three-events-over-polling]
Delivery is asynchronous by nature. A successful send means accepted, and what happened next arrives as signed webhooks: `message.sent` (the definitive "it left the line"), `message.delivered` / `message.read` (when receipts exist), `message.failed`, and `message.received` for replies. Poll `GET /v1/status/{id}` when you must; subscribe when you can. Deduplicate on `event_id`, verify signatures over raw bytes. Details in [Webhooks](/docs/api-reference/webhooks).
## Test mode [#test-mode]
`brk_test_` keys run the whole messaging surface against a simulator: same validation, same shapes, zero real sends, `"sandbox": true` on every response plus an `X-BlueReacher-Sandbox: true` header. `/v1/contacts` and `/v1/capability` answer from the simulator too, and `/v1/usage` returns a fixed sample, so nothing a test key does can reach a real workspace. Build the integration on a test key, swap the key at go-live, change nothing else.
## Opt-out is enforced platform-side [#opt-out-is-enforced-platform-side]
STOP suppresses the contact everywhere, fires `contact.opted_out`, and no send path (API, campaign, workflow) can reach them afterwards. You never parse STOP yourself, and re-subscribing deliberately requires an explicit confirmation flag. Your side of the contract: mirror the suppression in your own system when the webhook arrives.
# Live AI reply agent (/docs/guides/live-reply-agent)
This is the setup for a funnel where leads text your line first, for example a Meta ad that opens iMessage with a prefilled message, and an AI agent answers each one live.
## The loop [#the-loop]
1. A lead texts your line. Blue Reacher posts `message.received` to your webhook, signed with your secret.
2. Your endpoint verifies the signature, answers `200` straight away, and hands the message to your model.
3. While the model works, call `POST /v1/typing` so the lead sees the "…" bubble.
4. Send the answer with `POST /v1/messages` and `send_mode: "instant"`.
5. Follow `message.sent`, `message.delivered` and `message.read` on the same webhook, keyed by `message_id`.
```bash
curl -X POST https://api.bluereacher.com/v1/messages \
-H "Authorization: Bearer brk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: reply-17c3c7ae" \
-d '{ "to": "+16465550119", "message": "Hey, thanks for reaching out. What are you looking for?", "send_mode": "instant" }'
```
Use the inbound `message_id` in the `Idempotency-Key`. A retry after a timeout then replays the first answer instead of texting the lead twice.
## How long a reply takes [#how-long-a-reply-takes]
Measured on live lines, from the lead's text landing to your reply leaving the line:
| Step | Typical time |
| --------------------------------------- | ----------------------------- |
| Lead's text to your webhook | under 1 second |
| Your model | yours to control |
| `POST /v1/messages` (instant) answering | 2 to 3 seconds |
| The line sending it in Messages | a few seconds, up to about 12 |
Plan for 10 to 20 seconds end to end. The last step is the line typing and sending the way a person's phone does, one message at a time, which is part of what keeps a line healthy. It is not a queue you can skip.
Three things keep your share of it small:
* Answer the webhook with `200` before you call your model. Each delivery waits 10 seconds for your answer, and a slower endpoint is treated as failed and gets the event again.
* Do not wait on `POST /v1/read` or a reaction before you send the reply. Fire them after, or in parallel.
* Turn the typing bubble on the moment the webhook arrives. A lead who sees "…" waits; a lead who sees nothing for 15 seconds reads it as a bot that broke.
```bash
curl -X POST https://api.bluereacher.com/v1/typing \
-H "Authorization: Bearer brk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "phone": "+16465550119", "device_id": "8c9a4f2e-1d3b-4a6c-9e8f-2b7c5d1a9e3f", "is_typing": true, "duration_ms": 20000 }'
```
Use the line's `device_id` from `GET /v1/devices`; a line-limited key fills it in for you. The bubble clears by itself when your message lands. Details are on [rich messaging](/docs/api-reference/rich-messaging).
Every send also gets a short typing burst of its own, from a fraction of a second up to about three seconds scaled to the message length, so the lead always sees "…" just before your reply lands. The line passes your typing request on every few seconds, so hold it for at least a few seconds; a request cleared within a second or two may never show.
## How a line works [#how-a-line-works]
Each line is a real Apple ID signed in on Apple hardware, and every message goes out through Apple's own Messages app on that device. Apple has no server API for iMessage, so a send always passes through the device. That is why a reply takes a few seconds rather than milliseconds, and it is also why the lead sees a genuine blue bubble from a number that behaves like a person's phone, which keeps the line clear of spam filters.
## Capacity for ad traffic [#capacity-for-ad-traffic]
The daily cap on a line counts only brand-new cold contacts that you message first. A lead who texts you first never counts against it, and neither does anyone who has replied to you. That means an inbound-only funnel is limited by the request rate limits below, not by the daily cap.
| Limit | Value |
| ----------------------- | --------------------------- |
| Instant sends | 10 per minute per key |
| `POST /v1/messages` | 30 per minute per workspace |
| Reactions, typing, read | 30 per minute each |
| All `/v1` routes | 60 per minute per workspace |
A new line still ramps its cold-contact cap over its first weeks; `GET /v1/devices` shows today's `daily_cap`. If you plan to message cold contacts from the same line as well, read [rate limits](/docs/guides/rate-limits) first.
Attachments: one file per message. Images (JPEG, PNG, GIF, HEIC, WebP) and video (MP4, MOV) go up to 100 MB, audio and PDF up to 25 MB, all delivered at original quality over iMessage. For files that should look the same everywhere, keep images under 500 KB and videos under 10 MB. The full table is on [message formatting](/docs/platform/message-formatting).
## Reactions, read state and reply quotes [#reactions-read-state-and-reply-quotes]
Tapbacks: `POST /v1/reactions` with an `emoji` and the `message_id` you are reacting to. The answer is `queued`; the line applies it and the reaction's `delivered` field turns true in `GET /v1/conversations`. Reactions from the lead arrive as `reaction.received`.
Read state means two different things, depending on which side of the conversation you look at:
* A message the lead sent you shows `status: "read"` once it has been read in your inbox: opened in the app, or cleared with `POST /v1/read`. It says nothing about the lead's phone.
* `POST /v1/read` with `native: true` and the line's `device_id` also asks the line to mark the thread read in Messages, so the lead's phone shows "Read" under your last reply. The answer is `native_read_queued: true`. Apple sends no confirmation back, so there is no event for it; check it on a phone you own.
* A message you sent shows `read`, and fires `message.read`, when the lead's phone reports it read. That needs the lead to have Send Read Receipts on; with it off you will only ever see `delivered`.
Reply quotes: add `reply_to_message_id` to `POST /v1/messages` and the text goes out with a quote of the message you are answering on its first line, the same as replying from the app. Pass `reply_to_preview` instead when you already have the text. This is a quoted reply in an ordinary bubble, not Apple's inline "Replies" thread.
## When a tapback, typing bubble or read receipt does not show [#when-a-tapback-typing-bubble-or-read-receipt-does-not-show]
Texts and these three signals take different paths on the line. A text can deliver while a tapback stays `delivered: false`, or a typing request answers `success` with no bubble on the lead's phone. If that happens, send us the `message_id` or the time of the request in your Slack channel. We check the line's device and fix it on our side; there is nothing to change in your code.
## Before you turn the ads on [#before-you-turn-the-ads-on]
* Send yourself a text from a phone you own and time the full loop against the table above.
* Check that a webhook with a wrong signature is rejected by your endpoint.
* Confirm your retry path reuses the `Idempotency-Key`.
* Keep the typing bubble on, and keep your first reply short and human.
# MCP Server (/docs/guides/mcp-server)
## What it is [#what-it-is]
The Model Context Protocol is an open standard for connecting AI assistants to outside systems. Blue Reacher ships a **hosted** MCP server: stateless JSON-RPC 2.0 over Streamable HTTP (MCP protocol 2026-07-28, with the initialize handshake supported for 2025-06-18 and older clients), at
```
https://api.bluereacher.com/v1/mcp
```
There is no npm package, no local process, and nothing to keep running. Any MCP-capable client with the URL and your API key gets the full tool set. `brk_test_` sandbox keys work on every tool (simulator only, zero real sends), which makes this the fastest way to evaluate the platform: connect Claude with a test key and try things.
## Connect [#connect]
### Claude Code [#claude-code]
```bash
claude mcp add --transport http bluereacher https://api.bluereacher.com/v1/mcp \
--header "Authorization: Bearer brk_your_api_key"
```
Confirm with `/mcp` or `claude mcp list`.
No key yet? Use the free public sandbox key `brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2` in place of `brk_your_api_key`. Every tool answers from the simulator and nothing real is sent. See [Sandbox](/docs/guides/sandbox).
### Cursor [#cursor]
Add this to `~/.cursor/mcp.json` (all projects) or `.cursor/mcp.json` (one project):
```json
{
"mcpServers": {
"bluereacher": {
"url": "https://api.bluereacher.com/v1/mcp",
"headers": {
"Authorization": "Bearer brk_your_api_key"
}
}
}
}
```
### VS Code [#vs-code]
Add this to `.vscode/mcp.json`:
```json
{
"servers": {
"bluereacher": {
"type": "http",
"url": "https://api.bluereacher.com/v1/mcp",
"headers": {
"Authorization": "Bearer brk_your_api_key"
}
}
}
}
```
### Claude Desktop [#claude-desktop]
Claude Desktop's config file starts local servers, so it reaches a hosted server through the `mcp-remote` bridge (needs Node.js). The header value sits in `env` because Windows splits arguments on the space after `Bearer`. Add this to `claude_desktop_config.json` and restart the app:
```json
{
"mcpServers": {
"bluereacher": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://api.bluereacher.com/v1/mcp",
"--header",
"Authorization:${AUTH_HEADER}"
],
"env": {
"AUTH_HEADER": "Bearer brk_your_api_key"
}
}
}
}
```
### Any MCP client (generic JSON) [#any-mcp-client-generic-json]
```json
{
"mcpServers": {
"bluereacher": {
"type": "http",
"url": "https://api.bluereacher.com/v1/mcp",
"headers": {
"Authorization": "Bearer brk_your_api_key"
}
}
}
}
```
Use your client's secret facility for the key where available, and never commit a live key. For a zero-risk setup check, substitute a `brk_test_` sandbox key: tool discovery and calls return realistic simulated results and nothing real is written.
## Tools [#tools]
The server exposes the API surface as tools; discovery (`tools/list`) is always the authoritative catalogue. The set includes:
| Area | Tools |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| Messaging | `send_message` (1:1, existing group, or new group, with drip/instant and effects), `send_voice_memo`, `check_message_status` |
| Reading | `get_conversation` (1:1 or group), `list_groups`, `list_devices` |
| Signals | `send_reaction`, `remove_reaction`, `send_typing_indicator`, `mark_read` |
| Contacts | `list_contacts`, `create_contact`, `update_contact`, `lookup_imessage`, `get_opt_out_status`, `opt_out` |
| AI assistant | `get_bot_status`, `set_bot_status` |
| Calling | `list_calling_lines`, `prepare_call`, `get_call_status`, `end_call` |
| Call history | `list_call_logs`, `get_call_transcript` (read-only, work whether or not Calling is switched on) |
Some keys see more tools than this core set, and sandbox keys see the core set only. The account owner's own live key adds the video library: `list_video_library` lists its videos and `get_video_text` reads one video's page text plus its transcript lines once captions are ready (the same as the [Video library](/docs/api-reference/videos) routes), `update_video_details` saves a video's page text (pass back the `revision` from the list) and `draft_video_details` writes it for you, using an included allowance or the workspace's own AI key. `tools/list` shows exactly what your key can call.
Tool inputs and outputs for the messaging surface match the REST endpoints one-to-one, so anything in the [API reference](/docs/api-reference/overview) applies. The calling tools mirror the [Calls API](/docs/api-reference/calls) and are deliberately human-gated: `prepare_call` never dials on its own: it returns a short-lived confirmation URL that a signed-in person must open and press to start the call. Rate limits: the MCP endpoint itself is 60/min per key, and each `tools/call` also pays the underlying route's limit.
## Example workflows [#example-workflows]
**Qualify a list before spending sends**
> Check which of these 40 numbers can receive iMessage, then tell me the percentage and list the ones that can't.
`lookup_imessage`, grouped and summarized. Nothing is sent.
**Hold a natural conversation**
> When Dana replies, wait a beat, show typing, then answer from the same line.
`get_conversation`, `send_typing_indicator`, `send_message` with `send_mode: "instant"`.
**Check line health before a push**
> How much capacity is left on my lines today?
One `list_devices` call: `daily_cap`, `cold_outreach_today`, `available_today` per line.
**Hand a thread to a human**
> Pause the assistant for this contact while I take over.
`set_bot_status` with `snooze`.
## Safety and scoping [#safety-and-scoping]
* **Keep bulk sending human-approved.** Research, segmentation and drafting are safe to automate; approving outbound to real people is a product decision. Build on a test key first.
* **Use a scoped key.** A separate key for assistant use means one revoke kills it without touching your production integration.
* **Consent rules do not change.** Opt-outs are enforced platform-wide, so an assistant cannot message an opted-out contact any more than your code can.
* **Pacing is enforced underneath.** Drip-lane sends from an assistant inherit the same caps and windows as everything else, so an over-eager agent queues rather than burns.
## Troubleshooting [#troubleshooting]
**Tools do not appear.** Confirm the URL is exactly `https://api.bluereacher.com/v1/mcp` and the Authorization header carries a `brk_` key. The transport is Streamable HTTP (stateless POST); SSE-only clients need a bridge.
**401 on every call.** Key missing, malformed, or revoked; Blue Reacher keys start with `brk_`.
**Sends queue instead of dispatching.** That is the drip default working as intended; pass `send_mode: "instant"` for conversational replies.
# Migrating from another platform (/docs/guides/migrating)
Migrations fail in one of two ways: the suppression list does not come across, or the new lines get treated like the old mature ones and burn out in week one. Both are avoidable.
## Step 1: export before you cancel [#step-1-export-before-you-cancel]
Get these out of the old platform while you still have access, because most providers cut API access at cancellation and support-ticket exports take days:
1. Contacts, with phone numbers in E.164 and whatever record IDs you used.
2. The complete suppression and opt-out list, with timestamps. This is the one that matters most.
3. Message history, at minimum for contacts with open conversations.
4. Consent records, if the old platform held them rather than your CRM.
## Step 2: import suppression first, before anything else [#step-2-import-suppression-first-before-anything-else]
Load every opt-out into Blue Reacher before you import a single contact and before you send anything. Messaging someone who opted out on your old platform is a compliance violation that carries over with you; the obligation attached to your company, not to the vendor.
```javascript
for (const record of oldOptOuts) {
// Contact must exist first; opt-out writes never auto-create.
await fetch("https://api.bluereacher.com/v1/contacts", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BLUEREACHER_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({ phone_number: record.phone })
});
await fetch("https://api.bluereacher.com/v1/opt-out", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BLUEREACHER_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({ phone: record.phone, opted_out: true })
});
}
```
Opt-out writes are idempotent: re-running the import returns `already_opted_out: true` rather than creating duplicates. The suppression model is documented in [contacts and opt-out](/docs/api-reference/contacts).
## Step 3: import contacts with your CRM IDs [#step-3-import-contacts-with-your-crm-ids]
Contact upserts key on `phone_number`, so re-imports update rather than duplicate. Carry your CRM record ID in `custom_fields` (for example `{ "crm_id": "..." }`); the old platform's contact IDs stop being useful the day you finish the migration. Bulk import is in [contacts](/docs/api-reference/contacts).
## Step 4: treat new lines as new lines [#step-4-treat-new-lines-as-new-lines]
This is where migrations go wrong. Your old lines were mature and sending at full volume. Your Blue Reacher lines are new, and they ramp from a handful of new contacts a day up to the full policy caps (50 opted-in / 30 cold) over 16 to 20 active days, per [rate limits](/docs/guides/rate-limits). Remember what is not capped: contacts who ever replied, and anyone you have messaged before, are unlimited from day one, so an engaged book migrates at full speed even while cold outreach ramps.
Two ways to handle the gap:
1. Run both platforms in parallel for the ramp period, shifting volume across as capacity comes online. More expensive for a month, no drop in output.
2. Provision enough lines to cover your target volume at week-one capacity, then keep them as headroom afterwards. More lines, no parallel running.
What does not work is pushing new lines at your old volume. The pacing is enforced, so excess cold sends queue for later days rather than burning the line, but a program built on the assumption of day-one full cold volume will still miss its numbers.
## Step 5: rewire the integration [#step-5-rewire-the-integration]
Outbound: point your automation at `POST /v1/messages`, per the [send reference](/docs/api-reference/send-message). Inbound: register your webhook endpoint and verify the `X-BlueReacher-Signature` HMAC, per [webhooks](/docs/api-reference/webhooks).
Differences worth checking against your old provider's shape:
* Auth is a bearer token in the `Authorization` header, prefixed `brk_`. Some platforms use a raw key with no prefix, so a copied header pattern will fail with `401`.
* Numbers must be E.164. Platforms that accepted loose formatting will have left non-normalized numbers in your export.
* Webhook events are `message.received`, `message.sent`, `message.delivered`, `message.failed`, `contact.opted_out`, and `device.status_changed`. Map these to your old event names explicitly rather than assuming a match.
* Errors branch on a stable `error_code`, not on the message text. The full list is in [errors and status codes](/docs/guides/errors).
## Step 6: cut over on a slice [#step-6-cut-over-on-a-slice]
Move one segment, one campaign, or one rep first. Run it for a week. Confirm sends land, replies arrive on the right records, opt-outs propagate, and the reply queue works with the new notification path. Then move the rest.
## Migration checklist [#migration-checklist]
* [ ] Everything exported before cancelling the old account
* [ ] Suppression list imported first and verified with a spot check
* [ ] Contacts imported with CRM IDs in `custom_fields`
* [ ] Line capacity planned for the ramp, with parallel running or extra lines
* [ ] Outbound automation repointed and tested end to end
* [ ] Webhook endpoint registered, signature verification confirmed
* [ ] One slice cut over and observed for a week
* [ ] Old platform cancelled only after the slice ran clean
# Quickstart (/docs/guides/quickstart)
Everything below runs on a `brk_test_` key against the simulator: identical requests and responses to production, zero real messages. Swap the key at the end.
No account yet? Every example on this page uses the shared public sandbox key `brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2` and runs as-is. It is simulator-only and can never send a real message; it is shared, so its rate limits are shared too. What the simulator covers and how to tell you are in it: [Sandbox](/docs/guides/sandbox).
## 1. Send a message [#1-send-a-message]
```bash
curl -X POST https://api.bluereacher.com/v1/messages \
-H "Authorization: Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2" \
-H "Content-Type: application/json" \
-d '{ "to": "+13035550101", "message": "First send from the API" }'
```
```js
const res = await fetch("https://api.bluereacher.com/v1/messages", {
method: "POST",
headers: {
Authorization: "Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2",
"Content-Type": "application/json",
},
body: JSON.stringify({ to: "+13035550101", message: "First send from the API" }),
});
console.log(await res.json());
```
```python
import requests
res = requests.post(
"https://api.bluereacher.com/v1/messages",
headers={
"Authorization": "Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2",
},
json={"to": "+13035550101", "message": "First send from the API"},
)
print(res.json())
```
```json
{
"success": true,
"message_id": "sim_d_1788116691413_milvzs",
"mode": "drip",
"queued": true,
"scheduled_for": "2026-08-31T09:14:51.413Z",
"sandbox": true
}
```
The default lane is drip: paced, capacity-aware, scheduled about 10 minutes out in production. `"sandbox": true` and the `X-BlueReacher-Sandbox: true` header confirm nothing real happened.
## 2. Track it [#2-track-it]
```bash
curl https://api.bluereacher.com/v1/status/sim_d_1788116691413_milvzs \
-H "Authorization: Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2"
```
Sandbox ids progress on a timer: `pending`, then `sent` after about 5 seconds. Production behaves the same way, on real time scales, and the `message.sent` webhook beats polling.
## 3. Read the conversation [#3-read-the-conversation]
```bash
curl "https://api.bluereacher.com/v1/conversations?phone=%2B13035550101" \
-H "Authorization: Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2"
```
The simulator returns a realistic thread with delivery channels and a reaction, so your rendering code has something true-to-shape to chew on.
## 4. Check your lines [#4-check-your-lines]
```bash
curl https://api.bluereacher.com/v1/devices \
-H "Authorization: Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2"
```
`daily_cap`, `cold_outreach_today` and `available_today` are the capacity model in numbers: [what counts and what is unlimited](/docs/guides/rate-limits).
## 5. Reply instantly, like a conversation [#5-reply-instantly-like-a-conversation]
```bash
curl -X POST https://api.bluereacher.com/v1/messages \
-H "Authorization: Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2" \
-H "Content-Type: application/json" \
-d '{ "to": "+13035550101", "message": "Got it, sending the doc now", "send_mode": "instant" }'
```
Instant is the conversational lane: dispatches now, 10/min per key, and counts against each line's daily new-contact cap like drip does.
## 6. Wire webhooks [#6-wire-webhooks]
Register your endpoint with [`POST /v1/webhooks`](/docs/api-reference/webhooks) (or with us during onboarding), then verify the signature on every delivery before parsing:
```
X-BlueReacher-Signature: sha256=
```
To test it now, register with the test key and fire a simulated reply at the `webhook_id` it returns. Each URL gets its own slot, so this is safe on the shared key:
```bash
curl -X POST https://api.bluereacher.com/v1/webhooks -H "Authorization: Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2" -H "Content-Type: application/json" -d '{ "url": "https://your-app.com/webhooks/bluereacher" }'
curl -X POST https://api.bluereacher.com/v1/sandbox/inbound -H "Authorization: Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2" -H "Content-Type: application/json" -d '{ "webhook_id": "whk_sbx_...", "content": "Yes, Thursday works" }'
```
Full verification code and the event catalogue are in [Webhooks](/docs/api-reference/webhooks).
## 7. Go live [#7-go-live]
Swap `brk_test_` for `brk_live_`. Same endpoints, same shapes, real lines. Before the first campaign, read [Rate limits and sending capacity](/docs/guides/rate-limits) once; it is the difference between a program that compounds and a line that needs a cooldown in week one.
If your integration is an AI agent, skip the HTTP layer entirely and point it at the [MCP server](/docs/guides/mcp-server).
# Rate limits and sending capacity (/docs/guides/rate-limits)
Two different limits, and mixing them up is the most common integration bug.
**API rate limits** cap how fast your code can call the API. Technical limit: you hit a `429`, you back off `retryAfter` seconds, you continue.
**Sending capacity** caps how many *brand-new cold contacts* one line reaches per day. Deliverability limit: it exists because Apple's spam detection watches exactly that number, and it is not something to retry your way past.
## API rate limits [#api-rate-limits]
Per workspace, enforced by the platform. Every API key in a workspace draws on the same limits, so creating more keys does not raise them:
| Surface | Limit |
| ------------------------------------------------------------------------------ | -------------------------------------------------------------------- |
| All `/v1` routes, global | 60/min |
| `POST /v1/messages` | 30/min |
| Instant sends (`send_mode: "instant"`) | 10/min per key; no per-key daily cap (each line's daily cap applies) |
| `POST /v1/voice-memos` | 10/min |
| Reactions, typing, read, opt-out, bot | 30/min each |
| `POST /v1/mcp` | 60/min; `tools/call` also pays the target route's limit |
| `/v1/contacts` (all methods) | 300/min |
| `GET /v1/conversations/{contact_id}/messages` | 300/min |
| `GET /v1/usage` | 60/min |
| `POST /v1/capability` | 100 phones/request; probes 4,000/day/line |
| `GET /v1/calling-lines`, `GET` and `DELETE /v1/calls`, `GET /v1/call-settings` | 60/min, one shared bucket |
| `POST /v1/calls`, `POST /v1/calls/transfer`, `PATCH /v1/call-settings` | 10/min, one shared bucket |
A `429` looks like this:
```json
{
"error": "Rate limit exceeded",
"error_code": "rate_limited",
"retryAfter": 12,
"resetAt": "2026-08-31T09:20:00Z"
}
```
Wait `retryAfter` seconds and retry. Every `429` also carries a `Retry-After` header with the same number of seconds.
You do not have to wait for a `429` to know where you stand. Rate-limited routes return these headers on every reply, so a client can slow down before it hits the limit:
| Header | Meaning |
| ----------------------- | ----------------------------------------------------------------- |
| `X-RateLimit-Limit` | Requests allowed in the current window, when the route reports it |
| `X-RateLimit-Remaining` | Requests left in the current window |
| `X-RateLimit-Reset` | When the window resets, as an ISO 8601 time |
### When a line is full for the day [#when-a-line-is-full-for-the-day]
Instant sends have no daily cap of their own. They count against the same per-line daily cap as drip sends: distinct brand-new cold contacts, with contacts who have replied never counted. When the line, and every line the send could move to, is full for the day, the send is refused with `429 device_daily_cap_exceeded`. Retrying will not help until the day rolls over in your workspace timezone; add capacity or let drip queue the send instead.
## Sending capacity: what is actually limited [#sending-capacity-what-is-actually-limited]
The single most important fact, and the one integrators get wrong by assuming every message counts:
**Conversations with engaged contacts are unlimited.** Once a contact has
ever replied, there is no cap on messaging them. Contacts you have messaged
before, even without a reply yet, are also uncapped. Inbound never counts.
The daily cap counts one thing only: brand-new cold contacts, meaning numbers
this line has never messaged and that have never messaged you.
A number counts toward a line's daily cap only when all three are true: the line sent it a message today, the line had never messaged it before today, and it has never replied to you. Five follow-ups to the same new person count once, not five times.
### The Blue Reacher managed policy [#the-blue-reacher-managed-policy]
These thresholds come from Apple's observed enforcement behavior, not from us; we enforce them so your lines stay registered. Blue Reacher runs managed lines at:
| Traffic | New contacts per line per day |
| ------------------------------------------------------------------------ | ----------------------------- |
| Opted-in (they gave you the number: form fills, bookings, inbound leads) | 50 |
| Fully cold | 30 |
These are the healthy long-term operating rates for a warmed line. Sizing rule of thumb: a 200-new-conversations-per-day target on opted-in traffic is a 4-line setup.
Limits scale with engagement. High reply rates and near-zero spam reports are what Apple's trust model rewards, so a line that consistently earns replies gets headroom; we raise caps as a line proves out rather than publishing a fixed ceiling that would be wrong in both directions. What we never do is invent our own arbitrary limits: everything here exists to keep you under Apple's thresholds, and `daily_cap` on [`GET /v1/devices`](/docs/api-reference/lines) always shows the number actually in force for each line today. (The sandbox's simulated line reports an illustrative cap; read the live number from your own lines.)
### Warm-up [#warm-up]
A new line ramps to full capacity over 16 to 20 **active days**: a day banks toward the ramp when the line actually sends meaningful volume, and idle days pause progress instead of resetting it. During ramp, `daily_cap` reflects the current tier automatically. You never pace a ramping line yourself; the drip queue does it.
Plan for this commercially: order lines for the volume you want a month out, not the week you need it.
### Quiet hours and pacing [#quiet-hours-and-pacing]
There is no sending-hour limit by default. Pass `window_start_hour` and `window_end_hour` (whole hours, 0 to 24, start before end, org-local) and drip sends hold inside that window: with 9 and 18, a send that would land at 1am is pushed to 9am. Anything else is `400 invalid_send_window`. Set a window for cold and bulk sends; the [state texting laws](/docs/platform/state-laws) explain why. Conversational replies are exempt and go out immediately. Within the window, drip sends are spaced with randomized gaps, because a hundred messages leaving a line in thirty seconds is the clearest automation signal there is. When a workspace has several lines, sends rotate across them.
### What happens when a line trips anyway [#what-happens-when-a-line-trips-anyway]
Apple enforces per number, silently. Blue Reacher's monitoring watches the early signals (blue bubbles going green to known iPhones, delivery delays, reply-rate collapse) and reacts before you have to:
* **Cooldown.** A rate-limited line has cold sends paused automatically, typically 24 to 72 hours, then re-ramps at reduced volume. Replies to engaged contacts continue throughout, because two-way conversation is exactly the signal that rebuilds trust.
* **Replacement.** If Apple de-registers the number from iMessage outright, we replace the line: free, once per line per month, typically live within one business day. History stays in the platform; the new number ramps like a new line and reads as a new sender to recipients. Apple bans numbers, not accounts, which is why a swap fixes it.
Both surface on the `device.health_changed` webhook and in the dashboard.
## Scale by adding lines, not by pushing one line [#scale-by-adding-lines-not-by-pushing-one-line]
Capacity is per line, so volume is a multiplication problem: four lines at 50 opted-in contacts per day is 200 new conversations a day, roughly 4,400 a month, with every line inside the safe range.
Rules that keep it working:
* One prospect, one line, for the whole relationship. Sends auto-lock a contact to the line that reached them (`lock`, default on), so replies always come from the number the person knows.
* Fill lines evenly rather than running two hot and two idle; rotation does this for you when you do not pin `device_id`.
* Watch reply rate per line. A line sending 50 and getting 1 reply has a copy or list problem that more lines would only multiply.
* Reply fast. Engaged contacts are unlimited, so every reply you win converts capped traffic into uncapped conversation.
# Sandbox (/docs/guides/sandbox)
The sandbox is the full Blue Reacher API answered by a simulator. Requests are validated exactly like production and replies have the production shape, but no message is sent, no contact is touched and no line is used.
You do not need an account to use it. This key is public and works right now:
```text title="Public sandbox key"
brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2
```
Every `curl` in the [API reference](/docs/api-reference/overview) already carries this key, so any example you copy runs as-is.
## Try it in 30 seconds [#try-it-in-30-seconds]
Paste this into a terminal:
```bash
curl -X POST https://api.bluereacher.com/v1/messages \
-H "Authorization: Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2" \
-H "Content-Type: application/json" \
-d '{ "to": "+13035550101", "message": "hello sandbox", "send_mode": "instant" }'
```
```js
const res = await fetch("https://api.bluereacher.com/v1/messages", {
method: "POST",
headers: {
Authorization: "Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2",
"Content-Type": "application/json",
},
body: JSON.stringify({ to: "+13035550101", message: "hello sandbox", send_mode: "instant" }),
});
console.log(await res.json());
```
```python
import requests
res = requests.post(
"https://api.bluereacher.com/v1/messages",
headers={
"Authorization": "Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2",
},
json={"to": "+13035550101", "message": "hello sandbox", "send_mode": "instant"},
)
print(res.json())
```
```json
{
"success": true,
"message_id": "sim_i_1790520003126_ej41en",
"mode": "instant",
"status": "dispatched",
"device_id": "00000000-0000-4000-a000-00000000s1m1",
"sandbox": true
}
```
Then track it with the id you got back:
```bash
curl https://api.bluereacher.com/v1/status/sim_i_1790520003126_ej41en \
-H "Authorization: Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2"
```
The [quickstart](/docs/guides/quickstart) walks the rest of the loop: conversations, lines, the instant lane and webhooks.
## How to tell you are in the sandbox [#how-to-tell-you-are-in-the-sandbox]
* Every response body carries `"sandbox": true`.
* Every response carries the header `X-BlueReacher-Sandbox: true`.
* Message ids start with `sim_`.
* The line on every reply is named "Sandbox line (simulated)".
A live key returns none of these, so simulated traffic can never be mistaken for real traffic.
## What the simulator covers [#what-the-simulator-covers]
| Surface | In the sandbox |
| -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| Sending, both lanes, groups, voice memos | Simulated with production shapes |
| `GET /v1/status/{id}` | Progresses on a timer: drip goes `pending` then `sent` after about 5 seconds, instant goes `sent` then `delivered` after about 10 seconds |
| Conversations, lines, contacts, capability, opt-outs, signals | Simulated with realistic data |
| `/v1/usage` | A fixed sample |
| Calling (`/v1/calling-lines`, `/v1/calls`) | Simulated, never dials |
| [MCP server](/docs/guides/mcp-server) | Every tool, simulated |
| Conversation history (`GET /v1/conversations/{contact_id}/messages`) | Needs a live key: `403 sandbox_key_not_supported_here` |
| Call settings | Needs a live key: `403 live_key_required` |
Validation is faithful. A number that is not E.164, a tapback longer than one character, or a missing field fails in the sandbox exactly as it would live:
```json
{ "error": "to must be E.164 format e.g. +13035550101", "error_code": "invalid_phone", "sandbox": true }
```
## Test webhooks [#test-webhooks]
A test key can register a webhook and fire a simulated inbound reply at it. Blue Reacher signs the `message.received` event with your secret and delivers it the way production does. The steps are in [Webhooks](/docs/api-reference/webhooks).
The public key works for this. Each URL you register gets its own slot and its own secret for 30 days, identified by the `webhook_id` the registration returns, so another reader registering their own URL never touches yours. Pass that `webhook_id` when you fire the simulated reply.
## Try it from an AI assistant [#try-it-from-an-ai-assistant]
The sandbox key works on the MCP server too. One command connects Claude Code to a simulated account you can talk to:
```bash
claude mcp add --transport http bluereacher https://api.bluereacher.com/v1/mcp \
--header "Authorization: Bearer brk_test_Dc815ar0WQChF5odYSeB8k2ovmVf1zG2"
```
Then ask it to check whether a number takes iMessage, send a test message, or read a thread. Setup for Cursor, Claude Desktop and other clients is on the [MCP server](/docs/guides/mcp-server) page.
## Limits of the shared key [#limits-of-the-shared-key]
* [Rate limits](/docs/guides/rate-limits) are per key, so everyone using the public key shares them: 60 requests a minute across `/v1`, 10 a minute for instant sends. The `X-RateLimit-Remaining` header shows what is left.
* A test key is bound to no workspace, so it can never read or write real data.
## Going live [#going-live]
Swap the `brk_test_` key for your `brk_live_` key. Routes, bodies and error codes stay the same. Your live key and a private test key are issued during onboarding; see [Authentication](/docs/guides/authentication).
# Close (/docs/integrations/close)
## What you get [#what-you-get]
A native connection, not an API build. Once connected, Blue Reacher writes each iMessage conversation into the matching Close lead's activity feed, so reps see the thread where they already work.
* **Lead activity.** Outbound messages and inbound replies appear on the lead's activity feed as they happen.
* **Phone matching.** Contacts match to leads by phone number. Numbers stored without a country code will not match; fix the field, not the integration.
* **Optional lead creation.** An inbound message from a number Close does not know can create a new lead automatically, or be left unmatched if you prefer a clean list. Your call, set once.
## Connecting [#connecting]
Done-for-you setups get this connected during onboarding: we authorize the connection with you and confirm matching against a handful of real leads before anything goes live. There is nothing to host.
## Where the API fits alongside it [#where-the-api-fits-alongside-it]
The native sync covers reading and logging. For triggering sends from Close automations, or anything the native path does not express, the [REST API](/docs/api-reference/overview) and [webhooks](/docs/api-reference/webhooks) work the same as for any CRM; the patterns are in [CRM integrations](/docs/guides/crm-integrations).
# Follow Up Boss (/docs/integrations/follow-up-boss)
## What you get [#what-you-get]
A native connection built for real estate teams running Follow Up Boss. Blue Reacher logs iMessage conversations into FUB as native text messages, so they sit in the same history as everything else and count in the reporting your team already reads.
* **Native text history.** Outbound iMessages and inbound replies land on the person's timeline as texts, not as notes bolted on the side.
* **People creation.** An inbound message from a number FUB does not know creates a new person, so an unknown reply never disappears.
* **Name sync.** Contact names stay aligned between the two systems, so the thread and the record read the same.
## Connecting [#connecting]
Done-for-you setups get this connected during onboarding: we authorize the connection with you and confirm a live round trip before your team relies on it. Nothing to host.
## Where the API fits alongside it [#where-the-api-fits-alongside-it]
For triggering sends from FUB automations or action plans, and for anything the native path does not express, the [REST API](/docs/api-reference/overview) and [webhooks](/docs/api-reference/webhooks) apply unchanged; see [CRM integrations](/docs/guides/crm-integrations).
# HubSpot (/docs/integrations/hubspot)
## The native integration [#the-native-integration]
HubSpot does not need the API build below to see your messages. Connected natively, Blue Reacher puts iMessage threads into HubSpot's conversations inbox, and keeps the two systems matched:
* **Inbox threads.** Each iMessage conversation appears as a thread in the conversations inbox your team already works.
* **Two-way replies.** Replying from the HubSpot inbox sends a real iMessage from your line; the contact's reply lands back in the same thread.
* **Contact sync.** Contacts stay matched between the two systems by phone number.
Done-for-you setups get this connected during onboarding. The API path below is for the other direction: triggering sends from HubSpot workflows, which the inbox connection deliberately does not do on its own.
## The API path: workflow-triggered sends [#the-api-path-workflow-triggered-sends]
A workflow sends an iMessage when a contact hits any condition you can express in HubSpot. Replies come back as notes on the contact, with a timestamp property your lists and workflows can act on.
## Before you start [#before-you-start]
Get your API key (start with a `brk_test_` key; it simulates every send) and stand up an HTTPS endpoint for inbound replies. Register the endpoint with us and keep the signing secret server-side.
## Outbound: workflow to Blue Reacher [#outbound-workflow-to-blue-reacher]
With Operations Hub Professional or higher, use a webhook action.
1. Create a contact-based workflow with your enrollment trigger.
2. Add action: Send a webhook. Method POST, URL `https://api.bluereacher.com/v1/messages`.
3. Add header `Authorization` with value `Bearer brk_your_api_key`, and `Content-Type: application/json`.
4. Custom payload:
```json
{
"to": "{{ contact.phone }}",
"message": "Hi {{ contact.firstname }}, it's Marcus from Northside. Saw you booked time with us. Anything you want covered before we talk?",
"metadata": { "crm_id": "{{ contact.hs_object_id }}" }
}
```
Custom payloads and custom headers require Operations Hub Professional. Without it, use a Custom code action instead:
```javascript
exports.main = async (event, callback) => {
const res = await fetch("https://api.bluereacher.com/v1/messages", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BLUEREACHER_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
to: event.inputFields.phone,
message: `Hi ${event.inputFields.firstname}, quick question about your enquiry.`,
metadata: { crm_id: String(event.object.objectId) }
})
});
const data = await res.json();
callback({ outputFields: { queued: data.success ? "yes" : "no", messageId: data.message_id || "" } });
};
```
Store the key as a secret in the custom code action, not inline. The custom-code route also lets you branch the workflow on the response, which the webhook action cannot do.
## Phone number formatting [#phone-number-formatting]
HubSpot stores phone numbers however they were entered. The API needs E.164. Either normalize on the way in with a calculated property, or handle it in the custom code action:
```javascript
const e164 = raw.replace(/[^\d+]/g, "").replace(/^00/, "+");
const to = e164.startsWith("+") ? e164 : `+1${e164}`;
```
Assuming `+1` is fine for a US-only list and wrong for anything else. If you send internationally, store the country properly.
## Inbound: replies onto the contact [#inbound-replies-onto-the-contact]
Blue Reacher posts to your endpoint, and your endpoint writes to HubSpot. HubSpot cannot receive our webhook directly, since it needs a signature check and an API call back.
```javascript
import crypto from "crypto";
export default async function handler(req, res) {
const raw = req.rawBody;
const expected =
"sha256=" +
crypto
.createHmac("sha256", process.env.BLUEREACHER_WEBHOOK_SECRET)
.update(raw)
.digest("hex");
if (req.headers["x-bluereacher-signature"] !== expected) {
return res.status(401).json({ code: "invalid_signature" });
}
res.status(200).json({ received: true });
const e = JSON.parse(raw);
if (e.event !== "message.received") return;
const hsId = await hubspotIdForPhone(e.data.phone_number); // your stored mapping
await fetch("https://api.hubapi.com/crm/v3/objects/notes", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HUBSPOT_TOKEN}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
properties: {
hs_note_body: `iMessage reply: ${e.data.content}`,
hs_timestamp: e.timestamp
},
associations: [
{
to: { id: hsId },
types: [{ associationCategory: "HUBSPOT_DEFINED", associationTypeId: 202 }]
}
]
})
});
}
```
Set a custom contact property, `last_imessage_reply`, in the same handler. Active lists and workflows can trigger on it, which is how you get "notify the owner when a lead replies" without polling.
## Private app scopes [#private-app-scopes]
Create a private app in HubSpot with `crm.objects.contacts.read`, `crm.objects.contacts.write`, and `crm.objects.notes.write`. Use its token in the handler above.
## Suppress on opt-out [#suppress-on-opt-out]
Subscribe to `contact.opted_out` on your webhook endpoint and set a HubSpot property, `imessage_opted_out`, to true on the matching record. Then add that property as an exclusion on every messaging workflow. The platform refuses sends to opted-out contacts anyway, but keeping HubSpot in sync stops your team from working a record they should leave alone. See [opt-out handling](/docs/platform/opt-outs).
## Checklist [#checklist]
* [ ] Phone numbers normalized to E.164
* [ ] Key stored as a secret, never in a payload template
* [ ] `hs_object_id` stamped into contact `custom_fields` / your phone-to-record map
* [ ] Signature verified before the note is written
* [ ] `200` returned before the HubSpot call, not after
* [ ] Opt-out property syncing and excluded from every workflow
# Integrations overview (/docs/integrations)
Every integration does the same two things: send a message when something happens in your system, and write the reply back when it arrives. What changes is where the buttons are.
## Pick your path [#pick-your-path]
Two kinds of connection exist. **Native integrations** are connected for you during onboarding and sync conversations into the CRM with nothing to host. **API builds** are patterns you (or your automation tool) wire against the REST API.
| Your setup | Start here | Kind |
| ----------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------ |
| GoHighLevel | [GoHighLevel](/docs/guides/gohighlevel) | Native, two-way sync and workflow actions |
| HubSpot | [HubSpot](/docs/integrations/hubspot) | Native inbox sync, plus an API path for workflow sends |
| Salesforce | [Salesforce](/docs/integrations/salesforce) | Native timeline sync with embedded replies, plus a Flow path |
| Close | [Close](/docs/integrations/close) | Native, lead activity feed |
| Pipedrive | [Pipedrive](/docs/integrations/pipedrive) | Native, contact timelines |
| Follow Up Boss | [Follow Up Boss](/docs/integrations/follow-up-boss) | Native, logged as real texts |
| Jobber | [Jobber](/docs/integrations/jobber) | Native, client timelines (beta) |
| Facebook and Instagram lead ads | [Meta lead forms](/docs/integrations/meta-leads) | Native, form submission to first message |
| Zapier | [Zapier](/docs/integrations/zapier) | API build |
| n8n | [n8n](/docs/integrations/n8n) | API build |
| Make | [Make](/docs/integrations/make) | API build |
| Shopify | [Shopify](/docs/integrations/shopify) | API build over Shopify webhooks |
| An AI assistant (Claude, Cursor) | [MCP server](/docs/guides/mcp-server) | Native, one endpoint |
| Anything else, or in-house software | [Connecting other CRMs](/docs/guides/crm-integrations) | API build |
## Beyond the CRM [#beyond-the-crm]
The platform also connects outward. These are configured on your account rather than built:
* **Slack.** New-lead alerts, reply notifications, and automation updates routed to the channels your team watches.
* **Line-type lookup.** Numbers are identified as mobile, landline, or VoIP before they enter a messaging flow, so landlines never eat an iMessage send.
* **AI on your own key.** AI-assisted replies and analysis can run on your own OpenAI, Anthropic, or Google AI account, so usage and data terms are yours.
Ask your account manager to turn any of these on.
## The three things every integration needs [#the-three-things-every-integration-needs]
1. An API key. Build against a `brk_test_` key first; it simulates every send. Server-side only.
2. An HTTPS endpoint that accepts POST, returns `200` fast, and verifies the signature. Registered with us during onboarding or from the dashboard.
3. Sends default to the drip lane, which paces and load-balances across your lines for you; there is no line ID to manage unless you want to pin one.
## The identifier that makes it all work [#the-identifier-that-makes-it-all-work]
Stamp your CRM record ID into the contact's `custom_fields` (one upsert to `POST /v1/contacts`) and carry it in send `metadata`. Webhook events identify the contact by `contact_id` and `phone_number`; your handler resolves either to the CRM record via your stored mapping. Get this right at the start and every integration below is a wiring exercise. The full pattern is in [connecting other CRMs](/docs/guides/crm-integrations).
## What to check before you go live [#what-to-check-before-you-go-live]
* One test-key message end to end through the automation, response parsed
* One live message, confirmed on the CRM record
* A real reply from a device, confirmed landing within seconds
* A deliberately broken signature, confirmed rejected with `401`
* A deliberate `500` from your handler, confirmed retried, and your write confirmed idempotent on `message_id`
* The automation enabled for one small segment before the whole list
# Jobber (/docs/integrations/jobber)
The Jobber integration is in beta. It works, and we connect it for teams that
want it, with the caveat that details may still change.
## What you get [#what-you-get]
A native connection for home-service businesses running Jobber. Blue Reacher writes iMessage activity onto the matching client's timeline, photos included, so the office sees the whole exchange next to the job.
* **Client timeline notes.** Conversations, replies, and photos from the thread land on the Jobber client's timeline.
* **Phone matching.** Clients match by phone number, normalized to E.164.
* **Optional client creation.** An inbound message from an unknown number can create a new client, or stay unmatched, set once during connection.
## Connecting [#connecting]
Done-for-you setups get this connected during onboarding. We authorize the connection with you and verify matching before it goes live.
## Where the API fits alongside it [#where-the-api-fits-alongside-it]
For triggering sends from your own automations, the [REST API](/docs/api-reference/overview) and [webhooks](/docs/api-reference/webhooks) work as with any system; see [CRM integrations](/docs/guides/crm-integrations).
# Make (/docs/integrations/make)
## Outbound module [#outbound-module]
1. Add your trigger module (Watch Records, Watch Rows, a webhook, whatever starts the scenario).
2. Add module: HTTP, Make a request.
* URL `https://api.bluereacher.com/v1/messages`
* Method POST
* Headers: `Authorization` with `Bearer brk_your_api_key`, and `Content-Type` with `application/json`
* Body type: Raw, Content type JSON
```json
{
"to": "{{1.phone}}",
"message": "Hi {{1.first_name}}, it's Marcus from Northside. Saw your enquiry, want me to send times?",
"metadata": { "crm_id": "{{1.id}}" }
}
```
3. Turn on Parse response so downstream modules can read `message_id` and `mode`.
Store the key in a Make connection or a data store rather than pasting it into every scenario, so a rotation is one edit.
## Phone formatting [#phone-formatting]
Make has no built-in E.164 formatter. Use a Text parser or a formula:
```
{{ "+1" + replace(replace(replace(1.phone; " "; ""); "-"; ""); "("; "") }}
```
Strip the leading `+1` logic if your list is international, and store country separately rather than guessing.
## Error handling [#error-handling]
Right-click the HTTP module, Add error handler. Route `429` to a Sleep module (use the `retryAfter` value from the response) then a retry path, and route `4xx` other than `429` to a data store or a notification, since those need a human. Capacity never surfaces as an error on the drip lane; excess cold volume queues, per [rate limits](/docs/guides/rate-limits).
## Inbound scenario [#inbound-scenario]
1. New scenario, first module: Webhooks, Custom webhook. Add it, copy the URL.
2. Open the webhook's settings and enable Get request headers and Get request body as raw. Without the raw body you cannot verify the signature, because Make re-serializes parsed JSON and the hash will never match.
3. Register the URL as your Blue Reacher webhook endpoint.
4. Send a test message, reply to it, and click Redetermine data structure so Make learns the payload.
5. Add a Tools, Set variable module computing the HMAC:
```
{{ sha256(3.rawBody; "hex"; "your_signing_secret") }}
```
Make's `sha256` with a key argument produces the HMAC. Compare it to the `x-bluereacher-signature` header in a Router filter, and drop anything that does not match.
6. Router: one branch filtered on `event` equals `message.received` writing the reply to your CRM, one on `contact.opted_out` setting your suppression flag.
## Keep the acknowledgment fast [#keep-the-acknowledgment-fast]
Add a Webhook response module early in the scenario returning status 200, before the CRM work. Blue Reacher retries non-200 responses with backoff for 24 hours, so a slow scenario produces duplicate replies in your CRM. Acknowledge first, process after.
# Meta lead forms (/docs/integrations/meta-leads)
## What you get [#what-you-get]
A native connection from Meta lead ads to your iMessage lines. When someone submits a Facebook or Instagram Instant Form, the submission becomes a contact and can start an automated follow-up immediately, which is exactly the window where speed decides the outcome.
* **Facebook leads.** Instant Form submissions from Facebook ads become contacts with the form's fields attached.
* **Instagram leads.** The same for Instagram lead ads.
* **Workflow starts.** A new form submission can trigger an automation, so the first iMessage goes out while the lead is still holding their phone. The play this powers is [speed to lead](/docs/recipes/speed-to-lead).
## Consent [#consent]
A lead form submission is an inquiry, and your first message should read like an answer to it, sent once, with an easy way out. The rules and the reasoning are in the [compliance guide](/docs/platform/compliance); the practical send patterns are in the speed-to-lead recipe.
## Connecting [#connecting]
Done-for-you setups get this connected during onboarding: we authorize the Meta connection with you, map the form fields, and wire the follow-up automation. Nothing to host.
# n8n (/docs/integrations/n8n)
n8n is the best of the no-code options for this, because you can verify the webhook signature and still keep the build visual.
## Credential setup [#credential-setup]
Credentials, New, Header Auth. Name it Blue Reacher. Header name `Authorization`, header value `Bearer brk_your_api_key`. Every HTTP Request node then references the credential instead of carrying the key.
## Outbound workflow [#outbound-workflow]
1. Your trigger node: Schedule, Webhook, a database node, a CRM node.
2. HTTP Request node:
* Method POST
* URL `https://api.bluereacher.com/v1/messages`
* Authentication: Generic Credential Type, Header Auth, Blue Reacher
* Send Body: on, Body Content Type JSON
* Specify Body: Using JSON
```json
{
"to": "{{ $json.phone }}",
"message": "Hi {{ $json.first_name }}, it's Marcus from Northside. Quick question about your enquiry.",
"metadata": { "crm_id": "{{ $json.id }}" }
}
```
3. Add Settings, Retry On Fail with 3 tries for transient `5xx` responses. Do not retry `4xx`; those need a fix, not a repeat. Error semantics are in [errors and status codes](/docs/guides/errors).
## Pacing a batch [#pacing-a-batch]
Sends are paced platform-side, so you do not need to build delays for line health, and cold volume past today's capacity queues rather than erroring. The one limit your workflow can hit is the API request rate (30/min on `POST /v1/messages`): a Split In Batches node with a small Wait keeps a big batch under it, and `available_today` from [`GET /v1/devices`](/docs/api-reference/lines) at the start of the run tells you how much lands today versus queues for tomorrow.
## Inbound workflow with signature verification [#inbound-workflow-with-signature-verification]
1. Webhook node, method POST. Set Response Mode to Immediately so n8n acknowledges before processing.
2. Register the production URL as your Blue Reacher webhook endpoint.
3. Code node, run once for each item:
```javascript
const crypto = require('crypto');
const raw = JSON.stringify($input.item.json.body);
const provided = $input.item.json.headers['x-bluereacher-signature'];
const expected = 'sha256=' + crypto
.createHmac('sha256', $env.BLUEREACHER_WEBHOOK_SECRET)
.update(raw)
.digest('hex');
if (
!provided ||
provided.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected))
) {
throw new Error('Invalid signature');
}
return $input.item;
```
Enable Raw Body on the Webhook node if your n8n version re-serializes JSON differently from the sent bytes, and hash the raw string instead. A signature that never matches is almost always this.
4. Switch node on `{{ $json.body.event }}`: route `message.received` to your CRM update, `contact.opted_out` to your suppression flag, `message.sent` / `message.failed` to your delivery logging.
## A pattern worth building [#a-pattern-worth-building]
Reply, then classify, then route. Send the inbound text to an LLM node with a short prompt that returns one of interested, not-interested, question, or wrong-number, then branch: interested pings the rep in Slack immediately, question drafts a suggested answer for a human to approve, not-interested and wrong-number update the CRM and stop the sequence.
Keep a human in the loop on anything that sends. Auto-replying to a live prospect from a classifier is how a good conversation becomes a bad one, and an incorrect auto-reply to someone asking to opt out is a compliance problem, not just an awkward one.
# Pipedrive (/docs/integrations/pipedrive)
## What you get [#what-you-get]
A native connection, not an API build. Blue Reacher writes each iMessage conversation onto the matching Pipedrive contact's timeline, so the thread lives next to the deals and activities your team already tracks.
* **Contact timelines.** Outbound messages and inbound replies appear on the person's timeline as they happen.
* **Phone matching.** People match by phone number, normalized to E.164. Numbers stored without a country code will not match.
* **Optional person creation.** An inbound message from an unknown number can create a new person automatically, or stay unmatched if you keep a curated database. Set once during connection.
## Connecting [#connecting]
Done-for-you setups get this connected during onboarding: we authorize the connection with you and verify matching against real records before it goes live. Nothing to host, no webhook URL to manage.
## Where the API fits alongside it [#where-the-api-fits-alongside-it]
The native sync handles logging. To trigger sends from Pipedrive automations, or for anything else, use the [REST API](/docs/api-reference/overview) and [webhooks](/docs/api-reference/webhooks); the wiring patterns are in [CRM integrations](/docs/guides/crm-integrations).
# Salesforce (/docs/integrations/salesforce)
## The native integration [#the-native-integration]
Salesforce does not need the Flow build below to see your messages. Connected natively, Blue Reacher writes iMessage activity onto Salesforce records and lets reps answer without leaving them:
* **Contact and Lead history.** Each conversation lands on the matching Contact or Lead timeline as it happens.
* **Embedded replies.** An embedded panel on the record shows the thread and sends replies as real iMessages from your line.
* **Contact sync.** Records stay matched by phone number.
Done-for-you setups get this connected during onboarding. The Flow build below is for the other direction: firing sends from record-triggered automation.
## The Flow path: record-triggered sends [#the-flow-path-record-triggered-sends]
A record-triggered Flow sends an iMessage on any Lead or Contact condition. Replies come back as completed Tasks on the matching record, with a field your reps can filter and report on.
## Step 1: store the key properly [#step-1-store-the-key-properly]
Never put the API key in a Flow. Use Named Credentials.
1. Setup, Named Credentials, External Credentials, New.
2. Authentication Protocol: Custom.
3. Add a Principal, then under Authentication Parameters add name `ApiKey`, value `Bearer brk_your_api_key`.
4. Create a Named Credential pointing at `https://api.bluereacher.com`, linked to that External Credential.
5. Under the External Credential's Custom Headers, add `Authorization` with value `{!$Credential.BlueReacher.ApiKey}`.
6. Give the running user's permission set access to the External Credential Principal.
## Step 2: outbound from a Flow [#step-2-outbound-from-a-flow]
Create a record-triggered Flow on Lead or Contact.
1. Trigger: when the record meets your condition, for example Status becomes Working or a custom Ready for Outreach checkbox is set.
2. Add an HTTP Callout action against the Named Credential.
3. Method POST, path `/v1/messages`.
4. Body:
```json
{
"to": "{!$Record.MobilePhone}",
"message": "Hi {!$Record.FirstName}, it's Marcus from Northside. You asked about pricing last week, want me to send it over?",
"metadata": { "crm_id": "{!$Record.Id}" }
}
```
Use `MobilePhone`, not `Phone`. `Phone` is frequently a landline or a switchboard, and sending to it is a wasted send at best.
Salesforce phone fields are rarely E.164. Add a formula field that normalizes to `+1XXXXXXXXXX` for US numbers and map the callout to that field rather than the raw one.
## Step 3: inbound as Tasks [#step-3-inbound-as-tasks]
Expose an Apex REST endpoint and register its URL as your webhook endpoint.
```apex
@RestResource(urlMapping='/bluereacher/*')
global with sharing class BlueReacherWebhook {
@HttpPost
global static void handleReply() {
RestRequest req = RestContext.request;
String raw = req.requestBody.toString();
Blob mac = Crypto.generateMac(
'hmacSHA256',
Blob.valueOf(raw),
Blob.valueOf(BlueReacherSettings__c.getInstance().WebhookSecret__c)
);
String expected = 'sha256=' + EncodingUtil.convertToHex(mac);
String provided = req.headers.get('x-bluereacher-signature');
if (provided == null || !ConstantTimeCompare.equals(expected, provided)) {
RestContext.response.statusCode = 401;
return;
}
Map e = (Map) JSON.deserializeUntyped(raw);
if (e.get('event') != 'message.received') {
RestContext.response.statusCode = 200;
return;
}
Map data = (Map) e.get('data');
// Resolve the record by the normalized phone number.
String phone = (String) data.get('phone_number');
Contact c = [SELECT Id FROM Contact WHERE MobilePhone_E164__c = :phone LIMIT 1];
Task t = new Task(
WhoId = c.Id,
Subject = 'iMessage reply',
Description = (String) data.get('content'),
Status = 'Completed',
ActivityDate = Date.today()
);
insert t;
RestContext.response.statusCode = 200;
}
}
```
Store the signing secret in a protected custom setting or custom metadata, not in the class. Expose the endpoint through a Connected App with OAuth, or a Site with the right guest-user permissions, and restrict it to the minimum needed to insert a Task.
`WhoId` accepts both Lead and Contact IDs, so a single handler covers both as long as your Flow always sends `{!$Record.Id}`.
## Governor limits [#governor-limits]
A reply burst means many small transactions rather than one large one, so the usual bulk-DML advice does not apply here. What does: keep the Apex handler to a single insert, do no SOQL you can avoid, and never call out to another system from inside the webhook handler. If you need enrichment, fire a Platform Event and process it asynchronously.
## Opt-out sync [#opt-out-sync]
Subscribe to `contact.opted_out` and set `HasOptedOutOfEmail`-style custom field, for example `iMessage_Opted_Out__c`, on the matching record. Add it as a filter on every outbound Flow. Details in [opt-out handling](/docs/platform/opt-outs).
## Checklist [#checklist]
* [ ] Key in a Named Credential, not in the Flow
* [ ] `MobilePhone` used, normalized to E.164 by formula field
* [ ] `metadata.crm_id` mapped to `{!$Record.Id}`, and an indexed E.164 phone field to resolve replies
* [ ] Signature verified with a constant-time comparison
* [ ] Endpoint permissions limited to Task insert
* [ ] Opt-out field syncing and filtered on every Flow
# Shopify (/docs/integrations/shopify)
## What this covers [#what-this-covers]
Three flows, in the order they are worth building: abandoned checkout recovery, order and delivery updates, and post-purchase follow-up. All three run off Shopify webhooks into a small handler that calls Blue Reacher.
## Consent first [#consent-first]
Shopify's SMS marketing consent checkbox at checkout is the consent record for marketing messages. Read `customer.sms_marketing_consent.state` on every payload and send marketing only when it is `subscribed`. Transactional messages about an order the person placed sit under prior express consent, which is a different and lower bar, but keep the two flows separate in your code so a marketing message never rides on a transactional consent record. The distinction is in the [compliance guide](/docs/platform/compliance).
## Abandoned checkout recovery [#abandoned-checkout-recovery]
Subscribe to the `checkouts/update` webhook in Shopify (Settings, Notifications, Webhooks) pointed at your handler.
```javascript
export default async function handler(req, res) {
res.status(200).end();
const checkout = req.body;
if (checkout.completed_at) return;
if (checkout.customer?.sms_marketing_consent?.state !== "subscribed") return;
if (!checkout.customer?.phone) return;
// wait out the window before sending; enqueue rather than sleep
await queue.schedule("checkout-recovery", { checkoutId: checkout.id }, { delayMinutes: 60 });
}
```
Then, at send time, re-fetch the checkout and confirm it is still incomplete before messaging. Nothing damages a brand faster than a recovery message to someone who completed the order forty minutes ago.
```javascript
await fetch("https://api.bluereacher.com/v1/messages", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BLUEREACHER_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
to: checkout.customer.phone,
message: `Hi ${checkout.customer.first_name}, it's Sam from Northside. Your cart's still saved if you want it. Anything I can answer?`,
metadata: { shopify_customer_id: String(checkout.customer.id), flow: "abandoned-checkout" }
})
});
```
Timing that works: one message at 60 minutes, one at 24 hours if there is no reply, then stop. A third message on an abandoned cart converts near zero and generates opt-outs.
Do not put the checkout link in the first message. Ask the question, and send the link when they reply, per [links and previews](/docs/platform/link-previews).
## Order and delivery updates [#order-and-delivery-updates]
Subscribe to `orders/paid` and `fulfillments/create`. These are transactional, they are welcome, and they are the cheapest way to establish a thread that later marketing messages can live in.
```javascript
// fulfillments/create
message: `Hi ${firstName}, your order just shipped. Tracking: ${trackingNumber}. Reply here if anything looks off.`
```
An established thread also improves link preview rendering and gets your saved-contact rate up, both covered in [sender identity](/docs/platform/sender-identity).
## Post-purchase follow-up [#post-purchase-follow-up]
Subscribe to `orders/fulfilled`, wait for the delivery window plus a few days, then ask one question. Not a review request as the first message: ask whether the product worked, and request the review from people who say yes. Same consent rule applies, since this is marketing.
## Identity mapping [#identity-mapping]
Upsert the customer into Blue Reacher contacts once with `custom_fields: { shopify_customer_id }`, and keep your own phone-to-customer map. Inbound events identify the contact by phone number, so your handler resolves the Shopify customer and can pull order history before a human answers.
## Opt-outs back into Shopify [#opt-outs-back-into-shopify]
Subscribe to `contact.opted_out` on your Blue Reacher webhook and write it back to Shopify by setting the customer's SMS marketing consent to `unsubscribed` through the Admin API. A person who opts out of your texts should not keep receiving them from a different tool on the same stack. Sends are blocked platform-side regardless, per [opt-out handling](/docs/platform/opt-outs).
## Checklist [#checklist]
* [ ] Consent state checked on every marketing send
* [ ] Marketing and transactional flows separated in code
* [ ] Checkout re-verified as incomplete at send time
* [ ] No link in the first message
* [ ] `shopify_customer_id` stamped in contact `custom_fields` and send `metadata` across all flows
* [ ] Opt-outs written back to Shopify
# Zapier (/docs/integrations/zapier)
## Outbound: any trigger sends a message [#outbound-any-trigger-sends-a-message]
1. New Zap, pick your trigger (new row, new form submission, CRM stage change, whatever you already run).
2. Action: Webhooks by Zapier, event Custom Request.
3. Method POST, URL `https://api.bluereacher.com/v1/messages`.
4. Headers:
```
Authorization: Bearer brk_your_api_key
Content-Type: application/json
```
5. Data (as raw JSON, not the key-value fields, so nesting stays intact):
```json
{
"to": "{{phone}}",
"message": "Hi {{first_name}}, it's Marcus from Northside. Saw your enquiry come through, want me to send times?",
"metadata": { "crm_id": "{{record_id}}" }
}
```
Map `metadata.crm_id` to whatever ID your trigger step already returns, and keep your own phone-to-record mapping for the inbound side; webhook events identify the contact by phone number.
## Formatting phone numbers [#formatting-phone-numbers]
Zapier's Formatter step, Numbers, Format Phone Number, converts to E.164. Put it between the trigger and the webhook action and map its output to `to`. Skipping this is the most common cause of validation failures in Zapier builds.
## Inbound: replies into your stack [#inbound-replies-into-your-stack]
1. New Zap, trigger: Webhooks by Zapier, Catch Hook.
2. Copy the custom webhook URL Zapier gives you.
3. Register it as your Blue Reacher webhook endpoint.
4. Send yourself a test message and reply to it, so Zapier can sample a real payload.
5. Add a Filter step: continue only if `event` exactly matches `message.received`.
6. Add your action: look up the CRM record by the phone number in `data.phone_number`, post to Slack, create a task, whatever your team needs.
The payload fields are listed in [connecting other CRMs](/docs/guides/crm-integrations).
## What Zapier cannot do [#what-zapier-cannot-do]
Catch Hook does not verify the HMAC signature, so a Zapier endpoint accepts any POST to that URL. The URL is unguessable, which is adequate for routing a reply into Slack and not adequate for anything that writes to a system of record or triggers a payment. For those, run a small handler that verifies `x-bluereacher-signature` and calls Zapier afterward, or use [n8n](/docs/integrations/n8n) where you can verify in a code node.
## Notify a human fast [#notify-a-human-fast]
The highest-value Zap in most accounts is the simplest: on `message.received`, post to a Slack channel with the contact name and the reply text. Reply speed drives close rate, and a notification beats a dashboard nobody has open. See [deliverability](/docs/platform/deliverability) for why the clock matters.
## Handling opt-outs [#handling-opt-outs]
Add a second Filter branch for `event` equals `contact.opted_out` and have it flag the record in your CRM. Sends are blocked platform-side either way, but your team should see the flag before they work the record.
# Apple and iMessage: how this works (/docs/platform/apple-and-imessage)
Every technical evaluator asks the same three questions before building on an iMessage platform. Here they are, answered without marketing.
## Is Blue Reacher an official Apple partner? [#is-blue-reacher-an-official-apple-partner]
No, and neither is any other iMessage marketing platform. Apple does not sell an outbound marketing API for iMessage. Apple's business messaging product, Messages for Business, is a support channel that customers initiate; it does not let a business start a conversation with someone who has not written first, which is the opposite of what outbound needs.
Anyone claiming to be an authorized Apple partner for outbound iMessage is either describing Messages for Business, which is a different product, or is not telling you the truth. We would rather you hear that from us before you build than discover it later.
## So what is actually happening? [#so-what-is-actually-happening]
Blue Reacher operates dedicated iMessage lines, real phone numbers on managed infrastructure that sends and receives through the same iMessage service consumer devices use. When you call the API, we route the message through the line assigned to your workspace. That is why the messages arrive as blue bubbles with read receipts, typing indicators, and full-quality media: they are genuine iMessages, not a lookalike.
This is also why the ramp and the daily ceilings exist. The infrastructure behaves like a person messaging from a phone, and the [rate limits](/docs/guides/rate-limits) keep it inside that envelope.
## Why is there no A2P registration? [#why-is-there-no-a2p-registration]
A2P 10DLC registration is a carrier requirement for application-to-person SMS traffic on US carrier networks. iMessage does not travel over carrier SMS infrastructure, so there is no carrier registration step, no campaign approval queue, and no per-campaign vetting fee. Messages that fall back to SMS are delivered through the platform without you filing anything.
Not filing a carrier form does not change your legal obligations. The TCPA applies to the message, not the transport, which the [compliance guide](/docs/platform/compliance) covers in detail.
## What is the platform risk? [#what-is-the-platform-risk]
State it plainly: Apple controls the service, and a platform that depends on Apple's service carries that dependency. Any provider in this category, including us, is subject to it.
What reduces it in practice:
1. Dedicated lines per workspace. Your sending is isolated from other customers' behavior, which is not true of shared-pool providers.
2. Human-plausible sending patterns. The ramp, the daily ceilings, the randomized pacing, and the automatic capacity reduction when a line's report rate climbs all exist to keep behavior inside normal ranges. Volume-maximizing providers carry meaningfully more risk than this.
3. Channel fallback. Every message can fall back to SMS, so a program built on Blue Reacher is not architecturally stranded if iMessage becomes unavailable for a recipient or a line. Your integration, your contact records, your webhooks, and your automations continue to work; the delivery channel changes.
4. Replacement lines. If an individual line degrades or fails, we replace it. Line health is monitored per line and the recovery process is documented in [deliverability](/docs/platform/deliverability).
## What we do not claim [#what-we-do-not-claim]
We do not claim Apple authorization, endorsement, or partnership. We do not claim the channel is immune to change. We do not publish client names as proof of legitimacy, because customer lists are not evidence about platform risk and our clients did not sign up to be someone else's due diligence.
## What this means for your build [#what-this-means-for-your-build]
Build against the API the way you would build against any external dependency: carry your CRM record ids in `metadata` and `custom_fields` so your records own the relationship, store conversation history on your side as well as ours, and treat `delivery_channel` as a variable rather than a constant. Everything in [key concepts](/docs/guides/key-concepts) is designed to make that straightforward, and it is the same discipline that makes the fallback path useful rather than theoretical.
# Compliance guide (/docs/platform/compliance)
## Read this first: not legal advice [#read-this-first-not-legal-advice]
This page is general information for operators. It is not legal advice, and Blue Reacher is not your lawyer. Text messaging law moves through FCC orders and court rulings several times a year, and state rules are often stricter than federal ones. Have counsel review your opt-in language, your message templates, and your opt-out process before you send at volume. No product feature makes you compliant on its own.
## The TCPA applies to iMessage [#the-tcpa-applies-to-imessage]
The Telephone Consumer Protection Act (47 U.S.C. § 227) regulates calls and texts sent to telephone numbers. The FCC has treated text messages as "calls" under the statute since its 2003 Report and Order, and its TCPA Omnibus Declaratory Ruling and Order of July 10, 2015 extended that reading to internet-to-phone messaging.
Nothing in the law turns on which app renders the message. What matters is that you sent a commercial message to a phone number. Whether the delivery channel is `imessage` or `sms`, your obligations are the same.
Two things operators get wrong:
**"It's B2B, so the TCPA doesn't apply."** The federal do-not-call rules at 47 C.F.R. § 64.1200(c) protect residential subscribers, and the FCC presumes wireless numbers are residential. Most mobile numbers on a B2B list are somebody's personal cell phone. Treat every mobile number as covered.
**"We're not an autodialer, so we're clear."** *Facebook, Inc. v. Duguid*, 592 U.S. 395 (April 1, 2021) narrowed the autodialer definition to systems that generate numbers randomly or sequentially, which weakens most § 227(b) claims against list-based texting. It does nothing to § 227(c) do-not-call claims, which require no autodialer. Under § 227(c)(5) a plaintiff needs two messages in a 12-month period to a number on the National Do Not Call Registry or on your internal list. Statutory damages are $500 per message, up to $1,500 for willful or knowing violations.
## No A2P registration required is not the same as no obligation [#no-a2p-registration-required-is-not-the-same-as-no-obligation]
iMessage sends run on dedicated iMessage lines, managed for you, with no A2P registration required. Carrier registration and legal compliance are separate systems. Not filing a carrier form does not reduce your TCPA exposure by a dollar. Build your consent and opt-out process assuming a plaintiff's lawyer will read your logs, because that is who reads them.
## Consent [#consent]
| Message type | Standard | What that means in practice |
| ----------------------------------------------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Marketing or promotional | Prior express written consent (47 C.F.R. § 64.1200(f)(9)) | A signed agreement, e-signature counts, with a clear and conspicuous disclosure that the person agrees to receive marketing texts from you at that number, and a statement that consent is not a condition of purchase |
| Informational or transactional | Prior express consent | The person gave you the number for that purpose (order updates, appointment confirmations, a reply to a support ticket) |
| Reply inside a conversation the contact started | Consent from context | Still log the inbound message that started it |
Capture consent so it survives a deposition. For every opt-in, store the exact disclosure text the person saw, a timestamp, the IP address or event source, the checkbox state, the phone number as submitted, and a screenshot or versioned copy of the form. A CSV column that says `consent: yes` proves nothing.
Buying a list is not consent. Consent given to a lead vendor is a factual question about what the disclosure said, and the FCC's one-to-one consent rule that would have tightened this was vacated by the Eleventh Circuit in *Insurance Marketing Coalition Ltd. v. FCC*, No. 24-10277 (January 24, 2025). The rule is gone, the litigation risk is not. If you buy leads, get the disclosure text and the consent records in writing before you send.
## Revocation: the rules effective April 11, 2025 [#revocation-the-rules-effective-april-11-2025]
The FCC's Report and Order FCC 24-24 (CG Docket No. 02-278, adopted February 15, 2024) rewrote how consent gets revoked. The core provisions took effect **April 11, 2025**. Again, this is general information, not legal advice, and the operational details below are how Blue Reacher implements the rules, not a legal opinion about your program.
What changed:
* **Any reasonable means.** A contact can revoke consent by any reasonable method. You cannot designate an exclusive channel for opt-outs.
* **Per se reasonable keywords.** A reply of *stop*, *quit*, *end*, *revoke*, *opt out*, *cancel*, or *unsubscribe* is automatically a valid revocation. Other wording is not automatically unreasonable, and the burden is on you to show a request was not a revocation.
* **10 business days.** You must honor a revocation as soon as practicable and no later than 10 business days after receipt. The clock starts when your organization receives it, not when it reaches the platform.
* **One confirmation message.** You may send a single confirmation of the opt-out within five minutes, with no marketing content in it.
One piece is still on hold. The "revoke-all" provision at 47 C.F.R. § 64.1200(a)(10), which would make a revocation on one topic apply to all future messages from you on unrelated topics, was waived by the Consumer and Governmental Affairs Bureau on April 7, 2025 and extended again on January 6, 2026 to **January 31, 2027**. Building for a single global opt-out now is the cheaper path.
## STOP detection in Blue Reacher [#stop-detection-in-blue-reacher]
Every inbound message on every channel is scanned before it reaches your inbox. On a keyword match, the platform marks the contact `opted_out`, stops any active sequence, blocks future sends to that contact across every line and channel in your workspace, and fires a `contact.opted_out` webhook. A send to an opted-out contact is refused.
### Why keyword matching alone is not enough [#why-keyword-matching-alone-is-not-enough]
Keyword detection catches the per se reasonable list. It does not catch what people actually type in a conversational thread:
* "please take me off your list"
* "not interested, don't message me again"
* "wrong number"
* "who is this? remove me"
* "stpo" and "STOP." and "Stop pls"
* replies in Spanish, French, or any language your list speaks
Under the April 2025 rules every one of those can be a valid revocation, and if you get sued you carry the burden of showing it was not. Blue Reacher routes non-keyword messages that look like revocations into a review queue. Reviewing that queue is your job, not the platform's. Give it a named owner and a same-day service level, not a 10-business-day one.
Two operator rules that save money:
1. **Err toward honoring.** An ambiguous reply you honor costs you one lead. An ambiguous reply you ignore costs $500 to $1,500 per message afterward.
2. **Feed in off-platform revocations.** Opt-outs arrive by email, phone call, LinkedIn reply, and through your reps. Those count from the moment your company receives them. Push them into the platform the same day.
## Recording an off-platform revocation [#recording-an-off-platform-revocation]
**Purpose:** suppress a phone number when the revocation arrived somewhere other than an inbound message.
**Method and path:** `POST https://api.bluereacher.com/v1/opt-out`
**Auth:** send `Authorization: Bearer brk_your_api_key` and `Content-Type: application/json`.
```bash
curl -X POST https://api.bluereacher.com/v1/opt-out \
-H "Authorization: Bearer brk_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "phone": "+15551234567", "opted_out": true }'
```
```json
{ "success": true, "phone": "+15551234567", "opted_out": true, "already_opted_out": false }
```
The suppression is account-wide and immediate; there is no channel scoping, which is the right default. Idempotent: an identical repeat returns `already_opted_out: true` and is safe to treat as success. The contact must already exist (`404 contact_not_found` otherwise; create it first with `POST /v1/contacts`). Keep the verbatim text of the revocation as evidence in your own system of record; the suppression timestamp is returned as `opted_out_at` on `GET /v1/opt-out?phone=...`.
The full suppression model, including the deliberately harder re-subscribe path, is in [opt-out handling](/docs/platform/opt-outs).
## Quiet hours [#quiet-hours]
Federal rules at 47 C.F.R. § 64.1200(c)(1) bar telephone solicitations before 8 a.m. or after 9 p.m. in the called party's local time. Several states are stricter. Florida (Fla. Stat. § 501.059) and Oklahoma both cut the window to 8 a.m. through 8 p.m.
Three operational points:
* **Local time means where the person is, not what their area code says.** A number with a New York area code can belong to someone living in Los Angeles. Sending at 8:01 a.m. Eastern hits them at 5:01 a.m. Use the contact's known location when you have it, and hold the send until the latest safe hour across plausible zones when you do not.
* **Courts split in 2025 on whether quiet hours apply to messages sent with consent.** Do not build a program that depends on the permissive reading.
* **A safe operating default is 9 a.m. to 6 p.m. recipient local time, Monday through Friday.** For B2B outreach it also performs better.
## Identify yourself in the first message [#identify-yourself-in-the-first-message]
47 C.F.R. § 64.1200(d)(4) requires a telemarketer to give the caller's name, the entity on whose behalf the message is sent, and a contact phone number or address. The first message from a new line should name the person, name the company, and say how the recipient can reach you or stop the messages. "Hey, quick question" from an unknown number is both a compliance gap and the worst-performing opener in outbound.
## Record-keeping [#record-keeping]
Keep these, per contact, for at least five years. The TCPA statute of limitations is four years (28 U.S.C. § 1658), and internal do-not-call requests carry a five-year retention rule under 47 C.F.R. § 64.1200(d)(6).
* Consent record: disclosure text shown, timestamp, source, form version
* Full message history, both directions, with `created_at` timestamps
* Every revocation: verbatim text, receipt timestamp, timestamp when it was honored
* Your written internal do-not-call policy, available on request under 47 C.F.R. § 64.1200(d)(1)
* Proof of personnel training on that policy
* National Do Not Call Registry scrub logs, refreshed at least every 31 days
## Compliance checklist [#compliance-checklist]
* [ ] Counsel has reviewed your opt-in language and your message templates
* [ ] Written internal do-not-call policy exists and is available on request
* [ ] Everyone who touches outreach has been trained on it, with a dated record
* [ ] Consent evidence stored per contact: disclosure text, timestamp, source
* [ ] Purchased lists come with the vendor's disclosure text and consent records in writing
* [ ] National Do Not Call Registry scrub runs at least every 31 days
* [ ] Automatic STOP detection is on for every line and every channel
* [ ] The non-keyword revocation review queue has a named owner and a same-day service level
* [ ] Off-platform opt-outs (email, phone, reps) get pushed in via `POST /v1/opt-out` the same day
* [ ] Opt-outs apply across all lines and all channels, not just the line that received them
* [ ] Every revocation is honored well inside 10 business days, with both timestamps logged
* [ ] Send windows respect the recipient's actual local time, not their area code
* [ ] First message from a new line names the sender, the company, and a way to stop
* [ ] State rules checked for every state you send into, starting with Florida and Oklahoma
* [ ] Records retained five years
## Primary sources [#primary-sources]
* Telephone Consumer Protection Act of 1991, 47 U.S.C. § 227
* FCC implementing rules, 47 C.F.R. § 64.1200
* FCC Report and Order FCC 24-24, CG Docket No. 02-278, adopted February 15, 2024; revocation rules effective April 11, 2025
* FCC Consumer and Governmental Affairs Bureau waiver orders of April 7, 2025 and January 6, 2026, delaying 47 C.F.R. § 64.1200(a)(10) to January 31, 2027
* FCC TCPA Omnibus Declaratory Ruling and Order, July 10, 2015
* *Facebook, Inc. v. Duguid*, 592 U.S. 395 (April 1, 2021)
* *Insurance Marketing Coalition Ltd. v. FCC*, 11th Cir. No. 24-10277 (January 24, 2025)
* Florida Telephone Solicitation Act, Fla. Stat. § 501.059
* Oklahoma Telephone Solicitation Act, effective November 1, 2022
## One more time: this is not legal advice [#one-more-time-this-is-not-legal-advice]
Everything above is general information about how text outreach is regulated and how Blue Reacher's controls work. It is not legal advice, it does not cover every state law that may apply to you, and it does not create an attorney-client relationship. Rules and effective dates shift. Retain a lawyer who handles TCPA matters, have them review your program before you scale it, and re-check the FCC's docket at least twice a year.
# Content restrictions (/docs/platform/content-restrictions)
Two separate things restrict what you can send: the law, and what keeps a line alive. This page covers both, because a message can be perfectly legal and still cost you the line.
## Prohibited outright [#prohibited-outright]
These are not sendable on Blue Reacher lines. Accounts sending them are suspended, not warned.
1. Anything unlawful, including fraud, phishing, and impersonation of a person, brand, or government agency.
2. Illegal drugs and controlled substances, and cannabis from anyone without a state licence. Licensed cannabis operators are covered under elevated-risk categories below.
3. Firearms, ammunition, and weapon parts.
4. Sexually explicit content and adult services.
5. Gambling, sports betting, casinos, and lotteries.
6. High-risk financial offers: payday loans, debt relief and debt consolidation pitches, credit repair, get-rich-quick programs, and cryptocurrency investment solicitation.
7. Multi-level marketing and recruitment into income-claim programs.
8. Third-party lead generation where the recipient never gave consent to the entity actually sending.
9. Misleading sender identity, spoofed numbers, or claiming affiliation you do not have.
## Elevated-risk categories [#elevated-risk-categories]
These are sendable with tighter requirements. Talk to us before launching one so we can set the account up correctly.
Healthcare and anything touching patient information. Blue Reacher is not a HIPAA-covered arrangement by default, so do not send protected health information over a standard line. Appointment reminders that name no condition and no treatment are generally workable; anything clinical is not.
Financial services, insurance, and legal services. Legitimate and common on the platform, and also the categories that draw the most complaints when the list is cold. Consent quality matters more here than anywhere.
Political and advocacy messaging. Additional federal and state disclosure rules apply, and several states require specific disclaimers in the message body.
Licensed cannabis. Dispensaries, delivery services, brands and distributors that hold a state licence can message their own opted-in customers and buyers in states where they are licensed: order updates, support conversations and marketing. Get written approval from us before launch, and keep every message within your state's cannabis advertising rules, including age limits. iMessage delivers the message as written; SMS and RCS fallback still pass through carrier content filters that treat cannabis strictly, so expect weaker delivery on Android recipients.
Debt collection. Governed by the FDCPA and Regulation F on top of everything in the [compliance guide](/docs/platform/compliance), including required disclosures and contact-frequency limits.
## Patterns that damage lines regardless of content [#patterns-that-damage-lines-regardless-of-content]
Legality is the floor. These behaviors degrade a line even when the message is lawful and consented, because consumer messaging judges behavior before content.
1. A link in the first message to a new contact. The single most reliable way to reduce reply rates and increase reports. Send links after they reply.
2. Identical text across a large batch. If 200 messages open with the same six words, that is a detectable pattern. Vary the opener.
3. Bursts. Hundreds of sends in a short window from one line reads as automation. Pacing is applied automatically, and working around it is working against yourself.
4. All-caps, heavy emoji, and promotional punctuation like multiple exclamation marks or "$$$".
5. URL shorteners. Bit.ly-style domains are strongly associated with unwanted messaging. Use your own domain, see [link previews](/docs/platform/link-previews).
6. Messages with no personalization on a list where you know nothing specific about the person. That is a targeting failure that shows up as a reply-rate failure.
7. Re-contacting someone who ignored three messages. It converts near zero and generates reports.
## Media restrictions [#media-restrictions]
Size caps and supported types are in [message formatting](/docs/platform/message-formatting). Beyond format: do not send media that would be unwelcome from an unknown number, and do not attach documents to a first message. Both read as spam even when the content is benign.
## What happens when a violation is detected [#what-happens-when-a-violation-is-detected]
Prohibited-category content found on an account results in immediate suspension of sending and a review. Pattern violations, such as sustained bursts or a spike in reports, first trigger an automatic reduction in that line's daily capacity, then a pause on new conversations if the pattern continues. Line health mechanics are described in [rate limits](/docs/guides/rate-limits) and the recovery process is in [deliverability](/docs/platform/deliverability).
If you are unsure whether a campaign fits, ask before you send it. A pre-launch review costs an email; a suspended account costs the program.
# Deliverability best practices (/docs/platform/deliverability)
## What deliverability actually means [#what-deliverability-actually-means]
A delivery receipt tells you one thing: the message reached the recipient's thread. It does not tell you a person saw it, that the thread stays open, or that your line will still be landing messages next week.
Four different outcomes hide behind the word "delivered":
1. **Queued.** Accepted by the API.
2. **Delivered.** Landed in the recipient's thread.
3. **Read.** Opened by a human.
4. **Replied.** Someone answered.
Deliverability is your ability to keep reaching step 4, across hundreds of conversations, week after week, without a line going quiet. It is a rolling condition, not a per-message result.
```bash
curl -X POST https://api.bluereacher.com/v1/messages \
-H "Authorization: Bearer brk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"to": "+14155550187",
"message": "Hey Dana, saw you opened the second location last month. Quick question about front desk staffing."
}'
```
```json
{
"success": true,
"message_id": "d_1788116691413_milvzs",
"mode": "drip",
"queued": true,
"scheduled_for": "2026-08-31T09:14:51.413Z"
}
```
A queued send is an acceptance, not an outcome. Judge campaigns on replies. Treat everything before that as a leading indicator.
## What you control, what the platform handles [#what-you-control-what-the-platform-handles]
| Blue Reacher handles | You control |
| --------------------------------------------------------- | ---------------------------------------------- |
| Dedicated iMessage lines, provisioned and managed for you | Who you contact and where the list came from |
| No A2P registration required to start sending | How many new conversations you start per day |
| Line health monitoring and alerts on your account | The copy, and how specific it is to the person |
| SMS fallback when a recipient cannot receive iMessage | How fast you answer replies |
| Send pacing, quiet hours, and warm-up ramps | Honoring opt-outs in your own CRM |
| Opt-out capture on the line | Knowing when to stop |
Everything in the left column runs without you. Everything in the right column will sink a campaign if you get it wrong, and no amount of platform-side management compensates for a bad list or a reckless ramp.
## Why sending patterns beat message content [#why-sending-patterns-beat-message-content]
Consumer messaging is judged on behavior before anyone reads a word. Three pattern signals carry most of the weight:
* **Ramp.** How fast volume climbs on a line that is new or has been quiet.
* **Uniformity.** How similar your sends look to each other in timing, length, and structure.
* **Reaction.** What recipients do: reply, ignore, block, report.
A well-written message sent 400 times in one hour from a three-day-old line will do worse than an average message sent 40 times across a day. Content still matters, but it matters through humans: better copy earns more replies, and replies are the strongest positive signal you can produce.
Pattern rules the platform enforces for you: randomized gaps between sends on a line, never a fixed interval; cold deliveries clamped to the recipient's local daytime; rotation across lines. Pattern rules that stay yours: vary the opening line across a batch (200 messages starting with the same six words is a pattern), keep the first message short, and hold links back until they reply.
## Sustainable daily volume [#sustainable-daily-volume]
The capacity model counts one thing: brand-new cold contacts per line per day. Contacts who ever replied are unlimited. Contacts you have messaged before are unlimited. Replies never count. The enforced numbers, the Blue Reacher policy caps of 50 opted-in / 30 fully cold new contacts per line per day on a warmed line, and the warm-up ramp that leads up to them, are in [rate limits and sending capacity](/docs/guides/rate-limits).
These thresholds track Apple's observed enforcement behavior, not an arbitrary product decision: iMessage spam detection watches new-contact velocity against account trust, and the caps hold every line inside the envelope a heavy human texter occupies. Limits scale with engagement; a line that consistently earns replies and near-zero spam reports gains headroom over time.
Scale with lines, not with volume per line. 200 opted-in conversations a day is four warmed lines at 50 each, never one line at 200. Pushing a single line past the safe range costs a cooldown, and in the worst case the number itself.
## Reply rate is your health metric [#reply-rate-is-your-health-metric]
Reply rate is the only number that reports both things at once: your messages landed, and they landed on people who wanted them.
| 72-hour reply rate | Read it as |
| ------------------ | -------------------------------------------- |
| 8% and above | Healthy |
| 5% to 8% | Working. Tighten targeting. |
| 2% to 5% | Something is wrong. Diagnose before scaling. |
| Below 2% | Stop sending on that campaign. |
Slice the number per line, per list source, and per day. A pool average of 9% can hide one line sitting at 1%. The comparison that isolates the cause: same copy, same list, two lines. If line A returns 11% and line B returns 3%, the line is the problem. If both fall together, the list or the offer is.
Track opt-outs as a share of replies too. Under 5% is normal. Above 10% means you are messaging people who never belonged on the list, and that feeds directly into line health.
## List hygiene before a campaign [#list-hygiene-before-a-campaign]
Do all of this before the first send, not after the reply rate disappoints.
1. **Know the source and the date.** Every number traces to a source and a collection date. Anything older than 90 days gets re-verified or dropped.
2. **Normalize to E.164.** `+14155550187`. Strip extensions, punctuation, and duplicated country codes.
3. **Remove landlines and VoIP numbers.** They cannot hold a thread and they distort every metric you will use to make decisions.
4. **Dedupe across campaigns, not just inside this one.** The same person contacted twice in 30 days from two different lists is your most likely report.
5. **Apply your suppression list on every upload.** Past opt-outs, current customers, open conversations, anyone who asked to be left alone. Every time.
6. **Read 25 rows by hand.** If you cannot say in one sentence why this specific person would care, the list is wrong. Pacing does not fix a wrong list.
7. **Send a 50-contact test slice first.** Wait 24 hours. Read the replies. Then scale.
Step 7 is the one operators skip and the one that saves lines.
## Warning signs of degradation [#warning-signs-of-degradation]
| Signal | Usual cause | Act within |
| ------------------------------------------------------------------------------------- | ------------------------------------- | ------------------------ |
| One line's reply rate drops 40% below its own 7-day baseline, same list and copy | Line-level degradation | Same day |
| Reply rate falls across every line at once | List or offer problem | Pause new sends now |
| Delivery receipts normal, zero replies across 150+ sends on a line that used to reply | Degradation receipts do not show | Same day |
| Rising share of "who is this" and "how did you get my number" | Targeting or personalization too thin | Before the next campaign |
| Opt-outs above 3% of contacts messaged | Bad list source | Stop that source now |
| More messages falling back to `sms` than usual on a comparable list | Recipient mix, or line-level | 24 hours |
| Read receipts disappearing on a line that used to show them | Early degradation | 24 hours |
Clean receipts with no replies is the most misread signal in outbound messaging. Delivered is not landed.
## The recovery playbook [#the-recovery-playbook]
When a line degrades, run these in order.
1. **Stop new outbound on that line immediately.** Do not send a few more to confirm. More sends deepen the hole.
2. **Keep answering live threads.** Conversations already in progress are the healthiest traffic you have, and continuing them helps.
3. **Isolate the variable.** Two tests, 25 contacts each, 24 hours apart: the same copy on a known-good line, and the same list on a known-good line. One of them tells you whether the fault is the line, the list, or the copy.
4. **Rest the line 72 hours minimum.** No new conversations. Replies only.
5. **Re-warm from day one numbers.** 15 a day, then 30, then 50. Not from where you stopped. Full recovery takes 10 to 14 days and you cannot shorten it.
6. **Fix the input that caused it.** Rest treats the symptom. If you re-warm and go back to the same list at the same volume, you will be here again in two weeks.
If a line fails two re-warms, contact support and ask for a replacement. Two failures means the cause sits with the line, not with how you are sending.
## Operating checklist [#operating-checklist]
**Daily.** Compare reply rate per line against its own 7-day baseline. Clear the reply queue inside 4 business hours. Confirm no line went over its cap.
**Weekly.** Review opt-out rate by list source and retire any source above 3%. Rank lines side by side and flag the bottom one before it fails.
**Per campaign.** Run hygiene steps 1 through 7. Size the campaign to available line capacity before you upload, not after. Set the send window in recipient local time.
Reply speed belongs on this list. A reply that sits for six hours is worth far less than one answered in twenty minutes, to the deal and to the line.
# FAQ (/docs/platform/faq)
## Do I need an iPhone, a Mac, or an Apple ID? [#do-i-need-an-iphone-a-mac-or-an-apple-id]
No. Lines are dedicated iMessage lines that Blue Reacher provisions and manages. You send through the REST API or your CRM, and nothing runs on your hardware.
## Do I need A2P or 10DLC registration? [#do-i-need-a2p-or-10dlc-registration]
No. A2P registration is a carrier requirement for SMS traffic. iMessage traffic doesn't pass through carrier SMS filtering, so there's no registration process before you can start sending. Messages that fall back to SMS are still delivered through the platform without a registration step on your side.
## What happens when a recipient can't receive iMessage? [#what-happens-when-a-recipient-cant-receive-imessage]
The message falls back to SMS automatically, and every message and event carries the `delivery_channel` that actually carried it. You can also [check iMessage availability](/docs/api-reference/check-imessage) before sending, up to 100 numbers per call.
## How much can I send? [#how-much-can-i-send]
Conversations with engaged contacts are unlimited: anyone who has ever replied, and anyone you have messaged before, is never capped. The daily cap counts only brand-new cold contacts per line, and a warmed line runs at 50 a day for opted-in traffic or 30 fully cold, reached over a 16 to 20 day ramp. The enforced numbers are in [rate limits](/docs/guides/rate-limits), and the reasoning behind them is in [deliverability](/docs/platform/deliverability). Scaling volume means adding lines, not pushing one line harder.
## Is this TCPA compliant? [#is-this-tcpa-compliant]
The platform enforces the mechanical half: opt-out capture in plain language as well as STOP keywords, account-wide suppression at send time, and send windows. Consent for who you contact is yours, and the [compliance guide](/docs/platform/compliance) covers what that requires, including the April 2025 FCC revocation rules, quiet hours, and record-keeping.
## Which CRMs work? [#which-crms-work]
Eight platforms connect natively: [GoHighLevel](/docs/guides/gohighlevel) (two-way sync plus workflow actions), HubSpot, Salesforce, Close, Pipedrive, Follow Up Boss, Jobber, and Meta lead forms, each covered in [integrations](/docs/integrations). Any other CRM wires up through the [send API and webhooks](/docs/guides/crm-integrations), with contact `custom_fields` for record matching. There's also an [MCP server](/docs/guides/mcp-server) if you want an AI assistant operating your account.
## Can I send media, voice notes, and group messages? [#can-i-send-media-voice-notes-and-group-messages]
Yes. Media up to 25 MB per message with type specifics in [message formatting](/docs/platform/message-formatting), voice notes and tapbacks via [rich messaging](/docs/api-reference/rich-messaging), and group threads via [send group message](/docs/api-reference/send-group-message).
## How do opt-outs work? [#how-do-opt-outs-work]
Detected from keywords and plain language, enforced across every line on your account at send time, and exposed so you can sync suppression into your own systems. Details in [opt-out handling](/docs/platform/opt-outs).
## What does "delivered" actually mean? [#what-does-delivered-actually-mean]
That the message reached the recipient's thread. It doesn't mean read, and it doesn't mean the line is healthy. The metric that reports both is reply rate, and [deliverability](/docs/platform/deliverability) explains how to read it.
## Where do I start? [#where-do-i-start]
The [quickstart](/docs/guides/quickstart): API key, one send, status check, and a webhook for the reply, in about five minutes.
# Links and previews on iOS (/docs/platform/link-previews)
## The one rule that matters most [#the-one-rule-that-matters-most]
No link in a first message to a new contact. It reduces reply rates, increases reports, and is the most common self-inflicted cause of a degraded line. Send the link after they reply, when it answers a question they asked. Everything below applies to messages after that point.
## When iOS shows a rich preview [#when-ios-shows-a-rich-preview]
A rich preview, meaning the card with title, description, and image instead of a bare URL, depends on conditions on the recipient's device, not on anything the API sends. In practice the preview renders when:
1. The thread is established. Previews are much more likely once the recipient has replied at least once.
2. The sender is known. A saved contact gets previews that an unknown number does not, which is another argument for the contact-card flow in [sender identity](/docs/platform/sender-identity).
3. The URL sits on its own. A link at the very start or very end of a message previews more reliably than one buried mid-sentence.
4. There is exactly one URL. Multiple links in one message usually collapse to plain text.
5. The destination serves Open Graph tags and responds quickly to the fetch.
You cannot force a preview and you should not build a flow that depends on one. Write the message so it works with a bare URL, and treat the preview as a bonus.
## Make the destination preview-ready [#make-the-destination-preview-ready]
If you want the card to render well when it does render, the destination page needs `og:title`, `og:description`, and `og:image` set, an image around 1200 by 630 pixels, a fast first response, and no interstitial or redirect chain in front of it. Redirects through a tracking domain are the most common reason a preview fails on a page that otherwise has correct tags.
## Use your own domain [#use-your-own-domain]
Do not use public URL shorteners. Bit.ly-style domains are strongly associated with unwanted messaging, they suppress previews, and they give the recipient nothing to verify before tapping. A link on your own domain, ideally one that matches the company name in your message, does the opposite on all three counts. If you need click tracking, run it on a subdomain you own.
## Deep links [#deep-links]
A link into an app opens that app directly when the app is installed. Two shapes work:
Universal links, meaning normal HTTPS URLs on a domain that publishes an `apple-app-site-association` file, open the app when installed and the web page when not. This is the shape to use: it degrades gracefully.
Custom URL schemes, like `yourapp://path`, open the app but fail with an error when the app is not installed, and are not clickable in some contexts. Avoid them in outbound messages.
Common destinations that behave well in a thread: a calendar booking page, a payment link, a map link, a `tel:` number, and a `mailto:` address. Calendar and payment links in particular convert well as the second or third message, after the conversation has established what the link is for.
## Tracking without breaking things [#tracking-without-breaking-things]
Query parameters survive fine, so UTM tags work. Two constraints: keep the URL short enough to stay readable in a bubble, and never put personal data in the query string, since the URL is visible in the thread and in any screenshot the recipient takes or forwards.
## Testing before a campaign [#testing-before-a-campaign]
Send the exact message to a device in an established thread and a second device that has never received from the line. Check whether it previews, whether the URL is tappable, and how the message reads if the preview does not render. The second case is the one that ships.
# Message formatting and limits (/docs/platform/message-formatting)
Every message you send through Blue Reacher delivers on one of two channels: `imessage`, or `sms` when the recipient cannot take a blue bubble. The two do not accept the same content. This page tells you what each one carries, what gets rewritten on the way down, and what to build so a single payload works on both. (RCS columns below are ecosystem reference for recipients whose devices render rich SMS; the platform's own fallback is SMS.)
## Text [#text]
Message text goes in the `content` field as UTF-8. Whitespace is preserved, including line breaks (`\n`). There is no markdown rendering on any channel. Asterisks stay asterisks.
### Length caps [#length-caps]
| Channel | Hard cap on `content` | Behavior at the cap |
| -------- | --------------------- | -------------------------------------------------- |
| iMessage | 20,000 characters | Request rejected with `content_too_long` |
| RCS | 3,072 characters | Request rejected with `content_too_long` |
| SMS | 1,600 characters | Delivered as multiple segments, billed per segment |
Caps are not targets. For a first touch on a cold thread, stay between 90 and 320 characters. Past roughly 400 characters an iMessage bubble stops fitting on one screen, and reply rates on multi-screen opening messages drop hard. Save the long version for the reply.
### SMS segment math [#sms-segment-math]
SMS is the only channel where character count costs money. The encoding your text triggers decides how many characters fit per segment.
| Encoding | Single message | Per segment when split | Triggered by |
| -------- | -------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------- |
| GSM-7 | 160 chars | 153 chars | Plain ASCII letters, digits, basic punctuation |
| UCS-2 | 70 chars | 67 chars | Any emoji, curly quote, ellipsis character, em dash or en dash character, accented letter, non-Latin script |
One curly apostrophe pasted from a doc editor cuts your SMS capacity by more than half. Strip smart punctuation before you send: replace curly quotes with straight quotes, the ellipsis character with three periods, and long dash characters with a comma or a period. In GSM-7, the characters `^ { } [ ] ~ \ |` and the euro sign each count as two.
## Emoji [#emoji]
iMessage and RCS render the full current Unicode emoji set, including skin tone and multi-person sequences. SMS renders whatever the receiving handset supports, so emoji added after roughly 2021 can land as an empty box on older Android devices.
Two rules that matter operationally:
* One emoji in an SMS-fallback message costs you 90 characters of capacity. Decide whether it earns that.
* A skin tone modifier or a family sequence is several code points, not one character. A single such emoji can consume 7 to 11 characters of your budget.
Emoji in the `content` field never affect iMessage deliverability. They affect SMS cost and SMS rendering only.
## Media [#media]
Attach files with `media_url`. Blue Reacher fetches the file, stores a copy, and sends that copy. One `media_url` per message. To send several files, send several messages 2 to 4 seconds apart.
Your `media_url` must:
* Use HTTPS with a valid certificate
* Return HTTP 200 within 10 seconds, with no authentication challenge and no redirect chain longer than two hops
* Send an accurate `Content-Type` header (this is how the file type is detected, not the file extension)
* Stay reachable for at least 60 minutes after the request if it is a signed URL
### Supported types and size limits [#supported-types-and-size-limits]
| Type | Accepted formats | API max | Delivered on iMessage | Delivered on RCS | SMS fallback |
| -------- | -------------------------- | ------- | ------------------------------ | ------------------------------------ | --------------------------- |
| Image | JPEG, PNG, GIF, HEIC, WebP | 100 MB | Up to 100 MB, original quality | Up to 10 MB, recompressed above that | Downscaled to fit 500 KB |
| Video | MP4 (H.264 + AAC), MOV | 100 MB | Up to 100 MB | Up to 10 MB, transcoded | Replaced with a hosted link |
| Audio | M4A, CAF, MP3, WAV, AMR | 25 MB | Up to 25 MB | Up to 10 MB | Replaced with a hosted link |
| Document | PDF | 25 MB | Up to 25 MB | Replaced with a hosted link | Replaced with a hosted link |
The practical target for anything you want to look identical on all three channels is an image under 500 KB. A 1200 x 1200 JPEG at quality 80 lands near 250 KB and fills the bubble on every device. Animated GIFs should stay under 3 MB.
For video, encode H.264 baseline profile with AAC audio, keep it under 30 seconds, and keep the file under 10 MB. That clears iMessage and RCS intact and keeps the SMS link small enough to load on a weak connection.
### Voice memos [#voice-memos]
To render as a playable voice memo bubble on iMessage rather than a generic file attachment, send `.caf` or `.m4a` with mono audio at 22 kHz or lower. MP3 and WAV files attach as documents and lose the waveform and the inline play button.
Keep voice memos under 60 seconds. Anything past two minutes is usually not played. On RCS the same file arrives as an audio attachment with a plain play control. On SMS it becomes a link.
## Links [#links]
A bare URL in `content` renders as a rich preview card on iMessage. The preview is built on the recipient's device from your page's Open Graph tags, not by Blue Reacher. For the card to appear you need `og:title`, `og:description`, and an `og:image` under 1 MB on a page that returns 200 to an anonymous request. If your page sits behind a bot filter or a cookie wall, the card fails and the recipient sees a plain blue link.
Put the URL on its own line at the end of the message. A URL in the middle of a sentence still previews, but the card pushes the rest of your copy down.
### Why public URL shorteners hurt you [#why-public-url-shorteners-hurt-you]
Do not send `bit.ly`, `tinyurl.com`, or any other shared shortener domain. Four concrete reasons:
1. Reputation is shared. Carrier SMS filters score the domain, not your account. Every spam campaign that used that shortener this month is part of your score.
2. Several public shortener domains are already on US carrier blocklists. The message is dropped silently and you are billed for it.
3. Each redirect hop is another chance for the preview fetch to time out. Two hops means the iMessage card usually fails to build.
4. Recipients cannot see the destination. A hidden destination in an unsolicited message reads as a scam, which drives the report rate that governs your line health.
Use a subdomain you control, like `go.yourcompany.com`, with a valid certificate and exactly one redirect. Send it on warm threads first. Never put a link in the first message of a cold thread.
## What survives the fallback [#what-survives-the-fallback]
When a recipient is not reachable on iMessage, the message steps down to SMS. Content is rewritten on the way. Assume the SMS version is what a fallback recipient will see and write the copy so it still works there.
| Feature | iMessage | RCS | SMS |
| ----------------- | ------------------------------ | ------------------------------ | --------------------------------- |
| Plain text | Up to 20,000 chars | Up to 3,072 chars | Up to 1,600 chars, segmented |
| Emoji | Full support | Full support | Handset-dependent, costs capacity |
| Line breaks | Yes | Yes | Yes |
| Images | Up to 100 MB | Up to 10 MB | Downscaled to 500 KB |
| Animated GIF | Yes, animated | Yes, animated | First frame only |
| Video | Up to 100 MB inline | Up to 10 MB, transcoded | Hosted link |
| Voice memo bubble | Yes, with waveform | Audio file, plain player | Hosted link |
| PDF | Yes, inline | Hosted link | Hosted link |
| Link preview card | Yes | Yes | No, raw URL |
| Typing indicator | Yes | Yes | No |
| Read receipts | Yes, if recipient enables them | Yes, if recipient enables them | No |
| Delivery receipt | Yes | Yes | Carrier-level only |
| Inbound reactions | Tapbacks delivered as events | Reactions delivered as events | Text like "Liked ..." |
| Blue bubble | Yes | No | No |
Every Blue Reacher line is a dedicated iMessage line, managed for you, with no A2P registration required. The fallback channels carry the same content rules described above.
## Sending media [#sending-media]
```bash
curl -X POST https://api.bluereacher.com/v1/messages \
-H "Authorization: Bearer brk_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"to": "+14155550199",
"message": "Here is the 40 second walkthrough I mentioned.",
"media_urls": ["https://cdn.yourcompany.com/clips/walkthrough.mp4"]
}'
```
```json
{
"success": true,
"message_id": "d_1788120411520_a8x2mv",
"mode": "drip",
"queued": true,
"scheduled_for": "2026-08-31T10:02:41.520Z"
}
```
`media_urls` takes 1 to 10 public `https://` URLs (max 2048 characters each), on either the drip or instant lane, and `message` becomes optional when media is present. Media is fetched after the request returns; watch the `message.sent` / `message.failed` webhooks or poll [`GET /v1/status/{id}`](/docs/api-reference/messages).
Serve files over public HTTPS with no auth wall and a correct `Content-Type` header; an unreachable or non-media URL fails the send rather than delivering text as a partial result.
# Opt-out handling (/docs/platform/opt-outs)
Every inbound message on your account is checked for opt-out intent before it reaches your webhook. When a contact opts out, the suppression takes effect immediately and applies to your entire account. No line can message that contact again until you deliberately clear it.
This page covers what triggers an opt-out, what happens the moment one lands, the `contact.opted_out` and `contact.resubscribed` webhook events, the API for syncing suppression with your own systems, and why enforcement happens at send time instead of at list-build time.
## What triggers an opt-out [#what-triggers-an-opt-out]
Blue Reacher runs two checks on every inbound message.
**Keyword match.** Case-insensitive, punctuation-tolerant, whitespace-trimmed. A reply is a keyword opt-out when the whole message, after stripping punctuation and emoji, matches one of these:
| Keyword | Also matches |
| ----------- | --------------------------------- |
| STOP | `stop`, `Stop.`, `STOP!`, `stop ` |
| STOPALL | `stop all`, `stopall` |
| UNSUBSCRIBE | `unsubscribe`, `Unsubscribe.` |
| END | `end` |
| QUIT | `quit` |
| CANCEL | `cancel` |
| OPTOUT | `opt out`, `opt-out` |
| REVOKE | `revoke` |
**Plain-language match.** Most people do not type STOP. They write a sentence. Every inbound reply is classified for removal intent and the contact is suppressed when the message is a removal request, whatever the wording. These suppress:
* "please take me off your list"
* "don't text me again"
* "how did you get this number, remove me"
* "stop messaging me, I'm not interested"
* "unsubscribe me from whatever this is"
* "keep texting and I'm reporting this"
These do not suppress:
* "stop by the office next week" (keyword inside a sentence, not the whole message)
* "we're looking to stop using our current vendor" (same)
* "not interested" (a soft no, classified as a negative reply, not a removal request)
* "who is this?"
Soft negatives arrive on the inbound webhook with the message content and leave the contact sendable. If your policy is to suppress every negative reply, do that yourself by calling the opt-out API from your reply handler.
## What happens the moment a contact opts out [#what-happens-the-moment-a-contact-opts-out]
In the same processing run as the reply:
1. The contact is marked `opted_out: true` with a timestamp.
2. The suppression applies account-wide: every line, every campaign, every send path, including the API and any connected CRM's workflows.
3. The `contact.opted_out` webhook fires.
4. Any new send addressed to that contact is refused.
The identity being suppressed is the phone number. A contact who opts out on one of your lines cannot be reached from any other line on the account, including lines added later, on iMessage or SMS.
## The webhook events [#the-webhook-events]
Subscribe in the dashboard. `contact.opted_out` fires once per opt-out from a single enforcement chokepoint, so a STOP reply, an API opt-out and a dashboard action all emit identically:
```json
{
"event": "contact.opted_out",
"event_id": "5b1f8c2d-9e47-4a03-b6d8-1c7e9f2a4b60",
"timestamp": "2026-08-31T07:41:22.310Z",
"api_version": "2026-08-31",
"data": {
"contact_id": "c9b41d68-3a2f-4e07-b5d1-6f8e2a9c4d17",
"phone_number": "+15125550142",
"opted_out_at": "2026-08-31T07:41:22Z",
"source": "receive-message"
}
}
```
`source` names the enforcement path (an inbound reply, the API, a CRM sync). Write the suppression to your system of record in the same run, because this event is the only push signal you get, and deduplicate on `event_id`, because delivery is at-least-once.
The mirror event, `contact.resubscribed`, fires only when a genuinely opted-out contact sends a recognized START/UNSTOP re-subscribe phrase; a stray "start" in an ordinary conversation does not emit it. If you suppress work in your own system on opt-out, this is the event that deliberately undoes it.
## Syncing suppression from your systems [#syncing-suppression-from-your-systems]
When someone unsubscribes by email, tells a rep on a call, or gets flagged Do Not Contact in your CRM, push it in:
```bash
curl -X POST https://api.bluereacher.com/v1/opt-out \
-H "Authorization: Bearer brk_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "phone": "+15125550142", "opted_out": true }'
```
```json
{ "success": true, "phone": "+15125550142", "opted_out": true, "already_opted_out": false }
```
Idempotent: repeating the call returns `already_opted_out: true`. The contact must already exist; unknown phones return `404 contact_not_found` rather than silently creating a record, so create the contact first via [`POST /v1/contacts`](/docs/api-reference/contacts) when importing a suppression file.
Read suppression state before an import or a send window with `GET /v1/opt-out?phone=+15125550142`, which returns `opted_out`, `opted_out_at` and `known_contact`. `known_contact: false` with no opt-out on file is not consent; it only tells you this number has never asked you to stop.
## Clearing an opt-out [#clearing-an-opt-out]
Re-subscribing is deliberately harder than suppressing:
```bash
curl -X POST https://api.bluereacher.com/v1/opt-out \
-H "Authorization: Bearer brk_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "phone": "+15125550142", "opted_out": false, "confirm_resubscribe": true }'
```
Without `confirm_resubscribe: true` the call is refused with `400 resubscribe_confirmation_required`. A successful re-subscribe of a contact who was actually opted out fires `contact.resubscribed`, and no message is sent to the contact. Do it only when the contact explicitly asked to hear from you again, and keep your own record of that consent; Blue Reacher does not evaluate it. You own it.
## Why enforcement happens at send time [#why-enforcement-happens-at-send-time]
Filtering at list-build time is the obvious design: clean the list, then send it. It breaks in four predictable ways.
**Lists go stale.** An opt-out that lands at 10:02 does nothing to a list filtered at 09:00. The larger your campaign, the longer the window between build and last send.
**Opt-outs are account-wide, lists are not.** A contact who opts out on one line is usually sitting in three other lists for other campaigns. Filtering per list means every list has to learn about every opt-out independently. One missed sync is a message you cannot take back.
**Scheduled and multi-step sends fire later.** Follow-ups, drips, and retries execute hours or days after the list was assembled. A build-time filter cannot see a decision the contact makes in between.
**Failure directions are not equal.** A list-time filter that misses someone fails open: the message ships and you learn about it from the recipient. A send-time check fails closed: the send is refused and you see it in your logs. A refused send is recoverable. A delivered one is not.
Send-time enforcement also preserves the record. Deleting suppressed people from your list destroys your evidence that you honored the request. Keeping the contact with `opted_out` and `opted_out_at` means you can show exactly when someone asked you to stop and what happened next.
## Checklist [#checklist]
* Handle `contact.opted_out` and write to your system of record in the same run. Deduplicate on `event_id`.
* Treat a refused send to an opted-out contact as terminal. No retry, no rerouting through another line.
* Check `GET /v1/opt-out` on imported lists, and push CRM, email and phone unsubscribes into `POST /v1/opt-out` on a schedule, not only at import time.
* Never clear an opt-out without a consent record you can produce on request.
# Security and data handling (/docs/platform/security-and-data)
## Transport [#transport]
All API traffic runs over HTTPS with TLS 1.2 or higher. Plain HTTP requests are rejected rather than redirected. Webhook deliveries to your endpoint are made over HTTPS only, and your endpoint must present a valid certificate.
Every webhook payload carries an `x-bluereacher-signature` header containing an HMAC-SHA256 of the raw request body, computed with your endpoint's signing secret. Verify it before trusting a payload, and compare using a constant-time comparison. The verification steps and a working handler are in [webhooks](/docs/api-reference/webhooks).
## Authentication [#authentication]
API keys are workspace-scoped bearer tokens, prefixed `brk_`, and are shown once at creation. A key grants full API access to its workspace, so treat it as a production credential: server-side only, never in browser or mobile client code, never in a repository, rotated on staff changes and on any suspected exposure. Details and the rotation flow are in [authentication](/docs/guides/authentication).
## What data we hold [#what-data-we-hold]
Running the service requires holding the contents of the conversations you send and receive: contact phone numbers and any contact fields you supply, message bodies and media, delivery and read status, opt-out records with their evidence, and line configuration. Request metadata, including timestamps, endpoints, status codes, and trace IDs, is retained for operations and support.
## Retention and deletion [#retention-and-deletion]
Message and contact data persists for the life of your workspace so your conversation history stays intact. You control removal at the record level: deleting a contact through the [contacts API](/docs/api-reference/contacts) removes the contact record and its associated message history.
Opt-out records are the deliberate exception. They are retained after a contact is deleted, because a suppression list that forgets is worse than useless: re-importing a list would resurrect someone who asked you to stop. This also matches the record-keeping obligations in the [compliance guide](/docs/platform/compliance), which call for five-year retention of do-not-call requests.
On account closure, workspace data is deleted after a 30-day window during which an export can be requested. Suppression records are retained beyond that point for the same reason.
## Your own retention obligations [#your-own-retention-obligations]
Compliance record-keeping is yours, not ours, and it outlives your account. Export consent records, message history, and revocation evidence into your own system on a schedule rather than treating the platform as the archive of record. The five-year retention target and the specific items to keep are listed in the [compliance guide](/docs/platform/compliance).
## Subprocessors [#subprocessors]
Blue Reacher runs on infrastructure providers for hosting, database, storage, and messaging delivery, and uses standard operational tooling for error monitoring and support. The current subprocessor list, with each provider's role and processing location, is available on request and is provided as an appendix to the data processing agreement. Contact support for the current version rather than relying on a page that could go stale between reviews.
## Data processing agreement [#data-processing-agreement]
A DPA is available for customers who need one, including standard contractual clauses where the transfer requires them. Request it through support.
## What we do not do [#what-we-do-not-do]
We do not sell customer data. We do not use the contents of your conversations to build or train models. We do not share your contact lists between workspaces. Lines are dedicated per workspace, so your sending is not pooled with another customer's traffic, which is both a security property and the deliverability property described in [Apple and iMessage](/docs/platform/apple-and-imessage).
## For a security review [#for-a-security-review]
Reviewers usually want the same five things. Send them: this page, the [authentication](/docs/guides/authentication) page for credential handling, the [webhooks](/docs/api-reference/webhooks) page for signature verification, the [compliance guide](/docs/platform/compliance) for consent and retention practice, and the DPA with its subprocessor appendix from support. Anything they ask for beyond that, send it to us directly and we will answer it rather than have you guess.
# Sender identity (/docs/platform/sender-identity)
## What the recipient sees by default [#what-the-recipient-sees-by-default]
A message from a line the recipient has never contacted shows as the phone number, with no name and no photo, in the same visual style as any other unknown sender. Blue bubbles, read receipts, and typing indicators all work from the first message; the display name does not appear on its own.
Three things change that, and only one of them is under your direct control.
## 1. Identify yourself in the message [#1-identify-yourself-in-the-message]
The first line of a first message should name the person and the company. This is a legal requirement under 47 C.F.R. § 64.1200(d)(4), covered in the [compliance guide](/docs/platform/compliance), and separately it is the difference between a reply and a report. "Hey, quick question" from an unknown number performs worse than any other opener and gives the recipient nothing to verify.
A first message that works:
> Hi Dana, it's Marcus from Northside Realty. You asked about the Oak Street listing last week. Want the updated numbers?
Name, company, reason, question. Under 300 characters, no link.
## 2. Contact cards [#2-contact-cards]
Your line can send a contact card, which lets the recipient save you with your name, company, and photo in one tap. After they save it, your name and photo appear on every future message in that thread, and on any call.
Send it after they reply, not before. An unsolicited contact card as a first message reads as spam; the same card sent when someone asks who you are converts almost every time. The endpoint is in [rich messaging](/docs/api-reference/rich-messaging).
The pattern that works:
1. First message identifies you in text.
2. They reply.
3. You answer their question, then offer the card: "I'll send my contact so you have it."
4. Every message after that shows your name and photo.
## 3. Whether they already have you saved [#3-whether-they-already-have-you-saved]
If your number is already in the recipient's contacts, from a form fill, a prior call, or a business card, your name shows immediately. This is one of the reasons reactivation campaigns to past customers outperform cold outreach on the same offer: the sender is already known.
## Consistency across the account [#consistency-across-the-account]
Use one line per persona, not one line per campaign. If Marcus messages a lead from line A in March and line B in June, the thread history is split, the saved contact does not match, and the recipient experiences two strangers. Where a team shares an account, map each rep to a line and keep it stable. Line assignment is visible on the [line object](/docs/api-reference/lines).
## What not to do [#what-not-to-do]
Do not claim to be a person who does not exist, a company you are not, or an affiliation you do not have. Misleading sender information is separately actionable in several states, including Washington, see [state texting laws](/docs/platform/state-laws). Beyond the legal exposure, it collapses on the first reply.
Do not change the identifying details between messages in the same thread. The recipient scrolls up.
## Phone numbers only [#phone-numbers-only]
Blue Reacher lines are phone numbers. There is no email-address sender option, because a message from an email-based Apple ID lands differently on the recipient's device, cannot receive SMS fallback, and cannot be called back. Every line on your account is a real, callable number that can carry the whole conversation on a single identity.
# State texting laws (/docs/platform/state-laws)
## Read this first: not legal advice [#read-this-first-not-legal-advice]
This page is general information for operators, not legal advice, and Blue Reacher is not your lawyer. State legislatures and courts change these rules every session. Have counsel review your program against the states you actually send into. The federal baseline is covered in the [compliance guide](/docs/platform/compliance).
## Why state law matters more than federal law here [#why-state-law-matters-more-than-federal-law-here]
Federal TCPA claims got harder after [Facebook v. Duguid](/docs/platform/compliance) narrowed the autodialer definition. State mini-TCPA statutes filled the gap. Several of them define restricted equipment more broadly than the federal statute, carry their own damages, and let individuals sue directly. A campaign that clears the TCPA can still generate claims in Florida, Oklahoma, Washington, or Maryland.
The practical rule: your compliance floor is set by the strictest state on your list, not by federal law.
## The states that change how you send [#the-states-that-change-how-you-send]
### Florida [#florida]
The Florida Telephone Solicitation Act (Fla. Stat. § 501.059), as amended in 2021 and narrowed in 2023, restricts calls and texts made with an automated system for selecting or dialing numbers. Key operational points:
1. Quiet hours are 8 a.m. to 8 p.m. local time, tighter than the federal 8 a.m. to 9 p.m.
2. No more than three commercial messages within 24 hours on the same subject.
3. Private right of action with statutory damages of $500 per violation, trebled for willful violations.
The 2023 amendment (HB 761) added a requirement that a recipient reply STOP for certain claims to proceed, which reduced but did not eliminate exposure.
### Oklahoma [#oklahoma]
The Oklahoma Telephone Solicitation Act, effective November 1, 2022, mirrors Florida's structure closely: 8 a.m. to 8 p.m. local time, a private right of action, and $500 per violation with trebling. Treat Florida and Oklahoma as one rule set when you build send windows.
### Washington [#washington]
Washington's Commercial Electronic Mail Act and its texting provisions (RCW 19.190) make an unsolicited commercial text an actionable practice under the state Consumer Protection Act, with $500 per message available. Washington also treats misleading sender information as a separate violation, which is why sender identification matters, see [sender identity](/docs/platform/sender-identity).
### Maryland, New York, and others [#maryland-new-york-and-others]
Maryland's Stop the Spam Calls Act of 2023 tracks the vacated federal one-to-one consent concept at state level, meaning consent obtained by a lead vendor for a list of unnamed sellers is unlikely to hold. New York's General Business Law § 399-p regulates automated messages and requires disclosure of the calling entity. Both reward the same behavior: named-seller consent and clear identification.
### California [#california]
California does not have a texting-specific mini-TCPA, but Cal. Bus. & Prof. Code § 17538.41 restricts unsolicited commercial text messages, and the CCPA and CPRA govern the contact data itself. If your list came from a data broker, California residents on it carry data-rights obligations independent of texting law, including deletion requests.
## Practical operating rules [#practical-operating-rules]
Rather than maintaining fifty rule sets, most operators run one conservative program that clears all of them:
1. Send 9 a.m. to 6 p.m. recipient local time, weekdays. Inside every state's window with margin, and it performs better anyway.
2. Cap at three commercial messages per contact per rolling 24 hours, and in practice far fewer, see [follow-up cadence in deliverability](/docs/platform/deliverability).
3. Identify the sender and the company in the first message from a new line.
4. Use named-seller consent. Consent that lists "our marketing partners" fails in Maryland and is weak everywhere.
5. Honor any revocation across every line and channel in your account, which the platform enforces automatically, see [opt-out handling](/docs/platform/opt-outs).
6. Keep consent and revocation records for five years.
## Determining recipient location [#determining-recipient-location]
Area code is not location. A number with a 212 area code can belong to someone living in Miami, which puts your 8:30 p.m. Eastern send inside Florida's restricted window at 8:30 p.m. local. Where you hold a billing address, a shipping address, or a stated location, use it. Where you do not, hold sends to the narrowest window that is safe across the plausible zones, which in practice means finishing by 6 p.m. Pacific.
## Before you launch in a new state [#before-you-launch-in-a-new-state]
* [ ] Check whether the state has a mini-TCPA with a private right of action
* [ ] Check the quiet-hours window and take the stricter of state and federal
* [ ] Check whether the state requires specific disclosure language in the first message
* [ ] Check per-day message caps
* [ ] Confirm your consent language names your company specifically
* [ ] Have counsel confirm all of the above, because this page is not legal advice
## Primary sources [#primary-sources]
* Florida Telephone Solicitation Act, Fla. Stat. § 501.059, as amended by HB 761 (2023)
* Oklahoma Telephone Solicitation Act, effective November 1, 2022
* Washington RCW 19.190 and the Washington Consumer Protection Act
* Maryland Stop the Spam Calls Act of 2023
* New York General Business Law § 399-p
* California Bus. & Prof. Code § 17538.41
# Troubleshooting (/docs/platform/troubleshooting)
Each section starts from the symptom you're seeing. Error-code specifics live in [errors and status codes](/docs/guides/errors); this page is the diagnostic layer on top.
## A message sits in pending [#a-message-sits-in-pending]
Pending means accepted and scheduled, not sent. Three usual causes, in order of likelihood:
1. The drip delay and send window. A drip send schedules about 10 minutes out. If you passed `window_start_hour` / `window_end_hour`, it also waits for that window: with 9 and 18, a message queued at 8pm sends the next morning.
2. Pacing. Sends on a line space out at randomized intervals, so a large batch drains over hours by design. `queue_depth` on [`GET /v1/devices`](/docs/api-reference/lines) shows the backlog.
3. Capacity. A new cold contact past the line's `daily_cap` waits for headroom rather than failing.
If a message has sat through an open window with `available_today` positive on an online line, that's a real fault: contact support with the `message_id`.
## The line is out of capacity [#the-line-is-out-of-capacity]
`available_today: 0` on [`GET /v1/devices`](/docs/api-reference/lines) means the line has reached today's new-contact cap. Remember what is not capped: replies and anyone you have messaged before send fine regardless. Excess cold sends queue for the next window rather than erroring, so nothing is lost; if you hit the ceiling regularly, the fix is more lines, not more pressure. The split between API rate limits (`429 rate_limited`, retry after `retryAfter`) and sending capacity is explained in [rate limits](/docs/guides/rate-limits).
## A send to one contact is refused [#a-send-to-one-contact-is-refused]
If the contact opted out, on any line in your account, at any point in the past, sends to them are refused. Don't retry and don't reroute through another line; remove them from the campaign and sync your CRM suppression. [Opt-out handling](/docs/platform/opt-outs) covers how suppression propagates.
## 409 no\_online\_device [#409-no_online_device]
An instant send needs an online line and none was eligible. Check `status` on `GET /v1/devices`; a drip send would have queued instead, which is usually the right fallback for anything that can wait ten minutes.
## Deliveries look normal but replies stopped [#deliveries-look-normal-but-replies-stopped]
The most misread signal in outbound messaging. Delivery receipts stay clean while a degraded line quietly stops landing in front of humans. Compare the line's reply rate to its own 7-day baseline: a 40% drop on the same list and copy is a line-health signal, and the platform's own monitoring (`device.health_changed`, state `imessage_collapse`) watches for exactly this. Zero replies across 150+ sends on a line that used to reply is the same signal, louder; flag it to us the same day.
## More messages falling back to SMS than usual [#more-messages-falling-back-to-sms-than-usual]
First check the list: a different source mixes in more non-iMessage recipients, and [`POST /v1/capability`](/docs/api-reference/check-imessage) before sending settles it. If the list is comparable to previous sends, treat it as a line-health signal; a line under an Apple-side throttle shows exactly this pattern, and the cooldown machinery in [rate limits](/docs/guides/rate-limits) is the response.
## Webhooks stopped arriving [#webhooks-stopped-arriving]
Work through it in this order:
1. Your endpoint must return a `2xx` within 5 seconds. Three consecutive failed deliveries auto-disable the endpoint; re-enable it from the dashboard delivery log.
2. Check signature verification. A deploy that changed your secret handling silently drops every event as invalid. The verification steps are in [webhooks](/docs/api-reference/webhooks).
3. Use send-test and replay from the dashboard delivery log to confirm end to end.
While diagnosing, poll [`GET /v1/status/{id}`](/docs/api-reference/messages) so nothing is lost.
## A retried request sent twice [#a-retried-request-sent-twice]
It didn't, if you send an `Idempotency-Key` header on every send. A repeat within 24 hours replays the original response with `Idempotent-Replay: true`: treat it as success. Reusing a key with a different body is refused with `409 idempotency_payload_mismatch`, so derive the key from the message itself. If you're not sending the header yet, start; it's the difference between safe and unsafe retries.
## Still stuck [#still-stuck]
Send support the `message_id`, the `error_code` from the response, and the timestamp. Your key's own call log is also visible at [`GET /v1/usage`](/docs/api-reference/overview), which shows per-endpoint success rates and often answers "did my request even arrive".
# Appointment reminders (/docs/recipes/appointment-reminders)
## Why text and not email [#why-text-and-not-email]
A reminder only works if it is seen before the appointment. Email reminders sit unread in a promotions tab; a text is read within minutes. The measurable difference is not the reminder, it is the reply: a text reminder gets answered, so you find out about a conflict the day before instead of at the appointment time.
## Consent [#consent]
Reminders for an appointment the person booked with you are transactional, sent under prior express consent, which is a lower bar than marketing. Do not attach an offer to them. The moment a reminder carries a promotion it becomes a marketing message and needs marketing consent, per the [compliance guide](/docs/platform/compliance).
## The build [#the-build]
Two sends per appointment: 24 hours before, and 2 hours before.
```javascript
// scheduled job
for (const appt of await crm.appointmentsStartingIn({ hours: 24, window: 15 })) {
await send(appt, `Hi ${appt.firstName}, it's Sam from Northside. You're booked in for ${appt.timeLabel} tomorrow with ${appt.staffName}. Still good, or want to move it?`);
}
for (const appt of await crm.appointmentsStartingIn({ hours: 2, window: 15 })) {
if (appt.confirmed) continue;
await send(appt, `Hi ${appt.firstName}, see you at ${appt.timeLabel} today. ${appt.locationHint}`);
}
async function send(appt, content) {
await fetch("https://api.bluereacher.com/v1/messages", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BLUEREACHER_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
to: appt.phone,
message: content,
send_mode: "instant",
metadata: { crm_id: appt.recordId, recipe: "appointment-reminder" }
})
});
}
```
## The question that does the work [#the-question-that-does-the-work]
"Still good, or want to move it?" is the whole recipe. A reminder that only informs gets no reply and changes nothing. A reminder that asks gets one of three useful answers: yes (confirmed, and now they have committed out loud), no (you recover the slot with a day's notice instead of losing it), or silence (a signal worth acting on with the 2-hour message).
Handle the reply:
```javascript
if (e.event === "message.received") {
const text = e.data.content.toLowerCase();
const recordId = await crmIdForPhone(e.data.phone_number);
if (/^(yes|yep|yeah|confirmed|see you|👍)/.test(text)) {
await crm.update(recordId, { confirmed: true });
} else {
await notify(`Appointment reply needs a human: "${e.data.content}"`);
}
}
```
Auto-confirm only on unambiguous replies. Anything else goes to a person, because "yes but can we do 3 instead" is not a confirmation and a bot that treats it as one causes the no-show it was meant to prevent.
## Include what they need to show up [#include-what-they-need-to-show-up]
Address, floor, parking, what to bring, who to ask for. A reminder that says only the time assumes the person still has the booking email, and many do not. One extra line here removes a real cause of no-shows.
## Do not over-send [#do-not-over-send]
Two reminders. A third is noise, and reminders go to your best relationships, which are exactly the ones not worth annoying. If the appointment is more than a week out, add one confirmation at booking time and keep the 24-hour and 2-hour pair.
## What to measure [#what-to-measure]
No-show rate before and after, confirmation reply rate on the 24-hour message, and how many appointments get moved rather than missed. Moved appointments are wins, not losses, and reporting them as losses hides the value of the whole build.
# Recipes (/docs/recipes)
Each recipe is a whole outcome, not a feature demo: the trigger, the timing that works, the code, and what breaks.
| Recipe | What it fixes | Build time |
| ------------------------------------------------------------ | ----------------------------------------- | ------------ |
| [Speed to lead](/docs/recipes/speed-to-lead) | New leads going cold before anyone calls | An hour |
| [No-show recovery](/docs/recipes/no-show-recovery) | Booked appointments that never happen | An hour |
| [List reactivation](/docs/recipes/list-reactivation) | Old leads and past customers sitting idle | An afternoon |
| [Appointment reminders](/docs/recipes/appointment-reminders) | Reminders that get ignored in email | An hour |
## Build them in this order [#build-them-in-this-order]
Speed to lead first. It works on traffic you already pay for, it needs no list, and the effect shows up within a day.
No-show recovery second. Same property: the people already raised their hand, and every recovered appointment is revenue that was already booked.
Appointment reminders third, because they reduce the no-shows you then have to recover.
List reactivation last. Highest volume, highest risk, and the one that most needs the other three working first so you know your reply handling can absorb it.
## What every recipe assumes [#what-every-recipe-assumes]
An API key, a webhook endpoint that verifies signatures, and your CRM record ID stamped into contact `custom_fields` (plus send `metadata`) so replies resolve to the right record. The setup is in [integrations](/docs/integrations), and the reasoning is in [key concepts](/docs/guides/key-concepts).
Two rules carry across all four: no link in the first message, and a human answers replies fast. Neither is negotiable, and both are explained in [deliverability](/docs/platform/deliverability).
# List reactivation (/docs/recipes/list-reactivation)
## The problem and the risk [#the-problem-and-the-risk]
Every business is sitting on leads that went cold and customers who stopped buying. Reactivation is the highest-volume campaign most accounts run, which makes it the one most likely to damage a line if it is run carelessly.
The failure pattern is always the same: a big old list, uploaded at once, sent from one line, with a generic "just checking in" message. Reply rate collapses, opt-outs spike, the line degrades, and the account concludes iMessage does not work.
Run it the way below instead.
## Eligibility, before anything else [#eligibility-before-anything-else]
Drop, do not send:
1. Anyone whose last activity is over 12 months old, unless you can re-verify the number.
2. Anyone on your suppression list, past opt-outs, current customers you do not want in this campaign, active conversations.
3. Any number you cannot trace to a source and a collection date.
4. Landlines and VoIP numbers.
5. Anyone contacted in the last 30 days on any campaign.
Rule 2 deserves the extra pass. Old lists are where forgotten opt-outs live, and messaging one is the single worst send you can make: it is a compliance violation and a report at the same time. Apply your suppression list on every upload, per the [compliance guide](/docs/platform/compliance).
## Batch sizing [#batch-sizing]
Reactivation volume almost always exceeds one day's cold capacity. Do the arithmetic before you start. One nuance works in your favor: contacts you have messaged before are returning contacts and are not capped, so a true reactivation list (people you texted months ago) often costs far less capacity than its row count suggests. Only never-messaged numbers draw down the cold caps.
```javascript
const { devices } = await listLines(); // GET /v1/devices
const capacityToday = devices
.filter(l => l.status === "online")
.reduce((sum, l) => sum + l.available_today, 0);
const landsToday = eligible.slice(0, capacityToday); // the rest queues
```
Caps and the `available_today` field are documented in [rate limits](/docs/guides/rate-limits) and on the [line object](/docs/api-reference/lines). Drip sends load-balance across lines automatically, and cold volume past today's capacity queues for the next window rather than failing; run the campaign over as many days as the arithmetic requires. A 4,000-contact cold list on a handful of lines is a multi-day campaign by design.
## Test slice first, always [#test-slice-first-always]
Send 50 contacts. Wait 24 hours. Read every reply by hand. Only then scale.
What you are looking for: are people confused about who you are, is the offer landing, are you getting "who is this" replies, are the opt-outs coming in above 3 percent. A test slice costs you a day and routinely saves the campaign.
## The message [#the-message]
The single most important requirement is a real reason for the message. "Just checking in after a while" gets ignored. A price change, a new offer, availability, a genuine result gets replies.
For cold leads:
> Hey \[first name], \[your name] from \[company]. We spoke about \[service] back in \[month]. Since then \[one concrete new thing]. Worth a fresh look?
For past customers:
> Hi \[first name], it's \[your name] at \[company]. It's been a while since \[last purchase]. We've got \[specific new thing] now and I thought of you. Want the details?
Name yourself and the company every time. On a reactivation list the recipient genuinely may not remember you, and an unidentified message to someone who half-remembers you is the fastest route to a report. Vary the opening line across the batch, and no link in the first message.
## Send and stop conditions [#send-and-stop-conditions]
```javascript
for (const contact of batch) {
await sendMessage({
to: contact.phone,
message: personalize(contact),
metadata: { crm_id: contact.recordId, recipe: "reactivation" }
});
}
```
Note what is missing: no line pinning and no manual pacing. Drip-lane sends load-balance across your lines and space themselves out; a reactivation batch that exceeds today's cold capacity queues for the next window instead of failing. Contacts you have messaged before are returning contacts and do not count against the cold cap at all, which is exactly what makes reactivation the cheapest campaign you can run.
Stop the whole campaign, not just the line, if any of these fire:
1. Opt-out rate above 3 percent of contacts messaged.
2. Reply rate below 2 percent after 300 sends.
3. A rising share of "who is this" or "how did you get my number" replies.
Each of these says the list is wrong, and pushing more volume through a wrong list damages every line it touches. The full signal table is in [deliverability](/docs/platform/deliverability).
## One follow-up, then done [#one-follow-up-then-done]
One follow-up 4 to 5 days later, then stop:
> \[First name], last one from me. If \[thing] isn't a priority right now, no problem at all. If it is, one reply and I'll send \[the concrete thing].
Reactivation lists get two messages total. These are people who already ignored you once; a third message converts near zero and generates the opt-outs that hurt your lines.
## What to measure [#what-to-measure]
Reply rate and opt-out rate per list source, not just campaign-wide. Reactivation lists are usually several sources stacked together, and one bad source hiding inside a decent average is the most common thing this campaign teaches you. Retire any source above 3 percent opt-outs and keep the rest.
# No-show recovery (/docs/recipes/no-show-recovery)
## The problem [#the-problem]
A booked appointment that does not happen costs more than a lead that never books, because you paid to acquire it and then held time for it. Most teams send one apologetic email and move on.
Recovery works because the person already wanted the thing. They just missed a slot.
## The build [#the-build]
Trigger when an appointment passes without being marked attended, send inside the hour, offer times in the thread.
```javascript
// runs on your scheduler, e.g. every 15 minutes
for (const appt of await crm.findMissedAppointments({ withinMinutes: 90 })) {
await fetch("https://api.bluereacher.com/v1/messages", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BLUEREACHER_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
to: appt.phone,
message: `Hey ${appt.firstName}, we had you down for ${appt.timeLabel} today and missed you. No stress, things come up. Want me to grab you a spot tomorrow instead?`,
send_mode: "instant",
metadata: { crm_id: appt.recordId, recipe: "no-show-recovery" }
})
});
}
```
Tone carries this one. No guilt, no "you missed your appointment" formality, no cancellation-policy language. The message that rebooks is the one that assumes something reasonable happened.
## Timing [#timing]
First message within an hour of the missed slot, while it is still today's problem and they may still be free. Second message two days later if there is no reply. Then stop.
Sending the next morning instead of the same hour measurably reduces recovery, because by then the person has re-planned their week.
## Offer times in the thread, not a link [#offer-times-in-the-thread-not-a-link]
The instinct is to send the booking link. It performs worse. Ask the question, and when they reply, offer two specific slots:
> Great. I've got Thursday 10am or Friday 2pm, either work?
Two options gets an answer; an open calendar link gets a "let me look at my week". Send the confirmation link after they pick, which is also the point where a link belongs, per [links and previews](/docs/platform/link-previews).
## Feed the outcome back [#feed-the-outcome-back]
On reply, cancel the sequence and flag the record for a human. On rebooking, write the new appointment back to the CRM so the recovery is attributable:
```javascript
if (e.event === "message.received") {
const recordId = await crmIdForPhone(e.data.phone_number);
await crm.update(recordId, { noShowReplied: true, lastReply: e.data.content });
await sequences.cancel({ recordId });
await notify(`No-show replied: ${e.data.content}`);
}
```
## Stop at two [#stop-at-two]
Two messages. Someone who missed the appointment and ignored two follow-ups is telling you something. Move them to the reactivation list and come back in a quarter with a genuinely new reason, per [list reactivation](/docs/recipes/list-reactivation).
## Prevention beats recovery [#prevention-beats-recovery]
The cheapest no-show is the one that does not happen. Run [appointment reminders](/docs/recipes/appointment-reminders) alongside this recipe; teams that add reminders usually cut the volume this recipe has to handle substantially.
## What to measure [#what-to-measure]
Rebooking rate on messaged no-shows, and the split between message one and message two. If message two is doing most of the work, your first message is going out too late.
# Speed to lead (/docs/recipes/speed-to-lead)
## The problem [#the-problem]
A lead fills your form at 9:14 and a rep calls at 4:30. By then they have filled two other forms and spoken to somebody else. The lead was not bad; the response was slow.
Text beats a call here for a simple reason: it gets answered. The message arrives while they are still on your site.
## The build [#the-build]
Trigger on form submission, send within 60 seconds, notify a human on reply, stop the sequence on any reply.
```javascript
// POST from your form / CRM / webhook, whatever fires on new lead
export default async function newLead(req, res) {
res.status(200).end();
const { firstName, phone, source, recordId } = req.body;
await fetch("https://api.bluereacher.com/v1/messages", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BLUEREACHER_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
to: phone,
message: `Hi ${firstName}, it's Marcus from Northside. Just saw your ${source} enquiry come through. What's the best number to reach you on, or is this it?`,
send_mode: "instant",
metadata: { crm_id: recordId, recipe: "speed-to-lead" }
})
});
}
```
That opener does three things: identifies the sender and company, proves the message is about something the person just did, and asks a question that is trivially easy to answer. A question someone can answer in one word gets answered.
Two implementation notes. `send_mode: "instant"` is right here because the lead just raised their hand and a 10-minute drip delay defeats the recipe; instant sends are velocity-capped at 10/min per key and count against each line's daily new-contact cap, which speed-to-lead volume fits comfortably. And these are opted-in contacts, so the line's opted-in capacity applies; the moment they reply they are engaged and unlimited.
## Timing [#timing]
Send inside 60 seconds. The measurable drop-off starts within five minutes, and by an hour you are competing with whoever else they contacted.
If the form fires outside your send window, hold until the window opens rather than sending at 11pm. Queue it as a drip send with `window_start_hour` and `window_end_hour` set and the platform holds it until the window opens; the window rules and why they matter are in [state texting laws](/docs/platform/state-laws).
## Handle the reply in seconds, not hours [#handle-the-reply-in-seconds-not-hours]
The whole recipe fails if a fast message gets a slow answer. On `message.received`, notify a human immediately with the contact name and reply text, and make the notification something they already watch: Slack, a phone push, the CRM's own mobile alert.
```javascript
if (e.event === "message.received") {
await slack.post({
channel: "#leads",
text: `Reply from ${e.data.contact_name ?? e.data.phone_number}: "${e.data.content}"`
});
}
```
## Stop on reply [#stop-on-reply]
Any inbound message cancels the rest of the sequence. If someone answers message one and still gets the scheduled message two, you look automated, which undoes the whole point.
```javascript
if (e.event === "message.received") {
const recordId = await crmIdForPhone(e.data.phone_number);
await sequences.cancel({ recordId });
}
```
## The follow-up, if they do not reply [#the-follow-up-if-they-do-not-reply]
One follow-up at 24 hours, one at 3 days, then stop:
> Hey \[first name], still happy to help with \[the thing they asked about]. Want me to send a couple of times?
> \[First name], last one from me. If it's not the right time, no problem. If it is, just reply here.
Three messages total including the opener. A fourth converts near zero and produces opt-outs.
## What breaks this [#what-breaks-this]
Phone numbers that are not E.164, which fail validation. Normalize at the form, not at the API call.
Leads arriving faster than the instant-send velocity caps during a paid traffic spike. Fall back to the drip lane for the overflow (drop `send_mode` and the platform paces it), and check `available_today` on [`GET /v1/devices`](/docs/api-reference/lines) before big pushes.
Nobody watching the notification channel. This is the real failure mode. Assign the queue to a named person per shift.
## What to measure [#what-to-measure]
Reply rate on the opener, median time from form fill to first human reply, and booking rate on replied leads. If reply rate is healthy and bookings are not, the problem is what happens after the reply, not the message.