Skip to content

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

flintpay/flint
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; // succeeded

The 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.

shop.example.com/checkout
Your checkout page, on your domain. The layout is your markup, Stripe renders the card fields and the wallet buttons inside it, and every figure came from the order.

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

payment_data[]POST /wp-json/wc/store/v1/checkout
  1. Card form

    The gateway plugin's form fields, sent as payment_data

    assigned to $_POST, then process_payment()

  2. 3D Secure

    A redirect to WordPress and back

    verification_endpoint + a WordPress nonce

  3. Payment result

    payment_status plus payment_details, as key and value strings

    their meaning is set by the gateway plugin

  4. 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

ord_1kmn0aExampleGET /v1/orders/{order_id}
  1. Card form

    payment_collectionGET /v1/orders/{order_id}
  2. 3D Secure

    pending_actions[].client_actionruns on your page
  3. Payment result

    payment_attempt.statuslast_payment_error.code, a closed set
  4. Versioning

    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.

  1. Create the order from the cart

    POST /v1/orders

    Send 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.

    curl
    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 '{
        "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.
  2. 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 page
    const 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.

  3. Pay the order from your backend

    POST /v1/orders/{order_id}/pay

    Send 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.

    curl
    curl -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.

  4. 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 finish
    The whole challenge happens on shop.example.com. The order is paid once, by the attempt that started it.
  5. Write the paid order into WooCommerce

    order.paid

    Fulfill 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 WooCommerce
    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 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

flint listen --forward-to http://localhost:8080/webhooks/flint

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
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 }
    ]
  }'
201 Created
{
  "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
Both parts are written back onto the order, so support and reporting read the same figures.
app.withflintpay.com
The same order in the Flint dashboard, which comes with the keys. All 20 of its sections are there the first time you sign in.

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

Build your own checkout, step by step →

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.

In WooCommerce
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.

On Flint
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.

Find it again by the SKU you already have
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.

574
API operations on one set of keys
92
resource families, orders to payouts
211
webhook event types to subscribe to
20
dashboard sections before you write a line

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

What alpha accounts get →

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

composer require flintpay/flint:0.3.0-beta.1

Node

npm install @flintpay/node

CLI

npm install -g @flintpay/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