WooCommerce

WooCommerce REST API v3: consumer key and secret over HTTPS, reads orders, products, variations, stock, refunds and customers, writes price and stock.

WooCommerce is a WordPress plugin that turns a site into a shop, and it is the second platform after Shopify that an independent D2C brand is likely to be running. Because it is self hosted, every store is a different server with a different WordPress version, a different set of plugins and a different idea of what an order looks like once the plugins have finished with it. The API itself is good: a clean REST interface under the wc/v3 namespace, credentials that a merchant can issue in two minutes from wp-admin, full read and write on orders, products, variations and stock. The risks are all operational, not contractual: an unreachable host, a security plugin that strips the Authorization header, a webhook that never fires because WordPress cron only runs when somebody visits the site.

At a glance

What it is

WooCommerce is an open source e-commerce plugin for WordPress, owned by Automattic. It is free, it runs on the merchant's own hosting, and it powers a very large tail of small and mid sized stores worldwide. In India it is the usual choice for brands that started on a WordPress site, for agencies that build stores for clients, and for anyone unwilling to pay a monthly platform fee. It is not a marketplace: there is no shared buyer base, no seller onboarding, no platform fee and no settlement report, because money goes straight from the payment gateway to the merchant's bank account.

For our purposes the important consequence of self hosting is that the store, not a platform, is the availability risk. A Shopify store is always up; a WooCommerce store can be down, slow, on a shared host that rate limits us, or behind Cloudflare with a bot rule that blocks our user agent. Any connector must treat every call as capable of returning HTML instead of JSON.

Since WooCommerce 8.2 orders can be stored in dedicated database tables rather than as WordPress posts, a change known as High Performance Order Storage. This does not change the REST API shape, but it does mean the underlying data model differs between stores, which matters if anyone ever proposes reading the database directly instead of the API. Use the API.

API access

There is no developer programme and nothing to apply for. Requirements published in the docs: WooCommerce 3.5 or later, WordPress 4.4 or later, pretty permalinks enabled, because the default plain permalink structure breaks the REST routes. HTTPS is recommended and is required if we want to use the simple basic auth path.

The merchant creates credentials at WooCommerce, Settings, Advanced, REST API, Add key. They choose a WordPress user, a description and a permission level of Read, Write, or Read/Write, and WooCommerce shows a consumer key (ck_...) and consumer secret (cs_...) exactly once. The key inherits the chosen user's capabilities, so a key issued against a Shop Manager account can see orders and customers while a key against a lesser role may silently return fewer fields.

The namespace wc/v3 is current and has been stable for years; wc/v1 and wc/v2 still exist on most installs for backwards compatibility. There is also a wc-analytics namespace used by the WooCommerce admin dashboard. It is powerful, but it is not part of the published REST API reference and should be treated as unsupported and subject to change, low confidence for anything we build on.

Official client libraries: @woocommerce/woocommerce-rest-api (JavaScript), automattic/woocommerce (PHP), WooCommerce (Python), woocommerce_api (Ruby). They exist mainly to handle the OAuth 1.0a signing for us.

Authentication

Two mechanisms, chosen by the store's transport.

Over HTTPS: basic auth

The consumer key is the username and the consumer secret the password. This is the path to use, and in practice we should refuse to onboard a store that is not on HTTPS.

curl -u ck_9f1d0c5b6e2a4d7f8c3b1a0e9d8c7b6a5d4c3b2a:cs_3c2b1a0f9e8d7c6b5a4938271605f4e3d2c1b0a9 \
  "https://example-store.in/wp-json/wc/v3/orders?per_page=1&status=processing"

Some hosts strip the Authorization header, usually Apache with CGI or an over enthusiastic security plugin. The documented fallback is to pass the credentials as query parameters, which is safe only because the whole URL is inside the TLS session, though it does end up in server access logs:

GET /wp-json/wc/v3/orders?consumer_key=ck_...&consumer_secret=cs_...&per_page=1 HTTP/1.1
Host: example-store.in

