API referenceapi.tryprojectblue.com
llms.txtDashboard
View as Markdown

Project Blue API

Send iMessage and SMS from your own application. Integrate messaging into your CRM, workflows, or custom tools with a single API call.

The API is organised around REST. It accepts JSON request bodies, returns JSON responses, and uses standard HTTP verbs and status codes. Every request is authenticated with a bearer token.

Reading this with an AI assistant?
Every section is also served as Markdown. /llms.txt indexes them, /llms-full.txt is the whole reference in one file, and each section has its own /llms/<section>.md — the View as Markdown link at the top of each section points at it. Point your agent at those rather than scraping the page.
Base URL
https://api.tryprojectblue.com
View as Markdown

MCP server & skill

Working with an AI assistant? Connect it here first — this is the fastest way to send a message, and there is no API key to paste.

The Project Blue MCP server gives Claude Desktop, Claude Code, Cursor, and any other MCP-compatible client the ability to send iMessage and SMS, place FaceTime Audio calls, manage contacts, run Workflows, and read message and call history — directly from the editor or chat surface. Authentication is OAuth 2.1 in the browser.

Writing code against the REST API instead? Start with the Quickstart.

Server URL
https://api.tryprojectblue.com/api/mcp

Two ways to connect

Add the server directly. This is the standard path and works in any MCP client — paste the URL, or run the one-liner in the rail. Nothing else is installed.

Or install the plugin (Claude Code only). It registers the same server and adds a Project Blue skill carrying the workflow rules the tool descriptions cannot: the trial verification sequence, when a retry is being deduplicated rather than failing, the FaceTime call lifecycle, and which contact endpoint merges versus replaces.

Neither path supersedes the other
Both connect to the same server with the same tools and the same OAuth flow. claude plugin install is a convenience, not a requirement — if you already added the server with claude mcp add, it keeps working and there is nothing to migrate. Installing both would simply register the server twice, so pick one.

Set up your client

Pick your client in the rail and run the snippet. Every one of them ends the same way: your client opens a browser to app.tryprojectblue.com, you approve the connection, and it is done. OAuth 2.1 with dynamic client registration — no key to paste, no tokens to rotate.

Client
Where it goes
Claude Code
claude mcp add, or install the plugin below
Codex
codex mcp add then codex mcp login, or ~/.codex/config.toml
Cursor
~/.cursor/mcp.json or .cursor/mcp.json
VS Code
.vscode/mcp.json
Claude Desktop
Settings → Connectors → Add custom connector, then paste the server URL

Any other client that speaks remote MCP over streamable HTTP works with the server URL alone. For one that only accepts stdio servers, use mcp-remote as an adapter — both are in the rail.

Available tools

Tool
Type
Description
send_message
write
Send an iMessage or SMS to a single recipient. Supports media, audio, AI voice memo, an optional lineId override, and an optional iMessage-only send effect.
send_typing_indicator
write
Show the iMessage typing indicator. iMessage-only; requires an existing conversation. Pattern: typing → brief delay → send_message. Fire-and-forget — do not retry on ERROR.
send_reaction
write
React to a message with an iMessage tapback (love, like, dislike, laugh, emphasize, question). Pass messageHandle from list_messages. remove:true retracts the same type.
send_group_message
write
Send an iMessage or SMS to a group of 2–32 recipients. Queued (status always queued, service always null). Same numbers reuse the thread. No CRM sync. Unavailable on trial accounts — see Trial Accounts.
lookup_imessage_availability
read
Check whether a phone number supports iMessage.
get_lines
read
List the user's Project Blue sending lines (lineId, devicePhoneNumber, customName).
list_messages
read
List recent inbound/outbound messages with filters (service, direction, line, date ranges).
get_message
read
Fetch a single message by its opaque message_handle.
get_call_logs
read
List the user's outbound dialer call logs with filters for line and answered_at range. Includes call status, disposition, transcript, and recording URL when available.
start_facetime_call
write
Place a FaceTime Audio call from a FaceTime-enabled line. Returns call_uuid plus Agora WebRTC credentials the caller must use to join the call audio. Requires FaceTime Audio on the account.
get_facetime_call_status
read
Poll a FaceTime call's live status (initiated, ringing, answered, ended, declined, no_answer, failed) by call_uuid.
end_facetime_call
write
Hang up an in-progress FaceTime call by call_uuid.
create_external_contact
write
Create (or upsert by phone number) an external contact with name, email, and custom JSON metadata. For accounts without a connected CRM.
get_external_contacts
read
List the user's external contacts (newest first) with pagination, or look one up by phone number.
update_external_contact
write
Update an external contact's name, email, note, or customFields by contact id. customFields is replaced wholesale, not merged.
list_flows
read
List the user's published Workflows (id and name). Use the id as flowId with enroll_contact_in_flow.
enroll_contact_in_flow
write
Enroll a contact in a published Workflow. Starts the run immediately and may send real messages. Returns runId and the pinned pb_line_id.
cancel_flow_runs
write
Cancel all active Workflow runs for a contact. Safe when the contact has no active runs.
Connect

Opens a browser to complete OAuth on first use.

claude mcp add --transport http project-blue \
  https://api.tryprojectblue.com/api/mcp
PluginClaude Code

Optional: same server, plus the Project Blue skill.

claude plugin marketplace add try-pb/pb-api
claude plugin install project-blue@project-blue
View as Markdown

Quickstart

Four steps from an API key to a delivered message. Every call here is copy-paste ready.

1. Get a key, then check it

Create a key in the Project Blue dashboard under Settings → API Keys. Keys start with proj_ followed by 64 hex characters, and you may hold five active keys at a time.

The full key is shown once
The value is returned only at creation. Every later view is masked to the first 12 and last 4 characters, so store it somewhere durable now. If you lose it, delete the key and make a new one.

Start with /get-lines. It is the right first call because it reads rather than sends, and because its answer tells you which of the two setups you are in.

You got
It means
Do next
A JSON array
Paid account with its own line
Note a lineId, skip to step 3
An object with trial: true
Trial account on the shared line
Do step 2 first
401
The key never reached us, or was rejected
See below

The two 401 bodies mean different things. Missing or invalid Authorization header means the header was absent or lacked the Bearer prefix — the request never carried a key. Invalid API key means the header was well-formed but the key is wrong, revoked, or from another account.

2. Get a number you can text

On a paid account, skip this — any valid number works.

On a trial, sends route through a shared Project Blue line and can only reach verified destinations. Verification is confirmed by an inbound text: the owner of that number must text the shared line from their own phone. Nothing in the API or the dashboard can confirm it on their behalf.

Testing on your own? Verify yourself in a minute
Register your own mobile number in the webapp under Settings, text the shared line from that phone, then send to yourself in step 3. That is the whole loop, and it needs nobody else.

Sending to an unverified destination returns 403 with Trial accounts can only message numbers verified on the shared line. See Trial accounts for what else is restricted.

3. Send the message

Two fields is the whole request. If the recipient has iMessage it arrives as one; otherwise it falls back to SMS automatically.

A success returns status: "done" — but no message id, which is why there is a step 4.

4. Confirm it landed

The send response tells you the message was accepted, not that it was delivered. Read it back from list messages filtered to the number you texted, and check data[0].status. The same row carries the message_handle you need for get a message.

In production, register a webhook instead of polling.

Before you loop
  1. Re-running the same send is collapsed, not repeated. Identical text to the same number within the hour returns 200 with deduped: true and sends nothing. Change the text or pass a distinct idempotencyKey. This is the most common reason a first integration looks like it worked but nothing arrived.
  2. 60 requests per minute per key, then 429 with retryAfterSeconds.
  3. Numbers are normalized to E.164. Most formats are accepted on the way in; everything comes back as +15551234567.
  4. Trial sends are real messages on a line shared with other accounts. Do not load-test them — unverified probing burns iMessage reputation for everyone on that line.
1 · Check your keycURL

Safe to run — reads your lines, sends nothing.

export PB_API_KEY=proj_...

curl -s https://api.tryprojectblue.com/get-lines \
  -H "Authorization: Bearer $PB_API_KEY"
What comes back

An array. Note a lineId and skip to step 3.

[
  {
    "lineId": "a3f8c2d1-b4e9-4f2a-c8d3-e1f0a2b3c4d5",
    "devicePhoneNumber": "+15559876543",
    "customName": "Main Line"
  },
  {
    "lineId": "9c2e4a1b-d3f8-4e1c-a2b4-c5d6e7f8a9b0",
    "devicePhoneNumber": "+15557654321",
    "customName": "Secondary Line"
  }
]
3 · Send
curl -X POST https://api.tryprojectblue.com/send-api-message \
  -H "Authorization: Bearer $PB_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Hello from the Project Blue API",
    "phone": "+15551234567"
  }'
4 · Confirm
curl -sG https://api.tryprojectblue.com/get-messages-api \
  -H "Authorization: Bearer $PB_API_KEY" \
  --data-urlencode "direction=outbound" \
  --data-urlencode "to_number=+15551234567" \
  --data-urlencode "limit=1"
View as Markdown

Authentication

All API requests require a bearer token in the Authorization header. You can generate API keys from within your Project Blue dashboard under Settings → API Keys.

