BigCommerce

BigCommerce: store API account token or app OAuth, v2 orders and shipments, v3 catalogue, multi location inventory, webhooks, 30 second rate limit windows.

BigCommerce is a hosted SaaS commerce platform in the same bracket as Shopify, aimed at mid market and B2B merchants, with a reputation for a deeper API and fewer platform imposed limits on catalogue structure. For a connector it is one of the easier integrations in this catalogue: a merchant can mint a permanent access token from their own store admin in under a minute, the base URL is the same for every store with the store hash in the path, and reads and writes for orders, catalogue and stock are all first class. The awkward parts are the split between a v2 API for orders and shipments and a v3 API for everything modern, and webhooks that carry an id rather than the record and are not cryptographically signed.

At a glance

What it is

BigCommerce Holdings is a listed American company selling a hosted commerce platform on monthly plans named Standard, Plus, Pro and Enterprise. Stores are identified by an opaque store hash rather than a domain, which is what every API path contains. The platform leans towards larger catalogues, multi storefront setups and B2B features such as price lists and customer groups, and it markets itself as open, meaning fewer restrictions on API usage than its main competitor.

In India BigCommerce is a minority platform: it appears among exporters, B2B sellers and brands that were set up by an agency with a BigCommerce practice, rather than as a default choice. It is not a marketplace, so like Shopify and WooCommerce a connection gives us first party data with unmasked customer details, and no settlement data because money moves through the merchant's own gateway.

The API version story is unusual. Instead of dated versions, BigCommerce keeps two long lived generations side by side: v2, which still owns orders and shipments, and v3, which owns catalogue, inventory, customers, webhooks and everything newer. Both are current. There is no quarterly upgrade to schedule, but there is no formal deprecation calendar to rely on either.

API access

Two credential types, both self-serve.

Store-level API account. The merchant goes to Settings, Store-level API accounts, creates an account, chooses OAuth scopes per resource, and BigCommerce returns a client id, client secret, access token and API path. The token is static and never rotates. Neither the token nor its scopes can be changed afterwards: to change scopes the merchant creates a new account. This is the right path for onboarding a single brand.

App-level OAuth client. We register an app in the Developer Portal, get a client id and secret, and each merchant that installs the app produces a distinct access token for their store. Required for anything distributed through the BigCommerce app marketplace, and the only sane option if we expect many stores.

Base URL for both:

https://api.bigcommerce.com/stores/{store_hash}/v3/{resource}
https://api.bigcommerce.com/stores/{store_hash}/v2/{resource}

The store hash is stable and independent of the merchant's domain, so it is the natural tenant key. Sandbox stores are available to partners and developers at no cost, and behave like real stores.

BigCommerce publishes its OpenAPI specifications publicly at github.com/bigcommerce/api-specs, which is the most reliable reference for exact field names and can be used to generate a client. There are official Node and PHP API clients plus community libraries in other languages.

Authentication

Store-level API account

One header, no expiry, no refresh.

curl "https://api.bigcommerce.com/stores/abc123xyz/v2/orders?limit=1&sort=date_modified:desc" \
  -H "X-Auth-Token: 0mfqqk1vwiqhmvuyrkj3qhu3b6xy5dgzq" \
  -H "Accept: application/json"

The same header authenticates v2, v3 and the GraphQL Admin API. Scopes are chosen at creation time and are per resource, for example Orders read-only, Products modify, Information and settings read-only. If a scope is missing the response is HTTP 403 with a message naming the missing scope, which makes onboarding diagnostics easy.

App OAuth

  1. The merchant installs the app. BigCommerce redirects to our auth callback with code, scope, context and account_uuid. context is the string stores/{store_hash}, which is how we learn which store we just got.
  2. Exchange the code:
POST https://login.bigcommerce.com/oauth2/token
Content-Type: application/json

