RebelText API
Send SMS, check delivery status, and get status updates pushed to your server over webhooks.
Base URL
https://api.rebeltexting.com
All requests and responses use JSON. Send Content-Type: application/json on
every request that has a body.
Contents
- Quick start
- Changes: credits are now a dollar balance
- Authentication
- Responses and errors
- Send an SMS
- Check message status
- Webhooks
- Reference
Quick start
-
Send a message.
curl -X POST https://api.rebeltexting.com/v1/messages \ -H "Authorization: Bearer $REBELTEXT_API_KEY" \ -H "Content-Type: application/json" \ -d '{"to_number": "5125550199", "message": "Your order has shipped."}'The response contains a
reference_idsuch asmsg_01HQ3F5X8K2M9N4P6R7S8T9V0W. -
Check its status.
curl https://api.rebeltexting.com/v1/messages/msg_01HQ3F5X8K2M9N4P6R7S8T9V0W \ -H "Authorization: Bearer $REBELTEXT_API_KEY" -
Or have status updates pushed to you by registering a webhook, so you don't have to poll.
Changes: credits are now a dollar balance
Your account used to hold credits. It now holds a balance in US
dollars, and every message has a price in dollars. Amounts are sent as
decimal strings, such as "0.0095", never as JSON numbers. See
Segments and pricing.
These fields and codes were renamed. The old names are no longer returned, so update any code that reads them:
| Where | Before | Now |
|---|---|---|
Message object: send responses, message reads, sms.status and sms.inbound webhooks, batch entries |
credits (integer) |
cost (string, such as "0.0095") and currency ("usd") |
POST /v1/messages response |
credits_remaining (integer) |
balance (string, such as "48.21") |
POST /v1/messages/batch response |
credits, credits_remaining |
cost, balance, currency |
GET /v1/batches/{id} |
credits |
cost, currency |
GET /v1/account/usage and GET /v1/account |
credits_remaining |
balance, currency |
GET /v1/account/grants |
each grant's credits |
amount, currency |
GET /v1/plans |
included_credits |
included_balance (string) |
Error code, 402 |
insufficient_credits |
insufficient_balance |
Authentication
Authenticate every request with your API key as a bearer token:
Authorization: Bearer rt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
There are two kinds of key:
| Prefix | Use |
|---|---|
rt_live_ |
Sends real messages to real phones. |
rt_test_ |
For testing your integration. Messages go through validation, pricing, rate limits and webhooks exactly like live ones, but never reach a phone and always end up delivered. Test keys are not accepted in production. |
Keep your key secret. Don't put it in client-side code or commit it to source control.
| Problem | Status | code |
|---|---|---|
| Key missing or not recognised | 401 |
unauthenticated |
| Key is valid, but the account is suspended or closed | 403 |
account_inactive |
| Account has no active subscription | 402 |
subscription_required |
| Test key used against production | 403 |
test_key_in_production |
You need a subscription to send
Your key authenticates straight away, but sending needs an active plan behind it. Without one, every endpoint that sends a message — or that registers a brand or campaign, or buys a number — answers:
{
"status": false,
"message": "This account has no active subscription. Choose a plan to start sending.",
"code": "subscription_required"
}
Reading is unaffected: you can still call GET /v1/account, GET /v1/plans,
GET /v1/messages and your invoices, so you can see where you stand. Sign in to
the portal and pick a plan, and your key starts working on the next request —
there is no waiting period and nothing to restart.
subscription_required and insufficient_balance are deliberately different
codes. The first means there is no plan; the second means the plan is there and
your balance has run out.
Responses and errors
Every response uses the same shape.
Success
{
"status": true,
"message": "Success",
"data": { }
}
Error
{
"status": false,
"message": "The to_number is not a valid North American number.",
"code": "invalid_request",
"errors": {
"to_number": ["The to_number is not a valid North American number."]
}
}
statusistrueon success andfalseon error.messageis a human-readable summary. For validation errors it repeats the first field error, so it's safe to show to a user.codeis a stable, machine-readable error code. Checkcodein your code, notmessage. We may reword messages, but codes don't change. See Error codes.errorsappears on validation errors (422). It maps each field name to a list of problems with that field.
Unknown fields in a request body are rejected rather than silently ignored, so a
typo such as toNumber gives you a 422 telling you which field is wrong.
Request IDs
Every response has an X-Request-Id header. You can also send your own
X-Request-Id and we'll use it. Include it when you contact support so we can
find the exact request.
Send an SMS
POST /v1/messages
Queues one SMS for delivery. On success it returns 202 Accepted: the
message is queued, not yet delivered. Delivery status comes later. You can
check the status or get it from a
webhook.
Headers
| Header | Required | Description |
|---|---|---|
Authorization |
yes | Bearer <your API key> |
Content-Type |
yes | application/json |
Idempotency-Key |
no | A unique string for this send, such as order-4471-shipped. If you retry a request with the same key within 24 hours, no second message is sent. See Safe retries. |
Body
| Field | Type | Required | Description |
|---|---|---|---|
to_number |
string | yes | The recipient's phone number. Any common format works: 5125550199, (512) 555-0199, +15125550199. |
message |
string | yes | The text to send. Up to 1600 characters and 10 segments. |
from_number |
string | no | One of your numbers to send from. Leave this out to have us pick the best available number on your account. |
country_code |
string | no | Country code for to_number, such as +1. Defaults to +1. Ignored if to_number starts with +. |
metadata |
object | no | Your own string key/value pairs, such as {"order_id": "4471"}. Returned when you read the message and in its webhooks. |
scheduled_at |
string | no | Send later instead of now. An RFC 3339 timestamp, such as 2026-09-20T15:00:00Z, up to 90 days ahead. It can't be in the past. |
Example request
curl -X POST https://api.rebeltexting.com/v1/messages \
-H "Authorization: Bearer $REBELTEXT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-4471-shipped" \
-d '{
"to_number": "5125550199",
"from_number": "5125550100",
"message": "Your order #4471 has shipped.",
"metadata": { "order_id": "4471" }
}'
Example response — 202 Accepted
{
"status": true,
"message": "Message accepted",
"data": {
"reference_id": "msg_01HQ3F5X8K2M9N4P6R7S8T9V0W",
"sender": "+15125550100",
"receiver": "+15125550199",
"sms_type": "sms",
"message": "Your order #4471 has shipped.",
"media_url": null,
"status": "accepted",
"remarks": "The message was accepted and is waiting to be sent.",
"error_code": null,
"carrier_error_code": null,
"carrier_error_message": null,
"segments": 1,
"cost": "0.0095",
"currency": "usd",
"attempts": 0,
"metadata": { "order_id": "4471" },
"api_key_id": "key_01HQ2Z8Y7X6W5V4T3S2R1Q0P9N",
"scheduled_at": null,
"sent_at": null,
"received_at": null,
"delivered_at": null,
"created_at": "2026-09-17T10:04:11.204000Z",
"updated_at": "2026-09-17T10:04:11.204000Z",
"balance": "48.21"
}
}
data is a message object, plus balance, what your
account has left in US dollars after this message, as a decimal string.
- Save
reference_id. It's the message ID you use to check status and to match webhook events. statusisaccepted, orscheduledif you set a futurescheduled_at.receiveris the recipient in E.164 format (+15125550199).senderis""if you left outfrom_number. The number we choose appears when you read the message after it's sent.
Phone numbers
- A number without a leading
+getscountry_codeadded (default+1).5125550199and15125550199are the same US recipient. - A number starting with
+is used as is, andcountry_codeis ignored. - US and Canadian (
+1) numbers must be valid 10-digit North American numbers.
Segments and pricing
Your account holds a balance in US dollars. You add to it by topping up, and your plan may add to it each billing period. Each message is paid for from it:
- SMS costs your account's price per segment. The platform price is set
by RebelText (for example
$0.0095a segment), and your account may have a negotiated price of its own. - MMS costs a flat price per message, however long its text.
- Incoming SMS may be charged per segment too. By default it's free, and
coston ansms.inboundevent is"0.00".
cost in the response is what this message costs, and currency is always
"usd". For example, a 200-character GSM message is 2 segments; at $0.0095 a
segment it has "segments": 2 and "cost": "0.019", and your balance goes
down by that much.
Amounts — cost, balance and the others — are decimal strings of US
dollars, with up to 6 decimal places and never fewer than 2: "0.01",
"0.019", "0.0095", "48.21". They're strings so they never pass through
floating point; parse them with a decimal type, not a float, if you do
arithmetic on them.
How many segments a message takes:
- A message using only standard GSM characters fits 160 characters in one segment, or 153 per segment when split.
- A message with any other character, including most emoji and accented letters outside the GSM set, fits 70 characters in one segment, or 67 per segment when split.
{ } [ ] ~ ^ \ |and€each count as two GSM characters.
Safe retries
A network error can leave you unsure whether a send went through. Retrying without protection can send the message twice.
Send an Idempotency-Key header. If you retry with the same key, from the same
API key, within 24 hours:
- no second message is sent and you're not charged again
- the response is
202with the originalreference_idand"duplicate": true created_atandupdated_atarenull; read the message for its current state
{
"status": true,
"message": "Message accepted",
"data": {
"reference_id": "msg_01HQ3F5X8K2M9N4P6R7S8T9V0W",
"status": "accepted",
"created_at": null,
"updated_at": null,
"balance": "48.21",
"duplicate": true
}
}
(Shortened. The full message object is returned.)
Use a key that identifies the send itself, such as your order ID plus the event, not a random value generated for each attempt.
Errors
| Problem | Status | code |
|---|---|---|
Invalid phone number or country_code, missing or too-long message, invalid scheduled_at |
422 |
invalid_request |
| Body isn't valid JSON, or has an unknown field | 400 / 422 |
invalid_request |
| Recipient has opted out of messages from you | 422 |
recipient_suppressed |
| Balance too low for this message | 402 |
insufficient_balance |
| No active subscription | 402 |
subscription_required |
| Too many requests per second | 429 |
rate_limited |
| Request body too large | 413 |
payload_too_large |
Rate limits
Your plan sets how many messages per second you can send. Past that limit you get
429 rate_limited and a Retry-After header saying how many seconds to wait. A
rate-limited request isn't charged. Wait at least that long before you retry.
Check message status
GET /v1/messages/{reference_id}
Returns the current state of a message you sent.
Example request
curl https://api.rebeltexting.com/v1/messages/msg_01HQ3F5X8K2M9N4P6R7S8T9V0W \
-H "Authorization: Bearer $REBELTEXT_API_KEY"
Example response — 200 OK
{
"status": true,
"message": "Success",
"data": {
"reference_id": "msg_01HQ3F5X8K2M9N4P6R7S8T9V0W",
"sender": "+15125550100",
"receiver": "+15125550199",
"sms_type": "sms",
"message": "Your order #4471 has shipped.",
"media_url": null,
"status": "delivered",
"remarks": "The message was delivered to the recipient.",
"error_code": null,
"carrier_error_code": null,
"carrier_error_message": null,
"segments": 1,
"cost": "0.0095",
"currency": "usd",
"attempts": 1,
"metadata": { "order_id": "4471" },
"api_key_id": "key_01HQ2Z8Y7X6W5V4T3S2R1Q0P9N",
"scheduled_at": null,
"sent_at": "2026-09-17T10:04:11.881000Z",
"received_at": null,
"delivered_at": "2026-09-17T10:04:14.019000Z",
"created_at": "2026-09-17T10:04:11.204000Z",
"updated_at": "2026-09-17T10:04:14.019000Z"
}
}
data is a message object. Check status to see where
the message is. Message statuses lists every value.
If a message fails, status is failed or undelivered, error_code is set,
and remarks includes the reason. When the carrier gave its own code and text,
they're in carrier_error_code and carrier_error_message, in the carrier's
own words:
{
"status": "undelivered",
"remarks": "The carrier accepted the message but could not deliver it. Reason: Blocked by carrier spam filter.",
"error_code": "40002",
"carrier_error_code": "40002",
"carrier_error_message": "Message blocked by carrier spam filter"
}
Errors
| Problem | Status | code |
|---|---|---|
| No message with this ID on your account | 404 |
not_found |
Tip: Don't poll this endpoint in a tight loop. Webhooks tell you the moment a status changes.
Webhooks
Webhooks send an HTTP POST to your server when something happens, such as a
message being delivered or failing.
| Event | When it's sent |
|---|---|
sms.status |
A message you sent changes status: scheduled, sending, sent, delivered, undelivered or failed. |
sms.inbound |
Someone sends an SMS to one of your numbers. |
Each webhook endpoint subscribes to one or more event types. New endpoints
subscribe to sms.status by default.
Create a webhook
POST /v1/webhooks
| Field | Type | Required | Description |
|---|---|---|---|
url |
string | yes | The URL we send events to. Must be http or https; use https in production. |
event_types |
string[] | no | The events to receive: sms.status, sms.inbound, or both. Defaults to ["sms.status"]. |
description |
string | no | A note for yourself, up to 500 characters. |
Example request
curl -X POST https://api.rebeltexting.com/v1/webhooks \
-H "Authorization: Bearer $REBELTEXT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/rebeltext",
"event_types": ["sms.status", "sms.inbound"],
"description": "Production order notifications"
}'
Example response — 201 Created
{
"status": true,
"message": "Webhook endpoint created",
"data": {
"id": "wh_01HQ3F5X8K2M9N4P6R7S8T9V0W",
"url": "https://example.com/hooks/rebeltext",
"event_types": ["sms.status", "sms.inbound"],
"status": "active",
"description": "Production order notifications",
"secret": "whsec_3vK9dQ2mR7pL5xW8nT4bY6cF1hJ0sA...",
"consecutive_failures": 0,
"created_at": "2026-09-17T10:04:11Z"
}
}
⚠️ Save
secretnow. It is shown only once. You need it to verify signatures. If you lose it, delete the webhook and create a new one.
Errors
| Problem | Status | code |
|---|---|---|
Missing or invalid url, unknown event type, description too long |
422 |
invalid_request |
List webhooks
GET /v1/webhooks
Returns all your webhook endpoints and their health. secret is never included.
curl https://api.rebeltexting.com/v1/webhooks \
-H "Authorization: Bearer $REBELTEXT_API_KEY"
{
"status": true,
"message": "Success",
"data": {
"webhooks": [
{
"id": "wh_01HQ3F5X8K2M9N4P6R7S8T9V0W",
"url": "https://example.com/hooks/rebeltext",
"event_types": ["sms.status", "sms.inbound"],
"status": "active",
"description": "Production order notifications",
"consecutive_failures": 0,
"last_success_at": "2026-09-17T11:20:03Z",
"created_at": "2026-09-17T10:04:11Z"
}
]
}
}
| Field | Description |
|---|---|
consecutive_failures |
Failed deliveries in a row. Goes back to 0 after a successful delivery. |
disabled_until |
Set when too many deliveries have failed and we've paused sending to this endpoint. See Retries. Left out when not paused. |
last_success_at |
When a delivery last succeeded. Left out if none has. |
last_failure_at |
When a delivery last failed. Left out if none has. |
Delete a webhook
DELETE /v1/webhooks/{id}
Stops all events to this endpoint.
curl -X DELETE https://api.rebeltexting.com/v1/webhooks/wh_01HQ3F5X8K2M9N4P6R7S8T9V0W \
-H "Authorization: Bearer $REBELTEXT_API_KEY"
{ "status": true, "message": "Webhook endpoint deleted" }
Returns 404 not_found if there's no webhook with this ID on your account.
Send a test event
POST /v1/webhooks/{id}/test
Sends a sample sms.status event with status delivered to your endpoint. Use
it to check that your server receives events and verifies signatures, without
sending a real message.
curl -X POST https://api.rebeltexting.com/v1/webhooks/wh_01HQ3F5X8K2M9N4P6R7S8T9V0W/test \
-H "Authorization: Bearer $REBELTEXT_API_KEY"
{
"status": true,
"message": "Test webhook queued",
"data": { "delivery_id": "whd_01HQ4A2B3C4D5E6F7G8H9J0K1M" }
}
The test event has reference_id msg_test_00000000000000000000000000 and
metadata {"test": "true"}, so you can recognise and ignore it.
Receiving events
Each event is sent as a POST with a JSON body.
Headers
| Header | Description |
|---|---|
X-RebelText-Event |
The event type: sms.status or sms.inbound. |
X-RebelText-Delivery-Id |
A unique ID for this event (whd_...). The same event keeps its ID when retried, so use it to ignore duplicates. |
X-RebelText-Attempt |
Which attempt this is, starting at 1. |
X-RebelText-Timestamp |
When it was sent, in Unix seconds. |
X-RebelText-Signature |
The signature for checking that the event came from us. See Verifying signatures. |
Body
The body is a message object, the same one
GET /v1/messages/{reference_id} returns, so one
parser handles both.
sms.status
{
"reference_id": "msg_01HQ3F5X8K2M9N4P6R7S8T9V0W",
"sender": "+15125550100",
"receiver": "+15125550199",
"sms_type": "sms",
"message": "Your order #4471 has shipped.",
"media_url": null,
"status": "delivered",
"remarks": "The message was delivered to the recipient.",
"error_code": null,
"carrier_error_code": null,
"carrier_error_message": null,
"segments": 1,
"cost": "0.0095",
"currency": "usd",
"attempts": 1,
"metadata": { "order_id": "4471" },
"api_key_id": "key_01HQ2Z8Y7X6W5V4T3S2R1Q0P9N",
"scheduled_at": null,
"sent_at": "2026-09-17T10:04:11.881000Z",
"received_at": null,
"delivered_at": "2026-09-17T10:04:14.019000Z",
"created_at": "2026-09-17T10:04:11.204000Z",
"updated_at": "2026-09-17T10:04:14.019000Z"
}
sms.inbound
For incoming messages, sender is the person who texted you, receiver is your
number, and reference_id starts with inb_.
{
"reference_id": "inb_01HQ5B7C8D9E0F1G2H3J4K5M6N",
"sender": "+15125550199",
"receiver": "+15125550100",
"sms_type": "sms",
"message": "Thanks! When will it arrive?",
"media_url": null,
"status": "received",
"remarks": "The message was received from the sender.",
"error_code": null,
"carrier_error_code": null,
"carrier_error_message": null,
"segments": 1,
"cost": "0.00",
"currency": "usd",
"attempts": 0,
"metadata": {},
"api_key_id": null,
"scheduled_at": null,
"sent_at": null,
"received_at": "2026-09-17T10:06:40.512000Z",
"delivered_at": null,
"created_at": "2026-09-17T10:06:40.730000Z",
"updated_at": "2026-09-17T10:06:40.730000Z"
}
Responding
- Return any
2xxstatus as soon as you receive the event. Anything else, or no response within 10 seconds, counts as a failure and is retried. - Redirects aren't followed, so a
3xxalso counts as a failure. Register the final URL. - Return
410 Goneif you never want this event. We stop retrying it immediately. - Do slow work, such as database updates or sending emails, after you respond, for example in a background job.
- Events can arrive more than once and out of order. Ignore an
X-RebelText-Delivery-Idyou've already processed. When ordering a message's events, go byupdated_at, not by the order they arrive in.
Verifying signatures
Always check that an event came from RebelText before you trust it.
X-RebelText-Signature is built like this:
base64( hex( HMAC-SHA256(raw_request_body, webhook_secret) ) )
- Compute HMAC-SHA256 of the raw request body, using your webhook's full
secret(includingwhsec_) as the key. - Write the result as a lowercase hex string.
- Base64-encode that hex string.
- Compare it with
X-RebelText-Signatureusing a constant-time comparison. - Reject the event if
X-RebelText-Timestampis more than 5 minutes from your current time, in either direction.
Use the raw body exactly as received. If you parse the JSON and re-encode it, the bytes change and the signature won't match.
Node.js (Express)
const crypto = require('crypto');
const express = require('express');
const app = express();
const SECRET = process.env.REBELTEXT_WEBHOOK_SECRET;
app.post('/hooks/rebeltext', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.get('X-RebelText-Signature') || '';
const timestamp = Number(req.get('X-RebelText-Timestamp'));
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > 300) {
return res.sendStatus(400);
}
const hex = crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
const expected = Buffer.from(Buffer.from(hex).toString('base64'));
const received = Buffer.from(signature);
if (expected.length !== received.length || !crypto.timingSafeEqual(expected, received)) {
return res.sendStatus(403);
}
const event = JSON.parse(req.body);
res.sendStatus(200);
// Process `event` after responding.
});
PHP
<?php
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_REBELTEXT_SIGNATURE'] ?? '';
$timestamp = (int) ($_SERVER['HTTP_X_REBELTEXT_TIMESTAMP'] ?? 0);
$secret = getenv('REBELTEXT_WEBHOOK_SECRET');
if (abs(time() - $timestamp) > 300) {
http_response_code(400);
exit;
}
$expected = base64_encode(hash_hmac('sha256', $rawBody, $secret));
if (!hash_equals($expected, $signature)) {
http_response_code(403);
exit;
}
$event = json_decode($rawBody, true);
http_response_code(200);
Python
import base64, hashlib, hmac, time
def verify(secret: str, signature: str, timestamp: str, body: bytes) -> bool:
try:
if abs(time.time() - int(timestamp)) > 300:
return False
except (TypeError, ValueError):
return False
digest = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
expected = base64.b64encode(digest.encode()).decode()
return hmac.compare_digest(expected, signature)
Retries
If a delivery fails, we retry it on this schedule. Each delay varies by up to ±20%.
| Retry | Delay after previous attempt |
|---|---|
| 1 | 30 seconds |
| 2 | 2 minutes |
| 3 | 10 minutes |
| 4 | 1 hour |
| 5 | 6 hours |
If every retry fails, the delivery is marked dead. You can
replay it once your endpoint is working again.
Paused endpoints. After 10 failed deliveries in a row, we pause sending to
that endpoint. The first pause lasts 30 seconds. If it keeps failing, later
pauses last 1, 2, 5 and then 15 minutes. When a pause ends we send one delivery
first, and send the rest once it succeeds. While it's paused, disabled_until in
List webhooks shows when sending resumes. A successful delivery
resets the count and the pause length.
Pacing. We send at most 8 deliveries to one endpoint at a time. If events queue up, they arrive as a steady stream rather than all at once.
Delivery history and replay
List deliveries
GET /v1/webhook-deliveries
Shows recent webhook deliveries, newest first, including the payload we sent and the response your server gave. Use it to debug a webhook that isn't working.
| Query parameter | Default | Description |
|---|---|---|
message_id |
— | Only deliveries for this message (msg_...). |
limit |
50 |
Number of deliveries to return, up to 200. |
curl "https://api.rebeltexting.com/v1/webhook-deliveries?message_id=msg_01HQ3F5X8K2M9N4P6R7S8T9V0W" \
-H "Authorization: Bearer $REBELTEXT_API_KEY"
{
"status": true,
"message": "Success",
"data": {
"deliveries": [
{
"id": "whd_01HQ4A2B3C4D5E6F7G8H9J0K1M",
"endpoint_id": "wh_01HQ3F5X8K2M9N4P6R7S8T9V0W",
"url": "https://example.com/hooks/rebeltext",
"message_id": "msg_01HQ3F5X8K2M9N4P6R7S8T9V0W",
"event_type": "sms.status",
"status": "dead",
"attempt": 6,
"response_code": 502,
"last_error": "unexpected status 502",
"payload": { "reference_id": "msg_01HQ3F5X8K2M9N4P6R7S8T9V0W", "status": "delivered" },
"created_at": "2026-09-17T10:04:14Z"
}
]
}
}
(payload is shortened here. The full message object is returned.)
Delivery status |
Meaning |
|---|---|
pending |
Waiting to be sent. After a failed attempt, last_error and response_code explain the failure and next_retry_at shows when it will be retried. |
sending |
Being sent now. |
delivered |
Your server returned a 2xx (delivered_at). |
dead |
All retries failed, or your server returned 410. Nothing more happens unless you replay it. |
Replay a delivery
POST /v1/webhook-deliveries/{id}/replay
Sends a dead or delivered delivery again, starting a new set of retries. It
keeps the same payload and delivery ID.
curl -X POST https://api.rebeltexting.com/v1/webhook-deliveries/whd_01HQ4A2B3C4D5E6F7G8H9J0K1M/replay \
-H "Authorization: Bearer $REBELTEXT_API_KEY"
{ "status": true, "message": "Delivery queued for replay" }
Returns 404 not_found if the delivery doesn't exist, if it's still pending
or sending, or if its webhook has been deleted.
Reference
The message object
Sending a message, reading a message, and every webhook event all return this
object. Every field is always present. A field that doesn't apply is null, or
{} for metadata.
| Field | Type | Description |
|---|---|---|
reference_id |
string | The message ID: msg_... for messages you send, inb_... for messages you receive. |
sender |
string | The sending number in E.164 format. "" until a number is chosen, if you didn't set from_number. |
receiver |
string | The receiving number in E.164 format. |
sms_type |
string | sms. |
message |
string | The message text. |
media_url |
string | null | null for SMS. |
status |
string | See Message statuses. |
remarks |
string | A plain-English description of status. On failure, it ends with Reason: and the reason. |
error_code |
string | null | Set when status is failed, undelivered or rejected; otherwise null. |
carrier_error_code |
string | null | The carrier's own error code, as it sent it, when a failure came from the carrier. null when the carrier gave none, including for failures RebelText sets itself. |
carrier_error_message |
string | null | The carrier's own error text, as it sent it, up to 512 characters. null when the carrier gave none. |
segments |
integer | How many segments the message was split into. |
cost |
string | What this message costs, in US dollars, as a decimal string such as "0.0095". See Segments and pricing. |
currency |
string | The currency of cost. Always usd. |
attempts |
integer | Send attempts so far. |
metadata |
object | The metadata you sent, or {}. |
api_key_id |
string | null | The ID of the API key the message was sent with, such as key_01HQ.... null for messages you receive. |
scheduled_at |
string | null | The scheduled send time, if any. |
sent_at |
string | null | When the carrier accepted the message. |
received_at |
string | null | When an incoming message was received. null for messages you send. |
delivered_at |
string | null | When delivery to the phone was confirmed. |
created_at |
string | null | When the message was created. |
updated_at |
string | null | When the message last changed status. |
All timestamps are UTC, such as 2026-09-17T10:04:14.019000Z.
Message statuses
A sent message usually moves through these statuses:
accepted → queued → sending → sent → delivered
↘ undelivered
| Status | Meaning | Final? | Charged? |
|---|---|---|---|
accepted |
We received it and queued it for sending. | ||
scheduled |
Waiting for its scheduled_at time. |
||
queued |
Waiting to be sent. | ||
sending |
Being handed to the carrier. | ||
sent |
The carrier accepted it, but hasn't confirmed delivery yet. | Yes | |
delivered |
The carrier confirmed it reached the phone. | ✅ | Yes |
undelivered |
The carrier accepted it but couldn't deliver it. | ✅ | Yes |
failed |
It couldn't be sent. | ✅ | Only if the carrier accepted it first |
rejected |
Refused before sending. | ✅ | No |
cancelled |
Cancelled before it was sent. | ✅ | No |
received |
An incoming message (sms.inbound). |
✅ | Yes |
A message never moves back to an earlier status. sent usually becomes
delivered or undelivered within seconds, but some carriers never confirm
delivery. In that case sent may be the last status you see.
Webhooks are sent for scheduled, sending, sent, delivered,
undelivered and failed. They aren't sent for accepted, because you already
have that status from the send response.
Failure codes
When status is failed, error_code tells you why. These codes come from
RebelText. Any other value comes from the carrier, and carrier_error_code and
carrier_error_message then carry the carrier's own code and wording, so you
can tell a carrier's spam block from a problem on the route.
error_code |
Meaning | How to fix it |
|---|---|---|
no_sender_number |
Your account has no number available to send from. | Add a number to your account. |
sender_not_sms_capable |
The sending number can't send SMS. | Send from a different number, or leave out from_number. |
sender_not_found |
from_number isn't one of your numbers. |
Use one of your account's numbers, or leave out from_number. |
Error codes
code |
Status | Meaning |
|---|---|---|
invalid_request |
400, 422 |
The request is malformed or failed validation. For 422, errors lists the problems by field. |
unauthenticated |
401 |
The API key is missing or invalid. |
account_inactive |
403 |
Your account is suspended or closed. |
test_key_in_production |
403 |
You used a rt_test_ key against production. |
insufficient_balance |
402 |
Your balance is too low to send this message. Top up, or wait for your plan's next renewal. |
subscription_required |
402 |
Your account has no active subscription. Choose a plan in the portal; sending resumes on the next request. |
not_found |
404 |
The resource doesn't exist or doesn't belong to your account. |
payload_too_large |
413 |
The request body is too large. |
recipient_suppressed |
422 |
The recipient has opted out of messages from you. |
rate_limited |
429 |
Too many requests. Wait for the number of seconds in Retry-After, then retry. |
internal_error |
500, 503 |
Something went wrong on our side. Retry with the same Idempotency-Key. If the problem continues, contact support with the X-Request-Id. |