Paddle notifies the store through a single REST endpoint. Authentication is the signature, never the request’s origin.
Endpoint
POST /wp-json/wpinterface-paddle/v1/webhook (Webhooks::handle, public permission_callback). With the wpinterface_paddle_webhook_base_url filter set, the URL becomes {base}?rest_route=/wpinterface-paddle/v1/webhook.
| Response | Meaning |
|---|---|
| 200 OK / Already processed | Handled or deduplicated |
| 400 | Missing or malformed signature or payload |
| 401 / 403 | Signature did not verify (Billing / Classic) |
| 500 | An exception occurred; Paddle retries |
Signature verification
Billing: the header is Paddle-Signature: ts=<unix>;h1=<hex>[;h1=…]. The plugin computes HMAC-SHA256("{ts}:{raw body}", secret) and compares it with hash_equals against every h1 (to support secret rotation). The tolerance is 300 seconds. The live and sandbox secrets are both tried; the one that verifies decides the environment.
Classic: remove p_signature, cast the values to strings, ksort, PHP-serialize, then openssl_verify(…, OPENSSL_ALGO_SHA1) against the live and sandbox public keys (normalized to PEM). Non-string p_signature values are rejected.
Environment must match
The verifying environment must match the order’s mode (test ⇔ sandbox), otherwise the event is ignored.
Billing events
Billing_Driver::handle_event deduplicates events by event_id (transient wpinterface_paddle_evt_{md5}, 3 days, set after successful processing). After handling, the action wpinterface_paddle_billing_webhook fires.
| Event | Handling |
|---|---|
transaction.paid, transaction.completed | Parent order: custom_data must carry this site’s tag and the transaction must equal the order’s stored _wpinterface_paddle_transaction_id. Then: check the currency, refuse if totals.total is below the EDD total, store totals, the customer and line items, complete the order and activate pending subscriptions. Renewal (origin subscription_recurring): Orders::record_renewal() with Paddle’s total, tax and billing_period.ends_at. Other origins only add a note. A paid transaction for this site’s order that is not its current transaction gets a “superseded” note and a gateway error. |
transaction.payment_failed, transaction.past_due | Only for origin subscription_recurring and still unpaid: subscription failing + edd_recurring_payment_failed. |
subscription.created/activated/trialing/updated/resumed | Link the profile ID if the transaction webhook has not arrived yet (never activates). Recover failing → active. The expiration only moves earlier (paid renewals move it forward). Tracks pause state. |
subscription.past_due | failing |
subscription.paused | Pause note and meta _wpinterface_paddle_paused |
subscription.canceled, or scheduled_change.action = cancel | Cancelled in EDD without calling Paddle back. If the subscription was failing, access ends now. |
adjustment.created/updated | Under the order lock: refund approved → record in EDD (unless the store requested it); pending_approval → note; rejected → note, plus for store refunds a loud note, a gateway error and the refund transaction marked failed. Chargeback approved → edd_record_order_dispute. chargeback_warning / chargeback_reverse → note. |
Classic alerts
Classic_Driver::handle_alert handles the alerts below. After handling, the action wpinterface_paddle_classic_webhook fires.
| Alert | Handling |
|---|---|
Fulfilment webhook (p_order_id, no alert_name), payment_succeeded | Order from the passthrough (JSON with matching site and ref; a bare numeric passthrough from legacy pay links is accepted only on fulfilment webhooks for pending, abandoned or failed orders without the API marker). Check the currency and the amount, flag second payments, store receipt, earnings and totals, complete the order. |
subscription_created, subscription_updated | Link the profile only for placeholder IDs and a matching subscription_plan_id (a mismatch gets a note and a gateway error). Store update_url / cancel_url. deleted → cancel. Never moves the expiration on subscription_created; on subscription_updated only earlier, and only when next_bill_date changed. |
subscription_payment_succeeded | initial_payment=1 (or a pending parent): amount check, complete the parent, activate. Otherwise a renewal. Stores _wpinterface_paddle_last_paid. |
subscription_payment_failed | failing, unless older than the last successful payment |
subscription_cancelled | Cancel without calling Paddle back |
payment_refunded, subscription_payment_refunded | Under a per-order lock: dedupe by alert_id; match store requests (ledger); VAT refunds are noted only; otherwise record the refund in EDD. |
payment_dispute_created | Deduplicated; edd_record_order_dispute |
Classic bill dates are UTC days. Classic_Driver::expiration_from_bill_date() converts them to the end of that UTC day in site time, and rejects malformed dates.