# Commerce

One API over Shopify and WooCommerce stores: products, variants, stock, collections, discounts, pages, orders and more, plus syncing a store into a Meta catalog.

import { Tab, Tabs } from 'fumadocs-ui/components/tabs';

The Commerce API at `/v1/commerce` is one platform-neutral API over every connected store. Write an integration once and it works on Shopify and on WooCommerce (self-hosted WordPress).

## Before you start

A Commerce store is an account you already connect for blogs:

- **Shopify:** the OAuth flow with the store's `myshopify.com` domain, an install from the Shopify App Store, or a custom-app Admin token. See [Shopify](/platforms/shopify#connect).
- **WooCommerce:** connect the self-hosted WordPress site with a username and an Application Password. See [WordPress](/platforms/wordpress#connect-self-hosted-wordpress). The user must be allowed to manage WooCommerce.
- **WordPress.com:** not supported. Its token reaches no WooCommerce API.

## Check what a store can do [#capabilities-and-extra-permissions]

Call [Get a store](/commerce/get-commerce-store) before anything else. It returns the store's currency and country, the `capabilities` it can serve now, the `missingCapabilities` the platform supports but this store has not granted, and `grantPermissionsUrl`.

<Tabs items={['Node.js', 'Python', 'curl']}>
<Tab value="Node.js">
```typescript
import Zernio from '@zernio/node';

const zernio = new Zernio();

const { data } = await zernio.commerce.getCommerceStore({
  query: { accountId: '66b2e19d8c3f5a7e9d0b1c2d' },
});

console.log(data.store.capabilities, data.store.grantPermissionsUrl);
```
</Tab>
<Tab value="Python">
```python
from zernio import Zernio

client = Zernio()

store = client.commerce.get_commerce_store(account_id="66b2e19d8c3f5a7e9d0b1c2d")["store"]

print(store["capabilities"], store["grantPermissionsUrl"])
```
</Tab>
<Tab value="curl">
```bash
curl "https://zernio.com/api/v1/commerce/store?accountId=66b2e19d8c3f5a7e9d0b1c2d" \
  -H "Authorization: Bearer $ZERNIO_API_KEY"
```
</Tab>
</Tabs>

```json
{
  "store": {
    "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
    "platform": "shopify",
    "name": "Acme",
    "domain": "acme.myshopify.com",
    "url": "https://acme.com",
    "currency": "USD",
    "country": "US",
    "capabilities": ["products.read", "products.create", "products.update", "products.status", "products.price", "products.variants", "products.images", "collections.read", "collections.write", "collections.metafields", "metafields.read", "metafields.write", "pages.read", "pages.write"],
    "missingCapabilities": ["products.images_remove", "inventory.read", "inventory.write", "channels.read", "channels.write", "discounts.read", "discounts.write", "discounts.codes", "navigation.read", "navigation.write", "metaobjects.read", "metaobjects.write", "markets.read", "markets.write", "marketing.write"],
    "grantPermissionsUrl": "https://admin.shopify.com/store/acme/oauth/install?client_id=...&optional_scopes=write_files%2Cwrite_inventory%2Cread_locations%2C..."
  }
}
```

On Shopify, send the owner to `grantPermissionsUrl` to approve the missing capabilities; see [Grant optional permissions](/platforms/shopify/commerce#grant-optional-permissions). WooCommerce has no optional permissions: see [WooCommerce](/platforms/wordpress/commerce#how-it-behaves).

## List products

`query` goes to the platform's product search as is, and `collectionId` narrows the list to one collection. A `status` the platform has no equivalent of returns an empty page.

<Tabs items={['Node.js', 'Python', 'curl']}>
<Tab value="Node.js">
```typescript
const { data } = await zernio.commerce.listCommerceProducts({
  query: { accountId: '66b2e19d8c3f5a7e9d0b1c2d', status: 'active', limit: 50 },
});
```
</Tab>
<Tab value="Python">
```python
products = client.commerce.list_commerce_products(
    account_id="66b2e19d8c3f5a7e9d0b1c2d",
    status="active",
    limit=50,
)
```
</Tab>
<Tab value="curl">
```bash
curl "https://zernio.com/api/v1/commerce/products?accountId=66b2e19d8c3f5a7e9d0b1c2d&status=active&limit=50" \
  -H "Authorization: Bearer $ZERNIO_API_KEY"
```
</Tab>
</Tabs>

## Create a product

A new product starts as `draft` unless you send `status: "active"`. It takes up to 3 options, 100 variants and 20 images.

<Tabs items={['Node.js', 'Python', 'curl']}>
<Tab value="Node.js">
```typescript
const { data } = await zernio.commerce.createCommerceProduct({
  body: {
    accountId: '66b2e19d8c3f5a7e9d0b1c2d',
    title: 'Linen Shirt',
    descriptionHtml: '<p>Breathable linen, cut for summer.</p>',
    status: 'active',
    images: [{ url: 'https://cdn.example.com/linen-shirt.jpg', altText: 'Linen shirt' }],
    options: [{ name: 'Size', values: ['M', 'L'] }],
    variants: [
      { sku: 'LS-M', price: '49.00', options: [{ name: 'Size', value: 'M' }] },
      { sku: 'LS-L', price: '49.00', options: [{ name: 'Size', value: 'L' }] },
    ],
  },
});
```
</Tab>
<Tab value="Python">
```python
product = client.commerce.create_commerce_product(
    account_id="66b2e19d8c3f5a7e9d0b1c2d",
    title="Linen Shirt",
    description_html="<p>Breathable linen, cut for summer.</p>",
    status="active",
    images=[{"url": "https://cdn.example.com/linen-shirt.jpg", "altText": "Linen shirt"}],
    options=[{"name": "Size", "values": ["M", "L"]}],
    variants=[
        {"sku": "LS-M", "price": "49.00", "options": [{"name": "Size", "value": "M"}]},
        {"sku": "LS-L", "price": "49.00", "options": [{"name": "Size", "value": "L"}]},
    ],
)
```
</Tab>
<Tab value="curl">
```bash
curl -X POST "https://zernio.com/api/v1/commerce/products" \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
    "title": "Linen Shirt",
    "descriptionHtml": "<p>Breathable linen, cut for summer.</p>",
    "status": "active",
    "images": [{ "url": "https://cdn.example.com/linen-shirt.jpg", "altText": "Linen shirt" }],
    "options": [{ "name": "Size", "values": ["M", "L"] }],
    "variants": [
      { "sku": "LS-M", "price": "49.00", "options": [{ "name": "Size", "value": "M" }] },
      { "sku": "LS-L", "price": "49.00", "options": [{ "name": "Size", "value": "L" }] }
    ]
  }'
```
</Tab>
</Tabs>

[Activate, deactivate, archive or delete products](/commerce/change-commerce-product-state) takes up to 50 product ids and reports each outcome, so one failure does not abort the rest. A delete is permanent on both platforms.

## Set stock

Read the location ids with [List locations](/commerce/list-commerce-locations), then change each variant. `mode: "set"` makes `quantity` the new available count; `mode: "adjust"` adds it (negative to subtract).

<Tabs items={['Node.js', 'Python', 'curl']}>
<Tab value="Node.js">
```typescript
const { data } = await zernio.commerce.changeCommerceInventory({
  path: { productId: '8214569812345' },
  body: {
    accountId: '66b2e19d8c3f5a7e9d0b1c2d',
    mode: 'adjust',
    changes: [{ variantId: '44021234567890', locationId: '71234567890', quantity: -2 }],
  },
});
```
</Tab>
<Tab value="Python">
```python
stock = client.commerce.change_commerce_inventory(
    product_id="8214569812345",
    account_id="66b2e19d8c3f5a7e9d0b1c2d",
    mode="adjust",
    changes=[{"variantId": "44021234567890", "locationId": "71234567890", "quantity": -2}],
)
```
</Tab>
<Tab value="curl">
```bash
curl -X POST "https://zernio.com/api/v1/commerce/products/8214569812345/inventory" \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
    "mode": "adjust",
    "changes": [{ "variantId": "44021234567890", "locationId": "71234567890", "quantity": -2 }]
  }'
```
</Tab>
</Tabs>

The response is the product's stock after the change.

## Sync orders

Lists come newest first; pass `updatedAfter` to page forward from the last change you saw, oldest first. On Shopify you can also [refund](/platforms/shopify/commerce#refund-an-order), [cancel](/platforms/shopify/commerce#cancel-an-order) and [edit](/platforms/shopify/commerce#edit-an-order) orders, [create draft orders](/platforms/shopify/commerce#create-a-draft-order) and [look up customers](/platforms/shopify/commerce#look-up-a-customer).

<Tabs items={['Node.js', 'Python', 'curl']}>
<Tab value="Node.js">
```typescript
const { data } = await zernio.commerce.listCommerceOrders({
  query: { accountId: '66b2e19d8c3f5a7e9d0b1c2d', updatedAfter: '2026-10-20T08:00:00Z', limit: 50 },
});
```
</Tab>
<Tab value="Python">
```python
orders = client.commerce.list_commerce_orders(
    account_id="66b2e19d8c3f5a7e9d0b1c2d",
    updated_after="2026-10-20T08:00:00Z",
    limit=50,
)
```
</Tab>
<Tab value="curl">
```bash
curl "https://zernio.com/api/v1/commerce/orders?accountId=66b2e19d8c3f5a7e9d0b1c2d&updatedAfter=2026-10-20T08:00:00Z&limit=50" \
  -H "Authorization: Bearer $ZERNIO_API_KEY"
```
</Tab>
</Tabs>

- **Contents:** line items with unit prices, discounts and tax per line, shipping and fee lines, billing details, payment status and refunds.
- **Overlap:** an order edited while you page can shift the list. Start each sync a few minutes before the last `updatedAt` you saw and drop duplicates by `id`.
- **Amounts** are in the store currency. `unitPrice` and `subtotal` are before discounts, `total` after; `taxesIncluded` says whether they include tax.
- **`status`:** `pending_payment`, `on_hold`, `awaiting_fulfillment`, `partially_fulfilled`, `in_transit`, `delivered`, `completed` or `cancelled`, with the platform's value in `platformStatus`.
- **`paymentStatus`:** `pending`, `authorized`, `partially_paid`, `paid`, `partially_refunded`, `refunded`, `voided` or `failed`.
- **Webhooks instead of polling:** subscribe your [webhooks](/webhooks) to `commerce.order.created`, `commerce.order.updated` and `commerce.order.refunded`. They carry the store and order id; read the order with [Get an order](/commerce/get-commerce-order). A refund usually also fires `commerce.order.updated`.

## Read order attribution [#order-attribution]

`attribution` says where the order came from, for conversion tracking. `firstVisit` and `lastVisit` each carry `landingUrl`, `referrerUrl`, `source`, `sourceType` and `utm` (`source`, `medium`, `campaign`, `term`, `content`).

```json
"attribution": {
  "ready": true,
  "firstVisit": null,
  "lastVisit": {
    "occurredAt": "2026-10-20T09:12:00Z",
    "landingUrl": "https://shop.example.com/?utm_source=instagram&utm_campaign=fall",
    "referrerUrl": "https://l.instagram.com/",
    "source": "instagram",
    "sourceType": "SOCIAL",
    "utm": { "source": "instagram", "medium": "social", "campaign": "fall", "term": null, "content": null }
  },
  "customAttributes": [{ "key": "gift_message", "value": "Happy birthday" }]
}
```

`customAttributes` holds the cart, note or public order attributes. When `ready` is `false` the store has not attributed the order yet. What each platform records is on [Shopify](/platforms/shopify/commerce#how-it-behaves) and [WooCommerce](/platforms/wordpress/commerce#how-it-behaves).

## Sync a store to a Meta catalog

A catalog sync keeps a Meta product catalog in step with a store, for catalog ads (`goal: catalog_sales`) and Shops. Send:

- **`accountId`:** the store.
- **`catalogAccountId`:** a connected `facebook`, `instagram` or `metaads` account whose Meta login can manage the catalog (the `catalog_management` permission). Not WhatsApp: background runs refresh the catalog token through a Meta login.
- **`catalogId`:** the numeric Meta catalog id, from [List catalogs](/product-catalogs/list-ad-catalogs).

<Tabs items={['Node.js', 'Python', 'curl']}>
<Tab value="Node.js">
```typescript
const { data } = await zernio.commerce.createCommerceCatalogSync({
  body: {
    accountId: '66b2e19d8c3f5a7e9d0b1c2d',
    catalogAccountId: '66c3f2a0b1d4e5f6a7b8c9d0',
    catalogId: '1234567890123456',
  },
});
```
</Tab>
<Tab value="Python">
```python
sync = client.commerce.create_commerce_catalog_sync(
    account_id="66b2e19d8c3f5a7e9d0b1c2d",
    catalog_account_id="66c3f2a0b1d4e5f6a7b8c9d0",
    catalog_id="1234567890123456",
)
```
</Tab>
<Tab value="curl">
```bash
curl -X POST "https://zernio.com/api/v1/commerce/catalog-syncs" \
  -H "Authorization: Bearer $ZERNIO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
    "catalogAccountId": "66c3f2a0b1d4e5f6a7b8c9d0",
    "catalogId": "1234567890123456"
  }'
```
</Tab>
</Tabs>

The call answers `202` with `runStatus: "pending"` and starts the first full run. [Get a catalog sync](/commerce/get-commerce-catalog-sync) then reads:

```json
{
  "catalogSync": {
    "id": "66d4a1b2c3d4e5f6a7b8c9d0",
    "accountId": "66b2e19d8c3f5a7e9d0b1c2d",
    "catalogPlatform": "meta",
    "catalogAccountId": "66c3f2a0b1d4e5f6a7b8c9d0",
    "catalogId": "1234567890123456",
    "runStatus": "succeeded",
    "lastRunStartedAt": "2026-09-29T05:20:04.000Z",
    "lastRunFinishedAt": "2026-09-29T05:21:37.000Z",
    "lastError": null,
    "itemsSent": 412,
    "itemsSkipped": 9,
    "itemsDeleted": 3,
    "createdAt": "2026-09-20T10:02:11.000Z"
  }
}
```

| Field | Meaning |
|-------|---------|
| `runStatus` | `pending`, `running`, then `succeeded` or `failed`. |
| `itemsSent` | Variant items Meta accepted in the last run (rejected items are not counted). |
| `itemsSkipped` | Active products left out: not published or no image. |
| `itemsDeleted` | This store's items removed because they are gone or no longer listable. |
| `lastError` | Why the last run failed, or a note on items Meta rejected or had not finished processing. |

## Run or stop a catalog sync

- **Run now:** [Run a catalog sync now](/commerce/run-commerce-catalog-sync) queues a full run and answers `202`, or `409 catalog_sync_conflict` while a run is in progress.
- **Stop:** [Stop a catalog sync](/commerce/delete-commerce-catalog-sync). Items already in the catalog stay there.
- **Read:** [List catalog syncs](/commerce/list-commerce-catalog-syncs) and [Get a catalog sync](/commerce/get-commerce-catalog-sync), even after the store is disconnected.

## Find every operation

The [Commerce API reference](/commerce/get-commerce-store) has every operation, each page listing the platforms it serves. Shopify adds [channels, markets, menus and more](/platforms/shopify/commerce#shopify-only-operations); WooCommerce gaps are in [WooCommerce differences](/platforms/wordpress/commerce#woocommerce-differences).

## How it behaves

- **Store:** `accountId` (query on reads, body on writes) is the `_id` of a `shopify` account or a WooCommerce `wordpress` account. Objects report `platform: "shopify"` or `platform: "woocommerce"`.
- **No copies:** Zernio proxies each call and stores nothing, except the [catalog syncs](#sync-a-store-to-a-meta-catalog) you create.
- **Ids:** the platform's own ids (numeric strings). Read them from responses; never construct them.
- **Money:** responses return `{ "amount": "24.00", "currency": "USD" }`, a decimal string. Write bodies take a bare decimal amount in the store's currency.
- **Status:** `status` is one of `active`, `draft`, `pending_review`, `rejected`, `inactive`, `archived`, `deleted`, with the platform's raw value in `platformStatus`.
- **Timestamps:** ISO 8601, UTC.
- **Pagination:** lists return `{ <resource>, nextCursor }`. Pass `limit` and the previous `nextCursor` as `cursor`; it is `null` on the last page and opaque.
- **Catalog items:** one per product variant, grouped by product (`item_group_id`), with ids namespaced to the store (`<platform>:<store>:<variantId>`). A run only touches this store's items.
- **Listable products:** `active`, published to the online store (it has a storefront `url`) and with at least one image.
- **Full run:** pushes every listable variant, then deletes this store's other items.
- **Failed runs:** a run where Meta rejected every item is `failed`. A run not finished after 30 minutes reads as `failed` and is retried on the next daily pass.
- **One sync per store and catalog:** a second create for the pair is `409 catalog_sync_conflict`. A catalog can take several stores.
- **Product changes** refresh their items within minutes. A deleted product queues a full run, since its variant ids are gone.
- **Daily pass:** every sync not run in the last 20 hours runs once a day, catching missed deliveries and deleted variants. Syncs of a disconnected store are skipped until it is reconnected.
- **Product webhooks:** your [webhooks](/webhooks) get `commerce.product.created`, `commerce.product.updated` and `commerce.product.deleted`, with the store and product id; read it with [Get a product](/commerce/get-commerce-product).

## If it fails

| Error | Cause | Fix |
|-------|-------|-----|
| `400 platform_not_supported` | The account is not a store, or its platform does not serve this operation | Check `capabilities` on [Get a store](/commerce/get-commerce-store). |
| `400 invalid_field_value` | The platform rejected a field, or the field does not exist on it | Read the message; see [WooCommerce differences](/platforms/wordpress/commerce#woocommerce-differences). |
| `403 insufficient_permissions` | The store has not granted the scope | Send the owner to `grantPermissionsUrl` (the message points to it), or reconnect. |
| `404 product_not_found` / `404 resource_not_found` | The id was deleted or is another store's | List the resource for a current id. |
| `409 catalog_sync_conflict` | The store already syncs to that catalog, or a run is in progress | Run the existing sync, or wait for the run. |
| `429` | Rate limited by Zernio or by the platform | Retry later. See [rate limits](/guides/rate-limits). |
| `502` | The platform failed or was unavailable | Retry once the earlier request's outcome is known. |

## Related

- [Commerce API reference](/commerce/get-commerce-store): every endpoint with its fields.
- [Shopify commerce](/platforms/shopify/commerce): permissions and Shopify-only operations.
- [WooCommerce commerce](/platforms/wordpress/commerce): what WooCommerce serves and how it differs.
- [Product catalogs](/product-catalogs/list-ad-catalogs): the Meta catalogs a sync writes to.
- [Pixels and tracking tags](/guides/pixels): installing ad pixels on a store.