Settings → API Keys in the Project Blue dashboard.
Keep your API keys secure
Never expose your API key in client-side code or public repositories. Always make API calls from your server.
RequestHTTP header
Authorization: Bearer YOUR_API_KEY
ExamplecURL
curl -X POST https://api.tryprojectblue.com/send-api-message \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Hey! Just following up on our conversation.",
    "phone": "+15551234567"
  }'
View as Markdown

Trial accounts

Trial accounts (accounts still on a trial, before conversion to a paid line) get real API access — not a simulator. Sends are real messages. The difference is that a trial account sends through a shared Project Blue line, and it can only reach destinations that have been verified.

Verifying a destination

  1. Register the destination in the Project Blue webapp under Settings. This creates a pending verification pinned to your shared line.
  2. The owner of that destination texts the shared line from their own phone. That inbound text is what confirms the verification. There is no way to confirm it from the API or the dashboard.
  3. The destination shows as verified in the webapp, and API sends to it start succeeding.

Destination registration is webapp-only today — there is no API-key equivalent for registering a number or polling verification status yet.

Sending or probing an unverified destination returns 403 with Trial accounts can only message numbers verified on the shared line.

Endpoint availability

Endpoint
Trial
Notes
/send-api-message
Restricted
Verified destinations only. lineId is rejected (see below).
/api-check-imessage-availability
Restricted
Verified destinations only.
/get-lines
Restricted
Returns a trial envelope (empty lines list) instead of owned lines.
/get-messages-api
Available
Reads your own messages only.
/get-message-api/:message_handle
Available
Reads a single message you own.
/create-external-contact
Available
/get-external-contacts
Available
/update-external-contact
Available
/get-call-logs-api
Available
/get-flows
Available
List published Workflows. Each id is an opaque UUID string.
/cancel-flow-runs
Available
Webhooks
Available
Inbound/outbound webhook delivery for your account.
/get-effects
Unavailable
This endpoint is not available on trial accounts.
/send-reaction
Restricted
Verified destinations only. iMessage tapbacks.
/send-group-message
Unavailable
Trial accounts cannot create group chats.
/enroll-flow
Unavailable
Flow enrollment is not available on trial accounts.
/start-facetime-call-api
Unavailable
FaceTime calling is not available on trial accounts.
/get-facetime-call-status-api
Available
No dedicated trial gate; only useful if you already have a call_uuid.
/end-facetime-call-api
Available
No dedicated trial gate; only useful if you already have a call_uuid.
lineId is not accepted on trial accounts
Trial sends route through the shared line, so the line is not selectable. Supplying lineId on /send-api-message returns 400 — see the rail.

GET /get-lines on a trial account

Trial accounts do not own a device line. /get-lines returns a trial envelope instead of an array of lines.

Response
{
  "lines": [],
  "trial": true,
  "message": "This is a trial account. Sends route through a shared Project Blue line to your verified destinations. Your real line info will appear here after you upgrade."
}
View as Markdown

Send a message

POST/send-api-message

Send a message to a phone number via iMessage or SMS. If the recipient has iMessage, the message is delivered as an iMessage (blue bubble). Otherwise, it falls back to SMS automatically.

Request body
messagestring
The text content of the message. Required unless mediaAttachmentUrl or audioAttachmentUrl is supplied.
phonestringRequired
Recipient phone number. Accepts many formats — we normalize to E.164 (e.g. +15551234567).
lineIdstring (UUID v4)
Optional sender line override. Use the lineId value returned from GET /get-lines. When omitted, messages are load balanced across available lines. Rejected on trial accounts — see Trial Accounts.
mediaAttachmentUrlstring
URL to an image, video, or contact card attachment.
audioAttachmentUrlstring
URL to an audio file sent as a voice memo.
enableAiVoiceMemoboolean
When true, generates an AI voice memo from the message text using text-to-speech.
shouldAutoCreateContactboolean
Defaults to true. When enabled and a supported CRM is connected (HighLevel or HubSpot), automatically creates the contact in your CRM if they don't already exist.
firstNamestring
First name to persist on the auto-created Project Blue contact for this recipient. Applies to accounts without a connected CRM (external/API source).
lastNamestring
Last name to persist on the auto-created Project Blue contact for this recipient. Applies to accounts without a connected CRM (external/API source).
emailstring
Email address to persist on the auto-created Project Blue contact for this recipient. Applies to accounts without a connected CRM (external/API source).
customFieldsobject
Arbitrary JSON metadata (e.g. order IDs, links) persisted on the auto-created contact and shown in the contact details panel in the Project Blue app. Plain object only; max 10,000 characters when JSON-serialized.
idempotencyKeystring
1 to 200 characters. Values outside those bounds are silently ignored, not rejected. When omitted, the key defaults to your user id plus the destination plus the message text, so an identical retry within the hour is collapsed rather than sent twice.
effectstring
iMessage-only send effect applied to this one message. Bubble: slam, loud, gentle, invisible-ink. Screen: confetti, balloons, hearts, lasers, fireworks, echo, spotlight, shooting-star, celebration. Invalid on SMS (400). Use sparingly.

The phone parameter is flexible — we accept formats like (555) 123-4567, 555.123.4567, +15551234567, and more. All numbers are normalized to E.164 format before sending.

When lineId is provided, we route through that line. If it is omitted, message sends continue to use default load balancing across your available lines.

iMessage-only send effects
Optional effect applies to this one message and only works on iMessage. SMS destinations return 400. Bubble effects animate the bubble: slam, loud, gentle, invisible-ink. Screen effects take over the recipient's screen: confetti, balloons, hearts, lasers, fireworks, echo, spotlight, shooting-star, celebration. Use sparingly. GET /get-effects returns the same list.
Identical sends are collapsed for an hour
With no idempotencyKey, the key defaults to your user id plus the destination plus the message text. Sending the same text to the same number twice within the hour returns 200 with deduped: true and does not send a second message. This is the usual reason a first integration looks like it succeeded but nothing arrived on the retry — change the text, or pass a distinct idempotencyKey, when a repeat is intentional.

A replay returns status: "done" when the original send finished, or status: "processing" with messageType and devicePhoneNumber still null while it is in flight. Both carry deduped: true; a first-time send never does.

Request
curl -X POST https://api.tryprojectblue.com/send-api-message \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Hey! Just following up on our conversation.",
    "phone": "+15551234567"
  }'
Response
{
  "success": true,
  "status": "done",
  "message": "Message added to queue",
  "messageType": "iMessage",
  "phone": "+15551234567",
  "devicePhoneNumber": "+15559876543",
  "mediaAttachmentUrl": null,
  "audioAttachmentUrl": null
}
View as Markdown

List effects

GET/get-effects

List the iMessage send effects accepted by effect on /send-api-message. Each entry has a name, type (bubble or screen), and a short description.

Bubble effects: slam, loud, gentle, invisible-ink. Screen effects: confetti, balloons, hearts, lasers, fireworks, echo, spotlight, shooting-star, celebration. Not available on trial accounts.

RequestcURL
curl -X GET https://api.tryprojectblue.com/get-effects \
  -H "Authorization: Bearer YOUR_API_KEY"
Response200 OK
{
  "effects": [
    { "name": "slam", "type": "bubble", "description": "Slams the bubble onto the screen" },
    { "name": "loud", "type": "bubble", "description": "Enlarges the bubble, then shrinks it" },
    { "name": "gentle", "type": "bubble", "description": "Shrinks the bubble, then grows it" },
    { "name": "invisible-ink", "type": "bubble", "description": "Hides the message until the recipient swipes" },
    { "name": "confetti", "type": "screen", "description": "Full-screen confetti" },
    { "name": "balloons", "type": "screen", "description": "Full-screen balloons" },
    { "name": "hearts", "type": "screen", "description": "Full-screen hearts" },
    { "name": "lasers", "type": "screen", "description": "Full-screen lasers" },
    { "name": "fireworks", "type": "screen", "description": "Full-screen fireworks" },
    { "name": "echo", "type": "screen", "description": "Echoes the message across the screen" },
    { "name": "spotlight", "type": "screen", "description": "Spotlights the message" },
    { "name": "shooting-star", "type": "screen", "description": "Full-screen shooting star" },
    { "name": "celebration", "type": "screen", "description": "Full-screen celebration sparkles" }
  ]
}
View as Markdown

Send a group message

POST/send-group-message

Send an iMessage or SMS to a group of 2 to 32 recipients. There is no create step — pass numbers every time. The same participant set resolves to the same thread.

All or nothing iMessage
A group is delivered as iMessage only when every participant is iMessage reachable. One participant on Android drops the entire thread to SMS.
Queued — service is always null here
status is always "queued" and service is always null on this endpoint. The send is never synchronous — whether the thread lands as iMessage or SMS is not known until the cron runs the all-participants availability probe. Read the resolved service from /get-messages-api.
No CRM sync
Group sends are not mirrored into HighLevel, HubSpot, or Close, unlike single recipient sends via /send-api-message.
Request body
numbersstring[]Required
Recipient phone numbers, 2 to 32 entries. Flexible formats accepted; normalized to E.164. Deduped after normalization. Passing the same set of participants again reuses the existing group thread.
messagestring
The text content of the message. Required unless an attachment is supplied.
mediaAttachmentUrlstring
URL to an image, video, or contact card attachment. Mutually exclusive with audioAttachmentUrl.
audioAttachmentUrlstring
URL to an audio file sent as a voice memo. Mutually exclusive with mediaAttachmentUrl.
enableAiVoiceMemoboolean
Generates a voice memo from message.
groupNamestring
Honored only when the group is created. Ignored on reuse.
lineIdstring (UUID v4)
From GET /get-lines. On reuse it must match the line the group already lives on. Rejected on trial accounts — see Trial Accounts. Group send itself is unavailable on trial.
idempotencyKeystring
1 to 200 characters. Values outside those bounds are silently ignored, not rejected.

