Most NetSuite-to-Shopify integrations poll on a timer. For B2B that is too slow: a single wholesale order can move a thousand units, and the next buyer sees stock that no longer exists. The fix is event-driven sync with a reconciliation pass behind it.
A direct-to-consumer store can survive a fifteen-minute inventory lag. A wholesale store usually cannot. One B2B order can take a thousand units off the shelf in a single line, and every buyer who loads the catalogue in the following quarter-hour is looking at stock that no longer exists. They order it. You accept it. Then someone in operations has to make a phone call.
This is the most common failure we are called in to fix, and it is almost never caused by a bad integration tool. It is caused by a scheduled one. Below is the architecture we use instead, why each piece exists, and the specific places it goes wrong.
Why the timer is the problem
A scheduled sync has a fixed worst case: your sync interval. If you poll every fifteen minutes, then fifteen minutes is how stale your worst reading can be. For a DTC store selling two or three units per order, the exposure is small. For a distributor whose average order line is in the hundreds, the same fifteen minutes is the difference between an accurate catalogue and a fiction.
Worse, polling scales badly in exactly the wrong direction. As the catalogue grows, a full sweep of item availability takes longer, so teams lengthen the interval to stay inside NetSuite governance limits. The staleness window grows precisely as the business does.
Bar chart comparing oversell exposure by sync method. Fifteen-minute polling on a catalogue of 5,000 SKUs shows a 15 minute worst-case staleness window; five-minute polling shows 5 minutes; event-driven sync shows under 30 seconds.
The architecture
The shape that works is event-driven at the edge and reconciled in the background. NetSuite emits a change, a queue absorbs the burst, a worker transforms and writes to Shopify, and a nightly sweep repairs anything the stream dropped. Four moving parts, each with one job.
Architecture diagram. NetSuite item receipt, fulfilment and adjustment events fire a User Event script, which posts to an integration endpoint. Messages enter a queue keyed by SKU. A worker reads the queue, applies buffer rules, and writes to the Shopify Admin GraphQL inventorySetOnHandQuantities mutation. A separate nightly reconciliation job performs a full delta comparison and repairs drift. Failures route to a dead letter queue with alerting.
Emit from NetSuite, do not ask it
The trigger is a User Event script on the transaction types that actually move stock — Item Fulfilment, Item Receipt, Inventory Adjustment, and Inventory Transfer if you run multiple locations. It fires on afterSubmit and posts the affected item internal IDs to your integration endpoint. It deliberately does not post quantities.
/**
* @NApiVersion 2.1
* @NScriptType UserEventScript
*/
define(['N/https', 'N/runtime'], (https, runtime) => {
const afterSubmit = (context) => {
if (context.type === context.UserEventType.VIEW) return;
const rec = context.newRecord;
const skus = new Set();
const lines = rec.getLineCount({ sublistId: 'item' });
for (let i = 0; i < lines; i++) {
const itemId = rec.getSublistValue({
sublistId: 'item', fieldId: 'item', line: i
});
if (itemId) skus.add(String(itemId));
}
if (!skus.size) return;
// Notify only. The worker reads authoritative quantities itself, so a
// message that arrives late or out of order still resolves correctly.
https.post({
url: runtime.getCurrentScript().getParameter({ name: 'custscript_sync_endpoint' }),
body: JSON.stringify({
event: 'inventory.changed',
itemIds: [...skus],
sourceRecord: { type: rec.type, id: rec.id },
emittedAt: new Date().toISOString()
}),
headers: {
'Content-Type': 'application/json',
'X-Signature': runtime.getCurrentScript().getParameter({ name: 'custscript_sync_secret' })
}
});
};
return { afterSubmit };
});Make every write idempotent
Messages will be delivered twice. Networks retry, scripts re-fire, and someone will replay a queue during an incident. If your write path is not idempotent, a duplicate becomes a data corruption.
The defence is to set absolute quantities, never to increment. Shopify’s inventorySetOnHandQuantities mutation sets a value; applying it twice produces the same state. Pair it with a stored idempotency key — the SKU plus the NetSuite record revision — and drop any message whose key you have already processed at a revision greater than or equal to the one you hold.
mutation SetOnHand($input: InventorySetOnHandQuantitiesInput!) {
inventorySetOnHandQuantities(input: $input) {
inventoryAdjustmentGroup { createdAt reason }
userErrors { field message }
}
}
# variables
{
"input": {
"reason": "correction",
"referenceDocumentUri": "netsuite://item-fulfilment/91042",
"setQuantities": [
{
"inventoryItemId": "gid://shopify/InventoryItem/4409",
"locationId": "gid://shopify/Location/72",
"quantity": 318
}
]
}
}Buffer rules: the number you publish is not the number you hold
Raw availability is rarely the right figure to expose to wholesale buyers. Physical stock is committed to open orders, reserved for contract customers, sitting in a location you do not sell from, or in transit. Publishing the raw figure oversells; publishing an over-conservative one leaves revenue on the table.
Resolution ladder showing how published availability is calculated. Start with on-hand 1,240 units. Subtract committed to open orders, 380. Subtract contract reserve, 150. Exclude non-sellable locations, 60. Subtract safety buffer of 5 percent, 32. Published availability is 618 units.
- Step 1− 380Less: committed to open sales ordersNetSuite quantitycommitted
- Step 2− 150Less: contract reserveHeld for named accounts
- Step 3− 60Less: non-sellable locationsReturns and QA holding
- Step 4− 32Less: safety buffer (5%)Absorbs count drift
Keep these rules in the worker, expressed as configuration rather than code. Operations will want to change the buffer during peak season, and that should not require a deployment.
The nightly pass is not optional
Event streams drop messages. A script errors, an endpoint is down for a deploy, someone edits a record in a way that does not fire your trigger. Over weeks, small divergences accumulate into a catalogue nobody trusts.
So run a full comparison nightly: pull availability for every active SKU from both systems, diff them, repair the differences, and — this is the part teams skip — record the size of the drift. That number is your integration’s health metric. A drift count that is climbing week over week tells you something upstream broke long before a customer does.
Where this goes wrong in practice
- Multi-location mapping is treated as an afterthought. NetSuite locations and Shopify locations must map explicitly, including the ones you deliberately do not sell from. An unmapped location silently contributes zero.
- Kits and assemblies are synced as if they were simple items. A kit’s availability is derived from its components; pushing a stored figure for it will be wrong the moment a component moves.
- Governance limits are discovered in production. NetSuite scripts have unit budgets. Batch your reads and test against a production-sized catalogue, not a sandbox with two hundred items.
- The integration has no owner. It is treated as finished at go-live, so nobody watches the dead letter queue, and the first anyone hears of a failure is a customer complaint six weeks later.
Key takeaways
- For B2B, polling interval is oversell exposure. Move the hot path to events and keep the interval only for reconciliation.
- Send notifications, not quantities. Let the worker read the authoritative figure at processing time.
- Set absolute quantities with an idempotency key so replays are harmless.
- Published availability is a business calculation, not a raw figure. Make the buffer rules configurable.
- Run a nightly full delta and track drift as a health metric — it is your early warning that a trigger has broken.
None of this is exotic. It is the difference between an integration that works on the demo catalogue and one that still works at the end of a peak trading week, when a single wrong number is the thing everyone remembers.
360 Expert Solutions
E-commerce & ERP Integration Team
We build and scale B2B and DTC commerce on Shopify Plus, BigCommerce and WooCommerce, wired into NetSuite and Oracle. These articles are written by the engineers and architects who deliver those projects.
Building something like this?
We architect B2B commerce and ERP integrations end to end.