Save 44% with yearly billing, 5 months free.View Plans
textbee Logotextbee.dev
How to Poll for New SMS Without Duplicates or Gaps
API
Polling
Pagination
AutomationAI agentsWebhooks

How to Poll for New SMS Without Duplicates or Gaps

Page-number polling drops and repeats messages when new SMS arrive mid-read. Use textbee's cursor pagination to read every message exactly once.

TT

textbee team

10 min read
Share

TL;DR

  • Call GET /gateway/messages with order=asc and a from timestamp, follow meta.nextCursor until meta.hasMore is false, then store that last cursor and pass it back on your next poll. That reads every message exactly once.
  • Polling ?page=1 on a loop will show you the same message twice and miss others entirely, because new messages shift every row down while you read.
  • A cursor is an opaque bookmark that says "give me what comes after this exact message." New arrivals cannot move it.
  • @textbee/sdk wraps the whole loop as iterateMessages(). The Python SDK wraps it as iterate_messages().
  • Webhooks are still the better default for real-time inbound SMS. Use polling when you need backfill, reconciliation, or a pull-based design.

If you have ever written a loop that fetches page=1 of your message history every thirty seconds, you have written a bug. It may not have bitten you yet, but it is there.

This post explains the failure, then shows the fix.

Why do page numbers break under a live feed?

Page-based pagination assumes the list holds still while you read it. Message history never holds still: SMS arrive while your poller is working. Every new message shifts the rows below it down by one, so the boundary between page 1 and page 2 moves under you. You get repeats, or you get silent gaps.

Say you have 100 messages, newest first, reading 50 per page.

Page 1 (newest 50)   ->  messages 1..50
   ... 3 new messages arrive ...
Page 2 (next 50)     ->  messages 51..100  of the NEW list

Those three new messages pushed everything down by three positions. What was message 48 is now message 51. So page 2 hands you messages you already saw on page 1, and depending on how you deduplicate, you either process them twice or you skip past the boundary and lose three messages forever.

The nastier version is a poller that only ever reads page 1. If 60 messages arrive between polls and you read 50, the oldest 10 are pushed to page 2 and you never look at page 2. They are simply gone from your pipeline, with no error anywhere.

Deduplicating client-side by message id patches the duplicates. It does nothing for the gaps.

What is a cursor, exactly?

A cursor is an opaque bookmark returned in meta.nextCursor. Instead of asking for "the second page," you pass it back as ?cursor=... and ask for "everything after this exact message." Because it points at a message rather than a numeric offset, later arrivals cannot shift it.

JSON
{
  "data": [ /* ...messages... */ ],
  "meta": {
    "limit": 50,
    "nextCursor": "b3BhcXVlLWN1cnNvci12YWx1ZQ",
    "hasMore": true
  }
}

You never parse the string. Note what is missing from meta: in cursor mode textbee skips the total and totalPages counts, because counting the whole collection on every poll is expensive and you do not need it. nextCursor is null when there is nothing further.

The bookmark has to disambiguate messages that share a timestamp. A bulk send inserts hundreds of rows in the same instant, and a position built on time alone cannot tell them apart: it would either repeat that whole block or skip it. That is why the value is opaque. Treat it as a token, store it verbatim, and do not try to reconstruct it.

The polling recipe

Three steps.

1. Open with a starting point and ascending order.

Shell
curl "https://api.textbee.dev/api/v1/gateway/messages?direction=received&order=asc&from=2026-09-01T00:00:00Z" \
  -H "x-api-key: $TEXTBEE_API_KEY"

order=asc walks forward in time, oldest first. That is what you want when draining a backlog: you process messages in the order they arrived, and the cursor always moves forward.

2. Follow the cursor until it runs out.

JavaScript
let cursor = null