{
  "client_id": "d1a2b3c4e5f6",
  "client_secret": "9f8e7d6c5b4a3928",
  "code": "qr6h3s9a0x",
  "scope": "store_v2_orders store_v2_products store_v2_transactions store_inventory",
  "grant_type": "authorization_code",
  "redirect_uri": "https://connectors.example.com/bigcommerce/auth",
  "context": "stores/abc123xyz"
}
  1. The response carries the token and the store identity:
{
  "access_token": "0mfqqk1vwiqhmvuyrkj3qhu3b6xy5dgzq",
  "scope": "store_v2_orders store_v2_products store_inventory",
  "user": { "id": 24654, "username": "priya@example.in", "email": "priya@example.in" },
  "owner": { "id": 24654, "username": "priya@example.in", "email": "priya@example.in" },
  "context": "stores/abc123xyz",
  "account_uuid": "a4f2f1ce-2b1a-4f0c-9a7d-9f3c2b1a0e9d"
}

The documentation describes the app access token as semi permanent and does not publish an expiry, so there is no refresh flow to build. Treat a 401 as "the merchant uninstalled us" rather than "the token expired". Embedded app pages additionally receive a signed_payload_jwt on load, which identifies the store and user for the UI; it is not needed for a background connector.

Multi account: key the credential store on the store hash, and store the granted scope string so a missing scope can be diagnosed without a call.

Objects we can read

Orders

Orders live in v2. GET /v2/orders supports min_id, max_id, min_total, max_total, customer_id, email, status_id, cart_id, payment_method, min_date_created, max_date_created, min_date_modified, max_date_modified, page, limit, sort, is_deleted, channel_id and include. For incremental sync use min_date_modified with sort=date_modified:asc.

Status is both an integer status_id and a string status. The list of ids is platform defined; read it once per store from GET /v2/order_statuses and map from that rather than hardcoding integers, since the names a merchant sees can be customised. Common states include incomplete, awaiting payment, awaiting fulfilment, awaiting shipment, awaiting pickup, partially shipped, shipped, completed, cancelled, declined, refunded, partially refunded and disputed.

One v2 quirk: an empty result set returns HTTP 204 with no body, not an empty array.

[
  {
    "id": 512,
    "customer_id": 26,
    "date_created": "Fri, 18 Sep 2026 19:28:02 +0000",
    "date_modified": "Sat, 19 Sep 2026 04:02:10 +0000",
    "date_shipped": "Fri, 18 Sep 2026 12:40:00 +0000",
    "status_id": 2,
    "status": "Shipped",
    "subtotal_ex_tax": "2249.0000",
    "subtotal_inc_tax": "2698.8200",
    "total_ex_tax": "2249.0000",
    "total_inc_tax": "2698.8200",
    "shipping_cost_inc_tax": "0.0000",
    "handling_cost_ex_tax": "0.0000",
    "discount_amount": "250.0000",
    "coupon_discount": "0.0000",
    "refunded_amount": "0.0000",
    "items_total": 2,
    "items_shipped": 2,
    "payment_method": "Razorpay",
    "payment_provider_id": "pay_QK7M2ZPLTabcd",
    "payment_status": "captured",
    "currency_code": "INR",
    "default_currency_code": "INR",
    "currency_exchange_rate": "1.0000000000",
    "order_is_digital": false,
    "channel_id": 1,
    "billing_address": {
      "first_name": "Priya", "last_name": "Menon",
      "street_1": "14 Carter Road", "street_2": "Flat 302",
      "city": "Mumbai", "state": "Maharashtra", "zip": "400050",
      "country": "India", "country_iso2": "IN",
      "phone": "9876543210", "email": "priya@example.com"
    },
    "products": { "url": "https://api.bigcommerce.com/stores/abc123xyz/v2/orders/512/products", "resource": "/orders/512/products" },
    "shipping_addresses": { "url": "https://api.bigcommerce.com/stores/abc123xyz/v2/orders/512/shipping_addresses", "resource": "/orders/512/shipping_addresses" }
  }
]

Note that products and shipping_addresses are links, not embedded data. Either follow them per order, which is expensive, or pass include=products,shipping_addresses where supported. Dates on v2 are RFC 2822 strings, not ISO 8601, which trips up naive parsers.

The billing address, the shipping addresses and the customer email are PII, returned unmasked.

Order items

GET /v2/orders/{order_id}/products. Fields include id, order_id, product_id, variant_id, name, sku, quantity, base_price, price_ex_tax, price_inc_tax, total_ex_tax, total_inc_tax, quantity_shipped, quantity_refunded and applied_discounts.

