Skip to content

WooCommerce Store API

The Store API gives you a cart. It does not give you payments.

Cart, coupons, shipping rates and tax totals work well over plain JSON. Then you reach payment_data, and what you are actually writing against is a gateway plugin's private reading of the $_POST superglobal. Here is the whole mechanism, quoted from the source.

What you send
POST /wp-json/wc/store/v1/checkout
Cart-Token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...

{
  "billing_address":  { "first_name": "Ada", ... },
  "shipping_address": { "first_name": "Ada", ... },
  "payment_method": "stripe",
  "payment_data": [
    { "key": "wc-stripe-confirmation-token",
      "value": "ctoken_1Example" },
    { "key": "wc_stripe_selected_upe_payment_type", "value": "card" }
  ]
}

payment_data is an array of key and value pairs, not an object. That is the first sign of what it really is.

What WooCommerce does with it
// StoreApi/Legacy.php
public function process_legacy_payment(
    PaymentContext $context,
    PaymentResult &$result
) {
    // ...
    // Add the payment data from the API to the POST global.
    $_POST = $context->payment_data;

    // Call the process payment method of the chosen gateway.
    $payment_method_object =
        $context->get_payment_method_instance();

    $payment_method_object->validate_fields();

    $gateway_result = $payment_method_object->process_payment(
        $context->order->get_id()
    );

The comment on the assignment is WooCommerce's own, in StoreApi/Legacy.php.

read from source on 2026-07-24 · WooCommerce trunk and woocommerce-gateway-stripe develop

01Credit where it is due

The cart half is good.

Most of what a storefront needs from a commerce backend already works.

The Store API has been formally stable since March 2022, with a schema contract and a commitment that breaking changes get a new namespace. It is unauthenticated by design and scoped to the current session, which is exactly right for a storefront. /cart/add-item, /cart/apply-coupon, /cart/select-shipping-rate and /cart/update-customer all return recalculated totals with tax already resolved. You do not have to reimplement any of that, and you should not try.

So the cart is not the problem. If someone tells you headless WooCommerce does not work, they are overstating it. The problem is narrower and harder to see from the outside: it starts at the exact moment you try to take money.

02The mechanism

The payment layer is a form, not an API.

POST /checkout takes payment_method and payment_data. Following those two fields through core is the whole story.

1. The keys are sanitized
// StoreApi/Utilities/CheckoutTrait.php
foreach ( $request['payment_data'] as $data ) {
    $payment_data[ sanitize_key( $data['key'] ) ]
        = wc_clean( $data['value'] );
}

sanitize_key lowercases and strips anything outside a-z, 0-9, underscore and hyphen, which is why every documented key is already in that shape.

2. The gateway is resolved
// StoreApi/Routes/V1/Checkout.php
$available_gateways =
    WC()->payment_gateways->get_available_payment_gateways();

if ( ! isset( $available_gateways[ $request_payment_method ] ) ) {
    throw new RouteException(
        'woocommerce_rest_checkout_payment_method_disabled',
        /* ... */
        400
    );
}

Against the classic registry. Any enabled WC_Payment_Gateway is reachable here, whatever you have read about Blocks integration being required.

3. And then the superglobalIf no gateway claims the process-payment-with-context hook, WooCommerce falls back to the legacy path, assigns your JSON payload to $_POST, and calls the gateway's process_payment as though a WordPress checkout form had been submitted.
StoreApi/Legacy.php
// StoreApi/Legacy.php
public function process_legacy_payment(
    PaymentContext $context,
    PaymentResult &$result
) {
    // ...
    // Add the payment data from the API to the POST global.
    $_POST = $context->payment_data;

    // Call the process payment method of the chosen gateway.
    $payment_method_object =
        $context->get_payment_method_instance();

    $payment_method_object->validate_fields();

    $gateway_result = $payment_method_object->process_payment(
        $context->order->get_id()
    );

Your React application is not calling a payment API. It is impersonating a WordPress form submission over JSON, and the interface it has to satisfy is whichever $_POST keys that gateway plugin happens to read in the version you have installed.

That design is not an accident or a bug. It is what lets thousands of existing gateway plugins keep working without being rewritten, which is a real and defensible tradeoff for WooCommerce to have made. It is simply not a contract you can build a decoupled storefront against and expect to stay working.

03The contract

The keys are not a contract, and they move.

The clearest evidence is that WooCommerce's own documentation is wrong, and has been for years.

Still published as the Stripe example
// developer.woocommerce.com, Checkout API, Stripe example
{ "key": "stripe_source",
  "value": "[a_stripe_payment_source]" },
{ "key": "paymentMethod", "value": "stripe" },
{ "key": "wc-stripe-new-payment-method", "value": true }

stripe_source does not appear anywhere in the current Stripe gateway.

What the current gateway actually reads
wc-stripe-confirmation-token
wc_payment_intent_id
wc_stripe_selected_upe_payment_type
wc-stripe-payment-token          // reusing a saved card
issavedtoken
save_payment_method

Not a published interface. This list is what process_payment goes looking for in $_POST.

The Stripe key set has moved twice, from stripe_source to wc-stripe-payment-method to wc-stripe-confirmation-token, and wc-stripe-is-deferred-intent was added and later removed along with the entire non-deferred code path. None of those were deprecations, because none of them were ever published as an interface. There is no version, no sunset window, and no changelog entry addressed to an API consumer, because from WooCommerce's point of view no API consumer exists.

The documentation says as much, plainly: "We cannot comprehensively list all expected requests for all payment gateways", followed by advice to contact the gateway plugin's authors. WooCommerce's own official tutorial on placing an order through the Store API sidesteps the problem entirely by paying with cheque.

04Authentication

Where headless stops being headless.

A card needs 3D Secure. This is what your decoupled storefront gets back.

200 OK
HTTP/1.1 200 OK

{
  "order_id": 1042,
  "status": "pending",
  "payment_result": {
    "payment_status": "success",
    "payment_details": [
      { "key": "verification_endpoint",
        "value": "https://shop.example.com/?wc-ajax=wc_stripe_verify_intent&order=1042&nonce=8f2c1b9e4d&intent_id=pi_1Example" }
    ]
  }
}

payment_status is success. The order is still pending. Both of those are true at once.

woocommerce-gateway-stripe
// woocommerce-gateway-stripe, class-wc-stripe-blocks-support.php
$verification_endpoint = add_query_arg(
    [
        'order'       => $context->order->get_id(),
        'nonce'       => wp_create_nonce( 'wc_stripe_confirm_pi' ),
        'intent_id'   => $payment_details['payment_intent_id'],
        'redirect_to' => rawurlencode( $result->redirect_url ),
    ],
    home_url() . \WC_AJAX::get_endpoint( 'wc_stripe_verify_intent' )
);

$payment_details['verification_endpoint'] = $verification_endpoint;
$result->set_payment_details( $payment_details );
$result->set_status( 'success' );

Hooked at priority 9999, and it sets the result status to success on the way out.

There is a way around it, and you should know what it costs. The same response carries payment_intent_secret, so a sufficiently determined client can call Stripe.js itself and keep the buyer in the application. What you give up is the order status transition that the WordPress endpoint was going to perform. That work does not disappear; it becomes yours, reconciled from webhooks you now have to make reliable.

Which is a problem, because WooCommerce webhooks disable themselves silently after five consecutive failed deliveries, and there is an open issue on the Stripe gateway, filed in July 2026, where payments succeed, orders sit in pending payment and then auto-cancel, and the webhook returns HTTP 204 both on success and on signature-validation failure, so Stripe's dashboard cannot tell you which happened. Money captured, order cancelled, no signal.

05Underneath

The session you are holding.

None of this is in the two paragraphs of official documentation on cart tokens.

Cart-Token, decoded
Cart-Token: <HS256 JWT, signed with '@' . wp_salt()>

{
  "user_id": "t_5f2a91c4e8",
  "exp": 1753488000,        // issued + DAY_IN_SECONDS * 2, so 48h
  "iss": "store-api"
}

Read out of CartTokenUtils.php and JsonWebToken.php, neither of which the cart token documentation summarises.

Facts you will meet eventually

Token TTL
48h, filtered by wc_session_expiration
Signature
HS256 over '@' . wp_salt()
Nonce alternative
server-side wp_create_nonce only
Nonce refresh
12h is the guidance from core
Checkout rate limit
3 requests / 60s when enabled
CORS preflight
issue #29116, open since Feb 2021
Guest to logged-in
issue #55653, cart merge, open

The rate limit is off by default. When it is on, POST /checkout is capped at three requests per sixty seconds, so a 3D Secure challenge the buyer fumbles twice is a checkout that will not let them try again for a minute.

06The alternative

What this looks like without the glue.

Same buyer, same Stripe underneath, same domain. The difference is that the authentication step is a typed object instead of a URL somewhere else.

1. Open a payment leg on the order
ORDER=ord_1kmn0aExample

curl -X POST \
  https://api.withflintpay.com/v1/orders/$ORDER/payment-intents \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: leg-ord-1042-001" \
  -d '{
    "payment_source_selection": {
      "card": { "digital_wallets": ["apple_pay", "google_pay"] }
    }
  }'
201 Created
{
  "data": {
    "payment_intent": {
      "payment_intent_id": "pi_1kmn0aExample",
      "status": "requires_payment_method",
      "amount_money": { "amount": 16632, "currency": "USD" }
    },
    "payment_collection": {
      "stripe": {
        "account_id": "acct_1kmn0aExample",
        "publishable_key": "pk_test_1kmn0aExample",
        "elements": {
          "mode": "payment",
          "amount_money": { "amount": 16632, "currency": "USD" },
          "payment_method_types": ["card"],
          "digital_wallets": ["apple_pay", "google_pay"],
          "payment_method_creation": "manual"
        }
      }
    }
  }
}

The account and publishable key come back with it, so Elements mounts on your domain without a second round trip.

2. Pay the order
curl -X POST \
  https://api.withflintpay.com/v1/orders/$ORDER/pay \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: pay-ord-1042-attempt-1" \
  -d '{
    "payment_intents": [
      { "payment_intent_id": "pi_1kmn0aExample",
        "token": "pm_1kmn0aExample" }
    ],
    "expected_outstanding_money": {
      "amount": 16632, "currency": "USD"
    },
    "completion_behavior": "complete_order"
  }'
