Documentation

Numbers API

A real phone number, driven from your own code. Send SMS and MMS, read replies, and — if you'd rather not run a cron — let us send on a schedule.

Have it written for you

Copy a complete brief and paste it into Claude Code, Cursor or any coding agent. It covers everything on this page, including the parts that are easy to get wrong.

Quickstart

You need your number's API key, which appears once in your portal when the number is set up. Send it as x-api-key.

bash
curl -X POST https://stone-production-d2ea.up.railway.app/api/fa/v1/messages \
  -H "x-api-key: fan_YOUR_KEY" \
  -H "content-type: application/json" \
  -d '{"to":"+12695550123","body":"hello"}'

That is the whole product. Everything below is detail.

Authentication

Every request carries x-api-key. Keys start with fan_ and are stored hashed — we cannot show you an existing one, only replace it. Rotate from the portal; the old key stops working immediately.

This is a server-side secret. There is no Origin check, because there is no browser involved — do not ship it to one.

Send a message

http
POST /api/fa/v1/messages

{
  "to": "+12695550123",
  "body": "On my way, ~15 min.",
  "mediaUrls": ["https://cdn.example.com/eta.png"],
  "clientRef": "job-4412-eta"
}

clientRef is an idempotency key. Repeat a request with the same one and you get 200 and the original message back instead of a second send — which makes a naive retry loop safe.

json
{
  "message": {
    "id": "…",
    "seq": 8412,
    "cursor": "fa1_OTAzMQ",
    "direction": "outbound",
    "status": "queued",
    "from": "+12695288874",
    "to": "+12695550123",
    "createdAt": "2026-08-01T17:04:11.221Z"
  }
}

Receive messages

One loop gets you everything: replies people send you and delivery updates for messages you sent.

http
GET /api/fa/v1/messages?since=<cursor>&limit=100
This feed is at-least-once. A message re-appears whenever its state changes — when queued becomes delivered, you will see that message again with a new cursor. Upsert on id; do not append blindly.

Pass nextCursor back verbatim on the next call. It is an opaque string — do not parse it or do arithmetic on it. Omit since on your first call and you start from now rather than replaying your whole history.

json
{
  "messages": [ … ],
  "nextCursor": "fa1_OTE4OA",
  "hasMore": false
}

Poll about every 15 seconds. An empty poll is cheap; 120 a minute is the ceiling.

Sort by seq (immutable insert order) when you display a conversation. Do not sort by createdAt.

MMS

Attach up to 10 public https URLs with mediaUrls. We hand them to the carrier, which fetches them directly — so they must be reachable from the public internet and not behind auth.

Inbound attachments arrive on the message's mediaUrls. Fetch the bytes through:

http
GET /api/fa/v1/media/:messageId/:index

That redirects to a signed URL valid for five minutes, so the link cannot be pasted anywhere permanent.

Contacts and tags

Anyone who texts you is saved automatically. Save people yourself to give them a name and tags — tags are what a schedule aims at.

http
POST   /api/fa/v1/contacts        { phoneNumber, displayName?, tags? }
GET    /api/fa/v1/contacts?tag=friends
PATCH  /api/fa/v1/contacts/:id
DELETE /api/fa/v1/contacts/:id

A contact who replied STOP carries optedOut: true and cannot be deleted — their opt-out has to outlive the record, or the next send would silently recreate them as subscribed.

Hosted schedules

Optional. Hand us the message, the cadence and who it goes to, and we send it — no cron host of your own.

http
POST /api/fa/v1/schedules

{
  "name": "Friday note",
  "body": "Happy Friday 🎉",
  "audience": { "kind": "tag", "tag": "friends" },
  "cadence": "weekly",
  "startsAt": "2026-08-07T22:00:00Z",
  "timezone": "America/Detroit"
}

Cadences are once, hourly, daily, weekly, monthly, or cron with a cronExpr. The friendly ones take their time of day, weekday and day of month from startsAt, read in your timezone — so a weekly schedule keeps its wall-clock time across a daylight-saving change.

Audiences are { "kind": "all" }, { "kind": "tag", "tag": "…" } or { "kind": "contacts", "contactIds": [...] }, and they are resolved at send time — someone tagged this morning is included, someone who unsubscribed is dropped.

Creating or editing one returns the next five fire times, so you can see what you just asked for before relying on it.

http
GET    /api/fa/v1/schedules
PATCH  /api/fa/v1/schedules/:id     { status: "paused" | "active", … }
DELETE /api/fa/v1/schedules/:id
GET    /api/fa/v1/schedules/:id/runs
Schedules fire at most once an hour. Anything faster belongs on your own server calling POST /v1/messages, where the volume is visibly yours.

/runs is the audit trail: what fired, when, how many it reached, and how many were skipped for an opt-out or the daily cap. A partial run is never reported as a complete one.

Errors and limits

Every failure has the same shape. Branch on code, not on the message text.

json
{
  "error": "That recipient replied STOP and has been unsubscribed…",
  "code": "opted_out"
}
CodeStatusMeans
missing_api_key401No x-api-key header
invalid_api_key401Key not recognised
subscription_inactive402Sending paused; reads still work
number_released403The number is no longer yours
not_found404No such message, thread or contact
invalid_phone422`to` isn't E.164
invalid_cursor422Pass nextCursor back verbatim
opted_out409They replied STOP
too_frequent422Schedule faster than hourly
rate_limited429Slow down; see Retry-After
quota_exhausted402Out of messages for this period; top up
twilio_error502Carrier rejected it

Limits are per number: 20 sends a minute, 5 to any one recipient, 120 polls a minute, plus a daily send cap and a monthly message allowance. Read all of them from GET /v1/number. The daily cap protects the carrier registration your number depends on.

Your monthly allowance

Every number includes a set number of messages per billing period. It resets when you're billed, and messages you receive count towards it as well as the ones you send — an inbound message costs the same to carry. If you compare usedThisPeriod against your own send log they will not match, and that is why.

When it runs out, writes return 402 quota_exhausted and reads keep working — you never lose access to your own messages. Retrying will not help: buy more from your portal, or wait for periodEndsAt. Extra messages you buy never expire and are only spent once the included allowance is gone.

json
{
  "limits": {
    "perMinute": 20,
    "perDay": 100,
    "sentToday": 12,
    "remainingToday": 88,
    "perMonth": 500,
    "usedThisPeriod": 412,
    "remainingThisPeriod": 88,
    "extraCredits": 0,
    "periodEndsAt": "2026-09-01T00:00:00.000Z",
    "maxAudienceSize": 50
  }
}

Testing

Add "test": true to a send. It runs every check and records the message, but never reaches the carrier, never costs anything, and counts against neither your daily cap nor your monthly allowance — so you can build against this before your number is even live.

bash
curl -X POST https://stone-production-d2ea.up.railway.app/api/fa/v1/messages \
  -H "x-api-key: fan_YOUR_KEY" \
  -H "content-type: application/json" \
  -d '{"to":"+12695550123","body":"dry run","test":true}'

Billing and compliance

$5 once to set the number up, then $10 a month with the first month free. Cancel from your portal — you keep the number until the period you have paid for ends.

If a payment fails, sending pauses but reading never does. You can always get your own messages out.

STOP and HELP are handled for you, and are not optional. Anyone replying STOP is unsubscribed immediately and we will refuse to send to them afterwards, from the API and from the portal alike. You confirmed at signup that everyone you message has already agreed to hear from you — that is what keeps this number working.