All field notes
ERP Integration

Real-Time Inventory Between NetSuite and Shopify Plus B2B

Why polling every fifteen minutes quietly loses you orders, and the event-driven architecture that replaces it.

360 Expert Solutions5 min read

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.

Worst-case staleness is simply your sync interval. Event-driven collapses it to transport latency.

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.

Events carry speed; the nightly delta carries correctness. You need both.

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.

javascript
/**
 * @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 };
});
NetSuite User Event script — notify only, no quantities in the payload.

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.

graphql
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
      }
    ]
  }
}
Set absolute on-hand quantities. Running this twice is a no-op, not a doubling.

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.

On hand across all locations1,240
  1. Less: committed to open sales orders
    NetSuite quantitycommitted
    − 380
    Step 1
  2. Less: contract reserve
    Held for named accounts
    − 150
    Step 2
  3. Less: non-sellable locations
    Returns and QA holding
    − 60
    Step 3
  4. Less: safety buffer (5%)
    Absorbs count drift
    − 32
    Step 4
Published to Shopify618
Each rule is a deliberate business decision. Write them down — they will be questioned.

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.

NetSuiteShopify PlusB2BInventoryWebhooksIntegration

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.

ERP Integration

Celigo, Boomi or Custom: Choosing Integration Middleware

iPaaS platforms sell speed to first integration. Custom middleware sells control. The mistake is evaluating them on how fast the first flow ships, when the cost that actually bites is what happens at the fortieth change request.

Read
Platform Strategy

Shopify Plus B2B vs BigCommerce B2B Edition

Both platforms now ship native B2B. The marketing pages look interchangeable. The real differences are price list modelling, catalogue segmentation, checkout extensibility and how each handles a buyer who is also a consumer — and those decide the build.

Read
Platform Strategy

Migrating a B2B WooCommerce Store Without Losing Your Rankings

Most replatform traffic losses are not caused by the new platform. They are caused by URL structures that changed without a redirect map, category pages that no longer exist, and a launch that shipped before anyone crawled the old site properly.

Read
Book