Paddle notifies the store through a single REST endpoint. Authentication is the signature, never the request’s origin.
Endpoint
POST /wp-json/wpinterface-paddle-woo/v1/webhook (Webhooks::handle, public permission_callback). With the wpinterface_paddle_woo_webhook_base_url filter set, the URL becomes {base}/?rest_route=/wpinterface-paddle-woo/v1/webhook. A request with a Paddle-Signature header is handled as Billing; otherwise it must be a form post with a string p_signature (Classic).
| Response | Meaning |
|---|---|
| 200 OK / Already processed | Handled, or a Billing event already processed |
| 400 | Missing signature, or a Billing payload without event_type |
| 401 / 403 | Signature did not verify (Billing / Classic) |
| 500 | An exception was thrown while handling; Paddle retries |
Signature verification
Billing (verify_billing( $raw_body, $header, $secrets, $now = null )): parses ts=<unix>;h1=<hex>[;h1=…], rejects a timestamp more than 300 seconds from now, computes HMAC-SHA256( "{ts}:{raw body}", secret ) and compares with hash_equals() against every h1 (secret rotation). The live secret is tried, then the sandbox secret; the one that verifies decides the environment.
Classic (verify_classic( $fields, $public_key )): base64-decodes p_signature, removes it, casts scalar values to strings, ksorts, PHP-serializes, and runs openssl_verify( …, OPENSSL_ALGO_SHA1 ). Keys are accepted with or without BEGIN/END lines. The sandbox key is tried, then the live key.
Environment must match
The environment that verified the signature must match the order’s (or subscription’s) environment, otherwise the event is ignored.
Billing events
Billing_Driver::handle_event deduplicates events by event_id (transient wpinterface_paddle_woo_evt_{md5}, 3 days, set only after successful processing). An add-on can take over an event with wpinterface_paddle_woo_handle_billing_event; afterwards wpinterface_paddle_woo_billing_webhook fires.
| Event | Handling |
|---|---|
transaction.paid, transaction.completed | Initial payment: custom_data must carry this site’s tag, the order must be a Paddle order in the same environment, and the transaction must be the order’s current one or an earlier one with identical terms. Then refuse (with a one-time note) if the currency differs or the total is below the order total; store totals, tax, earnings, line items and the Paddle customer; link the subscription; complete the order. Renewal (origin subscription_recurring): Subscriptions::record_renewal(). Origins subscription_charge / subscription_update only get a note on the subscription. |
transaction.payment_failed, transaction.past_due | Only origin subscription_recurring, not paid, not already recorded: Subscriptions::mark_failing() |
subscription.created, .activated, .trialing, .updated, .past_due, .paused, .resumed, .canceled | Links an unknown subscription when custom_data points at this site’s order; stores the update-payment-method URL; maps the status. Pending, cancelled, expired and pending-cancel subscriptions ignore everything except cancellation. |
adjustment.created, adjustment.updated | Handled under the order lock (see Refund internals) |
Billing_Driver::EVENTS is the list above; Set up webhook subscribes the destination to it (filterable with wpinterface_paddle_woo_billing_webhook_events).
Classic alerts
Classic_Driver::handle_alert handles the alerts below. An add-on can take over an alert with wpinterface_paddle_woo_handle_classic_alert; afterwards wpinterface_paddle_woo_classic_webhook fires.
| Alert | Handling |
|---|---|
Fulfilment webhook (no alert_name), payment_succeeded | Order from the passthrough (site tag, environment, current ref). Check currency and amount, flag a second payment on an already-paid order, store total, tax, earnings, receipt URL and Paddle user ID, then complete the order. |
subscription_created, subscription_updated | Links an unknown subscription from the passthrough, but only if the plan ID matches the order’s plan. Stores update and cancel URLs. Maps deleted, past_due, paused, active / trialing; the next payment follows next_bill_date only when it changed. |
subscription_payment_succeeded | Stores _wpinterface_paddle_last_paid. Initial payment: currency and amount checks, complete the parent. Otherwise a renewal via record_renewal(). |
subscription_payment_failed | Failing, unless the event is not later than the last successful payment |
subscription_cancelled | cancel_from_paddle() with cancellation_effective_date |
payment_refunded, subscription_payment_refunded | Under the order lock (see Refund internals) |
payment_dispute_created | Deduplicated; order → on-hold |
Classic bill dates are UTC days; Classic_Driver::bill_time() converts them to the start of that UTC day.
Several stores on one Paddle account
Each order is tagged with Plugin::site_tag(): the first ten hex characters of md5( 'woo|' . home_url() ) (without scheme and trailing slash). Billing carries it in custom_data.wc_site, Classic in the passthrough’s wc_site. Driver::is_own_reference() ignores anything else. Refunds, disputes and subscription events are matched by Paddle IDs, so they never match another store’s records.
Changing the site URL
Changing the site URL changes the tag: first payments for orders created before the change are then ignored by the webhook (Billing orders can still be confirmed with Check payment with Paddle). Renewals and refunds are unaffected.