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, and an optional lineId override.
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.
/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.

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.

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

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

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

Creates a contact, or idempotently upserts by phone number if one already exists. Use this when you want the contact to exist before any message is sent.

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 contact's phone number. Accepts many formats — normalized to E.164.
firstNamestring
The contact's first name.
lastNamestring
The contact's last name.
emailstring
The contact's email address.
customFieldsobject
Arbitrary JSON metadata (e.g. order IDs, links) shown in the contact details panel. Plain object only; max 10,000 characters when JSON-serialized.
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.
RequestcURL
curl -X POST https://api.tryprojectblue.com/create-external-contact \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "phone": "+15551234567",
    "firstName": "Jamie",
    "lastName": "Rivera",
    "email": "jamie@example.com",
    "customFields": {
      "orderId": "ord_18342",
      "plan": "pro"
    }
  }'
Response200 OK
{
  "contact": {
    "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"
  }
}
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

Calling

Voice calling via the dialer is not provided by the Project Blue API. Outbound and inbound calls are handled by Twilio. We give you a Twilio API key in the webapp so you can build calling yourself.

FaceTime Audio calls are provided natively by the Project Blue API — see FaceTime Audio above.

Your Twilio API key is already created for you. You don't add phone numbers or caller ID — our team provides those.

Getting your Twilio API key

You must be on an API or Zapier based account.

In the Project Blue portal, go to Settings API Keys. Your Twilio API key is exposed there.

Settings → API Keys exposes the Twilio key for voice.

Use Twilio's Voice API documentation to implement outbound and inbound calls.

SMS, iMessage, and MMS stay on the Project Blue API. Only voice calling uses your Twilio key and Twilio's API.
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"
}