3. The issuer wants authentication
{
  "data": {
    "payment_attempt": {
      "payment_attempt_id": "opat_1kmn0aExample",
      "status": "requires_action",
      "is_resumable": true,
      "pending_actions": [{
        "pending_action_id": "pendact_1kmn0aExample",
        "action_type": "payment_authentication",
        "client_action": {
          "stripe": {
            "account_id": "acct_1kmn0aExample",
            "publishable_key": "pk_test_1kmn0aExample",
            "payment_intent": {
              "stripe_js_call": "handle_next_action",
              "client_secret": "pi_1kmn0aExample_secret_1kmn0aExample"
            }
          }
        }
      }]
    }
  }
}

The attempt says so in its own status, names the Stripe.js call to run, and hands over the credential the browser needs. No other host appears anywhere in it.

4. Resume the same attempt
# after the browser runs the named Stripe.js call,
# resume the same attempt
curl -X POST \
  https://api.withflintpay.com/v1/orders/$ORDER/pay \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: pay-ord-1042-resume-1" \
  -d '{ "payment_attempt_id": "opat_1kmn0aExample" }'

Only the attempt id. Do not resend the token, the selected legs, or the expected balance.

One attempt, start to finish
The attempt is a first-class record, so a challenged payment is inspectable rather than inferred from an order that has not moved.