[
  {
    "id": 8841,
    "order_id": 512,
    "product_id": 192,
    "variant_id": 384,
    "name": "Cold Pressed Coconut Oil",
    "sku": "CPO-500",
    "quantity": 2,
    "base_price": "1249.5000",
    "price_ex_tax": "1124.5000",
    "price_inc_tax": "1349.4100",
    "total_ex_tax": "2249.0000",
    "total_inc_tax": "2698.8200",
    "quantity_shipped": 2,
    "quantity_refunded": 0,
    "applied_discounts": [{ "id": "coupon-monsoon10", "amount": "250.0000" }]
  }
]

Products and listings

Catalogue is v3. GET /v3/catalog/products has an unusually rich filter set: id, id:in, id:not_in, id:min, id:max, name, sku, sku:in, upc, price, weight, condition, brand_id, inventory_level with range variants, inventory_low, out_of_stock, date_modified with :min and :max, date_last_imported, is_visible, is_featured, availability, status, categories, keyword, plus page, limit (maximum 250), sort and direction. Response shaping is done with include (variants, images, custom_fields, primary_image, modifiers, options, videos), include_fields and exclude_fields. Use include_fields aggressively; a full product with variants is a large object.

{
  "data": [
    {
      "id": 192,
      "name": "Cold Pressed Coconut Oil",
      "type": "physical",
      "sku": "CPO",
      "base_variant_id": 384,
      "brand_id": 31,
      "price": 1249.5,
      "cost_price": 610,
      "retail_price": 1499,
      "sale_price": 1124.5,
      "weight": 0.55,
      "inventory_level": 92,
      "inventory_warning_level": 10,
      "inventory_tracking": "variant",
      "is_visible": true,
      "availability": "available",
      "categories": [9, 14],
      "date_modified": "2026-09-17T11:41:02+00:00",
      "custom_url": { "url": "/cold-pressed-coconut-oil/", "is_customized": false },
      "variants": [
        {
          "id": 384,
          "product_id": 192,
          "sku": "CPO-500",
          "price": 1249.5,
          "sale_price": 1124.5,
          "retail_price": 1499,
          "cost_price": 610,
          "calculated_price": 1124.5,
          "inventory_level": 92,
          "inventory_warning_level": 10,
          "purchasing_disabled": false,
          "bin_picking_number": "A-14-3",
          "weight": 0.55,
          "option_values": [{ "id": 176, "label": "500 ml", "option_id": 220, "option_display_name": "Size" }]
        }
      ]
    }
  ],
  "meta": {
    "pagination": {
      "total": 418, "count": 50, "per_page": 50, "current_page": 1, "total_pages": 9,
      "links": { "current": "?page=1&limit=50", "next": "?page=2&limit=50" }
    }
  }
}

inventory_tracking is the field that decides where stock lives: none means untracked, product means the count on the product, variant (exposed as sku in some older docs) means the count on each variant. Our listings row is the variant when tracking is per variant, and the product otherwise.

Inventory

Two layers again. The inventory_level on a product or variant is the classic single number. The v3 Inventory API adds locations on top:

  • GET /v3/inventory/items with sku:in, variant_id:in, product_id:in, location_id:in, location_code:in, page and limit.
  • GET /v3/inventory/locations and POST /v3/inventory/locations.
  • PUT /v3/inventory/adjustments/absolute and the relative equivalent for writes.

An inventory item is one SKU with an array of per location rows:

{
  "data": [
    {
      "identity": { "sku": "CPO-500", "variant_id": 384, "product_id": 192, "sku_id": 384 },
      "locations": [
        {
          "location_id": 2,
          "location_code": "bhiwandi_fc",
          "location_name": "Bhiwandi FC",
          "available_to_sell": 92,
          "total_inventory_onhand": 104,
          "qty_backordered": 0,
          "location_enabled": true,
          "settings": {
            "safety_stock": 5,
            "is_in_stock": true,
            "warning_level": 10,
            "bin_picking_number": "A-14-3",
            "backorder_limit": 0
          }
        }
      ]
    }
  ],
  "meta": { "pagination": { "total": 1, "count": 1, "per_page": 50, "current_page": 1, "total_pages": 1 } }
}

available_to_sell is the sellable number after safety stock and open orders; total_inventory_onhand is physical. The difference is the closest thing to a reserved quantity, and it is not broken out further.

