South Arc Digital
Case Study17 min read

Shopify Plus B2B and NetSuite Integration Architecture

The Shopify Plus B2B NetSuite integration architecture is dominated by the company hierarchy and price-list determination, not by the order sync itself.

Vignesh Ramakrishnan

A DTC Shopify-to-NetSuite sync maps one Shopify customer to one NetSuite customer, one order to one sales order, one payment to one cash receipt. The shopify plus b2b netsuite integration architecture does not have that shape. One Shopify Company holds N buyers and M ship-to locations. A NetSuite parent customer holds N contacts and M addresses. A single B2B order against that company has to resolve a company-specific catalog, a per-location price list, a per-location tax-exempt certificate, and a payment term that defers cash receipt for 30 or 60 days. The order sync is the easy part. The company hierarchy and the price-list determination logic are where the integration lives or dies.

This post documents the architecture we settled on after three Shopify Plus B2B + NetSuite builds for mid-market brands running mixed DTC and wholesale on a single Shopify Plus store. If you want the failure-mode catalog that applies to either side of the DTC/B2B line, why your Shopify-to-NetSuite sync keeps breaking covers the five recurring patterns. This post picks up where the data model gets harder.

The business problem

Mid-market consumer brands are increasingly running both channels on one Shopify Plus store. The shape we kept seeing was a $20-50M DTC business adding $5-15M of wholesale revenue through the same storefront, using Shopify Plus B2B Companies for the wholesale side and the normal DTC checkout for retail. The Shopify Help Center B2B features overview confirms this is now the supported pattern on Plus, Advanced, Grow, and Basic plans, with Companies, Company Locations, quantity rules, price breaks, net terms, draft order flows, PO numbers, and Flow automations available on each tier.

Finance teams want the consolidated picture in NetSuite. They want one trial balance, one revenue recognition rollup, one outstanding A/R aging, with the wholesale invoices, the DTC card receipts, the Net 30 holds, and the retail refunds all sitting in the same general ledger. That is not optional. The CFO has to file one set of statements.

The failure mode on rescued projects: the brand had a working DTC-to-NetSuite integration, then bolted B2B on top. Wholesale orders flowed in as if they were DTC. Net 30 terms were stamped as immediate payments. Company-specific price lists were overwritten by the storefront list price. Six months in, finance was reconciling 40 to 80 B2B orders a month by hand and the controller no longer trusted the revenue report.

3-5%

of B2B order line value typically drifts in revenue recognition when wholesale is bolted onto a DTC-first Shopify-NetSuite sync, based on three brands we audited in 2025

The data model boundary

The first architectural decision is the mapping between Shopify's B2B object model and NetSuite's customer hierarchy. The two models share concepts but do not align cleanly.

Shopify B2B exposes a Company, which holds Company Contacts (the buyers) and Company Locations (the ship-to and bill-to addresses). The Company object on the GraphQL Admin API lets each Location have its own tax ID, tax exemptions, shipping address, billing address, pricing, payment terms, and contact list. Catalogs and payment terms are assigned at the Company or Location level, not at the buyer level.

NetSuite's OneWorld customer hierarchy supports parent and sub-customer relationships, with contacts attached at any level, addresses on any record, and price levels assigned per customer. Sub-customers can roll up to a parent for credit and reporting purposes. A NetSuite Customer record can also reference a custom Price Level for default pricing.

The mapping is not one-to-one.

Shopify B2BNetSuite
CompanyParent Customer
Company LocationSub-Customer (with billing/shipping address)
Company Contact (buyer)Contact attached to parent or sub
Catalog assigned to CompanyPrice Level (or Custom Price List) on parent
Catalog override on LocationPrice Level on sub-customer
Payment Terms on LocationTerms on Sales Order, default from sub
Tax exemption on LocationTax Item / Exemption Certificate on sub

"Company Location maps to NetSuite address" is the wrong default. Addresses in NetSuite are dumb records; they cannot carry their own price level, tax exemption, payment terms, or credit limit. Once a Shopify Location differs from its parent on any of those four dimensions, the integration has to promote that Location to a NetSuite sub-customer. The detection logic for that promotion is the most error-prone code in the integration, and the place we have rewritten the most.