do {
  const url = new URL('https://api.textbee.dev/api/v1/gateway/messages')
  url.searchParams.set('direction', 'received')
  url.searchParams.set('order', 'asc')
  if (cursor) url.searchParams.set('cursor', cursor)
  else url.searchParams.set('from', lastRunTimestamp)

  const res = await fetch(url, { headers: { 'x-api-key': process.env.TEXTBEE_API_KEY } })
  const { data, meta } = await res.json()

  for (const message of data) {
    await handleMessage(message)
  }

  cursor = meta.hasMore ? meta.nextCursor : null
} while (cursor)

3. Save the last cursor and resume from it next time.

Persist the last nextCursor you saw wherever you keep job state. On the next poll, pass it straight back. That is your exactly-once guarantee: you resume from the precise message you stopped at, no matter how many arrived in between.

The SDK does the loop for you

If you are on JavaScript or TypeScript, @textbee/sdk collapses all of that into an async iterator:

JavaScript
import { Textbee } from '@textbee/sdk'

const textbee = new Textbee({ apiKey: process.env.TEXTBEE_API_KEY })

for await (const message of textbee.iterateMessages({
  direction: 'received',
  order: 'asc',
  from: lastRunTimestamp,
})) {
  await handleMessage(message)
}

iterateMessages fetches a page, yields each message, then fetches the next page when you need it. Break out of the loop early and it stops fetching. To resume on the next run instead, call getMessages yourself, keep meta.nextCursor, and pass it back as cursor.

In Python, the textbee package does the same with iterate_messages:

Python
import os

from textbee import Textbee

textbee = Textbee(api_key=os.environ["TEXTBEE_API_KEY"])

for message in textbee.iterate_messages(
    direction="received",
    order="asc",
    from_=last_run_timestamp,
):
    handle_message(message)

Filtering by date range

from and to bound the window. Both accept ISO-8601 with an explicit timezone, or a bare date:

Shell
# Everything in August, UTC
curl "https://api.textbee.dev/api/v1/gateway/messages?from=2026-08-01&to=2026-09-01" \
  -H "x-api-key: $TEXTBEE_API_KEY"

from is inclusive, to is exclusive. That is deliberate, and it is what makes consecutive windows safe to stitch together. If you pull August with to=2026-09-01T00:00:00Z and September with from=2026-09-01T00:00:00Z, a message landing exactly at midnight is counted once, in September. With an inclusive upper bound it would appear in both.

It also spares you the 23:59:59.999 trick, which quietly drops anything in the final millisecond.

Timezones: pass an offset, not a guess

The API stores timestamps as instants, so a window in a local calendar is expressed with an offset:

Shell
# A local calendar month at UTC+03:00
curl "https://api.textbee.dev/api/v1/gateway/messages?from=2026-08-01T00:00:00%2B03:00&to=2026-09-01T00:00:00%2B03:00" \
  -H "x-api-key: $TEXTBEE_API_KEY"

Two rules worth knowing:

  • A bare date like 2026-08-01 is read as UTC midnight.
  • A datetime without a timezone, like 2026-08-01T00:00:00, is rejected with a 400. This is on purpose. In most languages that string parses as the local time of whichever machine happens to run the code, so the same request would mean different things on your laptop and on your server. Rather than let that ambiguity through silently, the API asks you to say what you mean.

Unknown values for from, to, order, status, direction, or cursor also return 400 rather than silently applying no filter. That is the behaviour you want: a typo fails loudly instead of quietly widening your query.

If you are building a rolling window in code, use Z timestamps and let your date library do the conversion:

JavaScript
const from = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()

Which timestamp does the filter use?

from and to filter on createdAt, which is when textbee stored the message. For received messages that is upload time, not the receivedAt your phone reports. The two differ whenever the device was offline, and paginating on the wrong one loses messages.

A phone with no connectivity keeps collecting SMS and uploads the backlog when it reconnects. Those messages carry an old receivedAt but a current createdAt.

If polling filtered on receivedAt, a poller that had already advanced past that timestamp would never see them. They would land in the past, behind the cursor, invisible. Filtering on storage time means every message arrives in front of your cursor, exactly once, even after an outage.

