Cart and Checkout Blocks

This content was generated with the assistance of AI and should be verified against the official documentation of each platform before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against WordPress 7.1.3 and WooCommerce 11.2.1 (released on 2026-10-09) and checked on 2026-10-27. The Cart and Checkout blocks are part of WooCommerce core, so every self-hosted store has them. They have been the cart and checkout that a new store gets since WooCommerce 8.3 (November 2023). A store that is older than that usually still has its Cart and Checkout pages built on the shortcodes [woocommerce_cart] and [woocommerce_checkout], because an upgrade does not rewrite pages, and a large part of the WooCommerce literature (every WooCommerce book in the bibliography of this section) describes only the shortcode version. This page explains both, shows how the block version works from the click on the Place order button down to the order in the database, and then shows every way to extend it: the PHP integration class, the data that travels with the Store API, the checkout filters, Slot and Fill, inner blocks, validation and events. The last part is a migration guide for the customisations that were written for the shortcode checkout.

The running example is the Pocket Coffee shop. One small plugin, Pocket Coffee Checkout Blocks, is built on this page and exercised on a local store (WordPress 7.1.3, WooCommerce 11.2.1, PHP 8.3, MariaDB 11.8, the Twenty Twenty-Five block theme). It registers an integration, adds a "free shipping" notice and a gift-wrap option to the totals of the Cart and the Checkout, renames the totals label and the order button, caps the quantity of a bean bag at 10, refuses parcels over 5 kg and orders to the Canary Islands, and adds one inner block to the Checkout with two checkboxes: Join the Coffee Club (optional) and I understand that beans are roasted to order (required). Everything the page shows as output was produced by that plugin or by a small throw-away probe that is described where it is used: pages were opened and orders were placed in a headless Chrome driven over the DevTools protocol, the Store API was called with curl and Python, WP-CLI read the results, and the quotations of logs and hook lists are the ones that were recorded.

Three things could not be run, and the page says so where they matter. The JavaScript is written without JSX and without a build step so that it runs as it stands; a @wordpress/scripts build (the route that the official documentation recommends) was not run because the Node toolchain of the test store does not include it. The block editor itself was not opened (it needs a signed-in administrator session), so the editor script of the inner block was syntax-checked and is shown, but its behaviour in the editor was not observed. WooPayments and every other payment extension that needs an account were not installed; the orders of this page were paid with the Direct bank transfer gateway. The page follows Store API, which documents the endpoints that these blocks call, and Product Collection and Blocks; the field API is on Additional Checkout Fields, the payment methods on Payment Gateways, and the PHP language basics on the PHP section of this site.

Blocks and shortcodes

The two experiences are two different applications that happen to sit on the same URLs.

Shortcode pages Block pages

Content of the page

[woocommerce_cart] and [woocommerce_checkout], optionally inside a Classic Shortcode block

A woocommerce/cart or woocommerce/checkout block with its inner blocks

Who draws the page

PHP templates (cart/cart.php, checkout/form-checkout.php and the templates they include), which a theme may override

A React application in the browser, started from block markup and from data that the server puts in the page

How the order is sent

A form post to /?wc-ajax=checkout (wc_checkout_params.checkout_url on the classic page) and AJAX calls to recalculate the review table

A POST /wp-json/wc/store/v1/checkout request, preceded by cart/update-customer and other Store API calls as the shopper types

Fields

The array that the woocommerce_checkout_fields filter returns

The core address fields (country-aware), the settings of the blocks, and the Additional Checkout Fields API

Payment methods

payment_fields() of the gateway prints HTML

The gateway needs a JavaScript registration (registerPaymentMethod) as well as its PHP class

Extension points

The classic action and filter set (woocommerce_checkout_process, woocommerce_review_order_before_submit, …​)

The Store API hooks, the checkout filters, Slot and Fill, inner blocks; a subset of the classic lifecycle hooks still runs

Look and feel

Theme CSS and template overrides

Global styles, block templates and the block settings

A store can have either, and a store can be switched from one to the other, because both create ordinary WooCommerce orders. To see what a store uses, read the content of the pages that WooCommerce knows as Cart and Checkout. This script was run on the test store (the pages are the ones WooCommerce created on activation, ID 6 and 7):

foreach ( array( 'cart', 'checkout' ) as $name ) {
	$id      = wc_get_page_id( $name );
	$content = get_post_field( 'post_content', $id );
	printf(
		"%-9s page %d: block=%s shortcode=%s classic-block=%s\n",
		$name,
		$id,
		has_block( 'woocommerce/' . $name, $content ) ? 'yes' : 'no',
		has_shortcode( $content, 'woocommerce_' . $name ) ? 'yes' : 'no',
		has_block( 'woocommerce/classic-shortcode', $content ) ? 'yes' : 'no'
	);
}
cart      page 6: block=yes shortcode=no classic-block=no
checkout  page 7: block=yes shortcode=no classic-block=no

To go back to the shortcode, the content of the page becomes a Classic Shortcode block. Its markup, which was saved on a test page and rendered, is one line, and it rendered the full classic form (the woocommerce-checkout form, the billing_postcode field and the woocommerce_checkout_place_order button were all in the HTML):

<!-- wp:woocommerce/classic-shortcode {"shortcode":"checkout"} /-->

The shortcode checkout is still supported in 11.2.1, which is why a store with years of customisations can stay on it, but new features of the checkout, such as the Additional Checkout Fields API, exist only for the block checkout. A new extension should work on the blocks and declare it.

Declaring compatibility

A plugin tells WooCommerce whether it works with the Cart and Checkout blocks with the same call that declares HPOS support, on before_woocommerce_init:

add_action(
	'before_woocommerce_init',
	function () {
		if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
			\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'cart_checkout_blocks', __FILE__, true );
		}
	}
);

Pass false to declare that the plugin is incompatible; do nothing if the plugin does not touch the cart or the checkout. WooCommerce uses the declarations to warn the merchant about plugins that do not support the blocks. It only checks the declaration of plugins that have a WC tested up to header in the main plugin file, so keep that header current. The result can be read from code; on the test store this returned for the plugin of this page:

$f = \Automattic\WooCommerce\Utilities\FeaturesUtil::get_compatible_plugins_for_feature( 'cart_checkout_blocks', true );
echo json_encode( array(
	'mine_compatible' => in_array( 'pocket-coffee-checkout-blocks/pocket-coffee-checkout-blocks.php', $f['compatible'], true ),
	'incompatible'    => $f['incompatible'],
) );
{"mine_compatible":true,"incompatible":[]}

The result has three lists, compatible, incompatible and uncertain; the last one holds the plugins that made no declaration at all.

Where the page lives in a theme

The Cart and Checkout pages are ordinary pages whose content is the block. A block theme draws them through the templates page-cart.html and page-checkout.html, which print the title and the content of the page; the documentation says to keep the Cart and Checkout blocks in the page content and not in the template, otherwise the page editor and the front end disagree and features that inspect the page content do not find the block. The Checkout template uses a header without the site navigation (the checkout-header template part). Section Themes: Block and Classic covers the theme side.

The blocks and their inner block areas

Block tree of the Checkout and Cart blocks with the inner block areas in amber and the slot and filter locations in teal

A block-based checkout is a tree of blocks. This is the tree that the Checkout and Cart pages had on the test store, read with parse_blocks() from the content that WooCommerce wrote on activation:

woocommerce/checkout
  woocommerce/checkout-fields-block
    woocommerce/checkout-express-payment-block
    woocommerce/checkout-contact-information-block
    woocommerce/checkout-shipping-method-block
    woocommerce/checkout-pickup-options-block
    woocommerce/checkout-shipping-address-block
    woocommerce/checkout-billing-address-block
    woocommerce/checkout-shipping-methods-block
    woocommerce/checkout-payment-block
    woocommerce/checkout-additional-information-block
    woocommerce/checkout-order-note-block
    woocommerce/checkout-terms-block
    woocommerce/checkout-actions-block
  woocommerce/checkout-totals-block
    woocommerce/checkout-order-summary-block
      woocommerce/checkout-order-summary-cart-items-block
      woocommerce/checkout-order-summary-coupon-form-block
      woocommerce/checkout-order-summary-subtotal-block
      woocommerce/checkout-order-summary-fee-block
      woocommerce/checkout-order-summary-discount-block
      woocommerce/checkout-order-summary-shipping-block
      woocommerce/checkout-order-summary-taxes-block

woocommerce/cart
  woocommerce/filled-cart-block
    woocommerce/cart-items-block
      woocommerce/cart-line-items-block
      woocommerce/product-collection   (collection: woocommerce/product-collection/cross-sells)
    woocommerce/cart-totals-block
      woocommerce/cart-order-summary-block  (heading, coupon form, subtotal, fee, discount, shipping, taxes)
      woocommerce/cart-express-payment-block
      woocommerce/proceed-to-checkout-block
      woocommerce/cart-accepted-payment-methods-block
  woocommerce/empty-cart-block  (a message pattern, a separator and a "new in store" product block)

Three properties of the tree matter for extension work.

  • Most of the blocks are not editable on the front end by plugins. They are rendered by React from data, and what you may change is limited to the extension points of the following sections. What a merchant can do in the editor is move blocks, remove optional ones, change the text and the settings of a block, and add blocks into the inner block areas.

  • Inner block areas are the blocks that accept children: in the figure, the ones outlined in amber. A block that you register can be limited to some areas with the parent property of its block.json. The list of areas in the source of the blocks registry (trunk, October 2026) is: woocommerce/checkout, checkout-fields-block, checkout-totals-block, checkout-contact-information-block, checkout-shipping-address-block, checkout-billing-address-block, checkout-shipping-method-block, checkout-shipping-methods-block, checkout-pickup-options-block, checkout-payment-block, woocommerce/cart, empty-cart-block, filled-cart-block, cart-items-block, cart-cross-sells-block, cart-totals-block, the Mini-Cart blocks (mini-cart-contents, empty-mini-cart-contents-block, filled-mini-cart-contents-block, mini-cart-title-block, mini-cart-items-block, mini-cart-footer-block) and the order summary blocks of the Cart and the Checkout (and their totals blocks).

  • A block can be forced. If the lock attribute of the block defaults to { remove: true, move: true }, WooCommerce inserts the block automatically into the area and the merchant can neither move nor remove it; that is how the core blocks and the extension blocks that must always be there are built.

