This playbook covers the most common SMB ecommerce integration pattern: Shopify as the storefront/checkout platform with Oracle NetSuite as the ERP for financials, inventory, fulfillment, and accounting. It applies to all Shopify plans (Basic through Plus) and all NetSuite editions (Standard through SuiteCloud Plus), though Plus/SuiteCloud Plus unlock higher API limits. The playbook covers three middleware approaches: Celigo (pre-built connector), Oracle's native NetSuite Connector for Shopify, and custom middleware (Node.js/Python).
| System | Role | API Surface | Direction |
|---|---|---|---|
| Shopify | Storefront, checkout, customer-facing | REST Admin API, GraphQL Admin API, Webhooks | Outbound (orders, customers) + Inbound (inventory, fulfillment) |
| Oracle NetSuite | ERP — financials, inventory, fulfillment, accounting | SuiteTalk REST, SuiteTalk SOAP, RESTlet, SuiteQL | Inbound (orders) + Outbound (inventory, fulfillment) |
| Celigo / Custom Middleware | Integration orchestrator | Pre-built flows or custom API client | Bidirectional orchestrator |
| API Surface | Protocol | Best For | Max Records/Req | Rate Limit | Real-time? | Bulk? |
|---|---|---|---|---|---|---|
| Shopify REST Admin API | HTTPS/JSON | Orders, products, customers, inventory | 250 per page | 40 req/min (Plus: 400) | Yes | No |
| Shopify GraphQL Admin API | HTTPS/GraphQL | Complex queries, bulk ops, mutations | Paginated | 50 pts/s (Plus: 500) | Yes | Yes |
| Shopify Webhooks | HTTPS POST | Real-time event notifications | 1 event/delivery | At-least-once; 19 retries | Yes | N/A |
| NetSuite SuiteTalk REST | HTTPS/JSON | CRUD on standard records, SuiteQL | 1,000 per page | 5-55 concurrent | Yes | No |
| NetSuite SuiteTalk SOAP | HTTPS/XML | Legacy CRUD, upsertList batch writes | 1,000 per upsertList | Shares concurrency pool | Yes | Semi |
| NetSuite RESTlet | HTTPS/JSON | Custom endpoints, business logic | Custom | 5 concurrent/user | Yes | Custom |
| NetSuite SuiteQL | HTTPS/JSON | Ad hoc queries, reporting | 100,000 rows | Shares concurrency pool | No | Query-only |
| Limit Type | Value | Applies To | Notes |
|---|---|---|---|
| Max records per REST page | 1,000 | NetSuite SuiteTalk REST | Use offset/limit pagination |
| Max records per SOAP upsertList | 1,000 | NetSuite SuiteTalk SOAP | Split larger batches |
| Max items per Shopify REST page | 250 | Shopify REST Admin API | Use cursor-based pagination |
| Max SuiteQL rows | 100,000 | NetSuite SuiteQL | Paginate with OFFSET |
| Shopify webhook response timeout | 5 seconds | Shopify Webhooks | Return 200 immediately, process async |
| NetSuite payload limit per RESTlet | 10 MB | NetSuite RESTlet | Chunk larger payloads |
| Limit Type | Value | Window | Edition Differences |
|---|---|---|---|
| Shopify REST requests | 40 per app per store | Per minute (2/s refill) | Plus: 400/min (20/s refill) |
| Shopify GraphQL cost | 50 points/s (1,000 bucket) | Per second | Advanced: 100 pts/s; Plus: 500 pts/s |
| NetSuite concurrent requests | 5 (base) | Per account, real-time | +10 per SuiteCloud Plus license; Tier 5: 55 |
| NetSuite frequency limit | Account-specific | 60s + 24h windows | Varies by account tier |
| Shopify webhook retries | 19 retries | 48 hours | Exponential backoff by Shopify |
| Limit Type | Per-Transaction Value | Notes |
|---|---|---|
| NetSuite SuiteScript governance units | 1,000 (client), 10,000 (server) | User Event Scripts on order creation consume from this pool |
| NetSuite search results per page | 1,000 | Use pagination for larger result sets |
| NetSuite sublist line limit | ~4,000 lines | Orders with more line items must be split |
| NetSuite concurrent web service users | Account-wide shared pool | One slow integration blocks all others |
| Flow | System | Use When | Token Lifetime | Refresh? | Notes |
|---|---|---|---|---|---|
| Token-Based Auth (TBA) | NetSuite | Server-to-server (recommended) | Indefinite | No (static) | Consumer key/secret + token ID/secret |
| OAuth 2.0 | NetSuite | Modern integrations, granular scopes | 60 min | Yes | Newer; not all SuiteApps support it yet |
| OAuth 2.0 | Shopify | Public apps, app store distribution | Varies | Yes | Standard OAuth 2.0 authorization code flow |
| Custom App API Key | Shopify | Private/custom apps (most integrations) | Indefinite | No | Admin API access token from Shopify admin |
START — Integrate Shopify storefront with NetSuite ERP
├── What's your order volume?
│ ├── < 500 orders/day
│ │ ├── Budget < $500/month? → NetSuite Connector for Shopify (~$200/mo)
│ │ └── Need customization? → Celigo Standard ($500-1,000/mo)
│ ├── 500-5,000 orders/day
│ │ ├── Standard flows sufficient? → Celigo Premium ($1,000-2,000/mo)
│ │ └── Complex custom logic? → Custom middleware (Node.js/Python)
│ └── > 5,000 orders/day
│ ├── Requires Shopify Plus (higher API limits)
│ └── Celigo Enterprise or custom high-throughput middleware
├── Which data flows?
│ ├── Orders only (Shopify → NetSuite) → Webhook: orders/create
│ ├── Orders + Inventory → Webhooks + scheduled sync (5-15 min)
│ ├── Full lifecycle → 5 flows: product, inventory, order, fulfillment, returns
│ └── Multi-store? → Celigo cloning or per-store tokens
├── Inventory sync strategy?
│ ├── Near-real-time (< 5 min) → Saved Search + scheduled SuiteScript
│ ├── Batch (15-60 min) → SuiteQL + Shopify GraphQL bulk
│ └── On-demand → Not recommended (race conditions)
└── Error handling?
├── Zero-loss → DLQ + retry + alerting
└── Best-effort → Retry 3x + log + manual review
| Step | Source | Action | Target | Data Objects | Failure Handling |
|---|---|---|---|---|---|
| 1. Product Sync | NetSuite | Item created/updated → push | Shopify | Item → Product (title, price, variants, SKUs) | Retry 3x; log SKU mismatches |
| 2. Inventory Sync | NetSuite | Scheduled: available qty per location | Shopify | Inventory Item → Inventory Level | Alert if delta > 10% |
| 3. Order Import | Shopify | Webhook: orders/create | NetSuite | Order → Sales Order | DLQ + retry; create customer if missing |
| 4. Customer Sync | Shopify | New customer → upsert | NetSuite | Customer → Customer Record | Dedup by email; merge if existing |
| 5. Payment Capture | Shopify | Payment captured → record | NetSuite | Transaction → Customer Payment | Alert on amount mismatch |
| 6. Fulfillment Pushback | NetSuite | Item Fulfillment → push tracking | Shopify | Fulfillment (tracking, carrier) | Retry 3x; alert on 422 |
| 7. Returns/Refunds | Shopify | Refund created → credit memo | NetSuite | Refund → Credit Memo / RA | Manual review if partial |
| 8. Financial Close | NetSuite | Reconcile payouts vs deposits | NetSuite | Payout → Bank Deposit | Flag variances |
Create a dedicated integration user in NetSuite with minimum required permissions. Never use an admin account for API access. [src1]
# NetSuite Setup (via UI):
# 1. Setup > Company > Enable Features > SuiteCloud:
# Enable Token-Based Authentication + SuiteTalk + REST Web Services
# 2. Setup > Integration > Manage Integrations > New:
# Name: "Shopify Integration" / TBA: enabled
# Record Consumer Key and Consumer Secret
# 3. Create integration role with minimum permissions:
# Transactions: Sales Order, Item Fulfillment, Credit Memo, Customer Payment
# Lists: Items, Customers, Locations
# 4. Create integration employee with this role
# 5. Generate Access Token (Token ID + Token Secret)
Verify: Setup > Integration > Integration Governance shows the new integration with 0 active connections.
Create a custom app in Shopify Admin for API credentials. [src2]
# Shopify Admin > Settings > Apps > Develop apps > Create app:
# Admin API scopes: read_orders, write_orders, read_products, write_products,
# read_inventory, write_inventory, read_customers, write_customers,
# read_fulfillments, write_fulfillments, read_locations
# Install app → generate Admin API access token (shown once)
# Register webhooks: orders/create, orders/updated, refunds/create,
# customers/create, customers/update
Verify: curl -H "X-Shopify-Access-Token: {token}" https://{store}.myshopify.com/admin/api/2025-01/shop.json returns 200.
Shopify webhook fires on order creation; middleware transforms and writes Sales Order to NetSuite via SuiteTalk REST. [src3, src7]
// Transform Shopify order → NetSuite Sales Order
function transformOrderToSalesOrder(shopifyOrder) {
return {
externalId: `SHOP-${shopifyOrder.id}`, // Idempotent upsert key
entity: { externalId: `SHOPCUST-${shopifyOrder.customer?.id || shopifyOrder.email}` },
tranDate: shopifyOrder.created_at.split('T')[0],
memo: `Shopify #${shopifyOrder.order_number}`,
item: {
items: shopifyOrder.line_items.map(li => ({
item: { externalId: li.sku },
quantity: li.quantity,
rate: parseFloat(li.price),
amount: parseFloat(li.price) * li.quantity
}))
},
custbody_shopify_order_id: String(shopifyOrder.id)
};
}
Verify: Test order in Shopify dev store appears in NetSuite > Transactions > Sales Orders with correct line items.
NetSuite is the source of truth. Query available quantities and push to Shopify. Run every 5-15 minutes. [src3, src4]
// Scheduled inventory sync: NetSuite SuiteQL → Shopify inventory_levels/set
async function syncInventory(netsuiteClient, shopifyClient) {
const items = await netsuiteClient.suiteql(`
SELECT i.itemId, i.externalId, il.quantityAvailable, il.location
FROM item i JOIN inventoryBalance il ON i.id = il.item
WHERE i.isInactive = 'F'`);
for (const item of items) {
await shopifyClient.post('/admin/api/2025-01/inventory_levels/set.json', {
location_id: locationMap[item.location],
inventory_item_id: await lookupShopifyId(item.externalId),
available: Math.max(0, item.quantityAvailable)
});
}
}
Verify: Change quantity in NetSuite; after sync cycle, Shopify Admin > Products > Inventory matches.
When NetSuite creates an Item Fulfillment, push tracking info back to Shopify. [src3, src5]
// Push fulfillment tracking to Shopify
async function pushFulfillment(fulfillment, shopifyClient) {
await shopifyClient.post(
`/admin/api/2025-01/orders/${fulfillment.shopifyOrderId}/fulfillments.json`,
{ fulfillment: {
tracking_number: fulfillment.trackingNumber,
tracking_company: mapCarrier(fulfillment.shipMethod),
notify_customer: true
}}
);
}
Verify: Test Item Fulfillment in NetSuite; Shopify order shows fulfillment status and tracking URL.
Refunds in Shopify generate Credit Memos in NetSuite. [src3, src5]
// Webhook: refunds/create → NetSuite Credit Memo
function transformRefundToCreditMemo(refund) {
return {
externalId: `SHOPREFUND-${refund.id}`,
createdFrom: { externalId: `SHOP-${refund.order_id}` },
item: { items: refund.refund_line_items.map(rli => ({
item: { externalId: rli.line_item.sku },
quantity: rli.quantity,
rate: parseFloat(rli.line_item.price)
}))}
};
}
Verify: Test refund in Shopify; NetSuite Credit Memo appears with matching amounts.
# Input: NetSuite inventory levels (from SuiteQL)
# Output: Shopify inventory updates via GraphQL mutation
import requests, time
GRAPHQL_URL = f"https://{STORE}.myshopify.com/admin/api/2025-01/graphql.json"
HEADERS = {"X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json"}
def bulk_set_inventory(updates):
mutation = """
mutation inventorySetQuantities($input: InventorySetQuantitiesInput!) {
inventorySetQuantities(input: $input) {
inventoryAdjustmentGroup { reason }
userErrors { field message }
}
}"""
for update in updates:
variables = {"input": {"name": "available", "reason": "correction",
"quantities": [{"inventoryItemId": update["iid"],
"locationId": update["lid"], "quantity": update["qty"]}]}}
resp = requests.post(GRAPHQL_URL, json={"query": mutation,
"variables": variables}, headers=HEADERS)
if resp.status_code == 429:
time.sleep(2)
time.sleep(0.2) # Respect 50 pts/s rate limit
# Test Shopify Admin API
curl -H "X-Shopify-Access-Token: {token}" \
"https://{store}.myshopify.com/admin/api/2025-01/shop.json"
# Check: X-Shopify-Shop-Api-Call-Limit: 1/40
# Test NetSuite REST API (TBA)
curl -X GET \
"https://{account}.suitetalk.api.netsuite.com/services/rest/record/v1/metadata-catalog" \
-H "Authorization: OAuth realm=\"{account}\", ..." \
-H "Content-Type: application/json"
# Expected: 200 OK with record type catalog
| Shopify Field | NetSuite Field | Type | Transform | Gotcha |
|---|---|---|---|---|
| order.id | salesOrder.externalId | String | Prefix "SHOP-" | Must be unique per record type |
| order.order_number | salesOrder.otherRefNum | String | Direct | Human-readable # (e.g., #1001) |
| order.customer.id | customer.externalId | String | Prefix "SHOPCUST-" | Guest checkout may lack customer.id |
| order.customer.email | customer.email | Direct | NS enforces unique email per customer | |
| line_items[].sku | item[].item.externalId | String | Case-sensitive | #1 failure cause: SKU mismatch |
| line_items[].price | item[].rate | Decimal | parseFloat() | Shopify stores as string |
| shipping_address | shipAddress | Object | Map subfields | province_code vs state abbreviation |
| total_tax | taxTotal | Decimal | Use ONE source | Dual tax calculation = #2 failure |
| variants[].sku | item.itemId | String | Case-sensitive | Variant ≠ Item; use Matrix Items |
| fulfillment.tracking_number | itemFulfillment.trackingNumber | String | Direct | NS may concatenate multiple numbers |
| Code | System | Meaning | Resolution |
|---|---|---|---|
| 429 | NetSuite REST | Too Many Requests (concurrency exceeded) | Exponential backoff: 2^n seconds, max 60s |
| SSS_REQUEST_LIMIT_EXCEEDED | NetSuite SOAP | Governance limit hit | Queue requests; implement connection pooling |
| INVALID_KEY_OR_REF | NetSuite | ExternalId reference not found | Create referenced record first, then retry |
| UNIQUE_CUST_EMAIL | NetSuite | Duplicate customer email | Lookup existing customer; update not create |
| 422 | Shopify | Unprocessable Entity (bad payload) | Check response body for field-level errors |
| 429 | Shopify | Rate limit exceeded | Wait per Retry-After header |
Increase sync frequency during sales; implement safety stock buffer; use Shopify inventory reservations. [src3]Durable message queue (SQS/Redis) between webhooks and NetSuite; controlled concurrency. [src6, src8]Normalize all SKUs to uppercase; daily reconciliation script. [src4]Match on email first; create only if no match; weekly dedup. [src4]Daily reconciliation job comparing Shopify orders vs NetSuite Sales Orders. [src2]Use one tax engine for both systems; disable the other's calculation. [src4]// BAD — wastes API calls (40/min limit); introduces 30s+ latency
setInterval(async () => {
const orders = await shopify.get('/orders.json', {created_at_min: lastPoll});
for (const order of orders) await importToNetSuite(order);
}, 30000);
// GOOD — instant delivery, no wasted API calls
app.post('/webhooks/orders/create', async (req, res) => {
if (!verifyHMAC(req)) return res.status(401).send('Invalid');
res.status(200).send('OK'); // Return within 5s
await orderQueue.enqueue(req.body); // Process async
});
// BAD — webhook retry creates duplicate Sales Orders
await netsuite.post('/salesOrder', transformOrder(shopifyOrder));
// GOOD — idempotent: same Shopify order always maps to same record
salesOrder.externalId = `SHOP-${shopifyOrder.id}`;
await netsuite.put(`/salesOrder/eid:SHOP-${shopifyOrder.id}`, salesOrder);
// BAD — infinite loops and race conditions
shopifyWebhook('inventory/update', (e) => netsuite.update(e));
netsuiteSchedule(() => shopify.bulkUpdate(netsuite.getLevels()));
// GOOD — NetSuite is single source of truth
async function scheduledSync() {
const levels = await netsuite.suiteql('SELECT ... FROM inventoryBalance ...');
for (const item of levels) {
await shopify.setInventoryLevel(item.id, item.location, item.qty);
}
}
Store configuration registry with per-store token, location map, and SKU prefix. [src4]Use SuiteQL for all new integrations; standard SQL pagination (OFFSET/FETCH). [src1]Return 200 immediately; process async; implement idempotency via ExternalId. [src2]Subscribe to draft_orders/update; transform separately. [src2]Load-test with realistic volumes; request matching concurrency from Oracle. [src6]Use ExternalId for all lookups; maintain registry per environment. [src1]# Check Shopify API rate limit usage
curl -s -I -H "X-Shopify-Access-Token: {token}" \
"https://{store}.myshopify.com/admin/api/2025-01/shop.json" | \
grep -i "x-shopify-shop-api-call-limit"
# Expected: X-Shopify-Shop-Api-Call-Limit: 1/40
# List Shopify webhook subscriptions
curl -H "X-Shopify-Access-Token: {token}" \
"https://{store}.myshopify.com/admin/api/2025-01/webhooks.json"
# NetSuite concurrency monitoring
# Setup > Integration > Integration Governance
# Monitor: Active Sessions, Peak Concurrency, Queue Depth
# Verify NetSuite item exists by ExternalId
# GET /services/rest/record/v1/inventoryItem/eid:{externalId}
# Reconcile order counts
# Shopify: GET /admin/api/2025-01/orders/count.json?created_at_min=2026-03-01
# NetSuite: SuiteQL: SELECT COUNT(*) FROM transaction
# WHERE externalId LIKE 'SHOP-%' AND tranDate >= '2026-03-01'
| Component | Version | Release Date | Status | Breaking Changes |
|---|---|---|---|---|
| NetSuite 2025.1 | SuiteTalk REST + SOAP | 2025-02 | Current | REST API record-level filtering |
| NetSuite 2024.2 | SuiteTalk REST + SOAP | 2024-08 | Supported | REST API expanded record types |
| Shopify 2025-01 | REST + GraphQL | 2025-01 | Current | Several REST endpoints deprecated |
| Shopify 2024-10 | REST + GraphQL | 2024-10 | Supported | Inventory API v2 |
| Shopify 2024-04 | REST + GraphQL | 2024-04 | Supported | Fulfillment Orders API required |
| Celigo App | SaaS (continuous) | Ongoing | Current | Check release notes monthly |
| Use When | Don't Use When | Use Instead |
|---|---|---|
| SMB/mid-market DTC with Shopify + NetSuite | Enterprise 50K+ orders/day needing sub-second sync | Custom event-driven architecture (Kafka/SQS) |
| Standard order-to-cash flow | Complex B2B pricing with contract rates in NetSuite | NetSuite SuiteCommerce or Shopify B2B with custom pricing |
| 1-10 Shopify stores, shared NetSuite | Multi-subsidiary with intercompany transactions | Custom middleware with subsidiary routing |
| Standard catalog (< 50K SKUs) | Complex BOM/kit assembly configurator | NetSuite Advanced Manufacturing + custom CPQ |