API reference
PhoneGateway gives the Android phone your business already owns a JSON API. You queue a text, the phone sends it from its own SIM, and the result comes back to you by polling or webhook. Replies land in the same place.
Overview
Base URL: https://api.phonegateway.app. All requests and responses are JSON (Content-Type: application/json). Times are Unix seconds. Phone numbers are E.164 (+19165550142); a bare 10-digit number is read as US.
Three kinds of caller use the API:
- Your account, with an API key made in the dashboard. It acts on your account only.
- The phone app, with a device key it gets when you scan a pair code. You never handle this one.
- A platform (software that offers PhoneGateway to its own customers), with a platform key. See Platforms.
Authentication
Send your key in the X-API-Key header. Keys look like ptl_<id>_<secret>, are shown once when created, and are stored hashed. Make and revoke them under API keys in the dashboard, or with the keys endpoints.
curl https://api.phonegateway.app/v1/account \
-H "X-API-Key: ptl_your_key"
{ "account": { "id": "acc_…", "name": "Bright Coat Painting", "email": "owner@…", "status": "active" } }
Keep keys on your server. A key in a web page or a mobile app can be read by anyone who looks. If one leaks, revoke it in the dashboard; sends with it stop at once.
Quick start
- Install the PhoneGateway app on an Android phone with a SIM.
- In the dashboard, open Phones, add a phone and scan the code with it.
- Under API keys, create a key.
- Send a text:
curl https://api.phonegateway.app/v1/messages \
-H "X-API-Key: ptl_your_key" \
-H "Content-Type: application/json" \
-d '{"to": "+19165550142", "body": "Hi Maria, your estimate is ready."}'
The response is 202 Accepted with the message in status queued. The phone picks it up within a few seconds and the status moves to sent, then delivered. Add a webhook to hear about each step, and about replies.
Send a text
/v1/messages| Field | Type | Notes |
|---|---|---|
| to | string | Recipient, E.164. Required. |
| body | string | The text, up to 1,600 characters. Long texts are sent as several segments. Required. |
| ref | string | Your own reference (an order id, a lead id), up to 128 characters. Replies from this person inherit the last ref you sent them, so you can match a reply to the thing it answers. |
| device_id | string | Send from a particular phone. Default: the active phone that checked in most recently. |
| idempotency_key | string | Retry safely: a second request with the same key returns the first message instead of sending again. Also accepted as the Idempotency-Key header. |
HTTP/1.1 202 Accepted
{ "message": { "id": "msg_8f2c…", "status": "queued", "contact": "+19165550142", "segments": 1, … },
"deduped": false }
A send can be refused before it's queued. Each refusal has an error sentence and a code:
| Status | code | Why |
|---|---|---|
| 400 | bad_number · empty · too_long | The request itself is wrong. |
| 409 | opted_out | This person replied STOP to you. Only they can undo that (by replying START), or you can remove them from the STOP list in the dashboard if they asked you to. |
| 409 | quiet_hours | It's night where they are (9 pm to 8 am by default, in the time zone of their area code). Queue it later or change the quiet hours. |
| 409 | phone_unavailable | The phone you named is paused or offline. |
| 429 | rate_limited | The phone's hourly or daily cap is reached. |
| 503 | no_phone · disabled | No connected phone on the account, or sending is turned off in settings. |
The message object
| Field | Meaning |
|---|---|
| id | msg_… |
| direction | outbound (you sent it) or inbound (they texted you) |
| status | Outbound: queued → leased (a phone took it) → sent → delivered or failed. Inbound: received. |
| contact | The other person's number |
| line_number | Your phone's own number, when known |
| body, ref, source | The text, your reference, and where it came from: api, dashboard, auto_reply or phone |
| device_id, segments, error | Which phone, how many carrier segments, and the failure reason if any |
| queued_at, sent_at, delivered_at, failed_at, created_at | Unix seconds |
/v1/messages/:idOne message by id.
History
/v1/messagesNewest first, 50 at a time (limit up to 200). Filters: contact, ref, direction (inbound/outbound), status. Page with before=<next_before> from the previous response.
GET /v1/messages?contact=%2B19165550142&limit=100
{ "messages": [ … ], "next_before": 1731020000 }
Conversations
/v1/conversationsOne row per person, newest activity first, with that person's latest message. This is the inbox view. Fetch a thread with GET /v1/messages?contact=….
{ "conversations": [ { "contact": "+19165550142", "last": { "direction": "inbound", "body": "Great, can you come Thursday?", … } } ] }
Phones
A phone (device) is an Android phone running the app, connected to your account by a one-time pair link. Up to ten per account.
/v1/devices{ "devices": [ { "id": "dev_…", "label": "Front desk", "phone_number": "+19165550199", "status": "active",
"paired": true, "carrier": "T-Mobile", "battery_level": 82, "charging": true, "last_seen_at": 1731024170, "seconds_since_seen": 4 } ] }
Status is active, offline (no check-in for three minutes), paused (by you, or automatically after three STOPs in a day; see paused_reason) or error.
/v1/devicesAdd a phone. Body: {"label": "Front desk"}. Returns the device plus a pair_page URL to show as a QR code, a pair_link (phonegateway://…) and pair_expires_at. The link works once and expires in 24 hours. The dashboard does this for you.
/v1/devices/:id/pairA fresh pair link for a replaced or lost phone. The old phone's key stops working the moment this is called.
/v1/devices/:id{"label": "…"} and/or {"status": "paused"} or "active". A paused phone keeps checking in but sends nothing; queued texts wait for it or another active phone.
/v1/devices/:idDisconnects the phone. Its unsent texts fail with phone removed; history stays.
STOP list
When someone texts STOP (or STOPALL, UNSUBSCRIBE, CANCEL, END, QUIT) to your phone, they go on your account's STOP list, get your opt-out confirmation, and every later send to them is refused with opted_out. START (or UNSTOP, YES) takes them off. HELP or INFO gets your help text. The list is per account: a STOP to you doesn't affect anyone else using PhoneGateway.
/v1/opt-outs/v1/opt-outs{"phone_number": "+1…", "reason": "asked by phone"} adds someone yourself.
/v1/opt-outs/:phoneRemoves a number. Do this only when the person asked you to text them again.
Settings
/v1/settings PATCH/v1/settings| Key | Default | Meaning |
|---|---|---|
| enabled | true | Master switch. Off refuses new sends (auto-replies still go). |
| max_per_hour | 30 | Segments per phone per rolling hour (1 to 200). |
| max_per_day | 150 | Segments per phone per rolling day (1 to 1,000). |
| min_gap_seconds | 10 | Pause between a phone's sends (0 to 600). Auto-replies skip it. |
| max_job_age_seconds | 21600 | A text not sent within this long fails instead of going out stale (5 minutes to 7 days). |
| quiet_hours_start / quiet_hours_end | 21 / 8 | Local hour (0 to 23) in the recipient's time zone. Equal values turn quiet hours off. |
| opt_out_message | "You're unsubscribed and won't get more texts from us." | Sent once on STOP. |
| help_message | "Reply STOP to unsubscribe." | Sent on HELP. |
PATCH /v1/settings
{ "max_per_hour": 20, "quiet_hours_start": 20, "help_message": "Bright Coat Painting. Call (916) 555-0199 or reply STOP to opt out." }
API keys
/v1/keysEach key's id, label, last use and revocation time. Never the secret.
/v1/keys{"label": "Zapier"}. Returns api_key once. Up to ten live keys.
/v1/keys/:key_idRevokes a key. Requests with it fail from the next one.
Stats
/v1/stats{ "last_24h": { "sent": 3, "delivered": 41, "failed": 1, "received": 18, "queued": 0 },
"devices": { "active": 1 }, "opt_outs": 2 }
Webhooks
A webhook is an https URL on your server that we POST to when something happens. Each event is tried right away and retried with backoff for about a day (8 tries) until your server answers with a 2xx. Up to ten webhooks per account.
/v1/webhooks POST/v1/webhooksPOST /v1/webhooks
{ "url": "https://yourapp.com/hooks/texts", "events": ["message.received", "message.failed"] } // or "events": "*"
201 { "webhook": { "id": "wh_…", … }, "secret": "whsec_…" } // the secret is shown once
/v1/webhooks/:id DELETE/v1/webhooks/:idChange url, events or active; or remove it with its delivery log.
/v1/webhooks/:id/deliveriesThe last 50 deliveries with status, attempts, your server's last HTTP status and the next retry time.
Events
Every delivery is a POST with a JSON body: { "id": "evt_…", "type": "…", "created": <unix>, "account_id": "acc_…", "data": { … } }. One event fans out to every webhook that wants it with the same id, so you can de-duplicate on it.
| type | data |
|---|---|
| message.received | message_id, contact, line_number, body, ref, device_id, received_at, keyword. keyword is opted_out, opted_in, help or null. |
| message.sent | message_id, contact, ref, source, device_id, sent_at |
| message.delivered | message_id, contact, ref, source, device_id, delivered_at |
| message.failed | message_id, contact, ref, source, device_id, error |
| contact.opted_out | contact, keyword, message_id. Stop texting them in your other systems too. |
Headers on every delivery: X-PTL-Event (the type), X-PTL-Delivery (whd_…, unique per webhook per event) and X-PTL-Signature.
Verifying signatures
X-PTL-Signature: t=<unix>,v1=<hex>, where v1 is HMAC-SHA256 of "<t>.<raw body>" with your webhook's secret. Check it on the raw request body, before parsing JSON, and reject timestamps older than five minutes.
// Node (Express with express.raw({ type: "application/json" }))
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(req, secret) {
const sig = Object.fromEntries(req.get("X-PTL-Signature").split(",").map(p => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(sig.t)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${sig.t}.`).update(req.body).digest("hex");
return expected.length === sig.v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(sig.v1));
}
# Python
import hmac, hashlib, time
def verify(headers, raw_body: bytes, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in headers["X-PTL-Signature"].split(","))
if abs(time.time() - int(parts["t"])) > 300: return False
expected = hmac.new(secret.encode(), parts["t"].encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
Answer 2xx quickly and do the work afterwards. A slow reply (over 8 seconds) counts as a failure and is retried.
AI agents (MCP server)
PhoneGateway is also an MCP server, so an AI agent can text from your phone through the same account, with the same limits and STOP list as the API. It speaks MCP over Streamable HTTP with no sessions and no sign-in flow: the agent sends your API key.
https://api.phonegateway.app/mcp# Claude Code
claude mcp add --transport http phonegateway https://api.phonegateway.app/mcp \
--header "X-API-Key: ptl_your_key"
# Codex, Cursor, ChatGPT and other MCP clients: Streamable HTTP at that URL,
# authenticated with X-API-Key or Authorization: Bearer ptl_your_key
| Tool | What it does |
|---|---|
send_text | Send a text to one person from your number (to, body, optional device_id, ref). Same checks and codes as POST /v1/messages. |
list_conversations | The inbox: one row per person with the last message. |
read_thread | Every text to and from one number. |
get_message | One message with its current status. |
list_phones | Your phones: online, battery, carrier, last check-in. |
get_stats | Last 24 hours of sent, delivered, failed and received. |
The server's instructions tell the agent to send only what the owner asked for, keep texts short, read the thread before replying and never text a number that asked to stop. A refused send (STOP list, quiet hours, a limit) comes back as a tool error carrying the API's error and code, so the agent can tell you why.
Errors
Errors are JSON: { "error": "a sentence you can show a person", "code": "optional_machine_code" }.
| Status | Meaning |
|---|---|
| 400 | Something in the request is wrong; error says what. |
| 401 | Missing, wrong or revoked key. |
| 403 | The key can't do that (an account key on a platform route). |
| 404 | No such message, phone, webhook or key on your account. |
| 409 | Refused by a rule: opted out, quiet hours, phone unavailable, a limit of ten reached. |
| 429 | The phone's hourly or daily cap. |
| 503 | No connected phone, or sending turned off. |
Limits
- Texts: up to 1,600 characters; GSM text splits into 153-character segments, emoji into 67-character segments. Caps count segments.
- Per account: 10 phones, 10 live API keys, 10 webhooks.
- Default per phone: 30 segments an hour, 150 a day, 10 seconds apart, quiet 9 pm to 8 am local to the recipient. All adjustable in settings, within the ranges shown there.
- A text queued longer than six hours (default) fails rather than going out stale.
- A phone that gets three STOPs in a day from its own sends is paused until you resume it.
These exist to keep your number from looking like a bulk sender to the carrier. PhoneGateway is for one-to-one business texting, not marketing blasts; see the terms.
Platforms
If your software serves many businesses, a platform key lets you create an account per customer and act for it. Send the platform key as X-API-Key and name the account with X-Account-Id. Platform keys are issued by hand; email us.
/v1/platform/accounts{ "name": "Joe's Plumbing", "email": "joe@example.com", "external_ref": "yourapp:joes-plumbing" }
201 { "account": { "id": "acc_…", … } } // the same external_ref returns the same account, with "existing": true
/v1/platform/accounts?external_ref=…Then every other endpoint works with X-Account-Id: acc_… added: make the customer's pair link, send for them, read their inbox. If the account has an email, that person can also sign in to the dashboard at phonegateway.app and see the same account.