The only blocks of the tree that are not WooCommerce-specific are the ones that the merchant types in: paragraphs, images and separators can be added to any area (additionalCartCheckoutInnerBlockTypes widens the list, see the filters).

Data: the stores, the server and the browser

The server is the source of truth for everything that is transactional or must survive a reload: the cart items and their quantities and prices, the totals, the customer and the addresses, the shipping rates and the chosen rate, the coupons, and the additional fields. The browser holds copies of that data in @wordpress/data stores and keeps interface state (an open panel, a field being edited, an unsent validation message) to itself. When the page loads WooCommerce puts the cart in the page (hydration) so that the blocks do not wait for a request, and after that every change goes through the Store API and the response replaces the copy.

The stores are registered by the script handle wc-blocks-data-store and exposed on window.wc.wcBlocksData. The names exported there on the test store (read in the browser) are the store descriptors cartStore, checkoutStore, paymentStore, validationStore, collectionsStore, queryStateStore, schemaStore and storeNoticesStore, their string keys (CART_STORE_KEY, CHECKOUT_STORE_KEY, PAYMENT_STORE_KEY, VALIDATION_STORE_KEY, COLLECTIONS_STORE_KEY, QUERY_STATE_STORE_KEY, SCHEMA_STORE_KEY, STORE_NOTICES_STORE_KEY), and helpers such as processErrorResponse, getErrorDetails and isEditor. The documentation prefers reading the stores over importing the internal React hooks of the blocks, because the stores are the public contract.

Store Key What it holds

Cart

wc/store/cart

The cart as the Store API returns it (items, totals, coupons, fees, shipping rates, customer addresses, extensions, errors), and the actions that change it (applyCoupon, addItemToCart, changeCartItemQuantity, selectShippingRate, setBillingAddress, setShippingAddress, updateCustomerData).

Checkout

wc/store/checkout

The checkout flow: status (idle, before_processing, processing, after_processing, complete), hasError, order ID and notes, redirect URL, extensionData (the values sent in extensions), additional fields, whether billing equals shipping, whether the shopper prefers pickup.

Payment

wc/store/payment

The payment status (idle, express_started, processing, ready, error), the registered, available and express payment methods, the active method and saved token, and paymentMethodData.

Validation

wc/store/validation

Field-level errors keyed by error ID; each is { message, hidden }. While the store has entries, the checkout will not proceed.

Store notices

wc/store/store-notices

The notices shown by the blocks, by notice context.

Collections, query state, schema

wc/store/collections, wc/store/query-state, wc/store/schema

Cached REST collections for blocks that list products, arbitrary query values by context, and the REST routes. Used by product blocks and internals.

The selectors that the stores had on the Checkout page of the test store were read with Object.keys( wp.data.select( store ) ). For the stores that extensions use most:

wc/store/cart      getCartData, getCartTotals, getCartErrors, getCartItem, getCartMeta, getCustomerData, getShippingRates,
                   getNeedsShipping, getHasCalculatedShipping, getCouponBeingApplied, getCouponBeingRemoved,
                   isApplyingCoupon, isRemovingCoupon, isCartDataStale, isCustomerDataUpdating, isShippingRateBeingSelected,
                   isItemPendingDelete, isItemPendingQuantity, hasPendingItemsOperations, ...
wc/store/checkout  getCheckoutStatus, isIdle, isBeforeProcessing, isProcessing, isAfterProcessing, isComplete,
                   isCalculating, hasError, hasOrder, getOrderId, getOrderNotes, getRedirectUrl, getExtensionData,
                   getAdditionalFields, getUseShippingAsBilling, getEditingBillingAddress, getEditingShippingAddress,
                   getShouldCreateAccount, getCustomerId, prefersCollection, ...
wc/store/payment   getState, getActivePaymentMethod, getAvailablePaymentMethods, getAvailableExpressPaymentMethods,
                   getPaymentMethodData, getPaymentResult, getSavedPaymentMethods, isPaymentIdle, isPaymentProcessing,
                   isPaymentReady, hasPaymentError, isExpressPaymentStarted, ...

and the keys of the object that getCartData() returned: coupons, shippingRates, shippingAddress, billingAddress, items, itemsCount, itemsWeight, crossSells, needsShipping, needsPayment, hasCalculatedShipping, fees, totals, errors, paymentMethods, paymentRequirements and extensions. The totals object has total_items, total_fees, total_discount, total_shipping, total_price, total_tax, tax_lines and the currency description (currency_code, currency_minor_unit, the separators, prefix and suffix). All money values are strings in the minor unit of the currency (cents), which is why a cart of 13.40 EUR reports "total_price":"1340". A component reads the stores with useSelect:

const { useSelect } = window.wp.data;
const { cartStore, checkoutStore } = window.wc.wcBlocksData;

const Status = () => {
	const { total, status } = useSelect( ( select ) => ( {
		total: select( cartStore ).getCartTotals().total_price,
		status: select( checkoutStore ).getCheckoutStatus(),
	} ) );
	return `${ status }: ${ total }`;
};

The actions of the payment store are internal in 11.2.1 (the documentation says extensions should not dispatch them yet); the extension points below are the supported route to change behaviour. The documentation of the data flow adds three rules worth remembering. Data entered in an address form is sent to the server at once (a debounced pushChanges call, and the whole cart in the response replaces the local copy, so a server hook that rewrites a city name changes the text in the field while the shopper types, which is jarring: normalise on checkout instead). A coupon or a shipping rate triggers a request and a new cart. And the payment method that the shopper picks is not sent until the order is placed, unless an extension sends it earlier with extensionCartUpdate.

How the Checkout block processes an order

In the browser

The client checkout status goes from idle to before_processing, processing, after_processing and complete, with the validation, payment setup, request and success or failure events between them

When the shopper presses Place order the block changes the checkout status step by step and emits events that extensions can observe. The statuses are values of the checkout store (isIdle(), isBeforeProcessing(), isProcessing(), isAfterProcessing(), isComplete()), and the payment store has its own status that moves in between. The sequence that a subscription on wp.data recorded while an order was placed on the test store was:

checkout=before_processing payment=idle
checkout=processing        payment=idle
checkout=processing        payment=processing
checkout=processing        payment=ready
POST /checkout?_locale=site
checkout=after_processing  payment=ready
checkout=complete          payment=ready      -> redirect to the order-received page

The steps in words:

  1. Click. The status becomes before_processing. Validation is run in this status.

  2. onCheckoutValidation. Every observer runs (they are not aborted by the first failure). An observer returns true (fine), false (go back to idle) or an object with errorMessage (a notice), context (which notice area) and validationErrors (inline errors by field). Field errors that were put in the validation store earlier also stop the flow, and the block scrolls to and focuses the first error.

  3. Status processing; payment status processing. The payment method’s onPaymentSetup observers run; they may return { type: 'success', paymentMethodData }, { type: 'failure', message } or { type: 'error', message, validationErrors }. This step does not take the payment: it prepares data (for a card, a token), and a returned paymentMethodData becomes payment_data of the request. When all observers are done, the payment status becomes ready.

  4. The request. The block sends POST /wc/store/v1/checkout. The body (read from the request on the test store) is shown below.

  5. Status after_processing. The response is stored (the payment result and the redirect URL). If there is no error, the onCheckoutSuccess observers run; on an error, onCheckoutFail. An observer may return { type: 'success', redirectUrl }, { type: 'failure', message, retry }, or true.

  6. Status complete and the browser is sent to the redirect URL, normally the order-received page.

The request body of an order for one 250 g bag, placed by Ada Lovelace (the test customer), was (the order-attribution values are cut here for length; they are the source and device data that WooCommerce collects with the Order Attribution feature and send under the extension namespace woocommerce/order-attribution):

{
  "additional_fields": {},
  "billing_address": { "first_name": "Ada", "last_name": "Lovelace", "company": "", "address_1": "Calle Mayor 1", "address_2": "",
    "city": "Madrid", "state": "M", "postcode": "28013", "country": "ES", "email": "ada@example.test", "phone": "" },
  "create_account": false,
  "customer_note": "",
  "customer_password": "",
  "extensions": {
    "woocommerce/order-attribution": { "source_type": "typein", "referrer": "(none)", "utm_source": "(direct)", "...": "..." },
    "pocket-coffee-blocks": { "club_optin": true, "roast_ack": true }
  },
  "shipping_address": { "first_name": "Ada", "last_name": "Lovelace", "company": "", "address_1": "Calle Mayor 1", "address_2": "",
    "city": "Madrid", "state": "M", "postcode": "28013", "country": "ES", "phone": "" },
  "expected_total": "1340",
  "payment_method": "bacs",
  "payment_data": [ { "key": "wc-bacs-new-payment-method", "value": false } ]
}

Three details of this body deserve attention. extensions is where every extension of the page puts what it wants the server to see (the second namespace is the plugin of this page; the first is WooCommerce’s own order attribution). additional_fields carries the values of the Additional Checkout Fields. And expected_total is the total that the shopper saw: since WooCommerce 11.1.0 the server compares it with the total it computes for the request and answers with HTTP 409 when they differ (because a price, a quantity, a stock limit, a coupon, shipping or tax changed meanwhile), so a shopper never pays an amount that was not on the screen.

The events and the stores live in the browser, so an extension that depends on them depends on its script loading and running without errors. Keep extension scripts small and test with the browser console open.

On the test store the observers of the plugin logged these three console lines over two clicks on Place order (the first click was refused because the roast date policy was not confirmed, the second placed the order), which shows that validation runs on every attempt and that the success observer receives the response of the server:

