How an EDD cart becomes a Paddle checkout, for one-time purchases and subscriptions, on both platforms.
Turning an EDD cart into Paddle lines
Driver::one_time_lines( $order, $purchase_data, $subscription ):
- Every cart item except the subscription item becomes a one-time line with its EDD line total (after discounts, including EDD tax if enabled). Zero-amount lines are skipped. Each line keeps its
cart_index. - The subscription item contributes what is due now: its
recurring_amount, or 0 during a trial. - Reconciliation:
adjustment = order total − (sum of 1 and 2). Positive: an extra one-time line (named “Sign-up fee” when the subscription has one, “Fees” otherwise, or the product name for license upgrades and renewals). Negative: a non-recurring flat discount (first-payment-only discounts, proration).
This guarantees Paddle charges exactly $order->total, whatever mix of EDD fees, sign-up fees, discounts and Software Licensing proration produced it. The order total (not $purchase_data['price']) is used because EDD Recurring zeroes trial items after the order is inserted.
One-time purchase (inline or overlay)
BROWSER (paddle-checkout.js) SERVER
EDD validates the form
form.onsubmit intercepted ────────▶ admin-ajax: wpinterface_paddle_checkout
· verify edd-process-checkout-nonce
· wp_redirect filter → JSON {redirect}
· edd_process_purchase_form()
→ Gateway::process_purchase()
→ edd_insert_payment (pending)
→ driver->start_checkout()
◀──────────────────────────── JSON { transaction_id | pay_link, payment_key,
email, success_url, checkout_nonce }
Paddle.Checkout.open() inline/overlay
checkout.completed / successCallback
poll wpinterface_paddle_order_status ▶ empty cart (first poll); Billing:
sync_order() via API (max 1 per 4 s)
redirect to success_url Gateway::confirm_payment() shows EDD's
"processing" page while still pending- The AJAX handler reuses EDD’s normal purchase processing. A temporary
wp_redirectfilter turns any redirect EDD would make (validation errors, free orders, gateway errors) into JSON{ redirect }, which the script follows. - Every response includes a fresh
checkout_nonce, because EDD may register and log in the buyer during the request, which invalidates the guest nonce on the page. - The cart is emptied only after Paddle reports success (first status poll), so abandoning the payment loses nothing.
- Without JavaScript the form posts normally and
respond()redirects toredirect_url(the Classic pay link, or the Billing transaction’scheckout.url). On any page with?_ptxn=, Paddle.js v2 is enqueued and opens the checkout automatically.
Subscription purchase
EDD Recurring hooks edd_gateway_paddle at priority 0 (before Gateway::process_purchase at 10) whenever the cart contains a recurring item:
Recurring_Gateway::validate_fields()blocks: more than one subscription per cart; unsupported periods; Classic in a currency other than USD, EUR or GBP; missing credentials.create_payment_profiles()sets a placeholderprofile_id(paddle-{purchase_key}-{n}), forcesstatus = pending(trials included), and appliesedd_recurring_create_subscription_argsso Recurring’s Software Licensing integration can record manual-renewal expirations.- EDD Recurring’s
record_signup()inserts the pending order and pending subscription ($offsite = true). complete_signup()callsalign_with_license()and thenGateway::respond().
License upgrades and renewals: EDD Recurring has already set the pending subscription’s expiration to the license expiration. align_with_license() turns the days until then into a Paddle trial, so the prorated amount is charged now and the recurring price starts at the license expiration. This mirrors EDD Recurring’s own approach for Stripe.
Billing transaction body
Billing_Driver::start_checkout() sends POST /transactions:
POST /transactions
{
"items": [
{ "quantity": 1, "price": {
"name": "Product — Option", "description": "…",
"unit_price": { "amount": "999", "currency_code": "USD" },
"tax_mode": "account_setting",
"quantity": { "minimum": 1, "maximum": 1 },
"product": { "name": "…", "tax_category": "standard", "image_url": "https://…" },
"billing_cycle": { "interval": "month", "frequency": 1 },
"trial_period": { "interval": "day", "frequency": 7 }
} }
],
"currency_code": "USD",
"custom_data": { "edd_order_id": 123, "edd_site": "1a2b3c4d5e" },
"discount": { "type": "flat", "amount": "500", "recur": false, "description": "Discount" }
}- Amounts are strings in the lowest denomination (
Orders::to_minor(); JPY, KRW, VND and CLP have no minor unit). - Period mapping: day → day/1, week → week/1, month → month/1, quarter → month/3, semi-year → month/6, year → year/1.
image_urlis sent only for HTTPS images (Paddle rejects others).- The response’s
items[].price.idvalues are stored in order meta_wpinterface_paddle_line_mapwith each line’scart_index(orfee), so later refunds can target the right lines. - The browser opens the transaction with
Paddle.Checkout.open({ transactionId, customer: { email }, settings: { … showAddDiscounts: false, allowDiscountRemoval: false } }). Paddle discount codes are turned off because EDD already applied its own.
Classic pay link
Classic_Driver::start_checkout() calls product/generate_pay_link with:
| Field | Value |
|---|---|
passthrough | JSON {"edd_order_id":123,"edd_site":"…","ref":"<16 random chars>"} |
prices[] | CUR:<order total> |
title, webhook_url | One-time orders only (custom pay link with its own fulfilment webhook) |
product_id, recurring_prices[], trial_days | Subscriptions: an auto-created plan plus EDD’s recurring price and trial |
customer_email, return_url, image_url | Prefill, success page (payment-confirmation=paddle), image |
discountable=0, quantity_variable=0 | The buyer cannot change price or quantity |
expires | Two days from now |
Plans are created once per environment, download, price option, period and currency with subscription/plans_create, and cached in the option wpinterface_paddle_classic_plans. Pay links override the plan’s prices, so one plan serves every price.
Each pay link gets a random ref. The order stores the current one (_wpinterface_paddle_checkout_ref) and a fingerprint of each recent link’s terms (_wpinterface_paddle_checkout_refs). A payment through an older link is accepted only if its terms are identical to the current link’s (for example the same checkout open in two tabs); otherwise the order gets a note and is not changed. Because Paddle signs the passthrough and the ref is unguessable, a buyer cannot point a hand-made checkout at someone else’s order.