Skip to content

API reference

Batches

Submit messages and read back what happened to them.

A 202 does not mean everything was accepted

Submit a batch

POST/public/v1/batches

Prices the batch, reserves the money and queues every accepted recipient. Returns 202: the batch is accepted and paid for, but nothing has been delivered yet.

Required scope MESSAGES_SEND

Body parameters

senderstringRequired

The name or number shown on the recipient's handset. Must be approved and operator-registered; check is_sendable on the sender-ids endpoint first.

max 32 characters

bodystringRequired

The message template. May contain merge tags in braces, resolved per recipient from that recipient's attributes.

max 4000 characters, not blank after trimming

message_classstringRequired

Decides queue priority, whether quiet hours apply, which frequency caps are enforced and which suppression list is consulted.

TRANSACTIONAL | MARKETING | OTP

channelstringOptional

The delivery channel.

SMS | WHATSAPP — default SMS

recipientsarrayRequired

Who to send to. Each entry carries a number and, optionally, the values for that recipient's merge tags.

1–100000 items

recipients.msisdnstringRequired

Local or international form. Normalised to E.164 with Burkina Faso as the default region; a number that cannot be parsed becomes a rejection row rather than an error.

max 32 characters — local or E.164, default region BF

recipients.attributesobjectOptional

Merge-tag values for this recipient. A tag with no matching attribute substitutes to an empty string.

scheduled_atstringOptional

When to send. Must be in the future. A batch that ends up more than 30 minutes late is cancelled and refunded rather than sent.

ISO 8601, must be in the future

timezonestringOptional

The timezone the schedule is expressed in. Only meaningful alongside scheduled_at.

IANA name, max 64 characters — defaults to the account timezone

is_testbooleanOptional

Excludes the batch from campaign statistics. It is still billed and still delivered — this is a reporting flag, not a sandbox.

default false

Request

curl -X POST https://api.smsend.net/public/v1/batches \
  -H "Authorization: Bearer $SMSEND_API_KEY" \
  -H "Idempotency-Key: order-1042" \
  -H "Content-Type: application/json" \
  -d '{
    "sender": "PRESTIGE",
    "body": "Bonjour {prenom}, votre commande {ref} est prête.",
    "message_class": "TRANSACTIONAL",
    "recipients": [
      { "msisdn": "+22670000001", "attributes": { "prenom": "Aïssata", "ref": "CMD-8842" } },
      { "msisdn": "+22670000002", "attributes": { "prenom": "Boureima", "ref": "CMD-8843" } }
    ]
  }'
<?php

$response = Http::withToken(getenv('SMSEND_API_KEY'))
    ->withHeaders(['Idempotency-Key' => 'order-1042'])
    ->post('https://api.smsend.net/public/v1/batches', [
        'sender' => 'PRESTIGE',
        'body' => 'Bonjour {prenom}, votre commande {ref} est prête.',
        'message_class' => 'TRANSACTIONAL',
        'recipients' => [
            ['msisdn' => '+22670000001', 'attributes' => ['prenom' => 'Aïssata', 'ref' => 'CMD-8842']],
            ['msisdn' => '+22670000002', 'attributes' => ['prenom' => 'Boureima', 'ref' => 'CMD-8843']],
        ],
    ]);

