A dependable Salesforce NetSuite integration starts by giving every object one system of record. Customers and deals come from Salesforce, and orders, invoices and payments from NetSuite. Sync changes through a queue rather than direct trigger callouts, and store each system's ID in the other. Most failures come from duplicates, missing ID mapping, timing and partial failures.
Common sync patterns
There are three patterns in common use. Most mature integrations use more than one of them.
1. Event-driven, one record at a time. A change in Salesforce, such as an Opportunity reaching Closed Won, publishes a Platform Event or Change Data Capture event. Middleware picks it up, transforms it and calls NetSuite's SuiteTalk REST web services. In the other direction, a NetSuite SuiteScript user event script (for example afterSubmit on an invoice) notifies the middleware. This suits the order hand-off, where sales and finance both need quick feedback.
2. Scheduled delta sync. On a schedule, middleware asks each system what has changed since the last run, using SuiteQL through the REST query endpoint on the NetSuite side and SystemModstamp filters or the Bulk API on the Salesforce side. This suits invoice status, payment updates and reference data, where a few minutes' delay is acceptable and polling is simpler to operate.
3. Bulk or initial load. A one-off or occasional bulk job to seed customers, items or historical orders. You'll need it at go-live, and it has to use the same matching and ID rules as the ongoing sync, or it will create the duplicates you're trying to avoid.
On protocol: Oracle has scheduled the removal of SOAP web services from NetSuite and recommends REST web services with OAuth 2.0 for new integrations. If you're designing now, build on REST and check Oracle's current SOAP removal timeline for any existing SOAP-based connector.
What to sync where
The ownership model decides everything else, so agree it with sales ops and finance before anyone writes code.
| Data | System of record | Direction | Notes |
|---|---|---|---|
| Accounts / Customers | Salesforce (until first order) | SF → NS, then limited NS → SF | Create the NetSuite customer when the first order is placed, not for every prospect. Finance-owned fields such as credit limit and terms come back read-only. |
| Contacts | Salesforce | SF → NS | Sync only the billing and shipping contacts finance needs. |
| Products / Items | NetSuite | NS → SF | Items, pricing and tax configuration are usually finance-controlled. Sync them into Salesforce Products and Price Books. |
| Opportunities / Quotes | Salesforce | Stay in SF | They become an order on Closed Won or quote acceptance. |
| Sales Orders | NetSuite (after creation) | SF → NS on create; NS → SF for status | Salesforce sends the order once. After that, fulfilment and status changes flow back. |
| Invoices | NetSuite | NS → SF | Usually read-only in Salesforce, so account teams can see billing status. |
| Payments / Credit memos | NetSuite | NS → SF | Summarised (paid, overdue, balance) unless sales actually needs line detail. |
The aim is to make each object as close to one-directional as possible. Fully bi-directional sync on the same fields is where conflicts, loops and "last write wins" surprises tend to start. If both systems really must edit a record, divide the fields between them so each field has exactly one owner.
Top pitfalls
Duplicates
The usual cause is a retry. The middleware creates a NetSuite customer, the response times out, and the retry creates a second customer. Prospect-to-customer conversions done by hand in NetSuite add more duplicates.
Practice: Treat every write as an upsert keyed on an external ID. NetSuite records support an externalId, and REST web services let you address a record by external ID (the eid: prefix), so "create or update the customer whose external ID is this Salesforce Account Id" is safe to retry. For the wider pattern, see idempotent ingestion layers.
ID mapping
Without stored cross-references, every sync has to match by name or email, which is fragile.
Practice: Store IDs in both directions. NetSuite's externalId holds the Salesforce record Id, and a Salesforce external ID field, such as NetSuite_Internal_Id__c, holds the NetSuite internal ID. Do this for customers, items, orders and invoices, and map subsidiaries, currencies, tax codes and payment terms through maintained lookup tables rather than hard-coded values.
Timing and ordering
An order arrives before its customer exists, or an invoice update is processed before the order write-back. Multi-currency deals pick up exchange rates at a different moment from the one finance expects.
Practice: Make the order worker resolve its dependencies: upsert the customer first, then create the order, in one flow. Use event timestamps to discard stale updates. Agree with finance whether currency conversion happens in NetSuite (often the preferred option) or is passed in from Salesforce.
Partial failures
The sales order is created in NetSuite, but writing the ID back to Salesforce fails. Or a header is created and one line item is rejected because of an inactive item. Now the two systems disagree, and nobody has been told.
Practice: Model each sync as a sequence of steps with recorded state, make every step retry-safe, and send anything that keeps failing to a dead-letter queue with alerting. See dead-letter queues and error recovery.
Concurrency and governance
NetSuite limits concurrent web service requests depending on your account's service tier, and SuiteScript runs under governance limits. Firing an unthrottled burst of requests at month-end tends to produce rejected calls.
Practice: Put a queue in front of NetSuite with a worker concurrency that stays below your account's limit, and back off on rate-limit responses.
Reference architecture
SALESFORCE MIDDLEWARE NETSUITE
Opportunity Closed Won ──► Platform Event ──► Queue ──► Order worker:
1. Upsert customer (eid: SF Account Id)
2. Create sales order (externalId = SF Opp Id)
3. Write back NS internal IDs ──► SF upsert
│
└──► DLQ + alerts + replay
NetSuite invoice/payment change ──► SuiteScript afterSubmit (or SuiteQL delta poll) ──► Queue
──► Status worker ──► Salesforce upsert on NetSuite_Internal_Id__c
The order worker logic, in pseudocode:
function handleOrderEvent(event):
if alreadyProcessed(event.eventId): return OK // idempotency store
opp = event.payload
customer = netsuite.upsert("customer", externalId = opp.accountId,
fields = mapCustomer(opp.account))
order = netsuite.upsert("salesOrder", externalId = opp.id,
fields = mapOrder(opp, customer.internalId))
salesforce.update("Account", opp.accountId, { NetSuite_Internal_Id__c: customer.internalId })
salesforce.update("Opportunity", opp.id, { NetSuite_Order_Id__c: order.internalId })
markProcessed(event.eventId)
on TransientError (timeout, 429, 5xx): retry with backoff
on PermanentError (validation, inactive item): send to DLQ, alert finance ops
Because each step is an upsert on an external ID, replaying the whole flow after a failure part-way through doesn't create duplicates. The middleware can be MuleSoft, which has NetSuite and Salesforce connectors, or a custom service. For how to choose, see MuleSoft vs custom middleware.