The same four moments, side by side

Authentication arrives as a typed action on the attempt, not a URL on another domain.

pending_actions

A challenged payment is resumable, and the API says so rather than leaving you to guess.

is_resumable

A decline is a 402 with a normalized code, never a 200 that means something else.

last_payment_error

The balance the buyer approved is checked before the card is charged.

expected_outstanding_money

Fulfillment keys off the sale being paid, with a stable id to deduplicate on.

order.paid

Reviewed 2026-07-24 against the published Flint API and WooCommerce trunk and woocommerce-gateway-stripe develop.

last_payment_error.code, closed set

  • card_declined
  • insufficient_funds
  • expired_card
  • incorrect_cvc
  • authentication_required
  • processing_error
  • payment_method_unavailable
  • payment_failed

Provider-specific strings are mapped into this set at the API boundary, so you never branch on a raw processor string. Branch on this code, not the message.

Payment legs

A leg is an amount frozen against the order's balance. Several can settle one order, which is how split tender and partial payment work.

  • POST /v1/orders/{order_id}/payment-intents
  • payment_collection
  • capture_method

Attempts

Every try at paying is inspectable, including the failed ones, with the reason and whether it can be resumed.

  • GET /v1/orders/{order_id}/payment-attempts
  • is_resumable
  • pending_actions[].action_type

Settlement

Paying checks the balance the buyer agreed to before charging, and each settled payment draws the outstanding amount down.

  • POST /v1/orders/{order_id}/pay
  • expected_outstanding_money
  • settlement_amounts.outstanding_money

Events, with a stable id to deduplicate on

  • payment_intent.requires_action
  • payment_intent.succeeded
  • payment_intent.payment_failed
  • order.paid
flint listen --forward-to http://localhost:8080/webhooks/flint

