Skip to content

omnitrade/woocommerce

Omnitrade WooCommerce: a sale paid on the shop's checkout (orders created unpaid), the shop's orders and their payment state, refunds, the catalogue (products, variations, stock) and webhooks, over the REST API v3.

Floor
Provider · Omnitrade
Version
1.x
Conformance suite
runs, no CI read
Verified for real
Its README does not say
Licence
MIT
composer require omnitrade/woocommerce:1.x-dev

WooCommerce for omnisense/omnitrade: a sale paid on the shop's own checkout (an order created unpaid, with its payment URL), the shop's orders and their payment state, refunds, the payment gateways switched on, the catalogue (products, variations, stock) and webhooks - WooCommerce's REST API v3, at

<url>/wp-json/wc/v3/

with the consumer key and secret as HTTP basic authentication (over HTTPS). There is no sandbox address: a staging shop is the test shop.

Operation Path
purchase POST /orders
fetch, fetch order GET /orders/{id}
refund GET /orders/{id}, POST /orders/{id}/refunds
payment methods GET /payment_gateways
products GET /products, GET /products/{id}/variations
product GET /products/{id} or GET /products?slug=, its variations
inventory GET /products?include=, GET /products/{id}, or every product and variation
notify none, unless a product webhook needs its variations or its parent

Installation

composer require omnitrade/woocommerce

Requires PHP 8.2, omnisense/omnitrade (1.x) and symfony/http-client (6.4, 7 or 8). In a Symfony application with Omnitrade\Bridge\Symfony\OmnitradeBundle, the factory is registered as soon as the package is installed.

$gateway = (new WooCommerceGatewayFactory($http))->create(['url' => 'https://shop.example/', 'consumer_key' => '...', 'consumer_secret' => '...']);   // $http: the application's HTTP client; none given, the factory makes its own

Credentials

  • A REST API key with Read/Write permissions (WooCommerce → Settings → Advanced → REST API): its consumer key (ck_...) and consumer secret (cs_...). Read is enough for the catalogue; purchases and refunds need Write. The shop must be served over HTTPS for basic authentication.
  • Webhooks (same screen → Webhooks) for Order updated (and Order created), and for the catalogue Product created, Product updated, Product deleted, Product restored, all with one secret of your own: webhook_secret.

Options

Option Type Default Required
url string - yes the shop's address (https://shop.example; a trailing slash is removed)
consumer_key string - yes ck_...
consumer_secret string - yes cs_...
webhook_secret ?string null for notify() the webhooks' secret
timeout int 15 no seconds
currency string EUR no the store's currency, for the catalogue's prices (bare decimals in the API); orders carry their own
weight_unit string kg no the store's weight unit (Settings → Products): kg, g, lbs, oz; weights are given in grams
product_status string any no what fetchProducts() lists: any, or publish
omnitrade:
    gateways:
        woo:
            factory: woocommerce
            options:
                url: '%env(WOOCOMMERCE_URL)%'                       # https://shop.example
                consumer_key: '%env(WOOCOMMERCE_KEY)%'               # ck_...
                consumer_secret: '%env(WOOCOMMERCE_SECRET)%'         # cs_...
                webhook_secret: '%env(WOOCOMMERCE_WEBHOOK_SECRET)%'  # for notify()
                currency: EUR                                        # the store's, for the catalogue

Capabilities

