Developers
Payments architecture
How the storefront takes payment on its own checkout page — Processors, Payment Interactions, Gateway Dialects, shopper actions, confirm payment, lost placements, express checkout, and the protections around them.
The Shopper pays on the storefront’s own checkout page, in the payment company’s own UI, and is never sent to a backend-hosted pay page. That is ADR 0022 (“embedded-only payment, and the Processor as a canonical concept”), extended by ADR 0025 (express checkout). This page explains the concepts, the Port operations, and how the WooCommerce adapter and the Companion plugin implement them.
The vocabulary
Section titled “The vocabulary”| Term | Meaning | Canonical shape |
|---|---|---|
| Processor | The payment company that moves the money. Canonical because it is not a backend: the same Processor sits behind different backends and gateways. The storefront picks its payment UI by Processor and nothing else. | Processor = "stripe" | "paypal" | "demo" |
| Embedded Payment | Paying on the checkout page in the Processor’s UI. A Capability; without it, offline methods only. | capabilities.embeddedPayment, EmbeddedPaymentMethod |
| Public Config | What the Processor’s browser SDK needs: public key or client id, mode, currency. Owned by the backend, delivered by the Port; secrets never cross. | EmbeddedPaymentConfig |
| Payment Interaction | Who owns the checkout’s primary button: form (Shopper fills the Processor’s form, then clicks “Place order”; Stripe card) or button (the Processor’s button replaces “Place order”; PayPal). |
interaction: "form" | "button" |
| Payment Token | What the browser SDK turns the payment details into. Carried unread from the SDK to placeOrder. |
PlaceOrderInput.paymentToken |
| Shopper Action | A step the Shopper must complete in the Processor’s UI before a placed order is paid, such as a 3-D Secure challenge. | ShopperAction on PlacedOrderResult |
| Confirm Payment | Finalises an order after its Shopper Action: the backend checks with the Processor and answers paid, pending or failed. | confirmPayment() |
| Prepare Payment | How a button method starts: the backend creates what the Processor needs (a PayPal order) and returns an opaque reference and the amount. |
preparePayment() → PreparedPayment |
| Lost Placement | A placement whose answer never reached the storefront, though the backend may have charged. | findPlacedOrder() |
| Express Checkout | Buying from a wallet button outside the checkout form, on the product, cart or checkout page. Its own Port concept, not a third Payment Interaction. | capabilities.expressCheckout, getExpressCheckout() |
| Gateway Dialect | What the WooCommerce adapter knows about one gateway it can take an embedded payment through. Adapter-internal; above the Port a method has a Processor, not a dialect. | GatewayDialect in adapter-woocommerce |
Port operations
Section titled “Port operations”All on the Checkout capability (CheckoutAdapter in commerce.ts):
| Operation | Does | Notes |
|---|---|---|
listPaymentMethods(ref?) |
Methods for this cart: each "offline" or "embedded" (with processor, interaction, config) |
There is no third kind. The old redirect kind and payNowUrl were removed (ADR 0022, decision 7). |
placeOrder(input) |
Places the order and, for an embedded method, charges the Payment Token | Rejects with PaymentDeclinedError (cart intact, no paid order) or PaymentAmountChangedError (nothing charged; approve again) |
preparePayment(input) |
Readies a button method’s approval |
Moves no money, places no order |
confirmPayment(input) |
Finalises after a Shopper Action, given order id, order key and the cart the order came from | Safe to repeat. Consumes the cart only when this call settles the order. |
findPlacedOrder(ref) |
The order an earlier placement from this cart settled, or null | Read-only; answers a recorded fact, never a lookalike |
getExpressCheckout(input) |
For a page (product, cart, checkout) and a cart: nothing, or the Processor, its config and the wallets to offer |
Wallets: apple_pay, google_pay, link, and demo |
PaymentMethod.id is still the backend’s gateway id, but it stays opaque: the storefront passes it back unchanged and never
branches on it. A Processor the storefront has no UI for is not offered, because it could only fail.
Paying with a form method (Stripe card)
Section titled “Paying with a form method (Stripe card)”-
The checkout lists the method with
processor: "stripe",interaction: "form"and its Public Config, and mounts Stripe’s card field. -
The Shopper clicks “Place order”. Stripe.js creates a PaymentMethod; its id is the Payment Token.
-
placeOrderActioncallsplaceOrder. The WooCommerce adapter’s Stripe dialect turns the token into the Store APIpayment_datathe official Stripe plugin expects (wc-stripe-payment-methodpluswc-stripe-is-deferred-intent) and posts the checkout. The plugin itself runs unmodified. -
The answer is a paid order (go to the thank-you page), a decline (stay, form intact), or an order that needs a Shopper Action.
Shopper Action and Confirm Payment
Section titled “Shopper Action and Confirm Payment”When the bank wants a 3-D Secure challenge, placeOrder returns an order that exists but is unpaid, with a shopperAction: the
Processor it belongs to and the client secret its SDK runs the challenge from. The storefront runs the challenge on the checkout
page, then calls confirmPayment with the order id and key. The backend, never the browser, checks with the Processor:
| Answer | What the checkout does |
|---|---|
paid |
Goes on to the thank-you page. The cart is consumed only now. |
failed |
Frees the checkout; the Shopper may retry or pay another way. A retry reuses the same pending order while the cart is unchanged. |
pending |
Does not give “Place order” back. A notice says not to pay again, the method is locked, and “Check again” confirms the same order. A confirm call that itself fails counts as pending, never failed. |
On WooCommerce, the Companion’s HeadlessStripeChallenge makes the Stripe plugin confirm storefront payments for Stripe’s
browser SDK, so the challenge runs in Stripe’s modal over the checkout and never redirects to a WordPress page. confirmPayment
calls the Companion’s POST /payment/stripe/verify, which has the Stripe plugin re-read the payment intent and answers
paid, pending or failed. A redirect-style action (an older Companion) is not followed; the checkout says the payment was not
completed.
Paying with a button method (PayPal)
Section titled “Paying with a button method (PayPal)”-
While PayPal is selected, its button replaces “Place order”. The button stays disabled while the form is invalid, because PayPal’s SDK opens its window the moment an enabled button is clicked.
-
On click, the checkout validates the form, saves the billing and shipping addresses on the cart and requires a chosen delivery rate, all in the same server call that runs
preparePayment. The prepared amount is therefore the order’s total. -
The WooCommerce adapter calls the Companion’s
POST /payment/paypal/orderwith theCart-Token. The PayPal plugin creates a PayPal order for exactly the cart total, and its id comes back as the prepared reference. -
The Shopper approves in PayPal’s window. Approval places the order with no further click: the reference rides the Store API checkout as
paypal_order_id, and the plugin captures while the order is created. There is no separate approve route. -
If the cart total moved after approval, the Companion’s
PayPalAmountGuardrefuses the checkout before anything is captured (rs_payment_amount_changedorrs_payment_not_prepared), the adapter raisesPaymentAmountChangedError, and the Shopper approves again for the new total.
Gateway Dialects
Section titled “Gateway Dialects”The WooCommerce adapter holds a dialect table, one entry per gateway id it can take an embedded payment through
(packages/adapter-woocommerce/src/gatewayDialects.ts). A method is embedded only when the cart offers the gateway, a dialect exists
for it, and the Companion’s GET /payment/config returned its Public Config. Any other online gateway is not offered at all, and
the adapter logs one warning per listing naming it and why.
| WooCommerce Stripe Gateway | WooCommerce PayPal Payments | |
|---|---|---|
| Gateway id | stripe |
ppcp-gateway |
| Verified plugin version | 11.0.0 | 4.1.3 |
| Processor | stripe |
paypal |
| Payment Interaction | form |
button |
| Public Config source | Companion GET /payment/config |
Companion GET /payment/config |
| Payment data | wc-stripe-payment-method, wc-stripe-is-deferred-intent |
paypal_order_id |
| Shopper Action | The plugin’s confirm marker, mapped to ShopperAction |
None |
| Confirm route | /payment/stripe/verify |
None (the plugin captures at checkout) |
| Prepare route | None | /payment/paypal/order |
| “Approve again” codes | rs_payment_amount_changed |
rs_payment_amount_changed, rs_payment_not_prepared |
| Express wallets | apple_pay, google_pay, link (Link not yet verified; see Express Checkout below) |
None |
| Silently owned sibling | None | ppcp-card-button-gateway |
The verified versions are pinned in the Companion (SupportedPluginVersions) and repeated as each dialect’s pluginVersion. The
Companion warns administrators about an active plugin outside the pinned minor line, and disables nothing. Its plugin-contract
check (composer test-contract) asserts every plugin internal the dialects rely on. The table is a constructor argument of the
checkout adapter, so a future Multivendor Plugin package could add its marketplace gateways behind the same Processors without
changing the storefront.
Lost placements
Section titled “Lost placements”The backend charges inside the placement call, so an answer lost after the charge (dropped connection, timeout, crash) leaves a paid order the storefront never heard of. The storefront therefore:
- calls
findPlacedOrderright after a placement fails for any reason other than a refusal, and again before the next placement from the same cart. It also asks after a decline, because WooCommerce reports any failure inside a gateway’s payment step under the decline code (ADR 0024); - remembers the unresolved cart in an httpOnly cookie, written by the failed placement itself;
- on a checkout opened with an unresolved placement behind it, asks at once through
POST /api/checkout/resume, and goes to the order if a webhook has paid it since.
The answer must be a fact the backend recorded, never an order that merely looks alike (same email, items, total), because a
Shopper may genuinely buy the same things twice. On WooCommerce the Companion records the order id in the cart’s session on
woocommerce_store_api_checkout_order_processed, and GET /payment/placed-order answers exactly that record for the request’s
Cart-Token. The adapter returns the order only when it is settled (processing, completed or on hold).
Express Checkout
Section titled “Express Checkout”ADR 0025 adds wallet buttons outside the checkout form: on the product page (buys only the product on show), the cart page, and the checkout page under the primary pay button after an “or” rule.
- One read.
getExpressCheckout({ page, cartId, productHandle? })answers nothing, or the Processor, its Public Config, the wallets, whether the wallet must ask for a phone number, and the opaquepaymentMethodIdto place with. - The sheet reuses the cart. While the wallet’s sheet is open the storefront calls the existing
setShippingAddressandselectShippingRate, markedfromWallet, because before approval a wallet reveals only part of the address. On WooCommerce the adapter sends theX-RS-Express-Checkout: 1header, and the Companion’sStorefrontExpressRequestlets the Stripe plugin’s own wallet address repair run. - Placement is
placeOrder. It carries the wallet’s Payment Token, the wallet’s addresses, andexpress: the wallet and the amount the sheet displayed. A decline, a challenge and a lost placement are handled exactly as for a card. - The backend enforces the amount. The Companion’s
ExpressAmountGuardrefuses a storefront express checkout unless the payment data’srs_express_amountandrs_express_currencyequal the order’s (409 rs_payment_amount_changed). - The storefront owns the look. The Port carries nothing about button appearance; the buttons follow the active Color Scheme and the storefront’s own button design.
- Domain registration. A wallet button only appears on a domain registered with Stripe. The Companion registers the storefront domain automatically when the Storefront URL or the Stripe keys or mode change.
A declined payment does not say why
Section titled “A declined payment does not say why”A Processor’s decline reason (“insufficient funds”, “incorrect security code”) is exactly what a card tester wants. ADR 0024 hides it on both sides:
- The Companion (
CheckoutProtection) replaces the message of every Store API payment error with one generic sentence, keeping the code. It applies to every Store API checkout on the store, whoever calls it, including the WordPress block checkout. - The storefront shows its own localised decline wording and never the backend’s message; the reason is logged server-side.
- The real reason stays where staff can read it: the order notes and the payment plugin’s log.
Hiding the reason slows card testing; the per-Shopper rate limit stops it, and that needs the signed shopper address below.
Signed shopper address
Section titled “Signed shopper address”Every backend request comes from the storefront server, so without help the backend’s per-address limits count the whole store as one client. ADR 0023 (signed shopper address) fixes that:
- The storefront reads the Shopper’s address from its proxy (the first
X-Forwarded-Forentry, elseX-Real-IP,lib/server/shopper-address.ts). The Composition Root injects it into the WooCommerce adapter’s config. - On every request carrying a
Cart-Tokenthe adapter addsX-RS-Shopper-AddressandX-RS-Shopper-Signature, an HMAC-SHA256 of"<unix seconds>.<address>"with the shared secret. - The Companion accepts a signature within 300 seconds of its own clock and uses the signed address for its payment and checkout limits. Without a valid signature it uses the connection’s address, so a direct caller cannot claim another address.
| Side | Setting |
|---|---|
| Storefront environment | STOREFRONT_SHARED_SECRET |
WordPress wp-config.php |
RSC_STOREFRONT_SHARED_SECRET (same value) |
Unset on either side, nothing breaks: the backend falls back to the connection address, and the Companion’s per-Shopper Store API
checkout limit (5 per minute) stays off. The address is only as trustworthy as the proxy in front of the storefront, which must set
X-Forwarded-For rather than append to a client’s value.
Payment-page security
Section titled “Payment-page security”The checkout and the order confirmation are Payment Pages (lib/security/payment-pages.ts, applied by middleware.ts):
- Each response gets a Content-Security-Policy with a fresh nonce and
'strict-dynamic': only scripts carrying the nonce, and scripts those load (Stripe.js, the PayPal SDK, Next.js chunks), may run, and nothing may frame the page (PCI DSS 6.4.3). - The Store Owner’s custom scripts are not rendered there, and every client navigation into a Payment Page becomes a full page load, so a tag that ran on an earlier page is not still running.
X-Frame-Options: DENYapplies on checkout, cart, account and confirmation; the confirmation also sendsReferrer-Policy: no-referrer, because its URL carries the order key.
Demo Mode
Section titled “Demo Mode”The Fake Adapter has a Processor of its own, demo: a local card form that loads no external script, DemoPay (a button method
approved in a local window), and a demo wallet for Express Checkout. Published test numbers charge (4242 4242 4242 4242), decline
(4000 0000 0000 0002) or need a simulated challenge (4000 0000 0000 3220). Demo Mode stays fully purchasable with no payment
account, which keeps the smoke suite hermetic.
Known limits
Section titled “Known limits”- PayPal’s SDK is loaded for immediate capture. A store that sets the PayPal plugin to authorise-only creates orders the SDK will not approve; use Capture.
- The two Stripe challenge shapes and real PayPal approval cannot be automated. The contract suite skips those cases rather than
faking them, and they stay on the payment sandbox checklist (
docs/payments/sandbox-checklist.mdin the storefront repo). - A prepared PayPal payment the Shopper never approves is left for PayPal to expire; the Port has no cancel operation.
- A Shopper who abandons a challenge leaves an unpaid order; WooCommerce’s own rules clean it up.