Headless commerce
Headless WooCommerce, without hand-rolling checkout.
Going headless is a storefront decision that turns into a payments project. The Store API hands you a cart and stops; totals you can trust, authentication, refund math and order state are glue you write and then own forever. Flint starts at the order instead, and Stripe Elements mounts on your domain.
curl -X POST https://api.withflintpay.com/v1/orders \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: order-cart-a1b2c3" \
-d '{
"line_items": [
{ "name": "Merino crew", "quantity": 1,
"unit_price_money": { "amount": 11800, "currency": "USD" } },
{ "name": "Wool socks", "quantity": 2,
"unit_price_money": { "amount": 1800, "currency": "USD" } }
]
}'Line items in. No total is sent, because no total is yours to compute.
your domain · Stripe Elements · one /v1 · test mode is a key, not a flag
01The problem
The Store API stops at the cart.
The half that works is genuinely good. The half that does not is not obvious until you are committed.
GET /wp-json/wc/store/v1/cart
Cart-Token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...
{
"items": [
{ "key": "a1b2c3", "name": "Merino crew", "quantity": 1,
"prices": { "price": "11800", "currency_minor_unit": 2 } },
{ "key": "d4e5f6", "name": "Wool socks", "quantity": 2,
"prices": { "price": "1800", "currency_minor_unit": 2 } }
],
"totals": {
"total_items": "15400",
"total_tax": "1232",
"total_price": "16632"
}
}Cart, coupons, shipping rates and tax-inclusive totals over plain JSON, stable since 2022.
$_POST superglobal and calls the gateway plugin's process_payment method, so the interface you are writing against is that plugin's private form handling.Which is a defensible tradeoff for WooCommerce to have made: it is what lets thousands of existing gateway plugins keep working without a rewrite. It is simply not something you can build a decoupled storefront against and expect to stay working, because nothing versions it. The Stripe key set has moved twice, and the example still published in WooCommerce's own Checkout API documentation names a key the current gateway no longer reads.
And when a card needs 3D Secure, the response is HTTP 200 with a verification_endpoint built from home_url(), carrying a WordPress nonce. The sanctioned way to finish a payment on your headless storefront is to send the buyer to WordPress and back.
02Two routes
Keep WooCommerce, or retire it.
These are not competing products. They are two points on one migration, and most teams do the first and then decide about the second.
Route A
Flint under your checkout
The smaller change. Your storefront reads the catalog and cart from WooCommerce as it already does, then hands the money to a payments API and writes the result back. WooCommerce stays the order of record and the admin your team knows.
- Keeps
- Catalog, admin, reporting, fulfillment workflow, most plugins that are not attached to checkout.
- Moves
- Totals at checkout, card collection, authentication, refunds, payment webhooks.
Route B
WordPress keeps the content
The larger change, and the one the API is actually shaped for. Catalog, promotions, inventory, subscriptions and orders move to Flint, and WordPress goes back to being very good at posts, pages and content.
- Keeps
- WordPress as CMS, your editorial workflow, your URLs, your SEO.
- Moves
- Products and variants, coupons, stock, subscriptions, orders, fulfillment.
The seam in Route A is worth naming before you build it: two systems hold order state. Flint is authoritative for the money and WooCommerce is authoritative for the order, and the write-back is what keeps them agreeing. That is a real cost, and it is the reason Route B exists. It is also a much smaller cost than it sounds, because the write-back is one idempotent call driven by one event.
03Route A
Four calls, then a write-back.
The buyer and the rails stay the same; the card form moves onto your domain.
API=https://api.withflintpay.com
AUTH="Authorization: Bearer YOUR_API_KEY"
# 1. the order, built from the cart you already have
curl -X POST $API/v1/orders -H "$AUTH" \
-d '{ "line_items": [ ... ] }'
# 2. a payment leg, which returns the Elements guidance
ORDER=ord_1kmn0aExample
curl -X POST $API/v1/orders/$ORDER/payment-intents -H "$AUTH" \
-d '{ "payment_source_selection": { "card": {} } }'
# 3. mount Stripe Elements on your domain, collect a source token
# 4. charge the order with it
curl -X POST $API/v1/orders/$ORDER/pay -H "$AUTH" \
-d '{
"payment_intents": [
{ "payment_intent_id": "pi_1kmn0aExample",
"token": "pm_1kmn0aExample" }
],
"expected_outstanding_money": {
"amount": 16632, "currency": "USD"
}
}'{
"data": {
"order_id": "ord_1kmn0aExample",
"status": "open",
"payment_status": "unpaid",
"pricing_amounts": {
"subtotal_money": { "amount": 15400, "currency": "USD" },
"tax_money": { "amount": 1232, "currency": "USD" },
"total_money": { "amount": 16632, "currency": "USD" }
},
"settlement_amounts": {
"paid_money": { "amount": 0, "currency": "USD" },
"outstanding_money": { "amount": 16632, "currency": "USD" }
}
}
}The order came back already priced: subtotal, tax and total are the API's arithmetic, and the balance is what payments settle against.
{
"data": {
"payment_intent": {
"payment_intent_id": "pi_1kmn0aExample",
"status": "requires_confirmation",
"amount_money": { "amount": 16632, "currency": "USD" }
},
"payment_collection": {
"stripe": {
"account_id": "acct_1kmn0aExample",
"publishable_key": "pk_test_1kmn0aExample",
"elements": {
"mode": "payment",
"payment_method_types": ["card"],
"digital_wallets": ["apple_pay", "google_pay"],
"payment_method_creation": "manual"
}
}
}
}
}One response carries everything the browser needs to mount Elements on your domain: the account, the publishable key, and the configuration.
And then WooCommerce hears about it.
One event, one idempotent call. order.paid is the durable signal rather than the browser redirect, which a buyer can skip by closing the tab. Retries and manual resends reuse the same webhook_event_id, which makes it the deduplication key for the write-back.
{
"webhook_event_id": "whev_1kmn0aExample",
"event_type": "order.paid",
"payload_version": 1,
"mode": "live",
"merchant_id": "mer_1kmn0aExample",
"created_at": "2026-07-24T14:00:00Z",
"data": {
"order_id": "ord_1kmn0aExample",
"status": "closed",
"payment_status": "paid",
"total_money": { "amount": 16632, "currency": "USD" },
"paid_money": { "amount": 16632, "currency": "USD" },
"outstanding_money": { "amount": 0, "currency": "USD" }
}
}Deliveries are retried, and an endpoint that fails does not silently switch itself off.
POST /wp-json/wc/v3/orders
Authorization: Basic <consumer key : consumer secret>
{
"payment_method": "flint",
"payment_method_title": "Card via Flint",
"set_paid": true,
"transaction_id": "ord_1kmn0aExample",
"billing": { "first_name": "Ada", "email": "ada@example.com" },
"line_items": [
{ "product_id": 482, "quantity": 1 },
{ "product_id": 517, "quantity": 2 }
]
}The Flint order id goes in transaction_id, so support can get from a WooCommerce order to the payment that settled it in one hop.
Events worth handling
- order.paid
- order.partially_paid
- order.refunded
- payment_intent.requires_action
No tunnel and no public URL while you build it. The CLI mints a signing secret for the session, so you are testing the same verification path you will run in production.
04Route B
What moves, and what it becomes.
A variable product with attributes and variations has somewhere to land. So does everything attached to it.
GET /wp-json/wc/v3/products/482
{
"id": 482,
"name": "Merino crew",
"type": "variable",
"attributes": [
{ "name": "Size", "options": ["S", "M", "L"] },
{ "name": "Colour", "options": ["Oat", "Slate"] }
],
"variations": [ 4821, 4822, 4823 ]
}
GET /wp-json/wc/v3/products/482/variations/4821
{
"id": 4821,
"sku": "MC-OAT-M",
"regular_price": "118.00",
"manage_stock": true,
"stock_quantity": 24
}A variable product, its attributes, and one variation with the SKU and stock.
curl -X POST https://api.withflintpay.com/v1/products \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: import-wc-482" \
-d '{
"name": "Merino crew",
"product_type": "physical",
"metadata": { "wc_product_id": "482" },
"options": [
{ "name": "Size",
"values": [{ "name": "S" }, { "name": "M" },
{ "name": "L" }] },
{ "name": "Colour",
"values": [{ "name": "Oat" }, { "name": "Slate" }] }
],
"variants": [
{
"sku": "MC-OAT-M",
"unit_price_money": { "amount": 11800, "currency": "USD" },
"tax_category": "clothing",
"inventory_tracking": "tracked",
"metadata": { "wc_variation_id": "4821" }
}
]
}'Attributes become options, variations become variants, and metadata carries the WordPress ids across so content that resolves by post id keeps resolving.
curl https://api.withflintpay.com/v1/catalog/by-sku/MC-OAT-M \
-H "Authorization: Bearer YOUR_API_KEY"Variants keep the SKU WooCommerce already had, so an incremental import resolves by SKU instead of maintaining a mapping table, and re-running it is safe.
Catalog
Products, options, variants, categories and images. Variants carry SKU, barcode, tax category and whether stock is tracked.
- POST /v1/products
- variants[].sku
- GET /v1/catalog/by-sku/{sku}
Coupons and promotions
Coupon codes for the simple case and a promotion engine for the rules that outgrow them, resolvable by code at checkout.
- POST /v1/coupons
- POST /v1/promotions
- GET /v1/promotions/by-code/{code}
Inventory
Stock per location, with reservations that hold units through a payment window rather than a single global quantity field.
- POST /v1/inventory-items
- GET /v1/inventory-levels
- POST /v1/inventory-reservations
Subscriptions
Renewals land as orders, so refunds and reporting need no second path.
- POST /v1/subscription-plans
- POST /v1/subscriptions
- POST /v1/subscriptions/{subscription_id}/pause
Fulfillment
Fulfillments, shipments and packages on the order that was paid, plus the fulfillment options a buyer picks from at checkout.
- POST /v1/orders/{order_id}/fulfillments
- POST /v1/shipments
- GET /v1/checkout-sessions/{checkout_session_id}/fulfillment-options
Orders
Line items, discounts, charges and tax on one record, with totals recomputed server-side and an outstanding balance that falls as payments settle.
- POST /v1/orders
- pricing_amounts.total_money
- settlement_amounts.outstanding_money
05The point
What you stop writing.
Every line of this is code that exists in a hand-rolled headless checkout, and every one of them is code you keep maintaining after launch.
curl -X POST https://api.withflintpay.com/v1/refunds \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Idempotency-Key: refund-ord-1042-001" \
-d '{
"order_id": "ord_1kmn0aExample",
"reason": "requested_by_customer",
"line_items": [
{ "order_line_item_id": "li_1kmn0aExample", "quantity": 1 }
]
}'{
"data": {
"refund_id": "ref_1kmn0aExample",
"status": "succeeded",
"amount_money": { "amount": 1944, "currency": "USD" },
"line_item_allocations": [
{
"order_line_item_id": "li_1kmn0aExample",
"quantity": 1,
"amount_money": { "amount": 1800, "currency": "USD" },
"tax_money": { "amount": 144, "currency": "USD" }
}
]
}
}$18.00 for the sock plus $1.44 of its tax. You computed neither number, and the allocation is written back onto the order so support and reporting read the same story.
Pinned to the thing that backs it
Totals are derived from the order, not submitted by your storefront.
pricing_amountsRefunds target items and quantities, so the tax split is not yours to compute.
line_item_allocationsAuthentication is a typed action on the attempt, not a redirect to another domain.
pending_actionsA declined card answers with HTTP 402 and a normalized code, not a 200 you have to reinterpret.
last_payment_errorWebhooks report the sale becoming paid, with a stable id to deduplicate on.
order.paidOne order can hold several payments, and the balance tracks them.
settlement_amountsReviewed 2026-07-24 against the published API.
Conventions that hold everywhere
- Money
- { "amount": 16632, "currency": "USD" }
- Pagination
- page_size, page_token, next_page_token
- Retries
- Idempotency-Key on any write, 24h replay
- Tracing
- request_id on every response and every error
- Environments
- one host, one /v1, the key picks the mode
06Specifics
The four that catch teams out.
In roughly the order they surface, once the happy path is working.
Wallets follow the domain, not the store
Apple Pay and Google Pay require the domain that renders the payment sheet to be registered with the processor, and a secure context to render it at all. When the storefront moves, that domain is your frontend rather than the WordPress site, and wallet buttons that used to appear stop appearing until it is registered. On Flint, wallets are part of the same payment leg as cards, so the same call that gives you Elements gives you the wallet.
- card
- apple_pay
- google_pay
- affirm
- ach_debit
That is the whole set, not a sample. No Klarna, Afterpay, SEPA, Link or Cash App today.
Tax follows the address
Tax recalculates on the order from a postal code, a billing address or a tax address, so the number the buyer sees and the number that settles are the same number. It is a call on the order rather than a plugin configured in an admin your storefront cannot reach.
Shipping rules stay under your control
Assign delivery methods to the checkout, then Flint quotes the buyer and applies the selected option as a taxable order charge. A method can use fixed pricing or call your backend for a live rate. If your WooCommerce shipping zones are doing real work today, move that logic behind the rate callback.
{
"order_id": "ord_1kmn0aExample",
"delivery_method_ids": [
"dmet_standardShipping",
"dmet_expressShipping",
"dmet_storePickup"
]
}Subscriptions are objects, not scheduled jobs
Renewals are orders, billed on a schedule Flint runs, with plans, trials, pause, resume and dunning as API surface. There is no site-traffic-driven scheduler in the path, and a headless customer account can manage a subscription over the same API it uses for everything else.
07Limits
What this does not do.
There is no WordPress plugin and no importer. Nothing here installs into WooCommerce. Route A means writing the integration and the write-back; Route B means writing a migration script. The SKU lookup and caller-owned metadata are what make that script tractable, not automatic.
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 belongs at the top of your evaluation, not the bottom.
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.
There is no storefront, theme, or CMS. Flint has no opinion about how your site is rendered, and no page builder. That is the point of Route B leaving WordPress in place.
No card-present, and disputes are read-only over the API. Terminals and chip readers are not part of the API today, and dispute evidence submission happens outside these endpoints.
Sometimes the right answer is to hand checkout back to WordPress. If your traffic is fine, your theme is fine, and the plugins attached to your checkout are doing real work, decoupling that checkout is a large bill for a small gain.
Pricing is published in full on the pricing page, including every conditional fee.
08Start
Ten minutes, no card required.
Node
CLI
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. Pay with 4242 4242 4242 4242, any future expiry, any CVC.
FAQ
Questions worth asking first.
Can WooCommerce do headless checkout?
The cart half, yes. Adding items, applying coupons, selecting shipping rates and reading tax-inclusive totals all work well over the Store API and have been stable since 2022. The payment half is the problem: the checkout endpoint takes a bag of key and value pairs that WooCommerce assigns to the $_POST superglobal before calling the gateway plugin's process_payment method, so the interface you are integrating against is that plugin's private form handling rather than a versioned API. It is buildable. It is not stable.
Do I have to leave WooCommerce to put checkout on my own frontend?
No. The smaller change keeps WooCommerce as the catalog and admin and puts a payments API underneath checkout: read the cart from the Store API, create a Flint order from those line items, collect the card with Stripe Elements on your own domain, and write the paid order back through the WooCommerce REST API when order.paid arrives. The larger change moves catalog, promotions, inventory and subscriptions to Flint and leaves WordPress as the CMS.
Is there a Flint plugin for WooCommerce?
No, and there is no importer either. Moving checkout onto Flint means writing the integration, and moving the catalog means writing a migration script. What makes that script tractable is that Flint variants carry the SKU WooCommerce already had, so GET /v1/catalog/by-sku/{sku} resolves a variant without a mapping table, and caller-owned metadata carries the WordPress post ids across so content that resolves by id keeps resolving.
Is there a PHP SDK?
No. There is a Node SDK and a CLI, and everything else talks to plain REST, which is documented and stable but is REST. For a team whose commerce stack is PHP that is a real cost and it is the first thing to weigh, not the last.
What happens to my WooCommerce plugins?
Anything that renders on or hooks the checkout page stops applying, and that is true of going headless in general rather than of Flint specifically. The Checkout block runs on JavaScript, so classic PHP customization through woocommerce_checkout_fields and template overrides does not apply to it, and WooCommerce's own guidance is that a plugin without blocks support will usually not be visible and may break the experience. If the reason you chose WooCommerce is a checkout plugin, decoupling that checkout is an expensive way to lose it.
Does Flint calculate shipping rates?
Flint evaluates the delivery methods assigned to the checkout. A method can use fixed pricing or call your backend for a live rate. Flint presents the available options, records the buyer's selection, applies the selected charge to the order, and taxes it.
Can I keep my existing Stripe account?
No. Flint provisions and operates its own Stripe-based processing accounts, and there is no way to attach a Stripe account you already have. Flint also inherits Stripe underwriting, so if Stripe declined your business, Flint cannot approve it. Card data still goes directly to Stripe with the same PCI scope as a direct integration.
When should I not do this?
When the storefront is not the reason you are here. If your traffic is fine, your theme is fine, and the plugin ecosystem attached to your checkout is doing real work, leaving checkout with WordPress is the correct answer. If you have already decided the frontend is yours, the question is what checkout costs to bring with it.