woo_client 0.3.1
woo_client: ^0.3.1 copied to clipboard
A WooCommerce client for Dart: the public Store API for carts and checkout with no API keys, and the full admin REST API. Typed, batched, and tested.
woo_client #
A WooCommerce client for Dart. Both of WooCommerce's APIs — the public Store API for shoppers, and the admin REST API for everything else.
// No API keys. This is what a shipped app should use.
final store = WooStore(baseUrl: 'https://your-store.com');
await store.cart.addItem(id: 799, quantity: 2);
final cart = await store.cart.get();
print(cart.totals.totalPrice); // $82.56

Every price above came off the wire as an integer — "12900" — and printed
itself in the store's own currency format. The stock lines are the store's own
wording. Source in storefront/.
Pure Dart, so it runs in Flutter, on a server, and in a CLI. Three dependencies, all from the Dart team.
Which API you want #
WooCommerce has two, and picking the wrong one is the single most expensive mistake in a Flutter storefront.
WooStore — Store API |
WooCommerce — admin API |
|
|---|---|---|
| Credentials | None | Consumer key, or an application password |
| Can see | The public catalogue, and the caller's own cart | Everything: every customer, every order, every setting |
| Cart & checkout | Yes | No |
| Safe in a shipped app | Yes | No |
| Use it for | A shopper's app or storefront | A server, a CLI, an admin tool |
Anything you ship to a device can be read off that device. A consumer key in a Flutter app is your whole store, handed to anyone who downloads it — no amount of obfuscation changes that. The Store API exists precisely so you do not need one.
To be exact about what is and is not new here, because absolutes are easy to
get wrong: two long-abandoned packages (woocommerce, last published 2020, and
its fork flutter_wp_woocommerce, 2022) do call a handful of Store API cart
endpoints. Neither sends a Cart-Token, so both depend on a cookie session and
neither works from an app; neither touches checkout or shipping rates. Across
every WooCommerce package on pub.dev, Cart-Token appears in none of them.
This is the only one that implements the Store API's session and checkout, and
therefore the only one usable outside a browser.
If you need admin data in a shipped app, put your own backend in front and use
WooCredentials.bearer against it.
Install #
dart pub add woo_client
The Store API #
Browsing #
final page = await store.products.list(
search: 'beanie',
category: 21,
orderBy: StoreProductOrderBy.popularity,
);
page.totalItems; // 57, from the store's own header
page.hasMore; // true
final product = await store.products.get(799);
final linked = await store.products.bySlug('beanie-with-logo'); // deep links
final categories = await store.products.categories();
Money that prints itself #
The Store API sends prices as integer minor units — "1800" is eighteen
dollars — with the store's own symbol, separators, and prefix alongside. A
client that ignores that shows $1800.
print(cart.totals.totalPrice); // $82.56
cart.totals.totalPrice.minorUnits; // 8256
cart.totals.totalPrice.amount; // 82.56
// Arithmetic is on integers, so it is exact.
final subtotal = item.price * item.quantity;
It respects the store's own settings, not your locale: a euro store gets
1.234,56 €, a yen store gets ¥1,800 with no decimal part at all.
The cart #
Every cart call returns the whole cart, because that is what the store sends — adding one item can change shipping, tax, and which coupons still apply.
await store.cart.addItem(id: 799, quantity: 2);
await store.cart.addItem(
id: variation.id,
variation: product.cartAttributes({'Colour': 'blue'}),
);
await store.cart.updateItem(key: item.key, quantity: 3);
await store.cart.removeItem(item.key);
await store.cart.applyCoupon('SAVE10');
// Several at once: one request and one recalculation, not one of each per item.
await store.cart.addItems({799: 1, 812: 2, 815: 1});
Quantity limits come with the cart, and know about minimums and multiples — so a stepper built on them cannot ask for something the store will refuse:
item.limits.clamp(3); // 4, if the product is sold in twos
item.limits.clamp(99); // 12, if that is all the stock there is
Shipping #
A country and a postcode are enough to get quotes; you do not need a full address.
final cart = await store.cart.updateCustomer(
shippingAddress: StoreAddress(postcode: 'N1 7GU', country: 'GB'),
);
for (final package in cart.shippingPackages) {
for (final rate in package.rates) {
print('${rate.name} — ${rate.price}'); // Flat rate — $13.00
}
}
await store.cart.selectShippingRate(packageId: 0, rateId: 'flat_rate:10');
Checkout #
final result = await store.checkout.submitAndClear(
billingAddress: address,
paymentMethod: 'stripe',
expectedTotal: cart.totals.totalPrice,
);
if (result.paymentResult.needsRedirect) {
// PayPal and friends finish off-site. Not paid until they come back.
await launchUrl(Uri.parse(result.paymentResult.redirectUrl));
} else if (result.isPaid) {
showThanks(result.orderId);
}
Pass expectedTotal — the number you actually showed the shopper — and the
store refuses the order if it no longer agrees, rather than charging a
different amount than the one on screen:
try {
await store.checkout.submit(..., expectedTotal: shownTotal);
} on WooTotalMismatchException catch (e) {
// Nothing was charged, and the new total is already here.
final fresh = StoreCheckoutResource.cartFrom(e);
await confirmAgain(fresh!.totals.totalPrice);
}
submitAndClear forgets the cart only when the payment actually went through —
a declined card leaves the basket intact.
Keeping the basket #
Carts are identified by a Cart-Token the store issues on the first request.
This client captures it and replays it for you. To keep a shopper's basket
across app launches, give it somewhere to write:
class PrefsCartTokens implements CartTokenStore {
PrefsCartTokens(this._prefs);
final SharedPreferences _prefs;
@override
Future<String?> read() async => _prefs.getString('woo_cart_token');
@override
Future<void> write(String? token) async => token == null
? await _prefs.remove('woo_cart_token')
: await _prefs.setString('woo_cart_token', token);
}
final store = WooStore(
baseUrl: 'https://your-store.com',
tokens: PrefsCartTokens(prefs),
);
A cart token also removes the nonce requirement — which matters, because a nonce can only be minted by WordPress itself, so an app has no way to produce one. Token-based is the only workable flow for a client that is not a web page on the store's own domain.
The admin API #
final woo = WooCommerce(
baseUrl: 'https://your-store.com',
credentials: WooCredentials.key(consumerKey: ck, consumerSecret: cs),
retry: const WooRetry.reads(),
);
final page = await woo.products.list(search: 'leather', onSale: true);
final order = await woo.orders.get(5120);
Products, orders, customers and coupons have named filters. Everything else
lives under woo.admin:
woo.admin.productCategories woo.admin.taxRates woo.admin.reports
woo.admin.productTags woo.admin.shippingZones woo.admin.settings
woo.admin.productAttributes woo.admin.shippingClasses woo.admin.data
woo.admin.reviews woo.admin.webhooks woo.admin.systemStatus()
woo.admin.attributeTerms(3) woo.admin.orderNotes(5120) woo.admin.refunds(5120)
woo.admin.paymentGateways() woo.admin.taxClasses()
Each is a WooCollection, so they all get the same list / get / create /
update / delete / all() / batch() — not just the ones someone got round
to.
Batch #
Up to 100 operations in one request. For a catalogue sync this is the difference between a minute and an hour.
final result = await woo.products.batch(
update: [
{'id': 799, 'regular_price': '119.00'},
{'id': 812, 'stock_quantity': 0},
],
delete: [800],
);
result.updated; // List<WooProduct>
Over 100 it throws rather than letting WooCommerce silently truncate, which looks like data loss.
Writing orders #
final order = await woo.orders.create(
lineItems: [
WooLineItem.order(productId: 799, quantity: 2),
WooLineItem.order(productId: 812, quantity: 1, variationId: 815),
],
billing: WooAddress(firstName: 'Ada', country: 'GB', email: 'ada@example.com'),
paymentMethod: 'stripe',
setPaid: false,
);
await woo.orders.setStatus(order.id, WooOrderStatus.completed);
WooLineItem.order deliberately sends only the product and the quantity. The
store applies its own prices, taxes, and coupons — a client that sends a price
is a client that can be told to send 0.01.
Authentication #
WooCredentials.key(consumerKey: ck, consumerSecret: cs) // a store credential
WooCredentials.applicationPassword(username: u, password: p) // WordPress core
WooCredentials.bearer(token) // your backend, or JWT
Application passwords are built into WordPress since 5.6, can be revoked individually, and run as a real user — so a shop manager cannot do what an administrator can, and the audit trail names them.
Both consumer keys and application passwords are refused over plain http://,
with an ArgumentError rather than a leaked secret.
Webhooks #
WooCommerce POSTs to your server when things happen. Anyone can POST to your endpoint, so check the signature before you believe the body — without it, a stranger can tell your system an order was paid.
final delivery = WooWebhookDelivery.fromRequest(
body: await request.readAsString(), // the exact bytes, not re-encoded
headers: request.headers,
);
if (!delivery.isSignedWith(secret)) return Response.forbidden('nope');
switch (delivery.topic) {
case 'order.created':
await handle(WooOrder.fromJson(delivery.json));
case 'product.updated':
await reindex(delivery.resourceId);
}
The comparison is constant-time. Nothing else in Dart does this today.
Errors #
try {
await woo.products.get(id);
} on WooNotFoundException {
return null;
} on WooRateLimitException catch (e) {
await Future.delayed(e.retryAfter ?? const Duration(seconds: 30));
} on WooAuthException catch (e) {
log('Key rejected: ${e.message}');
} on WooInvalidRequestException catch (e) {
log('Store said no: ${e.code} ${e.details}');
} on WooServerException {
return retryLater();
} on WooNetworkException catch (e) {
log('Never reached the store: ${e.cause}');
} on WooBadResponseException catch (e) {
log('Not JSON: ${e.body}'); // a plugin printing a notice, or a login wall
}
All eight extend WooException, which is sealed — a switch over an error
is exhaustive, and a new case is a compile error rather than a silent
fall-through.
Retrying #
Off by default, because this package cannot know whether your POST is safe to
repeat.
WooRetry.none() // the default
WooRetry.reads() // GETs only — what almost everyone wants
WooRetry.everything() // only if every call you make is idempotent
Retries back off exponentially with jitter, and honour the store's own
Retry-After rather than guessing shorter and getting refused again.
Testing your own code #
Pass any http.Client:
final store = WooStore(
baseUrl: 'https://example.com',
httpClient: MockClient((request) async => http.Response(
jsonEncode(cartFixture), 200,
headers: {'content-type': 'application/json', 'Cart-Token': 't'},
)),
);
Anything not wrapped #
Both clients expose the raw routes, with the same auth, paging, and errors:
await woo.getPage('/customers/33/downloads');
await woo.getPage('/reports/sales', query: {'period': 'month'});
await store.getOne('/products/collection-data');
And every model keeps the full response in .raw, so a plugin's field is a map
lookup rather than a release away:
product.raw['_yoast_wpseo_title'];
cart.raw['extensions'];
Compatibility #
- Store API — WooCommerce 8.0+, where it ships in core. No plugin needed.
- Admin API — WooCommerce 3.5+ (REST v3), any WordPress that runs it.
- HTTPS required for key and application-password authentication.
Not affiliated with or endorsed by Automattic. WooCommerce is their trademark; this is an independent client for their public APIs.