groupStatus is "creating" until the chat exists on the sending line, then "active". created is true only when this call created the group. groupId is opaque and stable — do not parse it.

On a replayed or deduplicated request, groupId and devicePhoneNumber can be null, because the group did not exist yet when the claim was taken.

Group send is unavailable on trial accounts — see Trial accounts.

Status codes
200
Message queued successfully
400
Invalid request — missing message/attachment, both attachments, recipient count, email/chat handles, phone format, or lineId
401
Missing or invalid Authorization header / Invalid API key
403
Unavailable on trial (see Trial accounts) — e.g. Trial accounts cannot create group chats. Also: One or more recipients are blocked.
409
This group already exists on a different line.
429
Rate limit exceeded
500
Internal server error
Request

groupName is honoured only when the group is created.

curl -X POST https://api.tryprojectblue.com/send-group-message \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "numbers": ["+16025551234", "+14808405291"],
    "message": "Hey both, following up on the walkthrough.",
    "groupName": "Elm St walkthrough"
  }'
Response
{
  "success": true,
  "status": "queued",
  "groupId": "pbg_...",
  "created": true,
  "groupStatus": "creating",
  "service": null,
  "recipients": ["+16025551234", "+14808405291"],
  "devicePhoneNumber": "+15559876543",
  "mediaAttachmentUrl": null,
  "audioAttachmentUrl": null
}
View as Markdown

Typing indicators

POST/send-typing-indicator

Show the iMessage typing indicator (the three dots) to a recipient. Authenticate with a Bearer API key, same as every other endpoint.

iMessage only, existing conversation required
Typing indicators are iMessage-only — SMS recipients never see them. The number must already have a conversation on the sending line; there is no indicator without a prior thread.
Request body
phoneNumberstring (E.164)Required
Recipient phone number in E.164 (e.g. +15551234567). Must already have an existing conversation on the sending line.
lineIdstring (UUID v4)
Optional sender line. Use the lineId from GET /get-lines. When omitted, uses the line that owns the existing conversation with this number.

The indicator auto-clears on the recipient's device after a short period or when a message arrives. The intended pattern is send-typing-indicator → wait about 2–4 seconds → send-api-message.

Response

On success the endpoint returns HTTP 200 with { status: "SENT" | "ERROR", number, error_message } error_message is null when status is SENT. Auth and request-validation failures still use standard 4xx bodies with an error field. Treat device-level failures (status: "ERROR") as fire-and-forget — do not retry.

Request
curl -X POST https://api.tryprojectblue.com/send-typing-indicator \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "phoneNumber": "+15551234567"
  }'
Response
{
  "status": "SENT",
  "number": "+15551234567",
  "error_message": null
}
View as Markdown

Reactions (Tapbacks)

POST/send-reaction

React to a specific message with one of the six standard iMessage tapbacks. Pass the message_handle from /get-messages-api or /get-message-api as messageHandle.

iMessage only
Tapbacks only work on iMessage. SMS or RCS targets return status: "ERROR" with Reactions are only supported on iMessage. Reactions on outbound messages are not supported yet.
Request body
messageHandlestringRequired
Opaque message handle from GET /get-messages-api or GET /get-message-api (message_handle). Identifies the message to react to.
reaction"love" | "like" | "dislike" | "laugh" | "emphasize" | "question"Required
One of the six standard iMessage tapbacks.
removeboolean
When true, retracts a previous reaction of the same type on this message. Defaults to false.
partIndexinteger (>= 0)
Which part of a multi-part message to react to. Defaults to 0.

Valid reaction values: love, like, dislike, laugh, emphasize, question. Set remove to true to retract a previous reaction of the same type. A typical pattern is acknowledging an inbound message with like or love instead of (or before) a text reply.

Response

On success the endpoint returns HTTP 200 with { status: "SENT" | "ERROR", messageHandle, reaction, remove, error_message }. Auth and request-validation failures still use standard 4xx bodies with an error field.

Request
curl -X POST https://api.tryprojectblue.com/send-reaction \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "messageHandle": "pbm_outk6dawyUgbl-EjXCZk-g5mZnmwmSbilbaX-fvceChASMVXv1v26ONS0XENe2ggdJ82j9TMpw",
    "reaction": "like"
  }'
Response
{
  "status": "SENT",
  "messageHandle": "pbm_outk6dawyUgbl-EjXCZk-g5mZnmwmSbilbaX-fvceChASMVXv1v26ONS0XENe2ggdJ82j9TMpw",
  "reaction": "like",
  "remove": false,
  "error_message": null
}
View as Markdown

CRM integration

The send endpoint works hand-in-hand with your CRM. If you have HighLevel or HubSpot connected, outbound messages sent through the API appear inside your CRM — just like messages sent from the Project Blue app.

Auto-create contacts

By default, shouldAutoCreateContact is true. This means if you send an outbound message and the recipient does not already exist as a contact in your CRM, we will automatically create the contact for you along with the message.

Set shouldAutoCreateContact to false if you only want messages logged for contacts that already exist in your CRM.

This field is only relevant if you have a supported CRM connected
If no CRM is connected, shouldAutoCreateContact has no effect. Messages are still sent normally regardless of this setting.

HighLevel

Outbound messages are synced directly into Conversations. If the contact doesn't exist and auto-create is enabled, we create the contact and the message appears in their conversation thread.

HubSpot

Outbound messages are logged as an activity on the contact record. If you have a HubSpot Inbox enabled, the message is also delivered there for your team to see and reply from.

If the contact doesn't exist and auto-create is enabled, we create the contact in HubSpot first, then log the activity.

RequestExisting contacts only

The message is logged only if the contact already exists.

curl -X POST https://api.tryprojectblue.com/send-api-message \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Hey! Following up on our call earlier.",
    "phone": "+15551234567",
    "shouldAutoCreateContact": false
  }'
View as Markdown

List messages

GET/get-messages-api

Returns a paginated list of the authenticated user's messages — outbound and inbound merged into a single feed. Each message includes a durable message_handle that can be passed to /get-message-api/:message_handle for the full record.

Query parameters
limitinteger (1–100)
Maximum number of messages to return. Defaults to 100.
offsetinteger (≥ 0)
Pagination offset. Defaults to 0. offset + limit must be ≤ 2000; deeper pages return 400 regardless of limit.
order_by"createdAt" | "sentAt"
Sort field. Defaults to createdAt.
order_direction"asc" | "desc"
Sort direction. Defaults to desc (newest first).
service"iMessage" | "SMS" | "RCS"
Filter by delivery service. Note: RCS is inbound-only — combining service=RCS with direction=outbound returns zero results.
direction"inbound" | "outbound"
Filter to inbound or outbound only. Omit for both.
pb_line_idstring
Encoded Project Blue line id (from GET /get-lines). The only supported way to filter by one of your own PB lines — do not use from_number/to_number for that.
from_numberstring (E.164)
External sender. Inbound-only filter. Combining with direction=outbound returns 400.
to_numberstring (E.164)
External recipient. Outbound-only filter. Combining with direction=inbound returns 400.
created_at_gtestring (ISO-8601)
Lower bound on created_at.
created_at_ltestring (ISO-8601)
Upper bound on created_at.
sent_at_gtestring (ISO-8601)
Lower bound on sent_at.
sent_at_ltestring (ISO-8601)
Upper bound on sent_at.
Group messages and from_number / to_number

On outbound group messages, to_number is the group's chat identifier (chat…), not an E.164 number. On inbound group messages, from_number is the individual participant who replied. The to_number and from_number filters are therefore not a way to fetch a group thread.

There is currently no supported filter for reading one group thread from /get-messages-api. The pbg_ groupId is not accepted as a filter, and the chat identifier is not exposed as a queryable field on this API.

About message_handle
The message_handle is an opaque, user-scoped identifier. Don't try to parse or decode it — just hand it back to /get-message-api to look up that specific message.
RequestcURL
curl -G https://api.tryprojectblue.com/get-messages-api \
  -H "Authorization: Bearer YOUR_API_KEY" \
  --data-urlencode "limit=5" \
  --data-urlencode "direction=outbound" \
  --data-urlencode "service=SMS"
Response
{
  "status": "OK",
  "data": [
    {
      "message_handle": "pbm_outk6dawyUgbl-EjXCZk-g5mZnmwmSbilbaX-fvceChASMVXv1v26ONS0XENe2ggdJ82j9TMpw",
      "content": "Hello this is a message from desktop Claude!",
      "from_number": "+14804328406",
      "to_number": "+14808405291",
      "line_id": "7fd53c9a-5e6f-40e7-48c2-57663bad6c9c",
      "service": "SMS",
      "direction": "outbound",
      "status": "delivered",
      "created_at": "2026-04-19T07:30:14.525Z",
      "sent_at": "2026-04-19T16:00:31.091Z",
      "media_attachment_url": null,
      "voice_attachment_url": null
    }
  ],
  "pagination": { "limit": 100, "offset": 0, "total": 427 }
}
View as Markdown