Over plain HTTP: OAuth 1.0a one legged

For stores without TLS, WooCommerce implements OAuth 1.0a signing with HMAC-SHA1 and no token exchange step: there is no request token, no user authorisation redirect and no access token, only a signature over each request. Parameters are oauth_consumer_key, oauth_timestamp, oauth_nonce, oauth_signature_method=HMAC-SHA1 and oauth_signature, sent either in the query string or in an Authorization header. Note the quirk that for PUT and DELETE the method used in the signature base string must match, and some servers need ?_method=PUT on a POST instead.

Credentials never expire and there is no refresh step. The store owner can revoke a key from the same admin screen, after which every call returns HTTP 401 with woocommerce_rest_cannot_view or a similar code. Multi account is trivial: our credential store keys on (site URL, consumer key, consumer secret), and each store is independent.

Warning

A consumer key is bound to a WordPress user, not to an app. If the merchant deletes that user, the key dies with it. Ask for the key to be created against a dedicated integration account with Shop Manager or Administrator role, and record which user it was issued to.

Objects we can read

Base URL is https://{store}/wp-json/wc/v3/. Dates are ISO 8601 in the site's own timezone, with a _gmt twin on most fields; always read the _gmt variant, because a site's timezone setting is not always what the merchant thinks it is. Money is returned as a string with two decimals, controlled by the dp parameter.

Orders

GET /wp-json/wc/v3/orders. Documented parameters: context (view or edit), page, per_page (default 10), search, after, before, modified_after, modified_before, dates_are_gmt, exclude, include, offset, order (asc/desc, default desc), orderby (date, modified, id, include, title, slug), parent, parent_exclude, status (any, pending, processing, on-hold, completed, cancelled, refunded, failed, trash), customer, product, dp and created_via. For incremental sync use modified_after with dates_are_gmt=true and orderby=modified&order=asc.

{
  "id": 727,
  "parent_id": 0,
  "number": "727",
  "order_key": "wc_order_58d2d042d1d",
  "created_via": "checkout",
  "version": "9.4.0",
  "status": "processing",
  "currency": "INR",
  "date_created_gmt": "2026-09-18T19:28:02",
  "date_modified_gmt": "2026-09-19T04:02:10",
  "discount_total": "250.00",
  "discount_tax": "0.00",
  "shipping_total": "0.00",
  "shipping_tax": "0.00",
  "cart_tax": "449.82",
  "total": "2698.82",
  "total_tax": "449.82",
  "prices_include_tax": true,
  "customer_id": 26,
  "customer_note": "",
  "billing": {
    "first_name": "Priya", "last_name": "Menon", "address_1": "14 Carter Road", "address_2": "Flat 302",
    "city": "Mumbai", "state": "MH", "postcode": "400050", "country": "IN",
    "email": "priya@example.com", "phone": "9876543210"
  },
  "shipping": {
    "first_name": "Priya", "last_name": "Menon", "address_1": "14 Carter Road",
    "city": "Mumbai", "state": "MH", "postcode": "400050", "country": "IN"
  },
  "payment_method": "razorpay",
  "payment_method_title": "Razorpay",
  "transaction_id": "pay_QK7M2ZPLTabcd",
  "date_paid_gmt": "2026-09-18T19:28:08",
  "date_completed_gmt": null,
  "cart_hash": "",
  "line_items": [
    {
      "id": 315,
      "name": "Cold Pressed Coconut Oil - 500 ml",
      "product_id": 93,
      "variation_id": 732,
      "quantity": 2,
      "tax_class": "",
      "subtotal": "2499.00",
      "subtotal_tax": "449.82",
      "total": "2249.00",
      "total_tax": "404.82",
      "taxes": [{ "id": 75, "total": "404.82", "subtotal": "449.82" }],
      "sku": "CPO-500",
      "price": 1124.5,
      "meta_data": [{ "id": 2095, "key": "pa_size", "value": "500ml" }]
    }
  ],
  "tax_lines": [
    { "id": 318, "rate_code": "IN-MH-GST", "rate_id": 75, "label": "GST", "compound": false, "tax_total": "404.82", "shipping_tax_total": "0.00" }
  ],
  "shipping_lines": [
    { "id": 317, "method_title": "Free shipping", "method_id": "free_shipping", "total": "0.00", "total_tax": "0.00" }
  ],
  "fee_lines": [],
  "coupon_lines": [],
  "refunds": [{ "id": 726, "reason": "", "total": "-250.00" }],
  "meta_data": []
}

