ERP API Pagination Patterns Comparison: Cursor vs Offset vs Keyset vs nextRecordsUrl

Type: ERP Integration Systems: Salesforce, SAP S/4HANA, Oracle ERP Cloud, NetSuite, D365, Workday, IFS, Acumatica Confidence: 0.86 Sources: 8 Verified: 2026-03-03 Freshness: 2026-03-03

TL;DR

System Profile

This comparison card covers pagination patterns across 8 ERP systems. The focus is on outbound data retrieval pagination (reading data from the ERP), not inbound bulk import patterns.

SystemRoleAPI SurfacePagination Pattern
SalesforceCRM + PlatformREST API v62.0Server-side cursor (nextRecordsUrl)
SAP S/4HANA CloudERPOData V2/V4$skip/$top (client) + $skiptoken (server-driven)
Oracle ERP CloudERPRESTOffset/limit with hasMore
Oracle NetSuiteERPREST + SuiteQLOffset/limit with 1,000-page cap
Dynamics 365 F&OERPOData V4@odata.nextLink + $skiptoken
WorkdayHCM + FinanceREST + SOAP + RaaSPage/offset (REST), no pagination (RaaS)
IFS CloudERPOData V4$skip/$top + server-driven paging
AcumaticaERPREST$top/$skip + filter-based keyset

API Surfaces & Capabilities

SystemPagination MethodDefault Page SizeMax Page SizeStateful?Deep Pagination?
SalesforcenextRecordsUrl (cursor)2,0002,000 (fixed)Yes (server cursor)Yes -- O(1)
SAP S/4HANA ($skiptoken)Server-drivenServer-controlledServer-controlledYesYes
SAP S/4HANA ($skip/$top)Client-drivenClient-set~10,000NoNo -- O(n)
Oracle ERP Cloudoffset/limit25500NoNo -- O(n)
NetSuite RESToffset/limit1,0001,000NoLimited (1K pages)
Dynamics 365@odata.nextLink10,00010,000Yes ($skiptoken)Yes
Workday RESTpage/offset10100NoNo
Workday RaaSNoneFull reportFull reportN/AN/A
IFS Cloud$skip/$top + nextLinkServer-controlled~10,000MixedPartial
Acumatica$top/$skip100No hard capNoNo -- O(n)

Rate Limits & Quotas

Pagination-Specific Limits

SystemPagination LimitImplicationWorkaround
SalesforceQuery locator expires after 15 min idleMust consume pages within 15 minReduce per-page processing time; parallelize downstream
SAP S/4HANA$skiptoken tied to sessionInvalidated on session expiryRe-authenticate and restart from last known position
Oracle ERP Cloud500 records per page max200 pages for 100K recordsAdd restrictive filters; use BICC for bulk
NetSuite1,000 pages max totalHard cap on retrievable recordsDate-range chunking or Saved Search CSV
Dynamics 36510,000 per page max; 6K req/5min throttleThroughput ceiling ~2M records/5minUse Data Management Framework for full exports
Workday RaaSNo pagination; full report in one callReports >50K rows timeoutWQL API or split with prompt parameters
AcumaticaNo page limit; linear degradationOffset 100K+ takes 10s+ per pageFilter-based keyset (LastModifiedDate > X)

Concurrent Pagination Sessions

SystemMax ConcurrentShared WithNotes
SalesforceNo explicit limitAll REST API callsEach page costs 1 API call from 100K/24h pool
SAP S/4HANATenant-specificAll OData requestsContact SAP for exact limits
Oracle ERP CloudNo documented limitAll REST API callsThrottled by overall throughput
NetSuite5-20 (by tier)All SuiteTalk/RESTStandard: 5, Premium: 15, Enterprise: 20
Dynamics 365Per-user throttledAll OData requests6K req/5min per user per web server

Authentication

SystemToken-Pagination CouplingImpact of Token Expiry
SalesforcenextRecordsUrl tied to sessionToken expiry invalidates all open query locators
SAP S/4HANA$skiptoken valid within sessionMust re-authenticate and restart
Oracle ERP CloudStateless -- no couplingResume with new token at same offset
NetSuiteStateless -- no couplingResume with new token at same offset
Dynamics 365$skiptoken may be session-scoped@odata.nextLink may fail after refresh
AcumaticaStateless -- no couplingResume with new token at same offset

Authentication Gotchas

Constraints

Integration Pattern Decision Tree

