Syncing large NetSuite transaction datasets (invoices, bills, credit notes)

NetSuite accounts with hundreds of thousands of transactions cannot be synced reliably with a plain GET /accounting/invoices (or bills / credit-notes) listing call. The unfiltered list goes through NetSuite's SOAP getAll API, which is slow on large datasets and is bound to an operationId-based pagination cursor that expires after 15 minutes — any transient error mid-paging forces the entire walk to restart from scratch.

This guide describes the recommended strategy for syncing those datasets through Apideck, with realistic timing expectations and rate-limit handling.

TL;DR

  1. List IDs only, using a filter (filter[id_since] or filter[updated_since]). This routes the request to NetSuite's REST/SuiteQL endpoint, which is significantly faster and uses a cursor token with no operationId timeout.
  2. Fan out per-ID detail calls (GET /accounting/invoices/{id}) with concurrency 3 to retrieve full transaction detail.
  3. Handle 429 with exponential backoff and resume from the last successfully-processed id.

Applies to invoices, bills, and credit-notes — all three support id_since and updated_since filters.

Required NetSuite role permissions

The filter[*] flow described below routes through NetSuite's REST/SuiteQL endpoint, which has its own access checks on every table the query touches (the transaction table plus the customer JOIN). The role used for token-based auth must include all of:

PermissionAreaLevel
SuiteAnalytics WorkbookReportsEdit
Find TransactionTransactionsView
Invoice / Bill / Credit MemoTransactionsView (per resource used)
CustomersListsView

…on top of the Setup permissions every Apideck connection needs (Log in using Access Tokens, REST Web Services, SOAP Web Services, all Full). The address subrecords joined into the invoice list (transactionBillingAddress, transactionShippingAddress) inherit access from the parent transaction — they do not need a separate Lists → Address permission.

The most commonly overlooked entry is SuiteAnalytics Workbook: it is not needed for the SOAP path, so connections that work fine on unfiltered list calls fail the moment a filter[*] parameter is added. Verify the role before migrating consumers to this flow.

NetSuite returns different errors depending on which permission is missing — the failure modes are documented in the consumer connection guide. One in particular is worth watching for: when the role is missing the per-resource transaction permission (e.g. Invoice for a consumer syncing invoices), the SuiteQL call returns 200 OK with data: [] instead of an error, so the integration appears healthy while returning nothing.

Why not just call the unfiltered list?

A plain GET /accounting/invoices (no filter, no sort) goes through NetSuite's SOAP getAll API, paginated by a NetSuite-side searchId (operation cursor):

  • The cursor is operationId-based: NetSuite stores the result set server-side and you walk it via searchMoreWithId calls.
  • The cursor expires after ~15 minutes of inactivity. Any error or pause mid-walk invalidates it and forces the consumer to restart the entire list from page 1.
  • SOAP getAll is also significantly slower than the SuiteQL alternative on large datasets.

For datasets above a few thousand records this becomes infeasible: a single transient downstream error mid-sync wastes the entire run.

The strategy

Phase 1 — list IDs through the filtered (SuiteQL) path

Sending any filter[*] parameter routes the request through NetSuite's REST/SuiteQL endpoint instead of SOAP. This:

  • Is significantly faster on large datasets.
  • Uses a stateless cursor token (no operationId, no 15-minute timeout).
  • Returns up to 200 records per page (recommended limit=200).

In Phase 1 the consumer only needs the id of each invoice; full detail is fetched in Phase 2. This makes the listing call cheap.

Two filter choices, depending on the use case:

  • filter[id_since] — backfill / cold-start sync. Walks the entire dataset deterministically by primary key, starting at 0. Resilient: if the run dies, resume from the last processed id without missing records.
  • filter[updated_since] — incremental sync. Returns transactions modified since the given ISO 8601 timestamp. Use to catch updates to existing records, not only new ones.

Both filters support pagination through the unified meta.cursors.next token.

Phase 2 — fan out per-ID detail calls

For each id from Phase 1, call GET /accounting/invoices/{id} (the SOAP GET-one endpoint) to retrieve the full transaction with its line items, custom fields, addresses and SOAP-only metadata.

Recommended concurrency: 3. NetSuite throttles concurrent access aggressively on a per-account basis. 3 is the empirically validated starting point; 5 is the safe upper bound for tenants with headroom (verify with a small batch first).

Realistic timing expectations

Benchmarked against a NetSuite account holding a large transaction dataset, sampling 100 invoices at concurrency 3:

PhaseCallsTimePer-call avg
List 100 IDs (SuiteQL)1~2.7s~2.7s
100 GET-one (SOAP)100~37s~1.1s (p50 873ms, p95 ~3.9s)
Total wall time101~40s

Throughput: ~2.5 invoices/s. Extrapolated:

Dataset sizeEstimated wall time
1,000 invoices~7 minutes
10,000 invoices~1 hour
100,000 invoices~11 hours