A location object carries id, code, label, description, type_id (PHYSICAL or VIRTUAL), enabled, managed_by_external_source, address (address1, address2, city, state, zip, country_code, phone, email, geo_coordinates), operating_hours, special_hours, time_zone and storefront_visibility.

Shipments and tracking

GET /v2/orders/{order_id}/shipments, plus /shipments/count and a store wide listing. Fields: id, order_id, customer_id, order_address_id, date_created, tracking_number, merchant_shipping_cost, shipping_method, shipping_provider, shipping_provider_display_name, tracking_carrier, generated_tracking_link, tracking_link, comments, items (each with order_product_id and quantity), billing_address and shipping_address.

[
  {
    "id": 77,
    "order_id": 512,
    "customer_id": 26,
    "order_address_id": 91,
    "date_created": "Fri, 18 Sep 2026 12:40:00 +0000",
    "tracking_number": "1234567890123",
    "merchant_shipping_cost": "0.0000",
    "shipping_method": "Standard",
    "shipping_provider": "",
    "tracking_carrier": "delhivery",
    "generated_tracking_link": "https://www.delhivery.com/track/package/1234567890123",
    "comments": "",
    "items": [{ "order_product_id": 8841, "product_id": 192, "quantity": 2 }]
  }
]

merchant_shipping_cost is what the merchant paid, when they entered it, which is more than most platforms give us. There are no carrier scan events: BigCommerce stores the number and a generated link, so the event history has to come from the carrier connector.

Returns and cancellations

Cancellation is an order status, not an object: a cancelled order carries the cancelled status_id. There is no returns or RMA object in the API.

The money record is a refund, in v3: GET /v3/orders/payment_actions/refunds for all refunds, GET /v3/orders/{order_id}/payment_actions/refunds for one order's, POST on the same path to create one, and POST /v3/orders/{order_id}/payment_actions/refund_quotes to calculate what can be refunded. A refund carries id, order_id, total_amount, total_tax, reason, created, items[] (which distinguish product, shipping, handling and order level refunds) and payments[] (the provider and amount per method).

Payments and settlements

GET /v3/orders/{order_id}/transactions returns the gateway movements: event (purchase, authorization, capture, refund, void, pending, settled), method (credit_card, electronic_wallet, store_credit, gift_certificate, custom, token, offsite, offline, nonce), amount, currency, gateway, gateway_transaction_id, status (ok or error) and date_created.

There is no settlement or payout object, since BigCommerce does not process payments itself. Fees and payouts come from the gateway, joined on gateway_transaction_id.

Customers

GET /v3/customers with filters on id:in, email:in, name:like, company:in, customer_group_id:in, date_created and date_modified ranges. Addresses are a sub-resource at GET /v3/customers/addresses. Everything is unmasked PII. Guest checkouts leave customer_id: 0 on the order, with the identity only in the order's billing address.

Locations

See Inventory above: GET /v3/inventory/locations is the location list and maps directly onto our locations table.

Writing back: listings, price and stock

Price fields on a variant are price, sale_price, retail_price and cost_price, with calculated_price read-only. A null price on a variant means it inherits from the product, so writing an explicit price to every variant is the safest way to keep the two layers in step.

Absolute stock adjustment:

{
  "reason": "nightly sync from warehouse system",
  "items": [
    { "location_id": 2, "sku": "CPO-500", "quantity": 80 },
    { "location_id": 2, "sku": "CPO-250", "quantity": 0 }
  ]
}

The response is a transaction_id, not the resulting levels, so a read back is needed if we want to confirm the new numbers. BigCommerce's own guidance is to prefer absolute adjustments and to reserve relative ones for cases where another system is also moving stock.

Note

Writes to the catalogue are synchronous and take effect immediately. There is no feed, no job status and no processing report to chase, which makes BigCommerce one of the simplest write targets in this catalogue. The cost is that a large catalogue update is many small calls against a 30 second rate limit window, so batch endpoints matter.

Webhooks and notifications

Create with POST /v3/hooks, passing scope, destination (HTTPS on port 443), is_active and an optional headers object.