Get a message

GET/get-message-api/:message_handle

Fetch a single message by its opaque message_handle (as returned from /get-messages-api). Handles are scoped to the authenticated user — a handle from another user's account returns 404.

Path parameters
message_handlestringRequired
Opaque handle starting with pbm_, returned by /get-messages-api.
RequestcURL
curl https://api.tryprojectblue.com/get-message-api/pbm_outk6dawyUgbl-EjXCZk-g5mZnmwmSbilbaX \
  -H "Authorization: Bearer YOUR_API_KEY"
Response
{
  "status": "OK",
  "data": {
    "message_handle": "pbm_outk6dawyUgbl-EjXCZk-g5mZnmwmSbilbaX",
    "content": "Hello this is a message from desktop Claude!",
    "from_number": "+14804328406",
    "to_number": "+14808405291",
    "line_id": "7fd53c9a-5e6f-40e7-48c2-57663bad6c9c",
    "service": "SMS",
    "direction": "outbound",
    "status": "delivered",
    "created_at": "2026-04-19T07:30:14.525Z",
    "sent_at": "2026-04-19T16:00:31.091Z",
    "media_attachment_url": null,
    "voice_attachment_url": null
  }
}
View as Markdown

Check iMessage availability

POST/api-check-imessage-availability

Check whether a phone number is reachable via iMessage before sending. Useful for routing logic or pre-qualifying contacts.

Request body
phonestringRequired
The phone number to check. Accepts many formats — we normalize to E.164.
Status codes
200
Phone checked successfully
400
Invalid phone number format
401
Missing or invalid API key
403
Trial account probing a destination that is not verified on the shared line
409
Trial account has no shared line provisioned
429
Rate limit exceeded
500
Internal server error
RequestcURL
curl -X POST https://api.tryprojectblue.com/api-check-imessage-availability \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "phone": "+15551234567"
  }'
Response
{
  "normalizedPhone": "+15551234567",
  "isIMessageAvailable": true
}
View as Markdown

Get lines

GET/get-lines

Fetch all sending lines available to your account. Pass the returned lineId value to /send-api-message when you want to force a specific line.

RequestcURL
curl -X GET https://api.tryprojectblue.com/get-lines \
  -H "Authorization: Bearer YOUR_API_KEY"
Response
[
  {
    "lineId": "a3f8c2d1-b4e9-4f2a-c8d3-e1f0a2b3c4d5",
    "devicePhoneNumber": "+15559876543",
    "customName": "Main Line"
  },
  {
    "lineId": "9c2e4a1b-d3f8-4e1c-a2b4-c5d6e7f8a9b0",
    "devicePhoneNumber": "+15557654321",
    "customName": "Secondary Line"
  }
]
View as Markdown

Create a contact

External contacts are Project Blue's native contact store for accounts without a connected CRM. They render in the Project Blue app's contact list and details panel.

You can create them explicitly with the endpoints below, or implicitly by passing firstName, lastName, email, and customFields on /send-api-message — those fields persist on the auto-created contact for the recipient.

For accounts without a connected CRM
If HighLevel or HubSpot is connected, contacts live in your CRM instead (see CRM integration). External contacts apply to external/API-source accounts.
POST/create-external-contact

Required: phone only (plus Authorization: Bearer <api key>). Optional: firstName, lastName, email, customFields.

customFields is a JSON object of key/value pairs, max 10,000 characters when stringified. Omit it entirely if you have none. Same phone for the same API key is an upsert (fills blank names, overwrites email, shallow-merges customFields).

Upserts do not behave like updates
When the phone number already exists this endpoint merges rather than replaces, and the rules differ per field:
  • firstName and lastName are only filled in when the stored value is empty. Sending a new name for a contact that already has one is silently discarded.
  • email overwrites whenever you send a non-empty value.
  • customFields is shallow-merged here — the opposite of update, which replaces the whole object. Sending {} changes nothing.

The response is the contact as it was before the merge, so a discarded name is not visible in it. Read the contact back if you need to confirm. The same merge runs when /send-api-message auto-creates a contact.

Request body
phonestringRequired
The only required field besides Authorization. E.164 like +12345678901.
firstNamestring
The contact's first name. On upsert, filled in only when the stored value is blank.
lastNamestring
The contact's last name. On upsert, filled in only when the stored value is blank.
emailstring
The contact's email address. On upsert, overwrites the stored email.
customFieldsobject
JSON object of key/value pairs. Max 10,000 characters when stringified. Omit it entirely if you have none. On upsert, shallow-merged into the stored object.
The contact object

All three endpoints return the same object. Create and update return it as { contact }; list returns { contacts, pagination }. There is no status envelope.

idstringRequired
Opaque contact id. Pass it back as contactId when updating; do not parse it.
firstNamestring | nullRequired
The contact's first name, or null if never set.
lastNamestring | nullRequired
The contact's last name, or null if never set.
phoneNumberstringRequired
The contact's number in E.164. Note the asymmetry: requests take phone, responses return phoneNumber.
emailstring | nullRequired
The contact's email address, or null if never set.
customFieldsobject | nullRequired
Whatever JSON metadata you last stored. Replaced wholesale on update, never merged.
notestring | nullRequired
Free-form note stored on the contact.
createdAtstring (ISO-8601)Required
When the contact was first created.
updatedAtstring (ISO-8601)Required
When the contact was last modified.
Request

Required: phone only. Optional: firstName, lastName, email, customFields.

{
  "phone": "+15551234567",          // required, E.164 like +12345678901
  "firstName": "Jane",              // optional
  "lastName": "Doe",                // optional
  "email": "jane@example.com",      // optional
  "customFields": {                 // optional object; omit if unused
    "shopifyCustomerId": "123",
    "shopifyOrderId": "456"
  }
}
Response200

Wrapped as { contact }. List returns { contacts, pagination }.

{
  "contact": {
    "id": "string",
    "firstName": "string | null",
    "lastName": "string | null",
    "phoneNumber": "+15551234567",
    "email": "string | null",
    "customFields": { } | null,
    "note": "string | null",
    "createdAt": "ISO-8601",
    "updatedAt": "ISO-8601"
  }
}
View as Markdown

List contacts

GET/get-external-contacts

Returns the authenticated user's external contacts, newest first, with pagination. Pass phone to look up a single contact by number.

Query parameters
limitinteger (1–100)
Maximum number of contacts to return. Defaults to 100.
offsetinteger (≥ 0)
Pagination offset. Defaults to 0.
phonestring
Look up a single contact by phone number (exact match after normalization to E.164).
RequestcURL
curl -G https://api.tryprojectblue.com/get-external-contacts \
  -H "Authorization: Bearer YOUR_API_KEY" \
  --data-urlencode "limit=25"
Response200 OK
{
  "contacts": [
    {
      "id": "cmm4k2p1z0001s6ry9x8u7q3v",
      "firstName": "Jamie",
      "lastName": "Rivera",
      "phoneNumber": "+15551234567",
      "email": "jamie@example.com",
      "customFields": {
        "orderId": "ord_18342",
        "plan": "pro"
      },
      "note": null,
      "createdAt": "2026-08-14T17:22:05.118Z",
      "updatedAt": "2026-08-14T17:22:05.118Z"
    }
  ],
  "pagination": { "limit": 25, "offset": 0, "total": 138 }
}
View as Markdown

Update a contact

POST/update-external-contact

Updates an existing contact's name, email, note, or custom metadata. The contact's phone number cannot be changed. Returns 404 if the contact does not belong to your account.

At least one of firstName, lastName, email, customFields, or note must be present. A request carrying only contactId returns 400. The phone number cannot be changed.

Request body
contactIdstringRequired
The contact id, as returned by the create or list endpoints.
firstNamestring
New first name for the contact.
lastNamestring
New last name for the contact.
emailstring
New email address for the contact.
notestring
Free-form note stored on the contact.
customFieldsobject
Replaces the entire stored customFields object — fetch the current contact and merge client-side to preserve existing keys. Plain object only; max 10,000 characters when JSON-serialized.
How customFields is validated
Three distinct 400 bodies come out of the same check, on every endpoint that accepts customFields: customFields must be a JSON object of key/value pairs when the value is not a plain object, customFields must be a JSON-serializable object when it contains something that cannot be stringified, and customFields JSON exceeds maximum length of 10000 characters past the size cap. That cap counts characters of serialized JSON, not bytes — non-ASCII values reach it later than their byte size suggests.
customFields is replaced, not merged
Sending customFields on an update overwrites the entire stored object. To add or change one key, fetch the contact first, merge on your side, and send the full object back.
RequestcURL
curl -X POST https://api.tryprojectblue.com/update-external-contact \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "contactId": "cmm4k2p1z0001s6ry9x8u7q3v",
    "note": "VIP customer — prefers texts over calls",
    "customFields": {
      "orderId": "ord_18342",
      "plan": "enterprise"
    }
  }'
Response
{
  "contact": {
    "id": "cmm4k2p1z0001s6ry9x8u7q3v",
    "firstName": "Jamie",
    "lastName": "Rivera",
    "phoneNumber": "+15551234567",
    "email": "jamie@example.com",
    "customFields": {
      "orderId": "ord_18342",
      "plan": "enterprise"
    },
    "note": "VIP customer — prefers texts over calls",
    "createdAt": "2026-08-14T17:22:05.118Z",
    "updatedAt": "2026-08-16T09:03:41.760Z"
  }
}
View as Markdown