// src/mapping/companyHierarchy.ts
type LocationDelta = {
  hasCustomCatalog: boolean;
  hasCustomTerms: boolean;
  hasCustomTaxExempt: boolean;
  hasCustomCreditLimit: boolean;
};
 
export function shouldPromoteToSubCustomer(
  location: ShopifyCompanyLocation,
  company: ShopifyCompany,
): boolean {
  const delta: LocationDelta = {
    hasCustomCatalog: location.catalogId !== company.defaultCatalogId,
    hasCustomTerms: location.buyerExperienceConfig?.paymentTermsTemplate?.id
      !== company.defaultPaymentTermsId,
    hasCustomTaxExempt: location.taxExemptions.length > 0,
    hasCustomCreditLimit: location.creditLimit != null,
  };
  // Any single delta forces a sub-customer; addresses alone do not
  return Object.values(delta).some(Boolean);
}

The output of this function determines whether the integration writes a NetSuite Address record on the parent or creates a sub-customer. We default to the cheaper path (Address on parent) and promote on demand. The reverse (creating sub-customers eagerly and demoting them) makes a mess of NetSuite reporting.

Order sync architecture

The B2B order is not a single event. It is a sequence of state transitions, and the integration has to know where in that sequence to write to NetSuite.

Draft orders versus placed orders

Many B2B orders sit as Shopify Draft Orders for hours or days while a rep adjusts line items, applies a manual discount, or waits for the buyer to confirm a delivery date. The Kensium guide to B2B checkout customization notes that orders placed on net terms generate draft orders the rep reviews before converting. NetSuite has no equivalent intermediate state.

If the integration writes drafts into NetSuite, inventory commits early and the reconciliation job starts flagging orders in NetSuite with no corresponding placed order on Shopify. The fix is a webhook router that branches on event type.

// src/webhooks/router.ts
export async function routeShopifyEvent(event: ShopifyWebhookEvent) {
  switch (event.topic) {
    case 'draft_orders/create':
    case 'draft_orders/update':
      // Stage in our intent ledger only. No NetSuite write.
      return ledger.upsertDraft(event.payload);
    case 'draft_orders/complete':
    case 'orders/create':
      // Now it is a commitment. Promote to NetSuite Sales Order.
      return netsuite.upsertSalesOrder(event.payload);
    case 'orders/paid':
      return netsuite.applyCustomerPayment(event.payload);
    default:
      return ledger.recordUnhandled(event);
  }
}

The intent ledger (a Postgres table we own) holds the draft state so we can answer questions like "is this draft still open?" without round-tripping to Shopify on every webhook.

Net terms and the order-to-cash sequence

A Net 30 B2B order becomes a NetSuite Sales Order with a payment term set, no immediate cash receipt. When the warehouse ships, an Item Fulfillment record is created in NetSuite, and the invoice is generated (automatically or by A/R, depending on the brand's revenue recognition policy). When the buyer pays 30 days later, the integration writes a Customer Payment record and applies it to the open invoice.

That sequence has four NetSuite records for one Shopify order: Sales Order, Item Fulfillment, Invoice, Customer Payment. DTC integrations create one or two. Invoice and Payment had to become first-class operations in the connector, with their own idempotency keys and retry logic. A missed Payment shows up as a hanging A/R balance, not as a duplicate order.

Tax determination

Shopify's B2B Location object carries tax exemption settings, but the actual exemption certificate (the resale certificate the buyer's accountant filed) lives in NetSuite or Avalara. Per Avalara's Exemption Certificate Management documentation, Shopify Plus customers can collect and apply certificates through Avalara, with the determination call reconciled against AvaTax at order time.

The integration has to read the certificate state from the system of truth (NetSuite for brands without Avalara, AvaTax for brands with it) before stamping tax on the draft. We do this as a hook in the draft order flow rather than at order placement, so the rep sees the right tax during draft editing.

