Skip to content

API reference

Messages

Read individual messages, and catch up on everything that changed while you were not listening.

This is your webhook recovery path

List messages

GET/public/v1/messages

Returns messages whose status changed at or after a given moment, ordered so that a cursor can walk the whole set without gaps.

Required scope MESSAGES_READ

Query parameters

status_changed_sincestringRequired

Where to resume from. Values older than seven days are silently clamped to seven days ago rather than refused.

ISO 8601 — clamped to the last 7 days

limitintegerOptional

How many messages to return per page.

default 100, maximum 500

batch_idstringOptional

Restrict the results to a single batch.

26-character ULID

Request

curl -G https://api.smsend.net/public/v1/messages \
  -H "Authorization: Bearer $SMSEND_API_KEY" \
  --data-urlencode "status_changed_since=2026-08-23T00:00:00Z" \
  --data-urlencode "limit=100"
<?php

$cursor = '2026-08-23T00:00:00Z';

do {
    $page = Http::withToken(getenv('SMSEND_API_KEY'))
        ->get('https://api.smsend.net/public/v1/messages', [
            'status_changed_since' => $cursor,
            'limit' => 500,
        ])
        ->json('data');

    foreach ($page['messages'] as $message) {
        // Deduplicate on message_id + status: the boundary row repeats.
        handle($message);
    }

    $cursor = $page['next_status_changed_since'] ?? $cursor;
} while ($page['has_more']);
let cursor = '2026-08-23T00:00:00Z'
let hasMore = true

while (hasMore) {
  const url = new URL('https://api.smsend.net/public/v1/messages')
  url.searchParams.set('status_changed_since', cursor)
  url.searchParams.set('limit', '500')

  const response = await fetch(url, {
    headers: { Authorization: `Bearer ${process.env.SMSEND_API_KEY}` },
  })

  const { data } = await response.json()

  for (const message of data.messages) {
    // Deduplicate on message_id + status: the boundary row repeats.
    handle(message)
  }

  cursor = data.next_status_changed_since ?? cursor
  hasMore = data.has_more
}
import os
import requests

cursor = "2026-08-23T00:00:00Z"
has_more = True

while has_more:
    data = requests.get(
        "https://api.smsend.net/public/v1/messages",
        headers={"Authorization": f"Bearer {os.environ['SMSEND_API_KEY']}"},
        params={"status_changed_since": cursor, "limit": 500},
    ).json()["data"]

    for message in data["messages"]:
        # Deduplicate on message_id + status: the boundary row repeats.
        handle(message)

    cursor = data["next_status_changed_since"] or cursor
    has_more = data["has_more"]

Responses

200A page of messages, plus the cursor to resume from.
{
  "data": {
    "messages": [
      {
        "message_id": "01K3F7Y1A4B8C2D6E0F4G8H2JK",
        "batch_id": "01K3F7XQZ8V2N4M6P8R0T5CJWE",
        "msisdn": "+22670000001",
        "status": "DELIVERED",
        "channel": "SMS",
        "message_class": "TRANSACTIONAL",
        "segments": 1,
        "encoding": "GSM7",
        "submitted_at": "2026-08-23T14:05:13+00:00",
        "delivered_at": "2026-08-23T14:05:19+00:00",
        "failed_reason": null,
        "status_changed_at": "2026-08-23T14:05:19+00:00"
      },
      {
        "message_id": "01K3F7Y1A4B8C2D6E0F4G8H2JM",
        "batch_id": "01K3F7XQZ8V2N4M6P8R0T5CJWE",
        "msisdn": "+22670000002",
        "status": "FAILED",
        "channel": "SMS",
        "message_class": "TRANSACTIONAL",
        "segments": 2,
        "encoding": "UCS2",
        "submitted_at": "2026-08-23T14:05:13+00:00",
        "delivered_at": null,
        "failed_reason": "ABSENT_SUBSCRIBER",
        "status_changed_at": "2026-08-23T14:05:22+00:00"
      }
    ],
    "has_more": true,
    "next_status_changed_since": "2026-08-23T14:05:22+00:00"
  }
}

Possible errors

CodeStatusMeaning
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.
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.

Retrieve a message

GET/public/v1/messages/{message_id}

Reads a single message by its identifier.

Required scope MESSAGES_READ

Path parameters

message_idstringRequired

The message identifier, as returned by the list endpoint or a webhook.

26-character ULID

Request

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

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

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

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

Responses

200The message.
{
  "data": {
    "message_id": "01K3F7Y1A4B8C2D6E0F4G8H2JK",
    "batch_id": "01K3F7XQZ8V2N4M6P8R0T5CJWE",
    "msisdn": "+22670000001",
    "status": "DELIVERED",
    "channel": "SMS",
    "message_class": "TRANSACTIONAL",
    "segments": 1,
    "encoding": "GSM7",
    "submitted_at": "2026-08-23T14:05:13+00:00",
    "delivered_at": "2026-08-23T14:05:19+00:00",
    "failed_reason": null,
    "status_changed_at": "2026-08-23T14:05:19+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.

Message statuses

A message ends in exactly one terminal state and never transitions again once it gets there.

ValueMeaning
SCHEDULED
Parked until its scheduled time.
QUEUED
Waiting for the router.
RETRY_SCHEDULED
A delivery attempt failed and another is scheduled.
SUBMITTED
Handed to a provider, which has not yet returned a reference. A message stuck here is SMSend's problem, not yours.
SENT
The provider accepted it and returned a reference.
DELIVEREDTerminal
The handset confirmed receipt.
FAILEDTerminal
Delivery failed. The reason is in failed_reason.
EXPIREDTerminal
No delivery receipt arrived within 48 hours.
REJECTEDTerminal
The provider or operator refused it.
CANCELLEDTerminal
Dropped at send time, or cancelled with its batch.

How the cursor works

Pages are ordered by the moment of the status change, with the message id as a tiebreaker. That makes the ordering total, so two messages that changed in the same millisecond cannot end up straddling a page boundary and one of them be missed.

When a page comes back empty the cursor is null. Keep your own position rather than advancing — moving forward past a quiet period would skip events that had not been written yet.