The billing and shipping blocks, plus customer_ip_address and customer_user_agent in edit context, are personally identifiable information and are returned in full with no masking.

Order items

Line items are embedded in the order, not a separate endpoint. Documented properties: id, name, product_id, variation_id, quantity, tax_class, subtotal, subtotal_tax, total, total_tax, taxes, meta_data, sku and price. The distinction that matters is subtotal versus total: subtotal is the line before order level discounts, total is after. price is the per unit price after discount, as a number rather than a string, which is a long standing inconsistency in the API. Variation attributes are carried in meta_data, with keys like pa_size, rather than in a structured field.

Products and listings

GET /wp-json/wc/v3/products. Filters include status (any, draft, pending, private, publish), type (simple, grouped, external, variable), sku, category, tag, on_sale, stock_status (instock, outofstock, onbackorder), min_price, max_price, modified_after, and orderby (date, modified, id, title, slug, price, popularity, rating, menu_order).

{
  "id": 93,
  "name": "Cold Pressed Coconut Oil",
  "slug": "cold-pressed-coconut-oil",
  "permalink": "https://example-store.in/product/cold-pressed-coconut-oil/",
  "type": "variable",
  "status": "publish",
  "catalog_visibility": "visible",
  "sku": "CPO",
  "price": "1124.50",
  "regular_price": "",
  "sale_price": "",
  "date_on_sale_from_gmt": null,
  "date_modified_gmt": "2026-09-17T11:41:02",
  "manage_stock": false,
  "stock_quantity": null,
  "stock_status": "instock",
  "backorders": "no",
  "categories": [{ "id": 9, "name": "Edible Oil", "slug": "edible-oil" }],
  "tags": [{ "id": 21, "name": "bestseller", "slug": "bestseller" }],
  "images": [{ "id": 792, "src": "https://example-store.in/wp-content/uploads/cpo.jpg", "alt": "" }],
  "attributes": [{ "id": 6, "name": "Size", "position": 0, "visible": true, "variation": true, "options": ["250 ml", "500 ml"] }],
  "variations": [732, 733],
  "meta_data": []
}

On a variable product the parent carries no price of its own except a derived display price, and regular_price is empty. The sellable unit is the variation, so our listings row is a variation for variable products and the product itself for simple products.

Inventory

There is no separate inventory endpoint and no concept of locations in core WooCommerce. Stock is three fields on the product or variation: manage_stock (boolean, or the string parent on a variation), stock_quantity (integer or null when stock is not managed) and stock_status. When manage_stock is false, stock_quantity is null and only stock_status is meaningful, which means for a large minority of stores we can know that something is in stock but not how much. Multi warehouse stock exists only through third party plugins, each with its own data shape.

GET /wp-json/wc/v3/products/<product_id>/variations returns the sellable rows:

[
  {
    "id": 732,
    "sku": "CPO-500",
    "permalink": "https://example-store.in/product/cold-pressed-coconut-oil/?attribute_size=500+ml",
    "date_modified_gmt": "2026-09-17T11:41:02",
    "price": "1124.50",
    "regular_price": "1249.50",
    "sale_price": "1124.50",
    "date_on_sale_from_gmt": "2026-09-01T00:00:00",
    "date_on_sale_to_gmt": "2026-09-30T23:59:59",
    "on_sale": true,
    "status": "publish",
    "manage_stock": true,
    "stock_quantity": 92,
    "stock_status": "instock",
    "backorders": "no",
    "weight": "0.55",
    "dimensions": { "length": "8", "width": "8", "height": "18" },
    "attributes": [{ "id": 6, "name": "Size", "option": "500 ml" }]
  }
]

