WooCommerce REST API Adapter
v3 Compatible
Last updated: May 15, 2026
A drop-in compatible REST API for STOAR that mirrors WooCommerce v3's wire format — same URL paths (/wp-json/wc/v3/...), same flat query-parameter syntax, same JSON response field names, same auth options. Existing WooCommerce client libraries (e.g. automattic/woocommerce PHP/JS, klarna/woocommerce-rest-api Node) can talk to STOAR without modification.
The adapter is the second implementation of STOAR's extensible API adapter framework (the first being Magento). It demonstrates how a fundamentally different vendor flavour — flat query params instead of nested searchCriteria[…], Basic Auth instead of Bearer-only, status vocabulary processing/completed instead of paid/delivered — sits next to Magento on the same data layer.
Table of contents #
- Quick start
- Authentication
- Endpoints
- Query parameters
- Field mapping (WooCommerce ↔ STOAR)
- Status translation
- Response shape
- Real-world example
- Error shape
- Rate limiting
Quick start #
# 1. Get a Sanctum token with woocommerce:admin ability — issued via /manager/api-tokens
# or via the Magento token endpoint (POST /rest/V1/integration/admin/token), then
# grant the woocommerce:admin ability inside /manager.
TOKEN="2|L5pXj63e881lkgtHPK7Hjozyq7Pb3trHCy0iaDRf76405f72"
# 2. List the last 5 orders (Bearer auth — STOAR extension; works alongside Basic)
curl -s "https://app.stoar.ai/wp-json/wc/v3/orders?per_page=5&orderby=date&order=desc" \
-H "Authorization: Bearer $TOKEN" \
| jq '.[] | {id, status, total, currency}'
# 3. Use HTTP Basic just like a real WooCommerce store
curl -s "https://app.stoar.ai/wp-json/wc/v3/products?per_page=3" \
-u "any-key:$TOKEN"
# 4. Or stuff credentials into the query string (still over HTTPS only)
curl -s "https://app.stoar.ai/wp-json/wc/v3/products?consumer_key=any&consumer_secret=$TOKEN"
Authentication #
WooCommerce supports three auth flavours; the adapter accepts all of them and converts them into the same Sanctum personal-access-token check internally. Tokens are issued from STOAR's /manager/api-tokens page (or programmatically via AdminUser::createToken('label', ['woocommerce:admin'])).
| Method | Format | Use case |
|---|---|---|
| HTTP Basic | Authorization: Basic base64(consumer_key:consumer_secret) |
Default for most WC client libraries — over HTTPS only |
| Query params | ?consumer_key=…&consumer_secret=… |
Quick curl testing — over HTTPS only |
| Bearer (STOAR extension) | Authorization: Bearer <token> |
Native Sanctum — interchangeable with the Magento adapter |
Mapping to Sanctum
The adapter ignores consumer_key (you can pass any string — "any-key" works). The consumer_secret MUST be a valid Sanctum personal-access token whose abilities array contains woocommerce:admin.
This mapping is purely an interpretation layer; from STOAR's perspective, every WC request is just a Bearer-authenticated Sanctum request. The same personal-access-token row backs both:
- a Magento client using
/rest/V1/orders(needsmagento:adminability) - a WooCommerce client using
/wp-json/wc/v3/orders(needswoocommerce:adminability)
You can issue a single token with both abilities, or separate ones — your call.
Customer tokens
Customer-scoped endpoints are not yet available. The woocommerce:customer ability is reserved for future use.
Endpoints #
All paths are relative to /wp-json/wc/v3 (verbatim WordPress REST namespace).
GET /wp-json/wc/v3/orders
List orders.
Authorization: Bearer / Basic / query, with woocommerce:admin ability.
Query params: see Query parameters.
Response (200): flat JSON array of orders. Pagination travels in headers, NOT inside the body:
HTTP/1.1 200 OK
X-WP-Total: 150
X-WP-TotalPages: 8
Content-Type: application/json
[
{ /* order */ },
{ /* order */ }
]
Why headers, not envelope? This is WooCommerce's actual convention — different from Magento's
{items, search_criteria, total_count}wrapper. WC client libraries (Automattic's official PHP/JS clients) readX-WP-Totalto drive their pagination cursors.
GET /wp-json/wc/v3/orders/{id}
Single order by numeric id.
Response (200): see Response shape.
Errors:
{
"code": "woocommerce_rest_shop_order_invalid_id",
"message": "Invalid shop_order ID.",
"data": { "status": 404, "id": 99999 }
}
GET /wp-json/wc/v3/orders/{id}/notes
Lists the order's status-history rows reshaped as WooCommerce notes.
Response (200):
[
{
"id": 42,
"author": "system",
"date_created": "2026-04-10T12:00:00+00:00",
"date_created_gmt": "2026-04-10T12:00:00+00:00",
"note": "Payment received",
"customer_note": false,
"_links": { "self": [...], "collection": [...], "up": [...] }
}
]
customer_note is always false because Stoar does not distinguish customer-visible notes from admin-only ones at the data level.
POST /wp-json/wc/v3/orders/{id}/notes
Adds a note (mapped onto a new OrderStatusLog row in Stoar).
Request:
{ "note": "Customer phoned to confirm delivery slot" }
customer_note (bool) is accepted but currently has no effect.
Response (201): the new note in the same shape as GET .../notes items.
Errors:
400rest_invalid_param— missingnote404— unknown order id
GET /wp-json/wc/v3/orders/{id}/refunds
Lists refunds for an order. Stoar has no separate refund table — the adapter returns either:
[]whenrefunded_amount === 0, or[{...}]with one synthetic refund row whoseid === parent_id === order.id
[
{
"id": 123,
"parent_id": 123,
"date_created": "2026-04-12T08:00:00+00:00",
"amount": "30.00",
"reason": "",
"refunded_by": 0,
"refunded_payment": true,
"meta_data": [],
"line_items": [],
"api_refund": true
}
]
POST /wp-json/wc/v3/orders/{id}/refunds
Issues a refund. Delegates to Stoar's existing Order::processRefund() which calls Stripe via StripeService::processRefund(). Stoar marks the order refunded on a full refund, or accumulates refunded_amount for partials.
Request:
{ "amount": 30, "reason": "Customer changed mind" }
Both fields are optional. If amount is omitted, Stoar refunds the full remaining refundable balance.
Response (201): the refund row in the shape above.
Errors:
404— unknown order id422woocommerce_rest_invalid_state— order is not refundable (no Stripe payment intent, status not paid/processing/shipped/delivered)
GET /wp-json/wc/v3/products
List products.
Query params: see Query parameters.
Response (200): flat array, with X-WP-Total / X-WP-TotalPages headers.
GET /wp-json/wc/v3/products/{id}
Single product by numeric id (NOT SKU like Magento).
Response (200): see Response shape.
Errors: 404 woocommerce_rest_product_invalid_id.
Query parameters #
WooCommerce uses flat query-string parameters — far simpler than Magento's nested searchCriteria[…] grammar. Translation into the same vendor-agnostic OrderQuery / ProductQuery value objects happens inside the adapter's Search/OrderQueryParser and Search/ProductQueryParser classes.
Common params (orders + products)
| Param | Default | Purpose |
|---|---|---|
per_page |
10 |
Results per page (max 100) |
page |
1 |
Page number, 1-indexed |
orderby |
date |
Sort field — see per-resource lists below |
order |
desc |
asc or desc |
include |
— | Comma-separated id list (?include=1,2,3) |
exclude |
— | Comma-separated id list to exclude |
search |
— | Substring match — see per-resource notes |
Orders only
| Param | Example | Effect |
|---|---|---|
status |
processing,completed |
CSV — translated to Stoar status values; OR'd within the group |
customer |
42 |
Filter by customer_id |
after |
2024-01-01T00:00:00 |
created_at >= … |
before |
2024-12-31T23:59:59 |
created_at <= … |
search |
jane |
LIKE on customer_email |
orderby values |
date (default), id, include, title |
date→created_at, title→customer_email |
Products only
| Param | Example | Effect |
|---|---|---|
status |
publish, draft |
Translated to Stoar active / inactive |
type |
simple, variable |
variable translates to Stoar configurable |
featured |
true / false |
Filters on is_featured |
category |
5 |
Single category id |
sku |
WID-1 |
Exact match |
slug |
widget-pro |
Exact match |
min_price |
10 |
price >= … |
max_price |
100 |
price <= … |
search |
widget |
LIKE on product name |
orderby values |
date, id, include, title, price, slug |
title→name, slug→slug |
Unknown parameters are silently ignored (matching WC behaviour). Filter values that don't translate to anything sensible (e.g. ?status=on-hold for products) result in an empty group, not a 400.
Field mapping (WooCommerce ↔ STOAR) #
Order
| WooCommerce field | STOAR source | Notes |
|---|---|---|
id |
id |
numeric |
number |
id (cast to string) |
WC convention |
order_key |
lookup_token |
Stoar's per-order magic-link token |
status |
status (translated) |
see Status translation |
currency |
currency (uppercased) |
eur → EUR |
total |
total_amount |
2-dp string |
cart_tax, total_tax |
tax_amount |
2-dp string |
shipping_total |
shipping_amount |
2-dp string |
discount_total |
discount_amount |
2-dp string |
customer_id |
customer_id ?? 0 |
guests get 0 |
customer_note |
customer_notes |
|
billing.* |
billing_info JSON column |
flat WC shape |
shipping.* |
shipping_info JSON column |
flat WC shape |
payment_method |
first non-archived OrderPayment.gateway |
falls back to Order.payment_method |
payment_method_title |
derived from gateway key | stripe → "Credit / Debit Card", etc. |
transaction_id |
stripe_payment_intent_id |
|
date_created, date_modified |
created_at, updated_at |
ISO8601 |
date_paid |
first succeeded OrderPayment.created_at |
null if no payment recorded |
date_completed |
updated_at if status === delivered |
null otherwise |
line_items[] |
Order.items (with variant + product) |
see below |
coupon_lines[] |
derived from coupon_code + discount_amount |
empty when no coupon |
refunds[] |
one synthetic entry when refunded_amount > 0 |
see Refunds endpoints |
meta_data[] |
always includes _stoar_status and _stoar_lookup_token |
extension data |
Order line_item
| WC field | Stoar source |
|---|---|
id |
OrderItem.id |
name |
OrderItem.name |
product_id |
OrderItem.product_id ?? 0 |
variation_id |
OrderItem.variant_id ?? 0 |
quantity |
OrderItem.quantity |
subtotal, total |
price * quantity (2-dp string) |
subtotal_tax, total_tax |
OrderItem.tax_amount |
sku |
variant SKU first, falls back to product SKU |
price |
OrderItem.price |
Product
| WC field | STOAR source | Notes |
|---|---|---|
id |
id |
|
name, slug, sku, description, short_description |
passthrough | |
permalink |
url('/product/' . slug) |
|
type |
type (translated) |
configurable ↔ variable |
status |
status (translated) |
active ↔ publish |
featured |
is_featured |
bool |
regular_price |
price (raw) |
2-dp string |
sale_price |
special_price (or "" when null) |
2-dp string |
price |
min(regular, sale) | 2-dp string |
on_sale |
special_price !== null && special_price < price |
|
purchasable |
stock > 0 && status === 'active' |
|
manage_stock |
always true |
|
stock_quantity |
stock; for configurable, sum of variant stock |
|
stock_status |
instock / outofstock |
|
weight |
weight (string) |
|
tax_class |
tax_class_id (string) |
|
categories[] |
one element from category relation |
Stoar has a single primary category |
images[] |
image_path + gallery_paths array |
first entry is primary |
variations[] |
variant ids (only for variable products) |
|
meta_data[] |
always includes _stoar_id, _stoar_low_stock_threshold, _stoar_image_prompt |
|
attributes[], default_attributes[] |
[] |
Stoar has no global attribute system |
tags[], related_ids[], upsell_ids[], cross_sell_ids[] |
[] |
not modelled in Stoar |
dimensions |
empty | not tracked |
average_rating, rating_count |
"0.00", 0 |
not aggregated at this level |
Status translation #
WooCommerce and Stoar use different status vocabularies. The adapter translates bidirectionally: when filtering (?status=processing), WC values are mapped to Stoar; when serialising responses, Stoar's status is mapped back to WC.
Order status
| WooCommerce | Stoar | Notes |
|---|---|---|
pending |
pending |
unpaid, awaiting payment |
processing |
paid (rendered) / accepts paid (filter) |
"payment captured, fulfilment in progress" |
processing |
processing, shipped (rendered) |
Stoar's processing and shipped both render as WC processing |
completed |
delivered |
|
cancelled |
cancelled |
|
refunded |
refunded |
|
on-hold |
pending (filter only) |
Stoar has no explicit on-hold state |
failed |
cancelled (filter only) |
best-fit; Stoar has no explicit failed state |
Product status
| WooCommerce | Stoar |
|---|---|
publish |
active |
draft, pending, private |
inactive |
Product type
| WooCommerce | Stoar |
|---|---|
simple |
simple |
variable |
configurable |
grouped, external |
(unsupported — not in Stoar) |
Filter values that map to null (e.g. ?status=trash for products) are silently ignored — they don't error.
Response shape #
Order — annotated example
{
"id": 456,
"parent_id": 0,
"number": "456",
"order_key": "abc123…",
"created_via": "checkout",
"version": "8.5.0",
"status": "processing",
"currency": "EUR",
"date_created": "2026-04-10T09:00:00+00:00",
"date_modified": "2026-04-10T09:30:00+00:00",
"discount_total": "0.00",
"discount_tax": "0.00",
"shipping_total": "5.00",
"shipping_tax": "0.00",
"cart_tax": "10.00",
"total": "115.00",
"total_tax": "10.00",
"prices_include_tax": false,
"customer_id": 45,
"customer_note": "",
"billing": { /* WC address shape (flat) */ },
"shipping": { /* WC address shape (flat) */ },
"payment_method": "stripe",
"payment_method_title": "Credit / Debit Card",
"transaction_id": "pi_test_…",
"date_paid": "2026-04-10T09:05:00+00:00",
"date_completed": null,
"cart_hash": "",
"meta_data": [
{ "id": 0, "key": "_stoar_status", "value": "paid" },
{ "id": 0, "key": "_stoar_lookup_token", "value": "abc123…" }
],
"line_items": [
{
"id": 456,
"name": "Widget",
"product_id": 789,
"variation_id": 12,
"quantity": 2,
"tax_class": "",
"subtotal": "100.00",
"subtotal_tax": "10.00",
"total": "100.00",
"total_tax": "10.00",
"taxes": [],
"meta_data": [],
"sku": "WID-1-A",
"price": 50
}
],
"tax_lines": [],
"shipping_lines": [
{
"id": 0,
"method_title": "Standard",
"method_id": "flat_rate",
"instance_id": "",
"total": "5.00",
"total_tax": "0.00",
"taxes": [],
"meta_data": []
}
],
"fee_lines": [],
"coupon_lines": [],
"refunds": [],
"_links": {
"self": [{ "href": "https://app.stoar.ai/wp-json/wc/v3/orders/456" }],
"collection": [{ "href": "https://app.stoar.ai/wp-json/wc/v3/orders" }]
}
}
Product — abbreviated example
{
"id": 789,
"name": "Widget",
"slug": "widget",
"permalink": "https://app.stoar.ai/product/widget",
"type": "simple",
"status": "publish",
"featured": true,
"catalog_visibility": "visible",
"description": "<p>A great widget</p>",
"short_description": "Great widget",
"sku": "WID-1",
"price": "79.99",
"regular_price": "99.99",
"sale_price": "79.99",
"on_sale": true,
"purchasable": true,
"manage_stock": true,
"stock_quantity": 42,
"stock_status": "instock",
"weight": "1.5",
"categories": [{ "id": 5, "name": "Widgets", "slug": "widgets" }],
"images": [
{ "id": 0, "src": "products/widget.webp", "name": "primary", "position": 0, "alt": "" },
{ "id": 0, "src": "products/g1.webp", "name": "gallery-1", "position": 1, "alt": "" }
],
"attributes": [],
"variations": [],
"meta_data": [
{ "id": 0, "key": "_stoar_id", "value": "789" },
{ "id": 0, "key": "_stoar_low_stock_threshold", "value": "3" }
],
"_links": {
"self": [{ "href": "https://app.stoar.ai/wp-json/wc/v3/products/789" }],
"collection": [{ "href": "https://app.stoar.ai/wp-json/wc/v3/products" }]
}
}
For a configurable / variable product, variations is populated with variant ids, and stock_quantity is the sum of variant stock.
Real-world example #
Live response from production for GET /wp-json/wc/v3/orders/10126 (USD $936.98, two simple-product line items, paid via PayID). PII anonymised.
Request
GET /wp-json/wc/v3/orders/10126
Authorization: Bearer 2|L5pXj63e881lkgtHPK7Hjozyq7Pb3trHCy0iaDRf76405f72
Response
{
"id": 10126,
"parent_id": 0,
"number": "10126",
"order_key": "REDACTED-FOR-DOCS",
"created_via": "checkout",
"version": "8.5.0",
"status": "processing",
"currency": "USD",
"date_created": "2025-06-03T04:56:43+00:00",
"date_modified": "2025-06-03T04:56:43+00:00",
"discount_total": "0.00",
"discount_tax": "0.00",
"shipping_total": "0.00",
"shipping_tax": "0.00",
"cart_tax": "0.00",
"total": "936.98",
"total_tax": "0.00",
"prices_include_tax": false,
"customer_id": 5794,
"customer_note": "",
"billing": {
"first_name": "Jane",
"last_name": "Doe",
"company": "",
"address_1": "1 Example Street",
"address_2": "",
"city": "Phoenix",
"state": "AZ",
"postcode": "85001",
"country": "US",
"email": "",
"phone": "+1-555-0100"
},
"shipping": { /* same shape as billing */ },
"payment_method": "payid",
"payment_method_title": "PayID",
"transaction_id": "",
"date_paid": null,
"date_completed": null,
"cart_hash": "",
"meta_data": [
{ "id": 0, "key": "_stoar_status", "value": "paid" },
{ "id": 0, "key": "_stoar_lookup_token", "value": "REDACTED-FOR-DOCS" }
],
"line_items": [
{
"id": 30219,
"name": "Reloop Terminal Mix 8",
"product_id": 112238,
"variation_id": 95589,
"quantity": 3,
"tax_class": "",
"subtotal": "897.00",
"subtotal_tax": "0.00",
"total": "897.00",
"total_tax": "0.00",
"taxes": [],
"meta_data": [],
"sku": "RELOOP_TERMINALMIX8_025-DEF",
"price": 299
},
{
"id": 30220,
"name": "Premium Skateboard Socks",
"product_id": 51706,
"variation_id": 33857,
"quantity": 2,
"tax_class": "",
"subtotal": "39.98",
"subtotal_tax": "0.00",
"total": "39.98",
"total_tax": "0.00",
"taxes": [],
"meta_data": [],
"sku": "SK8-SOCK-027-DEF",
"price": 19.99
}
],
"tax_lines": [],
"shipping_lines": [
{
"id": 0,
"method_title": "Free Shipping",
"method_id": "flat_rate",
"instance_id": "",
"total": "0.00",
"total_tax": "0.00",
"taxes": [],
"meta_data": []
}
],
"fee_lines": [],
"coupon_lines": [],
"refunds": [],
"_links": {
"self": [{ "href": "https://app.stoar.ai/wp-json/wc/v3/orders/10126" }],
"collection": [{ "href": "https://app.stoar.ai/wp-json/wc/v3/orders" }]
}
}
Caveats integrators should know
These overlap with Magento's caveats — the underlying Stoar data is the same, just rendered in a different envelope:
| Field | Observed | Why |
|---|---|---|
transaction_id, date_paid |
"" / null for paid orders |
Pulled from Stoar's OrderPayment audit log; legacy orders predating the log show empty |
payment_method |
"payid" |
This is the STOAR gateway-key (stripe / bank_transfer / cash_on_delivery / payid / invoice) — not a WC-canonical method name |
payment_method_title |
derived from gateway key | hardcoded mapping — patch in OrderResource::paymentMethodTitle() to extend |
version |
always "8.5.0" |
hardcoded — not the WC version really running |
created_via |
always "checkout" |
Stoar has no API-create flow yet |
customer_ip_address, customer_user_agent |
always "" |
not stored on Order |
cart_hash |
always "" |
not stored |
tax_lines |
always [] |
Stoar tracks tax at the order level only, not per jurisdiction |
attributes, default_attributes (products) |
always [] |
Stoar has no global attribute taxonomy |
meta_data._stoar_lookup_token |
per-order magic-link token | Same security warning as Magento — possession is sufficient to view the order without auth. Never expose this to client-side code or logs. |
Error shape #
WooCommerce errors use:
{
"code": "machine_readable_code",
"message": "Human-readable message",
"data": { "status": 404, "id": 99999 }
}
…NOT Magento's {message, parameters, trace}.
| HTTP | Trigger | code |
|---|---|---|
| 400 | bad query param / missing required body | rest_invalid_param |
| 401 | missing or invalid token | woocommerce_rest_cannot_view |
| 403 | wrong-ability token (e.g. magento:admin-only token, or customer token) |
woocommerce_rest_authorization_required |
| 404 | unknown resource id | woocommerce_rest_shop_order_invalid_id / woocommerce_rest_product_invalid_id |
| 422 | precondition failed (refund non-refundable order, …) | woocommerce_rest_invalid_state |
| 429 | rate-limit exceeded | woocommerce_rest_too_many_requests |
| 500 | unexpected server error | woocommerce_rest_internal_error |
data.status always mirrors the HTTP code. Resource-specific keys (data.id) are added when meaningful.
Rate limiting #
The same Sanctum-token-keyed throttle that protects Magento (api-rest, default 60 req/min) protects WC routes. Configure both globally at /manager/api-settings. When triggered, the response uses the WC error envelope:
{
"code": "woocommerce_rest_too_many_requests",
"message": "Too many requests. Please retry after a short pause.",
"data": { "status": 429 }
}
See Magento's Rate limiting section for the full configuration UI walkthrough.
See also #
app/Api/Core/OrderRepository.php,app/Api/Core/ProductRepository.php— the abstraction boundary every adapter relies onapp/Api/Adapters/WooCommerce/Support/StatusTranslator.php— bidirectional vocabulary mapping- WooCommerce REST API reference — the upstream spec this adapter mirrors