واجهة برمجة واتساب

Pagination & Idempotency

Response envelopes, cursor pagination, incremental sync with updatedSince, and safe retries with the Idempotency-Key header in the StartMessaging WhatsApp API.

This page covers the conventions every endpoint shares: the response envelope, how lists are paged, how to sync changes incrementally, and how to retry a write without doing it twice.

Response envelope

Every successful response wraps its payload in data, with request metadata in meta:

{
  "data": { "id": "3f6c2a1e-8b1d-4c53-9e7a-2b5d8f0c4a11", "status": "sent" },
  "meta": {
    "requestId": "b7c1e0d2-5a4f-4c3b-9e8d-1f2a3b4c5d6e",
    "timestamp": "2026-10-02T10:00:00.000Z"
  }
}

Errors use a different envelope, with a machine-readable error.code:

{
  "statusCode": 422,
  "error": {
    "code": "TEMPLATE_NOT_APPROVED",
    "message": "…",
    "details": { "status": "PENDING" }
  },
  "meta": { "requestId": "…", "timestamp": "2026-10-02T10:00:00.000Z", "path": "/api/v1/messages" }
}

An error’s meta also carries path, the route that was called (without its query string). A successful response’s meta has only requestId and timestamp.

Branch on error.code, never on message: messages are for humans and may change. Log meta.requestId with every call; it identifies the request in Developers → Logs and when you contact support. See Errors & Limits for every code.

Pagination

List endpoints use cursor-based pagination. They take two query parameters:

ParameterDescription
limitItems per page, 1 to 100. Default 25.
cursorThe nextCursor from the previous page. Leave it out for the first page.

and return the items with the cursor for the next page:

{
  "data": {
    "items": [ { "id": "…" }, { "id": "…" } ],
    "nextCursor": "WyIyMDI2LTEwLTAyVDEwOjAwOjAwLjAwMFoiLCI2ZjE…"
  },
  "meta": { "requestId": "…", "timestamp": "2026-10-02T10:00:00.000Z" }
}

Cursor rules

  • Pass nextCursor back as cursor until it is null. null means there are no more pages.
  • Keep every other parameter unchanged while paging. A cursor belongs to the query that produced it.
  • Cursors are opaque. Don’t parse or build them; their contents may change. A cursor that did not come from us is 400 INVALID_CURSOR.
  • Cursors are keyset positions, not page numbers, so they stay correct while new rows are inserted during your walk.
  • Lists that are always small (/tags, /pipeline-stages, /phone-numbers) use the same items / nextCursor shape with nextCursor: null.

Walking every page

async function listAll(path, params = {}) {
  const items = [];
  let cursor = null;
  do {
    const query = new URLSearchParams({ ...params, limit: '100' });
    if (cursor) query.set('cursor', cursor);
    const res = await fetch(`https://waba-api.startmessaging.com/api/v1${path}?${query}`, {
      headers: { Authorization: `Bearer ${process.env.SM_API_KEY}` },
    });
    const { data } = await res.json();
    items.push(...data.items);
    cursor = data.nextCursor;
  } while (cursor);
  return items;
}

const contacts = await listAll('/contacts', { tag: 'vip' });
import os, requests

def list_all(path, **params):
    items, cursor = [], None
    while True:
        query = {**params, "limit": 100}
        if cursor:
            query["cursor"] = cursor
        res = requests.get(
            f"https://waba-api.startmessaging.com/api/v1{path}",
            headers={"Authorization": f"Bearer {os.environ['SM_API_KEY']}"},
            params=query,
        )
        data = res.json()["data"]
        items.extend(data["items"])
        cursor = data["nextCursor"]
        if not cursor:
            return items

Incremental sync with updatedSince

GET /contacts and GET /conversations accept updatedSince: an ISO 8601 date (2026-10-01, read as midnight UTC) or a date-time with a time zone (2026-10-01T10:00:00Z or 2026-10-01T15:30:00+05:30). A date-time without a zone is 400 VALIDATION_ERROR. The list then returns only what changed at or after that instant, oldest change first (by updatedAt, then id). Use it to keep your own database in sync without re-reading everything.

  1. First sync: pass an updatedSince from before the workspace existed, for example 2020-01-01T00:00:00Z.
  2. Process rows in order and upsert them by id.
  3. Checkpoint on the updatedAt of the last row you processed. The walk only moves forward in time, so you can stop anywhere (between pages or in the middle of one) and resume from that value. It is also the next sync’s starting point.
  4. Start each sync a few minutes before your checkpoint. Changes commit in parallel, so a row can land with an updatedAt slightly older than one you have already seen. Seeing a row twice is harmless because you upsert by id; missing one is not.

Things to know:

  • A row that changes while you walk is not lost. Its new updatedAt is later than everything you have passed, so it comes round again at the end of the same walk or in the next sync.
  • Deleted rows drop out of the list rather than coming back as a change. Use the webhooks if you need to react to deletions or opt-outs.
  • Keep updatedSince unchanged while paging. The order travels in the cursor: a cursor from a list without updatedSince is refused on a list with it, and the reverse (400 INVALID_CURSOR).
  • Without updatedSince the lists keep the dashboard’s order (contacts newest first by createdAt, conversations by latest activity), and updatedAt values arrive in no order, so none of them is a safe checkpoint.