List flows

Workflows are automation flows built in the Project Blue app — send a message, wait, branch on a reply, and so on. These endpoints list your published flows, enroll a contact (which starts the run immediately), and cancel active runs for a contact.

Enrollment runs immediately
Only Published flows can be enrolled. Enrolling a contact executes the first step right away — real messages may be sent. When pb_line_id is omitted, a line is selected automatically at enrollment and pinned for the entire run; the chosen line is returned in the response.
GET/get-flows

Returns the authenticated user's published Workflows. Use each flow's id as flowId when enrolling a contact. Ids are opaque UUID strings, not sequential numbers — pass them back verbatim.

RequestcURL
curl -X GET https://api.tryprojectblue.com/get-flows \
  -H "Authorization: Bearer YOUR_API_KEY"
Response200 OK
{
  "flows": [
    { "id": "6f1c2a7e-9d4b-4c31-8a52-1e7f0b3d9c84", "name": "New Lead Follow-up" },
    { "id": "b28d5f30-71ac-4e69-9f13-52c6ad8071be", "name": "Appointment Reminder" }
  ]
}
View as Markdown

Enroll a contact

POST/enroll-flow

Starts a Workflow run for the given contact. The first step executes immediately. The response includes runId and the pb_line_id pinned to the run.

Because the first step runs before the response is written, the returned status is the state after that step. A run that begins with a wait comes back SLEEPING, and one that begins by asking a question comes back WAITING_REPLYACTIVE is the exception, not the rule. Statuses are uppercase: ACTIVE, SLEEPING, WAITING_REPLY, COMPLETED, CANCELLED, FAILED.

Request body
flowIdstring (UUID)Required
The Workflow id, as returned by GET /get-flows. An opaque UUID string — do not parse it. Must be 1 to 64 characters.
phoneNumberstringRequired
The contact's phone number. Accepts many formats — normalized to E.164.
pb_line_idstring (UUID)
Optional. Line to send from (lineId from GET /get-lines). When omitted, a line is selected automatically and pinned for the entire run.
Status codes
400
Invalid phone number, invalid pb_line_id, or no available lines
403
Contact has opted out
404
Flow not found or not published, or line not found
409
An active run already exists for this contact in this flow
RequestcURL
curl -X POST https://api.tryprojectblue.com/enroll-flow \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "flowId": "6f1c2a7e-9d4b-4c31-8a52-1e7f0b3d9c84",
    "phoneNumber": "+16025551234",
    "pb_line_id": "a3f8c2d1-b4e9-4f2a-c8d3-e1f0a2b3c4d5"
  }'
Response
{
  "success": true,
  "runId": 4182,
  "status": "SLEEPING",
  "currentNodeId": "node_wait_1",
  "pb_line_id": "a3f8c2d1-b4e9-4f2a-c8d3-e1f0a2b3c4d5"
}
View as Markdown

Cancel flow runs

DELETEPOST/cancel-flow-runs

Cancels all active Workflow runs for the given contact. Safe to call when the contact has no active runs. POST is accepted as an alias for clients that cannot send DELETE with a body.

Request body
phoneNumberstringRequired
The contact's phone number. Cancels all of this contact's active Workflow runs. Accepts many formats — normalized to E.164.
RequestcURL
curl -X DELETE https://api.tryprojectblue.com/cancel-flow-runs \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "phoneNumber": "+16025551234"
  }'
Response200 OK
{
  "success": true,
  "cancelled": 1
}
View as Markdown

Call logs

GET/get-call-logs-api

Returns the authenticated user's outbound call logs from the Project Blue dialer, including a recording_url when a recording is available. This is the call-log analog of /get-messages-api.

Outbound dialer calls only
This endpoint returns outbound calls placed from the Project Blue dialer.
recording_url can be null — that's expected

This endpoint reports call attempts, not just recorded calls — so unanswered, busy, failed, and very short calls all show up with recording_url: null. Answered calls typically populate recording_url within seconds of ended_at.

If a long-completed call still has recording_url: null, it usually means no audio was captured for that call (e.g. recording disabled on the line).

Query parameters
limitinteger (1–100)
Maximum number of call logs to return. Defaults to 100.
offsetinteger (≥ 0)
Pagination offset. Defaults to 0. offset + limit must be ≤ 2000; deeper pages return 400 regardless of limit.
call_log_timestamp"asc" | "desc"
Sort direction by the call log's created_at, so unanswered attempts interleave with answered calls. Defaults to desc (newest first).
pb_line_idstring (UUID)
Encoded Project Blue line id (from GET /get-lines). Returns 400 if the line does not belong to the API key's user.
answered_at_gtestring (ISO-8601)
Inclusive lower bound on answered_at. Note: only matches calls that were actually answered.
answered_at_ltestring (ISO-8601)
Inclusive upper bound on answered_at. Note: only matches calls that were actually answered.
CallLog object
idstringRequired
Internal call log id (stable, useful for dedupe).
line_idstring | nullRequired
UUID-encoded PB line id. Round-trips with the pb_line_id filter.
from_numberstringRequired
E.164 sender (your line).
to_numberstringRequired
E.164 recipient.
statusstringRequired
Call status (completed, no-answer, busy, failed, canceled, …).
dispositionstring | nullRequired
AI-assigned disposition derived from the call transcript. See the Dispositions table below for all possible values.
transcriptstring | nullRequired
Speaker-labelled transcript when one was generated.
duration_secondsnumber | nullRequired
Connected duration. null for calls that never connected.
answered_atstring (ISO-8601) | nullRequired
When the call was answered.
ended_atstring (ISO-8601) | nullRequired
When the call ended.
recording_urlstring | nullRequired
URL to the call recording when one is available; null otherwise.

Dispositions

After a recording is transcribed, the transcript is run through an AI classifier that assigns one of the following dispositions. Use this for routing, follow-up automation, or analytics. The disposition can be null on calls that haven't been classified yet.

Value
Meaning
answered
A human answered and spoke (any clear human speech that isn't a voicemail greeting).
voicemail
The call went to voicemail (voicemail greeting, beep, or 'leave a message' prompt detected).
busy
The line was busy.
no_answer
No one answered — just ringing or silence.
wrong_number
The person on the other end indicated this is the wrong number.
not_interested
The person explicitly declined or showed no interest.
callback_requested
The person asked to be called back at a later time.
meeting_scheduled
A meeting or appointment was scheduled on the call.
information_provided
Information was exchanged but no clear next step was set.
no_speech
The transcript was empty (no speech to classify).
unknown
Truly cannot determine from the transcript. Used sparingly.
Status codes
200
Call logs returned
400
Invalid query parameter (limit/offset/call_log_timestamp/dates/pb_line_id)
401
Missing or invalid API key
429
Rate limit exceeded
500
Internal server error
Request
curl -G https://api.tryprojectblue.com/get-call-logs-api \
  -H "Authorization: Bearer YOUR_API_KEY" \
  --data-urlencode "pb_line_id=6121307b-c29e-41d5-426b-46b679ab8648" \
  --data-urlencode "answered_at_gte=2026-04-01T00:00:00Z" \
  --data-urlencode "answered_at_lte=2026-05-01T00:00:00Z" \
  --data-urlencode "call_log_timestamp=asc" \
  --data-urlencode "limit=50"
Response
{
  "status": "OK",
  "data": [
    {
      "id": "cmorpmiw5fyb513ynkuz8jr39",
      "line_id": "6121307b-c29e-41d5-426b-46b679ab8648",
      "from_number": "+16027184932",
      "to_number": "+18016966474",
      "status": "completed",
      "disposition": "answered",
      "transcript": "Speaker A: Hey Colton, how are you?\nSpeaker B: This is Camila from Project Blue…",
      "duration_seconds": 168,
      "answered_at": "2026-05-04T21:24:06.882Z",
      "ended_at": "2026-05-04T21:26:53.882Z",
      "recording_url": "https://<storage-host>/call-recordings/<...>.mp3"
    },
    {
      "id": "cmorbzz12abcd13ynxxxxxxxx",
      "line_id": "6121307b-c29e-41d5-426b-46b679ab8648",
      "from_number": "+16027184932",
      "to_number": "+18015550199",
      "status": "no-answer",
      "disposition": null,
      "transcript": null,
      "duration_seconds": null,
      "answered_at": null,
      "ended_at": "2026-05-04T20:11:08.000Z",
      "recording_url": null
    }
  ],
  "pagination": { "limit": 100, "offset": 0, "total": 910 }
}
View as Markdown

FaceTime Audio

FaceTime-enabled accounts only
These endpoints are available only on API accounts with FaceTime Audio enabled. Calls from accounts without the feature return 403 with error code FACETIME_NOT_ENABLED — that means the feature isn't active on your account, not that the API is down. Interested in FaceTime Audio? Contact sales@tryprojectblue.com or reach out to support to upgrade.

Initiate real FaceTime Audio calls programmatically and connect to the live call audio via WebRTC using Agora's SDK. The pb_line_id must be a FaceTime-enabled line on your account.

POST/start-facetime-call-api
Request body
pb_line_idstring (UUID)Required
FaceTime-enabled line to call from. UUID returned from GET /get-lines.
phone_numberstring (E.164)Required
Destination phone number. Flexible formats accepted; normalized to E.164.
Response
status"OK"Required
Present on every successful call.
call_uuidstringRequired
Identifies the call. Pass it to the status and end endpoints.
agoraobject | nullRequired
WebRTC credentials for joining the call audio, or null when the device placed the call but returned no credentials. The call is still ringing in that case — you simply cannot join its audio, so check for null before calling the Agora SDK.

Joining the call

The returned agora credentials are used with the Agora Voice SDK to stream audio to and from the FaceTime call. Tokens are time-limited, so join the channel promptly after starting the call.

agora can be null on a 200
A 200 means the call was placed, not that you can hear it. When the device returns no credentials, agora is null and the call still rings — check for it before touching the SDK, and fall back to polling /get-facetime-call-status-api for the outcome.
RequestcURL
curl -X POST https://api.tryprojectblue.com/start-facetime-call-api \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "pb_line_id": "a3f8c2d1-b4e9-4f2a-c8d3-e1f0a2b3c4d5",
    "phone_number": "+15551234567"
  }'
