What is cursor-based pagination?
Cursor-based pagination is how the Graph API returns lists too long for one response: a WhatsApp Business Account's message templates, its phone numbers, or the WABAs a business owns. Instead of page numbers, each response carries a data array and a paging object. paging.cursors.before and paging.cursors.after are opaque strings that mark the first and last item of the current page, and paging.next and paging.previous are ready-made URLs for the neighbouring pages.
To walk a whole list you request the edge with a limit (the most items you want per page), process data, then request paging.next, or repeat your own request with after={cursor}, until a response comes back with no next link. Because a cursor marks a position in the result set rather than a numeric offset, items added or removed while you page do not shift every later page; Meta calls cursor paging the most efficient method and recommends it wherever an edge supports it.
The Graph API also has time-based paging (since and until) and offset paging on some edges, but the WhatsApp management edges you page most often, such as /{WABA_ID}/message_templates and /{WABA_ID}/phone_numbers, return cursors. Pagination is not field expansion: fields=name,status,category shapes each item, while cursors choose which slice of the list comes back. The two combine, and a nested edge requested through field expansion carries its own paging block.
Key takeaways
- Cursor-based pagination returns a list in pages, each with opaque
beforeandaftercursors marking where that page starts and ends. - Follow
paging.nextuntil it is absent: that, not a short or empty page, is the signal that you have reached the end. limitsets the most items per page; a page can still come back with fewer while more pages follow.- Cursors are short-lived bookmarks: use them straight away and store item IDs, never cursors.
- Field expansion decides which fields each item has; pagination decides which items you get.
Follow the cursors to the end of the list
import requests url = "https://graph.facebook.com/v26.0/{WABA_ID}/message_templates"params = {"fields": "name,status,category", "limit": 100}headers = {"Authorization": f"Bearer {TOKEN}"} templates, pages = [], 0while url and pages < 200: body = requests.get(url, params=params, headers=headers, timeout=30).json() if "error" in body: raise RuntimeError(body["error"]["message"]) templates.extend(body.get("data", [])) url = body.get("paging", {}).get("next") params = None # next already carries fields, limit and after pages += 1Step 5 of 5 · line 14
Drop your own params: next already contains them plus the after cursor, and adding them twice can break the request.
sync_templates.py, step 5 of 5, line 14: Drop your own params: next already contains them plus the after cursor, and adding them twice can break the request.
Cursor-based pagination in code
const BASE = "https://graph.facebook.com/v26.0";async function listTemplates(wabaId, token) {const all = [];let url = BASE + "/" + wabaId +"/message_templates?fields=name,status,category,language&limit=100";for (let page = 0; url && page < 200; page++) { // hard cap on pagesconst res = await fetch(url, { headers: { Authorization: "Bearer " + token } });const body = await res.json();if (!res.ok) throw new Error(body.error?.code + ": " + body.error?.message);all.push(...body.data);url = body.paging?.next; // undefined on the last page}return all;}
Follow paging.next until it disappears; never stop because a page looks short.
Key facts at a glance
Cursor-based pagination: key facts and limits
- Page shape
- data array + paging object
- Cursors
- paging.cursors.before / after (opaque strings)
- Navigation
- paging.next / paging.previous (full URLs)
- Page size
- limit= is a maximum, not a guarantee
- End of the list
- No paging.next in the response
- Other Graph paging
- Time-based (since/until) and offset on some edges
How does cursor-based pagination work?
Request the edge with a limit
Call, for example,
GET /v26.0/{WABA_ID}/message_templates?fields=name,status,category,language&limit=100. Keeplimitmoderate: very large pages are slower and more likely to time out.Process every item in data
Handle the whole
dataarray, and don't assume its length equalslimit. A page may hold fewer items even when more pages follow.Read the paging object
paging.cursors.aftermarks the end of this page, andpaging.nextis the full URL of the next one with your fields, filters and limit already in it.Fetch the next page
Request
paging.nextas it is, or reissue your own request withafter={cursor}. Usebeforeorpaging.previousto move backwards.Stop when next is missing
The last page has no
nextlink. Stop there, not on a short or empty page, and keep a hard page cap so a bug can never loop forever.
Common wrong turns
Common mistakes with cursor-based pagination
Reading only the first page
The commonest bug: a sync that never looks at
paging. Lists under one page work in testing, then silently truncate in production. Always loop onnext.Stopping on a short page
Fewer items than
limitdoes not mean the end; the Graph API can return a partial page with more to come. The only stop signal is a missingpaging.next.Storing cursors as if they were IDs
Cursors are position markers that can become invalid as the list changes. Persist template or phone number IDs, and restart the walk instead of resuming from yesterday's cursor.
Rebuilding the next URL and losing parameters
If you build page two yourself, carry over the same
fields, filters andlimit, or later pages come back in a different shape. Requestingpaging.nextunchanged avoids the problem.Looping without a guard
A bug that reuses the same cursor loops forever and burns your rate limit. Cap the page count and stop if a cursor repeats.
Example: Mirroring a template library in Hyderabad
The ops team at a Hyderabad consumer-lending company keeps a nightly copy of its WhatsApp templates so support can see which reminders are approved, paused or rejected. The job requests message_templates with limit=100 and the four fields it needs, follows paging.next five times, and stops when the sixth response has no next: 512 templates in six calls. The previous version read only the first response, so for months the dashboard showed 100 templates and missed a paused collections reminder that was failing on every send.
Why does cursor-based pagination matter?
Template libraries grow into the hundreds (verified portfolios can hold up to 6,000 templates per WABA), and businesses with many numbers or client WABAs outgrow a single page quickly. Code that reads only the first response fails silently: the sync job shows the first page of templates when there are 400, and the dashboard never sees the rejected ones on page three. Following cursors correctly is the difference between a complete mirror of your account and a quietly partial one.
Good practice
Best practices for cursor-based pagination
- Request only the fields you need with
fields=: smaller items make every page faster. - Use server-side filters where the edge's reference documents them instead of paging through everything and filtering locally.
- Log the page count and item count of every sync, so a sudden drop stands out.
- On large accounts, back off on rate limiting errors between pages rather than hammering the next URL.