Checkout flow

Updated

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:

  1. Every product line except the subscription’s line becomes a line with get_total() + get_total_tax(), so coupons are already applied. Names come from item_name() (“Product — Variation × 2”). Zero lines are skipped.
  2. Each positive fee becomes a line with its total and tax.
  3. Shipping becomes one line named “Shipping: {first method}”.
  4. The subscription contributes its due_now: the recurring total, or 0 during a free trial.
  5. 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

WhereHow the Paddle checkout opens
Classic (shortcode) checkoutpaddle-checkout.js handles WooCommerce’s checkout_place_order_success event
Checkout blockblocks.js handles onCheckoutSuccess
“Pay for order” receipt pageGateway::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():

  1. Writes _wpinterface_paddle_api and _wpinterface_paddle_sandbox on the order.
  2. Calls driver->start_checkout( $order ).
  3. 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) stores wpinterface_paddle_woo_setup_problem for an admin notice. The buyer gets a generic error notice.
  4. 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 returns responseTypes.ERROR with 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_cycle maps WooCommerce Subscriptions’ period and interval one to one; other periods throw.
  • image_url is the product’s medium image or the product_image setting, sent only when it starts with https://.
  • customer_id is sent when the buyer’s account has a remembered Paddle customer. If Paddle answers 400 mentioning customer_id, the request is retried without it.
  • The body passes through wpinterface_paddle_woo_billing_transaction.
  • The response’s items[].price.id values are stored in _wpinterface_paddle_line_map with each line’s item_id, so later refunds can target the right lines. _wpinterface_paddle_transactions maps the last ten transaction IDs to an md5 of their terms.
  • The browser opens it with Paddle.Checkout.open({ transactionId, customer: { email }, settings }), with showAddDiscounts: false and allowDiscountRemoval: false, because WooCommerce coupons are already applied.

Classic_Driver::start_checkout() calls product/generate_pay_link with:

FieldValue
passthroughJSON {"wc_order_id":123,"wc_site":"…","ref":"<16 random chars>"}
prices[]CUR:<order total>
title, webhook_urlOne-time orders only (the pay link’s own fulfilment webhook)
product_id, recurring_prices[], trial_daysSubscriptions: the auto-created plan, the recurring total, and the trial in days
customer_email, customer_country, customer_postcodePrefill from the billing address
return_urlThe order-received URL
discountable=0, quantity_variable=0, is_popup=trueThe buyer can’t change price or quantity
expiresTwo 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.js posts wpinterface_paddle_woo_order_status every 2 seconds, up to 15 times, then redirects even if the order is still unpaid. The order key is checked with hash_equals().
  • Billing sync: for unpaid Billing orders, Checkout::maybe_sync() calls Billing_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.

Was this page helpful?