Response200 OK
{
  "status": "OK",
  "call_uuid": "ftc_8a2b1c3d4e5f6g7h",
  "agora": {
    "appId": "your-agora-app-id",
    "channelName": "facetime-channel-abc123",
    "token": "agora-rtc-token...",
    "uid": 123456
  }
}
Join the callJavaScript

Tokens are time-limited — join promptly. Check res.agora is non-null first.

import AgoraRTC from "agora-rtc-sdk-ng";

const client = AgoraRTC.createClient({ mode: "rtc", codec: "vp8" });
await client.join(
  res.agora.appId,
  res.agora.channelName,
  res.agora.token,
  res.agora.uid,
);
const mic = await AgoraRTC.createMicrophoneAudioTrack();
await client.publish([mic]);
client.on("user-published", async (user, mediaType) => {
  await client.subscribe(user, mediaType);
  if (mediaType === "audio") user.audioTrack.play();
});
View as Markdown

FaceTime call status

GET/get-facetime-call-status-api

Status is driven by call lifecycle events. Poll this endpoint to track ringingansweredended.

Query parameters
call_uuidstringRequired
The call_uuid returned by POST /start-facetime-call-api.
call_status values
initiatedringingansweredendeddeclinedno_answerfailed
RequestcURL
curl -G https://api.tryprojectblue.com/get-facetime-call-status-api \
  -H "Authorization: Bearer YOUR_API_KEY" \
  --data-urlencode "call_uuid=ftc_8a2b1c3d4e5f6g7h"
Response200 OK
{
  "status": "OK",
  "call_uuid": "ftc_8a2b1c3d4e5f6g7h",
  "call_status": "answered",
  "direction": "outbound",
  "address": "+15551234567",
  "answered_at": "2026-07-18T18:04:12.000Z",
  "ended_at": null
}
View as Markdown

End a FaceTime call

POST/end-facetime-call-api
Request body
call_uuidstringRequired
The call_uuid returned by POST /start-facetime-call-api.
Daily dial limit
Each account may dial up to 40 unique destinations per calendar day (America/Los_Angeles). Redials to a number already called that day do not count. Exceeding the limit returns 429 with error_code: FACETIME_DAILY_LIMIT_REACHED plus uniqueDestinationsToday and limit fields.
Status codes
200
Call started / status returned
400
Invalid pb_line_id or phone_number
401
Missing or invalid API key
403
FACETIME_NOT_ENABLED — FaceTime Audio not enabled on this account
404
Call not found (status/end endpoints)
429
Rate limit exceeded or daily FaceTime dial limit reached
502
Device error placing the call
500
Internal server error
RequestcURL
curl -X POST https://api.tryprojectblue.com/end-facetime-call-api \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "call_uuid": "ftc_8a2b1c3d4e5f6g7h"
  }'
Response200 OK
{
  "status": "OK"
}
View as Markdown

Voice Calling SDK

Place and receive calls on your Project Blue lines from your own web app with @tryprojectblue/dialer. Your API key stays on your server; the browser holds only a short-lived token scoped to one line.

FaceTime Audio is a separate, native API — see FaceTime Audio. This section covers regular phone calls through the Project Blue dialer. Phone numbers and caller ID are managed by Project Blue; you never touch a carrier account.

How it works

  1. Your server calls the token mint with your API key and a lineId, and forwards the response to the browser.
  2. The SDK takes a tokenProvider that calls your server. It handles refresh, the call lifecycle, inbound registration, and your plan's daily minute allowance.
  3. Your UI calls dialer.call(number) and listens to events on the returned call.
Two hosts, on purpose
Lines come from the messaging API at api.tryprojectblue.com/get-lines. Tokens come from the app at app.tryprojectblue.com/api/v1/dialer/token. The same API key works on both. Everything else in this reference lives on the first host; only the token mint lives on the second.

Before you start

You need
Where
An API key
Dashboard → Settings → API Keys. Server-side only. Never ship it in a browser bundle or a mobile app.
A Dialer subscription
Dashboard → Dialer. Tokens are refused with dialer_subscription_required until a plan is active.
Approved business registration
Settings → Phone Compliance. Until approved, the mint answers dialer_not_provisioned.
A paid line
Trial and shared lines cannot place SDK calls. Inbound-only lines cannot either.
A browser
The SDK is browser-only: it needs WebRTC and a microphone. Ships ESM and CommonJS with TypeScript types.

The Settings → API Keys page in the dashboard shows these same steps with your account's status filled in, so it is the fastest way to see which prerequisite is missing.

Installnpm
npm install @tryprojectblue/dialer
The whole loop

Your server holds the API key and proxies the mint.

// Your server. The API key never leaves it.
app.post('/dialer-token', requireLogin, async (req, res) => {
  const upstream = await fetch('https://app.tryprojectblue.com/api/v1/dialer/token', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.PROJECT_BLUE_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ lineId: req.user.projectBlueLineId }),
  });

  // Forward status, body and the rate-limit hint as-is; the SDK understands them.
  // Never let a non-JSON 502/503 from a proxy throw.
  const retryAfter = upstream.headers.get('retry-after');
  if (retryAfter) res.set('Retry-After', retryAfter);
  const body = await upstream
    .json()
    .catch(() => ({ error: 'Token service unavailable', code: 'dialer_unavailable' }));
  res.status(upstream.status).json(body);
});
View as Markdown

Mint a token

POSThttps://app.tryprojectblue.com/api/v1/dialer/token

Exchange your API key for a 15-minute voice token pinned to one line. Call this from your server whenever the SDK's tokenProvider asks, and return the body to the browser as-is.

Server to server only
The request carries your workspace API key, so it must never be made from a browser or a mobile app. Put an authenticated route in front of it on your own backend — the snippets in the rail are complete examples — and have that route decide which lineId the signed-in user may call from.
Headers

Authorization: Bearer YOUR_API_KEY and Content-Type: application/json. The same key you use against api.tryprojectblue.com.

Request body
lineIdstringRequired
The line to call from, exactly as returned by GET https://api.tryprojectblue.com/get-lines with the same API key. Calls are placed from that line's number and count toward its dialer plan. Shared and trial lines are refused.
Response
tokenstringRequired
Opaque, short-lived voice token scoped to one line. Hand the whole response body to the SDK's tokenProvider; never store the token or build on its contents.
expiresAtstring (ISO 8601)Required
When the token stops working. Always 15 minutes from mint.
ttlSecondsnumberRequired
Token lifetime in seconds. Currently 900.
lineIdstringRequired
The line this token is pinned to, echoed back.
inboundEnabledbooleanRequired
Whether this token can also receive calls. True only when the line has an inbound number and someone in your workspace is in its ring set. Can differ from one mint to the next.
maxCallsPerTokennumberRequired
Outbound calls one token may place before it must be refreshed. Currently 10. The SDK refreshes ahead of this limit, so you never hit it in normal use.
allowanceobject | nullRequired
Today's minute allowance for the workspace and for this line. Null only when the plan could not be read at mint time; the token is still valid.

Forward it unchanged

Return the upstream status, body, and Retry-After header to the browser exactly as received. The SDK reads expiresAt, maxCallsPerToken, inboundEnabled, and allowance to schedule its own refreshes and to raise typed errors. If you reshape the body, refresh timing and error handling degrade to defaults.

Guard the .json() call. A proxy in front of the app can return a non-JSON 502 or 503, and an unhandled throw in your route becomes a generic 500 in the browser instead of a retryable token_unavailable.

Refusals

Every non-2xx body is { error, code }, plus extra fields on the cap and rate-limit cases. Unlike the messaging API, code is always present here, and the SDK maps it to a DialerError code for you.

