## 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 } } ```