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
- List IDs only, using a filter (
filter[id_since]orfilter[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. - Fan out per-ID detail calls (
GET /accounting/invoices/{id}) with concurrency 3 to retrieve full transaction detail. - 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:
| Permission | Area | Level |
|---|---|---|
| SuiteAnalytics Workbook | Reports | Edit |
| Find Transaction | Transactions | View |
| Invoice / Bill / Credit Memo | Transactions | View (per resource used) |
| Customers | Lists | View |
…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
searchMoreWithIdcalls. - 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
getAllis 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 at0. Resilient: if the run dies, resume from the last processedidwithout 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:
| Phase | Calls | Time | Per-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 time | 101 | ~40s | — |
Throughput: ~2.5 invoices/s. Extrapolated:
| Dataset size | Estimated 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
Incremental sync — bills
Same pattern for credit notes
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:
- Catch 429 explicitly (don't conflate with 5xx).
- Exponential backoff between retries (e.g.
2s,4s,8s, capped at60s). - Honor
Retry-Afterif the response header is present. - Resume from the last successfully-processed
idrather than restarting the run. Because Phase 1 is paginated byid_since, this is cheap: just re-issue the list with the next batch's cursor or withfilter[id_since]={last_processed_id}. - 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 queryerrors, but also200 OKresponses withdata: []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-oneis 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
transactionplus joined tables. Some fields — most notably user-defined custom fields, raw SOAP envelope metadata, and the full address objects — are only available through the SOAPGET-onepath. 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 fromGET-one. updated_sinceprecision: SuiteQL parses NetSuite'slastmodifieddatein 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]withsort=lastmodifieddateto walk the result set in modification order. Backfill syncs paired withfilter[id_since]are already returned inid ASCorder.