Headless WooCommerce: the Store API decides your architecture before your framework does
- The Store API is unauthenticated for reads. WooCommerce documents it as an API that “does not require API keys or authentication tokens for access”.
- Writes are different. They need a
Nonceheader, and a nonce can only be produced bywp_create_nonce( 'wc_store_api' )running inside WordPress — the docs state “There is no other mechanism in place for creating nonces.” - A browser on another origin cannot read that nonce anyway. WooCommerce exposes only
Cart-TokenthroughAccess-Control-Expose-Headers, with the source comment “We’re explicitly exposing the Cart-Token, not the nonce.” - Without a valid
Cart-Token, a request from a foreign origin comes back with noAccess-Control-Allow-Originheader at all. We ran this against our own store on 11 August 2026; the results are below. - So the first headless decision is where your frontend gets its cart identity: same-origin, an entry in
allowed_http_origins, or a server-side hop that fetches the firstCart-Token. Framework choice comes after.
get_allowed_http_origins()Cart-Token lifetime in WooCommerce source (DAY_IN_SECONDS * 2), filterable via wc_session_expiration'enabled' => falseEvery guide to headless WooCommerce argues the same two questions: which frontend framework, and Store API or GraphQL. Both are downstream. The constraint that decides whether a decoupled storefront works at all is the one nobody writes down — the Store API will not send a cross-origin response to a caller that has no cart identity, and the only cart identity a browser is permitted to read is one it cannot obtain until it already has one. This post specifies that gate, shows what our own store returns for four variants of the same request, and gives the two configurations that get past it. Version scope: WooCommerce 11.0.1 is the current release as of 11 August 2026; the source quoted here was read on a running 10.7.0 install and re-checked against trunk.
Headless is a CORS decision before it is a framework decision
WooCommerce’s own API index describes the Store API as endpoints “for the development of customer-facing cart, checkout, and product functionality” that are “unauthenticated and does not provide access to sensitive store data or other customer information”. Read quickly, that says the storefront half of your site needs no credentials. Read carefully, it says something narrower: reads are open, and everything else depends on the caller proving which cart it is holding.
That proof is the whole architecture. WooCommerce’s Authentication class removes WordPress’s default REST CORS handling and installs its own, and the docblock states why: “By default, the WordPress REST API allows access from any origin. Because some Store API routes return PII, we need to add our own CORS headers.” The replacement is stricter than the default, and a decoupled frontend is, by definition, on the wrong side of it.
Everything the rest of this post covers — token lifetime, rate limits, what the API refuses to return — follows from that one design choice. The expensive surprise is not in month six. It is in the first afternoon, when the storefront that worked against a local WordPress on the same port stops working the moment the two are split across hostnames.
Your frontend cannot create a Store API nonce
Write endpoints are gated. WooCommerce’s Store API documentation states that “Endpoints that do allow writes, for example, updating the current customer address, require a nonce-token”, and that the header is literally named Nonce. The checkout resource is stricter still: “All checkout endpoints require either a Nonce Token or a Cart Token otherwise these endpoints will return an error.”
The load-bearing sentence is the one about where a nonce comes from. WooCommerce documents that nonces are created with wp_create_nonce( 'wc_store_api' ) and adds, without qualification, “There is no other mechanism in place for creating nonces.”
That function is a WordPress function. It runs inside the WordPress request lifecycle and derives its value from the current user and session. A frontend that is a separate application — a static build, an app, anything that is not PHP executing inside your WordPress install — has no way to call it. There is a filter, woocommerce_store_api_disable_nonce_check, and the documentation is blunt about it: “This should only be done on development sites where security is not important. Do not enable this in production.”
So a decoupled frontend has exactly two honest options for writes: render through WordPress so a nonce can be embedded server-side, or use a Cart-Token instead. The next section removes one of those two for browser-based frontends.
The browser cannot read the nonce cross-origin either
Suppose you solve the minting problem with a proxy: your own server calls WordPress, gets a nonce, and hands it to the browser. You still hit a second wall, and this one is deliberate.
A browser can only read response headers that the server lists in Access-Control-Expose-Headers. WooCommerce populates that list with exactly one Store API header:
public function exposed_cors_headers( $exposed_headers ) {
$exposed_headers[] = 'Cart-Token';
return $exposed_headers;
}
The docblock above it states the intent in one line: “We’re explicitly exposing the Cart-Token, not the nonce. Only one of them is needed.”
Both headers are accepted on the way in — allowed_cors_headers() adds Cart-Token and Nonce to the permitted request headers — but only the cart token comes back out where JavaScript can see it. For a browser-based decoupled storefront the nonce path is therefore not a preference you weigh against cart tokens. It is closed, and Cart-Token is the only credential the platform will let your frontend hold.
Four requests to our own store, four different answers
The behaviour above is readable in the source, but it is worth measuring, because the practitioner write-ups that dominate this topic disagree with it. In our testing on 11 August 2026, against our own production WooCommerce 10.7.0 store using curl, four variants of a single GET /wp-json/wc/store/v1/cart request produced four different outcomes.
| Request | Access-Control-Allow-Origin in the response |
What a browser does |
|---|---|---|
No Origin header (server-to-server) |
Not sent — and not needed | Not applicable; CORS is a browser rule |
Origin: https://storefront.example.com, no token |
Absent | Blocks the read |
Same Origin, invalid Cart-Token |
Absent | Blocks the read |
Same Origin, valid Cart-Token |
Echoes https://storefront.example.com |
Allows the read |
Verdict: a foreign origin gets no access grant until it presents a valid cart token, and an invalid token is treated exactly like no token. Every other CORS header was present in all four responses — Access-Control-Allow-Methods, Access-Control-Allow-Credentials: true, and an Access-Control-Allow-Headers list containing both Cart-Token and Nonce. Only the one header that actually grants access was missing. That is what makes this failure hard to diagnose: the response looks CORS-aware, returns HTTP 200, and is still unreadable.
Reproduce it against your own store with two commands:
# 1. No Access-Control-Allow-Origin comes back
curl -s -D - -o /dev/null
-H "Origin: https://storefront.example.com"
"https://YOUR-STORE/wp-json/wc/store/v1/cart" | grep -i "access-control-allow-origin"
# 2. Same request, carrying a token minted by request 1
curl -s -D - -o /dev/null
-H "Origin: https://storefront.example.com"
-H "Cart-Token: PASTE_TOKEN_HERE"
"https://YOUR-STORE/wp-json/wc/store/v1/cart" | grep -i "access-control-allow-origin"
The bootstrap problem, and the two ways out
Put the two findings together and a circular dependency appears. WooCommerce’s send_cors_headers() grants the origin header when the request is a preflight, when the origin is already allowed, or — in the words of its own docblock — because “Users of valid Cart Tokens are also allowed access from any origin”. A cart token is obtained by reading the Cart-Token response header from a /cart request. But a browser on a foreign origin cannot read any response header from that request, because it was not granted access.
Your frontend needs the token to be allowed to fetch the token.
Path A is the smaller change. WooCommerce’s own docblock points at it: allowed origins “can be changed using the WordPress allowed_http_origins or allowed_http_origin filters if access needs to be granted to other domains.” By default get_allowed_http_origins() returns four entries — http and https for the admin host and the home host — so your storefront domain is not among them until you add it.
add_filter( 'allowed_http_origins', function ( $origins ) {
$origins[] = 'https://storefront.example.com';
return $origins;
} );
Path B keeps WordPress untouched and puts a server-side hop in front of the first request. It costs you a service to build, host and secure — the same hidden line item that shows up in every decoupled build, and the reason a “static frontend plus WooCommerce” diagram is never the whole architecture.
Choose deliberately. Path A means your storefront origin is a deploy-time constant; adding a preview domain or a second brand means another release. Path B means every shopper’s first request costs an extra hop through infrastructure you own.
Inside a Cart-Token: an HS256 JWT that expires in 48 hours
The token is not opaque. WooCommerce’s CartTokenUtils builds it with JsonWebToken::create() from three claims — user_id, exp and iss, the last hard-coded to store-api. Decoding one issued by our store returned a header of {"alg":"HS256","typ":"JWT"} and a payload whose exp minus iat was exactly 172,800 seconds.
That matches the source, where the expiry method carries the docblock “Gets the expiration of the cart token. Defaults to 48h” and computes time() + intval( apply_filters( 'wc_session_expiration', DAY_IN_SECONDS * 2 ) ). Three consequences follow, and they are design inputs rather than trivia.
First, the token is the cart. Its user_id claim identifies a guest session. In our testing, two consecutive requests sent without a token came back with two different user_id values — each tokenless request mints a new cart. A frontend that loses the token has not lost a session cookie it can rebuild; it has lost the only handle to that basket, because the Store API “cannot be used to look up other customers and orders by ID”.
Second, 48 hours is short for a saved cart. If your storefront persists the token in client storage and your merchandising assumes a cart survives a weekend, the default expiry contradicts the plan. wc_session_expiration moves it, and the same filter moves WooCommerce’s regular session length, so the change is not scoped to headless traffic.
Third, it is a bearer token in a header with no additional binding. Anyone holding it holds that cart. Treat it with the same care as a session cookie, which means not logging it, not putting it in a URL, and not leaving it in a shared analytics payload.
Rotating your WordPress salts empties every live cart
This one is derived from the source rather than quoted from a doc, and it is the failure mode most likely to be misread as a plugin bug.
Cart tokens are signed with a secret that CartTokenUtils defines in a single line: return '@' . wp_salt();. WordPress documents that “Salts are created using secret keys” whose material “originates from two locations: the database and the wp-config.php file”. Change either input and wp_salt() returns a different value.
Every outstanding cart token was signed with the old value. Signature validation fails for all of them at once, and each affected shopper’s next request is treated as a caller with an invalid token — which, per the table above, also means the CORS grant disappears for a cross-origin storefront. Rotating salts is standard hygiene after a credential exposure, and it is often done by a security responder who has no reason to think about carts. The symptom is a fleet-wide cart wipe with no deploy and no error in the WooCommerce logs.
The mitigation is procedural, not technical: put salt rotation on the same change-control path as a deploy, announce it, and schedule it into a low-traffic window. There is no grace period and no dual-key validation to lean on.
Rate limiting ships disabled, and answers 400 rather than 429
You have just put an unauthenticated, cart-mutating, publicly reachable API in front of the internet. WooCommerce does provide a limiter for it. Two details decide whether it helps you.
It is off. The documented defaults are explicit — “Rate Limiting is available for Store API endpoints. This is optional and disabled by default” — and the filter that turns it on ships with 'enabled' => false, a limit of 25 requests and a window of 10 seconds:
add_filter( 'woocommerce_store_api_rate_limit_options', function () {
return array(
'enabled' => true,
'proxy_support' => false,
'limit' => 25,
'seconds' => 10,
);
} );
And when it does trip, it does not answer 429. The WP_Error WooCommerce returns carries the code rate_limit_exceeded, the message “Too many requests. Please wait %d seconds before trying again.”, and array( 'status' => 400 ). A client retry policy keyed on 429 — which is what almost every HTTP library ships as its default backoff trigger — will never fire. It will treat a throttled cart update as a permanent client error and surface it to the shopper as a broken button.
This is the same shape as the rejection semantics we documented for NetSuite, where a concurrency rejection arrives as an HTTP 400 rather than the 429 every retry loop watches for. Read the error body, not just the status line. The observability headers are better news: RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, and RateLimit-Retry-After, the last “only shown when the limit is reached”.
One more asymmetry worth knowing before you test: rate limiting is applied only to callers who fail a current_user_can( 'edit_posts' ) check. Log in as an administrator to test the limiter and you are exempt from it.
The checkout limiter is a separate, feature-flagged switch
Checkout has its own limiter, and it is not the one above. WooCommerce ships a rate_limit_checkout feature that, when enabled, overrides the options for checkout requests only — forcing enabled true with a limit of 3 requests per 60 seconds. The documentation notes that this is the one piece of Store API rate limiting with a settings-screen control rather than a code filter.
Two conditions narrow it. It applies only to the checkout route, and only to requests that pass an internal “is this really a POST” check. That check exists because the WordPress data layer tunnels PUT and PATCH through POST using an X-HTTP-Method-Override header, and the documentation states the consequence plainly: “Only POST requests are rate limited”, with override-carrying requests excluded.
So the honest summary is that enabling a limiter covers order placement at three attempts a minute and leaves cart mutation governed by whatever you configured — or by nothing, which is the default. Decide the limit for cart writes explicitly; do not assume the checkout switch covered them.
Behind a CDN, every shopper can share one rate-limit bucket
The limiter groups requests by identity: the user ID for logged-in shoppers, otherwise an MD5 of the IP address. Which IP it reads is controlled by the proxy_support option, and that option also ships false.
With it false, WooCommerce reads REMOTE_ADDR. On a decoupled build that is very often not the shopper. If your storefront calls the Store API through your own server (path B above), REMOTE_ADDR is your server. Behind a reverse proxy or CDN that terminates connections, it is the edge node. Either way a population of shoppers collapses into a single bucket, and 25 requests per 10 seconds — a reasonable per-shopper ceiling — becomes an outage for everyone sharing it.
Setting proxy_support true makes WooCommerce read forwarded-for style headers in a documented order: X_REAL_IP, then CLIENT_IP, then the first entry in X_FORWARDED_FOR, then Forwarded. That is only safe when a trusted proxy is guaranteed to overwrite those headers, because they are otherwise attacker-controlled and turn a shared bucket into no bucket at all. Anything that fails IP validation is grouped under 0.0.0.0 — the source comment notes this is deliberate, “to group and still rate limit invalid ips”.
The decision table is short. Same-origin storefront, no CDN in front of the API: leave proxy_support false. Storefront calling through your own service: rate-limit at your service, where you can still see the shopper. CDN terminating in front of WordPress: enable proxy support only after confirming the edge strips and rewrites the client-IP headers it forwards.
What the Store API will not do at all
Some of a storefront simply has no Store API path, and this is where headless budgets go missing. WooCommerce states the boundary directly: data “is reflective of the current user (customer)”, and the API “cannot be used to look up other customers and orders by ID; only data belonging to the current user”. It also cannot write store settings.
Translate that into pages. A cart, a checkout, a product listing, a review: all served. An order history page, a saved-address book, a re-order button, a customer’s invoice list, anything an account area contains: not served, because they all require reading records that do not belong to the anonymous current session. Those need the authenticated wc/v3 REST API, which needs a consumer key and secret, which cannot live in a browser bundle.
The conclusion is structural rather than a matter of taste. A headless WooCommerce build that includes customer accounts always contains a credential-holding service you write and operate. Scope that service in the estimate, not in month two. The same split shows up in every decoupled commerce stack — we covered what headless commerce genuinely changes, and the rendering decision that quietly hides inside it, which is the companion question to this one.
Store API, REST API, GraphQL: what each one is for
The interface comparison that dominates search results treats these as three competing ways to do the same job. They are not interchangeable, and the choice is mostly made for you by what you are building.
| Dimension | Store API | REST API | GraphQL layer |
|---|---|---|---|
| Namespace | wc/store/v1 |
wc/v3 |
Single endpoint, community schema |
| Auth model | None for reads; Nonce or Cart-Token for writes |
Consumer key and secret | Depends on the plugin’s configuration |
| Cart and checkout | Yes — the only supported path | Not designed for cart | Varies by extension |
| Other customers’ records | No — current user only | Yes, with credentials | Yes, with credentials |
| Safe in a browser bundle | Yes | No — secrets cannot ship to clients | No, when credentialed |
| Maintained by | WooCommerce core | WooCommerce core | Community project |
Verdict: use the Store API for anything the shopper’s browser does, the REST API for anything your servers do, and treat a GraphQL layer as a convenience over the same underlying data rather than a third architecture. The version numbers matter here — WooCommerce documents that for the Store API “Currently, the only version is v1“, so there is no version-pinning strategy to plan around, unlike the quarterly cadence other commerce platforms impose.
The NetSuite integration boundary does not move
One piece of good news, and it is the reason going headless is a frontend project rather than a replatform. Your ERP integration does not sit on the frontend, and it does not care that the frontend changed.
Orders placed through a Store API checkout become ordinary WooCommerce orders. The same order-object hooks fire, the same CRUD layer reads them, and code written against wc_get_order() and woocommerce_order_status_changed behaves identically whether the order arrived from a theme, a block checkout, or a decoupled storefront. The integration boundary is WooCommerce, not the presentation layer.
Two caveats keep that from being a free pass. Anything you built against theme-layer or shortcode-checkout hooks does not run, because that layer is gone — the same class of breakage as the customisations that stop working when a store moves to block checkout, and worth auditing with the same list. And custom checkout fields must be registered through the Additional Checkout Fields API so they exist in the Store API schema at all, rather than being appended to a rendered form.
Pre-flight checklist before you commit to headless
Work down this list before the frontend repository is created. Every item is answerable from your own store or from WooCommerce’s documentation, and each one has the power to change the architecture rather than just the backlog.
- Run the two
curlcommands above against your store and record whetherAccess-Control-Allow-Origincomes back for your intended storefront origin. - Decide the CORS path in writing: register the origin via
allowed_http_origins, or build a server-side hop that fetches the firstCart-Token. - List every storefront domain that will need access, including preview and staging hosts, and confirm each is covered by that decision.
- Decide where the browser stores the
Cart-Token, and what the frontend does when a request returns as though the token were absent. - Confirm whether the 48-hour default token lifetime matches your intended cart-persistence promise, and set
wc_session_expirationdeliberately if it does not. - Add salt rotation to your change-control process, with the fleet-wide cart-invalidation consequence written next to it.
- Enable and size Store API rate limiting explicitly, rather than inheriting the disabled default.
- Make every client retry path read the error code, not only the HTTP status, so a
rate_limit_exceededat status 400 is retried rather than surfaced as a failure. - Verify which IP the limiter will actually see once your CDN or proxy is in front of it, and set
proxy_supportto match. - Test the limiter as a logged-out shopper — an administrator session is exempt from it.
- Inventory every account-area page and cost the credential-holding service each one requires.
- Confirm your cache layer preserves the
Varyand CORS headers WooCommerce sends, rather than collapsing responses across origins. - Audit existing checkout customisations for theme-layer and shortcode hooks that will not run once the frontend is decoupled.
Get the working checklists
The runbooks and decision checklists from these guides, as printable PDFs — free in the SoftXone guide library.
Deciding what a decoupled storefront can carry, and what still needs a credentialed service behind it, is the scoping work our team does before a frontend repository exists. NetSuite Integration Basic runs against the order objects a Store API checkout produces, so the ERP side is unaffected by the frontend decision, and the WooCommerce store operations guide library covers the platform mechanics referenced throughout this post.
References
- WooCommerce — Store APIThe unauthenticated-API statement, the
wc/store/v1namespace, the current-user data scope, and the limits on looking up other customers and orders. - WooCommerce — Nonce TokensThe
Nonceheader,wp_create_nonce( 'wc_store_api' ), and the statement that no other mechanism exists for creating nonces. - WooCommerce — Cart TokensHow a
Cart-Tokenis issued from/cartresponses and why it removes the nonce requirement. - WooCommerce — Checkout APIThe requirement that every checkout endpoint carry either a nonce token or a cart token.
- WooCommerce — Store API Rate LimitingThe disabled-by-default option array, the 25-per-10-seconds defaults, the response headers, and the POST-only restriction.
- WooCommerce source —
StoreApiAuthenticationThe CORS docblocks, the exposed-header comment, the checkout limiter values, and the HTTP 400 rate-limit error. - WooCommerce source —
CartTokenUtilsThe token claims, the'@' . wp_salt()signing secret, and the 48-hour default expiry. - WordPress —
get_allowed_http_origins()The four default origins and theallowed_http_originsfilter. - WordPress —
wp_salt()That salt material originates from both wp-config.php secret keys and a database-stored key. - WooCommerce — Getting started with WooCommerce APIsThe official split between the admin REST API and the customer-facing Store API.
- WooCommerce — Payment method integrationThe registration path a gateway takes to appear in block and Store API checkout.
- WPGraphQL for WooCommerceThe community GraphQL layer referenced in the interface comparison.
Frequently asked questions
Can a headless WooCommerce frontend use the Store API without any server-side code?
For cart and checkout on a registered origin, yes — an origin added to allowed_http_origins can call wc/store/v1 directly and carry a Cart-Token. Customer account features cannot: reading a shopper’s past orders needs the authenticated wc/v3 API and a consumer secret, which cannot ship in a browser bundle. Budget a credential-holding service the moment accounts enter scope.
Why does the Store API return HTTP 200 while the browser still blocks the response?
Because the response omits Access-Control-Allow-Origin while carrying every other CORS header, including Access-Control-Allow-Credentials and the allowed-headers list. The request succeeded on the server; the browser simply refuses to hand the body to JavaScript. Inspect the headers with curl, where no browser rule applies, before assuming the endpoint itself is broken.
How long does a WooCommerce Cart-Token stay valid?
Forty-eight hours by default, set in WooCommerce source as DAY_IN_SECONDS * 2 and adjustable through the wc_session_expiration filter. That same filter governs standard WooCommerce session length, so raising it for a decoupled storefront also lengthens sessions for theme-based traffic on the same install.
Does going headless change how orders reach NetSuite?
No. A Store API checkout produces an ordinary WooCommerce order, so integration code written against wc_get_order() and the order status hooks behaves identically. What stops working is anything bound to theme templates or shortcode-checkout hooks, plus custom checkout fields that were never registered through the Additional Checkout Fields API.
Is the Store API safe to expose publicly without rate limiting?
It ships with rate limiting disabled, so by default the store accepts unlimited cart writes from any caller. Enable it through woocommerce_store_api_rate_limit_options, size it deliberately, and confirm which IP the limiter reads once a CDN or your own proxy sits in front — with proxy support off it reads REMOTE_ADDR, which can collapse every shopper into a single bucket.

Leave a Reply