// src/tax/exemptionLookup.ts
export async function resolveTaxForDraft(
  draft: ShopifyDraftOrder,
  location: ShopifyCompanyLocation,
): Promise<TaxResolution> {
  if (avalaraEnabled) {
    return avatax.resolve({
      shipTo: location.shippingAddress,
      customerCode: location.netsuiteSubCustomerId,
      lines: draft.lineItems,
    });
  }
  const cert = await netsuite.getActiveExemption(
    location.netsuiteSubCustomerId,
    location.shippingAddress.state,
  );
  return cert ? { taxable: false, certId: cert.id } : await fallbackResolve(draft);
}

A missing certificate is a soft failure. We stamp tax, flag the draft, and surface a "exemption certificate missing for state X" alert to the rep. We do not silently exempt and hope the buyer's accountant catches it.

Inventory commitment

B2B and DTC compete for the same available-to-sell inventory pool. The brand has to choose where in the order lifecycle inventory commits: draft creation, draft completion, order placement, or warehouse pick. There is no universally right answer.

We have seen all four policies. The one that broke the worst was "commit at draft creation" combined with reps who leave drafts open for weeks. Three sales reps held 4,200 units of available-to-sell across their open drafts over a holiday weekend while the DTC site went out of stock on the same SKUs. The brand had to add a draft-aging policy and an automatic release that flushes drafts older than 14 days.

Our default policy: commit on draft completion (the "rep marks ready to send" event), with an aging release at 14 days. The reservation lives in NetSuite as a non-posting Estimate that converts to a Sales Order on placement. More plumbing than a DTC-only build needs, and what makes the warehouse pick sheet trustworthy.

Master data: catalog and pricing sync

The second architectural decision is which system owns the price the buyer sees at checkout. Both Shopify and NetSuite want to own it. They cannot both.

Per the Shopify developer changelog on Catalogs APIs, each Catalog object has a PriceList field that determines price adjustments for eligible contexts, including a CompanyLocation for B2B. The Catalogs API is the surface for managing which products each buyer sees and at what price. NetSuite holds the same information in its Price Levels and the Pricing sublist on item records, accessible via SuiteTalk REST with an expanded sublist.

The pattern that has held up: NetSuite is the authoring source, Shopify B2B Catalog is the projection.

A finance-owned NetSuite Custom Price List per company (or per tier of companies) is the canonical price. A sync job publishes deltas from NetSuite to the Shopify B2B Catalog API. An idempotent nightly reconciliation job catches drift between the two systems and writes corrections only in the NetSuite-to-Shopify direction.

// src/sync/pricelistPublisher.ts
export async function publishPriceListDelta(
  netsuitePriceListId: string,
  shopifyCatalogId: string,
): Promise<PublishResult> {
  const since = await ledger.lastPublishedAt(netsuitePriceListId);
  const changes = await netsuite.priceListChangesSince(netsuitePriceListId, since);
  const idempotencyKey = `pricelist-${netsuitePriceListId}-${since.toISOString()}`;
 
  const result = await shopify.catalogPriceListUpdate({
    catalogId: shopifyCatalogId,
    priceListUpdates: changes.map(toShopifyPriceUpdate),
    idempotencyKey,
  });
  await ledger.recordPublish(netsuitePriceListId, result.runAt, result.applied);
  return result;
}

The idempotency key is derived from the source price list and the watermark timestamp, not generated per call. A retried run with the same watermark produces the same key and is a no-op on the Shopify side.

Pricing edited in both Shopify and NetSuite. Reps adjust catalog prices in the Shopify admin to win deals. Finance updates NetSuite price levels for revenue recognition. The two diverge within weeks. Reconciliation requires a human comparing exports.

NetSuite is the authoring source. Reps cannot edit the Shopify B2B Catalog directly; they request a price change in the rep tool, it routes to finance, and finance updates NetSuite. The sync publishes the change to Shopify within minutes. One system holds the truth.

SKU master data follows the same pattern: when finance creates a new item in NetSuite, the publisher pushes the item, its catalog membership, and its initial price list entry to Shopify. The publish lag (5 to 15 minutes on our builds, depending on cron cadence) is the window where a buyer can ask for a SKU that exists in NetSuite but not yet on Shopify. We surface the lag in the rep tool.

Customer hierarchy sync