START -- Need to paginate through ERP data
|
+-- Which ERP?
|   +-- Salesforce --> nextRecordsUrl cursor (2,000/page, O(1))
|   |   +-- >10M records? --> Bulk API 2.0 instead
|   +-- SAP S/4HANA --> Server-driven $skiptoken (Prefer header)
|   |   +-- >100K records? --> CDS views or BICC
|   +-- Oracle ERP Cloud --> offset/limit + orderBy (REQUIRED)
|   |   +-- >100K records? --> BICC or BI Publisher
|   +-- NetSuite --> offset/limit (1,000 page cap!)
|   |   +-- >pageSize*1000? --> Date-range chunking
|   +-- Dynamics 365 --> Follow @odata.nextLink (never construct manually)
|   |   +-- >500K records? --> Data Management Framework
|   +-- Workday REST --> page/offset (max 100/page)
|   |   +-- RaaS reports --> No pagination; chunk with prompts
|   +-- IFS Cloud --> OData V4 server-driven paging
|   +-- Acumatica --> <50K: $top/$skip; >50K: filter-based keyset
|
+-- Performance: <10K: any method; 10K-100K: prefer cursor; >100K: cursor required; >1M: bulk API

Quick Reference

Cross-System Pagination Comparison

CapabilitySalesforceSAP S/4HANAOracle ERPNetSuiteD365WorkdayIFSAcumatica
Primary PatternCursor$skiptokenOffsetOffset$skiptokenPage/offset$skiptoken$top/$skip
Default Page Size2,000Server251,00010,00010Server100
Max Page Size2,000~10,0005001,00010,000100~10,000No cap
Deep Paging (>100K)ExcellentGoodPoorCappedGoodVery poorGoodPoor
Data ConsistencyStrongStrongWeakWeakStrongWeakStrongWeak
StatefulYesYesNoNoYesNoMixedNo
Cursor Expiry15 minSessionN/AN/ASessionN/ASessionN/A
Total Record CapNone*NoneNone~1MNone~50K RaaSNoneNone
ResumableNoNoYesYesNoYesDependsYes

Pagination Pattern Types

PatternHow It WorksTime ComplexityConsistencyJump to Page N?Best For
Cursor / nextRecordsUrlServer returns opaque token for next setO(1) per pageStrongNoLarge, volatile datasets
Offset / $skipClient specifies row offsetO(n)WeakYesSmall, static datasets
Keyset / filter-basedFilter by last-seen valueO(1) per pageStrong if sortedNoMedium-large, sorted data
$skiptoken (OData)Server-generated opaque position tokenO(1) per pageStrongNoOData APIs (SAP, D365, IFS)
Page numberClient requests page NO(n)WeakYesSmall REST APIs

Step-by-Step Integration Guide

1. Salesforce: Paginate with nextRecordsUrl

Salesforce REST API returns a nextRecordsUrl field when results exceed 2,000 records. This URL contains a server-side query locator acting as a cursor. [src1]

import requests

def paginate_salesforce(instance_url, access_token, soql_query):
    headers = {"Authorization": f"Bearer {access_token}"}
    url = f"{instance_url}/services/data/v62.0/query"
    params = {"q": soql_query}
    all_records = []
    while True:
        resp = requests.get(url, headers=headers, params=params)
        resp.raise_for_status()
        data = resp.json()
        all_records.extend(data["records"])
        if data["done"]:
            break
        url = f"{instance_url}{data['nextRecordsUrl']}"
        params = {}
    return all_records

Verify: data["totalSize"] should match len(all_records)

2. SAP S/4HANA: Server-Driven $skiptoken Paging

Set Prefer: odata.maxpagesize=N header. Response includes @odata.nextLink with $skiptoken. [src2]

import requests

def paginate_sap_odata(base_url, access_token, entity_set, page_size=1000):
    headers = {
        "Authorization": f"Bearer {access_token}",
        "Prefer": f"odata.maxpagesize={page_size}",
        "Accept": "application/json"
    }
    url = f"{base_url}/sap/opu/odata4/sap/{entity_set}"
    all_records = []
    while url:
        resp = requests.get(url, headers=headers)
        resp.raise_for_status()
        data = resp.json()
        all_records.extend(data.get("value", []))
        url = data.get("@odata.nextLink")  # Don't modify this URL
    return all_records

Verify: Compare count against GET {entity_set}/$count

3. Oracle ERP Cloud: Offset/Limit with orderBy

Always include orderBy -- results are non-deterministic without it. [src3]

import requests

def paginate_oracle_erp(base_url, access_token, resource, page_size=500):
    headers = {"Authorization": f"Bearer {access_token}"}
    all_records, offset = [], 0
    while True:
        params = {"limit": page_size, "offset": offset,
                  "orderBy": "CreationDate:asc", "totalResults": "true"}
        resp = requests.get(f"{base_url}/fscmRestApi/resources/latest/{resource}",
                           headers=headers, params=params)
        resp.raise_for_status()
        data = resp.json()
        all_records.extend(data.get("items", []))
        if not data.get("hasMore", False):
            break
        offset += page_size
    return all_records

Verify: data["totalResults"] matches len(all_records)

4. Dynamics 365: Follow @odata.nextLink

