Message history and polling
List sent and received SMS across every device with GET /gateway/messages: filters, page and cursor pagination, and polling without gaps.
Updated
The messages endpoint returns sent and received messages for every device on your account, newest first. Filter it by direction, status, device, batch, text and time. Use page numbers to browse, and use the cursor to poll for new messages without gaps or duplicates.
The endpoint
GET/api/v1/gateway/messages, open in the API reference
This request returns the 50 newest messages in both directions:
curl "https://api.textbee.dev/api/v1/gateway/messages" \
-H "x-api-key: YOUR_API_KEY"Parameters
All parameters are optional query parameters. An unknown filter value fails with a 400 error. textbee does not ignore it.
| Parameter | Values | Description |
|---|---|---|
direction | all, sent, received | Which messages to return. Default all. |
status | pending, dispatched, sent, delivered, failed, unknown, received | Delivery state. direction=sent&status=failed lists sends that failed. |
deviceIds | Comma-separated device IDs | Only these devices. Default: every device on the account. |
smsBatchId | A batch ID | Only messages from this batch, using the smsBatchId a send returns. |
search | Text | Matches the message text and the other party's number. |
from | ISO 8601 datetime with a timezone, or a date | Inclusive lower bound on createdAt. A date such as 2026-09-01 is read as UTC midnight. |
to | Same formats as from | Exclusive upper bound on createdAt. |
order | desc, asc | desc (default) is newest first. Use asc to walk forward in time when you poll. |
page | Integer, 1 or more | Page to return. Default 1. Do not combine with cursor. |
limit | 1 to 100 | Messages per page. Default 50. |
cursor | Opaque string | Position from a previous meta.nextCursor. Returns the page after that position. |
A datetime without a timezone, such as 2026-09-01T00:00:00, is rejected. Use 2026-09-01T00:00:00Z or 2026-09-01T00:00:00+03:00.
from is inclusive and to is exclusive. Back-to-back windows, such as one day and the next, never count a message twice.
direction and status are different things. direction=sent means outgoing messages. status=sent means the phone reported the message as sent.
A deviceIds entry or an smsBatchId that is not on your account returns 404.
Page and cursor pagination
The endpoint has two pagination modes. The mode depends on whether you pass cursor.
Page mode
Without cursor, the response has page numbers and a total count:
{
"data": [],
"meta": {
"page": 1,
"limit": 50,
"total": 1284,
"totalPages": 26,
"nextCursor": "eyJ0IjoiMjAyNi0wOS0yOFQwOToxNDowNS4xMTJaIn0",
"hasMore": true
}
}Use page mode to show a table with page numbers in your own UI.
Cursor mode
With cursor, the response has no total count. It is faster on large histories:
{
"data": [],
"meta": {
"limit": 50,
"nextCursor": "eyJ0IjoiMjAyNi0wOS0yOFQxMDowMjoxNy4wMDBaIn0",
"hasMore": false
}
}nextCursor is null on the last page. Treat the cursor as an opaque string. Do not build or change it yourself.
Use cursor mode to read a full history or to poll for new messages. Page numbers drift when new messages arrive during the read. A cursor does not drift.
Poll without gaps or duplicates
To poll for new received messages:
- On the first run, request
direction=received&order=ascwith afromtime. - Process each message in
data. - While
meta.hasMoreistrue, request the next page withcursor=<meta.nextCursor>. - Store the last
nextCursoryou received, for example in your database. - On the next run, start with that stored cursor instead of
from.
This sample runs one poll from a start time and follows the cursor:
BASE_URL="${TEXTBEE_BASE_URL:-https://api.textbee.dev/api/v1}"
QUERY="direction=received&order=asc&limit=50&from=2026-09-01T00:00:00Z"
CURSOR=""
while :; do
PAGE=$(curl -sS "$BASE_URL/gateway/messages?$QUERY${CURSOR:+&cursor=$CURSOR}" -H "x-api-key: $TEXTBEE_API_KEY")
echo "$PAGE" | jq -r '.data[] | "\(._id) \(.sender) \(.message)"'
[ "$(echo "$PAGE" | jq -r '.meta.hasMore')" = "true" ] || break
CURSOR=$(echo "$PAGE" | jq -r '.meta.nextCursor') # store this to resume the next poll
doneThe stored cursor is your resume point. The next run starts after the last message you processed, so it never re-reads a page and never returns a message twice.
Send the same filters with every request of one poll. Change only the cursor.
Most late uploads are safe. A phone that was offline uploads its backlog when it reconnects, and the next poll returns those messages. A message uploaded more than 24 hours late gets createdAt equal to receivedAt, so a poll that already moved past that time does not return it. For an exact record, run a daily poll with from and to over the previous two days and deduplicate on _id.
Useful filters
Failed sends of one batch. Use the smsBatchId from the send response:
curl "https://api.textbee.dev/api/v1/gateway/messages?smsBatchId=66f7c3a8b1c2d3e4f5a6b7c0&status=failed" \
-H "x-api-key: YOUR_API_KEY"Received messages on one device in one week:
curl "https://api.textbee.dev/api/v1/gateway/messages?direction=received&deviceIds=664a9b8cd0e1f2a3b4c5d6e7&from=2026-09-21&to=2026-09-28" \
-H "x-api-key: YOUR_API_KEY"All messages to or from one number. Encode the + as %2B:
curl "https://api.textbee.dev/api/v1/gateway/messages?search=%2B12015550123" \
-H "x-api-key: YOUR_API_KEY"Several devices at once:
curl "https://api.textbee.dev/api/v1/gateway/messages?deviceIds=664a9b8cd0e1f2a3b4c5d6e7,664a9b8cd0e1f2a3b4c5d6f1" \
-H "x-api-key: YOUR_API_KEY"The JavaScript SDK wraps these filters in getMessages and follows the cursor for you with iterateMessages.
Response fields worth knowing
| Field | Description |
|---|---|
_id | Message ID. It matches smsId in webhook payloads. |
direction | sent or received, lowercase. Pass it back as the direction filter. |
status | Delivery state. Received messages are always received. |
sender | Sender number. Received messages only. |
recipient | Destination number. Sent messages only. |
message | Message text. It can be an empty string. |
device | Object with the device _id, enabled, name, brand and model. |
smsBatch | Batch ID. Sent messages only. |
simSubscriptionId | SIM the message went out from, when set. |
receivedAt | When the phone received the message. |
requestedAt, dispatchedAt, sentAt, deliveredAt, failedAt | Timestamps of an outgoing message. See Delivery status and message states. |
errorCode, errorMessage | Failure details of a failed message. |
overLimit | true on a received message that arrived while the account was over its plan limit. Absent otherwise. |
createdAt | When textbee stored the message. The from and to filters use this field. |
originalCreatedAt | Upload time. Set only on a received message uploaded more than 24 hours after receivedAt. |
A missing status on an old message means the message predates status tracking. Treat it as unknown.
Messages from deleted devices are never included. If you delete a device in the dashboard, its messages leave the history. See Managing devices.
Frequently asked questions
Do I need a device ID to read messages?
No. The endpoint covers every device on your account. Use deviceIds only to narrow the list.
Why does a poll miss a message I see in the dashboard?
Check the filters. A from time after the message's createdAt, a deviceIds list without its device, or a different direction hides it. Also check the stored cursor: it resumes after the last message of the previous run.
Can I get messages of one conversation?
Yes. Use search with the other party's number. It matches both sent and received messages.
Is polling or a webhook better?
A webhook is faster. A poll needs no public URL and catches anything a webhook missed. See the comparison on Receiving SMS.
Next steps
- Receiving SMS: turn on forwarding on the phone
- Webhooks: get new messages pushed to your server
- Delivery status and message states: what each status and timestamp means
- Sending bulk SMS: find failed recipients of a batch
- API reference: the full response schema