These numbers are linear with dataset size and assume:

  • No persistent throttling (occasional 429s with backoff are absorbed).
  • Concurrency held at 3.

Curl examples

Backfill (cold start) — invoices

# Phase 1: list IDs with id_since (paginate via meta.cursors.next)
curl --globoff -X GET \
  'https://unify.apideck.com/accounting/invoices?filter[id_since]=0&limit=200' \
  --header 'x-apideck-consumer-id: {CONSUMER_ID}' \
  --header 'x-apideck-app-id: {APP_ID}' \
  --header 'x-apideck-service-id: netsuite' \
  --header 'Authorization: Bearer {APIDECK_API_KEY}'

# Phase 2: GET-one for each id (run concurrent batches of 3)
curl --request GET \
  'https://unify.apideck.com/accounting/invoices/{id}' \
  --header 'x-apideck-consumer-id: {CONSUMER_ID}' \
  --header 'x-apideck-app-id: {APP_ID}' \
  --header 'x-apideck-service-id: netsuite' \
  --header 'Authorization: Bearer {APIDECK_API_KEY}'

Incremental sync — bills

# Phase 1: list IDs updated since the last sync
curl --globoff -X GET \
  'https://unify.apideck.com/accounting/bills?filter[updated_since]=2026-04-01T00:00:00Z&limit=200' \
  --header 'x-apideck-consumer-id: {CONSUMER_ID}' \
  --header 'x-apideck-app-id: {APP_ID}' \
  --header 'x-apideck-service-id: netsuite' \
  --header 'Authorization: Bearer {APIDECK_API_KEY}'

# Phase 2: GET-one per id
curl --request GET \
  'https://unify.apideck.com/accounting/bills/{id}' \
  --header 'x-apideck-consumer-id: {CONSUMER_ID}' \
  --header 'x-apideck-app-id: {APP_ID}' \
  --header 'x-apideck-service-id: netsuite' \
  --header 'Authorization: Bearer {APIDECK_API_KEY}'

Same pattern for credit notes

curl --globoff -X GET \
  'https://unify.apideck.com/accounting/credit-notes?filter[id_since]=0&limit=200' \
  --header 'x-apideck-consumer-id: {CONSUMER_ID}' \
  --header 'x-apideck-app-id: {APP_ID}' \
  --header 'x-apideck-service-id: netsuite' \
  --header 'Authorization: Bearer {APIDECK_API_KEY}'

Rate limit handling

NetSuite throttles concurrent access at the account level. When the threshold is hit, the consumer receives:

HTTP/1.1 429 Too Many Requests

{
  "status_code": 429,
  "error": "Too Many Requests",
  "type_name": "ConnectorRateLimitError",
  "message": "Connector Rate Limit Error",
  "detail": { ... NetSuite error payload ... }
}

Recommended client behavior:

  1. Catch 429 explicitly (don't conflate with 5xx).
  2. Exponential backoff between retries (e.g. 2s, 4s, 8s, capped at 60s).
  3. Honor Retry-After if the response header is present.
  4. Resume from the last successfully-processed id rather than restarting the run. Because Phase 1 is paginated by id_since, this is cheap: just re-issue the list with the next batch's cursor or with filter[id_since]={last_processed_id}.
  5. Lower concurrency if rate limits are persistent. Drop from 3 → 2 → 1 before considering pausing entirely.

Notes and requirements

  • Role permissions matter: filtered list calls fail with several distinct symptoms depending on which permission is missing — most visibly 400 Bad Request — Invalid search query errors, but also 200 OK responses with data: [] when the per-resource transaction permission is missing. See Required NetSuite role permissions above and the failure-mode table in the consumer connection guide.
  • The SOAP GET-one is still per-record: there is no NetSuite endpoint that returns multiple full transactions in one call. The fan-out is fundamental to how NetSuite exposes detail data.
  • Why not skip Phase 2? The list call returns transactions through SuiteQL, which is a flat view of transaction plus joined tables. Some fields — most notably user-defined custom fields, raw SOAP envelope metadata, and the full address objects — are only available through the SOAP GET-one path. If the consumer doesn't need those, Phase 1 alone is sufficient (and much faster).
  • Filtered lists return summary line items (via a secondary SuiteQL fetch from transactionLine). For most reconciliation use cases the summary is enough; the full per-line detail comes from GET-one.
  • updated_since precision: SuiteQL parses NetSuite's lastmodifieddate in account timezone and emits a date-only ISO timestamp at UTC midnight. Sub-day precision is lossy. For incremental syncs, choose a window slightly wider than the last successful sync time to avoid missing records that were updated late on the same day.
  • Sort: incremental syncs should pair filter[updated_since] with sort=lastmodifieddate to walk the result set in modification order. Backfill syncs paired with filter[id_since] are already returned in id ASC order.