D365 returns @odata.nextLink with $skiptoken. Never construct pagination URLs manually. [src5, src8]

import requests

def paginate_dynamics365(base_url, access_token, entity, max_page_size=10000):
    headers = {"Authorization": f"Bearer {access_token}",
               "Prefer": f"odata.maxpagesize={max_page_size}"}
    url = f"{base_url}/data/{entity}"
    all_records = []
    while url:
        resp = requests.get(url, headers=headers)
        resp.raise_for_status()
        data = resp.json()
        all_records.extend(data.get("value", []))
        url = data.get("@odata.nextLink")
    return all_records

Verify: GET {entity}/$count matches len(all_records)

5. Acumatica: Filter-Based Keyset for Large Datasets

For >50K records, avoid $skip and use filter-based keyset by sorting on a unique field.

import requests

def paginate_acumatica_keyset(base_url, cookies, entity, page_size=100):
    all_records, last_id = [], ""
    while True:
        params = {"$top": page_size, "$orderby": "InventoryID"}
        if last_id:
            params["$filter"] = f"InventoryID gt '{last_id}'"
        resp = requests.get(f"{base_url}/entity/Default/24.200.001/{entity}",
                           cookies=cookies, params=params)
        resp.raise_for_status()
        records = resp.json()
        if not records:
            break
        all_records.extend(records)
        last_id = records[-1]["InventoryID"]["value"]
    return all_records

Verify: Compare against Acumatica Generic Inquiry count

Code Examples

cURL: Test Pagination for Each ERP

# Salesforce: Query with cursor pagination
curl -s -H "Authorization: Bearer $SF_TOKEN" \
  "$SF_INSTANCE/services/data/v62.0/query?q=SELECT+Id,Name+FROM+Account" \
  | jq '{totalSize: .totalSize, done: .done, nextUrl: .nextRecordsUrl}'

# SAP S/4HANA: Server-driven paging
curl -s -H "Authorization: Bearer $SAP_TOKEN" \
  -H "Prefer: odata.maxpagesize=100" \
  "$SAP_URL/sap/opu/odata4/sap/API_BUSINESS_PARTNER/A_BusinessPartner" \
  | jq '{count: (.value | length), nextLink: ."@odata.nextLink"}'

# Oracle ERP Cloud: Offset pagination (always include orderBy!)
curl -s -H "Authorization: Bearer $ORA_TOKEN" \
  "$ORA_URL/fscmRestApi/resources/latest/invoices?limit=500&offset=0&orderBy=InvoiceId:asc&totalResults=true" \
  | jq '{totalResults: .totalResults, hasMore: .hasMore}'

# NetSuite: Check for 1,000-page cap risk
curl -s -H "Authorization: $NS_AUTH" \
  "https://$NS_ACCOUNT.suitetalk.api.netsuite.com/services/rest/record/v1/customer?limit=1" \
  | jq '.totalResults'

# Dynamics 365: Follow @odata.nextLink
curl -s -H "Authorization: Bearer $D365_TOKEN" \
  -H "Prefer: odata.maxpagesize=5000" \
  "$D365_URL/data/SalesOrderHeaders" \
  | jq '{count: (.value | length), nextLink: ."@odata.nextLink"}'

Error Handling & Failure Points

Common Error Codes

SystemErrorCauseResolution
SalesforceQUERY_TIMEOUTQuery locator expired (>15 min)Reduce processing time; restart query
SalesforceINVALID_QUERY_LOCATORExpired nextRecordsUrlRe-execute original SOQL
SAP S/4HANA400 Bad Request on $skip$skip exceeds available recordsCheck $count; use $skiptoken
Oracle ERP500 Internal Server ErrorDB timeout at high offsetAdd filters; use BICC for bulk
NetSuiteSSS_REQUEST_LIMIT_EXCEEDEDConcurrency/governance limitBackoff; reduce concurrent sessions
Dynamics 365429 Too Many RequestsExceeded 6K req/5minRespect Retry-After; use $batch
Workday408 Request TimeoutRaaS report too largeSplit with prompts; use WQL
AcumaticaTimeoutDB scan at large $skipSwitch to filter-based keyset

Failure Points in Production

Anti-Patterns

Wrong: Using $skip/$top for deep pagination on SAP

# BAD -- O(n) scan + data consistency issues at high offsets
offset = 0
while True:
    url = f"{base_url}/API_BUSINESS_PARTNER?$top=1000&$skip={offset}"
    data = requests.get(url, headers=headers).json()
    records.extend(data["value"])
    if len(data["value"]) < 1000: break
    offset += 1000  # At 500K: 30+ second queries

Correct: Server-driven $skiptoken paging

# GOOD -- O(1) per page, consistent results
headers["Prefer"] = "odata.maxpagesize=1000"
url = f"{base_url}/API_BUSINESS_PARTNER"
while url:
    data = requests.get(url, headers=headers).json()
    records.extend(data["value"])
    url = data.get("@odata.nextLink")  # Contains $skiptoken

