TextRail

API docs

TextRail is JSON over HTTPS. The base URL is https://textrail.devflovv.com, and every request needs an API key.

Quick start

  1. Create an account and verify your email and phone number.
  2. Top up at least $10.
  3. Create an API key and keep it in an environment variable called TEXTRAIL_API_KEY.
  4. Send your first message.
curl https://textrail.devflovv.com/api/v1/sms \
  -H "Authorization: Bearer $TEXTRAIL_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "phoneNumber": "+14165550123",
    "message": "Your table for two is ready. Come on in."
  }'

The answer is 202 and a message id. Your message is queued, and you can follow it with that id.

Authentication

Send your key as a bearer token on every request. Keys start with tr_live_ or tr_test_. A key is shown once when you create it, so store it then. Keep keys on your server, never in a web page or a mobile app.

Header
Authorization: Bearer tr_live_...

Each key has scopes, chosen when you create it. A request outside a key’s scopes gets SCOPE_MISSING.

Key scopes
ScopeAllows
sms:send
Send messages
sms:read
Read a message’s status
otp:send
Send verification codes
otp:verify
Check verification codes

Send an SMS

POST /api/v1/sms

Queues one message. Needs the sms:send scope and an Idempotency-Key header.

Request body
FieldTypeMeaning
phoneNumber
string
Recipient in E.164 format, like +14165550123
message
string
curl https://textrail.devflovv.com/api/v1/sms \
  -H "Authorization: Bearer $TEXTRAIL_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "phoneNumber": "+14165550123",
    "message": "Your table for two is ready. Come on in."
  }'
Response 202
{
  "success": true,
  "id": "job_01KA7QW3M9ZP4XN8RT2VD6HJ5B",
  "status": "queued",
  "encoding": "GSM7",
  "chars": 40,
  "priceCents": 2,
  "expiresAt": "2026-10-30T22:15:00.000Z",
  "testMode": false,
  "sendingAvailable": true,
  "notice": null
}
Response body
FieldTypeMeaning
id
string
The message id
status
string
Always queued here
encoding
string
GSM7 or UCS2
chars
number
Characters counted against the limit
priceCents
number
Price held, in US cents. 2 cents is 2.
expiresAt
string
If it has not gone out by this time, it expires, free
testMode
boolean
True when a test key sent it
sendingAvailable
boolean
False when we cannot send right now. The message waits, and expires free if that lasts
notice
string or null
Plain words when something needs explaining

Check a message

GET /api/v1/sms/{id}

Returns the current state of a message your account sent. Needs the sms:read scope. Phone numbers come back masked.

curl https://textrail.devflovv.com/api/v1/sms/job_01KA7QW3M9ZP4XN8RT2VD6HJ5B \
  -H "Authorization: Bearer $TEXTRAIL_API_KEY"
Response 200
{
  "success": true,
  "sms": {
    "id": "job_01KA7QW3M9ZP4XN8RT2VD6HJ5B",
    "status": "delivered",
    "phoneNumber": "+1••••••0123",
    "country": "CA",
    "priceCents": 2,
    "charged": true,
    "lastError": null,
    "createdAt": "2026-10-30T22:00:00.000Z",
    "sentAt": "2026-10-30T22:00:04.000Z",
    "deliveredAt": "2026-10-30T22:00:07.000Z",
    "expiresAt": "2026-10-30T22:15:00.000Z",
    "testMode": false,
    "notice": null
  }
}
Fields of sms
FieldTypeMeaning
status
string
phoneNumber
string
Masked number
country
string or null
Two-letter country code
charged
boolean
True once the price was taken
lastError
string or null
A short reason code when something went wrong
sentAt
string or null
When it was sent
deliveredAt
string or null
When the carrier reported delivery
notice
string or null
Plain words when something needs explaining

Message statuses

A message moves forward through these. sent means it went out to the carrier. delivered appears only if the carrier reports it, so a message can stay at sent and still have arrived.

Statuses
StatusMeaningCost
Queued
Accepted, waiting to go out
Held
Sending
On its way out
Held
Sent
Sent to the carrier
2 cents
Delivered
The carrier reported delivery
2 cents
Failed
It did not go out
Free
Expired
It could not go out before expiresAt
Free
Unknown
We could not confirm the outcome. It is never resent on its own
Free

Reason codes

lastError says why a message didn’t go as planned.

