Skip to main content
The Sync Server keeps a Shopify store and a NetSuite account agreeing with each other. This guide walks one tenant — acme-outdoors — from nothing to a working loop: Shopify orders land in the tenant’s order book, pending orders become NetSuite sales orders, NetSuite quantities are written back to Shopify, and Shopify webhooks replace polling. Every call is tenant-scoped and every success is wrapped in { "meta": { "requestId" }, "data": … }; the examples show data only. See the API basics for the envelope and error codes. Before you start you need a tenant id and its key (x-stateset-api-key), and the tenant must have its shopify and netsuite integrations configured — Shopify store domain and Admin API token, NetSuite SuiteTalk host, realm and token-based auth credentials, plus the NetSuite defaults the mapping falls back to (default_customer_id, default_subsidiary_id, default_location_id). Configuration happens in the dashboard or the tenant config, not through this API.
1

Confirm both integrations are live

Every sync route returns 424 integration_missing when the tenant lacks the connector it needs, so check first. The health route calls each configured system rather than reading a cached flag.
Anything other than healthy for these two — unhealthy with a details string, or disabled — is a credentials or configuration problem; no amount of retrying a job gets past it.
2

Pull the first batch of Shopify orders

The Shopify sync reads orders from the store and creates them in the tenant’s order book. By default it fetches paid, unfulfilled orders (tenant config widens that with sync_financial_status, sync_fulfillment_status, sync_order_status, sync_limit) and keeps a since_id checkpoint so each run picks up where the last stopped. Query parameters override the filters for one run — useful for a bounded backfill. Ingest is idempotent on the Shopify order id, so re-running over the same window reports duplicates rather than creating orders twice.
The route answers 202 immediately — the work happens in a background job.
3

Watch the job

Poll the job until state leaves queued/running. The result is whatever the job type produces — for the Shopify sync, a count summary plus the checkpoint it advanced to.
The orders are now in the order book. Look one up by its Shopify id — netsuiteId is still empty, which is exactly what the next step consumes.
4

Push pending orders to NetSuite

The NetSuite sync takes every order in the book with no netsuiteId that is not cancelled and not already mid-submission, and creates a NetSuite sales order for each — up to 200 per run. The Shopify order id becomes the sales order’s externalId, so a submission NetSuite already accepted is recovered by lookup on retry instead of duplicated. Lines map to NetSuite items by SKU as the item’s external id (or an explicit NetSuite item id on the line); a line with no SKU fails the order.
Watch the job the same way. A clean run finishes succeeded with:
and GET /orders/{order_id} on each synced order now carries its NetSuite identity — netsuiteId (the internal record id), netsuiteTranId (the document number) and netsuiteSyncedAt.
If any order in the batch fails, the job ends failed with the first failure in error, but the orders that did sync stay synced — synced and failed in the result say how far it got. A failed order records netsuiteLastError and netsuiteLastAttemptAt, and is skipped by the next run for five minutes so NetSuite is not hammered with the same bad payload.
5

Send inventory back to Shopify

Inventory flows the other way: the sync reads available quantities from NetSuite and writes them to Shopify inventory levels for the matching SKUs. Limit it to source=netsuite so it does not also run any other inventory connectors the tenant has configured.
The job’s result reports direction and counts; errors, when present, is one string per SKU that could not be written (typically a SKU Shopify does not know).
6

Handle a failed job

A job moves queued → running → succeeded | failed. When a run fails and retryCount is below maxRetries (3 by default) the server parks it in retry_pending and retries on its own with exponential backoff — 2, 4, then 8 minutes — with the next attempt in nextRetryAt. Only after the retries are exhausted does it settle as failed. List those:
Read the error, fix the cause (a NetSuite item external id that does not match the SKU, an expired token, a missing default location), then requeue. A manual retry resets retryCount to 0, so the job gets a fresh set of automatic retries.
Retry is only accepted for failed or cancelled jobs — anything else returns 409 job_not_retryable. Its mirror, POST /jobs/{job_id}/cancel, only accepts queued or retry_pending jobs; a running job has to finish. For a fleet view use GET /jobs/summary, which returns per-state counts and the age of the oldest pending job.
7

Receive Shopify webhooks instead of polling

Polling catches up on a schedule; webhooks make new orders arrive within seconds. One call registers the standard topics (orders/create, orders/updated, orders/cancelled, orders/fulfilled, orders/paid, fulfillments/create, fulfillments/update) with Shopify, all pointing at {baseUrl}/v1/tenants/acme-outdoors/webhooks/shopify.
The receiving route is public — Shopify does not carry your tenant key — and is authenticated by verifying the X-Shopify-Hmac-SHA256 header against the raw request body with the tenant’s shopify.webhook_secret (your Shopify app’s API secret key). A missing or invalid signature is rejected with 422 before anything is stored, so configure the secret before you register. An orders/create or orders/paid delivery creates the order in the book exactly as the polling sync would; the next NetSuite sync picks it up. Every accepted delivery is recorded and processed asynchronously — watch them arrive:
Processing retries on its own up to maxAttempts (5) with a doubling delay starting at 30 seconds. An event that ends failed keeps its processingError; once you have fixed the cause, replay it rather than asking Shopify to resend:
8

Put the pushes on a schedule

Webhooks bring orders in, but the NetSuite push and the inventory write-back are still on-demand jobs. Schedule them so the loop runs without you — each run creates a normal job you can find in GET /jobs with its scheduleId set. Use everySeconds for an interval or cronExpression (six fields, seconds first) for cron; repeat with "jobType": "inventory_sync" for the write-back.
The 201 returns the schedule with its scheduleId, nextRunAt, and a consecutiveFailures counter. A schedule that keeps failing is disabled once that counter reaches maxConsecutiveFailures, with the reason in lastError — so a broken credential does not produce a failed job every five minutes forever.

Troubleshooting

The default filters only fetch paid, unfulfilled orders, and the since_id checkpoint only moves forward. Check result.options on the job for the filters that were applied, then widen them per run (financial_status, fulfillment_status, status, updated_at_min) or change the tenant’s sync_* defaults.
The run only selects orders with no netsuiteId, not cancelled, not flagged in flight, and whose last NetSuite attempt is more than five minutes old. Read the order: netsuiteLastError says why the last attempt failed, and a recent netsuiteLastAttemptAt means it is in cooldown.

What you built

Next steps

Jobs reference

Every filter on the job list, the summary and throughput helpers, and cancel.

Webhook events reference

Filtering by topic prefix and Shopify order id, bulk replay, and the summary counts.

Troubleshooting a stuck order

Circuit breakers, upstream limits, and the errors you should not retry.

Drive it from an agent

The same operations as MCP tools, with per-tenant read-only mode and blocklists.