WooCommerce Stripe: add saved payment method
Use this when an account page, theme override, Elementor template, or custom plugin touches WooCommerce My Account payment methods. This flow is easy to break because Woo core, Woo frontend JS, Stripe JS, and Stripe setup-intent handlers all depend on exact form IDs, input names, classes, and billing data.
Misconception this skill corrects
"The payment-methods page is just a list and a card form. I can rebuild the markup with my own class names."
Do not do that. The Add payment method endpoint is not a generic form. WooCommerce core processes a specific POST shape, Woo's wc-add-payment-method script opens/closes gateway boxes from specific selectors, and Stripe mounts Elements/Payment Element into specific containers before adding hidden fields that Woo's form handler and Stripe gateway expect.
First decision
- If the task is only to restyle My Account, keep Woo templates and override CSS/classes around them.
- If replacing templates, preserve the canonical structure below exactly and wrap it with custom markup instead of renaming IDs/classes.
- If adding custom billing fields, use the canonical Woo billing field IDs/names so Stripe can build
billing_details. - If debugging "can't add card", inspect the browser console, the submitted POST body, and Woo/Stripe logs before changing PHP.
Account endpoints
Woo maps account endpoints through WC_Query and template hooks:
payment-methods: rendersmyaccount/payment-methods.phpviawoocommerce_account_payment_methods().add-payment-method: rendersmyaccount/form-add-payment-method.phpviawoocommerce_account_add_payment_method().- Core form handling is
WC_Form_Handler::add_payment_method_action().
The Payment Methods menu item stays active on add-payment-method; do not invent a separate account section unless explicitly required.
Payment methods list template
When overriding myaccount/payment-methods.php, preserve these behaviors:
- Load methods with
wc_get_customer_saved_methods_list( get_current_user_id() ). - Keep hooks
woocommerce_before_account_payment_methodsandwoocommerce_after_account_payment_methods. - Keep the table class set:
woocommerce-MyAccount-paymentMethods shop_table shop_table_responsive account-payment-methods-table
- Keep column cells shaped as:
woocommerce-PaymentMethod woocommerce-PaymentMethod--{column_id} payment-method-{column_id}
- Keep row classes
payment-methodand conditionaldefault-payment-method. - Keep action links as
button {action_key}and preserve action URLs from$method['actions']. - The Add payment method link must use
wc_get_endpoint_url( 'add-payment-method' ).
Do not hardcode Stripe token rows. Woo builds the list through saved payment token filters; Stripe can add card, SEPA, Link, Cash App, Klarna, Amazon Pay, and other reusable token types.
Add payment method template contract
When overriding myaccount/form-add-payment-method.php, this structure is the contract:
<form id="add_payment_method" method="post">
<div id="payment" class="woocommerce-Payment">
<ul class="woocommerce-PaymentMethods payment_methods methods">
<li class="woocommerce-PaymentMethod woocommerce-PaymentMethod--<?php echo esc_attr( $gateway->id ); ?> payment_method_<?php echo esc_attr( $gateway->id ); ?>">
<input
id="payment_method_<?php echo esc_attr( $gateway->id ); ?>"
type="radio"
class="input-radio"
name="payment_method"
value="<?php echo esc_attr( $gateway->id ); ?>"
/>
<label for="payment_method_<?php echo esc_attr( $gateway->id ); ?>">
<?php echo wp_kses_post( $gateway->get_title() ); ?>
<?php echo wp_kses_post( $gateway->get_icon() ); ?>
</label>
<div class="woocommerce-PaymentBox woocommerce-PaymentBox--<?php echo esc_attr( $gateway->id ); ?> payment_box payment_method_<?php echo esc_attr( $gateway->id ); ?>">
<?php $gateway->payment_fields(); ?>
</div>
</li>
</ul>
<?php do_action( 'woocommerce_add_payment_method_form_bottom' ); ?>
<?php wp_nonce_field( 'woocommerce-add-payment-method', 'woocommerce-add-payment-method-nonce' ); ?>
<button type="submit" id="place_order" class="woocommerce-Button woocommerce-Button--alt button alt">...</button>
<input type="hidden" name="woocommerce_add_payment_method" id="woocommerce_add_payment_method" value="1" />
</div>
</form>
Required details:
form#add_payment_methodis used by Woo'swc-add-payment-methodJS and Stripe's classic/UPE scripts.name="payment_method"is required byWC_Form_Handler::add_payment_method_action().id="payment_method_{gateway_id}"and.payment_box.payment_method_{gateway_id}must match; Woo JS usesdiv.payment_box.+ radio ID..payment_methods input.input-radiois the selector Woo uses for gateway selection.- The nonce field name must be
woocommerce-add-payment-method-nonce. - The hidden field
woocommerce_add_payment_method=1must be submitted. - Call
$gateway->payment_fields(); do not manually recreate the Stripe fields unless you also implement every Stripe JS contract.
Woo form handling
Core only processes the form when both are present:
$_POST['woocommerce_add_payment_method']$_POST['payment_method']
Then it verifies woocommerce-add-payment-method-nonce, applies woocommerce_add_payment_method_form_is_valid, rate-limits per user, checks gateway support for add_payment_method or tokenization, calls $gateway->validate_fields(), then calls $gateway->add_payment_method().
If a custom template posts by AJAX or to a custom endpoint, it must either reproduce this flow safely or submit the same form to Woo. The safer path is to keep Woo's normal form post.
Custom account endpoints
WooCommerce does not provide a complete customer-facing REST API for saved payment methods. The built-in account flow is form/query based:
- Add card:
form#add_payment_method->WC_Form_Handler::add_payment_method_action()->$gateway->add_payment_method(). - Delete token:
delete-payment-method/{token_id}query var -> owner/nonce check ->WC_Payment_Tokens::delete(). - Set default:
set-default-payment-method/{token_id}query var -> owner/nonce check ->WC_Payment_Tokens::set_users_default().
If building a custom customer account endpoint, do not expose Woo consumer keys or Stripe secret keys to the client. Use normal WP REST authentication, map the request to get_current_user_id(), and enforce token ownership on every operation.
Recommended saved-card endpoint shape:
POST /.../payment-methods/setup-intent: create/updateWC_Stripe_Customerfor the current user, create a Stripe SetupIntent for allowed reusable payment method types, return onlyidandclient_secret.- The client confirms the SetupIntent with Stripe.js/Stripe SDK. Raw card data must never pass through WordPress.
POST /.../payment-methods/confirm: accept the SetupIntent ID, retrieve it server-side, verifystatus=succeeded, verify the SetupIntent customer matches the current user's Stripe customer, fetch the PaymentMethod, then create/update the Woo token with Stripe gateway token helpers.- Clear Stripe/Woo token caches and fire
woocommerce_stripe_add_payment_methodafter a successful save.
Use idempotency keys and rate limiting for setup/confirm endpoints. Core already rate-limits the form flow under add_payment_method_{user_id}; custom endpoints need an equivalent guard.
Do not treat "edit card" as changing PAN/CVC/expiry. Stripe PaymentMethods generally cannot be mutated that way; replace the card with a new SetupIntent/PaymentMethod. Billing details can be updated with WC_Stripe_API::update_payment_method().
Stripe UPE / Payment Element flow
Stripe Gateway 10.6.1 uses WC_Stripe_UPE_Payment_Gateway as the main gateway class. The frontend handle is wc-stripe-upe-classic, localized as wc_stripe_upe_params.
On add-payment-method pages, params include:
isAddPaymentMethod = truecartTotal = 0customerData.billing_countrycustomerBillingDatafromWC()->customercreateSetupIntentNoncepaymentMethodsConfigaddPaymentReturnURL = wc_get_account_endpoint_url( 'payment-methods' )
Main Stripe gateway payment fields render:
<fieldset id="wc-stripe-upe-form" class="wc-upe-form wc-payment-form">
<div class="wc-stripe-upe-element" data-payment-method-type="card"></div>
<div id="wc-stripe-upe-errors" role="alert"></div>
<input id="wc-stripe-payment-method-upe" type="hidden" name="wc-stripe-payment-method-upe" />
<input id="wc_stripe_selected_upe_payment_type" type="hidden" name="wc_stripe_selected_upe_payment_type" />
<input type="text" id="wc-stripe-hidden-style-input" class="input-text" ... />
</fieldset>
Individual reusable UPE method gateways render:
<fieldset id="wc-{gateway_id}-upe-form" class="wc-upe-form wc-payment-form">
<div class="wc-stripe-upe-element" data-payment-method-type="{stripe_payment_method_type}"></div>
<div id="wc-{gateway_id}-upe-errors" role="alert"></div>
</fieldset>
The UPE frontend:
- Mounts only into
.wc-stripe-upe-element[data-payment-method-type="..."]. - Watches
form#add_payment_methodsubmit. - Creates a Stripe PaymentMethod and appends hidden
wc-stripe-payment-method. - Creates or confirms a SetupIntent.
- Appends hidden
wc-stripe-setup-intentbefore allowing the Woo form submit.
The Stripe gateway add_payment_method() then requires $_POST['wc-stripe-setup-intent'], fetches the SetupIntent, fetches the Stripe PaymentMethod, creates or updates a Woo payment token, fires woocommerce_stripe_add_payment_method, and redirects to payment-methods.
Stripe classic Elements flow
The legacy/classic script in assets/js/stripe.js is still a useful compatibility reference. It depends on:
form#add_payment_methodorform#order_review- selected radios such as
#payment_method_stripe,#payment_method_stripe_sepa .payment_methods input[name="payment_method"]:checked#stripe-card-element, and when not inline,#stripe-exp-element,#stripe-cvc-element#stripe-iban-elementfor SEPA.stripe-source-errorsfor error placement
Classic add-card flow:
- On submit, if Stripe is selected and no saved token/source exists, prevent the normal submit.
- Build billing owner details.
- Call
stripe.createPaymentMethod({ type: 'card', card, billing_details }). - Append hidden
<input class="stripe-source" name="stripe_source" value="pm_...">. - POST to
wc_ajax_wc_stripe_create_setup_intentwithstripe_source_idandwc_stripe_params.add_card_nonce. - If the SetupIntent requires action, call
stripe.confirmCardSetup(client_secret, { payment_method }). - Submit the Woo form after success.
If stripe_source is missing on a classic flow, or wc-stripe-setup-intent is missing on a UPE flow, the backend cannot save the card.
Billing details are not optional
For card setup, Stripe uses billing details for fraud checks, SCA, address verification, receipts, and payment-method metadata. Custom account pages commonly break this.
If you render billing fields on the page, use these exact IDs/names:
billing_first_namebilling_last_namebilling_emailbilling_phonebilling_address_1billing_address_2billing_citybilling_statebilling_postcodebilling_country
Classic JS reads those selectors directly and sends:
stripe.createPaymentMethod({
type: 'card',
card: stripe_card,
billing_details: {
name,
email,
phone,
address: { line1, line2, city, state, postal_code, country }
}
});
UPE add-payment-method preloads customerBillingData from WC()->customer; if the UI lets users edit billing details, save them to the Woo customer before or during the flow, or submit canonical Woo billing fields and verify the Stripe JS consumes them. Do not use custom-only field names such as firstName, zip, or countryCode without mapping them back to Woo billing names.
Minimum country requirement: UPE uses customerData.billing_country to determine country-restricted payment methods. If it is empty, some methods may not mount or may fail validation.
Saved token names
Do not rename saved-token fields:
- Core saved token radio name pattern:
wc-{gateway_id}-payment-token. - New token checkbox pattern:
wc-{gateway_id}-new-payment-method. - Stripe card save checkbox:
wc-stripe-new-payment-method. - Stripe reusable APM checkbox pattern:
wc-stripe_{payment_method_type}-new-payment-method.
On add-payment-method pages Stripe forces save mode and may hide the checkbox. Hidden does not mean unnecessary.
Token deletion and defaults
Woo tokens live in woocommerce_payment_tokens plus woocommerce_payment_tokenmeta and are accessed through WC_Payment_Tokens. Never delete or default a token by raw SQL/meta.
For delete/default endpoints or custom account actions:
- Load the token with
WC_Payment_Tokens::get( $token_id ). - Require
$token->get_user_id() === get_current_user_id(). - For Stripe tokens, detach the remote PaymentMethod from the current user's Stripe customer via
WC_Stripe_Customer::detach_payment_method()or the gateway's existing cleanup path, then delete the Woo token. - Clear Stripe customer payment method caches after detach/default changes.
- Before deleting, check whether active subscriptions use that token/source. If yes, require a replacement payment method first or intentionally migrate those subscriptions.
- For default, call
WC_Payment_Tokens::set_users_default( $user_id, $token_id ); for Stripe also considerWC_Stripe_Customer::set_default_payment_method( $payment_method_id ).
Subscriptions compatibility
Saved Stripe tokens are used by WooCommerce Subscriptions for renewals and change-payment-method flows. The Stripe gateway hooks woocommerce_stripe_add_payment_method so Subscriptions compatibility code can react after a method is added.
For a subscription payment-method change, do not only update _stripe_source_id or token meta. Use WCS' change-payment path:
- Verify the current user owns the subscription, unless this is trusted admin/server code.
- Verify the token belongs to the same user and Stripe customer.
- Create/confirm the SetupIntent if the method is new.
- Use
WC_Subscriptions_Change_Payment_Gateway::update_payment_method( $subscription, $gateway_id )or the installed Stripe gateway'sconfirm_change_payment_from_setup_intentflow as the model. - Set the Stripe customer/payment method on the subscription through gateway helpers such as
set_customer_id_for_subscription()andset_payment_method_id_for_subscription(). - Preserve hooks:
woocommerce_subscriptions_pre_update_payment_method,woocommerce_subscription_payment_method_updated, and gateway-specific..._to_{gateway_id}/..._from_{gateway_id}.
Changing a subscription payment method can affect remote gateway profiles and manual-renewal behavior. Avoid manual meta writes unless doing a migration with full source review.
When editing account payment templates, verify:
- Adding a card from
/my-account/add-payment-method/creates a Woo payment token. - The new token appears in
/my-account/payment-methods/. - Existing subscriptions can use the token for "Change payment".
- Do not bypass
woocommerce_stripe_add_payment_methodor direct-create partial tokens.
Safe customization patterns
Prefer:
wc_get_template( 'myaccount/form-add-payment-method.php' );
or copy the Woo template and only add wrappers/classes around the canonical nodes.
Safe changes:
- Add wrapper divs around
#paymentor around the table. - Add CSS classes while keeping Woo/Stripe classes.
- Filter columns via
woocommerce_account_payment_methods_columns. - Render custom column content via
woocommerce_account_payment_methods_column_{column_id}. - Add content via
woocommerce_before_account_payment_methods,woocommerce_after_account_payment_methods,before_woocommerce_add_payment_method,after_woocommerce_add_payment_method, orwoocommerce_add_payment_method_form_bottom.
Risky changes that usually break add-card:
- Renaming
#add_payment_method. - Removing
.payment_methods,.input-radio,.payment_box, orpayment_method_{gateway_id}. - Rendering Stripe fields without
$gateway->payment_fields(). - Moving Stripe mount containers outside the selected gateway payment box.
- Removing nonce or hidden
woocommerce_add_payment_method. - Posting through a custom AJAX endpoint that never reaches
WC_Form_Handler::add_payment_method_action(). - Replacing billing field IDs/names with framework-only names.
Debug checklist
On /my-account/add-payment-method/:
- Page source contains
form#add_payment_method. - The selected gateway radio has
name="payment_method"and valuestripeorstripe_{method}. - The selected gateway box contains either
.wc-stripe-upe-elementor classic Stripe element containers. - Browser console has no Stripe mount errors.
- Network request creates a SetupIntent:
- UPE:
wc_stripe_init_setup_intentor related setup-intent call. - Classic:
wc_stripe_create_setup_intent.
- UPE:
- Final POST contains:
woocommerce_add_payment_method=1woocommerce-add-payment-method-noncepayment_method- UPE:
wc-stripe-setup-intent - Classic:
stripe_source
- Woo notice says "Payment method successfully added."
- Token appears in
wc_get_customer_saved_methods_list( get_current_user_id() ).
Source search commands
rg -n "add_payment_method|form#add_payment_method|wc-stripe-setup-intent|stripe_source|billing_details|wc-stripe-upe-element" \
wp-content/plugins/woocommerce \
wp-content/plugins/woocommerce-gateway-stripe
rg -n "woocommerce_account_payment_methods|woocommerce_account_add_payment_method|woocommerce_add_payment_method_form_bottom|woocommerce_stripe_add_payment_method" \
wp-content/plugins/woocommerce \
wp-content/plugins/woocommerce-gateway-stripe
See also
- Use
wc-payment-gatewayfor general custom gateway implementation and order state handling. - Use
wcs-subscription-hookswhen a saved token must affect WooCommerce Subscriptions renewal or payment-method-change behavior.