async function syncContacts(checkpoint) {
  // Re-read a few minutes before the last checkpoint to catch late commits.
  const since = new Date(new Date(checkpoint).getTime() - 5 * 60 * 1000).toISOString();
  let cursor = null;
  let last = checkpoint;
  do {
    const query = new URLSearchParams({ updatedSince: since, limit: '100' });
    if (cursor) query.set('cursor', cursor);
    const res = await fetch(`https://waba-api.startmessaging.com/api/v1/contacts?${query}`, {
      headers: { Authorization: `Bearer ${process.env.SM_API_KEY}` },
    });
    const { data } = await res.json();
    for (const contact of data.items) {
      await db.contacts.upsert(contact.id, contact);
      last = contact.updatedAt;
      await saveCheckpoint(last);
    }
    cursor = data.nextCursor;
  } while (cursor);
  return last;
}

Idempotency

Network failures happen: a request times out, and you can’t tell whether it went through. Send an Idempotency-Key header on a POST, and a retry with the same key returns the first answer instead of doing the work twice. This matters most for POST /messages, where a duplicate means the customer gets the same WhatsApp message twice.

curl -X POST https://waba-api.startmessaging.com/api/v1/messages \
  -H "Authorization: Bearer sm_live_your_api_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-10421-shipped" \
  -d '{
    "to": "+919812345678",
    "type": "template",
    "template": { "name": "order_shipped", "language": "en_US", "parameters": [{ "text": "10421" }] },
    "clientReference": "order-10421"
  }'

Key format

  • 1 to 255 printable ASCII characters, no spaces.
  • A UUID, or your own id such as order-10421-shipped, both work.
  • Anything else is 400 INVALID_IDEMPOTENCY_KEY.

Use one key per logical operation (one key per message you mean to send), and reuse it only for retries of that same request.

How a key behaves

SituationResult
Same key, same body, within 24 hoursThe original response is returned with the header Idempotent-Replayed: true. Nothing is done twice and no send budget is spent.
Same key, different body or different route422 IDEMPOTENCY_KEY_REUSED. A different clientReference makes it a different body.
Same key sent from a different API key422 IDEMPOTENCY_KEY_REUSED. A key belongs to the API key that first sent it.
The first request with this key is still running409 IDEMPOTENCY_KEY_IN_USE. Retry shortly.
More than 24 hours laterThe key has expired and the request runs again

“Same body” means the same bytes: the key is matched against the method, the route and the raw request body. When you retry, resend exactly the body you sent the first time rather than re-serialising it with fields in a different order.

Which outcomes release the key

Some outcomes mean “try again”, so they release the key and a retry with the same key runs for real. Others are the answer to that request, so they are stored and replayed.

OutcomeKey
400 validation errors (including WABA_NOT_CONNECTED)Released: fix the problem and retry with the same key
402 payment, subscription or monthly message allowance errors (including PLAN_MESSAGE_LIMIT_REACHED)Released
409 conflictsReleased
429 rate limitedReleased: wait for Retry-After, then retry
5xx, including 502 WABA_CONNECT_FAILEDReleased: retry later with the same key
2xx successStored and replayed
403, 404, 422 from the request itselfStored and replayed: the same request fails the same way
403 and 404 from the access checks that run first (INSUFFICIENT_SCOPE, PLAN_FEATURE_REQUIRED for the API itself, the team inbox or the sales pipeline, WORKSPACE_NOT_FOUND)Not claimed: the key is never taken, so a retry after you fix the key or plan runs for real

One window can’t be closed: if a 502 or a server crash happens after WhatsApp accepted the message but before the result was stored, a retry with the same key can send again. A claim held by a request that stopped mid-way expires after 60 seconds.

Retry recipe

async function sendWithRetry(payload, key, attempts = 5) {
  for (let i = 0; i < attempts; i++) {
    const res = await fetch('https://waba-api.startmessaging.com/api/v1/messages', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.SM_API_KEY}`,
        'Content-Type': 'application/json',
        'Idempotency-Key': key,
      },
      body: JSON.stringify(payload),
    });
    if (res.ok) return (await res.json()).data;

    const retryable = res.status === 409 || res.status === 429 || res.status >= 500;
    if (!retryable) throw new Error((await res.json()).error.code);

    const wait = Number(res.headers.get('Retry-After')) || 2 ** i;
    await new Promise((r) => setTimeout(r, wait * 1000));
  }
  throw new Error('Gave up after retries');
}

clientReference vs Idempotency-Key

Both are yours to choose, and they do different jobs:

Idempotency-Key headerclientReference body field
PurposeMake retries safe: never send twiceMatch messages and receipts to your own records
Format1 to 255 printable ASCII, no spaces1 to 256 printable ASCII, spaces allowed (not only spaces)
Lifetime24 hoursStored with the message
Comes back onThe replayed response (Idempotent-Replayed: true)The send response, GET /messages/{id}, conversation message lists and the message.sent, message.delivered, message.read and message.failed webhooks. message.received has none of its own; a customer’s reply carries the replied-to message’s as context.clientReference.
Two sends with the same valueSecond one is replayed, not sentTwo separate messages

Use both: Idempotency-Key: order-10421-shipped to protect the send, and clientReference: "order-10421" so the message.delivered webhook tells you which order it was about.

FAQ

Do GET requests need an Idempotency-Key? No. Reads never change anything, so they are safe to retry as they are. The header is for POST requests.

How long is an Idempotency-Key remembered? 24 hours. After that the same key runs the request again.

Can I jump to page 5 of a list? No. Lists are cursor-based, so you walk them page by page with nextCursor. This keeps results correct while new rows are added.

What is the largest page I can request? limit=100. The default is 25.

Can I use updatedSince on every list? Only on GET /contacts and GET /conversations. Other lists are small or are better followed with webhooks.