Wrong: Oracle offset without orderBy

# BAD -- non-deterministic results; records may duplicate or disappear
params = {"limit": 500, "offset": offset}
data = requests.get(f"{url}/invoices", params=params).json()

Correct: Always include orderBy

# GOOD -- deterministic page ordering on unique column
params = {"limit": 500, "offset": offset, "orderBy": "InvoiceId:asc"}
data = requests.get(f"{url}/invoices", params=params).json()

Wrong: Ignoring NetSuite 1,000-page cap

# BAD -- silently stops at page 1,000 even with more data
while True:
    data = requests.get(f"{url}?limit=100&offset={offset}").json()
    all_records.extend(data["items"])
    if not data["hasMore"]: break  # False positive at page 1,000!
    offset += 100

Correct: Date-range chunking for NetSuite

# GOOD -- chunk by date to stay under 1,000-page cap per window
current = start_date
while current < end_date:
    chunk_end = min(current + timedelta(days=7), end_date)
    records = paginate_within_cap(
        f"{url}?q=lastModifiedDate BETWEEN '{current}' AND '{chunk_end}'",
        page_size=1000)
    all_records.extend(records)
    current = chunk_end

Common Pitfalls

Diagnostic Commands

# Salesforce: Pre-check record count
curl -s -H "Authorization: Bearer $SF_TOKEN" \
  "$SF_INSTANCE/services/data/v62.0/query?q=SELECT+COUNT()+FROM+Account" \
  | jq '.records[0].expr0'

# SAP S/4HANA: Get entity count
curl -s -H "Authorization: Bearer $SAP_TOKEN" \
  "$SAP_URL/sap/opu/odata4/sap/API_BUSINESS_PARTNER/A_BusinessPartner/\$count"

# Oracle ERP Cloud: Get total results
curl -s -H "Authorization: Bearer $ORA_TOKEN" \
  "$ORA_URL/fscmRestApi/resources/latest/invoices?limit=1&totalResults=true" \
  | jq '.totalResults'

# NetSuite: Assess page cap risk
curl -s -H "Authorization: $NS_AUTH" \
  "https://$NS_ACCOUNT.suitetalk.api.netsuite.com/services/rest/record/v1/customer?limit=1" \
  | jq '.totalResults'

# Dynamics 365: Count entities
curl -s -H "Authorization: Bearer $D365_TOKEN" \
  "$D365_URL/data/SalesOrderHeaders/\$count"

Version History & Compatibility

ERPFeatureIntroducedStatusNotes
SalesforcenextRecordsUrl cursorAPI v20+ (2010)StableUnchanged for 15+ years
SalesforceGraphQL cursorsv56.0 (2022)GAAlternative for complex queries
SAP S/4HANAOData V4 $skiptoken2020CurrentPreferred over $skip/$top
Oracle ERP CloudREST offset/limit2017CurrentNo cursor alternative planned
NetSuiteREST API pagination2019.2Current1,000-page cap since launch
Dynamics 365$skiptoken adoption2023+CurrentReplacing $skip
WorkdayWQL pagination2022CurrentAlternative to RaaS
IFS CloudOData V4 paging22R1CurrentStandard OData pattern
AcumaticaREST $top/$skip2019 R1CurrentNo cursor alternative

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Extracting <100K records for syncExtracting >1M records for migrationBulk API (SF), BICC (Oracle), DMF (D365)
Incremental data syncInitial full data loadBulk export APIs or file-based import
Feeding dashboards with fresh dataHistorical analytics over full datasetData warehouse / BICC / Prism Analytics
Moderate-volume scheduled integrationsHigh-frequency event processingCDC / Platform Events / webhooks

Cross-System Comparison

CapabilitySalesforceSAP S/4HANAOracle ERPNetSuiteD365WorkdayIFSAcumatica
Pagination TypeCursor$skiptokenOffsetOffset$skiptokenPage/offset$skiptoken$skip + keyset
ConsistencyStrongStrongNoneNoneStrongNoneStrongNone
Perf at 100KExcellentGoodDegradedCappedGoodN/AGoodDegraded
Perf at 1MGoodGoodVery poorImpossibleGoodN/AGoodVery poor
Max RetrievableUnlimited*UnlimitedUnlimited**~1MUnlimited~50K RaaSUnlimitedUnlimited**
ResumableNoNoYesYesNoYesDependsYes
Random AccessNoNoYesYesNoNoNoYes
StandardProprietaryOData V4RESTRESTOData V4ProprietaryOData V4REST
Learning CurveLowMediumLowLowMediumHighMediumLow

*Subject to daily API call quota. **Subject to timeout at very high offsets.

Important Caveats

Related Units