$batch = $response->json('data');
const response = await fetch('https://api.smsend.net/public/v1/batches', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.SMSEND_API_KEY}`,
    'Idempotency-Key': 'order-1042',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    sender: 'PRESTIGE',
    body: 'Bonjour {prenom}, votre commande {ref} est prête.',
    message_class: 'TRANSACTIONAL',
    recipients: [
      { msisdn: '+22670000001', attributes: { prenom: 'Aïssata', ref: 'CMD-8842' } },
      { msisdn: '+22670000002', attributes: { prenom: 'Boureima', ref: 'CMD-8843' } },
    ],
  }),
})

const { data } = await response.json()
import os
import requests

response = requests.post(
    "https://api.smsend.net/public/v1/batches",
    headers={
        "Authorization": f"Bearer {os.environ['SMSEND_API_KEY']}",
        "Idempotency-Key": "order-1042",
    },
    json={
        "sender": "PRESTIGE",
        "body": "Bonjour {prenom}, votre commande {ref} est prête.",
        "message_class": "TRANSACTIONAL",
        "recipients": [
            {"msisdn": "+22670000001", "attributes": {"prenom": "Aïssata", "ref": "CMD-8842"}},
            {"msisdn": "+22670000002", "attributes": {"prenom": "Boureima", "ref": "CMD-8843"}},
        ],
    },
)

batch = response.json()["data"]

Responses

202The batch was accepted. Read the counts to see how many recipients made it through.
{
  "data": {
    "batch_id": "01K3F7XQZ8V2N4M6P8R0T5CJWE",
    "status": "SCHEDULED",
    "accepted": 948,
    "rejected": 52,
    "rejections": [
      { "index": 17, "msisdn": "+22670000018", "reason_code": "SUPPRESSED" },
      { "index": 41, "msisdn": null, "reason_code": "INVALID_MSISDN" },
      { "index": 88, "msisdn": "+22670000089", "reason_code": "DUPLICATE_IN_BATCH" }
    ],
    "reserved_amount": 23700,
    "currency": "XOF",
    "total_segments": 948
  }
}

Possible errors

CodeStatusMeaning
IDEMPOTENCY_KEY_REQUIRED422A batch was submitted without an Idempotency-Key header.
VALIDATION_FAILED422The request was malformed: a missing or invalid parameter, a bad idempotency key length, a rejected webhook URL, or an unsupported method or content type.
REQUEST_IN_PROGRESS409Another request with this idempotency key is still in flight. Wait for the stated interval and retry.
IDEMPOTENCY_KEY_REUSED409This idempotency key was already used for a request with a different body. Use a new key.
INSUFFICIENT_FUNDS402The prepaid wallet cannot cover the quoted amount.
CREDIT_LIMIT_EXCEEDED402The postpaid accrual would exceed the account's credit limit.
WALLET_IN_DEBIT402The wallet is in debit and cannot be spent from.
BATCH_EMPTY422The batch contains no recipients.
BATCH_BODY_EMPTY422The message body is blank once trimmed.
SCHEDULE_INVALID422The scheduled time is in the past, or the timezone is not a valid IANA identifier.
SENDER_NOT_APPROVED422The sender is not approved for this account.
UNAUTHORIZED401No key, a malformed key, a wrong secret, a revoked or expired key, or a suspended account. All indistinguishable by design.
INSUFFICIENT_SCOPE403The key is valid but does not carry the scope this endpoint requires. The scope is named in the detail.
ACCOUNT_SUSPENDED403The account is suspended and cannot use the API.
RATE_LIMITED429The key's request limit was exceeded. Wait for the interval in Retry-After.
INTERNAL_ERROR500An unexpected failure on SMSend's side. Quote the request id to support.

Retrieve a batch

GET/public/v1/batches/{batch_id}

Reads the current state of a batch, including cumulative per-state counts and the full list of rejected recipients.

Required scope MESSAGES_READ

Path parameters

batch_idstringRequired

The identifier returned when the batch was submitted.

26-character ULID

Request

curl https://api.smsend.net/public/v1/batches/01K3F7XQZ8V2N4M6P8R0T5CJWE \
  -H "Authorization: Bearer $SMSEND_API_KEY"
<?php

$batch = Http::withToken(getenv('SMSEND_API_KEY'))
    ->get('https://api.smsend.net/public/v1/batches/01K3F7XQZ8V2N4M6P8R0T5CJWE')
    ->json('data');
const response = await fetch(
  'https://api.smsend.net/public/v1/batches/01K3F7XQZ8V2N4M6P8R0T5CJWE',
  { headers: { Authorization: `Bearer ${process.env.SMSEND_API_KEY}` } },
)

const { data } = await response.json()
import os
import requests

batch = requests.get(
    "https://api.smsend.net/public/v1/batches/01K3F7XQZ8V2N4M6P8R0T5CJWE",
    headers={"Authorization": f"Bearer {os.environ['SMSEND_API_KEY']}"},
).json()["data"]

Responses

200The batch.
{
  "data": {
    "batch_id": "01K3F7XQZ8V2N4M6P8R0T5CJWE",
    "status": "SENDING",
    "accepted": 948,
    "rejected": 52,
    "rejections": [
      { "index": 17, "msisdn": "+22670000018", "reason_code": "SUPPRESSED" }
    ],
    "sender": "PRESTIGE",
    "channel": "SMS",
    "message_class": "TRANSACTIONAL",
    "scheduled_at": "2026-08-24T09:00:00+00:00",
    "counts": {
      "total": 948,
      "queued": 948,
      "submitted": 941,
      "sent": 935,
      "delivered": 900,
      "failed": 35,
      "cancelled": 0
    },
    "created_at": "2026-08-23T14:05:11+00:00"
  }
}

Possible errors

CodeStatusMeaning
NOT_FOUND404No such resource — including one that belongs to another account, which is deliberately indistinguishable from one that never existed.
UNAUTHORIZED401No key, a malformed key, a wrong secret, a revoked or expired key, or a suspended account. All indistinguishable by design.
INSUFFICIENT_SCOPE403The key is valid but does not carry the scope this endpoint requires. The scope is named in the detail.
RATE_LIMITED429The key's request limit was exceeded. Wait for the interval in Retry-After.
INTERNAL_ERROR500An unexpected failure on SMSend's side. Quote the request id to support.

Batches over 10,000 recipients

Above 10,000 recipients the submission is validated asynchronously, and the response comes back with a VALIDATING status and every count zeroed. The zeros mean "not computed yet", not "nothing was accepted".

{
  "data": {
    "batch_id": "01K3F7XQZ8V2N4M6P8R0T5CJWE",
    "status": "VALIDATING",
    "accepted": 0,
    "rejected": 0,
    "rejections": [],
    "reserved_amount": 0,
    "currency": "XOF",
    "total_segments": 0
  }
}

The real figures arrive either from a later fetch of the batch or from a BATCH_ACCEPTED webhook. A batch left validating for 30 minutes is marked failed.

Rejection reasons

Each entry names the zero-based position of the recipient in the array you submitted, so you can map it back to your own data. The number is null when the input could not be normalised at all.

ValueMeaning
INVALID_MSISDN
The number could not be parsed, or is not valid for its region.
DUPLICATE_IN_BATCH
The same normalised number appears more than once in this submission.
UNROUTABLE_PREFIX
No operator owns the number's prefix.
SUPPRESSED
The recipient has opted out of this class of message.
DUPLICATE_RECENT_BODY
The identical rendered message was sent to this number within the last 24 hours.
RATE_LIMIT_PER_MINUTE
The account's per-minute recipient ceiling was reached.
RATE_LIMIT_PER_DAY
The account's daily recipient ceiling was reached.
QUIET_HOURS
Marketing only. The send fell outside the account's permitted hours.
FREQUENCY_CAP_DAILY
Marketing only. This recipient has already received the day's maximum.
FREQUENCY_CAP_WEEKLY
Marketing only. This recipient has already received the week's maximum.

Batch statuses

SETTLED is the only terminal state that means the work finished and the money is squared.

ValueMeaning
VALIDATING
Accepted but not yet priced or paid for. Only reachable on the asynchronous path, and expires after 30 minutes.
SCHEDULED
Priced and reserved, waiting for its scheduled time.
QUEUED
Priced and reserved, waiting for the router.
SENDING
In flight.
SETTLEDTerminal
Finished, with the money squared and any refund posted.
REJECTEDTerminal
Every recipient was dropped before anything was reserved.
FAILEDTerminal
Validation itself failed.
CANCELLEDTerminal
Cancelled before or during sending, and refunded.