Status
code
SDK code
Meaning
401
invalid_api_key
token_unavailable
Missing, revoked, or wrong API key.
400
invalid_line_id
line_unavailable
lineId missing or not a value from /get-lines. Refused before the rate-limit slot is spent.
404
line_not_found
line_unavailable
Not one of this workspace's lines. Returned for other workspaces' lines too — existence is never confirmed.
403
line_inbound_only
line_unavailable
The line receives calls but cannot place them.
403
line_not_eligible
line_unavailable
Shared and trial lines are not an SDK surface.
403
dialer_subscription_required
subscription_required
No Dialer add-on on the workspace.
402
account_paywalled
account_paywalled
The account is past due.
402
dialer_cap_reached
cap_reached
The workspace or this line has used today's minutes. Body carries used, included, cap, periodEnd.
404
dialer_not_provisioned
not_provisioned
Business registration (Phone Compliance) not yet approved, so calling is not provisioned.
403
api_key_unsupported
token_unavailable
A legacy key that cannot mint. Create a new key in Settings → API Keys.
429
rate_limited
rate_limited
Too many mints for this key. Body carries retryAfterSeconds; a Retry-After header is set.
500
dialer_misconfigured
token_unavailable
Calling is not fully configured for this account. Contact support.
503
dialer_unavailable
token_unavailable
Token service temporarily unavailable. Retry with backoff.
The mint has its own rate limit
This endpoint allows 30 mints per minute per API key, separate from the 60 requests per minute on the messaging API. A well-behaved SDK session mints once at start, then roughly once every 14 minutes and once per 10 calls, so the limit only matters if your server mints on every page load. Malformed requests are refused before they consume a slot.
Status codes
200
Token minted
400
invalid_line_id
401
invalid_api_key
402
dialer_cap_reached or account_paywalled
403
dialer_subscription_required, line_inbound_only, line_not_eligible, api_key_unsupported
404
line_not_found or dialer_not_provisioned
429
rate_limited — read retryAfterSeconds
500
dialer_misconfigured or internal_error
503
dialer_unavailable — retry with backoff
Request

Server to server only. Note the app host.

curl -X POST https://app.tryprojectblue.com/api/v1/dialer/token \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "lineId": "0b3e0c59-ff0b-4110-faf6-6f976339a8c8" }'
Response
{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImN0eSI6InR3aWxpby1mcGE7dj0xIn0…",
  "expiresAt": "2026-09-04T12:15:00.000Z",
  "ttlSeconds": 900,
  "lineId": "0b3e0c59-ff0b-4110-faf6-6f976339a8c8",
  "inboundEnabled": true,
  "maxCallsPerToken": 10,
  "allowance": {
    "state": "ok",
    "used": 30,
    "included": 180,
    "cap": 540,
    "pct": 16,
    "periodEnd": "2026-09-05T00:00:00.000Z",
    "line": { "used": 30, "cap": 540, "capped": false }
  }
}
View as Markdown

The dialer session

ProjectBlueDialer is one calling session for one line. Create it with a tokenProvider, call start(), and keep it alive for as long as the user can place or receive calls.

Options
tokenProvider() => Promise<TokenResponse | ErrorBody | string>Required
Called whenever the SDK needs a token: at start(), before expiry, and before a token's call limit is reached. Must call your own server. Return the mint response body as-is (recommended), or just the token string. To surface a refusal, throw mintErrorFromResponse(status, body) — or return the error body with its HTTP status added.
refreshLeadSecondsnumberdefault: 60
Refresh the token this many seconds before it expires. Minimum 5. Tokens live 15 minutes, so keep it well under that.
loggerconsole-like | nulldefault: console (warn/error only)
Where the SDK logs. Pass an object with debug/info/warn/error, or null to silence it entirely.
edgestring | string[]
Preferred media region, e.g. "ashburn" or ["ashburn", "roaming"]. Leave unset unless you have measured a reason to pin one.
playIncomingRingtonebooleandefault: true
Play a ringtone in the browser when an inbound call arrives. Set false to render your own.

Writing the tokenProvider

It runs whenever the SDK needs a token: at start(), about a minute before the current token expires, and before a token has been used for its tenth call. It must call your server, never the mint directly.

Return the mint body unchanged. To report a refusal, throw mintErrorFromResponse(res.status, body) — it returns null on a 2xx and the matching typed error otherwise, so the three-line pattern in the rail is the whole thing. If you would rather return the error body, add status to it first; without it the SDK reports 402 for a cap refusal and 400 for everything else.

Methods

Method
Returns
Notes
start()
Promise<void>
Fetches the first token and connects. Resolves once calls can be placed. Calling it twice is a no-op.
call(to)
Promise<DialerCall>
Places an outbound call. to must be E.164 with the leading +; spaces, dashes and parentheses are stripped, but a country code is never guessed. One call at a time — a second call() while one is active throws invalid_state.
refreshToken()
Promise<void>
Forces a refresh now. Usually unnecessary; concurrent calls share one in-flight refresh.
destroy()
Promise<void>
Hangs up any call, stops refreshing, releases the microphone, and removes all listeners. Idempotent. Call it on logout or unmount.
isReady()
boolean
Started, not destroyed, and holding a token.
Getters
Property
Type
Notes
allowance
DialerAllowance | null
The latest allowance the server sent with a token. Null before start().
inboundEnabled
boolean
Whether the current token lets this session receive calls.
activeCall
DialerCall | null
The call in progress, if any.

Events

Subscribe with dialer.on(event, handler); off and once work the same way.

Event
Payload
When
ready
{ inboundEnabled }
Calls can be placed. Fires again after every successful token refresh, with the current inbound state.
allowance
DialerAllowance
A fresh allowance arrived with a token. Warn users on state: "soft".
capReached
DialerCapReachedError
The workspace or line hit its cap. No new tokens until periodEnd; a call in progress continues.
incoming
DialerCall
Someone is calling the line this session is scoped to. Call accept() or reject().
tokenRefreshFailed
DialerError
A background refresh failed. The session keeps its current token until it expires and retries with backoff.
error
DialerError
Session-level error, e.g. inbound registration failed. Calls carry their own error events.
destroyed
destroy() completed.

Token refresh, in detail

You never manage tokens directly, but knowing the schedule explains what your server will see. The session refreshes 60 seconds before expiry by default, immediately before the call that would exceed maxCallsPerToken, and whenever the voice service reports the token invalid. A failed refresh emits tokenRefreshFailed and retries after 5, 15, then 45 seconds; the current token keeps working until it expires.

A cap_reached refusal is different: the session emits capReached, stops retrying, and wakes itself one second after periodEnd. A call that is already up is never cut off — caps apply at mint time only.

Start a sessionbrowser.ts
import { ProjectBlueDialer, mintErrorFromResponse } from '@tryprojectblue/dialer';

const dialer = new ProjectBlueDialer({
  async tokenProvider() {
    // Calls YOUR server, never Project Blue directly.
    const res = await fetch('/dialer-token', { method: 'POST' });
    const body = await res.json();
    const error = mintErrorFromResponse(res.status, body);
    if (error) throw error;
    return body;
  },
});

dialer.on('ready', ({ inboundEnabled }) => setStatus(inboundEnabled ? 'Ready' : 'Ready (outbound only)'));
dialer.on('allowance', (a) => {
  if (a.state === 'soft') showBanner(`${a.pct}% of today's minutes used`);
});
dialer.on('capReached', (error) => showBanner(error.message));

await dialer.start();
Tear downbrowser.ts
// On logout, route change or component unmount.
// Hangs up any call, stops refreshing, releases the microphone. Idempotent.
await dialer.destroy();
View as Markdown

Calls

A DialerCall is one call. Outbound calls come back from dialer.call(); inbound calls arrive on the session's incoming event and must be accepted or rejected.

Fields
Field
Type
Notes
direction
"outbound" | "inbound"
Which way the call was placed.
remoteNumber
string | null
The number you dialed, or the caller's number, in E.164 when known.
callerName
string | null
Inbound only. The caller's display name from your CRM, when the line has one.
lineName
string | null
Inbound only. The name of the line being called.
Methods
Method
Notes
accept()
Inbound only. Answer the call.
reject()
Inbound only. Decline without answering.
hangup()
End the call from your side.
mute(shouldMute = true)
Mute or unmute the microphone. Emits mute.
isMuted()
Current mute state.
sendDigits(digits)
DTMF tones for phone menus, e.g. "1" or "123#".
status()
One of the statuses below.
isActive()
True from connecting through reconnecting; false once closed.
status() values
pendingconnectingringingopenreconnectingclosed
Value
Meaning
pending
Created, no signaling yet.
connecting
Signaling in progress.
ringing
The remote side is being rung.
open
Media is flowing.
reconnecting
Lost the connection and trying to recover.
closed
Ended.

Events

Event
Payload
When
ringing
{ hasEarlyMedia }
Outbound: the remote side is being rung. hasEarlyMedia means carrier audio is already playing.
accepted
Media is flowing.
disconnected
The call ended, by either side.
cancelled
Inbound: the caller hung up before you answered.
rejected
You called reject(), or the remote side declined.
error
DialerError
The call failed.
reconnecting
DialerError
Connection lost; the SDK is trying to recover.
reconnected
Recovered.
mute
boolean
Mute state changed.
warning
string
Non-fatal media quality warning, e.g. high-jitter.
warningCleared
string
A previous warning cleared.

Exactly one of disconnected, cancelled, or rejected fires when a call ends, and never more than once. Clear your UI on all three.

Inbound calls

A session receives calls only while its token was minted with inboundEnabled: true. That requires the line to have an inbound number and at least one person in your workspace in its ring set; the SDK then rings alongside them, never instead of them. A line with nobody in its ring set still answers "no one is available", SDK or not.

Because inboundEnabled is evaluated at every mint, it can change mid-session. Read the value from each ready event rather than caching it at start, and show an inbound UI only when it is true.