Shipments and tracking

Not in core. WooCommerce records a fulfilment only as the order status moving to completed, and there is no AWB, carrier or tracking field in the wc/v3 order schema. Tracking in practice comes from a plugin, most often WooCommerce Shipment Tracking or the Advanced Shipment Tracking plugin, or from an aggregator such as Shiprocket, and those write their data into order meta_data under plugin specific keys. Our connector should read meta_data opportunistically, recognise the common keys per plugin, and otherwise fill shipments from the carrier or aggregator connector instead. Treat any shipment field we extract from meta as low confidence until the specific plugin is identified for that store.

Returns and cancellations

WooCommerce has no return object. A cancellation is the order status cancelled; a return is expressed only as a refund. GET /wp-json/wc/v3/orders/<id>/refunds gives the money view, with properties id, date_created_gmt, amount, reason, refunded_by, refunded_payment, line_items, tax_lines, shipping_lines, fee_lines and meta_data. On creation, api_refund decides whether the payment gateway is actually called and api_restock whether stock is put back.

{
  "id": 726,
  "date_created_gmt": "2026-09-21T20:07:11",
  "amount": "1124.50",
  "reason": "Wrong size",
  "refunded_by": 1,
  "refunded_payment": true,
  "line_items": [],
  "meta_data": []
}

A full refund also usually moves the order status to refunded, which is the only signal we get that goods came back. Whether they physically came back is not recorded anywhere.

Payments and settlements

None. WooCommerce records the gateway (payment_method, payment_method_title) and the gateway's own reference (transaction_id), and nothing else. There is no payout, no fee breakdown and no settlement period, because the platform never holds the money. To build settlements for a WooCommerce brand we have to connect the gateway directly: Razorpay, PayU, Cashfree, Stripe, PayPal or whatever the store uses, matching on transaction_id.

Customers

GET /wp-json/wc/v3/customers with search, email, role, orderby (id, name, registered_date) and the usual paging. Properties: id, email, first_name, last_name, username, role, billing, shipping, is_paying_customer, avatar_url, date_created_gmt, meta_data. Guest checkouts produce orders with customer_id: 0 and no customer record at all, so the order's billing.email is the only identity we get for them. All of this is unmasked PII.

Locations

A WooCommerce store has one address, readable from GET /wp-json/wc/v3/settings/general (woocommerce_store_address, woocommerce_store_city, woocommerce_default_country, woocommerce_store_postcode). Map it to a single locations row per store.

Writing back: listings, price and stock

Fully supported, provided the key has Write permission.

The batch endpoint is documented as limited to 100 objects per operation array by default, and the limit is a filterable value in PHP so a given store may allow more or fewer. Batch responses echo each object back under the matching key, with per object error entries rather than a single failure, so the result has to be inspected object by object.

{
  "update": [
    { "id": 732, "regular_price": "1299.00", "stock_quantity": 80, "manage_stock": true },
    { "id": 733, "regular_price": "699.00", "stock_quantity": 0, "stock_status": "outofstock" }
  ]
}

There is no staging, no dry run and no asynchronous job: a PUT is applied immediately and the response is the new state of the object. Setting stock_quantity is an absolute set, not a delta, and there is no compare and set, so two systems writing stock to the same store will overwrite each other without warning. If the merchant runs another integration, decide which side owns stock.

Webhooks and notifications

Webhooks are created either in wp-admin under WooCommerce, Settings, Advanced, Webhooks, or over the API at POST /wp-json/wc/v3/webhooks with name, topic, delivery_url and secret. Status is active, paused or disabled.

Core topics are resource.event pairs: coupon.created, coupon.updated, coupon.deleted, customer.created, customer.updated, customer.deleted, order.created, order.updated, order.deleted, product.created, product.updated, product.deleted. Custom topics can be mapped to any WordPress action hook. Note there is no order.paid or order.status_changed topic: a status change arrives as order.updated and we have to diff it ourselves.