The third architectural decision is who creates the Company and who creates the NetSuite Customer.

Go-to-market dictates the direction. Sales-led brands create the Company in NetSuite first: the deal closes, the customer is provisioned in NetSuite, the integration creates a matching Shopify Company and invites the buyer to the customer portal. Self-serve brands let the buyer sign up through the Shopify B2B portal: Shopify creates the Company, the integration creates the NetSuite Customer and routes the account to finance for credit review. We have built both. They are different integrations.

For sales-led, the NetSuite-to-Shopify direction uses the flow described in the Shopify Help Center documentation on adding B2B customers. The integration calls the GraphQL companyCreate mutation, attaches the location, creates the Company Contact, and triggers the customer account invite.

For self-serve, the buyer-driven signup creates a partial Company in Shopify. The integration writes a NetSuite Customer with status "Pending Credit Review" and a $0 credit limit. Until finance approves, the buyer can browse the catalog but cannot place a net-terms order. They can still pay by card.

The merge problem

The case that broke the first build: a buyer placed three DTC orders on her personal email before her employer was added as a B2B customer. Her DTC customer record in Shopify and her new B2B contact under the company were two different records. NetSuite likewise had two. Lifetime value reporting summed her wrong.

There is no clean automated answer. The merge requires confirming intent (is this really the same person; does she want her DTC orders associated with the company?) and propagating across four systems (Shopify customer accounts, NetSuite, the analytics warehouse, the email platform). We built a manual reconciliation queue with a "propose merge" surface in the rep tool. Finance approves, the integration does the four-system write, and the audit log records who approved it. We did not solve this. We built a workflow for finance to solve it themselves.

What broke

Three specific failure modes from real builds that the architecture above is shaped around.

The 2024 Customer Accounts migration. Shopify migrated B2B from the classic customer accounts to the new customer accounts model in 2024. The buyer object schema changed, the login flow changed, and integrations built against the legacy classic accounts started receiving payloads with missing or relocated fields. One brand we were supporting had a working integration that broke on a Friday afternoon when their Shopify store auto-migrated. The webhook payloads were valid, the integration's mapper silently dropped fields it could no longer find, and three days of B2B orders synced to NetSuite without the company association. Catching this required a schema validator on every webhook payload that fails loudly on unknown shape changes rather than coercing them through.

SuiteTalk REST gaps. SuiteTalk REST does not support every customer hierarchy operation we need. Promoting an Address to a sub-customer, attaching a contact to a sub-customer while preserving the parent relationship for credit, and applying a custom price list to a sub-customer's order line are operations that are either not in REST or have undocumented edge cases. Tim Dietrich's SuiteTalk REST overview documents the broader gap. Our workaround was a small set of SuiteScript RESTlets exposing the missing operations, hosted in the client's NetSuite account. The integration is now a hybrid of REST and RESTlet calls, and the deployment runbook includes a SuiteScript bundle install we did not plan for.

Revenue recognition timing. A brand using NetSuite's advanced revenue management module wanted B2B Net 60 invoices to recognize revenue at fulfillment, not at invoice creation. The default rule on a Sales Order line is stamped from the item and triggers at invoicing. Changing it globally would have moved the DTC behavior too, which finance did not want. The integration had to stamp a custom Revenue Element on each B2B order line that overrode the default to "Recognize at Fulfillment." We caught it in the first month of UAT, after the controller saw revenue posting on the day the rep generated the invoice and asked "why?"

What this architecture enabled

  • B2B and DTC orders flowed through the same Shopify Plus store and landed in the same NetSuite ledger without manual reclassification.
  • Finance month-end review for the B2B side dropped from roughly two days to three hours, the time mostly spent on the merge queue and on revenue rec edge cases the integration deliberately escalates rather than auto-resolves.
  • New B2B companies onboarded in under 30 minutes (from sales-led NetSuite creation to a buyer email invite) instead of the previous two-day manual setup.
  • The brand added a second sales channel (a wholesale rep app built on top of the same intent ledger) without forking the integration. The ledger held the canonical state; the rep app was a new producer of intents into the same pipeline.
  • Inventory commitment policy was tunable per channel. DTC committed at order placement; B2B committed at draft completion; warehouse staff stopped seeing phantom commits.

