> ## Documentation Index
> Fetch the complete documentation index at: https://docs.stateset.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Sync a Shopify store with NetSuite

> From a tenant API key to orders flowing Shopify → NetSuite and inventory flowing back — trigger the syncs, watch the jobs, recover a failed one, and receive Shopify webhooks.

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](/stateset-sync-server-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.

```bash theme={null}
export SYNC="https://api.sync.stateset.com/v1/tenants/acme-outdoors"
export SYNC_KEY="ss_sync_…"
```

<Steps>
  <Step title="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.

    ```bash theme={null}
    curl "$SYNC/integrations/health" --header "x-stateset-api-key: $SYNC_KEY"
    ```

    ```json theme={null}
    [
      { "id": "shopify",  "name": "Shopify",  "status": "healthy", "latencyMs": 312 },
      { "id": "netsuite", "name": "NetSuite", "status": "healthy", "latencyMs": 884 }
    ]
    ```

    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.
  </Step>

  <Step title="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.

    ```bash theme={null}
    curl --request POST "$SYNC/sync/shopify?updated_at_min=2026-08-01T00:00:00Z&limit=250" \
      --header "x-stateset-api-key: $SYNC_KEY"
    ```

    The route answers `202` immediately — the work happens in a background job.

    ```json theme={null}
    {
      "jobId": "6d1f0c2e-3b0a-4f7e-9a51-2c8f4a0d7b11",
      "jobType": "shopify_sync",
      "status": "queued",
      "createdAt": "2026-08-31T14:02:11Z"
    }
    ```
  </Step>

  <Step title="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.

    ```bash theme={null}
    curl "$SYNC/jobs/6d1f0c2e-3b0a-4f7e-9a51-2c8f4a0d7b11" --header "x-stateset-api-key: $SYNC_KEY"
    ```

    ```json theme={null}
    {
      "jobId": "6d1f0c2e-3b0a-4f7e-9a51-2c8f4a0d7b11",
      "jobType": "shopify_sync",
      "state": "succeeded",
      "finishedAt": "2026-08-31T14:02:19Z",
      "retryCount": 0,
      "maxRetries": 3,
      "result": {
        "fetched": 42, "created": 40, "duplicates": 2, "skipped": 0, "failed": 0,
        "checkpointUpdated": true, "currentSinceId": "6120058241234"
      }
    }
    ```

    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.

    ```bash theme={null}
    curl "$SYNC/orders?shopify_order_id=6120058241234" --header "x-stateset-api-key: $SYNC_KEY"
    ```

    ```json theme={null}
    {
      "items": [{
        "id": "0f9b7c3a-1d24-4e6b-8f1a-5b3d2c9e7a40",
        "shopifyOrderId": "6120058241234",
        "orderNumber": "ORD-10042",
        "lineItems": [{ "title": "Trail Pack 32L", "sku": "TP-32-GRN", "quantity": 1, "price": 129.00 }],
        "netsuiteId": null
      }],
      "pagination": { "page": 1, "pageSize": 20, "hasNext": false, "hasPrevious": false }
    }
    ```
  </Step>

  <Step title="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.

    ```bash theme={null}
    curl --request POST "$SYNC/sync/netsuite" --header "x-stateset-api-key: $SYNC_KEY"
    ```

    Watch the job the same way. A clean run finishes `succeeded` with:

    ```json theme={null}
    { "status": "ok", "pending": 40, "attempted": 40, "synced": 40, "failed": 0, "failures": [] }
    ```

    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`.

    <Warning>
      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.
    </Warning>
  </Step>

  <Step title="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.

    ```bash theme={null}
    curl --request POST "$SYNC/sync/inventory?source=netsuite" --header "x-stateset-api-key: $SYNC_KEY"
    ```

    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).

    ```json theme={null}
    {
      "source": "netsuite", "target": "shopify",
      "items_synced": 318, "items_failed": 2,
      "errors": ["SKU TP-32-OLD: variant not found in Shopify"],
      "started_at": "2026-08-31T14:10:00Z", "completed_at": "2026-08-31T14:10:41Z"
    }
    ```
  </Step>

  <Step title="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:

    ```bash theme={null}
    curl "$SYNC/jobs?state=failed&job_type=netsuite_sync" --header "x-stateset-api-key: $SYNC_KEY"
    ```

    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.

    ```bash theme={null}
    curl --request POST "$SYNC/jobs/9c3e5a10-7b2d-4d6f-8e1a-0b4c2d9f6a77/retry" \
      --header "x-stateset-api-key: $SYNC_KEY"
    ```

    ```json theme={null}
    {
      "jobId": "9c3e5a10-7b2d-4d6f-8e1a-0b4c2d9f6a77",
      "jobType": "netsuite_sync",
      "state": "queued",
      "retryCount": 0,
      "maxRetries": 3
    }
    ```

    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.
  </Step>

  <Step title="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`.

    ```bash theme={null}
    curl --request POST "$SYNC/shopify/webhooks/register" \
      --header "x-stateset-api-key: $SYNC_KEY" \
      --header "content-type: application/json" \
      --data '{ "baseUrl": "https://api.sync.stateset.com" }'
    ```

    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:

    ```bash theme={null}
    curl "$SYNC/webhook-events?source=shopify&topic=orders/create&page_size=5" \
      --header "x-stateset-api-key: $SYNC_KEY"
    ```

    ```json theme={null}
    {
      "items": [{
        "id": "b2a7e9c4-51d3-4f08-9e6b-7c1a3d5f2e90",
        "source": "shopify",
        "topic": "orders/create",
        "shopifyOrderId": "6120058249876",
        "signatureValid": true,
        "state": "succeeded",
        "attemptCount": 1,
        "maxAttempts": 5
      }],
      "pagination": { "page": 1, "pageSize": 5, "hasNext": true, "hasPrevious": false }
    }
    ```

    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:

    ```bash theme={null}
    curl --request POST "$SYNC/webhook-events/b2a7e9c4-51d3-4f08-9e6b-7c1a3d5f2e90/replay" \
      --header "x-stateset-api-key: $SYNC_KEY"
    ```

    ```json theme={null}
    { "queued": true, "webhookId": "b2a7e9c4-51d3-4f08-9e6b-7c1a3d5f2e90" }
    ```
  </Step>

  <Step title="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.

    ```bash theme={null}
    curl --request POST "$SYNC/schedules" \
      --header "x-stateset-api-key: $SYNC_KEY" \
      --header "content-type: application/json" \
      --data '{ "jobType": "netsuite_sync", "everySeconds": 300, "enabled": true }'
    ```

    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.
  </Step>
</Steps>

## Troubleshooting

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>
</AccordionGroup>

## What you built

| Piece                | Endpoint                                                          | What it does                                                                            |
| -------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| Preflight            | `GET /integrations/health`                                        | Live check that Shopify and NetSuite credentials work                                   |
| Order ingest         | `POST /sync/shopify`                                              | Pulls paid, unfulfilled Shopify orders into the order book from a `since_id` checkpoint |
| ERP push             | `POST /sync/netsuite`                                             | Creates NetSuite sales orders for every order without a `netsuiteId`                    |
| Inventory write-back | `POST /sync/inventory?source=netsuite`                            | Writes NetSuite available quantities to Shopify inventory levels                        |
| Jobs                 | `GET /jobs/{job_id}`, `POST /jobs/{job_id}/retry`                 | State, result and error; requeue a `failed` job with a fresh retry budget               |
| Real-time ingest     | `POST /shopify/webhooks/register`                                 | Subscribes seven order and fulfillment topics to the HMAC-verified receiver             |
| Webhook audit        | `GET /webhook-events`, `POST /webhook-events/{webhook_id}/replay` | Every delivery, its signature result and processing state; replay on demand             |
| Automation           | `POST /schedules`                                                 | Interval or cron schedules that create the same jobs on their own                       |

## Next steps

<CardGroup cols={2}>
  <Card title="Jobs reference" icon="list-checks" href="/api-reference/sync/jobs/v1-tenants-by-jobs-get">
    Every filter on the job list, the summary and throughput helpers, and cancel.
  </Card>

  <Card title="Webhook events reference" icon="webhook" href="/api-reference/sync/webhook_events/v1-tenants-by-webhook-events-get">
    Filtering by topic prefix and Shopify order id, bulk replay, and the summary counts.
  </Card>

  <Card title="Troubleshooting a stuck order" icon="stethoscope" href="/stateset-sync-server-troubleshooting">
    Circuit breakers, upstream limits, and the errors you should not retry.
  </Card>

  <Card title="Drive it from an agent" icon="robot" href="/stateset-sync-mcp">
    The same operations as MCP tools, with per-tenant read-only mode and blocklists.
  </Card>
</CardGroup>
