One rule for every list endpoint. Send the next_cursor value back as cursor and stop when has_more is false. Identical on every paginated endpoint, every platform.
Every list endpoint on every platform paginates the same way. Learn it once and it works on all 227 paginatable endpoints: TikTok, Instagram, YouTube, Reddit, LinkedIn, Naver, all of them.
Read pagination.next_cursor from the response, send it back as the cursor query parameter, and stop when pagination.has_more is false.
That is the entire contract. You never construct a cursor, decode a token, or look up a platform's native pagination param. The API hands you an opaque token and takes it straight back.
Need 200 posts, not 20? Ask for them. On all 199 list endpoints the API walks the cursor for you and returns every row in one response, so you do not have to write the loop below:
Pages are walked until the response holds at least this many rows, then the walk stops. The last page comes back whole (it was billed), so data.items can hold up to one page more than you asked for, and pagination.next_cursor resumes right after it without skipping a row.
max_credits
1 to 100000
The most the call may spend. A page starts only while what is already spent plus that page's price still fits, so the call never goes past it. A value below the price of one page is a free 400 whose error.details.page_credits names the price.
max_pages
1 to 50
The most pages to walk. Default 1, or 50 when you send max_items or max_credits.
Each page walked is billed exactly as one call to that endpoint (a cached page is free) and counts against your rate limit. The answer carries a data.walk report: pages walked, rows fetched and returned, the credits billed and the request id of every page. data.walk.stopped says why the walk ended:
data.walk.stopped
Meaning
end
The list ran out. pagination.has_more is false.
max_items
The walk holds the rows you asked for.
max_credits
The next page would have taken the call past max_credits.
max_pages
The walk reached max_pages.
time_budget
The call ran low on time before it finished. Send pagination.next_cursor back as cursor with the same parameters to continue.
page_error
A later page failed. The rows already fetched are kept and data._warnings has walk_incomplete.
GET /v1/utility/estimate?id=instagram/profile/posts&handle=natgeo&max_items=200 quotes the walk for free before you run it. Endpoints that already collect until N with their own limit (listed under Three kinds of limit) keep that control instead.
Opaque token for the next page. Send it back as cursor. null at the end of the list.
pagination.has_more
true while more pages exist, false on the last page. This is your stop signal.
pagination.page_size
How many items came back in data.items on this page.
pagination.typical_page_size
How many items a full page of this endpoint usually holds for your request, or null when we have not verified it, as on a source whose page changes with your parameters. An endpoint that merges several sources can return more. A page_size below it with has_more: true is a short page, not the end.
pagination.next_page_credits
What the next page costs, in credits, or null when there is no next page. See What the next page costs.
The token is a single sc. string that works identically for cursor-, page-, and offset-style upstreams, so your loop is byte-for-byte the same everywhere. (data.total, when present, is the upstream's own result count, useful for a progress bar, but never use it to decide when to stop; has_more is the only always-correct signal.)
pagination.next_page_credits is the price of sending next_cursor back, worked out before you send it. It is priced from the next request itself (your parameters plus the new cursor) by the same pricer that charges the call, so it is the number your balance is checked against.
Flat endpoints charge exactly this.
Metered endpoints hold this much up front and refund what the page does not use, so the next response's credits_used can be lower, never higher.
A cache hit is free (credits_used: 0), and the next page is still quoted at what it costs when it is not cached.
A negotiated rate on your account is in the quote whenever this page was charged.
null means there is no next page: has_more is false, or the response gives no cursor to resume from.
A walk (max_items, max_credits or max_pages) returns its last page's block, so the quote is for one page. Resuming with the same parameters walks again, up to max_pages pages.
Before a long walk, multiply: the pages you still want times next_page_credits is the most the rest of the walk costs. max_credits (above) enforces that ceiling for you. A page whose page_size is below typical_page_size while has_more is true is a short page from the source, so keep following the cursor rather than raising your limit.
The response comes back with "pagination": { "next_cursor": "sc.eyJ2Ijoy…", "has_more": true, "page_size": 10, "typical_page_size": 10, "next_page_credits": 1 }.
Request page 2 (pass that next_cursor back as cursor):
Keep going until a response returns "has_more": false (and "next_cursor": null). Swap tiktok/profile/videos for any list endpoint in the table below and this is unchanged.
Identical for every list endpoint on every platform:
TypeScript
async function* paginate(path: string, params: Record<string, string>) { let cursor: string | null = null; do { const url = new URL(`https://www.socialcrawl.dev/v1/${path}`); for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); if (cursor) url.searchParams.set("cursor", cursor); // <-- always "cursor" const res = await fetch(url, { headers: { "x-api-key": process.env.SOCIALCRAWL_KEY! }, }); const json = await res.json(); for (const item of json.data.items) yield item; cursor = json.pagination.next_cursor; // <-- send back verbatim next round } while (cursor); // <-- pagination.has_more === false ends the loop}// Usage: same call shape for TikTok, Instagram, YouTube, Reddit, …for await (const video of paginate("tiktok/profile/videos", { handle: "mrbeast" })) { // … handle each item}
Two things to get right:
Send next_cursor back as the EXACT string you received, under the key cursor. Don't decode, trim, or re-encode it; it's an opaque token.
Stop when has_more is false (equivalently, when next_cursor is null). Don't infer the end from an empty page or from total. An empty page says why it is empty in data.empty_reason, for example end_of_list, nothing_new_since or window_filtered; see Why a list is empty.
The real-world loop is "pull every post/comment/follower an account has" without burning credits or tripping limits. Walk pages until has_more is false, and keep three things in mind:
TypeScript
// Drain every video for a handle, then stop.async function drain(path: string, params: Record<string, string>) { const all: unknown[] = []; let cursor: string | null = null; let pages = 0; do { const url = new URL(`https://www.socialcrawl.dev/v1/${path}`); for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); if (cursor) url.searchParams.set("cursor", cursor); const res = await fetch(url, { headers: { "x-api-key": process.env.SOCIALCRAWL_KEY! }, }); // Respect backpressure: on 429 (CONCURRENCY_LIMIT) or 503 back off and retry // the SAME cursor; never advance past a page you didn't successfully read. if (res.status === 429 || res.status === 503) { const wait = Number(res.headers.get("retry-after") ?? "2") * 1000; await new Promise((r) => setTimeout(r, wait)); continue; } const json = await res.json(); all.push(...json.data.items); cursor = json.pagination.next_cursor; pages += 1; } while (cursor); return { items: all, pages };}
Concurrency. You can drain many accounts in parallel, but a single API key allows at most 50 concurrent requests. Exceed it and you get a 429 CONCURRENCY_LIMIT (0 credits charged). Cap your worker pool well under 50; within one account, pages are inherently sequential (each needs the previous next_cursor).
Credit budget. Each page is a normal billed request: one page of a 1-credit endpoint costs 1 credit, so draining a 50-page feed costs about 50 credits. Multiply pages by the endpoint's per-call cost (see Endpoint pricing) before you loop, and cap pages if you only need the most recent N results. Cache hits on re-runs cost 0 credits. If you only need the most recent N rows, send max_items=N and max_credits instead and skip the loop (Get more in one call).
Bounded depth. Page- and offset-style endpoints (Naver, LinkedIn, the app stores) have a finite maximum depth; has_more flips to false when you reach it. Cursor-style feeds end when the upstream runs out of items.
A poller that checks a creator every day only needs the posts published since its last run. Eight creator post lists take two parameters that end the walk where your last poll stopped, so you pay for the pages with new posts and no more:
Platform
Endpoint
Facebook
GET /v1/facebook/profile/posts
Instagram
GET /v1/instagram/profile/posts, GET /v1/instagram/profile/reels
Threads
GET /v1/threads/user/posts
TikTok
GET /v1/tiktok/profile/videos
Twitter/X
GET /v1/twitter/user/tweets
YouTube
GET /v1/youtube/channel/shorts, GET /v1/youtube/channel/videos
Parameter
Send
What happens
stop_at_id
The post.id or post.url of the newest post you already hold
The page stops just before that post. That post and every post after it are left off, next_cursor is not returned, and pagination.stopped_at is known_id. If the post is not on this page, the page comes back in full with its cursor, so keep walking.
since
A date (YYYY-MM-DD, read as midnight UTC) or an ISO 8601 timestamp
Posts published before it are left off, and the page that reaches one ends the walk with pagination.stopped_at set to since. A post with no date is kept.
Send the same stop_at_id (or since) on every page of the walk, then keep the newest post you received for the next run:
TypeScript
// Fetch only the posts published since the last poll.async function pollNewPosts( path: string, params: Record<string, string>, newestHeldId: string | null,) { const fresh: any[] = []; let cursor: string | null = null; do { const url = new URL(`https://www.socialcrawl.dev/v1/${path}`); for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); // First run: bound the walk by date. Every later run: by the newest post held. if (newestHeldId) url.searchParams.set("stop_at_id", newestHeldId); else url.searchParams.set("since", "2026-09-01"); if (cursor) url.searchParams.set("cursor", cursor); const res = await fetch(url, { headers: { "x-api-key": process.env.SOCIALCRAWL_KEY! }, }); const json = await res.json(); fresh.push(...json.data.items); cursor = json.pagination.next_cursor; // null once the walk has stopped } while (cursor); // Pinned posts sit out of date order, so never keep one as the newest. const newest = fresh.find((row) => row.post.flags?.pinned !== true); return { fresh, newestHeldId: newest?.post.id ?? newestHeldId };}// savedId is the newestHeldId you stored after the previous run, or null.const result = await pollNewPosts("tiktok/profile/videos", { handle: "mrbeast" }, savedId);
Three details decide whether a poll is correct and cheap:
Pinned posts never end the walk. A profile opens with the posts its owner pinned, and those can be years old. A pinned post never counts as the boundary. If it is older than since, or it is the post you named in stop_at_id, it is left off the page and the walk goes on.
The price does not change. Each page costs what a page of that endpoint costs, 1 credit on all eight. A daily poll that finds your newest post on page 1 pays for page 1 and stops there. A since that is not a date, or a stop_at_id longer than 512 characters, is a 400 INVALID_REQUEST and costs nothing.
YouTube needs newest first. A stop only means something on a list in date order, so either parameter sets sort=latest on channel/videos and sort=newest on channel/shorts when you leave sort out, and sort=popular with either is a 400. YouTube list dates are estimated from labels such as "4 months ago", so use stop_at_id there for an exact stop.
Every list whose rows carry a date takes since and until: posts, comments, replies, reviews and news articles, on every platform. The window is applied to each page before it is returned, so every dated row you receive is inside it.
Parameter
Send
What happens
since
A date (YYYY-MM-DD, read as midnight UTC) or an ISO 8601 timestamp
Rows published before it are left off the page.
until
A date (YYYY-MM-DD, the whole UTC day is included) or an ISO 8601 timestamp
How far a walk goes depends on whether the list is in date order:
Lists in date order stop at since. On these lists since sets the newest-first sort when you leave it out, and the page that reaches an older row ends the walk with pagination.stopped_at set to since, exactly like Incremental sync. Another sort value with since is a 400. They are the creator feeds above and app_store/app-reviews (sort_by=most_recent), google_play/app-reviews (sort_by=newest), instagram/post/comments (sort=recent), linkedin/company/posts (sort_by=recent), linkedin/post/comments (sort_order=recent), naver/news/search (sort=date), naver/place/reviews (sort=recent), reddit/profile/posts (sort=new), reddit/search (sort=new), reddit/search/comments (sort=new), reddit/subreddit (sort=new), tiktok/post/comments (sort=recent), tripadvisor/reviews (sort_by=most_recent), trustpilot/reviews (sort=recency), twitter/search/tweets (sort=latest), twitter/tweet/replies (sort=latest), youtube/search/advanced (order=date), youtube/video/comments (order=newest).
Every other list filters each page and keeps its cursor. Comments in relevance order, keyword search and most reviews are not in date order, so an old row on page 1 says nothing about page 2. Keep following next_cursor; a page can come back with no rows and a cursor when every row on it was outside the window, and it says so with data.empty_reason set to window_filtered.
The price does not change. Each page costs what a page of that endpoint costs, whether or not rows were left off. A row with no date is kept. A since or until that is not a date, or a since later than until, is a 400 INVALID_REQUEST and costs nothing.
Other spellings are read as the window.published_after, start_date, from_date and date_from are applied as since, and published_before, end_date, to_date and date_to as until, on any list that does not take them under its own name. A named window (publish_time or date_posted set to yesterday, this-week, this-month, last-3-months or last-6-months) and a day count (max_age_days=730) become a since that far back. A list that has its own date filter keeps it: youtube/search/advanced still takes published_after, and Threads search still takes start_date. The other way round works too: on an endpoint with no window of its own, since and until reach the bound it takes under its own name (start_date on Tavily and the congress trades, from on news, date_from on the Prism reports, startDate on LinkedIn ads). Every rename is named in data._warnings, for example "date_from was applied as since, this endpoint's name for it.".
A list whose rows carry no date refuses a date. Profile lists, product lists and suggestions return 400 INVALID_REQUEST with error.details.reason set to unsupported_date_filter, before any credit is charged, so a date key is never silently ignored.
You never need a platform's native pagination param name: always send cursor. If you do send a foreign cursor param (say you copy max_id off an Instagram response onto a TikTok endpoint), you get a 400 INVALID_REQUEST with a did_you_mean: "cursor" hint and zero credits charged, instead of silently receiving page 1 again.
If you pass a cursor the upstream rejects (expired, hand-edited, copied across endpoints), you'll get a 502 UPSTREAM_ERROR or 400 INVALID_REQUEST envelope. Credits are refunded on upstream errors (see Credits → When are credits refunded?). Start over with no cursor.
limit means one of three different things depending on the endpoint, and the difference changes both how many requests you make and what you pay. The limit behaviour column in the tables below tells you which one you have.
Page size: caps a single upstream page. You still paginate yourself: one request returns one page, and you send cursor back for the next. The API maps limit to the upstream's native name (Naver display, GitHub per_page, and so on), capped at that endpoint's maximum. This is the common case.
limit (or count) also works on an endpoint that names its page size differently and has no cursor: Sephora and AliExpress page_size, YouTube comments and trending max_results, Klarna size, Wayfair and Home Depot items_per_page. It is applied under that name, a value above one page is capped at the most one page holds, and data._warnings says what was applied ("limit was applied as page_size=60, the most this endpoint returns in one page."). On an endpoint whose page size is fixed, limit is ignored and _warnings says so; send max_items instead to get more rows in one call.
Collect-until-N: the endpoint paginates for you. It walks upstream pages server-side until it has limit unique items (or the source runs dry), de-duplicates as it goes, and returns them in a single response. You are billed per page or window actually consumed, and the unused budget is refunded. This is how you get more than one window's worth of results in one call.
These endpoints collect until N:
Endpoint
Max limit
What one call costs
GET /v1/facebook/profile/reels/full
50
limit (1-50) walks upstream pages server-side until that many reels are collected, billing per page of 10 consumed (5 credits each, 5-25 total). It is not a page size.
GET /v1/instagram/profile/posts/full
50
limit (1-50) walks upstream pages server-side until that many items are collected, billing per page of ~12 consumed (5 credits each, 5-25 total). It is not a page size.
GET /v1/instagram/profile/reels/full
50
limit (1-50) walks upstream pages server-side until that many items are collected, billing per page of ~12 consumed (5 credits each, 5-25 total). It is not a page size.
GET /v1/threads/search
100
limit (1-100) walks successive result windows server-side, de-duplicating posts as it goes, and bills 1 credit per window consumed with the unused window budget refunded. It is not a page size.
Row cap: on a lane that can fill its own rows with include, limit takes the top N rows of the page the search returned and caps the extra credits the join can spend with them, so limit=5&include=… holds the page price plus 5. It is applied after the page arrives and does not change the page the upstream served, so the cursor still advances past the whole page: rows beyond N are not returned by the next page either. Use it to buy a cheaper join, not to walk a list in smaller steps.
An n/a in the column means no limit control at all: to get fewer items, slice client-side; to get more, paginate another page.
Two things that are notlimit: /v1/prism/comments takes both max and limit: max bounds how many pages it scans (and is what you pay for), while limit only caps how many of the scanned comments come back, so it never changes the price. And pagination.page_size in the response is a report of what arrived, not a control.
227 endpoints paginate today (138 cursor-based, 71 page-based, 18 offset-based). Every one of them takes the universal cursor input and returns the same pagination block (next_cursor / has_more / page_size): no per-platform special cases. The Style column is informational only; you always send cursor.