# Project Blue API > Send iMessage and SMS, place FaceTime Audio calls, manage contacts, and run Workflows from your own application. REST over HTTPS with bearer token auth; an MCP server is also available. # 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](https://api.tryprojectblue.com/llms.txt) indexes them, [/llms-full.txt](https://api.tryprojectblue.com/llms-full.txt) is the whole reference in one file, and each section has its own `/llms/
.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 ## 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](https://api.tryprojectblue.com/#quickstart). ### Server URL ### 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 — Claude Code** Opens a browser to complete OAuth on first use. ```bash claude mcp add --transport http project-blue \ https://api.tryprojectblue.com/api/mcp ``` **Connect — Codex** Add the server, then authenticate. Codex picks HTTP transport from --url. ```bash codex mcp add project-blue \ --url https://api.tryprojectblue.com/api/mcp codex mcp login project-blue ``` **Connect — Cursor** ~/.cursor/mcp.json (global) or .cursor/mcp.json (per-project). ```json { "mcpServers": { "project-blue": { "url": "https://api.tryprojectblue.com/api/mcp" } } } ``` **Connect — VS Code** .vscode/mcp.json in the workspace. ```json { "servers": { "project-blue": { "type": "http", "url": "https://api.tryprojectblue.com/api/mcp" } } } ``` **Connect — Codex (TOML)** ~/.codex/config.toml, if you prefer editing config directly. ```toml [mcp_servers.project-blue] url = "https://api.tryprojectblue.com/api/mcp" auth = "oauth" ``` **Connect — Any client** Paste this URL into any MCP client's connector settings. ``` https://api.tryprojectblue.com/api/mcp ``` **Connect — mcp-remote** Adapter for clients that only accept stdio servers. ```json { "mcpServers": { "project-blue": { "command": "npx", "args": ["-y", "mcp-remote", "https://api.tryprojectblue.com/api/mcp"] } } } ``` **Plugin — Claude Code** Optional: same server, plus the Project Blue skill. ```bash claude plugin marketplace add try-pb/pb-api claude plugin install project-blue@project-blue ``` ## 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](https://api.tryprojectblue.com/#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](https://api.tryprojectblue.com/#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](https://api.tryprojectblue.com/#get-message). In production, register a [webhook](https://api.tryprojectblue.com/#webhooks) 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](https://api.tryprojectblue.com/#rate-limits) 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 key — cURL** Safe to run — reads your lines, sends nothing. ```bash export PB_API_KEY=proj_... curl -s https://api.tryprojectblue.com/get-lines \ -H "Authorization: Bearer $PB_API_KEY" ``` **What comes back — Paid** An array. Note a lineId and skip to step 3. ```json [ { "lineId": "a3f8c2d1-b4e9-4f2a-c8d3-e1f0a2b3c4d5", "devicePhoneNumber": "+15559876543", "customName": "Main Line" }, { "lineId": "9c2e4a1b-d3f8-4e1c-a2b4-c5d6e7f8a9b0", "devicePhoneNumber": "+15557654321", "customName": "Secondary Line" } ] ``` **What comes back — Trial** An envelope, not an array. Do step 2 first. ```json { "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." } ``` **What comes back — 401 — header** ```json { "error": "Missing or invalid Authorization header" } ``` **What comes back — 401 — key** ```json { "error": "Invalid API key" } ``` **3 · Send — cURL** ```bash 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" }' ``` **3 · Send — Node** ```javascript const res = await fetch( "https://api.tryprojectblue.com/send-api-message", { method: "POST", headers: { Authorization: `Bearer ${process.env.PB_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ message: "Hello from the Project Blue API", phone: "+15551234567", }), }, ); const body = await res.json(); if (!res.ok) throw new Error(body.error); console.log(body.status); // "done" ``` **3 · Send — Python** ``` import os, requests res = requests.post( "https://api.tryprojectblue.com/send-api-message", headers={"Authorization": f"Bearer {os.environ['PB_API_KEY']}"}, json={ "message": "Hello from the Project Blue API", "phone": "+15551234567", }, timeout=30, ) res.raise_for_status() print(res.json()["status"]) # "done" ``` **4 · Confirm — cURL** ```bash 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" ``` **4 · Confirm — 200 OK** ```json { "status": "OK", "data": [ { "message_handle": "pbm_outk6dawyUgbl-EjXCZk-g5mZnmwmSbilbaX", "content": "Hello from the Project Blue API", "to_number": "+15551234567", "service": "iMessage", "direction": "outbound", "status": "delivered", "sent_at": "2026-08-17T18:04:12.000Z" } ], "pagination": { "limit": 1, "offset": 0, "total": 1 } } ``` ## 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**. ![API Keys settings in the Project Blue dashboard](https://api.tryprojectblue.com/pb-app/pb_api.png) > **Keep your API keys secure** > > Never expose your API key in client-side code or public repositories. Always make API calls from your server. **Request — HTTP header** ```http Authorization: Bearer YOUR_API_KEY ``` **Example — cURL** ```bash 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" }' ``` ## 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 — 200 — trial /get-lines** ```json { "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." } ``` **Response — 400 — lineId rejected** ```json { "error": "lineId is not accepted on trial accounts. Sends route through the shared line automatically." } ``` ## 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 - `message` (string) — The text content of the message. Required unless mediaAttachmentUrl or audioAttachmentUrl is supplied. - `phone` (string, required) — Recipient phone number. Accepts many formats — we normalize to E.164 (e.g. +15551234567). - `lineId` (string (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. - `mediaAttachmentUrl` (string) — URL to an image, video, or contact card attachment. - `audioAttachmentUrl` (string) — URL to an audio file sent as a voice memo. - `enableAiVoiceMemo` (boolean) — When true, generates an AI voice memo from the message text using text-to-speech. - `shouldAutoCreateContact` (boolean) — 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. - `firstName` (string) — First name to persist on the auto-created Project Blue contact for this recipient. Applies to accounts without a connected CRM (external/API source). - `lastName` (string) — Last name to persist on the auto-created Project Blue contact for this recipient. Applies to accounts without a connected CRM (external/API source). - `email` (string) — Email address to persist on the auto-created Project Blue contact for this recipient. Applies to accounts without a connected CRM (external/API source). - `customFields` (object) — 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. - `idempotencyKey` (string) — 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 — Basic** ```bash 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" }' ``` **Request — Media** Attach an image, video, or contact card by URL. ```bash curl -X POST https://api.tryprojectblue.com/send-api-message \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "message": "Check out this property!", "phone": "+15551234567", "mediaAttachmentUrl": "https://example.com/property-photo.jpg" }' ``` **Request — AI voice memo** Generates speech from the message text — no audio file needed. ```bash curl -X POST https://api.tryprojectblue.com/send-api-message \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "message": "Hey, wanted to quickly touch base about your appointment tomorrow.", "phone": "+15551234567", "enableAiVoiceMemo": true }' ``` **Request — Line override** Force a specific sending line instead of load balancing. ```bash curl -X POST https://api.tryprojectblue.com/send-api-message \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "message": "Hi from a specific line.", "phone": "+15551234567", "lineId": "a3f8c2d1-b4e9-4f2a-c8d3-e1f0a2b3c4d5" }' ``` **Response — 200 OK** ```json { "success": true, "status": "done", "message": "Message added to queue", "messageType": "iMessage", "phone": "+15551234567", "devicePhoneNumber": "+15559876543", "mediaAttachmentUrl": null, "audioAttachmentUrl": null } ``` **Response — 200 — replayed** A retry collapsed onto an earlier send. Nothing was sent again. ```json { "success": true, "status": "done", "message": "Message added to queue", "messageType": "iMessage", "phone": "+15551234567", "devicePhoneNumber": "+15559876543", "mediaAttachmentUrl": null, "audioAttachmentUrl": null, "deduped": true } ``` **Response — 200 — in flight** The original send is still processing, so the channel is not resolved yet. ```json { "success": true, "status": "processing", "message": "Message added to queue", "phone": "+15551234567", "messageType": null, "devicePhoneNumber": null, "deduped": true } ``` **Response — 400 — invalid line** ```json { "error": "Invalid lineId. Expected a UUID v4 token returned from GET /get-lines." } ``` ## 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 - `numbers` (string[], 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. - `message` (string) — The text content of the message. Required unless an attachment is supplied. - `mediaAttachmentUrl` (string) — URL to an image, video, or contact card attachment. Mutually exclusive with audioAttachmentUrl. - `audioAttachmentUrl` (string) — URL to an audio file sent as a voice memo. Mutually exclusive with mediaAttachmentUrl. - `enableAiVoiceMemo` (boolean) — Generates a voice memo from message. - `groupName` (string) — Honored only when the group is created. Ignored on reuse. - `lineId` (string (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. - `idempotencyKey` (string) — 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](https://api.tryprojectblue.com/#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 — New group** groupName is honoured only when the group is created. ```bash 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" }' ``` **Request — Same group again** Same numbers resolve to the same thread — no groupId needed. ```bash 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": "Confirming 2pm Thursday." }' ``` **Response — 200 OK** ```json { "success": true, "status": "queued", "groupId": "pbg_...", "created": true, "groupStatus": "creating", "service": null, "recipients": ["+16025551234", "+14808405291"], "devicePhoneNumber": "+15559876543", "mediaAttachmentUrl": null, "audioAttachmentUrl": null } ``` **Response — 409** ```json { "error": "This group already exists on a different line.", "devicePhoneNumber": "+15559876543" } ``` **Response — 403** ```json { "error": "Trial accounts cannot create group chats." } ``` **Response — 400** ```json { "error": "numbers must be an array of 2 to 32 phone number strings." } ``` ## 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. **Request — Existing contacts only** The message is logged only if the contact already exists. ```bash 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 }' ``` ## 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 - `limit` (integer (1–100)) — Maximum number of messages to return. Defaults to 100. - `offset` (integer (≥ 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_id` (string) — 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_number` (string (E.164)) — External sender. Inbound-only filter. Combining with direction=outbound returns 400. - `to_number` (string (E.164)) — External recipient. Outbound-only filter. Combining with direction=inbound returns 400. - `created_at_gte` (string (ISO-8601)) — Lower bound on created_at. - `created_at_lte` (string (ISO-8601)) — Upper bound on created_at. - `sent_at_gte` (string (ISO-8601)) — Lower bound on sent_at. - `sent_at_lte` (string (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. **Request — cURL** ```bash 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 — 200 OK** ```json { "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 } } ``` **Response — 400 — filter** ```json { "error": "to_number only applies to outbound messages. Use pb_line_id to filter inbound messages by your own line." } ``` **Response — 400 — depth** offset + limit may not exceed 2000. ```json { "error": "Pagination depth too deep. offset+limit must be <= 2000" } ``` ## 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_handle` (string, required) — Opaque handle starting with pbm_, returned by /get-messages-api. **Request — cURL** ```bash curl https://api.tryprojectblue.com/get-message-api/pbm_outk6dawyUgbl-EjXCZk-g5mZnmwmSbilbaX \ -H "Authorization: Bearer YOUR_API_KEY" ``` **Response — 200 OK** ```json { "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 } } ``` **Response — 404** ```json { "error": "Message not found" } ``` **Response — 400** ```json { "error": "Invalid or missing message_handle" } ``` ## 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 - `phone` (string, required) — 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 **Request — cURL** ```bash 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 — 200 OK** ```json { "normalizedPhone": "+15551234567", "isIMessageAvailable": true } ``` **Response — 400** ```json { "error": "Invalid phone number format (Not a valid phone number). Please use format +12345678901" } ``` **Response — 409 — trial** Trial accounts only: the destination is verified but no shared line is provisioned. ```json { "error": "This trial account is not provisioned on a shared line" } ``` ## 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. **Request — cURL** ```bash curl -X GET https://api.tryprojectblue.com/get-lines \ -H "Authorization: Bearer YOUR_API_KEY" ``` **Response — 200 OK** ```json [ { "lineId": "a3f8c2d1-b4e9-4f2a-c8d3-e1f0a2b3c4d5", "devicePhoneNumber": "+15559876543", "customName": "Main Line" }, { "lineId": "9c2e4a1b-d3f8-4e1c-a2b4-c5d6e7f8a9b0", "devicePhoneNumber": "+15557654321", "customName": "Secondary Line" } ] ``` **Response — 401** ```json { "error": "Missing or invalid Authorization header" } ``` ## 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](https://api.tryprojectblue.com/#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](https://api.tryprojectblue.com/#update-contact), 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 - `phone` (string, required) — The contact's phone number. Accepts many formats — normalized to E.164. - `firstName` (string) — The contact's first name. - `lastName` (string) — The contact's last name. - `email` (string) — The contact's email address. - `customFields` (object) — 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. - `id` (string, required) — Opaque contact id. Pass it back as contactId when updating; do not parse it. - `firstName` (string | null, required) — The contact's first name, or null if never set. - `lastName` (string | null, required) — The contact's last name, or null if never set. - `phoneNumber` (string, required) — The contact's number in E.164. Note the asymmetry: requests take phone, responses return phoneNumber. - `email` (string | null, required) — The contact's email address, or null if never set. - `customFields` (object | null, required) — Whatever JSON metadata you last stored. Replaced wholesale on update, never merged. - `note` (string | null, required) — Free-form note stored on the contact. - `createdAt` (string (ISO-8601), required) — When the contact was first created. - `updatedAt` (string (ISO-8601), required) — When the contact was last modified. **Request — cURL** ```bash 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" } }' ``` **Response — 200 OK** ```json { "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" } } ``` ## 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 - `limit` (integer (1–100)) — Maximum number of contacts to return. Defaults to 100. - `offset` (integer (≥ 0)) — Pagination offset. Defaults to 0. - `phone` (string) — Look up a single contact by phone number (exact match after normalization to E.164). **Request — cURL** ```bash curl -G https://api.tryprojectblue.com/get-external-contacts \ -H "Authorization: Bearer YOUR_API_KEY" \ --data-urlencode "limit=25" ``` **Response — 200 OK** ```json { "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 } } ``` ## 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 - `contactId` (string, required) — The contact id, as returned by the create or list endpoints. - `firstName` (string) — New first name for the contact. - `lastName` (string) — New last name for the contact. - `email` (string) — New email address for the contact. - `note` (string) — Free-form note stored on the contact. - `customFields` (object) — 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. **Request — cURL** ```bash 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 — 200 OK** ```json { "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" } } ``` **Response — 404** ```json { "error": "Contact not found" } ``` **Response — 400 — no fields** ```json { "error": "At least one updatable field is required (firstName, lastName, email, customFields, note). phoneNumber cannot be updated." } ``` **Response — 400 — customFields** ```json { "error": "customFields JSON exceeds maximum length of 10000 characters" } ``` ## 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. **Request — cURL** ```bash curl -X GET https://api.tryprojectblue.com/get-flows \ -H "Authorization: Bearer YOUR_API_KEY" ``` **Response — 200 OK** ```json { "flows": [ { "id": "6f1c2a7e-9d4b-4c31-8a52-1e7f0b3d9c84", "name": "New Lead Follow-up" }, { "id": "b28d5f30-71ac-4e69-9f13-52c6ad8071be", "name": "Appointment Reminder" } ] } ``` ## 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_REPLY` — `ACTIVE` is the exception, not the rule. Statuses are uppercase: `ACTIVE`, `SLEEPING`, `WAITING_REPLY`, `COMPLETED`, `CANCELLED`, `FAILED`. ### Request body - `flowId` (string (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. - `phoneNumber` (string, required) — The contact's phone number. Accepts many formats — normalized to E.164. - `pb_line_id` (string (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 **Request — cURL** ```bash 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 — 200 OK** ```json { "success": true, "runId": 4182, "status": "SLEEPING", "currentNodeId": "node_wait_1", "pb_line_id": "a3f8c2d1-b4e9-4f2a-c8d3-e1f0a2b3c4d5" } ``` **Response — 400** ```json { "error": "Invalid flowId" } ``` **Response — 403** ```json { "error": "Contact is opted out / blocked" } ``` **Response — 404** ```json { "error": "Flow not found or not published" } ``` **Response — 409** ```json { "error": "An active run already exists for this contact in this flow" } ``` ## Cancel flow runs `DELETE POST /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 - `phoneNumber` (string, required) — The contact's phone number. Cancels all of this contact's active Workflow runs. Accepts many formats — normalized to E.164. **Request — cURL** ```bash curl -X DELETE https://api.tryprojectblue.com/cancel-flow-runs \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "phoneNumber": "+16025551234" }' ``` **Response — 200 OK** ```json { "success": true, "cancelled": 1 } ``` ## 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 - `limit` (integer (1–100)) — Maximum number of call logs to return. Defaults to 100. - `offset` (integer (≥ 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_id` (string (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_gte` (string (ISO-8601)) — Inclusive lower bound on answered_at. Note: only matches calls that were actually answered. - `answered_at_lte` (string (ISO-8601)) — Inclusive upper bound on answered_at. Note: only matches calls that were actually answered. ### CallLog object - `id` (string, required) — Internal call log id (stable, useful for dedupe). - `line_id` (string | null, required) — UUID-encoded PB line id. Round-trips with the pb_line_id filter. - `from_number` (string, required) — E.164 sender (your line). - `to_number` (string, required) — E.164 recipient. - `status` (string, required) — Call status (completed, no-answer, busy, failed, canceled, …). - `disposition` (string | null, required) — AI-assigned disposition derived from the call transcript. See the Dispositions table below for all possible values. - `transcript` (string | null, required) — Speaker-labelled transcript when one was generated. - `duration_seconds` (number | null, required) — Connected duration. null for calls that never connected. - `answered_at` (string (ISO-8601) | null, required) — When the call was answered. - `ended_at` (string (ISO-8601) | null, required) — When the call ended. - `recording_url` (string | null, required) — 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** ```bash 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" ``` **Request — Pagination** ```bash # page 1 curl ".../get-call-logs-api?limit=100&offset=0" # page 2 curl ".../get-call-logs-api?limit=100&offset=100" # stop when data.length < limit, or offset >= pagination.total ``` **Request — Last 24h** Outbound calls for one line in the last day. ```bash GTE=$(date -u -v-24H +"%Y-%m-%dT%H:%M:%SZ") # macOS curl -sS \ -H "Authorization: Bearer $PB_API_KEY" \ "https://api.tryprojectblue.com/get-call-logs-api?pb_line_id=$LINE_ID&answered_at_gte=$GTE" ``` **Request — By disposition** The endpoint has no disposition filter — filter client-side. ```bash curl -sS -H "Authorization: Bearer $PB_API_KEY" \ "https://api.tryprojectblue.com/get-call-logs-api?limit=100" \ | jq '.data | map(select(.disposition == "answered"))' ``` **Response — 200 OK** ```json { "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:///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 } } ``` **Response — 400 — line** ```json { "error": "Invalid or unavailable pb_line_id for this API key user." } ``` **Response — 400 — date** ```json { "error": "answered_at_gte is not a valid ISO-8601 date" } ``` **Response — 400 — depth** offset + limit may not exceed 2000. ```json { "error": "Pagination depth too deep. offset+limit must be <= 2000" } ``` ## 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](mailto: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_id` (string (UUID), required) — FaceTime-enabled line to call from. UUID returned from GET /get-lines. - `phone_number` (string (E.164), required) — Destination phone number. Flexible formats accepted; normalized to E.164. ### Response - `status` ("OK", required) — Present on every successful call. - `call_uuid` (string, required) — Identifies the call. Pass it to the status and end endpoints. - `agora` (object | null, required) — 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. - `appId` (string, required) — Agora application id. - `channelName` (string, required) — Channel to join for this call. - `token` (string, required) — Time-limited RTC token. Join promptly after starting the call. - `uid` (number, required) — The uid to join as. ### 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`](https://api.tryprojectblue.com/#facetime-status) for the outcome. **Request — cURL** ```bash 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" }' ``` **Response — 200 OK** ```json { "status": "OK", "call_uuid": "ftc_8a2b1c3d4e5f6g7h", "agora": { "appId": "your-agora-app-id", "channelName": "facetime-channel-abc123", "token": "agora-rtc-token...", "uid": 123456 } } ``` **Join the call — JavaScript** Tokens are time-limited — join promptly. Check res.agora is non-null first. ```javascript 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(); }); ``` ## FaceTime call status `GET /get-facetime-call-status-api` Status is driven by call lifecycle events. Poll this endpoint to track `ringing` → `answered` → `ended`. ### Query parameters - `call_uuid` (string, required) — The call_uuid returned by POST /start-facetime-call-api. ### call_status values `initiated`, `ringing`, `answered`, `ended`, `declined`, `no_answer`, `failed` **Request — cURL** ```bash curl -G https://api.tryprojectblue.com/get-facetime-call-status-api \ -H "Authorization: Bearer YOUR_API_KEY" \ --data-urlencode "call_uuid=ftc_8a2b1c3d4e5f6g7h" ``` **Response — 200 OK** ```json { "status": "OK", "call_uuid": "ftc_8a2b1c3d4e5f6g7h", "call_status": "answered", "direction": "outbound", "address": "+15551234567", "answered_at": "2026-07-18T18:04:12.000Z", "ended_at": null } ``` ## End a FaceTime call `POST /end-facetime-call-api` ### Request body - `call_uuid` (string, required) — 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 **Request — cURL** ```bash 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" }' ``` **Response — 200 OK** ```json { "status": "OK" } ``` ## 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](https://api.tryprojectblue.com/#facetime) 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. ![Twilio API key in Project Blue Settings → API Keys](https://api.tryprojectblue.com/pb-app/pb_twilio_webhook.png) [Use Twilio's Voice API documentation](https://www.twilio.com/docs/voice) 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. ## 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 in the Project Blue dashboard](https://api.tryprojectblue.com/pb-app/pb_webhook.png) ### 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`](https://api.tryprojectblue.com/#list-messages) 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. - `message` (string, required) — The text content of the message. - `destination` (string, required) — The phone number the message was sent to, in E.164 format. - `receivedAt` (string, required) — ISO 8601 timestamp of when the message was received. - `direction` (string, required) — Either "inbound" or "outbound", based on message direction. - `messageId` (number, required) — Unique numeric identifier for the message. - `guid` (string, required) — Globally unique message identifier. - `linePhoneNumber` (string, required) — 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 — Inbound** ```json { "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" } ``` **Payload — Outbound** ```json { "message": "Great! Let's book you in for Thursday at 2pm.", "destination": "+15551234567", "receivedAt": "2026-03-04T18:31:00.000Z", "direction": "outbound", "messageId": 789, "guid": "sample-guid-5678", "linePhoneNumber": "+15559876543" } ``` ## Supported media Use the `mediaAttachmentUrl` field to send rich media with your messages. The following formats are supported. ### Images `JPEG / JPG`, `PNG`, `GIF`, `WebP` ### Videos `MP4`, `MOV`, `AVI`, `MKV`, `FLV`, `WebM` ### Contact cards `VCF / vCard` ## Voice memos & audio Use `audioAttachmentUrl` to send audio files as voice memos. The following formats are supported. `M4A`, `MP3`, `WAV`, `OGG`, `WebM`, `CAF` > **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. ## 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](https://api.tryprojectblue.com/#facetime-end) 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. **Response — 429** ```json { "error": "Rate limit exceeded", "message": "Too many requests. Please wait before sending more messages.", "retryAfterSeconds": 60 } ``` ## 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](https://api.tryprojectblue.com/#list-messages) or [call logs](https://api.tryprojectblue.com/#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. **Response — 401 Unauthorized** ```json { "error": "Missing or invalid Authorization header" } ```