No tunnel and no public URL. The CLI mints a signing secret for the session, so you are testing the same verification path you will run in production. Deliveries are retried, and a failing endpoint does not silently switch itself off.

07Limits

What this does not do.

There is no WordPress plugin. Nothing here installs into WooCommerce. Moving checkout onto Flint means writing the integration, and if you want WooCommerce to stay the order of record you write the write-back too.

There is no PHP SDK. A Node SDK and a CLI, and plain REST for everything else. For a team whose commerce stack is PHP that is a real cost, and it is the first thing to weigh.

Flint is not a payment processor. Stripe processes every card, and Flint provisions and operates the processing account. You cannot attach a Stripe account you already have, and Flint inherits Stripe underwriting: if Stripe declined your business, Flint cannot approve it.

Sometimes the answer is to hand checkout back to WordPress. If your store's value is in the plugin ecosystem attached to its checkout, decoupling that checkout costs more than it returns.

Pricing is published in full on the pricing page, including every conditional fee.

08Start

Ten minutes, no card required.

Node

npm install @flintpay/node

Test keys are prefixed flint_test_ and bound to a sandbox with its own data, so nothing you do there touches live money. Live keys are prefixed flint_live_. Same host, same paths; the key decides. Authentication is worth testing first: pay with 4000 0025 0000 3155 to force a 3D Secure challenge.

FAQ

Questions worth asking first.

Can you build a headless checkout on the WooCommerce Store API?

Yes. People ship it. The cart half of the Store API is genuinely good: adding items, applying coupons, selecting shipping rates and reading tax-inclusive totals all work over plain JSON and have been stable since 2022. The problem is the payment half, where the endpoint accepts a bag of key and value pairs that WooCommerce assigns to the $_POST superglobal before calling the gateway plugin's process_payment method. That is a form submission contract, not an API contract, and nothing versions it.

What is payment_data in the Store API checkout endpoint?

An array of key and value string pairs sent to POST /wp-json/wc/store/v1/checkout. WooCommerce sanitizes each key with sanitize_key, assembles them into an array, and in the legacy payment path assigns that array directly to $_POST before calling the chosen gateway's process_payment method. Which keys a gateway expects is defined by that plugin's own code, not by the Store API schema, so the correct payload differs per gateway and per gateway release.

Why does the stripe_source example in the WooCommerce docs not work?

Because the Stripe gateway stopped reading that key. The Checkout API documentation still shows stripe_source, paymentMethod and wc-stripe-new-payment-method as the Stripe example, and stripe_source does not appear anywhere in the current gateway. The keys moved from stripe_source to wc-stripe-payment-method to wc-stripe-confirmation-token, and wc-stripe-is-deferred-intent was added and then removed with the whole non-deferred code path. None of those changes were deprecations, because none of them were ever a published interface.

Does a payment gateway need Blocks support to work with the Store API?

No, and this is widely misreported. The eligibility check in the checkout route is WC()->payment_gateways->get_available_payment_gateways(), the classic registry, so any enabled WC_Payment_Gateway can be named as payment_method. If no gateway handles the process-payment-with-context hook, WooCommerce falls back to the legacy path and calls process_payment directly. The Blocks IntegrationInterface governs client-side asset registration, which is why a gateway can be reachable over the API while its Apple Pay button and its 3D Secure handling are unavailable to a frontend that is not WordPress.

What happens with 3D Secure in a headless WooCommerce checkout?

The Stripe gateway returns HTTP 200 with payment_status success, and puts a verification_endpoint into payment_details. That URL is built from home_url() plus a WordPress AJAX endpoint and carries a WordPress nonce, so the sanctioned way to finish authentication is to send the buyer out of your storefront onto the WordPress domain and back. A sophisticated client can instead read the payment intent secret from the same response and call Stripe.js itself, but then the order status transition that the WordPress endpoint was going to perform becomes your problem to reconcile from webhooks.

How long does a Store API Cart-Token last?

48 hours by default. It is an HS256 JSON Web Token signed with an at sign prepended to wp_salt(), carrying user_id, exp and an iss of store-api, with the expiry set to the current time plus DAY_IN_SECONDS times two and filterable through wc_session_expiration. Sending a valid Cart-Token means no nonce is required, which is the sanctioned path for a cross-origin frontend. Nonces are the alternative and can only be minted server-side with wp_create_nonce.

Do I have to leave WooCommerce to fix this?

No. Keeping WooCommerce as the catalog and admin while a payments API owns checkout is a smaller change than replatforming. That is where Flint sits: the order is the primitive, totals compute server-side, and authentication arrives as a typed action on the payment attempt instead of a URL on another domain.