Headless WooCommerce · public alpha
Headless WooCommerce checkout on your own domain.
Keep WooCommerce for the catalog, the cart, and the admin. Flint takes the payment step: it computes the tax and the total, tells Stripe Elements what to collect on your page, runs 3D Secure without a trip to WordPress, and sends one webhook when the order is paid. The official PHP and Node SDKs run on the server you already have.
3.79% + 35¢ on cards · no monthly fee · free sandbox · no credit card
use Flint\{Client, ClientOptions, RequestOptions};
$flint = new Client(new ClientOptions(
baseUrl: 'https://api.withflintpay.com',
apiKey: getenv('FLINT_API_KEY'),
));
// 1. The WooCommerce cart becomes an order. No totals are sent.
$order = $flint->orders->create([
'external_reference_id' => $cartKey,
'line_items' => $lineItems,
'delivery_destination' => ['address' => $shippingAddress],
'tax' => [
'enabled' => true,
'calculation' => [
'mode' => 'automatic',
'price_mode' => 'additive',
],
],
], new RequestOptions(idempotencyKey: "order-$cartKey"));
// 2. What Stripe Elements needs to mount on your page. No secrets in it.
$collection = $flint->orders->get($order->order_id)->payment_collection;
// 3. The browser posts back a single-use credential. Charge the order.
$result = $flint->orders->pay([
'order_id' => $order->order_id,
'body' => [
'action' => 'pay',
'payment_source' => ['token' => $paymentMethodId],
'expected_outstanding_money' =>
$order->settlement_amounts->outstanding_money,
'buyer_contact' => ['email' => $email],
],
], new RequestOptions(idempotencyKey: "pay-$cartKey-1"));
echo $result->payment_attempt->status; // succeededThe whole backend half of checkout, on PHP 8.2 or later with no framework dependency. You never send a total: Flint computes it from the line items and charges what the order still owes.
your domain · Stripe Elements · PHP and Node SDKs · the API key picks test or live
01The gap
Where a headless WooCommerce checkout gets stuck.
The Store API's cart half works well: items, coupons, shipping rates, and tax totals over JSON, stable since 2022. The checkout endpoint is different. It hands your request to a gateway plugin's form handler inside WordPress, and these are the four places your storefront feels it.
On the Store API checkout endpoint
Card form
The gateway plugin's form fields, sent as payment_data
assigned to $_POST, then process_payment()
3D Secure
A redirect to WordPress and back
verification_endpoint + a WordPress nonce
Payment result
payment_status plus payment_details, as key and value strings
their meaning is set by the gateway plugin
Versioning
payment_data keys carry no version
the Stripe key set has moved twice
4 workarounds, each tied to one gateway plugin's internals
On Flint, you read the order
Card form
payment_collectionGET /v1/orders/{order_id}3D Secure
pending_actions[].client_actionruns on your pagePayment result
payment_attempt.statuslast_payment_error.code, a closed setVersioning
Flint-Version: 2026-09-07dated, sent per request
1 order · 1 API key · 1 dated API version
None of this touches your products, your cart, or your WooCommerce admin. Flint replaces the payment step and leaves the rest where it is.
reviewed 2026-07-24 against WooCommerce trunk and the Stripe gateway plugin · corrections: Flint Help
02How it works
From the WooCommerce cart to a paid order.
Your storefront keeps reading products and the cart from WooCommerce. At checkout your backend makes three Flint calls, and one webhook puts the paid order into WooCommerce admin.
Create the order from the cart
POST /v1/ordersSend the names, quantities, and prices from the Store API cart, plus the shipping address. Flint computes the subtotal, the tax for that address, and the total, and holds the balance every payment settles against. Your cart key rides along, so you can look the order up by an id WooCommerce already gave you.
curlcurl -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 '{ "external_reference_id": "wc-cart-a1b2c3", "line_items": [ { "name": "Merino crew", "quantity": 1, "unit_price_money": { "amount": 11800, "currency": "USD" }, "tax": { "taxable": true } }, { "name": "Wool socks", "quantity": 2, "unit_price_money": { "amount": 1800, "currency": "USD" }, "tax": { "taxable": true } } ], "delivery_destination": { "address": { "line1": "540 Broad Street", "city": "Augusta", "state": "GA", "postal_code": "30901", "country": "US" } }, "tax": { "enabled": true, "calculation": { "mode": "automatic", "price_mode": "additive" } } }'what Flint computed Merino crew, quantity 1line_items[0] $118.00 Wool socks, quantity 2line_items[1] $36.00 Subtotalpricing_amounts.subtotal_money $154.00 Tax, Augusta GApricing_amounts.tax_money $12.32 Totalpricing_amounts.total_money $166.32 Still owedsettlement_amounts.outstanding_money $166.32 Change a line item or the address and every figure is recomputed. Your storefront displays these amounts as returned. Mount Stripe Elements on your page
GET /v1/orders/{order_id}Read the order and pass its payment collection block to the browser as returned. It names the Stripe account, the publishable key, the amount, and the wallets to offer, and it holds no secrets. Apple Pay and Google Pay appear in the same element once your hostname is registered.
200 OK{ "data": { "order_id": "ord_1kmn0aExample", "payment_collection": { "stripe": { "account_id": "acct_1kmn0aExample", "publishable_key": "pk_test_1kmn0aExample", "elements": { "next_step": "collect_payment_source", "submit_to": "pay_order", "mode": "payment", "amount_money": { "amount": 16632, "currency": "USD" }, "payment_method_types": ["card"], "digital_wallets": ["apple_pay", "google_pay"], "payment_method_creation": "manual" } } } } }your checkout pageconst stripe = Stripe(collection.stripe.publishable_key, { stripeAccount: collection.stripe.account_id, }); const guidance = collection.stripe.elements; const elements = stripe.elements({ mode: guidance.mode, amount: guidance.amount_money.amount, currency: guidance.amount_money.currency.toLowerCase(), paymentMethodCreation: guidance.payment_method_creation, paymentMethodTypes: guidance.payment_method_types, }); elements.create("payment").mount("#payment-element"); // on submit: a single-use credential, posted to your backend await elements.submit(); const { paymentMethod } = await stripe.createPaymentMethod({ elements }); await fetch("/checkout/pay", { method: "POST", body: JSON.stringify({ token: paymentMethod.id }), });Card details go from the buyer's browser to Stripe. They never reach your server or Flint's.
Pay the order from your backend
POST /v1/orders/{order_id}/paySend the credential the browser created and the balance the buyer approved. Flint confirms the payment and settles the order in the same request. If the order changed after the buyer saw the total, Flint refuses before charging.
curlcurl -X POST https://api.withflintpay.com/v1/orders/ord_1kmn0aExample/pay \ -H "Content-Type: application/json" \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Idempotency-Key: pay-cart-a1b2c3-1" \ -d '{ "action": "pay", "payment_source": { "token": "pm_1kmn0aExample" }, "expected_outstanding_money": { "amount": 16632, "currency": "USD" }, "buyer_contact": { "email": "ada@example.com" } }'200 OK{ "data": { "order": { "order_id": "ord_1kmn0aExample", "status": "closed", "payment_status": "paid", "settlement_amounts": { "paid_money": { "amount": 16632, "currency": "USD" }, "outstanding_money": { "amount": 0, "currency": "USD" } } }, "payment_attempt": { "order_payment_attempt_id": "opat_1kmn0aExample", "status": "succeeded", "is_resumable": false } } }A decline arrives in this same response as one of 18 codes, and the order stays open for another card.
Run 3D Secure on your own page
POST /v1/orders/{order_id}/pay · action "resume"When the issuer asks for authentication, the pay call returns a typed action instead of a URL. Your page hands it to Stripe.js, the buyer authenticates there, and your backend resumes the same attempt with no new credential.
200 OK, authentication required{ "data": { "payment_attempt": { "order_payment_attempt_id": "opat_1kmn0aExample", "status": "requires_action", "is_resumable": true, "pending_actions": [ { "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" } } } } ] } } }browser, then backend// in the browser, on your checkout page const action = attempt.pending_actions[0].client_action.stripe; await stripe.handleNextAction({ clientSecret: action.payment_intent.client_secret, }); // then your backend resumes the same attempt. No new credential. await flint.orders.pay({ order_id: orderId, body: { action: "resume", order_payment_attempt_id: attemptId }, });One attempt, start to finishThe whole challenge happens on shop.example.com. The order is paid once, by the attempt that started it. Write the paid order into WooCommerce
order.paidFulfill from the webhook, never from the browser redirect, which a buyer can skip by closing the tab. Your handler creates the WooCommerce order through its REST API already marked paid, so your team keeps working in the admin they know. Retries reuse the same event id, which makes it your deduplication key.
POST /webhooks/flint{ "webhook_event_id": "whev_1kmn0aExample", "event_type": "order.paid", "payload_version": 1, "mode": "live", "merchant_id": "mer_1kmn0aExample", "created_at": "2026-09-19T14: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" } } }Into WooCommercePOST /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 gets from a WooCommerce order to the payment that settled it in one step.
Events your handler subscribes to
- order.paid
- order.partially_paid
- order.refunded
- payment_intent.requires_action
Each event is a fact about one object, with a snapshot of that object in the body. Both SDKs verify the signature against the raw request body. Webhooks guide →
Receive them on localhost
No tunnel and no public URL while you build. The CLI mints a signing secret for the session, so you test the same signature check you will run in production.
reviewed 2026-09-19 against the published API · corrections: Flint Help
03Refunds
Refund one pair of socks. The tax comes back with it.
Name the line item and the quantity. Flint works out what that line settled for, adds its share of the tax, records the split on the order, and rejects any refund larger than what was paid. There is no refund arithmetic in your storefront.
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",
"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 socks plus $1.44 of tax. You sent a line item id and a quantity, and no amounts.
| one pair of socks, refunded | ||
|---|---|---|
| Wool socks, quantity 1line_item_allocations[].amount_money | $18.00 | |
| Tax on that lineline_item_allocations[].tax_money | $1.44 | |
| Refundedamount_money | $19.44 | |
Refund by amount, by line item, or by charge. The refund event tells your backend, so the WooCommerce order can record it too. Refunds guide →
04Checkout details
Wallets, tax, shipping, and saved cards come with the order.
Each of these is a field on the order or one call beside it, so none of them becomes a project of its own.
Apple Pay and Google Pay
Register your storefront's hostname once. The wallets then render in the same Payment Element as the card fields and settle into the same order.
- POST /v1/payment-method-domains
- elements.digital_wallets
Tax from the shipping address
Set the delivery destination and Flint calculates US sales tax for it, on the items and on the shipping charge. The figure the buyer sees is the figure that settles.
- delivery_destination.address
- tax.calculation.mode
- pricing_amounts.tax_money
Shipping options
Open an embedded checkout session and Flint quotes the buyer's address against your delivery methods, records the choice, and adds the charge to the order. Methods use fixed prices or live rates from your own endpoint.
- POST /v1/delivery-methods
- POST /v1/checkout-sessions/{checkout_session_id}/delivery-quotes
- POST /v1/checkout-sessions/{checkout_session_id}/delivery-selections
Signed-in buyers and saved cards
Put the customer on the order and their saved cards are available at checkout. A returning buyer pays with a saved payment method and never retypes a card number.
- customer_id
- payment_source.payment_method_id
- GET /v1/me/payment-methods
Coupons
Apply a Flint promotion by code, or carry the discount WooCommerce already calculated as a named amount. It lands in the order's totals and the tax is calculated on what is left.
- discounts[].promotion.promotion_code
- discounts[].manual.amount_money
- pricing_amounts.discount_money
One order, findable from both sides
Your WooCommerce cart or order key travels on the Flint order and filters the orders list. The Flint order id sits on the WooCommerce order as its transaction id.
- external_reference_id
- GET /v1/orders?external_reference_id={id}
- metadata
05When you want more
Start with checkout. Move the catalog when you want to.
Checkout is the first step, and it can be the only one. When you want stock, promotions, and subscriptions on the same API as the payment, a variable product and its variations map straight across, and WordPress keeps doing content.
GET /wp-json/wc/v3/products/482
{
"id": 482,
"name": "Merino crew",
"type": "variable",
"attributes": [
{ "name": "Size", "options": ["S", "M", "L"] },
{ "name": "Color", "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 "Flint-Version: 2026-09-07" \
-H "Idempotency-Key: import-wc-482" \
-d '{
"name": "Merino crew",
"product_type": "physical",
"metadata": { "wc_product_id": "482" },
"options": [
{
"client_option_key": "size",
"name": "Size",
"values": [
{ "client_value_key": "s", "value": "S" },
{ "client_value_key": "m", "value": "M" },
{ "client_value_key": "l", "value": "L" }
]
},
{
"client_option_key": "color",
"name": "Color",
"values": [
{ "client_value_key": "oat", "value": "Oat" },
{ "client_value_key": "slate", "value": "Slate" }
]
}
],
"variants": [
{
"sku": "MC-OAT-M",
"unit_price_money": { "amount": 11800, "currency": "USD" },
"line_item_tax_category": "clothing",
"inventory_item": {
"name": "Merino Crew, Oat, M",
"sku": "MC-OAT-M"
},
"selected_option_values": [
{ "client_option_key": "size", "client_value_key": "m" },
{ "client_option_key": "color", "client_value_key": "oat" }
],
"metadata": { "wc_variation_id": "4821" }
}
]
}'Attributes become options and variations become variants. Metadata carries the WordPress ids across, so content that references a product by its WordPress id keeps resolving.
curl 'https://api.withflintpay.com/v1/products?sku=MC-OAT-M' \
-H "Authorization: Bearer YOUR_API_KEY"Variants keep their WooCommerce SKU, so an incremental import finds the product to update without a mapping table.
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/products?sku={sku}
Promotions
Automatic rules and code campaigns, resolved by code at checkout and applied to the order's totals.
- POST /v1/promotions
- GET /v1/promotion-codes?code={code}&expand=promotion
Inventory
Stock per location, with reservations that hold units for an order in place of one global quantity field.
- POST /v1/inventory-items
- GET /v1/inventory-levels
- POST /v1/inventory-reservations
Subscriptions
Plans, trials, pause, resume, and dunning, billed on a schedule Flint runs. Renewals land as orders, so refunds and reporting work the same way.
- POST /v1/subscription-plans
- POST /v1/subscriptions
- POST /v1/subscriptions/{subscription_id}/pause
Fulfillment and returns
Fulfillments, shipments, and packages on the order that was paid, and returns that refund the right lines.
- POST /v1/orders/{order_id}/fulfillments
- POST /v1/shipments
- POST /v1/returns
Customer accounts
Order history, subscriptions, returns, and saved cards for a signed-in buyer, rendered in your own account pages over a customer session.
- POST /v1/customer-sessions
- GET /v1/me/orders
- GET /v1/me/subscriptions
What comes with the keys
Stripe processes every card. You sign up once and hold one set of keys.
Card data goes straight from your checkout page to Stripe, with the same PCI scope, fraud detection, and dispute handling as a direct integration. Flint provisions and runs the processing account, and you pay one processing fee per payment.
API counts come from the published OpenAPI spec · every code sample is checked against it at build time
Public alpha
Alpha accounts get new features first.
Merchants take real payments on Flint today, and new features reach alpha accounts before general rollout. The order you create this week is the record you keep.
shipped 2026-09-29 · Codes for saved details default to the auto channel
Building now?
Create an account from your terminal with flint signup. The sandbox is free, with no credit card.
06Start
From a sandbox key to a paid test order in ten minutes.
PHP
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_, on the same host and the same paths. Pay with 4242 4242 4242 4242, any future expiry, any CVC.
FAQ
Before you sign up.
Can WooCommerce do headless checkout?
The cart half, yes. Adding items, applying coupons, selecting shipping rates, and reading tax-inclusive totals all work over the Store API and have been stable since 2022. The payment half goes through the gateway plugin: the checkout endpoint takes key and value pairs that WooCommerce assigns to the $_POST superglobal before calling the plugin's process_payment method, and a card that needs 3D Secure sends the buyer to a WordPress URL. Flint replaces that step with a versioned API. Your backend creates an order from the cart, Stripe Elements collects the card on your page, and 3D Secure runs there too.
Do I have to leave WooCommerce to put checkout on my own frontend?
No. WooCommerce stays the catalog, the cart, and the admin. Your backend creates a Flint order from the Store API cart, the buyer pays on your domain, and your order.paid webhook handler writes the paid order into WooCommerce through its REST API. You can move the catalog, promotions, inventory, and subscriptions to Flint later on the same keys, and keep WordPress as the CMS.
Is there a PHP SDK?
Yes. flintpay/flint is the official PHP SDK, for PHP 8.2 and later with the cURL and JSON extensions and no framework dependency. It is generated from the same API contract as the Node SDK, @flintpay/node, so every public operation is a method in both, and both verify webhook signatures for you. The API is plain HTTP and JSON, so neither is required.
Does it work with Next.js, Nuxt, Astro, or Faust?
Yes. Flint has no requirements for your frontend. The browser talks to Stripe.js and to your own backend, and your backend talks to Flint with the API key. That backend can be a Next.js route handler, any Node server, or a small PHP endpoint on the WordPress server you already run.
How does 3D Secure work without sending buyers to WordPress?
When the issuer asks for authentication, the pay call returns a typed pending action with a client secret scoped to that one step. Your checkout page passes it to the Stripe.js handleNextAction call, the buyer authenticates, and your backend resumes the same payment attempt. There is no verification URL on another host and no nonce to carry.
Do Apple Pay and Google Pay work on my storefront's domain?
Yes. Register each checkout hostname once with POST /v1/payment-method-domains and serve checkout over HTTPS. The wallets then appear in the same Stripe Payment Element as the card fields and settle into the same order. Embedded payments guide
How do paid orders get into WooCommerce?
Through the WooCommerce REST API, from your webhook handler. When order.paid arrives, create the WooCommerce order with set_paid true and put the Flint order id in transaction_id. Deliveries are retried and every retry reuses the same webhook_event_id, so store it and the write-back runs once. Your team keeps fulfilling from the WooCommerce admin they already use.
How is sales tax calculated?
Flint calculates US sales tax on the order from the delivery destination you send, on the line items and on shipping charges, and returns it in the order's pricing amounts. If you already run a tax engine, send its jurisdiction components in external mode and Flint records them on the order and reverses them on refunds.
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.
Which payment methods can buyers use?
Cards, Apple Pay, and Google Pay on your own form. Flint's hosted checkout page adds ACH debit and Affirm. Every method settles into the same kind of order, so refunds, webhooks, and reporting work the same way however the money arrived.
Who processes the cards?
Stripe processes every card. Card data goes directly from your checkout page to Stripe, with the same PCI scope, fraud detection, and dispute handling as a direct Stripe integration. Flint provisions and operates the processing account, so there is one signup, one set of keys, and one fee per payment.
What does it cost?
Cards cost 3.79% + 35¢, with no monthly fee. The sandbox is free and needs no credit card. One processing fee per payment covers Flint and the card processing underneath, so there is no separate processor bill.
How do I get started?
Request access and we email you a link to create your account, or create one now from your terminal with flint signup. The sandbox is free, with no credit card and no sales call. Flint is in public alpha and onboards US businesses today; buyers can pay from anywhere. Install the PHP or Node SDK, create an order from a test cart, and pay it with the 4242 test card in about ten minutes. Quickstart
Put checkout on your own domain this week.
Free sandbox, no credit card, no sales call. WooCommerce keeps the catalog and the admin, and your first paid test order takes about ten minutes.
Skip the wait: npm install -g @flintpay/cli && flint signup creates your account and a sandbox key from the terminal. CLI guide
POST /v1/orders · create your first order