API docs
TextRail is JSON over HTTPS. The base URL is https://textrail.devflovv.com, and every request needs an API key.
Quick start
- Create an account and verify your email and phone number.
- Top up at least $10.
- Create an API key and keep it in an environment variable called
TEXTRAIL_API_KEY. - 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.
Authorization: Bearer tr_live_...Each key has scopes, chosen when you create it. A request outside a key’s scopes gets SCOPE_MISSING.
| Scope | Allows |
|---|---|
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.
| Field | Type | Meaning |
|---|---|---|
phoneNumber | string | Recipient in E.164 format, like +14165550123 |
message | string | The text. 160 characters at most |
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."
}'{
"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
}| Field | Type | Meaning |
|---|---|---|
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"{
"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
}
}| Field | Type | Meaning |
|---|---|---|
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.
| Status | Meaning | Cost |
|---|---|---|
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.
| Code | Meaning |
|---|---|
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.
| Field | Type | Meaning |
|---|---|---|
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"
}'{
"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.
| Field | Type | Meaning |
|---|---|---|
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"
}'{
"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
sentwithtestMode: trueand 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.
| Text | Characters |
|---|---|
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.
{
"success": false,
"error": {
"code": "MESSAGE_TOO_LONG",
"message": "The message does not fit in one SMS."
}
}| Code | Status | What 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. |