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 Anything other than
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.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
The route answers
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.202 immediately — the work happens in a background job.3
Watch the job
Poll the job until The orders are now in the order book. Look one up by its Shopify id —
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.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 Watch the job the same way. A clean run finishes and
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.succeeded with:GET /orders/{order_id} on each synced order now carries its NetSuite identity —
netsuiteId (the internal record id), netsuiteTranId (the document number) and
netsuiteSyncedAt.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 The job’s
source=netsuite so it does not
also run any other inventory connectors the tenant has configured.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 Read the Retry is only accepted for
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: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.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 (The receiving route is public — Shopify does not carry your tenant key — and is authenticated
by verifying the Processing retries on its own up to
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.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: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
The
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.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 Shopify sync succeeds with fetched: 0
The Shopify sync succeeds with fetched: 0
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.An order never gets picked up by the NetSuite sync
An order never gets picked up by the NetSuite sync
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.