Useful scopes, confirmed from the events reference: store/order/created, store/order/updated, store/order/statusUpdated, store/order/archived, store/order/refund/created, store/product/created, store/product/updated, store/product/deleted, store/product/inventory/updated, store/product/inventory/order/updated, store/sku/created, store/sku/updated, store/sku/deleted, store/sku/inventory/updated, store/sku/inventory/order/updated, store/shipment/created, store/shipment/updated, store/shipment/deleted, store/customer/created, store/customer/updated, store/cart/converted, store/cart/abandoned. Wildcards such as store/product/* subscribe to a whole family.

The delivered body is a notification, not the record:

{
  "scope": "store/order/statusUpdated",
  "store_id": "1025646",
  "data": { "type": "order", "id": 512 },
  "hash": "dd70c0976e06b67aaf671e73f49dcb79230ebf9d",
  "created_at": 1561479335,
  "producer": "stores/abc123xyz"
}

So every webhook is followed by a read. hash is a SHA-1 of the encoded data and is useful for deduplication.

Warning

There is no HMAC signature header. Authenticity rests entirely on the optional headers object we set when creating the hook, for example a bearer secret of our own, plus checking producer against the store hash we expect. Treat the endpoint as public and verify by reading the record back over the API before acting on it.

Retries start after about 60 seconds and back off up to 86400 seconds. A hook that keeps failing for 48 hours is deactivated and the merchant contact is emailed; reactivation is a PUT setting is_active back to true. There is also short term blocklisting when the success rate over a two minute window falls below 90 percent, so a slow endpoint will be cut off before it is deactivated. Limits: at most 10 webhooks per combination of store, client id and scope, and only one per combination of store, client id, scope and destination.

Poll underneath the webhooks anyway: orders on min_date_modified every 10 to 15 minutes, catalogue on date_modified:min daily.

Rate limits and pagination

Quotas are counted in a rolling 30 second window and published per plan, as of 2026-09-21:

Every response carries the accounting, which is what we should drive the scheduler from rather than the table above:

  • X-Rate-Limit-Time-Window-Ms: window length, 30000.
  • X-Rate-Limit-Requests-Quota: requests allowed in the window.
  • X-Rate-Limit-Requests-Left: requests remaining.
  • X-Rate-Limit-Time-Reset-Ms: milliseconds until the window resets.

On HTTP 429 the same headers are returned; sleep for X-Rate-Limit-Time-Reset-Ms and retry, backing off further if it recurs. A 429 with no rate limit headers means the platform itself is under load, which is a different signal and deserves a longer pause.

Pagination differs by generation. v3 returns meta.pagination with total, count, per_page, current_page, total_pages and links, and limit caps at 250. v2 uses page and limit with no envelope, returns 204 for an empty page, and gives no total, so paging ends when a short page or a 204 arrives.

Practical cadence: on a Standard plan, 150 requests per 30 seconds is roughly 5 per second sustained, which comfortably covers an order sync every 10 minutes plus a nightly catalogue sweep at limit=250. Keep concurrency at 2 to 3 and let the headers throttle us.

Mapping to the unified model

orders

order_items

listings

inventory

shipments

returns and settlements

returns: there is no return object, so map a v3 refund instead: id to return_id, total_amount to refund_amount, reason to reason, created to initiated_at, items[] to items[]. There is no physical receipt timestamp. settlements: nothing to map, the gateway holds it.

Gaps and open questions

  • No returns or RMA object. Refunds are the only return signal, and they say nothing about whether goods came back.
  • No settlement data, since BigCommerce does not hold funds.
  • Webhooks are unsigned. We rely on a self chosen header secret plus a read back, which is weaker than the HMAC schemes elsewhere in this catalogue.
  • Order status integers were not enumerated from a source we could read in full; read them per store from GET /v2/order_statuses. Medium confidence on any hardcoded mapping.
  • Enterprise rate limits are described as varying by plan and resource, with no published table. Drive from the response headers.
  • The relative inventory adjustment endpoint exists but its exact request body was not reproduced from the reference page we could reach. Confirm before using it; the absolute endpoint is verified and preferred.
  • The GraphQL Admin API exists alongside REST and may cover some of this more efficiently, but its coverage relative to REST was not assessed here.
  • Whether app access tokens ever expire is not stated in the documentation. Assume they do not, and handle 401 as an uninstall.

Sources