[pocket-coffee] onCheckoutValidation
[pocket-coffee] onCheckoutValidation
[pocket-coffee] onCheckoutSuccess payment success

The object that onCheckoutSuccess received in 11.2.1 had the keys redirectUrl, orderId, customerId, orderNotes and processingResponse (with message, paymentStatus, redirectUrl and paymentDetails). Two differences from the official text are worth knowing: the documentation names the last one paymentResult, and orderId was 0 in the run, while the redirect URL already contained the order ID. Read processingResponse and the redirect URL, not orderId.

On the server

The nine numbered steps of the Store API checkout request with the hooks that fire in each step

The server side of the request is Automattic\WooCommerce\StoreApi\Routes\V1\Checkout::process_order(), an optimistic flow that tries to save the order as few times as possible. The same code answers a call from the block, from a mobile application and from a script. Order step logging (the filter that controls it was added in 11.0.1 and the default is on) writes every step to a WooCommerce log whose source is place-order-debug- followed by a short random ID; the messages of the order placed above were:

[Store API #1] Place Order flow initiated
[Store API #2] Cart validated
[Store API #3::update_customer_from_request] Persisted shipping_address fields
[Store API #3::update_customer_from_request] Persisted billing_address fields
[Store API #3::update_customer_from_request] Persisted contact fields
[Store API #3] Updated customer data from request
[Store API #4::create_or_update_draft_order] Created order from cart
[Store API #4::create_or_update_draft_order] Set order draft id
[Store API #4] Created/Updated draft order
[Store API #5::update_order_from_request] Set customer note and payment method
[Store API #5::update_order_from_request] Persisted additional fields
[Store API #5] Updated order with posted data
[Store API #6::process_customer] Synced customer data from order
[Store API #6] Created and/or persisted customer data from order
[Store API #7] Validated order data
[Store API #8] Reserved stock for order
[Store API #9] Order processed

Every line also carries a JSON context (the order ID, the totals, the cart items and a backtrace); the file is in the wc-logs folder of the uploads directory and in WooCommerce > Status > Logs. The woocommerce_order_step_logging_enabled filter turns the logging off (it is read once at the start of the request). The steps, in order, with the number that the log gives them:

  1. Start (#1). The order flow begins and the logging session starts.

  2. Validate the user and the cart (#2). A guest may only check out if guest checkout is allowed, and the cart must not be empty.

  3. Update the customer (#3) in the session from the request (addresses, contact fields, additional fields of the contact and address locations), firing woocommerce_store_api_checkout_update_customer_from_request( $customer, $request ).

  4. Calculate the totals and validate the cart (not numbered in the log): woocommerce_store_api_validate_cart_item( $product, $cart_item ) for each item and woocommerce_store_api_cart_errors( $errors, $cart ) once, then the comparison of expected_total with the computed total (11.1.0).

  5. Create or update the draft order (#4) from the cart (woocommerce_store_api_checkout_order_created( $order ) and woocommerce_store_api_checkout_update_order_meta( $order )). Since 10.9.0 the draft order is created here, at place-order time, and not while the shopper types.

  6. Write the posted data into the order (#5) and fire woocommerce_store_api_checkout_update_order_from_request( $order, $request ) (the right place to read $request['extensions'] and to save order meta; throwing a RouteException here rejects the order).

  7. Create or sync the customer (#6) (a new account if the shopper asked for one).

  8. Validate the order before payment (#7) with woocommerce_checkout_validate_order_before_payment( $order, $errors ) (since 9.9.0) and hold the coupons used (usage limits).

  9. Reserve the stock (#8) for the order (wc_reserve_stock_for_order(); stock is only held while the order has the status checkout-draft or pending), set the status to pending and fire woocommerce_store_api_checkout_order_processed( $order ): the order is complete and valid, no money has moved yet.

  10. Process the payment (#9). If the order needs payment, woocommerce_rest_checkout_process_payment_with_context( $context, &$result ) runs; the gateway’s process_payment() (see Payment Gateways) sets the result (success, failure, pending, error) and the redirect URL. If the order needs no payment, it is completed without a gateway. The status after the gateway is the gateway’s decision (on-hold for bank transfer, processing for a paid order).

The documentation page of that name describes the client flow in terms of files (checkout-processor.js) and context providers; the steps above are what the server of 11.2.1 does, from its source and from the logs of the test store. Opening the Checkout page with a full cart did not create a draft order: the number of wc-checkout-draft orders in the database stayed at 16.

The names of the hooks that actually fired during one POST /wc/store/v1/checkout, recorded in order by a temporary plugin that logged every hook with a matching name once (the many woocommerce_order_item_* and save hooks between them are left out):

woocommerce_blocks_validate_location_order_fields
woocommerce_checkout_init
woocommerce_store_api_checkout_update_customer_from_request
woocommerce_store_api_validate_cart_item
woocommerce_store_api_cart_errors
woocommerce_checkout_create_order_line_item
woocommerce_checkout_create_order_shipping_item
woocommerce_store_api_checkout_order_created
woocommerce_store_api_checkout_update_order_meta
woocommerce_store_api_checkout_update_order_from_request
woocommerce_checkout_validate_order_before_payment
woocommerce_new_order
woocommerce_order_status_pending
woocommerce_order_status_checkout-draft_to_pending
woocommerce_order_status_changed
woocommerce_store_api_checkout_order_processed
woocommerce_rest_checkout_process_payment_with_context
woocommerce_order_status_on-hold
woocommerce_reduce_order_stock
woocommerce_order_status_pending_to_on-hold
woocommerce_order_status_pending_to_on-hold_notification

and these classic checkout hooks did not fire at all in that request: woocommerce_checkout_process, woocommerce_after_checkout_validation, woocommerce_checkout_create_order, woocommerce_checkout_update_order_meta, woocommerce_checkout_order_processed and woocommerce_before_checkout_process. The classic woocommerce_checkout_create_order_line_item and woocommerce_checkout_create_order_shipping_item did fire (line items are built with the same code), and the order-status hooks of the order lifecycle (woocommerce_new_order, woocommerce_order_status_*, woocommerce_reduce_order_stock) fire as always. A customisation that relies on a classic checkout hook must therefore move to the Store API hook of the same step; the mapping table in the migration section lists the pairs.

The same flow as a sequence diagram:

sequenceDiagram participant S as Shopper participant C as Checkout block participant X as Extension scripts participant A as Store API checkout route participant W as WooCommerce order and hooks participant G as Payment gateway S->>C: Place order C->>C: status before_processing C->>X: onCheckoutValidation observers X-->>C: true, or errors in the validation store C->>C: status processing, payment status processing C->>X: onPaymentSetup observers of the active method X-->>C: success with paymentMethodData C->>C: payment status ready C->>A: POST /checkout with addresses, payment_method, extensions, expected_total A->>W: validate cart and customer, compare expected_total A->>W: create or update draft order A->>W: update_order_from_request(order, request) Note over A,W: a RouteException here answers 4xx and no payment is taken A->>W: validate order before payment, hold coupons, reserve stock A->>W: status pending, order_processed(order) A->>G: process_payment() through rest_checkout_process_payment_with_context G-->>A: success or failure, redirect URL A-->>C: 200 order_id, status, payment_result C->>C: status after_processing C->>X: onCheckoutSuccess or onCheckoutFail observers C-->>S: redirect to the order-received page

Errors

A failure at any step is returned as an HTTP error with a JSON body { code, message, data }. The status says who must act: 400 for something the shopper or the code can correct (a validation error, a bad quantity), 409 when the cart changed and the client must reload it (the body contains the cart), and 5xx for a server problem. The block shows the message in a notice at the top of the checkout. Messages are shown to the shopper: write them for a customer. On the test store these requests were made with the Store API directly (a script that creates a cart, adds a bag, and posts to /checkout; the cart token authenticates the guest):

POST /checkout, roast_ack false      -> 400 {"code": "pocket_coffee_roast_ack_missing", "message": "Please confirm the roast date policy.", "data": {"status": 400}}
POST /checkout, shipping state GC    -> 400 {"code": "woocommerce_rest_checkout_custom_validation_error", "message": "Pocket Coffee does not ship to the Canary Islands yet.", "data": {"status": 400}}
POST /checkout, a cart of 6 kg       -> 409 {"code": "pocket_coffee_parcel_weight", "message": "A parcel can weigh 5 kg at most. Please split your order."}
POST /checkout, valid order          -> 200 {"order_id": 447, "status": "on-hold", "payment_method": "bacs", "payment_result": {"payment_status": "success", ...}}

Extending the blocks

The four data paths between the PHP of an extension and the JavaScript in the blocks: script data, cart extension data, setExtensionData and extensionCartUpdate, and validation

Everything below is one plugin. The sections show its pieces, then the complete files. The choice of the extension point follows the question "what do you want to change?":

I want to …​ Use

load a script and give it settings

IntegrationInterface registered for the Mini-Cart, the Cart and the Checkout

show server data that changes with the cart

woocommerce_store_api_register_endpoint_data() on the cart endpoint; read it in extensions

change a label, a price format or a class

registerCheckoutFilters()

render a component in a fixed place

Slot and Fill (registerPlugin with the ExperimentalOrderMeta fill, and the other slots)

collect input from the shopper

an inner block with registerCheckoutBlock() and setExtensionData(); or an Additional Checkout Field

run server code when the shopper clicks something

woocommerce_store_api_register_update_callback() and extensionCartUpdate()

stop the order

wc/store/validation, onCheckoutValidation, woocommerce_store_api_cart_errors, woocommerce_checkout_validate_order_before_payment, or a RouteException

save something on the order

woocommerce_store_api_checkout_update_order_from_request

Build and enqueue

The front-end code of an extension is JavaScript that must be loaded on the page of the block, after the scripts of WooCommerce that it uses. The recommended build is @wordpress/scripts with the WooCommerce Dependency Extraction Webpack Plugin (@woocommerce/dependency-extraction-webpack-plugin). The plugin turns an import from a WooCommerce package into a read of the global (import { registerCheckoutFilters } from '@woocommerce/blocks-checkout' becomes window.wc.blocksCheckout) and writes a build/index.asset.php file that lists the script handles to depend on. A webpack.config.js that adds it to the default configuration is:

const defaultConfig = require( '@wordpress/scripts/config/webpack.config' );
const WooCommerceDependencyExtractionWebpackPlugin = require( '@woocommerce/dependency-extraction-webpack-plugin' );

module.exports = {
	...defaultConfig,
	plugins: [
		...defaultConfig.plugins.filter( ( plugin ) => plugin.constructor.name !== 'DependencyExtractionWebpackPlugin' ),
		new WooCommerceDependencyExtractionWebpackPlugin(),
	],
};

(This configuration is reduced from the one in the @woocommerce/extend-cart-checkout-block template, which is the official starting point: npx @wordpress/create-block -t @woocommerce/extend-cart-checkout-block your_extension_name. It was not run; the example plugin of this page avoids the build by reading the globals and by listing the handles itself.) The mapping between the globals and the script handles that the page of the example needs, which the generated asset file would contain, is:

Global Script handle Provides

window.wc.blocksCheckout

wc-blocks-checkout

registerCheckoutFilters, registerCheckoutBlock, extensionCartUpdate, the slot components (ExperimentalOrderMeta, ExperimentalDiscountsMeta, ExperimentalOrderShippingPackages, ExperimentalOrderLocalPickupPackages), createSlotFill, useValidateCheckout, ValidationInputError, StoreNoticesContainer, components such as CheckboxControl, TextInput, ValidatedTextInput, Panel, TotalsItem, TotalsWrapper, TotalsFees, TotalsTaxes, Subtotal.

window.wc.blocksCheckoutEvents

wc-blocks-checkout-events

checkoutEvents with onCheckoutValidation, onCheckoutSuccess and onCheckoutFail, the event names CHECKOUT_EVENTS and checkoutEventsEmitter.

window.wc.wcBlocksData

wc-blocks-data-store

The stores listed above.

window.wc.wcBlocksRegistry

wc-blocks-registry

registerPaymentMethod, registerExpressPaymentMethod, registerBlockComponent, registerInnerBlock and the Product Collection registration.

window.wc.wcSettings

wc-settings

getSetting, getSettingWithCoercion, getAdminLink, isWcVersion, isWpVersion, and constants such as CURRENCY, COUNTRIES, STORE_PAGES, WC_VERSION.

window.wc.blocksComponents

wc-blocks-components

The shared components (Button, CheckboxControl, FormattedMonetaryAmount, RadioControl, Spinner, ValidatedCheckboxControl…​).

The exports in the third column are the keys of each global, read in the browser on the test store; the script handles are the ones that the example plugin lists as dependencies and that WordPress resolved (the scripts loaded and ran). The official page about wc.blocksCheckoutEvents documents onCheckoutValidation directly on that global; in 11.2.1 it is on the checkoutEvents object inside it, which is what the example uses.

IntegrationInterface

Automattic\WooCommerce\Blocks\Integrations\IntegrationInterface is how an extension gets its scripts, styles and settings loaded where they are needed. A class implements five methods and is registered with the IntegrationRegistry of each block (mini-cart, cart, checkout) on the action woocommerce_blocks_{area}_block_registration, which passes the registry:

Method Purpose

get_name()

A unique name. The client reads the data with getSetting( 'name_data' ): the name with _data appended.

initialize()

Called when the integration is registered. Register the scripts and styles here.

get_script_handles()

Handles of the scripts to enqueue on the front end when the block is present.

get_editor_script_handles()

Handles of the scripts to enqueue in the block editor.

get_script_data()

An associative array, serialised to JSON, available to the scripts with getSetting. It must be serialisable; compute it with care because it runs on every page that has the block.

The registrations must run after WooCommerce has loaded the blocks, that is on woocommerce_blocks_loaded, and the script has to be registered with wc-settings among its dependencies and in the footer (WooCommerce moves a header script that depends on wc-settings to the footer and prints a console warning). The class of the example plugin:

<?php
/**
 * Integration with the Mini-Cart, Cart and Checkout blocks.
 *
 * @package PocketCoffee
 */

use Automattic\WooCommerce\Blocks\Integrations\IntegrationInterface;

defined( 'ABSPATH' ) || exit;

/**
 * Registers the scripts and passes static data to them.
 */
class Pocket_Coffee_Blocks_Integration implements IntegrationInterface {

	/**
	 * Unique name; the client reads the data with getSetting( 'pocket-coffee-blocks_data' ).
	 *
	 * @return string
	 */
	public function get_name() {
		return 'pocket-coffee-blocks';
	}

	/**
	 * Registers the scripts. Called once per request, when a block asks for the integration.
	 */
	public function initialize() {
		$base = plugin_dir_url( POCKET_COFFEE_BLOCKS_FILE ) . 'assets/';
		$ver  = filemtime( plugin_dir_path( POCKET_COFFEE_BLOCKS_FILE ) . 'assets/frontend.js' );

		wp_register_script(
			'pocket-coffee-blocks-frontend',
			$base . 'frontend.js',
			array( 'wc-blocks-checkout', 'wc-blocks-checkout-events', 'wc-blocks-registry', 'wc-blocks-data-store', 'wc-settings', 'wp-element', 'wp-i18n', 'wp-data', 'wp-plugins' ),
			$ver,
			true
		);
		wp_register_script(
			'pocket-coffee-blocks-editor',
			$base . 'editor.js',
			array( 'wc-blocks-checkout', 'wc-settings', 'wp-blocks', 'wp-block-editor', 'wp-element', 'wp-i18n' ),
			$ver,
			true
		);
		wp_set_script_translations( 'pocket-coffee-blocks-frontend', 'pocket-coffee-checkout-blocks' );
	}

	/**
	 * Scripts loaded on the storefront.
	 *
	 * @return string[]
	 */
	public function get_script_handles() {
		return array( 'pocket-coffee-blocks-frontend' );
	}

	/**
	 * Scripts loaded in the block editor.
	 *
	 * @return string[]
	 */
	public function get_editor_script_handles() {
		return array( 'pocket-coffee-blocks-frontend', 'pocket-coffee-blocks-editor' );
	}

	/**
	 * Static data for the scripts; it must be serialisable.
	 *
	 * @return array
	 */
	public function get_script_data() {
		return array(
			'clubDiscountPercent' => 10,
			'blockMetadata'       => json_decode( file_get_contents( plugin_dir_path( POCKET_COFFEE_BLOCKS_FILE ) . 'blocks/club/block.json' ), true ),
		);
	}
}

The main file declares compatibility, registers the inner block, registers the integration for the three areas, extends the Store API and hooks the server-side rules. It is long because it holds nine small pieces, each marked with a number and explained in the section that follows:

<?php
/**
 * Plugin Name:       Pocket Coffee Checkout Blocks
 * Description:       Extends the Cart and Checkout blocks: integration, Store API data, filters, Slot and Fill, an inner block.
 * Version:           0.1.0
 * Requires at least: 7.0
 * Requires PHP:      7.4
 * Requires Plugins:  woocommerce
 * License:           GPL-2.0-or-later
 * Text Domain:       pocket-coffee-checkout-blocks
 *
 * @package PocketCoffee
 */

use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;

defined( 'ABSPATH' ) || exit;

define( 'POCKET_COFFEE_BLOCKS_FILE', __FILE__ );

// 1. Tell WooCommerce that the plugin works with the Cart and Checkout blocks (and with HPOS).
add_action(
	'before_woocommerce_init',
	function () {
		if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
			\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'cart_checkout_blocks', POCKET_COFFEE_BLOCKS_FILE, true );
			\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', POCKET_COFFEE_BLOCKS_FILE, true );
		}
	}
);

// 2. Register the inner block on the server, from the same block.json the client uses.
add_action(
	'init',
	function () {
		register_block_type_from_metadata( __DIR__ . '/blocks/club' );
	}
);

// 3. Everything that talks to the Cart and Checkout blocks waits for woocommerce_blocks_loaded.
add_action( 'woocommerce_blocks_loaded', 'pocket_coffee_blocks_loaded' );

/**
 * Registers the integration, the Store API data and the update callback.
 */
function pocket_coffee_blocks_loaded() {
	require_once __DIR__ . '/class-pocket-coffee-blocks-integration.php';

	foreach ( array( 'mini-cart', 'cart', 'checkout' ) as $area ) {
		add_action(
			'woocommerce_blocks_' . $area . '_block_registration',
			function ( $registry ) {
				$registry->register( new Pocket_Coffee_Blocks_Integration() );
			}
		);
	}

	// Read-only data on every cart response: extensions["pocket-coffee-blocks"] (view, edit).
	woocommerce_store_api_register_endpoint_data(
		array(
			'endpoint'        => 'cart',
			'namespace'       => 'pocket-coffee-blocks',
			'data_callback'   => function () {
				return array(
					'free_shipping_threshold' => 4000,
					'gift_wrap'               => (bool) WC()->session->get( 'pocket_coffee_gift_wrap', false ),
				);
			},
			'schema_callback' => function () {
				return array(
					'free_shipping_threshold' => array(
						'description' => __( 'Cart items subtotal (minor units) above which shipping is free.', 'pocket-coffee-checkout-blocks' ),
						'type'        => 'integer',
						'readonly'    => true,
					),
					'gift_wrap'               => array(
						'description' => __( 'Whether gift wrap was requested.', 'pocket-coffee-checkout-blocks' ),
						'type'        => 'boolean',
						'readonly'    => true,
					),
				);
			},
			'schema_type'     => ARRAY_A,
		)
	);

	// Data accepted on the POST /checkout request: extensions["pocket-coffee-blocks"].
	// A key that the request leaves out is validated as null, so a type that must be optional lists 'null'.
	woocommerce_store_api_register_endpoint_data(
		array(
			'endpoint'        => 'checkout',
			'namespace'       => 'pocket-coffee-blocks',
			'schema_callback' => function () {
				return array(
					'club_optin' => array(
						'description' => __( 'The shopper wants to join the Coffee Club.', 'pocket-coffee-checkout-blocks' ),
						'type'        => array( 'boolean', 'null' ),
						'optional'    => true,
					),
					'roast_ack'  => array(
						'description' => __( 'The shopper confirmed the roast date policy.', 'pocket-coffee-checkout-blocks' ),
						'type'        => array( 'boolean', 'null' ),
						'optional'    => true,
					),
				);
			},
			'schema_type'     => ARRAY_A,
		)
	);

	// Run on demand: extensionCartUpdate( { namespace: 'pocket-coffee-blocks', data } ) hits POST /cart/extensions.
	woocommerce_store_api_register_update_callback(
		array(
			'namespace' => 'pocket-coffee-blocks',
			'callback'  => function ( $data ) {
				WC()->session->set( 'pocket_coffee_gift_wrap', ! empty( $data['gift_wrap'] ) );
			},
		)
	);
}

// 4. A fee that depends on the session flag the update callback sets.
add_action(
	'woocommerce_cart_calculate_fees',
	function ( WC_Cart $cart ) {
		if ( WC()->session && WC()->session->get( 'pocket_coffee_gift_wrap', false ) ) {
			$cart->add_fee( __( 'Gift wrap', 'pocket-coffee-checkout-blocks' ), 2.50, true );
		}
	}
);

// 5. Cart validation: a parcel must not weigh more than 5 kg.
add_action(
	'woocommerce_store_api_cart_errors',
	function ( WP_Error $errors, WC_Cart $cart ) {
		if ( $cart->get_cart_contents_weight() > 5 ) {
			$errors->add( 'pocket_coffee_parcel_weight', __( 'A parcel can weigh 5 kg at most. Please split your order.', 'pocket-coffee-checkout-blocks' ) );
		}
	},
	10,
	2
);

// 6. Read what the inner block sent and save it on the order. Runs before payment.
add_action(
	'woocommerce_store_api_checkout_update_order_from_request',
	function ( WC_Order $order, WP_REST_Request $request ) {
		$data = $request['extensions']['pocket-coffee-blocks'] ?? null;
		if ( null === $data ) {
			return; // The request did not come from the Pocket Coffee block.
		}
		if ( empty( $data['roast_ack'] ) ) {
			throw new RouteException( 'pocket_coffee_roast_ack_missing', esc_html__( 'Please confirm the roast date policy.', 'pocket-coffee-checkout-blocks' ), 400 );
		}
		$order->update_meta_data( '_pocket_coffee_club_optin', ! empty( $data['club_optin'] ) ? 'yes' : 'no' );
	},
	10,
	2
);

// 7. Order validation before payment (WooCommerce 9.9.0): the replacement of woocommerce_after_checkout_validation.
add_action(
	'woocommerce_checkout_validate_order_before_payment',
	function ( WC_Order $order, WP_Error $errors ) {
		if ( in_array( $order->get_shipping_state(), array( 'GC', 'TF' ), true ) ) {
			$errors->add( 'pocket_coffee_no_islands', __( 'Pocket Coffee does not ship to the Canary Islands yet.', 'pocket-coffee-checkout-blocks' ) );
		}
	},
	10,
	2
);

// 8. The "order placed" log line of the running example.
add_action(
	'woocommerce_store_api_checkout_order_processed',
	function ( WC_Order $order ) {
		wc_get_logger()->info(
			sprintf( 'Order placed: #%s total %s %s', $order->get_order_number(), $order->get_total(), $order->get_currency() ),
			array( 'source' => 'pocket-coffee-blocks' )
		);
	}
);

// 9. Quantity limits: the block replacement for woocommerce_quantity_input_max and its siblings.
add_filter(
	'woocommerce_store_api_product_quantity_maximum',
	function ( $value, WC_Product $product ) {
		return 0 === strpos( (string) $product->get_sku(), 'PC' ) ? min( (int) $value, 10 ) : $value;
	},
	10,
	2
);

The Mini-Cart block has its own registry because it is a separate block (woocommerce/mini-cart) with its own contents blocks. The loop that registers the same class for mini-cart, cart and checkout makes the scripts load wherever the shopper meets the cart. If a script is only needed in the Checkout, register it only there.

Data in the Store API responses (ExtendSchema)

Server data that changes with the cart (a threshold, a flag, a computed price) belongs in the response, not in the get_script_data() array, which is fixed for the page load. woocommerce_store_api_register_endpoint_data() adds an object under extensions.<namespace> to the responses of an endpoint: here the cart endpoint (so every cart and checkout response carries it, and the blocks receive it in the extensions prop of their filters and slots). The arguments are the endpoint identifier (for example cart, cart-item, checkout or product; each Store API schema class has an IDENTIFIER constant), the namespace (unique, your plugin’s), a data_callback that returns the values, a schema_callback that describes them (a JSON Schema fragment per key; readonly for output) and schema_type, normally ARRAY_A. A data_callback is not needed for an endpoint whose data only comes in, like the checkout endpoint in the example, where the schema documents the keys the browser may send under the namespace.

$ python3 -I checkout_checks.py ok     # first line: a guest creates a cart, adds one bag and reads the cart
cart extensions: {"pocket-coffee-blocks": {"free_shipping_threshold": 4000, "gift_wrap": false}}

The shape of the object is validated against the schema in development (the Store API logs schema problems when WP_DEBUG is on). Use a long, unique namespace; a collision with another plugin overwrites its data. The official page on the subject is the Store API extension guide, and Store API covers every endpoint that can be extended.

Checkout filters

registerCheckoutFilters( namespace, filters ) registers callbacks that change values the blocks are about to display, like the PHP filters of the shortcode pages but in JavaScript. A callback has the signature ( value, extensions, args, validation ) => value: the current value, the object of Store API extension data, a context object (for cart lines, { cart, cartItem, context }, where context is cart or summary) and, for filters whose result has to contain a placeholder, a validation flag. A callback that returns an invalid value is ignored, and the callbacks of several extensions are applied one after the other. The 16 filters that the 11.2.1 bundles contain (each name was found in the built scripts) are:

Filter Where Value and notes

itemName

Cart lines, order summary

The product name (string).

cartItemClass

Cart lines, order summary

CSS class of the line (string or empty string).

cartItemPrice

Cart lines, order summary

Price format; must contain <price/>. Default <price/>.

cartItemScreenReaderPrice

Order summary

The price read by a screen reader; must contain <price/>.

subtotalPriceFormat

Cart lines, order summary

Format of the line subtotal; must contain <price/>.

saleBadgePriceFormat

Cart lines

Format of the sale price badge; must contain <price/>.

showRemoveItemLink

Cart lines

Boolean: show the remove link. Cart block only.

totalLabel

Totals footer

Default Total.

totalValue

Totals footer

Format of the total; must contain <price/>.

coupons

Order summary

The list of applied coupons (label and totals); use it to rename, never to change amounts.

showApplyCouponNotice

Coupon form

Boolean: show the "coupon applied" notice.

showRemoveCouponNotice

Coupon form

Boolean: show the "coupon removed" notice.

proceedToCheckoutButtonLabel

Cart

Label of the button. Default Proceed to Checkout.

proceedToCheckoutButtonLink

Cart

URL of the button.

placeOrderButtonLabel

Checkout

Label of the order button.

additionalCartCheckoutInnerBlockTypes

Editor

Block names allowed in each inner block area (default: paragraph, image, separator); called once per area with args.block.

The cart-line filters were also found in the bundle of the Mini-Cart contents, so they apply there too (this was not rendered). additionalCartCheckoutInnerBlockTypes is a filter for the editor: register it in a script that runs in the block editor, inside a DOMContentLoaded handler. The example script registers five filters (the full file is further down); on the test store the Checkout and Cart pages then showed, read from the DOM of the pages:

order summary item   : "Espresso Beans 1kg (hello) (hello) (PC-ESP-1KG)"     itemName adds the SKU in the summary context
order button         : "Place Pocket Coffee order"                            placeOrderButtonLabel
totals footer label  : "Total (EUR, VAT included)"                            totalLabel
cart button          : "Go to checkout"                                       proceedToCheckoutButtonLabel

(The two (hello) suffixes in the product name are added by a product-name filter that was active on the test store and are not part of this example; the SKU suffix is the work of the itemName filter.) A price format that does not keep <price/> is discarded, which is the reason the documentation says to return defaultValue for any case you do not handle.

Slot and Fill

A slot is a place in the blocks that renders whatever fills have been registered for it; WordPress’s SlotFill is the mechanism and registerPlugin the way to hand a fill to the Checkout (the scope must be woocommerce-checkout, even for the Cart). The slots of 11.2.1, all with the Experimental prefix that means "may change":

Fill component Where it renders Props the fill receives

ExperimentalOrderMeta

Below the order summary (below the totals in the Cart, above the "Proceed to checkout" button)

cart, extensions, context (woocommerce/cart or woocommerce/checkout)

ExperimentalDiscountsMeta

Below the coupon input

cart, extensions, context

ExperimentalOrderShippingPackages

In the shipping step of the Checkout and in the shipping options of the Cart

collapsible, collapse, showItems, noResultsMessage, renderOption, cart, extensions, components (with ShippingRatesControlPackage), context

ExperimentalOrderLocalPickupPackages

In the Pickup options block of the Checkout (not in the Cart)

renderPickupLocation, cart, extensions, components (ShippingRatesControlPackage, RadioControl)

The cart prop is the cart store data with its keys in camelCase (cart.cartTotals, not cart.totals), and the money values are the same minor-unit strings. In the example the notice is built from cart.cartTotals.total_items and from the threshold that the server put in extensions:

cart of one 8.90 EUR bag   -> "Add 31.10 EUR more to unlock free shipping in Spain."   (ExperimentalOrderMeta, below the totals)
gift wrap checkbox         -> rendered by ExperimentalDiscountsMeta, below the coupon input

Two practical limits: a fill renders inside its slot and cannot change what the block rendered around it, and WooCommerce wraps fills so that an error in one is reported to administrators instead of breaking the page (createSlotFill takes an onError function to customise the message). If you need a place that no slot offers, the option is an inner block.

extensionCartUpdate and update callbacks

An extension must not write to the cart store; it asks the server. extensionCartUpdate( { namespace, data } ) posts to POST /wc/store/v1/cart/extensions; the server runs the callback registered for that namespace with data, recalculates the totals and returns the cart, and the block re-renders with the response. Exactly one callback can be registered per namespace (registering again replaces it), so a callback that serves several buttons takes an action key in data. The gift-wrap checkbox of the example calls it, the callback sets a session flag, and a woocommerce_cart_calculate_fees hook adds a fee when the flag is set:

$ python3 -I checkout_checks.py wrap
before: 1340 {'free_shipping_threshold': 4000, 'gift_wrap': False}
after update: 200 1590 ['Gift wrap:250'] {'free_shipping_threshold': 4000, 'gift_wrap': True}

The order total went from 13.40 to 15.90 EUR and the fee appeared under fees in the cart response. The same thing was done from the Cart page: clicking the Gift wrap checkbox (which calls extensionCartUpdate) added a Gift wrap 2.50 line, changed the total from 13.40 to 15.90 EUR and set extensions["pocket-coffee-blocks"].gift_wrap to true in the cart store; clicking it again restored 13.40. The cart data is also what expected_total carries at checkout.

An inner block with setExtensionData

To add input to the checkout, add an inner block. It is a normal WordPress block that is registered in two places: on the server with register_block_type_from_metadata() (the example does it on init, from the same block.json as the client), and in JavaScript. The block.json of the example:

{
	"$schema": "https://schemas.wp.org/trunk/block.json",
	"apiVersion": 3,
	"name": "pocket-coffee/checkout-club",
	"version": "0.1.0",
	"title": "Coffee Club and roast date",
	"category": "woocommerce",
	"description": "Lets the shopper join the Coffee Club and confirm the roast date policy.",
	"parent": [ "woocommerce/checkout-contact-information-block" ],
	"attributes": {
		"lock": {
			"type": "object",
			"default": {
				"remove": false,
				"move": false
			}
		}
	},
	"supports": {
		"align": false,
		"html": false,
		"multiple": false,
		"reusable": false
	},
	"textdomain": "pocket-coffee-checkout-blocks"
}

The parent list says in which areas the block can be inserted. lock with remove and move both true would make the block forced; here both are false, so the merchant can place or remove it. On the front end WooCommerce replaces the saved HTML of the block by the React component registered with registerCheckoutBlock( { metadata, component } ). The metadata is the content of block.json, which the example passes to the script through get_script_data() so that the PHP and the JavaScript can never disagree. The component receives props such as checkoutExtensionData (with setExtensionData( namespace, key, value )) and extensions. Values passed to setExtensionData are stored in the checkout store under extensionData and sent in extensions with the checkout request: that is the pocket-coffee-blocks object in the request body above.

The block is saved in the page as the markup below (the same markup the editor writes; the page of the test store was created from the content of the Checkout page with this block inserted into the contact information block):

<!-- wp:woocommerce/checkout-contact-information-block -->
<div class="wp-block-woocommerce-checkout-contact-information-block">
<!-- wp:pocket-coffee/checkout-club -->
<div class="wp-block-pocket-coffee-checkout-club"></div>
<!-- /wp:pocket-coffee/checkout-club -->
</div>
<!-- /wp:woocommerce/checkout-contact-information-block -->

The two scripts follow. The front-end script contains the filters, the slot fills, the inner block component, the events and the registration; the editor script registers the block type so that the merchant can place it (it was syntax-checked with node --check and its behaviour in the editor was not observed).

( function () {
	'use strict';

	const NS = 'pocket-coffee-blocks';
	const ERROR_ID = 'pocket-coffee-roast-ack';

	const { createElement: el, Fragment, useEffect, useState } = window.wp.element;
	const { __, sprintf } = window.wp.i18n;
	const { useDispatch, useSelect } = window.wp.data;
	const { registerPlugin } = window.wp.plugins;
	const {
		CheckboxControl,
		ExperimentalDiscountsMeta,
		ExperimentalOrderMeta,
		ValidationInputError,
		extensionCartUpdate,
		registerCheckoutBlock,
		registerCheckoutFilters,
	} = window.wc.blocksCheckout;
	const { validationStore } = window.wc.wcBlocksData;
	const { getSetting } = window.wc.wcSettings;
	const { checkoutEvents } = window.wc.blocksCheckoutEvents;

	// 1. Checkout filters: change labels and classes of existing parts of the blocks.
	registerCheckoutFilters( NS, {
		itemName: ( value, extensions, args ) =>
			args?.context === 'summary' && args?.cartItem?.sku
				? value + ' (' + args.cartItem.sku + ')'
				: value,
		cartItemClass: ( value, extensions, args ) =>
			String( args?.cartItem?.sku || '' ).indexOf( 'PC' ) === 0
				? 'pocket-coffee-item'
				: value,
		totalLabel: () => __( 'Total (EUR, VAT included)', 'pocket-coffee-checkout-blocks' ),
		placeOrderButtonLabel: () => __( 'Place Pocket Coffee order', 'pocket-coffee-checkout-blocks' ),
		proceedToCheckoutButtonLabel: ( value, extensions, args ) =>
			args?.cart?.items?.length
				? __( 'Go to checkout', 'pocket-coffee-checkout-blocks' )
				: value,
	} );

	// 2. Slot and Fill: render components inside the blocks. Props: cart, extensions, context.
	const FreeShippingNotice = ( { cart, extensions } ) => {
		const threshold = extensions[ NS ]?.free_shipping_threshold ?? 0;
		const subtotal = parseInt( cart.cartTotals.total_items, 10 ) || 0;
		const missing = threshold - subtotal;
		const minor = cart.cartTotals.currency_minor_unit;
		const text =
			missing > 0
				? sprintf(
					/* translators: %s: amount with currency, e.g. 12.30 EUR */
					__( 'Add %s more to unlock free shipping in Spain.', 'pocket-coffee-checkout-blocks' ),
					( missing / Math.pow( 10, minor ) ).toFixed( minor ) + ' ' + cart.cartTotals.currency_code
				)
				: __( 'Free shipping in Spain is unlocked for this order.', 'pocket-coffee-checkout-blocks' );
		return el( 'div', { className: 'wc-block-components-totals-wrapper pocket-coffee-free-shipping' }, text );
	};

	// 3. extensionCartUpdate: send data to the server now and get the recalculated cart back.
	const GiftWrap = ( { extensions } ) => {
		const checked = !! extensions[ NS ]?.gift_wrap;
		return el(
			'div',
			{ className: 'wc-block-components-totals-wrapper pocket-coffee-gift-wrap' },
			el( CheckboxControl, {
				label: __( 'Gift wrap (2.50 EUR)', 'pocket-coffee-checkout-blocks' ),
				checked,
				onChange: ( value ) =>
					extensionCartUpdate( { namespace: NS, data: { gift_wrap: value } } ),
			} )
		);
	};

	registerPlugin( NS, {
		scope: 'woocommerce-checkout',
		render: () =>
			el(
				Fragment,
				null,
				el( ExperimentalOrderMeta, null, el( FreeShippingNotice ) ),
				el( ExperimentalDiscountsMeta, null, el( GiftWrap ) )
			),
	} );

	// 4. The inner block: sends its values with the checkout request and blocks the order until the policy is accepted.
	const ClubBlock = ( { checkoutExtensionData } ) => {
		const { setExtensionData } = checkoutExtensionData;
		const [ optin, setOptin ] = useState( false );
		const [ ack, setAck ] = useState( false );
		const { setValidationErrors, clearValidationError } = useDispatch( validationStore );
		const error = useSelect( ( select ) => select( validationStore ).getValidationError( ERROR_ID ) );

		useEffect( () => {
			setExtensionData( NS, 'club_optin', optin );
		}, [ optin, setExtensionData ] );

		useEffect( () => {
			setExtensionData( NS, 'roast_ack', ack );
			if ( ack ) {
				clearValidationError( ERROR_ID );
			} else {
				setValidationErrors( {
					[ ERROR_ID ]: {
						message: __( 'Please confirm the roast date policy.', 'pocket-coffee-checkout-blocks' ),
						hidden: true,
					},
				} );
			}
		}, [ ack, setExtensionData, setValidationErrors, clearValidationError ] );

		return el(
			'div',
			{ className: 'pocket-coffee-club' },
			el( CheckboxControl, {
				label: __( 'Join the Coffee Club (10% off beans)', 'pocket-coffee-checkout-blocks' ),
				checked: optin,
				onChange: setOptin,
			} ),
			el( CheckboxControl, {
				label: __( 'I understand that beans are roasted to order', 'pocket-coffee-checkout-blocks' ),
				checked: ack,
				onChange: setAck,
			} ),
			error && ! error.hidden
				? el( ValidationInputError, { errorMessage: error.message, propertyName: ERROR_ID } )
				: null
		);
	};

	registerCheckoutBlock( {
		metadata: getSetting( NS + '_data', {} ).blockMetadata,
		component: ClubBlock,
	} );

	// 5. Checkout events: observers run in order and must return true (or a response object).
	checkoutEvents.onCheckoutValidation( () => {
		window.console.info( '[pocket-coffee] onCheckoutValidation' );
		return true;
	} );
	checkoutEvents.onCheckoutSuccess( ( { processingResponse } ) => {
		window.console.info( '[pocket-coffee] onCheckoutSuccess payment ' + processingResponse.paymentStatus );
		return true;
	} );
	checkoutEvents.onCheckoutFail( () => {
		window.console.info( '[pocket-coffee] onCheckoutFail' );
		return true;
	} );
}() );
( function () {
	'use strict';

	const { registerBlockType } = window.wp.blocks;
	const { useBlockProps } = window.wp.blockEditor;
	const { createElement: el } = window.wp.element;
	const { __ } = window.wp.i18n;
	const metadata = window.wc.wcSettings.getSetting( 'pocket-coffee-blocks_data', {} ).blockMetadata;

	registerBlockType( metadata, {
		edit: () =>
			el(
				'div',
				useBlockProps(),
				__( 'Coffee Club opt-in and roast date confirmation (shown to shoppers as two checkboxes).', 'pocket-coffee-checkout-blocks' )
			),
		save: () => el( 'div', useBlockProps.save() ),
	} );
}() );

What the run of the Checkout page showed for the block: it rendered the two checkboxes inside the contact section, the extension data of the store was { "pocket-coffee-blocks": { "club_optin": false, "roast_ack": false } } at load (the order attribution namespace was there too), and the validation store held the entry pocket-coffee-roast-ack with hidden: true next to the entries for the empty address fields. Pressing Place order without ticking the policy checkbox left the status at idle and the notice "Please confirm the roast date policy." appeared; after ticking both boxes the extension data became club_optin: true, roast_ack: true, the order was placed, and the order meta _pocket_coffee_club_optin was yes.

Validation

There are four layers, and a robust extension uses at least the last one, because the browser can be bypassed:

  1. Fields, with the validation store (setValidationErrors( { id: { message, hidden } } ), clearValidationError( id ), showAllValidationErrors()): inline errors and a guard that blocks the order button. hidden: true keeps the message invisible until the shopper presses Place order, which then shows every error and focuses the first. This is what the inner block does for the policy checkbox.

  2. The flow, with onCheckoutValidation observers (arbitrary asynchronous checks; return true or an error object) and the useValidateCheckout() hook, which runs the validation on demand and resolves to { hasError }. disableCheckoutFor( promise ) of the checkout store disables the button while a promise is pending (isCalculating), for instance during a lookup. (These two are documented; they were not run here.)

  3. The cart, on the server: woocommerce_store_api_cart_errors( WP_Error $errors, WC_Cart $cart ) runs whenever the cart is validated (cart requests and checkout); an error added there appears in the errors of the cart response and stops the checkout with HTTP 409. The example refuses a parcel over 5 kg:

    $ python3 -I checkout_checks.py weight     # product 79 weighs 1 kg and the cart has 6 of them
    add-item 201 null
    cart errors: [{"code": "pocket_coffee_parcel_weight", "message": "A parcel can weigh 5 kg at most. Please split your order."}]
    409 {"code": "pocket_coffee_parcel_weight", "message": "A parcel can weigh 5 kg at most. Please split your order."}
  4. The order, on the server, just before payment: woocommerce_checkout_validate_order_before_payment( WC_Order $order, WP_Error $errors ) (since 9.9.0) adds errors, and a RouteException thrown from woocommerce_store_api_checkout_update_order_from_request stops at once (new RouteException( 'code', 'message', 400 ); the class is Automattic\WooCommerce\StoreApi\Exceptions\RouteException). The $errors->add() route produced the code woocommerce_rest_checkout_custom_validation_error, the exception route the code that you choose; both were shown in the table of errors above.

Other validation hooks exist for the earlier steps: woocommerce_store_api_validate_add_to_cart( $product, $request ) when an item is added (throw an exception to refuse it), and woocommerce_store_api_validate_cart_item( $product, $cart_item ) for each line when the cart is validated.

Quantity limits

The classic woocommerce_quantity_input_max, _min, _step filters do nothing in the blocks. The replacement is a family of filters built from woocommerce_store_api_product_quantity_ and the name of the limit: minimum, maximum, multiple_of and editable, each called with the value, the product and the cart item (or null). The example caps the Pocket Coffee bags at 10:

$ python3 -I checkout_checks.py qty
add 11: 201 quantity in cart: 10
400 {"code": "invalid_quantity", "message": "The maximum quantity of &quot;Colombia Huila 250 g (hello)&quot; allowed in the cart is 10"}
add 3: 201 {"minimum": 1, "maximum": 10, "multiple_of": 1, "editable": true}

(The (hello) suffix of the product name comes from the same product-name filter and is not part of this example.) Adding 11 bags was accepted and clamped to 10 without an error, while changing the quantity of a line above the limit was refused with HTTP 400. The quantity_limits object of a cart item (minimum, maximum, multiple_of, editable) is what the blocks use to draw the quantity selector. The 11.2.0 release added the filter woocommerce_store_api_cart_item_quantity_validation for the validation itself.

Migrating classic checkout customisations

A table of classic checkout customisations and the Cart and Checkout block replacement for each, marked as keeps working, use the replacement, or no equivalent

The books of the bibliography teach the shortcode checkout: edit the fields with woocommerce_checkout_fields, validate in woocommerce_checkout_process, save the extra data in woocommerce_checkout_update_order_meta, print markup on the woocommerce_review_order_* hooks, and override checkout/form-checkout.php in the theme. Those techniques are correct for the shortcode and are legacy for the blocks: some never run, some have a replacement. The reliable way to port a customisation is to find the step of the flow it belongs to (the table above and the hook list of the server flow) and use the hook of that step.

Fields

woocommerce_checkout_fields is not consulted by the Checkout block. To prove it, a temporary plugin removed the postcode and the company from the billing fields and made the phone required with that filter. The classic form reflected it, the block did not:

classic  WC()->checkout()->get_checkout_fields( 'billing' )
  filter active : has_postcode=false  phone_required=true
  filter off    : has_postcode=true   phone_required=false
block    Checkout block (labels read from the DOM, filter active)
  Email address | Country/Region | First name | Last name | Address | Postal code | City | Province | Phone (optional)
  • Add a field: the Additional Checkout Fields API (woocommerce_register_additional_checkout_field()), documented in Additional Checkout Fields.

  • Hide or relax a core field: the field layout follows the country (the locale), so the supported hook is woocommerce_get_country_locale. Tried on the test store, this removed the postcode for Spain from the block and nothing else:

    add_filter(
    	'woocommerce_get_country_locale',
    	function ( $locale ) {
    		$locale['ES']['postcode']['required'] = false;
    		$locale['ES']['postcode']['hidden']   = true;
    		return $locale;
    	}
    );
    before: Email address | Country/Region | First name | Last name | Address | Postal code | City | Province | Phone (optional)
    after : Email address | Country/Region | First name | Last name | Address | City | Province | Phone (optional)

    The locale applies to the shipping and the billing form together ("billing shape should match shipping shape", says the documentation), and removing address fields can make taxes, shipping rates and fraud checks fail, so test every country that you sell to. Hiding address_1 also hides address_2.

  • Phone, company and second address line: they are options (woocommerce_checkout_phone_field, woocommerce_checkout_company_field, woocommerce_checkout_address_2_field) with the values required, optional or hidden, edited in the block’s settings. The documentation suggests changing the default with the default_option_woocommerce_checkout_phone_field filter. On the test store the options already existed in the database (a migration routine of the blocks wrote them: optional, hidden and optional), so a default filter had no effect and the label stayed "Phone (optional)". When the rows exist, change the option itself (wp option update woocommerce_checkout_phone_field required, which was not run because it would change the shared test store).

  • Address card text: woocommerce_localisation_address_formats is the filter that formats the saved-address card.

  • Order notes: the block must be removed in the editor; woocommerce_enable_order_notes_field does nothing.

Behaviour

Classic hook What to do in the blocks

woocommerce_checkout_process, woocommerce_after_checkout_validation

woocommerce_checkout_validate_order_before_payment (add to the WP_Error) or a RouteException in woocommerce_store_api_checkout_update_order_from_request; field-level checks in the validation store. Neither classic hook fires (observed).

woocommerce_checkout_create_order, woocommerce_checkout_update_order_meta

woocommerce_store_api_checkout_update_order_from_request( $order, $request ) for data that comes from the request (including $request['extensions']), or woocommerce_store_api_checkout_update_order_meta( $order ). The example plugin does not call $order->save() and its meta was saved with the order.

woocommerce_checkout_order_processed

woocommerce_store_api_checkout_order_processed( $order ) (before payment). For "after payment" use the order status hooks (woocommerce_order_status_processing, woocommerce_payment_complete) that both checkouts share.

woocommerce_cart_calculate_fees, woocommerce_before_calculate_totals, woocommerce_after_calculate_totals

The same hooks; they run when the Store API calculates the cart (the gift-wrap fee of the example is one).

woocommerce_cart_item_name, woocommerce_cart_item_price, woocommerce_cart_subtotal, woocommerce_cart_total and the other display filters

registerCheckoutFilters (itemName, cartItemPrice, subtotalPriceFormat, totalLabel, totalValue). The classic display filters have no effect.

woocommerce_order_button_text, woocommerce_order_button_html

The placeOrderButtonLabel filter, or the text setting of the block in the editor.

woocommerce_proceed_to_checkout

The proceedToCheckoutButtonLabel and proceedToCheckoutButtonLink filters.

woocommerce_before_cart, woocommerce_before_checkout_form, woocommerce_checkout_before_customer_details, woocommerce_review_order_before_submit and the other markup hooks

A Slot fill (ExperimentalOrderMeta, ExperimentalDiscountsMeta, the shipping and pickup slots) or an inner block. Most of the classic markup hooks have no counterpart because the block does not print them.

woocommerce_quantity_input_max, _min, _step

woocommerce_store_api_product_quantity_maximum, _minimum, _multiple_of (tested above).

woocommerce_cart_item_thumbnail, woocommerce_product_get_image

woocommerce_store_api_cart_item_images (an array of image objects in the cart item).

woocommerce_get_item_data

Still read for the line item meta; the blocks keep only a small set of inline tags (a, b, em, i, strong, br, abbr, span).

woocommerce_available_payment_gateways, woocommerce_gateway_title, woocommerce_gateway_icon, woocommerce_gateway_description

Unsetting a gateway can work, but the title, icon and description are drawn by the gateway’s JavaScript component; see Payment Gateways.

woocommerce_thankyou, woocommerce_thankyou_{gateway}, woocommerce_before_thankyou, woocommerce_order_details_before_order_table

Still fired by the Order confirmation blocks (found in the source of 11.2.1); keep them for order-received page output.

woocommerce_checkout_get_value, woocommerce_default_address_fields, woocommerce_checkout_show_terms, woocommerce_ship_to_different_address_checked

No effect. Prefill by saving the customer’s data (WC()->customer), and read the state from the stores (getUseShippingAsBilling()).

Template overrides cart/cart.php, checkout/form-checkout.php, checkout/review-order.php and the other overrides

Not used. Customise the block template parts and page-cart.html and page-checkout.html, or use global styles.

The official table of hook alternatives is long: it lists 314 hooks, of which 123 are fully supported, 7 partially supported, 155 not supported and 29 unknown at the time of writing. It was generated by logging the hooks of the shortcodes and the blocks and it is the place to look for a hook that is not in the table above. A few conclusions that it states in prose: the lifecycle and cart-total hooks (woocommerce_before_calculate_totals, woocommerce_cart_calculate_fees, woocommerce_calculate_totals, woocommerce_shipping_init, woocommerce_load_cart_from_session and others) work as before; shipping methods are added the usual way (see Shipping Methods); changing a core field is not supported through woocommerce_checkout_fields; "performing actions just before the order is finalised" with woocommerce_checkout_create_order is not supported.

Moving an existing store

  1. Inventory the customisations. Search the plugin and theme code for the classic hook names of the table, for woocommerce_checkout_fields, and for overrides of the cart and checkout templates in the theme’s woocommerce/ folder.

  2. Check the plugins. Every plugin that touches the cart or the checkout either declares cart_checkout_blocks compatibility (see above) or must be replaced; the uncertain list is the one to review.

  3. Port the data customisations to the Store API hooks, the fields to the Additional Checkout Fields API, and the display ones to filters and slot fills.

  4. Switch on a staging copy: replace the content of the Checkout page with the Checkout block (and the Cart page with the Cart block), keeping the old content in the revision history. The rollback is the Classic Shortcode block shown at the start of the page.

  5. Test payments, taxes and shipping with real country and product combinations, and the emails (the order is the same, but a customisation that stored data in a hook that no longer fires will be missing it).

Pages of an old store may keep working without any of this: nothing forces the migration in 11.2.1. The cost of waiting is that new extensions and payment methods assume the blocks.

Settings that belong to the blocks

A few behaviours are chosen in the editor or in WooCommerce settings and not in code:

  • The phone, company and second address line fields (options listed above), the text and the style of the Place order button, the terms and conditions checkbox and the order notes block can be configured or removed in the block editor. The woocommerce_checkout_phone_field family of options in the test database showed optional, hidden and optional.

  • Guest checkout, account creation and login at checkout come from WooCommerce > Settings > Accounts & Privacy and apply to both checkouts; the request carries create_account and customer_password (both in the body above).

  • Express payments appear in the Express checkout blocks only when a gateway registers an express payment method.

  • Rate limiting of the Store API is off by default and can limit the checkout separately; the Store API page describes it.

Testing and debugging

  • The browser console and the stores. wp.data.select( 'wc/store/checkout' ) and the other selectors show the state; wp.data.select( 'wc/store/validation' ).getValidationErrors() shows what blocks the button.

  • The order step log (above) shows how far a request got and the totals at each step. A step that is missing is where it stopped.

  • curl or a script against the Store API reproduces a failing checkout without the browser. A guest cart needs the Cart-Token header that GET /cart returns, or a Nonce; the Store API page has the details. The script below, which produced the outputs of the previous sections, uses the cart token with urllib from the standard library.

  • A headless browser that clicks the real button catches script errors that a Store API test cannot. The runs of this page used the Chrome DevTools protocol: set the addresses with wp.data.dispatch( 'wc/store/cart' ).setShippingAddress( ... ) and setBillingAddress( ... ), tick the inner block checkboxes, click .wc-block-components-checkout-place-order-button, wait for the redirect.

  • Playwright and PHPUnit are the official test tools; Testing, Deployment and Marketplace covers them. They were not run here.

The script behind the checks

"""Checks of the Pocket Coffee checkout rules through the Store API of a local store.

Usage: python3 -I checkout_checks.py ok | noack | islands | wrap | weight | qty
"""
import json
import sys
import urllib.error
import urllib.request

BASE = 'http://localhost:8081/wp-json/wc/store/v1'
ADDRESS = {'first_name': 'Ada', 'last_name': 'Lovelace', 'address_1': 'Calle Mayor 1', 'city': 'Madrid',
           'state': 'M', 'postcode': '28013', 'country': 'ES', 'phone': ''}


def call(method, path, body=None, headers=None):
    request = urllib.request.Request(
        BASE + path,
        data=json.dumps(body).encode() if body is not None else None,
        method=method,
        headers={'Content-Type': 'application/json', **(headers or {})},
    )
    try:
        with urllib.request.urlopen(request) as response:
            return response.status, dict(response.headers), json.loads(response.read() or b'null')
    except urllib.error.HTTPError as error:
        return error.code, dict(error.headers), json.loads(error.read() or b'null')


def start(product_id, quantity):
    """A guest cart: the Cart-Token returned by GET /cart identifies it in later calls."""
    _, headers, _ = call('GET', '/cart')
    auth = {'Cart-Token': headers.get('Cart-Token') or headers.get('cart-token')}
    status, _, cart = call('POST', '/cart/add-item', {'id': product_id, 'quantity': quantity}, auth)
    return auth, status, cart


def checkout(auth, extensions, address=ADDRESS):
    body = {
        'billing_address': {**address, 'email': 'ada@example.test'},
        'shipping_address': address,
        'payment_method': 'bacs',
        'extensions': extensions,
    }
    return call('POST', '/checkout', body, auth)


def show(status, out, keys=('code', 'message', 'data')):
    print(status, json.dumps({key: out.get(key) for key in keys}))


mode = sys.argv[1]
if mode == 'ok':
    auth, _, cart = start(77, 1)
    print('cart extensions:', json.dumps(cart['extensions']))
    status, _, out = checkout(auth, {'pocket-coffee-blocks': {'club_optin': True, 'roast_ack': True}})
    show(status, out, ('order_id', 'status', 'payment_method', 'payment_result'))
elif mode == 'noack':
    auth, _, _ = start(77, 1)
    status, _, out = checkout(auth, {'pocket-coffee-blocks': {'club_optin': True, 'roast_ack': False}})
    show(status, out)
elif mode == 'islands':
    auth, _, _ = start(77, 1)
    island = {**ADDRESS, 'state': 'GC', 'postcode': '35001', 'city': 'Las Palmas'}
    status, _, out = checkout(auth, {'pocket-coffee-blocks': {'roast_ack': True}}, island)
    show(status, out)
elif mode == 'wrap':
    auth, _, cart = start(77, 1)
    print('before:', cart['totals']['total_price'], cart['extensions']['pocket-coffee-blocks'])
    status, _, cart = call('POST', '/cart/extensions',
                           {'namespace': 'pocket-coffee-blocks', 'data': {'gift_wrap': True}}, auth)
    fees = [fee['name'] + ':' + fee['totals']['total'] for fee in cart['fees']]
    print('after update:', status, cart['totals']['total_price'], fees, cart['extensions']['pocket-coffee-blocks'])
elif mode == 'weight':
    auth, status, cart = start(79, 6)          # product 79 weighs 1 kg
    print('add-item', status, json.dumps(cart.get('code')))
    _, _, cart = call('GET', '/cart', None, auth)
    print('cart errors:', json.dumps(cart.get('errors')))
    status, _, out = checkout(auth, {})
    show(status, out, ('code', 'message'))
elif mode == 'qty':
    auth, status, cart = start(77, 11)
    print('add 11:', status, 'quantity in cart:', cart['items'][0]['quantity'])
    key = cart['items'][0]['key']
    status, _, out = call('POST', '/cart/update-item', {'key': key, 'quantity': 12}, auth)
    show(status, out, ('code', 'message'))
    auth, status, cart = start(77, 3)
    print('add 3:', status, json.dumps(cart['items'][0]['quantity_limits']))

Pitfalls

  • Hooks that do not run. The classic checkout actions and filters of the table above do nothing in the blocks; the failure is silent. Check with the hook list before trusting a snippet from a book or an old forum answer.

  • Money values are minor-unit strings. "1340" is 13.40 EUR; compare with parseInt and the currency’s currency_minor_unit, and never with floats.

  • orderId of onCheckoutSuccess is not reliable in 11.2.1 (it was 0); use the redirect URL or look the order up on the server.

  • Observers cannot change the state. An observer of a checkout event must not dispatch actions that change the cart or checkout state; it returns a response object instead.

  • An absent key is a null key. The checkout request is validated against the schema of the extension. A key that the request leaves out is checked as null, so a key declared 'type' => 'boolean' is rejected with rest_invalid_param ("…​ is not of type boolean") even if the schema says 'optional' => true. In the run the type had to be array( 'boolean', 'null' ).

  • One callback per namespace. A second woocommerce_store_api_register_update_callback() for the same namespace replaces the first.

  • The extensions namespace is global. A short or generic namespace collides with another plugin; use the plugin slug.

  • Scripts in the wrong place. A script that needs wc-settings and loads in the header is moved to the footer and logs a warning; register extension scripts in the footer with the right dependencies.

  • Experimental names. The slot components and some APIs are prefixed Experimental and can change; pin the WooCommerce version you test against and run the checkout flow after every minor release (there is one about every five weeks).

  • Do not remove fields to hide a problem. Payment gateways, tax calculation and fraud screening use the address; removing fields can pass in a test mode and fail in production.

  • Draft orders: do not rely on a draft order existing while the shopper types; since 10.9.0 it is created when the order is placed, and the woocommerce_store_api_checkout_update_draft action (10.8.0) is the hook for a live PATCH request.

  • Cache. The Cart and Checkout pages and the Store API must never be cached by a page cache or a CDN; Performance and Security lists the exclusions.