Reason codes
CodeMeaning
NUMBER_REJECTED
That number can’t take this message
MESSAGE_TOO_LONG
It didn’t fit in one SMS
CARRIER_REJECTED
The carrier refused it
SEND_FAILED
It couldn’t go out
CANCELLED
It was cancelled before it went out
OUTCOME_UNKNOWN
The outcome couldn’t be confirmed
DELIVERY_FAILED
It went out, and the carrier reported it wasn’t delivered
EXPIRED_UNSENT
It expired before it could go out

Verification codes

TextRail makes the code, texts it and checks it, so you store nothing. A code has 6 digits, lasts 5 minutes and allows 5 tries.

Send a code

POST /api/v1/otp/send

Needs the otp:send scope and an Idempotency-Key header. One code is active per key, recipient and purpose. Asking again within 60 seconds gets OTP_COOLDOWN. A code is one SMS at 2 cents.

Request body for sending a code
FieldTypeMeaning
phoneNumber
string
Recipient in E.164 format
purpose
string
Your label, like login. Lowercase letters, digits, - and _, up to 32
curl https://textrail.devflovv.com/api/v1/otp/send \
  -H "Authorization: Bearer $TEXTRAIL_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "phoneNumber": "+14165550123",
    "purpose": "login"
  }'
Response 202
{
  "success": true,
  "requestId": "otp_01KA7QX8C2HF6TN3PV9WD4MK7S",
  "status": "queued",
  "expiresIn": 300,
  "sendingAvailable": true,
  "notice": null
}

Check a code

POST /api/v1/otp/verify

Needs the otp:verify scope. A code works once. Any failure, whether wrong, expired, used or unknown, is a 200 with verified: false, so the answer gives nothing away.

Request body for checking a code
FieldTypeMeaning
requestId
string
From the send response
code
string
The 6 digits your user typed
curl https://textrail.devflovv.com/api/v1/otp/verify \
  -H "Authorization: Bearer $TEXTRAIL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "requestId": "otp_01KA7QX8C2HF6TN3PV9WD4MK7S",
    "code": "482913"
  }'
Response 200
{
  "success": true,
  "verified": true
}

Test keys

A tr_test_ key runs the same validation, idempotency and rate limits as a live key, on the same verified account. No real SMS goes out and nothing is charged.

  • Messages go straight to sent with testMode: true and a price of 0.
  • The code for a test verification is always 000000.

Message length

A message is exactly one SMS and is never split. Too long gets a 422 and costs nothing.

Length limits
TextCharacters
GSM alphabet: Latin letters, digits, common punctuation
160
Anything else: emoji, Urdu, Chinese and so on
70

These GSM characters count as two: ^ { } \ [ ] ~ | €. One character outside the GSM alphabet moves the whole message to the limit of 70, and an emoji counts as two of those.

Safe retries

Sending needs an Idempotency-Key header: any unique string you make for that message. If a request times out, send it again with the same key and body. You’ll get the first answer back, with an Idempotent-Replayed: true header, and no second message.

Keep the key with the message in your own records, so a retry after a crash reuses it. The same key with a different body gets IDEMPOTENCY_CONFLICT.

Errors

Errors use normal HTTP status codes and one JSON shape. error.code is stable, so build on that. error.message is for people.

Response 422
{
  "success": false,
  "error": {
    "code": "MESSAGE_TOO_LONG",
    "message": "The message does not fit in one SMS."
  }
}
Error codes
CodeStatusWhat to do
VALIDATION_FAILED
400
A field is missing or malformed. error.fields lists each one.
IDEMPOTENCY_KEY_REQUIRED
400
Add an Idempotency-Key header.
UNAUTHENTICATED
401
Send Authorization: Bearer with a valid key.
INSUFFICIENT_BALANCE
402
Top up, then send again.
ACCOUNT_NOT_VERIFIED
403
Verify the email and phone number on your account.
ACCOUNT_SUSPENDED
403
The account is suspended, so its keys are refused.
SCOPE_MISSING
403
This key lacks the scope the route needs.
COUNTRY_NOT_ALLOWED
403
Sending to that country is not enabled right now.
NOT_FOUND
404
No message with that id on this account.
IDEMPOTENCY_CONFLICT
409
That key was used with a different body. Use a new key.
MESSAGE_TOO_LONG
422
Shorten the message. Nothing was charged.
INVALID_PHONE_NUMBER
422
Use E.164 format, like +14165550123.
RATE_LIMITED
429
Slow down and retry with the same Idempotency-Key.
QUOTA_EXCEEDED
429
This key has reached its quota.
OTP_COOLDOWN
429
A code went out in the last 60 seconds. Wait, then ask again.
INTERNAL
500
Something failed on our side. Retry with the same Idempotency-Key.
SERVICE_PAUSED
503
Sending is paused. Retry later with the same Idempotency-Key.