Sparetext

API reference

REST over HTTPS, JSON in and out.

Authentication

Create a free account, pair a phone, and make a key on the API keys page and send it as the x-api-key header, or as Authorization: Bearer. Base URL:

https://sparetext.dev/api/v1

Endpoints

POST/messages

Send a text

Queues the text on your default phone (or the one you name) and wakes it. Up to 100 recipients and 1,600 characters. One SMS holds 160 GSM-7 characters, or 153 per segment when it's longer; an emoji or any character outside GSM-7 makes it UCS-2, with 70 (or 67 per segment). Carriers bill each segment.

to
string or string[], up to 100. E.164 numbers, like +447700900124. A number without a country code is read in your account's country (Settings › Phone numbers) and refused if none is set. Numbers that replied STOP are skipped and listed in skipped.
text
string. The message.
deviceId
optional. Which phone sends it.
scheduledAt
optional ISO time, up to 30 days ahead.
ttl
optional whole seconds, 10 to 30 days. If the text hasn't gone out this long after it may (scheduledAt, or now), it is dropped unsent and fails with error expired. For one-time codes. Or give expiresAt, an ISO time, instead.
Idempotency-Key
optional header. Any unique string; a retry with the same key within 24 hours gets the first answer back instead of texting again.
Request
curl -X POST https://sparetext.dev/api/v1/messages \
  -H "x-api-key: $SPARETEXT_API_KEY" \
  -H "content-type: application/json" \
  -d '{"to": "+15551234567", "text": "Your code is 481920", "ttl": 300}'
Response
{
  "batchId": "b_x1…",
  "messages": [{ "id": "msg_20261027_k3j9x2ab", "contact": "+15551234567", "status": "queued", … }]
}
GET/messages/{id}

Check a text

Status moves queued → dispatched (shown as Sending: the phone has it) → sent → delivered, or failed with the phone's reason in error. Only Android phones get delivery reports, so on iPhone sent is final.

Request
curl https://sparetext.dev/api/v1/messages/msg_20261027_k3j9x2ab \
  -H "x-api-key: $SPARETEXT_API_KEY"
Response
{ "message": { "id": "msg_…", "status": "delivered", "sentAt": "…", "deliveredAt": "…" } }
GET/messages

List texts

Newest first. Page with nextCursor.

direction
inbound or outbound
contact
only texts with this number
status
queued, sent, delivered, failed, received…
q
search the text
since
ISO time; only newer texts
limit
1 to 100, default 50
before
the nextCursor from the last page
Request
curl "https://sparetext.dev/api/v1/messages?direction=inbound&limit=20" \
  -H "x-api-key: $SPARETEXT_API_KEY"
Response
{ "messages": [ … ], "nextCursor": "MjAyNi0xMC0y…" }
DELETE/messages/{id}

Cancel a text

Stops a text that is still scheduled or waiting for the phone. Once the phone has taken it, it can't be called back (409).

Request
curl -X DELETE https://sparetext.dev/api/v1/messages/msg_20261027_k3j9x2ab \
  -H "x-api-key: $SPARETEXT_API_KEY"
Response
204 No Content
GET/devices

List phones

Each phone with online, battery, queue and mode.

Request
curl https://sparetext.dev/api/v1/devices -H "x-api-key: $SPARETEXT_API_KEY"
Response
{ "devices": [{ "id": "dev_…", "name": "Pixel 8", "platform": "android", "online": true, "queued": 0 }] }
POST/gateway/send-sms

textbee-compatible send

Same body as textbee's send-sms, so existing code can switch by changing the base URL and key.

Request
curl -X POST https://sparetext.dev/api/v1/gateway/send-sms \
  -H "x-api-key: $SPARETEXT_API_KEY" -H "content-type: application/json" \
  -d '{"recipients": ["+15551234567"], "message": "Hello"}'
Response
{ "data": { "batchId": "b_…", "messages": [ … ] } }

Webhooks

We POST JSON to your URL for message.received, message.sent, message.delivered, message.failed, device.paired, and device.offline and device.online (an Android phone or Mac relay that stops checking in, and its return; the phone's last status says why). Each request carries X-Sparetext-Signature: t=<unix seconds>,v1=<hex>, an HMAC-SHA256 of t.body with the webhook's secret. Check it over the raw body, and refuse a t older than 5 minutes.

Answer with any 2xx within 5 seconds, then do the work. Anything else is tried again: after 5 seconds, then 5 and 30 minutes, then 2, 5, 10 and 10 hours, 8 tries in all. Every try sends the same body with the same id, signed afresh, and X-Sparetext-Attempt counts them, so skip an id you have already handled. Events can arrive out of order.

Node.js
import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody: the request body exactly as it arrived (a string or Buffer).
export function fromSparetext(rawBody, header, secret) {
  const sig = Object.fromEntries(String(header ?? "").split(",").map((p) => p.split("=")));
  const expected = createHmac("sha256", secret).update(`${sig.t}.${rawBody}`).digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(sig.t)) < 300;
  return fresh && String(sig.v1).length === expected.length && timingSafeEqual(Buffer.from(String(sig.v1)), Buffer.from(expected));
}

Android's sending limit

Android lets an app send 30 SMS parts a minute (a long text counts each part). Over that it stops to ask the person holding the phone, so Sparetext paces under it and texts beyond it wait, queued, for the next minute. To send faster from a phone you own, raise the limit over USB with adb, then restart the phone:

Shell
adb shell settings put global sms_outgoing_check_max_count 1000

Carriers watch volume too: hundreds of texts an hour from one SIM can get it filtered or blocked, whatever Android allows.

Errors

Every error has a status, a stable code and a sentence you can show to people.

409 {"error": {"code": "no_device", "message": "Connect a phone first: Phones > Add phone."}}
400 bad_requestSomething in the body is wrong; the message says what.
401 unauthorizedMissing, wrong or revoked key.
404 not_foundNo such text, phone or endpoint.
409 no_deviceNo phone is on to send it.
429 rate_limitedSlow down and retry.
500 internalOur side. Retry with backoff.