Contents
RebelText API
Send SMS and MMS, track delivery, and get status updates pushed to your server.
Base URL
https://rebeltext.docoitest.com
Every request is authenticated with your API key, and every request and
response body is JSON: send Content-Type: application/json whenever a request
has a body.
AI agents and LLM tools can read this guide as
Markdown, which /docs also
returns to any request that sends Accept: text/markdown.
Quick start
-
Send a message.
curl -X POST https://rebeltext.docoitest.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 includes a
reference_id, such asmsg_01HQ3F5X8K2M9N4P6R7S8T9V0W. That's the message's ID. -
Check whether it was delivered.
curl https://rebeltext.docoitest.com/v1/messages/msg_01HQ3F5X8K2M9N4P6R7S8T9V0W \ -H "Authorization: Bearer $REBELTEXT_API_KEY" -
Stop polling. Register an endpoint and we'll tell your server the moment a message's status changes.
Authentication
Send your API key as a bearer token on every request:
Authorization: Bearer rt_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
There are two kinds of key:
| Prefix | What it does |
|---|---|
rt_live_ |
Sends real messages to real phones. |
rt_test_ |
For building and 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 refused in production. |
Treat your key like a password: keep it out of client-side code and out of source control.
You need a subscription to send
A new key works straight away for reading, but sending needs an active plan.
Without one, every endpoint that sends a message, registers a brand or
campaign, or buys a number answers 402:
{
"status": false,
"message": "This account has no active subscription. Choose a plan to start sending.",
"code": "subscription_required"
}
You can still read: GET /v1/account, GET /v1/plans, GET /v1/messages and
your invoices all keep working, so you can see where you stand. Pick a plan in
the console and your key can send on its very next request. There's no waiting
period and nothing to restart.
Don't confuse this with insufficient_balance. subscription_required means
there's no plan; insufficient_balance means there is a plan, but your balance
has run out.
Authentication errors
| 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 |
| Provisioning a number past the account's number allowance | 403 |
number_limit_reached |
Responses and errors
Every response has the same envelope, so one piece of code can handle them all.
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."]
}
}
statustrue on success, false on error.messagecodecode, never on message: we may reword messages, but codes don't change. See Error codes.errors422). Maps each field name to a list of what's wrong with it.Unknown fields in a request body are rejected rather than silently ignored. A
typo such as toNumber gets a 422 that names the field.
Request IDs
Every response carries an X-Request-Id header. You can send your own
X-Request-Id and we'll use it instead. Quote it when you contact support and
we can find the exact request. Recent requests are also listed in the console
under API keys › Request log.
Rate limits
Your plan sets how many messages a second you can send. Go over it and you get
429 rate_limited with a Retry-After header: the number of seconds to wait.
A rate-limited request isn't charged. Wait at least that long, then retry.
Pagination
The message lists return one page at a time, newest first. Every page says where it sits in the list:
limitcountlimit, and fewer only on the last page.has_morenext_cursorcursor to get the next page. "" on the last page.All four are always present. When has_more is true the next page is never
empty, so keep requesting until it's false.
A message list covers the last 30 days unless you set since and until
(RFC 3339 times).
Media (MMS)
Add media to a send and the message becomes an
MMS. message is then optional.
- A message carries one attachment.
- It must be an image or audio file: JPEG, PNG, GIF, WebP, BMP, TIFF, HEIC/HEIF, AVIF, MP3, M4A, AAC, WAV, Ogg, Opus, AMR, FLAC, AIFF or MIDI. SVG isn't accepted.
- An MMS costs a flat price per message, however long its text. See Pricing and segments.
- In the response,
sms_typeis"mms"andmedia_urlis the attachment's URL.
Attach a file by URL
Send a public http or https URL that the carrier can fetch. A URL ending in
one of the file extensions above, such as .png or .mp3, is accepted as is.
Any other URL is fetched briefly when you send, and must turn out to be an
image or audio file. URLs on private or local network addresses are refused.
curl -X POST https://rebeltext.docoitest.com/v1/messages \
-H "Authorization: Bearer $REBELTEXT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to_number": "5125550199",
"message": "Here is your ticket.",
"media": "https://cdn.example.com/tickets/4471.png"
}'
Upload a file
Send the request as multipart/form-data, with the file in a part named
media, up to 5 MiB. We work out the file type from its contents, not its
name, and host the file for the carrier to fetch.
curl -X POST https://rebeltext.docoitest.com/v1/messages \
-H "Authorization: Bearer $REBELTEXT_API_KEY" \
-F to_number=5125550199 \
-F message="Here is your ticket." \
-F media=@ticket.png
When media is refused
A media value that isn't a supported file, can't be fetched, is over 5 MiB,
or is sent as both a URL and a file gets 422 invalid_request, with a media
entry in errors.
Phone numbers
- A number without a leading
+getscountry_codeadded (default+1), so5125550199and15125550199are 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.
Pricing and segments
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. Every message is paid for from that balance:
| Message | How it's priced |
|---|---|
| SMS | Your account's price per segment. RebelText sets the platform price (for example $0.0095 a segment), and your account may have a negotiated price of its own. |
| MMS | A flat price per message, however long its text. |
| Received SMS | May be charged per segment. Free by default, in which case cost on an sms.inbound event is "0.00". |
cost in a response is what that message costs, and currency is always
"usd". A 200-character GSM message, for example, 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 are strings
cost, balance and every other amount are decimal strings of US dollars,
with up to 4 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. If you do arithmetic on them, parse them with a decimal type, not a
float.
How many segments a message takes
| Characters used | One segment holds | Each segment of a longer message holds |
|---|---|---|
| Only standard GSM characters | 160 characters | 153 characters |
| Any other character, including most emoji and accented letters outside the GSM set | 70 characters | 67 characters |
{ } [ ] ~ ^ \ | and € each count as two GSM characters.
Safe retries
A network error can leave you unsure whether a send went through, and simply
retrying can send the message twice. Send an Idempotency-Key header to make
retries safe.
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.)
Pick a key that identifies the send itself, such as your order ID plus the
event (order-4471-shipped), not a random value generated for each attempt.
How webhooks work
A webhook sends an HTTP POST to your server when something happens to your
messages, so you don't have to poll.
- Register an endpoint with your server's URL, in the
console under Webhooks or with the API, and save the
secretit returns. - Receive events on that URL and answer each with a
2xx. - Verify each event's signature before you trust it.
There are two event types. An endpoint subscribes to one or both, and to
sms.status alone if you don't say.
sms.statusscheduled, sending, sent, delivered, undelivered or failed.sms.inboundReceiving events
Each event arrives as a POST with a JSON body and these headers:
X-RebelText-Eventsms.status or sms.inbound.X-RebelText-Delivery-Idwhd_...). A retried event keeps its ID, so use it to ignore duplicates.X-RebelText-Attempt1.X-RebelText-TimestampX-RebelText-SignatureThe body is a message object, the same one
GET /v1/messages/{reference_id} returns, so one
parser handles both.
How to respond
- Return any
2xxas soon as the event arrives. Anything else, or no answer within 10 seconds, counts as a failure and is retried. - Redirects aren't followed, so a
3xxis a failure too. Register the final URL. - Return
410 Goneto refuse an event for good. We stop retrying it straight away. - 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. Skip any
X-RebelText-Delivery-Idyou've already processed, and order a message's events byupdated_at, not by when they arrived.
sms.status body
{
"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 body
For a received message, 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"
}
Verifying signatures
Always check that an event came from RebelText before you act on it.
X-RebelText-Signature is built like this:
base64( hex( HMAC-SHA256(raw_request_body, webhook_secret) ) )
To check it:
- Compute the HMAC-SHA256 of the raw request body, using your endpoint's
full
secret(includingwhsec_) as the key. - Write the result as a lowercase hex string.
- Base64-encode that hex string.
- Compare the result 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 it arrived. If you parse the JSON and encode it again, 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 and pausing
Retries. When a delivery fails, we try again on this schedule. Each delay varies by up to ±20%.
| Retry | Delay after the 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. Once your server is
working again you can replay it.
Pausing. After 10 failed deliveries in a row, we pause sending to that
endpoint. The first pause lasts 30 seconds; if failures continue, later pauses
last 1, 2, 5 and then 15 minutes. When a pause ends we send one delivery first,
and the rest once it succeeds. While paused, disabled_until in
List endpoints shows when sending resumes. A successful
delivery resets both the failure count and the pause length.
Pacing. We send at most 8 deliveries to one endpoint at a time, so a backlog arrives as a steady stream rather than all at once.
Use with AI agents (MCP)
RebelText speaks the Model Context Protocol, so an AI agent such as Claude, ChatGPT, Claude Code or Cursor can send messages and look up their status for you. It runs on an ordinary API key and behaves exactly like the REST API: the same validation, the same subscription requirement, the same charges, and every call appears in your API log.
Endpoint: https://rebeltext.docoitest.com/mcp (Streamable HTTP).
Sign in from the app
Most AI apps, such as Claude and ChatGPT, need only the endpoint URL. Add it as a connector or custom MCP server and the app sends you to the RebelText console to sign in and approve it. The page names the app and the address it will return you to. Only allow it if that address belongs to the app you are connecting.
Approving issues a live API key named after the app, such as "Claude (connected app)". It counts toward your plan's keys and appears on the API keys page with the others. To disconnect the app, revoke that key. Connecting needs a plan and a free key in your allowance.
Use an API key
Apps that take a header instead can use a key you issue yourself. Claude Code:
claude mcp add --transport http rebeltext https://rebeltext.docoitest.com/mcp \
--header "Authorization: Bearer $REBELTEXT_API_KEY"
Other clients take the same URL and Authorization header in their MCP
server settings.
Tools
| Tool | Does | REST equivalent |
|---|---|---|
send_message |
Sends one SMS or MMS. Charges your balance. | POST /v1/messages |
get_message |
One sent message and its status | GET /v1/messages/{id} |
list_messages |
Sent messages, with filters | GET /v1/messages |
get_inbound_message |
One received message | GET /v1/inbound-messages/{id} |
list_inbound_messages |
Received messages | GET /v1/inbound-messages |
get_conversation |
Texts to and from one number, oldest first, each marked outbound or inbound | GET /v1/messages and GET /v1/inbound-messages |
get_account |
Account, plan and subscription | GET /v1/account |
get_usage |
Balance and queue depth | GET /v1/account/usage |
list_numbers |
Numbers you can send from | GET /v1/numbers |
list_suppressions |
Numbers you won't message | GET /v1/suppressions |
add_suppressions |
Stop messaging numbers | POST /v1/suppressions |
remove_suppression |
Allow a number again | DELETE /v1/suppressions/{number} |
send_message is marked as destructive, so well-behaved clients ask you before
each send. An agent with your key can still spend your balance, so give it a key
you can revoke, and pass idempotency_key so a retried send never goes out
twice (Safe retries).
API reference
Every endpoint, grouped by what it acts on. Each one lists its parameters, with
a request and a response example beside it. Paths are relative to the base URL,
https://rebeltext.docoitest.com, and every request needs your
API key.
Messages
Send messages and find out what happened to them. Both endpoints return the message object.
Send a message (SMS or MMS)
POST /v1/messages
Queues one SMS for delivery, or one MMS when it carries media.
A successful call returns 202 Accepted: the message is queued, not yet
delivered. To find out what happened next, check its status
or let a webhook tell you.
Headers
AuthorizationrequiredBearer <your API key>Content-TyperequiredIdempotency-Keyoptionalorder-4471-shipped. Retry with the same key within 24 hours and no second message is sent. See Safe retries.Body
to_numberstringrequired5125550199, (512) 555-0199, +15125550199. See Phone numbers.messagestringrequired, unless media is sentmediastring or fileoptionalfrom_numberstringoptionalcountry_codestringoptionalto_number, such as +1. Defaults to +1. Ignored when to_number starts with +.metadataobjectoptional{"order_id": "4471"}. Returned when you read the message and in its webhooks. In a multipart/form-data request, send it as a JSON-encoded string.scheduled_atstringoptional2026-09-20T15:00:00Z, up to 90 days ahead and not in the past.What you get back
data is a message object plus balance: what your
account has left, in US dollars, after paying for this message.
- Save
reference_id. It's the ID you use to check the message's status and to match its webhook events. statusisaccepted, orscheduledif you set a futurescheduled_at.receiveris the recipient in E.164 format, such as+15125550199.senderis""if you left outfrom_number. The number we chose shows up when you read the message after it's sent.
Errors
| Problem | Status | code |
|---|---|---|
Invalid phone number or country_code; message missing or too long; media unsupported, unreachable or too big; 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 a second (Rate limits) | 429 |
rate_limited |
| Request body too large | 413 |
payload_too_large |
Check message status
GET /v1/messages/{reference_id}
Returns the current state of a message you sent.
Reading the result
data is a message object. Its status says where the
message is; Message statuses lists every value.
When a message fails, status is failed or undelivered, error_code is
set and remarks gives the reason. If the carrier supplied its own code and
text, you get them word for word in carrier_error_code and
carrier_error_message:
{
"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"
}
Tip: Don't poll this endpoint in a tight loop. Webhooks tell you the moment a status changes.
Errors
| Problem | Status | code |
|---|---|---|
| No message with this ID on your account | 404 |
not_found |
List messages
GET /v1/messages
Returns the messages you've sent, newest first, a page at a time. See Pagination.
limitdefault 50200.cursornext_cursor from the previous page.sincedefault 30 days agountildefault nowstatusfromtoapi_key_idkey_...).A from or to that isn't a phone number gets 422 invalid_request rather
than an empty list. A message still waiting for us to choose its sender has no
from yet, so it only appears in lists that don't filter on from.
Send a batch
POST /v1/messages/batch
Sends a message to up to 1000 recipients in one request. Settings at the top
apply to every message, and each recipient can override body, media and
metadata. A recipient's metadata is merged into the batch's, not swapped
for it.
The field names differ from Send a message:
from,bodyandtohere, andmediais a list.
messagesobject[]requiredto (required, any common phone format) and, optionally, its own body, media and metadata.bodystringrequired, unless every recipient has its own body or mediafromstringoptionalmediastring[]optionalmedia_url reports the first.metadataobjectoptionalscheduled_atstringoptionalEvery recipient is accepted or rejected on its own, so one bad number doesn't
hold up the rest. The response is 202 whenever at least one recipient was
accepted, and 422 only when none were.
A recipient that didn't become a message has reference_id null, a cost
of "0.00", and status either rejected (the reason follows Reason: in
remarks, and error_code names it) or error (a problem on our side;
error_code is internal_error).
Batches don't take an Idempotency-Key, so retrying a batch after a
network error can send it twice. Before you retry, list messages
to see what went out.
Get a batch
GET /v1/batches/{id}
Returns a batch's summary: how many recipients were accepted and rejected, and what they cost. Each recipient's outcome is in the response to Send a batch; to see them later, list messages.
Errors
| Problem | Status | code |
|---|---|---|
| No batch with this ID on your account | 404 |
not_found |
Received messages
Messages people send to your numbers, the console's Received messages. Each one is stored, charged and sent to any endpoint
subscribed to sms.inbound (see Receiving events). These
endpoints read them back.
- Whose reply it is. A reply to one of your own numbers is yours. A reply to a shared-pool number goes to the account that most recently messaged that person from it in the last 30 days.
- What it costs. A received SMS is charged per segment at the price for received messages, which is free unless your account says otherwise; a received MMS is charged per message. The charge is taken when the message arrives, even if it takes your balance below zero.
- Attachments. A received MMS has its first attachment in
media_urland all of them inmedia_urls. We copy them to our own storage within seconds, because the carrier's copies expire after 30 days. - Opt-outs. A reply that's just STOP, STOPALL, UNSUBSCRIBE, CANCEL, END or QUIT (in any case) adds the sender to your suppressions, and START removes them again. The reply itself is still stored and delivered. HELP isn't answered for you.
List received messages
GET /v1/inbound-messages
Returns replies to your numbers, newest first, a page at a time. See Pagination.
limitdefault 50200.cursornext_cursor from the previous page.sincedefault 30 days agountildefault nowfromtoGet a received message
GET /v1/inbound-messages/{id}
Returns one reply, exactly as the sms.inbound webhook delivered it.
Errors
| Problem | Status | code |
|---|---|---|
| No received message with this ID on your account | 404 |
not_found |
Suppressions
Numbers you won't message. A send to a suppressed number is refused with
422 recipient_suppressed and isn't charged. A recipient who replies STOP is
added automatically (see Received messages), and you can
add your own, such as a do-not-call list.
List suppressions
GET /v1/suppressions
Returns your suppressed numbers, each with why and when it was added.
limitdefault 100500.Add suppressions
POST /v1/suppressions
Stops messages to one number or thousands, straight away.
numbersstring[]requiredreasonstringoptionalmanual (the default), dnc_import, carrier_block or complaint. stop_keyword is refused: only a STOP reply records it.notestringoptionalNumbers that can't be read as phone numbers are skipped and listed in
invalid; the rest are still added.
Remove a suppression
DELETE /v1/suppressions/{number}
Lets you message a number again. The number can be in any common format.
A number that replied STOP can't be removed this way: only their own START reply lifts it.
Errors
| Problem | Status | code |
|---|---|---|
| The number isn't suppressed | 404 |
not_found |
| The recipient replied STOP | 409 |
recipient_opted_out |
Numbers
List your numbers
GET /v1/numbers
Returns the numbers you can send from: your own, plus any shared numbers available to your account.
numberdedicatedtrue for a number that's yours alone, false for a shared one.sms, mmstoll_freetps_limitdaily_limitAccount
Get your account
GET /v1/account
Returns your account, its plan, balance and subscription.
id, name, statusplan_idbalance, currencyrate_per_secondsubscriptionGet usage
GET /v1/account/usage
Your balance, plan, send rate and how busy delivery is, in one call, so a bulk sender can pace itself.
balance, currencyplanrate_per_secondqueue_depthThere's no API for adding money: top up from the console (Billing), in whole dollars.
Webhooks
Manage your endpoints: the URLs we send events to. You can do the same in the console under Webhooks. How events are delivered, verified and retried is covered in How webhooks work.
Register an endpoint
POST /v1/webhooks
Registers a URL to receive events.
urlstringrequiredhttp or https; use https for anything real.event_typesstring[]optionalsms.status, sms.inbound, or both. Defaults to ["sms.status"].descriptionstringoptional⚠️ Save
secretfrom the response now: it is shown only once. You need it to verify signatures. If you lose it, delete the endpoint and register a new one.
Errors
| Problem | Status | code |
|---|---|---|
Missing or invalid url, unknown event type, description too long |
422 |
invalid_request |
List endpoints
GET /v1/webhooks
Returns all your endpoints and how healthy each one is. secret is never
included.
Each endpoint has these health fields as well as the ones it was created with:
consecutive_failures0 after a successful delivery.disabled_untillast_success_atlast_failure_atDelete an endpoint
DELETE /v1/webhooks/{id}
Stops all events to this endpoint. Returns 404 not_found if there's no endpoint
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 this endpoint (the console's Send test).
Use it to check that your server receives events and verifies signatures,
without sending a real message.
The test event has reference_id msg_test_00000000000000000000000000 and
metadata {"test": "true"}, so you can recognise and ignore it.
Delivery log
Every event we send to an endpoint is a delivery. The console shows them under Webhooks › Delivery log. Use these requests to see what was sent, what your server answered, and to send a delivery again.
List deliveries
GET /v1/webhook-deliveries
Shows recent webhook deliveries, newest first, with the payload we sent and the response your server gave. Use it to debug an endpoint that isn't working.
message_idmsg_...).limitdefault 50200.Each delivery has a status:
Delivery status |
Meaning |
|---|---|
pending |
Waiting to be sent. After a failed attempt, last_error and response_code explain the failure and next_retry_at says 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, with a fresh set of retries. It
keeps the same payload and delivery ID, so your duplicate check still works.
Returns 404 not_found if the delivery doesn't exist, is still pending or
sending, or belongs to an endpoint that has been deleted.
Objects and errors
The message object
Sending a message, reading one, and every webhook event all return this object.
Every field is always present, except media_urls: one that doesn't apply is
null, or {} for metadata. All timestamps are UTC, such as 2026-09-17T10:04:14.019000Z.
reference_idstringmsg_... for messages you send, inb_... for messages you receive.senderstring"" until a number is chosen, if you didn't set from_number.receiverstringsms_typestringsms, or mms for a message with media.messagestringmedia_urlstring | nullnull for SMS.media_urlsstring[]statusstringremarksstringstatus in plain English. On failure it ends with Reason: and the reason.error_codestring | nullcarrier_error_codestring | nullnull when the carrier gave none, including for failures RebelText sets itself.carrier_error_messagestring | nullnull when the carrier gave none.segmentsintegercoststring"0.0095". See Pricing and segments.currencystringcost. Always usd.attemptsintegermetadataobjectmetadata you sent, or {}.api_key_idstring | nullkey_01HQ.... null for messages you receive.scheduled_atstring | nullsent_atstring | nullreceived_atstring | nullnull for messages you send.delivered_atstring | nullcreated_atstring | nullupdated_atstring | nullMessage 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 |
A message someone sent you (sms.inbound). |
✅ | Yes |
- A message never moves back to an earlier status.
sentusually becomesdeliveredorundeliveredwithin seconds, but some carriers never confirm delivery, sosentmay be the last status you see.- Webhooks are sent for
scheduled,sending,sent,delivered,undeliveredandfailed. Not foraccepted: you already have that from the send response.
Failure codes
When status is failed, error_code says why. The codes below come from
RebelText. Any other value comes from the carrier, and then
carrier_error_code and carrier_error_message 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 a 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 an 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 console; sending works again on your 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. |
recipient_opted_out |
409 |
You tried to remove a suppression the recipient asked for by replying STOP. Only their START reply lifts it. |
rate_limited |
429 |
Too many requests. Wait 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 it keeps happening, contact support with the X-Request-Id. |