How a WooCommerce order becomes a Paddle checkout, on both platforms, and how the payment is confirmed while the buyer waits.
Turning an order into Paddle lines
Driver::order_lines( $order, $subscription ) returns lines (each name, amount, product_id, item_id) and adjustment:
- Every product line except the subscription’s line becomes a line with
get_total() + get_total_tax(), so coupons are already applied. Names come fromitem_name()(“Product — Variation × 2”). Zero lines are skipped. - Each positive fee becomes a line with its total and tax.
- Shipping becomes one line named “Shipping: {first method}”.
- The subscription contributes its
due_now: the recurring total, or 0 during a free trial. - Reconciliation:
adjustment = order total − sum of the above, ignored below 0.01. Positive: an extra one-time line named “Sign-up fee” when the subscription has one, “Fees” otherwise. Negative (negative fees, rounding): a non-recurring flat discount.
This makes Paddle charge exactly $order->get_total(), whatever mix of coupons, fees, sign-up fees and shipping produced it. Classic doesn’t use the lines: it sends the order total as one price.
The three entry points
| Where | How the Paddle checkout opens |
|---|---|
| Classic (shortcode) checkout | paddle-checkout.js handles WooCommerce’s checkout_place_order_success event |
| Checkout block | blocks.js handles onCheckoutSuccess |
| “Pay for order” receipt page | Gateway::receipt_page() prints a box; paddle-checkout.js opens it on load and on Pay now |
All three call Gateway::process_payment() (or reuse its result), which calls Gateway::start_checkout():
- Writes
_wpinterface_paddle_apiand_wpinterface_paddle_sandboxon the order. - Calls
driver->start_checkout( $order ). - On failure: logs, adds an order note, and if the error code is a setup error (
transaction_default_checkout_url_not_set,transaction_checkout_url_domain_is_not_approved) storeswpinterface_paddle_woo_setup_problemfor an admin notice. The buyer gets a generic error notice. - On success: deletes
wpinterface_paddle_woo_setup_problem.
process_payment() returns result, redirect and the flat values of Gateway::checkout_details() (wpinterface_paddle_order_id, …_order_key, …_transaction_id, …_pay_link, …_email, …_success_url). The redirect is the order-received URL for Store API requests (the block waits for Paddle first) and the receipt page otherwise.
Classic checkout flow
BROWSER (paddle-checkout.js) SERVER
Place order (WooCommerce AJAX) ────────────▶ ?wc-ajax=checkout
· WooCommerce validates, creates the order
· Gateway::process_payment()
→ start_checkout() → driver->start_checkout()
◀──────────────────────────────────── { result, redirect: receipt page,
wpinterface_paddle_* details }
checkout_place_order_success:
inline: frame replaces #place_order
overlay: popup
checkout.completed / successCallback
poll wpinterface_paddle_woo_order_status ───▶ Checkout::ajax_order_status()
(every 2 s, at most 15 times) · order key check
· Billing: sync_order(), max once per 4 s
redirect to wpinterface_paddle_success_url order-received page: confirmation_notice()- Inline: a box with an Edit details button replaces Place order. Edit details calls
wpinterfacePaddleWoo.cancel(), which is refused while Paddle is taking the payment. Overlay: closing the popup leaves the order unpaid. - Checkout block: inline renders the frame inside the payment option. A paid result returns
responseTypes.SUCCESS; closing or an error returnsresponseTypes.ERRORwith a notice, and the order stays unpaid. - Receipt page: reuses the stored transaction or pay link unless there is none or the account type or sandbox setting changed since; then it starts a fresh checkout.
- If Paddle.js didn’t load,
pay()rejects with the “content blockers” message instead of opening anything.
Subscription purchase
Subscriptions::checkout_subscription( $order ) returns the terms of the subscription the order starts, or null. It looks at the subscriptions of order types parent and resubscribe and throws when the order has more than one subscription, the subscription has more than one line item, or the subscription’s product can’t be found among the order’s lines.
Returned keys: subscription_id, name, item_id, product_id, period, interval, recurring_amount, trial_length, trial_period (only while the trial is in the future), sign_up_fee, due_now. The sign-up fee isn’t sent separately: it is part of the reconciliation line.
Billing transaction body
Billing_Driver::start_checkout() sends POST /transactions:
POST /transactions
{
"items": [
{ "quantity": 1, "price": {
"name": "Product — Option", "description": "Product — Option",
"unit_price": { "amount": "999", "currency_code": "USD" },
"tax_mode": "account_setting",
"quantity": { "minimum": 1, "maximum": 1 },
"product": { "name": "Product — Option", "tax_category": "standard", "image_url": "https://…" },
"billing_cycle": { "interval": "month", "frequency": 1 },
"trial_period": { "interval": "day", "frequency": 7 }
} }
],
"currency_code": "USD",
"custom_data": { "wc_order_id": 123, "wc_site": "1a2b3c4d5e" },
"discount": { "description": "Discount", "type": "flat", "amount": "500", "recur": false },
"customer_id": "ctm_…"
}- Amounts are strings in the lowest denomination (
Orders::to_minor(); JPY, KRW, VND and CLP have no minor unit). billing_cyclemaps WooCommerce Subscriptions’ period and interval one to one; other periods throw.image_urlis the product’s medium image or theproduct_imagesetting, sent only when it starts withhttps://.customer_idis sent when the buyer’s account has a remembered Paddle customer. If Paddle answers 400 mentioningcustomer_id, the request is retried without it.- The body passes through
wpinterface_paddle_woo_billing_transaction. - The response’s
items[].price.idvalues are stored in_wpinterface_paddle_line_mapwith each line’sitem_id, so later refunds can target the right lines._wpinterface_paddle_transactionsmaps the last ten transaction IDs to an md5 of their terms. - The browser opens it with
Paddle.Checkout.open({ transactionId, customer: { email }, settings }), withshowAddDiscounts: falseandallowDiscountRemoval: false, because WooCommerce coupons are already applied.
Classic pay link
Classic_Driver::start_checkout() calls product/generate_pay_link with:
| Field | Value |
|---|---|
passthrough | JSON {"wc_order_id":123,"wc_site":"…","ref":"<16 random chars>"} |
prices[] | CUR:<order total> |
title, webhook_url | One-time orders only (the pay link’s own fulfilment webhook) |
product_id, recurring_prices[], trial_days | Subscriptions: the auto-created plan, the recurring total, and the trial in days |
customer_email, customer_country, customer_postcode | Prefill from the billing address |
return_url | The order-received URL |
discountable=0, quantity_variable=0, is_popup=true | The buyer can’t change price or quantity |
expires | Two days from now (UTC date) |
The fields pass through wpinterface_paddle_woo_classic_pay_link. Plans are created once per environment, product, period, interval and currency with subscription/plans_create and cached in wpinterface_paddle_woo_classic_plans. Pay links override the plan’s prices, so one plan serves every price. Only USD, EUR and GBP are allowed. Trials convert with day = 1, week = 7, month = 30, year = 365.
Each pay link gets a random ref. A payment through an older link is accepted only if its terms equal the current link’s (the same checkout open in two tabs); otherwise the order gets a stale-link note and isn’t changed. Paddle signs the passthrough and the ref is unguessable, so a hand-made checkout can’t pay someone else’s order.
Confirming the payment while the buyer waits
- Status poll: after Paddle reports success,
paddle-core.jspostswpinterface_paddle_woo_order_statusevery 2 seconds, up to 15 times, then redirects even if the order is still unpaid. The order key is checked withhash_equals(). - Billing sync: for unpaid Billing orders,
Checkout::maybe_sync()callsBilling_Driver::sync_order()at most once every 4 seconds per order. A paid or completed transaction is processed exactly like a webhook. - Order-received page:
Checkout::confirmation_notice()runs the same sync. If the order is still unpaid it prints a notice with a “Pay for this order” link and reloads the page every 5 seconds, at most 12 times. - Admin: Check payment with Paddle calls
sync_order()directly. - Classic has no API read for this: Classic orders are completed only by the fulfilment webhook or alerts.