devShakib

woo_client

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

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, and no other Dart package implements it.

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 headerpage.hasMore;      // truefinal product = await store.products.get(799);final linked = await store.products.bySlug('beanie-with-logo');  // deep linksfinal 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.56cart.totals.totalPrice.minorUnits;     // 8256cart.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: 815, variation: {'pa_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 twositem.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.reportswoo.admin.productTags          woo.admin.shippingZones   woo.admin.settingswoo.admin.productAttributes    woo.admin.shippingClasses woo.admin.datawoo.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 credentialWooCredentials.applicationPassword(username: u, password: p)  // WordPress coreWooCredentials.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 defaultWooRetry.reads()        // GETs only — what almost everyone wantsWooRetry.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

---

Not affiliated with or endorsed by Automattic. WooCommerce is their trademark;

this is an independent client for their public APIs.

woo_client on pub.dev