Answering needs a user gesture
Browsers block audio until the page has been interacted with. Wire accept() to a button the user clicks; do not auto-answer from the incoming handler.
Outboundbrowser.ts
const call = await dialer.call('+15551234567'); // E.164, leading + required

call.on('ringing', ({ hasEarlyMedia }) => setStatus(hasEarlyMedia ? 'Ringing…' : 'Connecting…'));
call.on('accepted', () => setStatus('Connected'));
call.on('disconnected', () => setStatus('Ended'));
call.on('reconnecting', () => setStatus('Reconnecting…'));
call.on('warning', (name) => console.warn('call quality:', name));

muteButton.onclick = () => call.mute(!call.isMuted());
keypad.onpress = (digit) => call.sendDigits(digit);
hangupButton.onclick = () => call.hangup();
Inboundbrowser.ts

Only fires when the token was minted with inboundEnabled: true.

dialer.on('incoming', (call) => {
  showIncomingUi(call.remoteNumber, call.callerName, call.lineName, {
    answer: () => call.accept(),
    decline: () => call.reject(),
  });
  call.on('cancelled', hideIncomingUi); // caller hung up before you answered
  call.on('accepted', () => setStatus('Connected'));
  call.on('disconnected', () => setStatus('Ended'));
});
View as Markdown

Errors & limits

Every rejection from the SDK is a DialerError with a stable code, the HTTP status of the mint response when there was one, and the original cause. Two subclasses carry extra fields.

Classes
Class
code
Extra fields
DialerError
any below
code, status, cause
DialerCapReachedError
cap_reached
used, included, cap, periodEnd (Date)
DialerRateLimitedError
rate_limited
retryAfterSeconds
Codes
code
Meaning
cap_reached
Daily minute cap hit. DialerCapReachedError with used, included, cap, periodEnd. Nothing to retry until periodEnd.
rate_limited
Your server is minting too fast for this key. DialerRateLimitedError with retryAfterSeconds.
subscription_required
No Dialer add-on on the workspace.
account_paywalled
The account is past due.
line_unavailable
The line id is unknown, not yours, inbound-only, or a shared/trial line.
not_provisioned
Business registration not yet approved.
token_unavailable
The tokenProvider threw, returned something that was not a token, or the mint failed for another reason.
invalid_state
call() before start(), after destroy(), or while another call is in progress.
microphone_unavailable
The browser denied microphone access or has no input device.
connection_failed
Could not reach the voice service.
call_failed
Anything else, including a malformed number passed to call(). See error.cause.

The mapping from the mint's server codes to these is in Mint a token. Branch on code or use instanceof for the two subclasses; do not match on message, which is written for end users and may change.

Plans, caps and minutes

Every call is metered against your Dialer plan in whole minutes. The allowance that arrives with each token tells you where you stand today, for the workspace and for the token's line. Three things follow from it:

  1. Soft threshold. allowance.state becomes "soft" as you approach the included minutes. Nothing is refused; show a banner.
  2. Hard stop. When the workspace or this line reaches its daily cap, the next mint returns 402 dialer_cap_reached and the SDK emits capReached. New calls fail at the mint; calls in progress continue. The session resumes on its own after periodEnd, midnight UTC.
  3. Per-line caps. A capped line is a hard stop for tokens pinned to it even while other lines on the account keep calling. In that case used and cap in the 402 body are the line's figures, not the workspace's.

Limits

Limit
Value
What happens
Token lifetime
15 min
SDK refreshes 60 s early by default (refreshLeadSeconds).
Calls per token
10
SDK refreshes before the eleventh call; a well-behaved client never hits this.
Mints per API key
30 / min
429 rate_limited with retryAfterSeconds. Separate from the messaging API's 60 / min.
Concurrent calls per session
1
call() throws invalid_state while one is active. Open a second session for a second simultaneous call.
Daily minutes
per plan
Read allowance. Resets at midnight UTC.
Package
@tryprojectblue/dialer on npm, MIT licensed, ESM + CJS with bundled types, no framework dependency. Its README repeats the essentials of this section.
Handle errorsbrowser.ts
import { DialerError, DialerCapReachedError, DialerRateLimitedError } from '@tryprojectblue/dialer';

try {
  await dialer.call(number);
} catch (error) {
  if (error instanceof DialerCapReachedError) {
    // Nothing to retry until error.periodEnd (midnight UTC).
    disableDialPad(`Daily minutes used. Calling resumes ${error.periodEnd?.toLocaleTimeString()}.`);
  } else if (error instanceof DialerRateLimitedError) {
    setTimeout(retry, error.retryAfterSeconds * 1000);
  } else if (error instanceof DialerError && error.code === 'microphone_unavailable') {
    showBanner('Allow microphone access to place calls.');
  } else if (error instanceof DialerError) {
    showBanner(error.message); // error.code, error.status, error.cause
  } else {
    throw error;
  }
}
View as Markdown

Webhooks

Webhooks let you receive real-time notifications when messages are sent or received. Configure webhooks from within the Project Blue dashboard alongside your API keys.

Configuration

In the Project Blue app, you can:

  • Paste the webhook URL you want to receive events at
  • Toggle whether the webhook fires for outbound messages, inbound messages, or both
  • Send test payloads to verify your endpoint is working
Webhook configuration lives beside your API keys.

Delivery

Your endpoint should answer 2xx. Anything else — including a network error or a timeout — counts as a failure.

There are no retries. Each event is delivered exactly once; a failed delivery is not queued or replayed, so a webhook is not a durable log. Reconcile with /get-messages-api if you need guaranteed coverage.

After 20 consecutive failures the webhook is automatically disabled. The counter resets on the first success. Today that happens silently — there is no email and no dashboard banner, so check the Webhooks tab if events stop arriving.

Webhook payload

The direction field indicates whether the message was inbound or outbound.

messagestringRequired
The text content of the message.
destinationstringRequired
The phone number the message was sent to, in E.164 format.
receivedAtstringRequired
ISO 8601 timestamp of when the message was received.
directionstringRequired
Either "inbound" or "outbound", based on message direction.
messageIdnumberRequired
Unique numeric identifier for the message.
guidstringRequired
Globally unique message identifier.
linePhoneNumberstringRequired
The Project Blue line phone number associated with this message.
HubSpot accounts receive a wider payload
On accounts whose webhooks are configured through the HubSpot variant of the Webhooks tab, every field above is still present, plus contactPhoneNumber, dateReceived (a legacy alias for receivedAt), hubspotContactId (number), and hubspotContactIdText (the same id as a string). Those webhooks have no inbound/outbound toggles and are not covered by the auto-disable behaviour described above.
Payload
{
  "message": "Yes, I'm interested! When can we schedule?",
  "destination": "+15551234567",
  "receivedAt": "2026-03-04T18:30:00.000Z",
  "direction": "inbound",
  "messageId": 456,
  "guid": "sample-guid-1234",
  "linePhoneNumber": "+15559876543"
}
View as Markdown

Supported media

Use the mediaAttachmentUrl field to send rich media with your messages. The following formats are supported.

Images

JPEG / JPGPNGGIFWebP

Videos

MP4MOVAVIMKVFLVWebM

Contact cards

VCF / vCard
View as Markdown

Voice memos & audio

Use audioAttachmentUrl to send audio files as voice memos. The following formats are supported.

M4AMP3WAVOGGWebMCAF
AI voice memos
Set enableAiVoiceMemo to true and include a message to generate a natural-sounding voice memo via text-to-speech. No audio file needed — we generate it for you.
View as Markdown

Rate limits

Every API-key endpoint allows 60 requests per minute, counted per API key in a rolling 60-second window.

Each response carries RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset. A rejected request also carries Retry-After, and the body repeats the wait as retryAfterSeconds.

The limit is counted before your key is checked
Requests are bucketed by the token in the Authorization header, and that happens before the key is validated. Requests made with a revoked or mistyped key still consume that token's budget, and requests with no Authorization header at all share a single per-IP bucket.
Two different limits return 429
This per-minute limit is not the only one. FaceTime separately caps dialling at 40 unique destinations per calendar day and returns 429 with error_code: FACETIME_DAILY_LIMIT_REACHED. Branch on error_code rather than on the status alone: waiting retryAfterSeconds will never clear the daily cap.
Response429
{
  "error": "Rate limit exceeded",
  "message": "Too many requests. Please wait before sending more messages.",
  "retryAfterSeconds": 60
}
View as Markdown

Error handling

The API uses standard HTTP status codes. All error responses include a JSON body with an error field describing what went wrong.

Status codes
200
Message sent successfully
400
Invalid request body or missing required fields
401
Missing or invalid API key
403
Forbidden — e.g. endpoint unavailable on trial accounts (see Trial accounts), unverified trial destination, or opted-out contact
409
Conflict — a group already exists on another line, a Workflow run is already active for this contact, or a trial account has no shared line provisioned
429
Rate limit exceeded (60 requests/minute per key), or the FaceTime daily dial cap
500
Internal server error

Two error bodies are worth special-casing. Pagination depth too deep means offset + limit exceeded 2000 on list messages or call logs — narrow the query by date or line rather than paging deeper. This trial account is not provisioned on a shared line is a 409 that only trial accounts see; it means the destination is verified but the account has no shared line yet.

Response401 Unauthorized
{
  "error": "Missing or invalid Authorization header"
}