Payment gateways
The Gateway API connects donation forms to payment processors. It has a PHP half
(src/Framework/PaymentGateways/) and a JavaScript half (a registered gateway object on the v3
form), and the boundary between them is a security boundary. Most of this document is about that
boundary.
The rule
Funds are never collected on the client. The capture happens server-side, or it doesn't happen.
This is the anchor. The client is allowed to set up a payment — build an order, tokenize a card,
collect an authorization from the donor. What it must never do is complete the transaction. The
step that actually moves money runs on the server, inside the gateway's createPayment(), after
the form has been validated and the payment object has been checked against what the server
expects.
Two supporting rules follow from it:
- Nothing reaches the processor until the server has validated the complete form. Client-side validation is UX. A browser can skip it, and several field types have rules that exist only server-side.
- Data arriving from the processor is a claim, not a fact. Whether it's an order the client handed you or a webhook the processor sent you, verify it against server-held state before acting.
All three have been the subject of fixes. None is theoretical.
The lifecycle
A v3 form donation runs through these steps:
beforeCreatePayment(values)— JS, optional. Prepares client-side state and returns extra data to merge into the request. This is not a validation gate.- The form POSTs to the donate route (
Routes/DonateRoute.php, signed — seeDonateRouteSignature), which runs full server-side validation and then calls the gateway's PHPcreatePayment()/createSubscription(). afterCreatePayment(response)— JS, optional. Completes whatever the server started, using values the server returned.
Step 2 is where money moves. Whatever the client did in step 1, the transaction is completed by the PHP gateway during the server-side donate request.
Stripe: beforeCreatePayment calls elements.submit() to collect card data and returns settings.
The server validates the form and creates the PaymentIntent, returning a clientSecret.
afterCreatePayment calls stripe.confirmPayment() with that secret — confirming an intent the
server created and owns.
PayPal Commerce: the order is created and approved through PayPal's client checkout flow, and the
donor authorizes it in PayPal's own UI. None of that captures funds. The capture happens in
PayPalCommerce::createPayment() on the server — see below.
When the SDK owns the button
PayPal Commerce can't use the flow above, because PayPal's own React buttons intercept the click and open PayPal's popup directly. Left alone, the donor would be inside PayPal's checkout before GiveWP's form validation ever ran — and an order would exist on PayPal's side for a donation the server would have rejected.
So the gateway rebuilds the gate manually, in the buttons' onClick, before allowing PayPal to
proceed (src/PaymentGateways/Gateways/PayPalCommerce/payPalCommerceGateway.tsx):
- Check gateway/subscription compatibility →
actions.reject() - Client-side validation via react-hook-form
trigger()→actions.reject() - Server-side validation —
handleValidationRequest()POSTs the whole form toDonationForms/Routes/ValidationRoute.php→actions.reject() - Only then
actions.resolve()
The in-code comment explains why step 3 is not redundant with step 2, and it's worth repeating because it's the part that looks removable:
Ideally, the client-side validations should be enough. However, in some cases, these validations are reached later when the donation is already created on the PayPal side… #1 - Billing Address Block: depending on the selected country, the city, state, and zip fields can be required or not and there are custom validation rules on the server side. #2 - Gift Aid Block: when users opt-in… required fields… the validation rules for it live only on the server.
Only then does PayPal's SDK invoke its createOrder callback. That callback doesn't call PayPal
directly either — createOrder.tsx POSTs the complete form values plus the gateway's own keys to
the give_paypal_commerce_create_order AJAX action, and AjaxRequestHandler::createOrder() makes
the API call and returns an order id. The card-fields flow (beforeCreatePayment →
cardFieldsForm.submit()) reaches the same handler without passing through the onClick gate, and
the action is registered on wp_ajax_nopriv behind a page-rendered nonce, so the handler cannot
rely on anything upstream having validated the request. It does not trust it:
validateFrontendRequest()— form id and donation form nonceValidateDonationFormRequest(src/DonationForms/Actions/) — the posted values are held to the form's own rules through the same validator the validate route uses (v3: the fields-API rules on every posted field — amount minimum/maximum when custom amounts are on, required fields, honeypot; v2:give_donation_form_validate_fields()followed bygive_checkout_error_checks, the same sequencegive_process_donation_form()runs, which is where the level check and add-on rules hang). The gateway never learns form configuration; it asks the form layer and acts on the verdict. This is the seam to reuse if another gateway ever has to call its processor before the donate route runs.- for v3 forms, the total sent to PayPal (
give-amount, fee recovery included) must be at least theamountthe form validated;createPayment()reconciles the order to the donation before capture, so that is the only bound the handler needs - for v2 forms, the final amount after the
give_donation_totalfilter is checked again: positive and within the form's configured maximum
The donor then approves the order inside PayPal's UI. At this point no money has moved. An approved PayPal order is an authorization, not a payment.
Where the funds are actually collected
onApprove stores the order id and clicks the form's real submit button, which runs the normal
signed donate route → full server-side validation → PayPalCommerce::createPayment().
That method is where money moves, and it verifies before it does:
- Fetch the order from PayPal by id — the server asks PayPal for the order's state rather than trusting what the client reported.
- Branch on status.
COMPLETEDmeans it was already captured;APPROVED/CREATEDmeans it still needs capturing. Anything else throws. validate3dSecure(), andupdateOrderFromDonation()ifshouldUpdateOrder()finds the order no longer matches the donation.approveOrder($payPalOrderId)— despite the name, this issues PayPal'sOrdersCaptureRequest. This is the capture.validatePayPalOrder()— the capture exists, isn'tDECLINED, and carries no error details.- Return
PaymentComplete::make($transactionId)using the real capture id.
So the client's entire contribution is an order id. Everything that binds that id to money happens server-side, after validation, with the order re-fetched from PayPal rather than taken on the client's word.
The v2/legacy path still exposes capture through the give_paypal_commerce_approve_order AJAX
action (AjaxRequestHandler::approveOrder()) — a client-triggerable capture endpoint, registered on
both wp_ajax and wp_ajax_nopriv, that the v2 scripts (SmartButtons.js,
AdvancedCardFields.js) call right after PayPal's onApprove and before the form is submitted. So
on v2, capture happens before the donation record exists and before give_process_donation has run.
approveOrder() and updateOrderAmount() refuse v3 forms outright, which is what makes the rule
above hold without exception on v3. For v2 they run the legacy validator
(give_donation_form_validate_fields(), through ValidateDonationFormRequest) first, but they still
capture.
[Judgement] createPayment() already handles APPROVED orders and already receives
payPalOrderId from the legacy processing path, so it is the natural home for v2 capture too. Until
that moves, treat the v3 shape as the model and the v2 endpoint as legacy.
Don't generalize the PayPal shape
handleValidationRequest is called from exactly two places: the PayPal Commerce gateway and the
multi-step form's NextButton. It is a workaround for SDKs that seize the submit, not the house
style. A gateway that submits through the normal donate route already gets server-side validation
for free and should not add a second round trip.
Webhooks and inbound data
Anything the processor sends you is attacker-reachable. PayPalStandardWebhook::verifyEventData()
is the reference for what verification means in practice — it checks, before processing:
- merchant identity —
receiver_emailmatches the site's configured account - amount and currency — match the stored donation record
- record linkage — for refunds and reversals, the parent transaction id matches the donation
Verifying the notification's signature is not sufficient. A signature proves the message came from the processor; it does not prove the message is about a donation on this site, for this amount, in this currency. Check the payload against your own records.
Stripe webhook listeners live in
Gateways/Stripe/StripePaymentElementGateway/Webhooks/Listeners/ — one class per event.
Gateway route methods reached by redirect are signed: use generateSecureGatewayRouteUrl() rather
than generateGatewayRouteUrl() for anything that mutates state, and see supportsMethodRoute()
and callRouteMethod() for how they dispatch.
Structure
src/Framework/PaymentGateways/—PaymentGatewayabstract class,PaymentGatewayInterface,PaymentGatewayRegister,SubscriptionModulesrc/PaymentGateways/Gateways/TestGateway/— the minimal implementation, start heresrc/PaymentGateways/Gateways/Stripe/StripePaymentElementGateway/— full-featured referencesrc/PaymentGateways/Gateways/PayPalCommerce/— the SDK-owns-the-button case- Subscription modes:
src/Subscriptions/ValueObjects/SubscriptionMode.php
Capability flags on the abstract class tell the rest of the plugin what a gateway can do —
supportsSubscriptions(), supportsRefund(), supportsFormVersions(), canPauseSubscription(),
canUpdateSubscriptionAmount(), and friends. Declare them honestly; the admin UI and the
subscription code branch on them.
Public docs: https://givewp.com/documentation/developers/how-to-build-a-gateway-add-on-for-givewp/
Checklist for a new or changed gateway
- Can the client complete a transaction on its own? If any code path lets the browser capture, charge, or otherwise finalize payment without the server, that's the bug. Setup and authorization on the client are fine; collection is not.
- Does the server re-fetch the payment object from the processor before capturing, rather than trusting the id and amounts the client reported?
- If the SDK takes over the submit button, does the pre-flight run server-side validation, not just client-side?
- Does the webhook handler verify merchant identity, amount, currency, and record linkage — not just the signature?
- Are state-changing gateway routes generated with
generateSecureGatewayRouteUrl()? - Are the
supports*/can*flags accurate? - Is anything sensitive being logged?
Log::redact()strips keys containingcard,password,secret,token(filterable viagive_log_redaction_list) — but only keys it recognizes.
谢谢你的反馈!