Messaging
This guide covers message submission, status tracking, cost preview, segment encoding, and operational guardrails.
Submitting a message
Via account
POST /v1/accounts/{accountId}/messages
Authorization: Bearer eyJ... (or X-Api-Key)
Content-Type: application/json
{
"channelType": "Sms",
"destination": "+15551234567",
"body": "Your verification code is 123456. Expires in 10 minutes.",
"messageType": "Transactional",
"actor": "user-api"
}
Via subaccount
POST /v1/subaccounts/{subaccountId}/messages Authorization:
Bearer eyJ... (or X-Api-Key)
Response (201 Created):
{
"messageId": "...",
"status": "queued",
"segmentCount": 1,
"encoding": "gsm7",
"estimatedCost": 0.008
}
Tagging a message with your own reference
Set clientReference to your own id for what the message is about, such as an appointment or order id.
It can be up to 128 characters (surrounding spaces are trimmed; a blank value means none).
{
"channelType": "Sms",
"destination": "+15551234567",
"body": "Reminder: your visit is tomorrow at 2pm. Reply C to confirm.",
"messageType": "Transactional",
"actor": "user-api",
"clientReference": "appt-12345"
}
ArelHub returns clientReference unchanged on the message, in the data of every webhook for
that message, and with every reply to it, so you can match events to your own records
without keeping a phone-number lookup. ArelHub never interprets it and does not require it to be unique. A value
longer than 128 characters is rejected with 400 Bad Request.
Scheduling a message
Set sendAt to send the message later instead of straight away. It is a UTC time in ISO-8601 with a
Z suffix, at least 60 seconds and at most 30 days in the future.
{
"channelType": "Sms",
"destination": "+15551234567",
"body": "Reminder: your visit is tomorrow at 2pm. Reply C to confirm.",
"messageType": "Transactional",
"actor": "user-api",
"clientReference": "appt-12345",
"sendAt": "2026-10-20T13:00:00Z"
}
The message is checked when you submit it, exactly as a message sent straight away is, so a rejection is
returned immediately. The response is 201 Created with the message in the Scheduled
state and your sendAt echoed back. A sendAt outside the allowed window is rejected with
400 Bad Request and the code SEND_AT_OUT_OF_RANGE.
At the send time the message is released and sent through the normal path, which normally takes a minute or
two. It is charged when it is sent, not when you schedule it. If the account is paused for outbound sending at
that moment, or the recipient has opted out since, the message is handled as it would be for any other message
sent then. A message held for manual review is sent when it is approved, even if its sendAt has
passed.
Cancel a scheduled message
POST /v1/accounts/{accountId}/messages/{messageId}/cancel
POST /v1/subaccounts/{subaccountId}/messages/{messageId}/cancel
A message that is still Scheduled moves to Canceled, is never sent and is never
charged; the response is the message. Any other message cannot be canceled and the call returns
409 Conflict with the code MESSAGE_NOT_CANCELABLE. If the cancel and the send time
arrive together, one of them wins and the other reports what the message is now, so a 409 means the
message was already on its way.
Expiring a message
Set expiresAt (UTC, ISO-8601 with Z) when a message is only worth sending for a while,
such as a reminder that is useless after the appointment. It must be in the future and, if you also set
sendAt, later than it. Otherwise the request is rejected with 400 Bad Request and the
code EXPIRES_AT_INVALID.
{
"channelType": "Sms",
"destination": "+15551234567",
"body": "Your driver is 10 minutes away. Reply 1 to reschedule.",
"messageType": "Transactional",
"actor": "user-api",
"expiresAt": "2026-10-20T14:00:00Z"
}
ArelHub checks the expiry before every attempt to send the message: the first send, a retry, a message released
from an account hold, and a scheduled message released at its sendAt. A message that is past its
expiresAt moves to Expired, is never sent and is never charged, and you receive a
MessageExpired webhook. A message that has already been handed to the carrier is not recalled, so it
can still arrive after expiresAt.
For a message that offers reply options, expiresAt is also the answer
deadline. Once it has passed, the message's replyStatus reads Expired if no answer
arrived. A reply that arrives later is still stored, listed and forwarded to your webhook, with
"afterExpiry": true in its reply block, but it does not answer the message.
Marking a message urgent
Set priority to Urgent (the default is Normal) for messages that must not
queue behind routine traffic, such as a delivery that is about to fail.
{
"channelType": "Sms",
"destination": "+15551234567",
"body": "Your driver cannot find your address. Reply 1 with directions.",
"messageType": "Transactional",
"actor": "user-api",
"priority": "Urgent"
}
Priority only decides the order of work that has to wait. A message sent straight away is dispatched at once whatever its priority, so it changes nothing there. It matters when sending is delayed:
- When messages are retried, urgent ones go first, then the one that has waited longest.
- When an account's outbound hold is released, held urgent messages are released first.
- A scheduled urgent message is sent as soon as its
sendAtarrives, instead of waiting for the next pass of the sending queue, which can take a minute or two for a normal message.
An urgent message does not get a larger share of the carrier's sending capacity, and it is checked, charged and
limited exactly like a normal one. priority is returned on the message.
Message status lifecycle
| Status | Meaning |
|---|---|
scheduled |
Accepted, waiting for its sendAt |
queued |
Accepted, awaiting dispatch |
dispatched |
Submitted to carrier |
delivered |
Confirmed delivery receipt from carrier |
undelivered |
Carrier confirmed non-delivery |
failed |
Internal error during dispatch |
canceled |
Canceled before it was sent |
expired |
Expired before it could be sent |
GET /v1/accounts/{accountId}/messages/{messageId}
Message creation runs the server-owned lifecycle. Use the message response or this GET endpoint to observe its state and audit trail.
Replies
When a recipient texts back, ArelHub stores the reply and links it to the message it answers. You can receive
replies as they arrive by subscribing a webhook to
MessageReceived, or read them back with the endpoints below.
How a reply is linked to a message
A reply is linked to the newest message your account sent to that person, from the number that received
the reply, provided that message reached the carrier and was sent within the last 72 hours. If no message
matches, the reply is still stored and delivered; it just has no link (inReplyToMessageId is
null).
Because a reply carries no reference to a specific message, the number is what tells ArelHub which conversation it belongs to. If you send two messages to the same person from the same number and they reply once, the reply is linked to the newer message. To keep answers unambiguous, wait for a person's answer before sending them another question from the same number.
Replies that are opt-out or opt-in keywords are handled first, exactly as before: STOP opts the person
out, START opts them back in, and HELP is recognised. They are also stored and delivered
like any other reply, with keyword set to Stop, Start or Help
(otherwise null).
List replies
GET /v1/accounts/{accountId}/inbound-messages?from=2026-10-01T00:00:00Z&to=2026-11-01T00:00:00Z&pageSize=50
Authorization: Bearer eyJ... (or X-Api-Key)
{
"items": [
{
"inboundMessageId": "00000000-0000-0000-0000-0000000000a1",
"accountId": "00000000-0000-0000-0000-000000000001",
"subaccountId": null,
"channelType": "Sms",
"from": "+15551234567",
"to": "+12025551234",
"body": "C",
"receivedAt": "2026-10-15T10:00:00+00:00",
"keyword": null,
"inReplyToMessageId": "00000000-0000-0000-0000-000000000002",
"inReplyToClientReference": "appt-12345",
"replyOptionKey": null,
"replyOptionValue": null
}
],
"nextCursor": "MjAyNjEwMTUxMDAwMDA..."
}
- Replies are returned newest first.
fromis inclusive andtois exclusive. Both are optional.pageSizeis 1 to 200 and defaults to 50.- To read the next page, pass the previous response's
nextCursorascursor. WhennextCursorisnullyou have reached the end. - An invalid cursor, an empty date range or a page size outside 1 to 200 returns
400 Bad Request.
For one subaccount's replies:
GET /v1/subaccounts/{subaccountId}/inbound-messages
Authorization: Bearer eyJ... (or X-Api-Key)
Replies to one message
GET /v1/accounts/{accountId}/messages/{messageId}/replies
Authorization: Bearer eyJ... (or X-Api-Key)
Returns an array of replies in the same shape as above, oldest first, or an empty array if there
are none. It returns 404 Not Found when the message does not exist in the account. For a message sent
through a subaccount, use GET /v1/subaccounts/{subaccountId}/messages/{messageId}/replies.
Reply options
A reply option lets a recipient answer with a short text, such as C to confirm, and tells your system
which answer they gave, with no text parsing on your side. You define the options on an SMS template. Messages
sent from that template then report the answer.
Define the options on a template
Set them in the dashboard's template editor, or with a signed-in user token (template management is not available to API keys):
POST /v1/templates
Authorization: Bearer eyJ...
Content-Type: application/json
{
"name": "Appointment reminder",
"channelType": "Sms",
"bodyTemplate": "Hi {{name}}, your visit is tomorrow at {{time}}. Reply C to confirm or R to reschedule.",
"replyOptions": [
{ "key": "C", "value": "confirm", "aliases": ["CONFIRM", "1"] },
{ "key": "R", "value": "reschedule", "aliases": ["RESCHEDULE", "2"] }
]
}
keyis what the recipient texts.aliasesare other texts that choose the same option.valueis what your system receives (1 to 64 characters).- Replies are not case sensitive, and keys and aliases are stored in upper case.
- A template can have up to 10 options, each with up to 5 aliases. A key or alias is 1 to 20 letters or digits and must be unique across the template.
- Only SMS templates can have reply options.
- ArelHub does not add the instruction to your message. Write it in the body ("Reply C to confirm").
PUT /v1/templates/{templateId} changes them: leave replyOptions out to keep the current
options, send [] to remove them, or send a new list to replace them. Messages that were already sent
keep the options they were sent with; editing a template never changes how an earlier message is answered.
Words you cannot use. The platform acts on these words itself, so none can be a key or an alias:
CANCEL, END, HELP, INFO, QUIT,
START, STOP, STOPALL, UNSTOP, UNSUBSCRIBE and
YES. Note that CANCEL and YES are in the list: CANCEL is an
opt-out word and YES is an opt-in word, so use something else, such as X,
N, 1 or 2, for "cancel" and "yes".
A refused option returns 400 Bad Request with the reason in detail:
REPLY_OPTION_RESERVED_KEYWORD for a reserved word, or REPLY_OPTION_INVALID for anything
else (too many options, a key with a space, a duplicate, a template that is not SMS).
Send a message that offers the options
Send as usual with templateId. The message starts out waiting for an answer:
{
"messageId": "00000000-0000-0000-0000-000000000002",
"replyStatus": "Awaiting",
"replyOptionKey": null,
"replyOptionValue": null,
"answeredAt": null
}
How a reply is matched
The whole reply must be one of the keys or aliases. Capitals, spaces around the text, and full stops or
exclamation marks at the end are ignored, so c, C , C. and
C! all choose C. Anything else, such as yes please, C or R or
Cx, is not treated as an answer. It is still delivered to you like any other reply.
ArelHub never guesses.
If you have sent a person a message with options and then another message without any, a reply is linked to the one with options, so a plain follow-up does not take the answer away.
Where the answer appears
- Webhook. The
MessageReceivedevent carries adata.replyblock. See Message webhooks. - The message.
replyStatusisAwaitinguntil an answer arrives, thenAnswered, withreplyOptionKey,replyOptionValueandansweredAtset.replyStatusisNonefor a message that offered no options. - The reply. Each reply returned by the replies endpoints has
replyOptionKeyandreplyOptionValuewhen it chose an option, andnullotherwise.
A person can change their mind: a later reply that matches an option replaces the earlier answer on the message. Every reply stays in the list, so you can see the full history.
Segment preview
POST /v1/messages/segments/preview
Content-Type: application/json
{
"body": "Your order# 123456 is ready for pickup at 123 Main St."
}
Response:
{
"segmentCount": 1,
"encoding": "gsm7",
"characterCount": 51,
"charactersPerSegment": 160
}
Messages with non-GSM characters switch to UCS-2, reducing per-segment capacity from 160 to 70 characters.
Message cost
GET /v1/billing/message-costs
GET /v1/billing/message-costs/channels
GET /v1/billing/message-costs/detail
Quota enforcement
Returns
429 Too Many Requests
when quota is exhausted.
GET /v1/quota/subaccounts/{subaccountId}
Templates
POST /v1/accounts/{accountId}/templates/{templateId} # create
POST /v1/accounts/{accountId}/templates/{templateId}/render # preview substitution
Routing policy
GET /v1/subaccounts/{subaccountId}/routing-policy
POST /v1/subaccounts/{subaccountId}/routing-policy/preview
Trying the next channel when a message is not confirmed
A routing policy normally tries the next channel only when sending on the first one is refused. Two optional settings make it move on after the message was sent, for a message that is never confirmed or whose delivery fails:
PUT /v1/subaccounts/{subaccountId}/routing-policy
{
"preferredChannelType": "Sms",
"fallbackChain": ["WhatsApp", "Email"],
"isEnabled": true,
"receiptTimeoutSeconds": 300,
"fallbackOnDeliveryFailure": true
}
| Field | Meaning |
|---|---|
receiptTimeoutSeconds |
If a sent message has no final delivery receipt after this many seconds (60 to 86400), it is sent on the
next channel of the route. 0 turns the wait off. |
fallbackOnDeliveryFailure |
When a delivery failure is reported after the message was sent, send it on the next channel of the route instead of ending the message. |
Leave a field out to keep its current value; both are returned on the policy. They only apply to messages sent through a subaccount, and only while the policy is enabled. Each channel is tried once, in the order of the route, and every attempt is charged as a channel attempt of its own.
The first message is not recalled. A message that was only slow can still arrive after the next channel has been tried, so the recipient may receive it on both channels. Set a timeout long enough for your channel and turn these on only where a duplicate is better than silence. If every channel has been tried and the last one is not confirmed either, the message stays as it is; there is no further attempt.
Opt-outs and consent
ArelHub stops every message to a recipient who has opted out, on every channel attempt, including a fallback channel. You can record opt-outs yourself, look up a recipient, import a list, and keep proof of the consent you collected.
Where an opt-out applies
| Opt-out | Blocks |
|---|---|
| A subaccount opt-out you record | That subaccount's messages, and your account-level messages (sent without a subaccount) |
An account-level opt-out, entireAccount | The account and every subaccount |
An account-level opt-out, accountLevelOnly | Messages sent without a subaccount |
A STOP reply to a text | The number that received it: that subaccount, or your account-level number |
A recipient who replies STOP can only re-subscribe by replying START. A removal made
through support is changed through support. You can lift the opt-outs your own team recorded or imported.
Routes and access
Use the subaccount routes for one subaccount and the account routes for your account-level sending. A subaccount
user or a key limited to a subaccount reaches only its own subaccount. A route you cannot reach answers
404.
/v1/subaccounts/{subaccountId}/recipient-consent
/v1/accounts/{accountId}/recipient-consent
Both accept a user JWT or an API key. Reading needs any user role, or a key with
ReadRecipientConsent or ManageRecipientConsent. Changing needs an account Admin or
Manager, or a key with ManageRecipientConsent. Existing keys do not have these rights until you add
them.
| Method and path (after the route above) | Purpose |
|---|---|
GET /opt-outs | List opt-outs |
GET /preferences?recipient= | One recipient's status per channel |
POST /opt-out | Record an opt-out |
POST /opt-in | Re-subscribe a recipient, with evidence of new consent |
POST /opt-outs/import | Import opt-outs from a CSV file |
GET /opt-outs/export | Export opt-outs as CSV |
POST /opt-in-records | Store one piece of consent evidence |
GET /opt-in-records | List stored consent evidence (page with cursor) |
POST /opt-in-records/import | Import consent evidence from a CSV file |
GET /opt-in-records/export | Export consent evidence as CSV |
Record an opt-out
POST /v1/accounts/{accountId}/recipient-consent/opt-out
X-Api-Key: ...
Content-Type: application/json
{ "recipient": "+15551234567", "channelType": "Sms", "scope": "entireAccount", "reason": "Asked by phone" }
On an account route scope is required: accountLevelOnly or entireAccount.
A missing scope returns RECIPIENT_OPT_OUT_SCOPE_REQUIRED. Subaccount routes take no scope.
Recording the same opt-out twice is not an error.
Re-subscribe
POST /opt-in needs consentConfirmed: true, a consentMethod
(WebForm, Written, Verbal, Text or Other) and
consentEvidence. The evidence is written to your audit log before the recipient is re-subscribed. If
you cannot lift the opt-out the call returns 409: RECIPIENT_OPT_OUT_NOT_LIFTABLE for a
STOP or a removal through support, and RECIPIENT_NOT_OPTED_OUT when there is nothing to
lift. Nothing is written in either case.
Bulk import and export
Upload a CSV as multipart/form-data in a field named file. Files can hold up to 10,000
rows and 5 MB. Opt-out files have the columns recipient, channel and an optional
reason. Consent evidence files have phone_number, opted_in_at (UTC),
method and optional source and consent_language. Account opt-out imports
also need ?scope=accountLevelOnly or ?scope=entireAccount.
The response lists every row with a result of created, already_exists,
duplicate_in_file or invalid, and the SHA-256 of the file. An import only adds opt-outs:
it never re-subscribes anyone, and it does not send opt-out webhooks. Upload the same file again and nothing
changes. Exports are capped at 10,000 rows; the X-Truncated header is true when more rows
exist. Cells that start with =, +, - or @ are written with a
leading apostrophe so a spreadsheet does not run them.
Consent evidence
Storing a record shows when and how a recipient agreed to receive texts. It is never required to send and it never
lifts an opt-out: if the recipient is still opted out the response carries an X-Consent-Warning
header. The consent date cannot be in the future. Subscribe an endpoint to RecipientOptedOut and
RecipientOptedIn to receive changes; see Webhooks.