Request Supported
Purchase yes (an unpaid order, paid on the shop's checkout)
Authorize, Capture, VoidAuthorization no
Refund yes (through the shop's payment gateway)
FetchTransaction yes (the order's payment state)
GetPaymentMethods yes (the shop's gateways enabled)
Notify yes
FetchOrder yes
FetchProducts, FetchProduct, FetchInventory yes
AffiliateLink, connected accounts, subscriptions no

Behaviour

Purchase. An order created pending, set_paid: false, answered PENDING with its payment_url as redirectUrl, where the shop's own payment gateways take the money; fetch() or the order.updated webhook say when they did. WooCommerce brings nobody back: returnUrl and cancelUrl are not used. The order:

Payment Order
amount->currency currency
lines, when they add up line_items: name, quantity, total (the line's, as a decimal); a numeric reference as product_id
no lines adding up one item of the amount, named by the description (else the reference)
discount (lines adding up) a fee line Discount of minus the discount
shipping (lines adding up) a shipping line flat_rate, Shipping
notice customer_note
reference the meta omnitrade_reference
metadata more meta_data
customer billing: email, first name (the name's first word) and last name (the rest), phone, the two first street lines, postcode, city, country

Not sent: idempotencyKey, method, locale, destination. An answer without an id or a payment_url is a ProviderException (Order <id> was created, but the shop gave no payment URL.).

Fetch. The order's status, read with its refunds:

status Status
processing, completed PAID; REFUNDED or PARTIALLY_REFUNDED when its refunds add up to the total or less
refunded REFUNDED
cancelled CANCELLED
failed REFUSED
pending, on-hold, checkout-draft, any other PENDING, with its payment_url

The amount is its total, refunded its refunds' sum, method its payment_method, metadata its number, order_key and transaction_id, createdAt its date_created_gmt.

Fetch order. The same order as a PlatformOrder: number #<number>, the status as above, total, the customer from billing (email, name, phone, customer_id as reference) and the shipping address (else the billing one), its line_items (name, price, quantity, SKU, product_id as reference), financialStatus the order's status, fulfillmentStatus completed for a completed order (else null), createdAt.

Refund. The order is read for its currency and total; POST /orders/{id}/refunds with the amount (the order's total when none is given), reason, and api_refund: true: the shop's payment gateway gives the money back when it can. The refund is recorded as REFUNDED, whatever its amount; transactionReference is the order's id, message the reason. The idempotency key is not sent.

Payment methods. The gateways GET /payment_gateways lists with enabled true: id as code, title as label, the kind guessed from the id (stripe, card: card; paypal, apple, google: wallet; bacs, sepa, bank: bank; klarna, afterpay: buy now pay later; else other), the gateway in raw.

Notify. X-WC-Webhook-Signature is checked first (base64 of the HMAC-SHA256 of the raw body with webhook_secret); the topic is X-WC-Webhook-Topic, the event's id X-WC-Webhook-Delivery-ID.

Topic status transaction reference
order.created, order.updated the order's status, as fetch() reads it the order the order's id
order.deleted none none the order's id
product.created, product.updated, product.restored none none the product's id; product carries it
product.deleted none none the product's id, no product
any other none none none

raw holds the topic, the X-WC-Webhook-Resource and X-WC-Webhook-Event headers and the payload. How the product webhooks are read: WooCommerce's catalogue.

Errors from the shop. Every call reads the JSON answer; an HTTP status of 400 or more is a ProviderException with WooCommerce's message and its code as providerCode (woocommerce_rest_shop_order_invalid_id).

Example

<?php

require __DIR__.'/vendor/autoload.php';

use Omnitrade\Model\Customer;
use Omnitrade\Model\Line;
use Omnitrade\Model\Money;
use Omnitrade\Model\Payment;
use Omnitrade\WooCommerce\WooCommerceGatewayFactory;
use Symfony\Component\HttpClient\HttpClient;

$woo = (new WooCommerceGatewayFactory(HttpClient::create()))->create([
    'url' => getenv('WOOCOMMERCE_URL'),
    'consumer_key' => getenv('WOOCOMMERCE_CONSUMER_KEY'),
    'consumer_secret' => getenv('WOOCOMMERCE_CONSUMER_SECRET'),
    'webhook_secret' => getenv('WOOCOMMERCE_WEBHOOK_SECRET'),
]);

$transaction = $woo->purchase(new Payment(
    amount: Money::of(2750, 'EUR'),
    reference: 'ORDER-1042',
    customer: new Customer(email: 'camille@example.org', name: 'Camille Durand', street: ['12 rue des Juifs'], postalCode: '67000', city: 'Strasbourg', country: 'FR'),
    lines: [new Line('Margaux 2019', Money::of(1200, 'EUR'), 2, reference: '501')],
    shipping: Money::of(350, 'EUR'),
));
header('Location: '.$transaction->redirectUrl);   // the shop's checkout for that order

// later: the order.updated webhook, or
$woo->fetch($transaction->reference)->isPaid();
$woo->refund($transaction->reference, Money::of(500, 'EUR'), reason: 'One bottle broken');

A WooCommerce catalogue copied page by page, offline, with its output, is in the core's manual (catalogue). In the core's Docker harness: docker compose run --rm omnitrade purchase woocommerce --amount 1990, catalogue woocommerce --inventory.

Errors

Every failure is an Omnitrade\Exception\ProviderException whose provider is woocommerce:

  • an HTTP status of 400 or more: WooCommerce's message, its code as providerCode;
  • a body that is not JSON (an HTML page from WordPress or a proxy): WooCommerce answered HTTP <status> with a body that is not JSON.; a network failure: WooCommerce request failed: ..., a copy of the HTTP client's exception as previous;
  • WooCommerce created no order., Order <id> was created, but the shop gave no payment URL., WooCommerce recorded no refund.;
  • a webhook: InvalidNotificationException without webhook_secret or for a signature that does not match; a verified body that is not JSON is a ProviderException.

A missing url, consumer_key or consumer_secret is an InvalidConfigException when the gateway is built; Authorize, Capture, VoidAuthorization, AffiliateLink, connected accounts and subscriptions raise RequestNotSupportedException before any call. fetchProduct() of an id the shop does not have (..._invalid_id) is null.

No failure carries the consumer key and secret: a network failure's message has them masked as *** (the key parameters of a URL too), its previous is a copy of the HTTP client's exception (the same class and code, without its request), and no trace of the chain keeps its arguments, where the request's options stood (No secret in a failure).

Verified, and not

Tested offline, 23 tests on answers shaped as the REST API v3 documents them: Tests/WooCommerceGatewayTest.php (a purchase as an order paid on the shop's checkout, an order fetched as its status means, a refund and the payment methods, a webhook checked then read), Tests/WooCommerceCatalogueTest.php (the catalogue page by page, the published ones only when asked, a simple product as its own only variant, a variable product's variations as its variants, a product found by id, slug or variation or not at all, the inventory of products and variations, the product webhooks) and Tests/WooCommerceContractTest.php, the family's conformance suite (nothing to fix).

Not verified: no call was made to a WooCommerce shop.

Responsibility

An independent, unofficial client, not affiliated with, endorsed by or sponsored by Automattic Inc. (WooCommerce). Whoever runs it complies with the terms of the shop it reaches and WooCommerce's (woocommerce.com/terms-conditions) and obtains what they require: see the README's Responsibility section.

Propose a change docs/index.md

Catalogue

The shop's products, variations and stock, read through the omnitrade catalogue contract over the REST API v3.

Setting it up

composer require omnitrade/woocommerce symfony/http-client
omnitrade:
    gateways:
        woo:
            factory: woocommerce
            options:
                url: '%env(WOOCOMMERCE_URL)%'                       # https://shop.example
                consumer_key: '%env(WOOCOMMERCE_KEY)%'               # ck_..., Read is enough for the catalogue
                consumer_secret: '%env(WOOCOMMERCE_SECRET)%'         # cs_...
                webhook_secret: '%env(WOOCOMMERCE_WEBHOOK_SECRET)%'  # for the product webhooks
                currency: EUR
                weight_unit: kg
                product_status: any
Option Default
currency EUR The store's currency (WooCommerce → Settings → General): the REST API gives prices as bare decimals.
weight_unit kg The store's weight unit (Settings → Products): kg, g, lbs, oz; weights are given in grams.
product_status any What fetchProducts() lists: any (drafts, pending and private ones too), or publish.

Requests

Request What is sent
fetchProducts(?cursor, ?updatedSince, ?query, limit) GET /products?per_page=limit (≤ 100)&page=cursor&status=any&orderby=id&order=asc; updatedSince → modified_after=2026-10-01T08:00:00&dates_are_gmt=true, query → search. The cursor is the page number; the next one is given while X-WP-TotalPages says there is one (without the header: while a page comes full). Each variable product's variations are read with it, GET /products/<id>/variations (one call per variable product). Pass the same updatedSince and query with the cursor.
fetchProduct($reference) A numeric id → GET /products/<id> (a variation's id gives its product); any other id is a slug, and a page's URL gives its slug (the last segment of /product/<slug>/), or its id with ?p=<id> → GET /products?slug=<slug>&status=any. null when there is none.
fetchInventory($ids) The ids through GET /products?include=… (a simple product's own stock, a variable product's variations'), the rest - variations - one by one through GET /products/<id>; none given, every product and variation of the shop. Unknown ids are left out.

What is mapped how

WooCommerce omnitrade
id Product::$reference
name (HTML entities decoded), description (else short_description), slug title, description, handle
brands (WooCommerce 9.6+), else a Brand/Marque attribute (pa_brand, pa_marque) brand
categories, tags categories, tags (their names)
attributes not used for variations attributes (['Cépage' => ['Pinot blanc', 'Auxerrois'], 'Contenance' => '75 cl'])
attributes used for variations (variable products) options (Option)
images media (images)
permalink url
status publish / draft, pending, private, future / trash Product::ACTIVE / DRAFT / ARCHIVED
date_modified_gmt updatedAt
a simple product its one variant: reference the product's id, title null
a variable product's variations its variants: reference the variation's id, title its values ("2019"), options its attribute values (['Millésime' => '2019'], an "any" value left out), image, its own permalink on the offer
sku, global_unique_id sku, barcode
regular_price, sale_price Offer: on sale, the price is sale_price and compareAt the regular one; else regular_price (or price). No price: no offer. available = published, purchasable and not outofstock.
manage_stock, stock_quantity Stock: the quantity when managed, else null with tracked: false; a variation's "parent" counts on its product's
weight weight, in grams

Webhooks

Add webhooks (WooCommerce → Settings → Advanced → Webhooks) for Product created, Product updated, Product deleted (and Product restored) to your endpoint, with the same secret as the order webhook; notify() checks X-WC-Webhook-Signature.

Topic Notification
product.created, product.updated, product.restored $product, mapped as above. The payload of a variable product lists its variations' ids only: they are read along (one call). A variation's payload is read as its whole product (two calls). $reference is the product's id.
product.deleted $product null, $reference the product's id.

Examples

$page = $woo->fetchProducts(updatedSince: $lastSync);
while (true) {
    foreach ($page->products as $product) {
        // $product->brand, $product->options, $product->variants[0]->price(), ->stock->quantity
    }
    if (!$page->hasMore()) {
        break;
    }
    $page = $woo->fetchProducts($page->next, updatedSince: $lastSync);
}

$product = $woo->fetchProduct('https://shop.example/product/margaux/');
$stocks = $woo->fetchInventory(['501', '602']);

With the harness of omnisense/omnitrade (core/docker):

docker compose run --rm omnitrade catalogue woocommerce --query=margaux
docker compose run --rm omnitrade catalogue woocommerce --inventory

Propose a change docs/catalogue.md