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:

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

  1. Install the PhoneGateway app on an Android phone with a SIM.
  2. In the dashboard, open Phones, add a phone and scan the code with it.
  3. Under API keys, create a key.
  4. 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

POST/v1/messages
FieldTypeNotes
tostringRecipient, E.164. Required.
bodystringThe text, up to 1,600 characters. Long texts are sent as several segments. Required.
refstringYour 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_idstringSend from a particular phone. Default: the active phone that checked in most recently.
idempotency_keystringRetry 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:

StatuscodeWhy
400bad_number · empty · too_longThe request itself is wrong.
409opted_outThis 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.
409quiet_hoursIt'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.
409phone_unavailableThe phone you named is paused or offline.
429rate_limitedThe phone's hourly or daily cap is reached.
503no_phone · disabledNo connected phone on the account, or sending is turned off in settings.

The message object

FieldMeaning
idmsg_…
directionoutbound (you sent it) or inbound (they texted you)
statusOutbound: queued → leased (a phone took it) → sent → delivered or failed. Inbound: received.
contactThe other person's number
line_numberYour phone's own number, when known
body, ref, sourceThe text, your reference, and where it came from: api, dashboard, auto_reply or phone
device_id, segments, errorWhich phone, how many carrier segments, and the failure reason if any
queued_at, sent_at, delivered_at, failed_at, created_atUnix seconds
GET/v1/messages/:id

One message by id.

History

GET/v1/messages

Newest 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

GET/v1/conversations

One 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.

GET/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.

POST/v1/devices

Add 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.

POST/v1/devices/:id/pair

A fresh pair link for a replaced or lost phone. The old phone's key stops working the moment this is called.

PATCH/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.

DELETE/v1/devices/:id

Disconnects 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.

GET/v1/opt-outs
POST/v1/opt-outs

{"phone_number": "+1…", "reason": "asked by phone"} adds someone yourself.

DELETE/v1/opt-outs/:phone

Removes a number. Do this only when the person asked you to text them again.

Settings

GET/v1/settings PATCH/v1/settings
KeyDefaultMeaning
enabledtrueMaster switch. Off refuses new sends (auto-replies still go).
max_per_hour30Segments per phone per rolling hour (1 to 200).
max_per_day150Segments per phone per rolling day (1 to 1,000).
min_gap_seconds10Pause between a phone's sends (0 to 600). Auto-replies skip it.
max_job_age_seconds21600A text not sent within this long fails instead of going out stale (5 minutes to 7 days).
quiet_hours_start / quiet_hours_end21 / 8Local 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

GET/v1/keys

Each key's id, label, last use and revocation time. Never the secret.

POST/v1/keys

{"label": "Zapier"}. Returns api_key once. Up to ten live keys.

DELETE/v1/keys/:key_id

Revokes a key. Requests with it fail from the next one.

Stats

GET/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.

GET/v1/webhooks POST/v1/webhooks
POST /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
PATCH/v1/webhooks/:id DELETE/v1/webhooks/:id

Change url, events or active; or remove it with its delivery log.

GET/v1/webhooks/:id/deliveries

The 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.

typedata
message.receivedmessage_id, contact, line_number, body, ref, device_id, received_at, keyword. keyword is opted_out, opted_in, help or null.
message.sentmessage_id, contact, ref, source, device_id, sent_at
message.deliveredmessage_id, contact, ref, source, device_id, delivered_at
message.failedmessage_id, contact, ref, source, device_id, error
contact.opted_outcontact, 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.

POSThttps://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
ToolWhat it does
send_textSend a text to one person from your number (to, body, optional device_id, ref). Same checks and codes as POST /v1/messages.
list_conversationsThe inbox: one row per person with the last message.
read_threadEvery text to and from one number.
get_messageOne message with its current status.
list_phonesYour phones: online, battery, carrier, last check-in.
get_statsLast 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" }.

StatusMeaning
400Something in the request is wrong; error says what.
401Missing, wrong or revoked key.
403The key can't do that (an account key on a platform route).
404No such message, phone, webhook or key on your account.
409Refused by a rule: opted out, quiet hours, phone unavailable, a limit of ten reached.
429The phone's hourly or daily cap.
503No connected phone, or sending turned off.

Limits

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.

POST/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
GET/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.