The integration that survives a year is the one where the company hierarchy and the price-list ownership were settled in week one, not the one with the cleanest webhook code.

Observations

What worked. Treating the Shopify B2B Catalog as a projection of NetSuite price lists, not as an independent system, eliminated drift. Owning our own intent ledger (Postgres, keyed on Shopify draft and order IDs) gave us a place to hold draft state without round-tripping to either Shopify or NetSuite for every webhook. Promoting Company Locations to NetSuite sub-customers only when a delta forced it kept the NetSuite customer list legible to finance. Routing draft events to the ledger and only placed orders to NetSuite kept the inventory and revenue numbers honest.

What didn't. Trying to support both "NetSuite is the source for new companies" and "Shopify is the source for new companies" in a single integration. The two directions have different validation rules, different credit review workflows, and different failure modes. We split them into two code paths feeding the same downstream pipeline. Trying to auto-merge DTC and B2B customer records using heuristic matching (same email domain, same shipping address). The false positive rate was unacceptable and finance lost trust in the integration. Manual merge with an audit log is slower but correct. Connector picks made under time pressure are worth revisiting. Some of the constraints we hit on Celigo Integration App for the B2B side could have been predicted by reading the Celigo vs Boomi vs custom build comparison before signing, not after.

What we'd do differently. We would build the price-list publisher and the customer hierarchy sync before the order sync, on every project. The order sync is satisfying to build and demos well. The price-list and hierarchy syncs are what determine whether the order sync produces correct revenue. Starting with orders first means a week of feeling productive followed by six weeks of finding out that the customer mapping was wrong. We would also stand up the SuiteScript RESTlet bundle in week one of the build, even before we knew exactly which operations it would expose, because the NetSuite admin install path is the longest pole and is always blocked on someone other than us.

References

  1. Shopify Help Center, "Overview of B2B features on Shopify," Shopify documentation, 2026. https://help.shopify.com/en/manual/b2b/getting-started/features
  2. Shopify Help Center, "Creating and managing B2B customers using companies," Shopify documentation, 2026. https://help.shopify.com/en/manual/b2b/companies-and-customers/creating-companies
  3. Shopify Developer Platform, "Company object, GraphQL Admin API," 2026. https://shopify.dev/docs/api/admin-graphql/latest/objects/Company
  4. Shopify Developer Changelog, "Introducing new Catalogs APIs to manage pricing and product publishing for different customers," 2024. https://shopify.dev/changelog/introducing-new-catalogs-apis-to-manage-pricing-and-product-publishing-for-different-customers
  5. Oracle NetSuite, "SuiteTalk REST Web Services API Guide," NetSuite documentation, 2026. https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/book_1559132836.html
  6. Oracle NetSuite, "Price Level record," NetSuite documentation, 2026. https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/article_0718033232.html
  7. Avalara, "Exemption Certificate Management," Avalara product documentation, 2026. https://www.avalara.com/us/en/products/sales-and-use-tax/compliance-document-management.html
  8. Tim Dietrich, "NetSuite SuiteTalk REST Overview, Common Issues, and Advice," 2024. https://timdietrich.me/blog/netsuite-suitetalk-rest-overview-issues-advice/
  9. Kensium, "Shopify B2B Checkout Customization: Net Terms and POs," 2026. https://www.kensium.com/blog/shopifyplus-b2b-checkout-customization

Stack: Node.js 20, TypeScript, Shopify Admin API 2026-04 (GraphQL plus REST for the B2B Catalogs surface), NetSuite SuiteTalk REST with a small SuiteScript RESTlet bundle for the operations REST does not expose, Postgres for the intent ledger and the publish watermarks, deployed on Google Cloud Run with Cloud Scheduler for the price-list publisher and the nightly reconciliation job. Customers were mid-market Shopify Plus brands running mixed DTC and wholesale, with annual revenue in the $25-65M range and B2B as 20-30% of that mix.


Want to build something like this?

We design and ship AI products, automation systems, and custom software.

Get in touch

Related