The request body is the same JSON the REST API would return for that resource. Headers on delivery:

POST /webhooks/woocommerce HTTP/1.1
Content-Type: application/json
X-WC-Webhook-Source: https://example-store.in/
X-WC-Webhook-Topic: order.updated
X-WC-Webhook-Resource: order
X-WC-Webhook-Event: updated
X-WC-Webhook-Signature: 2Sg0Yw0aH6Vw7g1rN0tRk4qUu0uS0cN6fA7Z2bQ0mFo=
X-WC-Webhook-ID: 4212
X-WC-Webhook-Delivery-ID: 9931

Verification: base64 encoded HMAC-SHA256 of the raw request body, keyed with the webhook secret, compared against X-WC-Webhook-Signature.

echo -n "$RAW_BODY" \
  | openssl dgst -sha256 -hmac "$WC_WEBHOOK_SECRET" -binary \
  | base64

Retry policy: after 5 consecutive deliveries that do not return a 2xx, the webhook is set to disabled and stays that way until somebody re-enables it, which our connector can do over the API. Delivery logs, including request and response bodies, are kept under WooCommerce, Status, Logs.

Warning

Deliveries are queued and sent by WordPress cron through wp_remote_post. On a low traffic store, WordPress cron only runs when somebody loads a page, so a webhook can sit unsent for hours. Never treat WooCommerce webhooks as near real time, and always keep a modified_after poll running underneath them.

Rate limits and pagination

WooCommerce publishes no rate limit for the wc/v3 REST API. The practical limits are the host's: PHP max_execution_time, memory limit, any Cloudflare or mod_security rule in front, and whatever the shared hosting plan tolerates. Assume a small store cannot serve more than a few requests per second and that a heavy per_page=100 products call with variations can take several seconds.

Pagination is offset based. page starts at 1, per_page defaults to 10 and is generally capped at 100. Responses carry X-WP-Total and X-WP-TotalPages, plus a Link header with rel="next", rel="prev", rel="first" and rel="last". Offset paging over a table that is being written to will skip or repeat rows, so for backfills sort ascending by a stable key (orderby=id&order=asc) and page forward, or window by date. _fields=id,status,total trims the response and is worth using on any high volume sweep.

Suggested cadence: orders modified_after every 10 to 15 minutes with per_page=50, products and variations once or twice a day plus on webhook, customers daily. Back off to hourly on any store that returns 5xx or takes more than a few seconds per page.

Mapping to the unified model

orders

order_items

listings

inventory

shipments

Not available from core WooCommerce. shipment_id, carrier, awb and the event list have to come from a shipping plugin's meta_data or from the carrier connector. The only core signal is status: completed and date_completed_gmt, which can seed shipped_at with low confidence.

returns and settlements

returns: map refund id to return_id, amount to refund_amount, reason to reason, date_created_gmt to initiated_at. There is no received_at and no return status lifecycle. settlements: nothing to map, the data lives at the payment gateway.

Gaps and open questions

  • No shipment or tracking object in core, and every tracking plugin stores its data differently. We need a per plugin meta key map before shipments can be populated from WooCommerce itself.
  • No settlement or fee data at all. Per order economics for a WooCommerce brand require a gateway connector.
  • No rate limits are published, so the safe cadence is a guess that has to be tuned per store. Medium confidence on the suggested intervals.
  • The wc-analytics namespace would give us pre-aggregated reports, but it is undocumented and unsupported. Not verified whether its routes are stable across WooCommerce versions.
  • Whether a given store has manage_stock enabled at all is store by store, so inventory coverage will be partial across a population of WooCommerce brands.
  • Multi location stock, subscriptions, bookings and memberships are all plugin territory with their own REST namespaces, none checked here.
  • The batch limit of 100 is filterable per install, so a store may reject a 100 object batch or accept more. Detect it by trying and reading the error.

Sources