Both timestamps are on every message, so use receivedAt for display and reporting. Just do not paginate on it.

Other filters worth knowing

The same endpoint handles the queries you would otherwise do client-side:

ParameterWhat it does
directionall (default), sent, or received. Matches the lowercase direction field on each message.
deviceIdsComma-separated device ids. Defaults to every device on the account.
smsBatchIdOne batch only, using the smsBatchId a send returns.
statuspending, dispatched, sent, delivered, failed, unknown, or received.
searchMatches the message text and the other party's number.
limitMessages per page. Default 50, maximum 100.
Shell
# Only these two devices
?deviceIds=665f1c2a9b1e4a0012ab34cd,665f1c2a9b1e4a0012ab34ce

# Which recipients of a bulk send failed
?smsBatchId=665f1c2a9b1e4a0012ab34cf&status=failed

# Search the message body or the other party's number
?search=invoice

Two traps in that table. First, direction and status are not the same axis: direction=sent means outbound, while status=sent means the device dispatched it. You often want both together, as in direction=sent&status=failed. Second, an older type parameter is a deprecated alias of direction. It still works, but write new code against direction.

One default worth calling out: messages belonging to devices you have deleted are never returned. Removing a device removes its history from your reads. Encrypted message bodies are also not searchable, since the server cannot read them.

The full parameter list, with every response field, is in the API reference.

When should I poll and when should I use webhooks?

Use webhooks when you want inbound SMS in real time and can expose an HTTPS endpoint. Push beats pull on latency, every time. Use polling when you cannot, or when you need to look backwards. They solve different problems, and the strongest setup runs both.

Use polling when:

  • You cannot expose a public endpoint (a script behind a firewall, a desktop tool, a cron job).
  • You are backfilling history, or reconciling after downtime.
  • Your webhook endpoint was down and you need to catch up on what it missed.
  • You are building an agent or automation that works on a schedule rather than on an event. This is the usual shape for an AI agent with its own phone number.

For the push side, the inbound webhook guide walks through endpoint setup and signature verification. The strongest setup uses both: webhooks for the live path, and a periodic cursor sweep as the safety net that guarantees nothing was lost while your endpoint was unreachable.

Frequently asked questions

How long is a cursor valid?

A cursor is a position, not a session, so there is no expiry clock on it. Store one for a week and it still resolves to the same point in your history. The only thing that invalidates it is deleting the device whose messages it points into, since deleted devices' messages are never returned.

What if I lose the cursor?

Fall back to a from timestamp. Pass the last message time you successfully processed, minus a small safety margin, with order=asc, and start a fresh cursor walk from there. You will re-read a few messages, so keep a short-lived set of processed message ids and skip anything you have already handled. That is why handlers should be idempotent even with cursors in place.

Should I use webhooks or polling for inbound SMS?

Webhooks, if you can expose an HTTPS endpoint. They deliver in real time and cost you no idle requests. Polling is the right choice when you cannot accept inbound traffic, or when you need to backfill. Running both is common: the webhook is the fast path and a nightly cursor sweep proves nothing was missed. Details are in the receiving SMS docs.

Does the cursor include messages I sent?

It follows whatever direction filter you set. With direction=received you walk inbound messages only; with direction=sent you walk your own outbound sends and can watch their status change; omit it and you get both, since the default is all. Keep the filter identical across every call in a cursor walk, because changing it mid-walk changes the set the cursor is positioned in.

Wrapping up

The rule of thumb: page numbers are for humans browsing, cursors are for machines reading.

If a person is clicking through a table in a dashboard, page=3 is exactly right, and textbee still supports it with total and totalPages. If code is consuming the feed, use the cursor. It costs one extra field in your job state and removes an entire class of bug that is nearly impossible to notice in production.

Full parameter reference is on the receiving SMS docs page.

Get more textbee in Google Search

Add textbee.dev as a preferred source. Our posts show up more often in your Google results.

Add as a preferred source