WooCommerce
View as MarkdownThe WorthyPayments for WooCommerce plugin (v2) takes card, Apple Pay and Google Pay payments in your WooCommerce store through the Payment Element. The buyer types the card into our iframe, where Evervault encrypts it, and a new card's 3-D Secure runs inside the same frame. No card data reaches WordPress or your server, so your store can stay on PCI SAQ A (see PCI and card data for what that asks of your checkout page's scripts).
It works on the classic checkout, the Checkout block, the pay-for-order page, and for headless clients that check out through the WooCommerce Store API (a mobile app, or a dashboard of your own). Signed-in buyers can save cards, at checkout or from My account → Payment methods, and WooCommerce Subscriptions renewals charge the saved card: a subscription always saves the card it starts with, a $0 free trial included. Optionally: Express checkout, Apple Pay and Google Pay buttons on product, cart and checkout pages with shipping chosen in the wallet sheet, tax from your WorthyPayments tax rates, and a WP-CLI command that moves your NMI plugin's subscriptions to WorthyPayments.
Requires WordPress 6.2+, WooCommerce 8.0+ and PHP 7.4+; tested up to WordPress 7.1 and
WooCommerce 11.2. HPOS (High-Performance Order Storage) is supported. The plugin ships a
translation template, languages/worthypayments.pot (text domain worthypayments).
Install#
- Download the plugin from your dashboard:
/plugins/woocommerce-worthypayments-2.13.0.zip(each version has its own file;/plugins/woocommerce-worthypayments.jsonnames the current one). It is not on the WordPress.org directory; if you cannot find it, contact support. - WordPress admin → Plugins → Add New → Upload Plugin → choose the zip → Install Now → Activate.
- WooCommerce → Settings → Payments → WorthyPayments.
The payment method shows at checkout only when the store's currency (on a pay-for-order page,
the order's) is one WorthyPayments settles in the mode the payment is made in. The plugin reads
the list (settleable_currencies of GET /v1/ping) with each mode's secret
key when you save the settings, keeps it for a week and reads it again in the background; until
it has been read once, nothing is hidden. When the store's currency is not on the active mode's
list, the settings page says so.
Later versions arrive through WordPress's own update screens: see Updates.
The v1 plugin (woocommerce-tailoredpay) is a different plugin folder. Deactivate and delete
it once v2 is configured; the two do not share settings.
Deleting the plugin (Plugins → Delete) removes its settings, its caches, its scheduled jobs and webhook events still waiting, and from your users an unfinished card set-up and their WorthyPayments customer id (found again by e-mail if you reinstall), on every site of a network. Orders and their payment details, subscriptions and the buyers' saved cards stay, so payments remain refundable and disputes answerable if you install it again. The webhook endpoint registered for this site stays at WorthyPayments too: remove it in Dashboard → Developers → Webhooks if the store will not come back.
Deactivating or deleting the plugin while subscriptions renew through it switches them to manual renewal (WooCommerce Subscriptions has no gateway to charge them with): their buyers are e-mailed to pay each renewal instead of being charged. Reactivating the plugin brings automatic renewal back.
Updates#
From version 2.9.0 the plugin updates like a plugin from WordPress.org, from your WorthyPayments dashboard:
- Plugins and Dashboard → Updates show "There is a new version of WorthyPayments for WooCommerce available" when one is published. View version … details shows its changelog; Update now installs it.
- Enable auto-updates on the plugin's row lets WordPress install new versions itself. On WordPress 6.6 and later, an automatic update that breaks the site on load is rolled back to the version before.
- WordPress checks for a new version twice a day; Dashboard → Updates → Check again checks
now. The plugin reads
https://app.paygeyt.com/plugins/woocommerce-worthypayments.json(the plugin'sUpdate URI, so WordPress never confuses it with a WordPress.org plugin of the same name). If that address cannot be reached, no update is offered and nothing else changes; after 72 hours of that, Plugins and Dashboard → Updates say so. - Every release is signed by WorthyPayments. The plugin carries WorthyPayments' public keys and trusts a release only when its version, checksum and download address are signed with one of them (Ed25519). The private keys are kept offline, never on the server that serves the files, so a change to what that server serves cannot reach your store: an unsigned or wrongly signed release is not offered, and a version older than the one you run is never installed.
- The update is installed only if the downloaded zip has the signed SHA-256 checksum, comes
from
https://app.paygeyt.com/plugins/woocommerce-worthypayments-<version>.zip, and holds the signed version. Otherwise the update stops with a message saying which check failed, and the installed version keeps running: try again later. The download stops past 20 MB (a release is far smaller), so a server sending more cannot fill your disk. - The plugin installs its own updates: WordPress is given a placeholder
(
worthypayments-update:<version>) that only the plugin can turn into the file. An update run without the plugin's code (wp plugin update --skip-plugins) fails and changes nothing. On a multisite network, network-activate the plugin: an update started from the network admin needs the plugin's code there.
Updating from 2.8.0 or earlier: those versions cannot see updates. Install the current version once by hand: download its zip (see Install) and use Plugins → Add New → Upload Plugin, which offers Replace current with uploaded. Settings, orders, saved cards and subscriptions are kept.
Renewals during an update. WordPress replaces the plugin's files while the store keeps running, so a renewal charged at that moment can be cut off. The first admin or scheduled request on the new version finishes what was cut off:
- a charge that was sent but whose answer was never recorded is read back with the same idempotency key, so it is recorded once and never sent twice;
- a renewal order that Subscriptions' schedule created while the plugin was being replaced, and that was never charged, is charged then, with a note on the order. A pending renewal order a shop manager made by hand (Create pending renewal order) is never charged this way.
Each step is recorded in Payment activity.
Configure#
The settings come in sections, in the order you fill them in: General, Test keys, Live keys, Webhooks and protection, Checkout, Advanced. Each key section says where its keys come from and which scopes the secret key needs.
| Setting | What to enter |
|---|---|
| Enable | Turn the method on. |
| Title / Description | What the buyer sees in the payment list. Apple Pay and Google Pay appear in the payment form by themselves when your account has them on. |
| Test mode | On: the test keys are used. Off: the live keys. Test payments need your own NMI sandbox key: see Test, then go live. |
| Test / Live publishable key | pk_test_… / pk_live_…. Loaded into the checkout page. |
| Test / Live secret key | sk_test_… / sk_live_…. Stays on your server, and is never shown again once saved: the field stays empty and says a key is saved (its last four characters). Leave it empty to keep the saved key; tick Remove the saved value to clear it. A key that does not start with its field's prefix (a publishable key in a secret field, a live key in a test field) is not saved: the key you had stays, and the field says why. |
| Test / Live webhook signing secret | Filled in for you by Webhook set-up, or from the endpoint you register (see Webhooks). Write-only like the secret key. |
| Webhook set-up | Set up the webhook for me, on by default: saving with a mode's signing secret empty registers this site's endpoint in that mode and saves its secret. See Webhooks. |
| Payment attempts per hour | Card-testing protection, 10 by default, 0 for off. See Card testing. |
| Buyer IP address from | The connection (default), or a header your proxy or CDN sets on every request: CF-Connecting-IP, True-Client-IP, X-Real-IP or X-Forwarded-For. Pick a header only when the site is really behind that proxy: anyone can send these headers to a site that is not. The WORTHYPAYMENTS_TRUSTED_IP_HEADER constant in wp-config.php overrides it. |
| Express checkout | Off by default: Apple Pay and Google Pay buttons on product, cart and checkout pages. See Express checkout. |
| Tax rates | WooCommerce's own tax rates (default) or My WorthyPayments tax rates. See Tax rates from WorthyPayments. |
| Payment form look | Match my theme (default) or WorthyPayments default. See Appearance. |
| Accent colour / Corner radius | Optional. Used instead of what the form takes from your theme. |
| Debug log | Writes API calls to WooCommerce → Status → Logs, source worthypayments. |
| Payment activity kept for | Days a row of the payment activity log is kept: 365 by default, at least 30. |
Both keys of the active mode must be filled in, or the method does not appear at checkout;
while the method is enabled without them, every admin screen says so and names the mode.
The plugin checks the prefixes when you save, then checks every key that is filled in with
GET /v1/ping and says what it found at the top of the screen:
- a key our API refuses (invalid, expired, revoked), or a key of the other mode;
- a secret key that lacks one of the scopes below (each missing scope is named);
- a publishable key whose allowed domains do not list this site's hostname (the plugin pings
it with this site's
Origin), so the card form would not load; - with Webhook set-up on, what it did with the webhook endpoint; with it off, that no
signing secret is saved or no endpoint sends to this site (deliveries would all be refused
or never come); and either way, that the endpoint for this site does not send an event the
plugin uses, is disabled at WorthyPayments, or sends to an address this site no longer
answers on (after a permalink change: a
/wp-json/…endpoint needs pretty permalinks); - while Apple Pay is on for your account, whether this site serves Apple's domain verification file (see Apple Pay on your domain).
What passed (a key works, the webhook is set up) is shown too, even when something else on the screen needs fixing. A key that cannot be checked right now (our API unreachable) is saved anyway, and the screen says so.
While the method is on in test mode, the Dashboard, the Plugins screen and WooCommerce's own screens show a WorthyPayments is in test mode notice to shop managers, with a link to turn it off.
Allowed domains. The publishable key's allowed domains must list every hostname your
checkout runs on, or the payment form stays blank. Each entry is a hostname, localhost, an
IP address, or *. plus a name (*.example.com). A Unicode name is stored as punycode, a
key holds at most 50 entries, and an entry that can never match an origin is refused when
you save. See
Allow your domain.
Hosts. From 2.13.0 the plugin loads the SDK from https://app.worthypayments.com/sdk/v1/
and calls https://api.worthypayments.com/v1; the card frame is
https://element.worthypayments.com. If your site sets a Content-Security-Policy, allow those
three (Content-Security-Policy). To stay on
paygeyt.com, the same API and SDK (Two domains), put both lines in
wp-config.php:
define( 'WORTHYPAYMENTS_SDK_URL', 'https://app.paygeyt.com/sdk/v1/worthypayments.umd.js' );
define( 'WORTHYPAYMENTS_API_BASE', 'https://api.paygeyt.com/v1' );
Updates still come from app.paygeyt.com (see Updates).
Secret key scopes. payment_intents:create, payments:read, payments:charge
(saved-card payments, renewals, capture and void), refunds:create, customers:read,
customers:write (customers, and the setup intents that save a card with no payment),
customer_sessions:create (saved cards), disputes:read (a dispute is read again before its
order changes) and events:read (webhooks that never arrived are recovered), and tax:read
when Tax rates is set to WorthyPayments. For Set up the webhook for me, also
webhooks:read and webhooks:write. See Authentication. A key
without disputes:read leaves dispute deliveries as order notes (the order is not put on hold
on the delivery's word alone), and the admin notices name the missing scope.
An order keeps the mode it was paid in: refunds and captures of a test order always use the test keys, even after you switch the store to live.
Test, then go live#
Test once, end to end:
- Your own NMI sandbox account. Test payments run on it, never on a shared one. Create one
at guide.nmi.com (Create Sandbox Account), copy its API
security key, switch your WorthyPayments dashboard to test mode and paste the key under
Settings → Integrations, on the NMI card. Without it every test payment is refused
sandbox_key_required; the order's note says what to do. See Your own NMI sandbox key. - Test keys. Dashboard in test mode → Developers → API keys: a publishable key whose allowed domains list this site, and a secret key with the scopes above. Paste them, keep Test mode on and Set up the webhook for me on, and save: the screen confirms each key and the webhook.
- A test order. Pay with NMI's test cards, not Stripe's:
4111 1111 1111 1111(Visa), any future expiry, CVV123. The full list, and the 3-D Secure cards, are in Test cards. The order goes to Processing; WooCommerce → WorthyPayments activity lists the payment. - A refund from the order screen, and, with WooCommerce Subscriptions, a sign-up and one
renewal: WooCommerce → Status → Scheduled Actions, find the subscription's
woocommerce_scheduled_subscription_paymentand Run it. The renewal order is charged with the saved card.
Then go live:
- Dashboard in live mode → Developers → API keys: the same two keys, live. Paste them in Live keys and save once with Set up the webhook for me on, so the live webhook is registered.
- Apple Pay on a live domain is registered by WorthyPayments support (see Apple Pay on your domain).
- Turn Test mode off and save. Test orders keep their test keys for refunds. A test order still unpaid (no payment taken or held) is paid live if the buyer pays it now; a live order is never paid in test mode. Subscriptions signed up in test mode are not renewed live: see WooCommerce Subscriptions.
- Place one small real order with your own card, refund it, and check both in Payment activity and in your dashboard. See also Going live.
How a payment works#
- The checkout page shows the Payment Element in the payment list, under the plugin's title:
the card form, and Apple Pay / Google Pay above it when your account has them on (with
Express checkout on, the wallet buttons are at the top of the page
instead). It is mounted before any order exists
(deferred intent,
mode: 'payment') with the cart total, which it keeps current as the cart changes. - The buyer clicks WooCommerce's own Place order (or approves Apple Pay / Google Pay). The
Element checks the card first. Then WooCommerce's checkout runs as usual: it validates the
form and creates the order, and the plugin creates a
payment intent for the order's
total with your secret key (
reference= the order number, andmetadataorder_id/order_number/wc_site, a short hash of your store's address /site_url, plussubscription_idwhen the order starts a subscription, which the charge and its webhooks carry too). The intent also states what the order is: its products asline_items, theshipping_addresswhen it ships, and the buyer's first and last name asbilling_details(see What a payment says about the order). - The Element pays that intent. Declines are shown inside it, and the buyer can try another card: placing the order again reuses the same order and intent. 3-D Secure challenges open inside the Element.
- The plugin re-reads the intent and its charge with your secret key and moves the order. The browser's word is never taken for it.
The Checkout block works the same way, and so does the pay-for-order page (a failed order, a payment link), where the order already exists.
A cart that starts a subscription is paid the same way, with two differences: the Element shows no Save this card box and no Apple Pay / Google Pay buttons, because the card is always saved (a wallet's card cannot be), and the checkout says so under the form. At a $0 total (a free trial) the Element is mounted to save the card with no payment (setup mode): the plugin creates a setup intent instead of a payment intent, and nothing is charged.
While the Element confirms a payment (and runs 3-D Secure), the rest of the checkout form is frozen and a busy line shows above the card form; Place order does nothing until the payment form has loaded, and says so.
| What the API says | Order status |
|---|---|
| Captured | Processing (or Completed for virtual, downloadable items) |
| Authorized only (a manual or delayed Capture on your Payment Workflow route) | On hold, with Capture / Void actions |
processing (outcome not known yet) | On hold, updated by the webhook |
| Declined | Stays Pending payment and payable; a charge.failed webhook marks it Failed (still payable) |
An order whose payment did not go through stays Pending payment. Placing the order again
reuses the same order and the same intent, so nothing is charged twice. A decline's note quotes
the gateway's own words (last_payment_error.message) when our API states them.
After five declined attempts our API closes the intent (canceled, cancellation_reason: failed_attempts): the order is marked Failed (still payable) with the last decline in the
note, the buyer reads that the payment was declined five times and closed, and placing the
order again starts a new intent under a new idempotency key; a closed intent is never reused.
An intent the order no longer uses is closed (POST /v1/payment_intents/{id}/cancel): when
the order is cancelled (abandoned, so a payment page left open cannot pay it), and when the
order is paid another way, with a saved card or a new intent (duplicate). An intent that is
processing or has succeeded cannot be closed: that is a payment going through, so nothing else
is charged and the order follows that payment (a cancelled order it paid goes On hold for
review).
What a payment says about the order#
Every payment the plugin makes, through the Element, with a saved card or as a renewal, tells our API what was bought and where it goes. These never change what is charged: the order's total is the amount, and nothing else is compared with it.
| Field | What it holds |
|---|---|
line_items | The order's products, up to 100: name, amount (the unit price in cents, tax included, after discounts: the line's total divided by its quantity), quantity, and the product's sku and first category when it has them. Shipping and fees are not lines. |
shipping_address | Where the order ships (name, phone, line1, line2, city, state, postal_code, country), when it ships somewhere. |
| Buyer's name | First and last name apart: billing_details on an intent, customer.first_name / last_name on a charge. |
Fraud screening scores the payment on them (Kount judges the real basket and where the goods
go), and they are kept with the payment as evidence for a dispute: the transaction in your
dashboard shows the order (its number links to the order in your store's admin), its products
and the shipping address, and GET /v1/charges/{id} returns them. A product filed only under
WooCommerce's default category (Uncategorized) is sent with no category. Text is cut to our API's limits (a name or address field at 255 characters,
a first or last name at 100, a SKU or category at 100) before it is sent.
When the cart changes before the buyer pays, the plugin updates the open intent: a part the
order no longer has (the shipping address removed, say) is sent as null, which removes it.
An API older than the plugin. If our API answers that it does not know one of these fields
(400 parameter_unknown naming it), the plugin sends the same payment again without them, under
the same idempotency key for a charge (the refusal consumed nothing), and leaves them out of
every payment for the next 12 hours. A value our API refuses for one order only costs that
order's request its details: the next order sends them again.
When the buyer does not come back. A buyer may close the page right after 3-D Secure, or
lose the redirect back to your store. The webhook settles the order then. In case it is
missing or late, the plugin also reads the order's intent itself, with your secret key, 10
minutes after the buyer was given it, then after 30 minutes, 2 hours and 12 hours, and stops
as soon as the order is settled or the intent is paid or expired. A $0 sign-up's setup intent
is read the same way. The looks run on WooCommerce's Action Scheduler (WooCommerce → Status
→ Scheduled Actions, hook worthypayments_reconcile_intent).
Hold stock. When WooCommerce's Hold stock time runs out (WooCommerce → Settings → Products → Inventory), WooCommerce cancels the unpaid orders. The plugin reads each of its own orders' intent first: an order whose payment succeeded or is still settling is moved to the right status instead, and never cancelled. If the intent cannot be read (our API unreachable), the order is kept and asked about again at the next cleanup. An intent the buyer never paid does not keep the order. A payment that still lands on an order already cancelled moves it to On hold with a note asking you to fulfil or refund it.
Appearance#
The card form is the WorthyPayments Payment Element, a frame on our domain, so your theme's
CSS cannot reach into it. With Payment form look set to Match my theme (the default), the
plugin reads what your checkout already shows and passes it to the form as
appearance variables:
| Taken from your checkout | Becomes |
|---|---|
| A billing field's background and text colour | colorBackground, colorText |
| Its label colour | colorTextSecondary |
| Its border colour (when it has a border), corner radius, font and font size | colorBorder, borderRadius, fontFamily, fontSizeBase |
| The Place order button's colours | colorPrimary, colorPrimaryText |
A dark field starts the form from the night theme. Anything that would be hard to read is
left in the default look rather than copied:
- text that does not contrast with its background;
- a button colour too close to the field's;
- a colour outside sRGB (for example
display-p3oroklch()); - a font size outside 12–20 px.
Corners are capped at 24 px.
The form loads only the fonts it serves itself (Inter, Roboto, Open Sans, Lato, Montserrat and Poppins). Any other font falls back down your theme's font stack.
Accent colour and Corner radius win over what is read from the theme.
WorthyPayments default turns the theme matching off and leaves only those two.
Developers can change the result at two points.
- PHP, on the server.
worthypayments_element_appearancereceives the settings as['variables' => […]]and may returntheme,variablesandrules. Its result is laid over what is read from the theme.worthypayments_element_match_theme(a bool) turns the theme reading on or off. - JavaScript, in the browser.
wp.hooksfilterworthypayments.elementAppearancegets the final appearance and has the last word.
add_filter( 'worthypayments_element_appearance', function ( $appearance ) {
$appearance['variables']['colorPrimary'] = '#0f766e';
$appearance['rules']['.Input'] = array( 'boxShadow' => 'none' );
return $appearance;
} );
wp.hooks.addFilter( 'worthypayments.elementAppearance', 'my-theme', function ( appearance ) {
appearance.variables.fontFamily = 'Poppins, sans-serif';
return appearance;
} );
A name or value the form does not accept is dropped (see what appearance does not control). The card is always typed into the WorthyPayments form, so changing its look never changes your PCI scope.
Express checkout#
With Express checkout on, the Apple Pay and Google Pay buttons themselves appear on
product pages, on the cart page above Proceed to checkout, at the top of the classic
checkout page, and in the express area of the Cart and Checkout blocks (worthypayments.js
mountExpressCheckout()). The buyer
pays in the wallet's own sheet, signed in or as a guest:
- The sheet opens with the total WooCommerce computed for the cart (on a product page, for that product alone).
- If the order ships, the buyer picks the shipping address and the shipping method in the sheet. Each choice is priced by WooCommerce, with your shipping zones, rates and taxes, and the sheet shows the new total before the buyer authorizes. An address you do not ship to is refused: Google Pay says so on the address; on Apple Pay the payment is refused when the buyer authorizes, and nothing is placed.
- Only when the buyer authorizes is the order placed, by WooCommerce's own checkout, with the e-mail, name, phone, billing address and shipping address from the sheet (every checkout validation and hook runs; a refusal is shown in the sheet). The plugin creates its intent, and the wallet pays it. The order note says Placed with express checkout (Apple Pay).
- The order is then verified exactly like any other: the plugin re-reads the payment with your secret key. A sheet closed without paying leaves no order behind.
The total the buyer authorized is the total charged: if the order's total differs from the one the sheet showed (a coupon or fee that depends on the billing address, for example), the payment is refused, nothing is charged, and the buttons are drawn again with the new total.
The buttons are offered when:
- Apple Pay or Google Pay is on for your account in this mode (checked with
GET /v1/wallet-config, once an hour) and the buyer's device can pay with it; - the buyer may check out: signed in, or a guest where WooCommerce allows guest checkout, or where an account is created at checkout with a generated password (the sheet cannot ask for one). A subscription always needs an account;
- there is something to pay now (a $0 free trial is not offered).
With the buttons on the page, the checkout's own payment form shows no second set of wallet buttons: card entry stays there.
Subscriptions are offered Google Pay only, which hands back the card itself: it is saved and renewals charge it, like a card typed in. An Apple Pay token cannot be saved yet, so Apple Pay is not offered for a subscription. On a product page, a subscription with a free trial or a sign-up fee sends the buyer to the cart (what is charged today is not its price).
On a product page, the cart becomes that product alone once the buyer opens the sheet, as with Stripe's express checkout. If the store requires accepting its terms, the terms sentence is shown under the buttons, and paying through them accepts it. A cart split into several shipping packages offers the first package's methods in the sheet; the others keep the method already chosen.
Apple Pay on your domain#
Apple shows the Apple Pay button only on a domain registered for your account, and verifies
the domain by fetching https://<your-domain>/.well-known/apple-developer-merchantid-domain-association.
The plugin serves that file (WorthyPayments' copy, refreshed weekly). Then:
- Save the plugin settings while Apple Pay is on: the plugin reads the file back from your site, the way Apple does, and says whether it is served. A server rule, a redirect, or another plugin's file at that address (another payment plugin's Apple Pay set-up, for example) stops it.
- Have the domain registered for your account: in test mode, add it in Dashboard → Settings → Payment methods → Digital Wallets; in live mode, ask WorthyPayments support.
Google Pay needs no domain step. Both wallets need the site served over HTTPS.
Saved cards#
A signed-in buyer sees Save this card for future payments under the card form in the Element. It is never pre-ticked: the buyer's tick is the consent, and the card is saved with that payment. A cart that starts a subscription has no box: the card is always saved (see WooCommerce Subscriptions), and the checkout tells the buyer. State on your site what saved cards are used for (one-click checkout, subscription renewals) and how a buyer removes one.
- Customer. The first time a signed-in buyer pays, the plugin finds or creates their
customer by the account's e-mail (looked up with
POST /v1/customers/search, so the e-mail is never in a URL) and keeps thecus_…id on the WordPress user (one per mode). The intent names that customer, so the card is saved to it. A WordPress account's e-mail is not verified, so a customer another account of the site already holds is not adopted: that buyer pays without the offer to save the card. A customer deleted in your dashboard is forgotten, and its create key is never used again (409 customer_deleted); its e-mail stays reserved to it, so that buyer pays without the offer to save the card. - The card. The plugin reads the save result from the intent with your secret key
(
card_save) and adds the card to the buyer's account as a WooCommerce saved card: brand, last four and expiry, with ourpm_…id as the token. A save that is stillpendingis looked up again three times over the next 40 minutes. It is never guessed: a card the customer already had before this payment is only taken if it is already this account's saved card. - Paying with a saved card. The buyer picks it in the payment list (classic checkout,
the Checkout block, the pay-for-order page). The plugin charges it from your server with
POST /v1/charges,initiated_by: "customer", the way Stripe's own WooCommerce plugin charges a saved card. The buyer is present, so your 3-D Secure rules apply, and so does yourworthypayments_three_ds_requestfilter. When one asks for 3-D Secure, the card is authenticated on the encrypted copy kept when it was saved: the challenge opens on the checkout page, and once the buyer finishes it the plugin sends the same charge again with the authentication, under the same idempotency key (3DS on a saved card). A card with no such copy (saved before 2026-10-18, or imported from your NMI vault) is declinedsoft_decline_3dswith nothing charged: the buyer is asked to choose Use a new payment method and enter the card again, once. The checkout page also sends the buyer's device signals fromworthypayments.js(getFingerprint(),getKountSessionId()) with the choice, and the plugin passes them on as the charge'sdevicefor fraud scoring, as the Element does for a new card. It sends the buyer's device session too (getDeviceSession(), refreshed while the page stays open), as the charge'sdevice_session, so the charge carries the buyer's own IP address and browser rather than your server's (Device data collection). When the buyer places the order, the page asks for the signals once more and waits for them, 2 seconds at most (classic checkout, the pay-for-order page and the Checkout block), then sends the order with what it has: the payment never waits longer for a signal. A signal the browser could not produce is left out, and the payment goes ahead without it. If our API refuses an expired session (the same charge sent again after a 3-D Secure challenge or a re-check can outlive its 30 minutes), the plugin sends that charge once more without it, under the same idempotency key. A renewal has no buyer at a browser, so it has no session: our API records your server's address as your server's, and no per-IP fraud rule counts it. - My account → Payment methods lists the cards and lets the buyer set a default or delete
one. Deleting a card there deletes it at WorthyPayments too
(
DELETE /v1/customers/{id}/payment_methods/{pm}), except a live card deleted on a copy of your store (from plugin 2.8.0): when WooCommerce Subscriptions flags the site as a duplicate of the live one, orwp_get_environment_type()is notproduction, the card is removed from that copy only, so a staging clean-up never stops the real store's renewals. - Add payment method (on that page) saves a new card with no payment. The Element is
mounted in setup mode;
when the buyer saves, the plugin creates a setup intent for their customer
(
POST /v1/setup_intents,usage: off_session), the Element verifies the card with a $0 authorization and saves it, and the plugin re-reads the setup intent with your secret key before the card becomes a WooCommerce saved card. WooCommerce's own limit of one new card every 20 seconds is applied before the card is verified, so a refused add never leaves a card saved behind it. - A card removed or re-issued at WorthyPayments (
payment_method.detached,payment_method.updated): a removed card leaves the buyer's account too (without calling our API again), each subscription that renews with it gets a note, and for a week an admin notice lists those subscriptions: their next renewal is declined until the customer changes the payment method. A re-issued card's brand, last four and expiry are updated on its saved card. Only cards of the event's mode (and customer) are touched. - Cards are kept per mode: test cards are hidden while the store is in live mode.
- Wallet payments (Apple Pay, Google Pay) are not saved as cards, except a subscription paid with Google Pay through Express checkout. Guests cannot save cards.
WooCommerce Subscriptions#
The plugin supports WooCommerce Subscriptions: renewals, cancellation, suspension and reactivation, amount and date changes, payment-method changes by the customer and by an admin, and several subscriptions in one order.
The first order. The buyer pays it in the Element, and the card is always saved: the
plugin creates the order's intent with
setup_future_usage: "off_session"
and the order's customer, so there is no box to leave unticked, and the checkout tells the
buyer that the card is kept for the renewals. The customer is the one of the order's account,
so a buyer who creates their account at checkout is covered as well as one who was signed in.
That payment is the initial customer-initiated transaction the card is saved with; every
renewal on the card references it. The payment form offers no Apple Pay or Google Pay for a
subscription cart (we do not save a wallet's device token for renewals yet); with
Express checkout on, the buyer may pay with Google Pay, which hands
back the card itself, saved and renewed like a typed card. A buyer may also pay with a saved
card.
A $0 sign-up (a free trial) has nothing to pay, so the card is saved with no payment: the
plugin creates a setup intent
for the order's customer (POST /v1/setup_intents, usage: off_session, your
worthypayments_three_ds_request filter applied), the Element verifies the card with a $0
authorization, and the plugin re-reads the setup intent with your secret key (on the return,
the Store API's second call or the setup_intent.succeeded webhook, whichever comes first).
Then the card becomes the buyer's saved card and the subscription's, and the order is
completed. A buyer may pick a saved card instead.
When no card is saved with the first order (no customer could be found or created for it, the save failed), the subscription is switched to manual renewal with a note: WooCommerce Subscriptions asks the customer to pay each renewal, and the first renewal they pay on the site, or a change of the subscription's card in their account, switches it back to automatic.
Renewals. WooCommerce Subscriptions creates the renewal order and asks the plugin for the payment. The plugin makes one server-side charge, with no browser:
POST /v1/charges
Idempotency-Key: <a UUIDv4 stored on the renewal order>
{
"amount": 2000,
"currency": "usd",
"payment_method": { "payment_method_id": "pm_bb90c8f9-683b-4e75-8772-da2374a25461" },
"recurring": true,
"initiated_by": "merchant",
"description": "Order #5001",
"customer": { "email": "jane@example.com", "name": "Jane Doe", "first_name": "Jane", "last_name": "Doe", "phone": "+14155550142" },
"billing_address": { "line1": "510 Townsend St", "city": "San Francisco", "state": "CA", "postal_code": "94103", "country": "US" },
"shipping_address": { "name": "Jane Doe", "line1": "510 Townsend St", "city": "San Francisco", "state": "CA", "postal_code": "94103", "country": "US" },
"line_items": [{ "name": "Coffee beans (monthly)", "amount": 2000, "quantity": 1, "sku": "BEANS-1KG", "category": "Coffee" }],
"metadata": { "order_id": "5001", "order_number": "5001", "wc_site": "5c5a5c4cf85a", "site_url": "https://shop.example.com", "subscription_id": "4990" }
}
The buyer's first and last name are sent apart as well as together (our API would otherwise
split the full name on its last space, which cuts "Juan Carlos de la Cruz" wrongly), with the
order's shipping address when it ships somewhere and its products as line_items (see
What a payment says about the order). Empty fields are
left out.
Every payment the plugin makes carries the same metadata keys, so a charge and its webhooks
name the order the same way whichever way it was paid: order_id (WooCommerce's order id),
order_number (what the buyer and the admin see), wc_site (a short hash of the store's
address: two stores on one account have the same order ids) and site_url, plus
subscription_id on a renewal and on the order a subscription starts with. An intent's
metadata is copied onto its charge; a saved-card payment and a renewal send it on
POST /v1/charges. Before 2.4.0, saved-card payments and renewals carried wc_order_id,
wc_subscription_id and wc_site instead, and charges made then keep those keys: read
order_id and fall back to wc_order_id.
- Always 2D. A renewal is merchant-initiated: it never sends
three_ds, never runs yourworthypayments_three_ds_requestfilter, and is charged without 3-D Secure whatever your rules ask (a customer flagged Require 3-D Secure, the 3DS risk band, a fraud rule asking for 3DS). A fraud rule that blocks still blocks it. - One key per attempt. The key and the exact body are stored on the renewal order before the call. A re-run of the same attempt (a cron re-run, a timeout) sends the same request with the same key, so it can never charge twice. When the attempt gets a final answer, the key is spent: a later retry of a declined renewal is a new attempt with a new key, because the old key would only replay the decline (see Idempotency).
- A paid renewal is never charged again. A new attempt starts only while the renewal order, read again under the order's lock, still needs payment: a second run of the same renewal that read the order before the first run paid it sends nothing.
- A duplicate scheduled renewal is skipped. WooCommerce Subscriptions renews a subscription whenever its scheduled payment action runs, and does not check that the payment is due; a second action for the same subscription would create a second renewal order and charge the same period again. For a subscription renewed by WorthyPayments, an action that finds the next payment date still ahead (more than 10 minutes) while the subscription's next payment action is still scheduled creates no renewal order and charges nothing; the subscription gets a note. Never stopped: Process renewal on the subscription screen, a retry of a failed renewal, and the subscription's only payment action run early by hand (WooCommerce → Status → Scheduled Actions → Run), which renews it now.
- A 3-D Secure challenge finished too late charges nothing. A saved-card payment waiting for the buyer's challenge is sent only while its order still needs payment: a challenge finished after the order was cancelled (unpaid stock released) or paid another way is not charged. Before it is sent, the order's own payment on the site is read, as for a renewal: a buyer who paid the order with the card form in another tab meanwhile (its webhook not here yet) is not charged by the saved card too, and a card form still open there is closed first.
- Retries are WooCommerce Subscriptions'. A declined renewal is marked Failed with a note naming our decline code. If Retry Failed Payments is on (WooCommerce → Settings → Subscriptions; it is off by default), Subscriptions retries by its own rules; otherwise it e-mails the customer to pay the renewal themselves. The plugin adds no retry of its own, and WorthyPayments does not retry a renewal your store charges through the API, so there is only one retry schedule.
- A card whose bank wants the customer to confirm the payment. A renewal never runs 3-D
Secure, so such a card is declined
soft_decline_3ds, with nothing charged. As Stripe's plugin does, the customer then gets the plugin's own e-mail, Renewal payment needs confirmation (WorthyPayments) (WooCommerce → Settings → Emails, where you can turn it off or reword it): their bank needs them to confirm the payment, with a Confirm and pay the renewal link to the renewal order's payment page, where they pay with the card present and the bank can ask them to confirm it. Subscriptions' failed-renewal and retry e-mails, and WooCommerce's failed-order e-mail, are not sent for that renewal, so the customer gets one e-mail. From plugin 2.8.0, Subscriptions' scheduled retries of that renewal do not charge the same card again (it would be declined the same way, and card networks penalise repeated retries): the renewal fails again with a note, and Subscriptions keeps its own schedule. Once the customer changes the card, the next retry charges the new one. The order's Retry Renewal Payment action still sends the charge.
| What the API answers | Renewal order |
|---|---|
| Captured | Processing / Completed; the subscription continues |
| Authorized only (a Capture node on your route) | On hold, with Capture / Void actions |
402 soft_decline_3ds | Failed, note; the customer gets the Renewal payment needs confirmation e-mail with the payment link |
Another 402 decline, or another refusal | Failed, note with the code; Subscriptions retries if its retry system is on, otherwise e-mails the customer a link to pay it |
pending, a 5xx, or no answer | On hold; the plugin asks again with the same key (every 10 minutes, a few times), or the webhook settles it. If it still cannot tell, a note asks you to look the payment up in your dashboard |
Changing the card. In My account → Subscriptions → Change payment, the customer picks
one of their saved cards, or Use a new payment method: the Element saves the new card with
no payment (a setup intent for the subscription's customer, as for Add payment method), the
plugin re-reads it with your secret key, and renewals charge the new card from then on. Nothing
is charged now. The subscription moves to WorthyPayments only once the new card is saved: a
card that is declined or not saved leaves it on the payment method and card it had. Ticking
Update the payment method used for all of my current subscriptions moves the others to the
same card. An admin can edit the subscription's WorthyPayments card (pm_…) and
WorthyPayments customer (cus_…) on the subscription screen; the customer must be the
subscriber's own (the one on their account, the subscription's current one, or one of their
saved cards'), and the plugin checks with WorthyPayments that the card belongs to it before
saving.
Subscriptions created in test mode keep their test card. Once the store is live, their renewals are not charged (a test card takes no money, and the renewal would show as paid): each fails with a note saying the customer must add a card, and every admin screen says how many such subscriptions are active. When the customer adds a card (Change payment) or pays the renewal on the site, it renews live.
A failed renewal can be paid by the customer from their account, with a saved card or a new one in the Element (always saved, as for the first order); renewals then continue on the card they paid with.
The card on show. My account → Subscriptions, and a subscription's own page, name the card
renewals charge: Via Visa ending in 4242 rather than the method's title. The admin's
subscription list and screen add it to the title: Credit or debit card (Visa ending in
4242). The brand and last four come from the customer's saved card holding the subscription's
pm_…; a card that is not one of their saved cards shows the title alone.
Subscriptions that renew through another gateway (your NMI plugin, during the move) are never touched by this plugin, except by the migration command below when you run it.
Moving subscriptions from your NMI plugin#
If your subscriptions renew through an NMI gateway plugin today, WorthyPayments can import the
cards in your NMI Customer Vault as saved cards on our side (no card is re-entered, and
nothing is changed or deleted at NMI). After the import, WorthyPayments support gives you a
mapping file: one row per imported vault entry, with the NMI customer_vault_id, our
customer (cus_…) and payment_method (pm_…), and the card's brand, last four, expiry
and whether its first NMI transaction was found (anchor). It holds no card number. JSON
lines or CSV with a header row:
{"customer_vault_id":"1234567890","customer":"cus_0d7f6c2a-9d1b-4d8e-b8a3-1f2e3d4c5b6a","payment_method":"pm_5c2f1e90-3a4b-4c6d-8e0f-9a1b2c3d4e5f","card_brand":"visa","card_last4":"1111","card_exp_month":11,"card_exp_year":2030,"anchor":true}
Put the file on your server (never in the web root) and run, with WP-CLI, first a dry run. With
--report, every subscription gets a row in a CSV file you can sort and share (subscription,
outcome, the vault id's last four, the card, the next payment date in UTC, the recurring total)
instead of a terminal line each:
wp worthypayments migrate-subscriptions --mapping=/root/vault-mapping.jsonl --dry-run --report=/root/preview.csv
Then a first small run, then the rest. Every change is appended to the log you name:
wp worthypayments migrate-subscriptions --mapping=/root/vault-mapping.jsonl --log=/root/migration.jsonl --limit=10
wp worthypayments migrate-subscriptions --mapping=/root/vault-mapping.jsonl --log=/root/migration.jsonl --report=/root/run.csv
| Option | Meaning |
|---|---|
--mapping=<file> | The mapping file. A file missing a column or with a malformed row is refused whole, with the line number. |
--dry-run | Report only; no subscription is changed. |
--log=<file> | Required without --dry-run. JSON lines: each subscription's record before the move, then the result. |
--report=<file> | One CSV row per subscription (and one per unused mapping row) instead of a terminal line each; the terminal shows one progress line per page. |
--gateway-from=<id> | Your NMI plugin's gateway id. Default nmi-for-woocommerce. |
--source=token (default) | The vault id is the token of your NMI plugin's saved card attached to the subscription (the last one attached), or the buyer's only saved card of that gateway. |
--source=subscription-meta / user-meta with --source-key=<key> | The vault id is in that meta key on the subscription / on its user. |
--mode=test|live | Which keys the cards belong to. Default: the store's active mode. |
--batch=<n> | Subscriptions read per page, 1 to 1000 (default 200). |
--rate=<n> | WorthyPayments API calls per second at most, 1 to 50 (default 10). |
--from-id=<id> | Start after this subscription id: the value the last progress line printed. |
--limit=<n> | Stop after moving (or, in a dry run, finding) this many. |
The command reads the active, on-hold and pending-cancel subscriptions paid with
--gateway-from (a pending-cancel one renews again if the buyer reactivates it), page by page
in subscription id order. It first prints which mode, secret key (its last four characters) and
old gateway it uses, and stops at once if WorthyPayments refuses the key. For each subscription:
- reads its NMI vault id and looks it up in the mapping;
- reads our customer with your secret key: its e-mail must be the buyer's account e-mail or the subscription's billing e-mail, and the mapped card must be one of that customer's cards;
- outside a dry run, in one database transaction: stores the
cus_…on the WordPress user (when it has none), adds the card to the buyer's saved cards, switches the subscription to WorthyPayments with thepm_…, keeps on the subscription what it held before (for a revert), and adds a note naming the vault id's last four characters and the card. A subscription set to manual renewal stays manual. A renewal already created on NMI and still unpaid (a failed renewal waiting for Subscriptions' retry) moves with it, so the retry is charged through WorthyPayments; it gets a note too.
Large stores. The command is built for any number of subscriptions:
- Memory stays flat. The mapping file is streamed into a working table
(
{prefix}worthypayments_vault_map) once, and a later run with the same file reuses it; each page of subscriptions is read by id, decided and dropped. Measured on a test store (posts storage): 220,000 subscriptions previewed at 134 MB, the same peak as 20,000, with every page costing the same at the end as at the start; 20,000 moved at about 200 a second under 150 MB. Those runs read few distinct customers, so the API checks below were not the limit. - API calls are paced. The checks read each mapped customer once per page, at most
--ratecalls a second. Your key's ceiling (100,000 requests an hour, see Rate limits) is shared with your checkout and renewals: the default of 10 a second uses about a third of it. A429is waited out (2, 4 … 64 seconds); if the API still refuses, the command stops and says where to resume. A call that gets no answer or a5xxis made again (1, 2, 4 seconds) before the subscription counts aserror. With one customer per subscription, budget two calls per subscription: 100,000 subscriptions take about 5½ hours at the default rate, and the move can run in several sittings. - Stop and resume at any time. Each subscription is moved in its own database transaction,
so a run that is stopped (Ctrl-C, a lost SSH session, the server restarting) leaves every
subscription either moved or untouched. Every page ends with
To resume after an interruption: --from-id=<id>; run the same command with it (with--report, the same file is added to). Running again from the start is also safe: what was moved reportsalready_ours, and an unpaid renewal a stopped run did not hand over yet is handed over then. The command warns before it starts when the store cannot give that guarantee: tables that are not InnoDB,WC_USE_TRANSACTIONSoff, or WooCommerce's HPOS compatibility sync on (it commits each save itself); a re-run completes what a stop left. - One run at a time. A second
migrate-subscriptions,revert-subscriptionsorforget-mappingon the same site is refused while one runs.
One line (or CSV row) per subscription, then totals:
| Outcome | Meaning |
|---|---|
would_migrate / migrated | Moved (or would be, in a dry run). |
already_ours | Moved by an earlier run. Re-running is safe; an unpaid renewal still on NMI is handed over (the line says so). |
no_vault_id | No vault id where --source looks: check the source with a dry run. |
no_mapping | The vault id is not in the mapping (the import could not take that card): the subscription stays on NMI until the buyer updates their card. |
ambiguous | The mapping lists the vault id with two different cards. Ask support. |
customer_conflict | The user already has a different WorthyPayments customer (also when another of their subscriptions was moved to a different one earlier in the same run). |
email_mismatch | The mapped customer's e-mail is not the buyer's. Nothing is attached to a card that may belong to someone else. |
card_not_customers | The mapped card is not that customer's card. |
renewal_in_progress | Subscriptions is charging its renewal right now (checked when the subscription is decided and again just before it is moved). Run again later. |
error | Our API could not be read, WooCommerce refused a write, or could not load the subscription. The line says why; the run goes on. |
The totals also say how many moved cards carry their stored-credential anchor (renewals on
those reference the buyer's original NMI transaction), the recurring amount that moves per
currency, how many moved subscriptions renew within the next 24 hours (their next renewal is
the first one charged through WorthyPayments), how many API calls were made, and, once a run
reaches the last subscription, the mapping rows no subscription used (20 on the terminal, all
in the report). Run in a quiet window, or pause Action Scheduler, so that no NMI renewal runs
during the move; a subscription whose renewal is being charged at that moment is skipped
(renewal_in_progress).
Nothing is deleted. NMI is never called, and your NMI plugin's saved cards and meta stay on every subscription and user. Deleting a card that was imported from your NMI vault never deletes it at NMI. To move subscriptions back, re-activate your NMI plugin and run:
wp worthypayments revert-subscriptions --dry-run
wp worthypayments revert-subscriptions --log=/root/migration.jsonl
It reads, on every subscription the migration moved, what it held before, so it needs no log
file to read and covers every run; --log (required without --dry-run) records what it puts
back. --batch, --from-id and --limit work as for the move. A subscription is put back only
while it still renews with the card the migration gave it (changed_since when a card change
since then wins), and only when the old gateway is active (old_gateway_missing). Its unpaid
renewals go back too, with your NMI plugin's token copied onto them so its retry works: the one
that moved with it and one created on WorthyPayments since, except one whose charge through
WorthyPayments has no answer yet. A subscription whose renewal is being charged at that moment
is left for a later run (renewal_in_progress); one that cannot be put back is reported
error; the run goes on either way. Subscriptions moved by plugin 2.5 carry no such record and
are reported no_record; put them back by hand (set the subscription's payment method back to
your NMI plugin). The saved card stays in the buyer's account. When you are done, drop the
working table:
wp worthypayments forget-mapping
Deleting the plugin drops it too.
Where the money goes after the move. A moved subscription renews through WorthyPayments, and WorthyPayments charges the card in your NMI Customer Vault under the NMI account that holds it, whatever your Payment Workflow route would pick (a vault entry exists only in that account). Within that account, the processor is the one your route's Payment Workflow sends; check the route before the first renewal if your NMI account has several processors. Each renewal references the card's first NMI transaction (where the import found it). Nothing at NMI changes, and the vault entry keeps any card update NMI applies to it.
Before you move:
- If you also set up recurring billing in NMI itself (NMI's Recurring / Subscriptions, not WooCommerce Subscriptions), NMI keeps charging those plans on its own schedule whatever WooCommerce does. Cancel any plan that covers a subscription you move, or the buyer is charged twice.
- Run the dry run in a quiet window and read its report: every
would_migraterow is a subscription whose next renewal is charged through WorthyPayments.
After you move:
- Turn the NMI payment method off at checkout (WooCommerce → Settings → Payments), but keep the NMI plugin active: WooCommerce refunds an order through the gateway that took it, so the orders your NMI plugin took stay refundable from the order screen only while that plugin is active. Deactivate it only once no active subscription uses it and you no longer expect to refund its orders; after that, refund them in your NMI merchant portal.
- Subscriptions the move left on NMI (
no_mapping,email_mismatchand so on) keep renewing through the NMI plugin while it is active. If you deactivate it, WooCommerce Subscriptions switches them to manual renewal: the buyer is e-mailed to pay each renewal.
3-D Secure#
The Element runs every challenge; nothing is configured in the plugin. By default our rules decide when a payment is stepped up (see 3-D Secure).
To decide per order from your own code (a rules engine, a risk score), use the
worthypayments_three_ds_request filter. It runs when the order's intent is created, and when
the buyer pays with a saved card (never for a renewal):
add_filter( 'worthypayments_three_ds_request', function ( $request, WC_Order $order ) {
if ( $order->get_total() >= 500 ) {
return 'challenge';
}
return $request; // 'automatic'
}, 10, 2 );
| Value | Meaning |
|---|---|
automatic | Our rules decide (default). Nothing extra is sent. |
any | Authenticate, no challenge preference. |
challenge | Authenticate, ask the issuer for a challenge. |
challenge_mandated | Authenticate, a challenge is mandated. |
The request applies to a card typed for this payment and to a saved card the buyer picks (see
Saved cards). A rule on our side can still require a step-up the filter did
not ask for. A saved card's challenge runs on the checkout page through Evervault, the same
provider the Element uses: if your site sets a Content-Security-Policy, allow
https://js.evervault.com (scripts) and https://*.evervault.com (frames and connections),
as in Content-Security-Policy.
Tax rates from WorthyPayments#
By default WooCommerce computes tax from its own rates. With Tax rates set to My WorthyPayments tax rates, cart and checkout tax comes from the tax rates you keep with us instead: the plugin asks our tax calculation for each cart, and each taxable cart line gets the rates the answer names for it. This works on the classic checkout, the blocks, the Store API and Subscriptions' recurring totals.
- Products. The plugin sends each line's WooCommerce product id (the parent product's id for a variation) as the line's product. Attach your rates to that id. A line with no product rate gets your rates for the buyer's address (WooCommerce's taxable address), then your default rate.
- WooCommerce still does the arithmetic with those percentages, so the order's tax lines, refunds and reports are WooCommerce's. Each of our rates appears as a row in a WooCommerce tax class named WorthyPayments, created and kept up to date by the plugin. Do not assign products to that class; turning the setting off leaves WooCommerce's own taxes as they were.
- Requirements. WooCommerce taxes enabled (WooCommerce → Settings → General), and the
tax:readscope on the secret key. A product's tax status still applies: a product set to "None" is not taxed. - Inclusive rates must match WooCommerce's "Prices entered with tax" setting. A rate whose
inclusiveflag differs is refused, like an unreadable answer. - Shipping and fees keep WooCommerce's own rates (our calculation does not tax shipping).
- When the rates cannot be read (our API unreachable, a missing scope, a refused rate), the cart shows no tax and the order cannot be placed: the buyer sees Tax could not be calculated for this order. Please try again in a moment. No order is charged without its tax.
- Answers are reused for 10 minutes for the same cart and address. A renewal keeps the tax lines Subscriptions copies from the subscription. Recalculate in the order editor uses WooCommerce's own rates, not ours.
Headless checkout (Store API)#
A mobile app or your own dashboard can check out through WooCommerce's Store API with
payment_method: "worthypayments". It takes four steps: read the cart, place the order,
confirm its intent in the Payment Element, and tell the store. Every Store API call carries the
cart's Cart-Token header (and, for a signed-in buyer's cookie session, the Store API Nonce
header), as WooCommerce documents. Nothing the client sends is believed: the store verifies it
with your secret key.
0. Read the cart. GET /wc/store/v1/cart carries the plugin's data under
extensions.worthypayments:
"extensions": {
"worthypayments": {
"saved_cards": [
{ "token": 87, "brand": "visa", "last4": "4242", "exp_month": 12, "exp_year": 2029, "default": true }
],
"subscription": true
}
}
saved_cards is the signed-in buyer's cards (empty for a guest; see
Paying with a saved card). subscription: true means the cart
starts a subscription: the card is always saved, so show no "save" choice and no Apple Pay /
Google Pay, and tell the buyer the card is kept for the renewals. At a zero total (a free trial)
the card is saved with no payment, through a setup intent.
1. Place the order (no payment_data):
POST /wc/store/v1/checkout
Cart-Token: eyJ0eXAiOiJKV1Qi…
Content-Type: application/json
{
"billing_address": {
"first_name": "Jane", "last_name": "Doe", "address_1": "510 Townsend St",
"city": "San Francisco", "state": "CA", "postcode": "94103", "country": "US",
"email": "jane@example.com", "phone": ""
},
"payment_method": "worthypayments"
}
The answer is 202 with the intent to confirm. For a $29 subscription:
{
"order_id": 1042,
"status": "pending",
"order_key": "wc_order_1xarNfYJsEIIc",
"payment_result": {
"payment_status": "pending",
"payment_details": [
{ "key": "worthypayments_payment_intent", "value": "pi_4b124c602f49458b92204957ee5b21b6" },
{ "key": "worthypayments_intent_type", "value": "payment_intent" },
{ "key": "worthypayments_client_secret", "value": "pi_4b124c602f49458b92204957ee5b21b6_secret_…" },
{ "key": "worthypayments_status", "value": "requires_payment_method" },
{ "key": "worthypayments_return_url", "value": "https://shop.example.com/wc-api/worthypayments_return/?order_id=1042&key=wc_order_1xarNfYJsEIIc" },
{ "key": "worthypayments_amount_text", "value": "$29.00" },
{ "key": "worthypayments_save_card", "value": "never" },
{ "key": "worthypayments_setup_future_usage", "value": "off_session" },
{ "key": "worthypayments_subscription", "value": "yes" }
],
"redirect_url": "https://shop.example.com/wc-api/worthypayments_return/?order_id=1042&key=wc_order_1xarNfYJsEIIc"
}
}
For a $0 subscription sign-up the intent is a setup intent: it saves the card with no payment, and is named under its own key, so it cannot be mistaken for a payment:
"payment_details": [
{ "key": "worthypayments_setup_intent", "value": "seti_21a340a423fd45a997f221cea4e41188" },
{ "key": "worthypayments_intent_type", "value": "setup_intent" },
{ "key": "worthypayments_client_secret", "value": "seti_21a340a423fd45a997f221cea4e41188_secret_…" },
{ "key": "worthypayments_status", "value": "requires_payment_method" },
{ "key": "worthypayments_return_url", "value": "https://shop.example.com/wc-api/worthypayments_return/?order_id=1043&key=wc_order_MsiJWTOq5SZsq" },
{ "key": "worthypayments_amount_text", "value": "$0.00" },
{ "key": "worthypayments_save_card", "value": "never" },
{ "key": "worthypayments_setup_future_usage", "value": "" },
{ "key": "worthypayments_subscription", "value": "yes" }
]
| Key | Meaning |
|---|---|
worthypayments_intent_type | payment_intent (something to pay) or setup_intent (a $0 subscription sign-up: save the card, charge nothing) |
worthypayments_payment_intent / worthypayments_setup_intent | The intent's id (pi_… / seti_…), the one this order owns |
worthypayments_client_secret | What the Element mounts with (step 2) |
worthypayments_return_url | Where the browser goes once the Element is done (step 3); also redirect_url |
worthypayments_save_card | auto: mount with fields: { savePaymentMethod: 'auto' } to offer Save this card (a signed-in buyer, a purchase that starts no subscription); never: no box (a guest, a subscription, a setup intent) |
worthypayments_setup_future_usage | off_session: the card is saved with this payment whatever the buyer does (the order starts or renews a subscription): tell the buyer. Empty otherwise |
worthypayments_subscription | yes when the order starts or renews a subscription |
Placing the order again before it is paid (the same cart) answers the same order, and the same intent while it can still take the payment (a setup intent only until a card was tried on it; then a new one).
2. Confirm that client secret with the Payment Element and your publishable key. The Element tells a payment intent's secret from a setup intent's by itself: a setup intent mounts it in setup mode (a Save card button, no wallets, nothing charged). It drives 3-D Secure in its own frame.
const wp = WorthyPayments('pk_live_…');
wp.mountPaymentElement('#card', {
clientSecret, // worthypayments_client_secret
fields: { savePaymentMethod: saveCard }, // worthypayments_save_card
onSuccess: () => tellTheStore(), // step 3
onError: (e) => { /* a decline is shown inside the Element; the buyer can try another card */ },
});
- Your dashboard (a web page): mount it there. Its origin must be on the publishable key's allowed domains (Allow your domain).
- A mobile app: host a small page that mounts the Element on an https origin listed on the
key, and open it in the app's web view (WKWebView, Android WebView). That page's origin is the
one the Element checks; a
file://page or a custom URL scheme cannot be listed. Hand the client secret to the page through your app's JavaScript bridge or the URL fragment, not a query string, which servers and proxies log. - When it is done, either call step 3 from the app, or navigate the web view to
worthypayments_return_url: the store reads the intent and redirects to the order-received page (/checkout/order-received/{order_id}/, a URL your app can watch for to close the web view), or, when nothing went through, back to the order's pay page (the checkout page for a $0 sign-up) with a notice.
3. Tell the store. Post the checkout again with the same Cart-Token, an unchanged cart,
and the whole body of step 1 again (billing_address and payment_method are required on
every call), plus payment_data naming the intent:
{
"billing_address": { "first_name": "Jane", "last_name": "Doe", "address_1": "510 Townsend St", "city": "San Francisco", "state": "CA", "postcode": "94103", "country": "US", "email": "jane@example.com", "phone": "" },
"payment_method": "worthypayments",
"payment_data": [ { "key": "worthypayments_payment_intent", "value": "pi_4b124c602f49458b92204957ee5b21b6" } ]
}
For a setup intent, send worthypayments_setup_intent with the seti_… id instead. Either
key also takes the full client secret. Without billing_address the Store API answers 400 rest_missing_callback_param (Missing parameter(s): billing_address); without
payment_method, 400 woocommerce_rest_checkout_missing_payment_method. WooCommerce reuses the
pending order only while the cart is unchanged: after a cart change the same call places a new
order, the intent you confirmed is not that order's, and the value is refused.
To finish one particular order instead (the cart changed, the app was restarted, a pay-for-order link), post to that order:
POST /wc/store/v1/checkout/1042
{
"key": "wc_order_1xarNfYJsEIIc",
"billing_email": "jane@example.com",
"billing_address": { "first_name": "Jane", "last_name": "Doe", "address_1": "510 Townsend St", "city": "San Francisco", "state": "CA", "postcode": "94103", "country": "US", "email": "jane@example.com", "phone": "" },
"payment_method": "worthypayments",
"payment_data": [ { "key": "worthypayments_payment_intent", "value": "pi_4b124c602f49458b92204957ee5b21b6" } ]
}
key (the order_key of step 1) and billing_email identify a guest's order; a signed-in
buyer's own order needs neither. An order that is already paid answers
invalid_order_update_status (This order cannot be paid for.).
Either way the store accepts the value only when it names the intent of this order, then reads that intent with your secret key (for a payment, its amount and currency against the order; for a setup intent, that it saved a card for the order's customer):
{
"order_id": 1042,
"status": "processing",
"payment_result": {
"payment_status": "success",
"payment_details": [
{ "key": "result", "value": "success" },
{ "key": "redirect", "value": "https://shop.example.com/checkout/order-received/1042/?key=wc_order_1xarNfYJsEIIc" }
],
"redirect_url": "https://shop.example.com/checkout/order-received/1042/?key=wc_order_1xarNfYJsEIIc"
}
}
| Result | Answer |
|---|---|
Paid, authorized, or still processing; a setup intent that saved the card | 200, payment_status: success, redirect_url = the order-received page |
| Declined (a payment, or a setup intent's last attempt) | 400 with the buyer sentence (for example Your card was declined for insufficient funds. Try another card.); the order stays payable |
| Not confirmed yet, another order's intent, or an amount mismatch | 400 The payment was not completed. Please try again. |
If the app never comes back, the webhook still completes the order (charge.* for a payment,
setup_intent.succeeded for a $0 sign-up).
Paying with a saved card#
For a signed-in buyer, GET /wc/store/v1/cart lists their saved cards under
extensions.worthypayments.saved_cards:
{ "token": 87, "brand": "visa", "last4": "4242", "exp_month": 12, "exp_year": 2029, "default": true }
Send the chosen token with the checkout, and the store charges the card itself, with no
Element step:
"payment_data": [ { "key": "wc-worthypayments-payment-token", "value": "87" } ]
Add the buyer's device signals when your app can read them (worthypayments.js
getFingerprint(), getKountSessionId() and getDeviceSession()). They go to fraud scoring,
the device session gives the charge the buyer's own IP instead of your store's, and a missing
one does not stop the payment:
{ "key": "worthypayments_device_fingerprint", "value": "tp_fp_9f2c…" },
{ "key": "worthypayments_kount_session_id", "value": "6a1f0c2e…" },
{ "key": "worthypayments_device_session", "value": "ds_K7Q2M4ZJ3WXH5N6P8R2T4V6YB3" }
The answer is 200 success (paid, authorized or still processing) or 400 with the decline
sentence. A token of another account is refused. A card with no encrypted copy for 3-D Secure
is a 400 asking the buyer to enter the card again: place the order without the token and
confirm with the Element (step 1). When your rules or filter ask for 3-D Secure, the answer is
202 with the challenge to run:
"payment_details": [
{ "key": "worthypayments_three_ds_session", "value": "tds_visa_439ab3fc28d1" },
{ "key": "worthypayments_order_id", "value": "1042" },
{ "key": "worthypayments_order_key", "value": "wc_order_…" },
{ "key": "worthypayments_three_ds_url", "value": "https://shop.example.com/?wc-ajax=worthypayments_three_ds" },
{ "key": "worthypayments_return_url", "value": "https://shop.example.com/wc-api/worthypayments_return/?order_id=1042&key=wc_order_…" }
]
Run the session's challenge with Evervault in your app's web view
(Running the challenge), then POST order_id and
key (form-encoded) to worthypayments_three_ds_url. The store sends the same charge again
with the session and answers { "success": true, "data": { "redirect": "<order-received page>" } }
when it went through, or { "data": { "message": "<buyer sentence>" } } when it did not.
Placing the order again instead starts a new attempt, with a new challenge.
Webhooks#
The plugin's endpoint is https://<your-site>/wp-json/worthypayments/v1/webhook, registered
once in test mode and once in live mode.
Set up the webhook for me (on by default) does it when you save: for each mode whose secret
key works and whose signing secret field is empty, the plugin registers that URL with
POST /v1/webhook-endpoints and the events below, and saves the signing
secret it gets back. If an endpoint with that URL exists already, its secret is rolled instead
(POST /v1/webhook-endpoints/{id}/roll-secret; the previous secret keeps working for 24
hours) and the new one saved. The secret key needs webhooks:read and webhooks:write for
this. A signing secret you filled in yourself is never replaced.
To do it by hand instead, register the URL in Dashboard → Developers → Webhooks with these
events and paste each endpoint's signing secret into the matching field: charge.succeeded,
charge.authorized, charge.failed, charge.canceled, charge.expired, charge.refunded,
dispute.created, dispute.updated, dispute.won, dispute.lost, dispute.closed,
dispute.funds_withdrawn, dispute.funds_reinstated, pre_dispute_alert.created,
pre_dispute_alert.updated, setup_intent.succeeded, setup_intent.setup_failed,
payment_method.detached and payment_method.updated.
An endpoint registered by an earlier version lacks dispute.closed and the two
payment_method.* events. When the secret key has webhooks:write, the plugin adds what is
missing itself (POST /v1/webhook-endpoints/{id} with every event it uses, keeping any others
the endpoint already sends): once after each plugin update, in the 12-hourly check, when you
save the settings, and from Set up the WorthyPayments webhook again. An endpoint that was
deleted at WorthyPayments is registered again and its new signing secret saved. Without the
scope, or on a platform version that cannot update an endpoint yet, the settings screen and an
admin notice name the events it does not send: add them in Dashboard → Developers → Webhooks.
The plugin verifies every delivery as described in
Verifying a delivery (the X-Webhook-Mode header picks
the secret, both parts of a rolled secret are accepted, a delivery whose timestamp is more than
5 minutes from the server's clock is refused). Then:
- The delivery belongs to the mode whose secret verified it. It acts only on an order of
that mode: a test delivery never touches a live order (it is answered
200, ignored, and recorded as Webhook rejected,mode_mismatch). A body whose signedlivemodesays the other mode is ignored the same way. - A delivery is applied once, recognised by the event
idin its signed body (the sameevt_…in every retry and inGET /v1/events), never by the unsignedX-Webhook-Idheader. - It is answered at once and applied in the background (an Action Scheduler job,
worthypayments_webhook_event): the request only verifies, dedupes and queues. A job that cannot apply its event yet (no answer from our API, a5xx, the order busy, a refund whose sale the order does not know yet) runs again after 1, 5 and 30 minutes, 2 and 6 hours; a refusal that would repeat (401,403,404) is recorded as Webhook not applied and not retried. Without Action Scheduler, or when its queue has stalled (WP-Cron off and no real cron: a job more than 10 minutes late), the delivery is applied in the request itself, with a couple of the stuck ones; then500asks our retry to deliver it again. - A webhook only tells the plugin to look: the order changes after it re-reads the payment, the refund or the dispute with your secret key.
- An event more than a day old (recovered, or delivered late) brings the order up to date without sending any e-mail, to the buyer or to you.
| Event | What the plugin does |
|---|---|
charge.succeeded, charge.authorized, charge.failed | Re-reads the order's intent and charge (for a renewal or a saved-card payment, the charge) and moves the order |
charge.canceled, charge.expired | An authorized order becomes Cancelled; a paid order whose captured charge was voided before it settled goes On hold (do not ship) and the admin is e-mailed |
charge.refunded | Each refund made in your WorthyPayments dashboard or in the gateway's portal is recorded on the order as its own WooCommerce refund; the note (or reason) you gave it goes into a private order note (see Refunds), and its ARN is noted when the refund is of this order's own charge. A refund that arrives before the order knows its sale reads the order's payment first, and waits for it rather than being dropped |
setup_intent.succeeded, setup_intent.setup_failed | For a $0 subscription sign-up: re-reads the order's setup intent; once it saved the card, the card is recorded and the order completed (a buyer who closed the page before the return) |
dispute.* | The dispute is read again and the order follows it (see Disputes) |
pre_dispute_alert.* | An order note; manage the alert in your dashboard |
payment_method.detached, payment_method.updated | See Saved cards |
Deliveries for orders the plugin did not take (another gateway's, another store's) are
answered 200 and ignored. A refund is matched by its order reference only when its payment's
metadata.wc_site names this store: two stores on one account both have an "Order #5". A
payment of this store whose order was deleted is recorded (Payment for a deleted order)
and listed in the double-charge watch.
Missed webhooks#
Our webhooks are retried for about 34 hours, then given up. The plugin does not depend on them alone:
- Every hour it reads
GET /v1/eventssince the last time (events:read), in each mode whose secret key is saved, and hands each event to the same handler as a webhook: one the webhook already applied is a duplicate, anything else is applied now. The feed also lists the events of our historical imports, which are never sent as webhooks and must not reach your buyers: an event sends WooCommerce's e-mails only when the feed marks itreplay: false(and it is less than a day old); one markedreplay: true, or from a platform version that does not mark them, is applied without any e-mail. A long backlog continues a page at a time in follow-up jobs; an event that still cannot be applied after 6 runs is given up with an activity row. - Every day it reads the disputes changed since the day before (
GET /v1/disputes), and each order created in the last 30 days that is still Processing, Completed or On hold has its charge read again: an authorization captured, voided or expired since, a refund made outside WooCommerce and a dispute still open are brought in line, without e-mails. - WooCommerce → Status → Tools: Pull missed WorthyPayments events now, and Set up the WorthyPayments webhook again (a new signing secret for this site's endpoint in each mode, or the endpoint registered, and the events it lacks added; the previous secret keeps working for 24 hours).
Webhook and key health#
On the Dashboard, the Plugins screen and WooCommerce's screens, shop managers see a notice for the active mode when:
- payments were taken, but no webhook has arrived since (2 hours on);
- deliveries are being refused, and why: a signature that does not match the secret saved here (three in a row, so one stray request is not reported), this server's clock more than 5 minutes off, no secret for their mode, or a delivery of one mode claiming the other;
- the endpoint for this site is disabled at WorthyPayments (its deliveries kept failing, or you turned it off), there is none, or it does not send an event the plugin uses and could not be given it;
- the secret key is refused (revoked, expired, invalid) or lacks a scope, or cannot read events;
- our API has not answered several requests in a row.
The key and the endpoint are checked every 12 hours, in the background. The WorthyPayments section of WooCommerce → Status (the System Status report) shows the same: the mode, the kinds of key saved (never a key), the last accepted and refused delivery, the last key check and event pull, and the plugin's waiting and failed jobs.
Refunds#
Refund from the order screen as usual (Refund → Refund via WorthyPayments), in full or in
part. Each refund calls POST /v1/refunds under the order's lock, with an
idempotency key derived from the order, the refunds it already has, the refusals so far and
the refund's exact body (amount, reason, note). A refund that was refused (an open dispute or
alert, more than is left to refund) can be tried again as soon as its cause is gone: the next
try is a new request, not a replay of the refusal. An authorized, uncaptured payment cannot be
refunded: void it.
A refund that got no answer (a timeout, a 5xx, an answer that could not be read, or our
API's idempotency_error) may have gone through, so WooCommerce does not record it and the
plugin sends nothing new on the order until its outcome is known:
- Refund the same amount again to complete it: the plugin resends that exact request under its key (the words you type the second time are not sent), so our API answers it once, whether or not the first one went through.
- Another amount first looks the unanswered refund up (
GET /v1/refunds?charge=). Found: it is recorded on the order as the refund you made, and you are told so; nothing new is sent. Not found yet: you are asked to refund the first amount again or wait. After 24 hours (the life of its key) a refund that is still not found never went through, and the new one is sent. - The webhook, or a re-read the plugin schedules, records it as soon as it shows up. The next refund you try is then told once that the earlier one went through, instead of refunding it a second time.
The Reason for refund you type goes with the refund as its note (up to 500 characters)
and shows on the refund in your dashboard. A reason that is exactly one of our API's codes
(requested_by_customer, duplicate, fraudulent) is sent as reason instead.
The other way round: a refund you make in your WorthyPayments dashboard (or through the API),
or in your gateway's own portal once our gateway sync has seen it (source: "gateway"),
reaches the order with the charge.refunded webhook. The plugin reads the charge's refunds
(GET /v1/refunds?charge=) under the order's lock and records each one that went through
(succeeded) and that the order does not have yet as one WooCommerce refund of its own
amount, which keeps the WorthyPayments refund id (_worthypayments_refund_id). Totals are
never compared: a refund still in flight, a refund you recorded by hand in WooCommerce and a
lost dispute that plugin 2.11 or earlier recorded as a refund that moved no money do not
change what is recorded (since 2.12 a lost dispute records no refund at all). Two
deliveries at once record it once: the second finds the order busy and is delivered again.
What the customer sees stays neutral. WooCommerce shows a refund's reason on My account, the order-received page and the Refunded order e-mail, so a refund made outside WooCommerce gets the reason "Refunded by the store through WorthyPayments." The note (or reason) you gave it in your dashboard goes into a private order note, as plain text (tags removed), with its amount and refund id.
A refund that is pending (the gateway has not confirmed it) is recorded at once and read
again every 15 minutes. If it fails, its WooCommerce refund is removed from the order with a
note saying no money went back, a fully refunded order goes back On hold, and the site's
admin e-mail is told (filter worthypayments_admin_alert_recipient to send it elsewhere).
Each refund is recorded by its id, once, whichever way it was made.
Capture#
Whether a payment is captured at once or only authorized is set by the Capture node of your Payment Workflow route (see Authorize & capture). An authorized order is On hold with two order actions: Capture charge and Void authorization.
Moving an authorized order to Processing or Completed also captures it, and cancelling it voids it, as with Stripe's plugin. The capture takes the order's total at that moment, so lower the total first (remove an item, add a negative fee) to capture less than was authorized: what was captured is measured against the lowered total, and the order is paid. If the capture fails, the order goes back On hold with a note saying why: an order is never left marked for fulfilment while its money was not taken. A refused capture can be tried again (with the cause fixed, or the total lowered): the next try is a new request. The plugin had a Capture setting for this until 2.5.1; the behaviour is now always on.
A partial capture in your dashboard (say $60 of a $100 authorization) leaves the order On hold with a note: captured $60.00 of the order's $100.00. The money taken is the order's (refunds and disputes follow the charge), but it is not paid in full: lower the total to what was captured and mark it Processing, collect the rest, or refund. The same goes for a payment that captured less than the order total as the buyer paid: back from the payment page or a saved card's 3-D Secure challenge, the buyer is shown the order, never asked to pay again, and a card saved with that payment is kept for the order and its subscriptions.
A captured payment voided before it settled (a void in your dashboard, a bulk void, a fraud reversal) brings no money. The order, Processing or Completed until then, goes On hold with the note the payment was voided after it was captured … Do not ship it, and the site's admin e-mail is told.
Double charges#
What the plugin does so that one order is paid once (plugin 2.8.0 and later):
- One request at a time per order. The return from the payment, a webhook, a scheduled
re-check and a renewal apply to an order one after another, under a lock only one request
can hold. A webhook that finds the order busy is not recorded as applied: its job runs
again later (or, applied in the request, it answers
500and our retry delivers it again). - A charge is sent once. A saved-card or renewal charge is stored with its idempotency key before it is sent; a resend after no answer uses the same key, so it cannot charge twice. An order that no longer needs payment is never charged.
- The same cart submitted twice at once (a double click, two tabs) gives WooCommerce two orders before either is paid. A new payment takes the cart's lock first (the cart and the buyer): the second submission is told Your payment for this cart is already being processed and charges nothing. The cart's lock lasts up to 10 minutes, longer than the slowest new payment, so a slow answer from our API never lets the second one through.
- A checkout submitted again for a cart just paid. When the buyer's page never showed the result and they press Place order again, WooCommerce makes a second order for the same cart. If that cart was paid within the last 5 minutes (remembered with the buyer's session and beyond it), the second order takes no payment: it is cancelled with a note and the buyer is shown the paid order. An earlier order paid through the card form whose webhook has not arrived yet is read first, so it counts. The same cart bought again after 5 minutes goes through.
- A renewal already paid on the site (the customer used the e-mailed link and the webhook has not synced it yet) is not charged again by the next scheduled try: the plugin reads the order's own payment first, and leaves it alone while it is paid, settling or in its 3-D Secure challenge. So does a saved card's 3-D Secure challenge the buyer finishes after paying the same order with the card form in another tab.
- A pay link opened while the order's card is being charged (a renewal, or a saved card) starts no second payment. The card form's payment is made under the order's lock, so a charge always sees it before it sends anything; while a charge holds the lock, or was sent and has no answer yet, the buyer is told A payment for this order is already being processed and nothing is created. A saved card still waiting on its 3-D Secure challenge charged nothing, so the buyer can pay with the card form instead.
- An intent whose key cannot answer (our API holds another body under it after an answer was lost) is created again under the next attempt's key. An intent moves no money.
- A second charge is flagged, never hidden. If money is taken for an order that is already
paid (a payment form left open in another tab, an earlier payment attempt the buyer
completed, or the order paid with another payment method since), the order keeps its first
charge for refunds and disputes, gets a note naming the second charge, and is listed in the
double-charge watch; every admin screen shows a warning while the watch
of the last 7 days lists a case you have not reviewed. Nothing is refunded automatically:
check both charges in your dashboard, refund the one taken twice, then Mark as reviewed
(on the warning or the watch): the warning comes back only for a case recorded after that.
Developers can act on the
worthypayments_second_chargeaction ($order,$charge_id,$charge). - A payment made through an earlier payment attempt of the order is found by its
metadata(order_idandwc_site) and applied: an unpaid order is paid by it.
Card testing#
Card testing is someone trying stolen card numbers, one after another, through your checkout.
Every payment attempt passes through the plugin (a new intent, a retry after a decline, a saved
card, a $0 card check, the pay-for-order page's payment form, a card saved from My account),
and the plugin counts it under every identity it knows: the IP address, the buyer's
WooCommerce session, the billing e-mail, the account and the order. When any of them has made
Payment attempts per hour attempts (10 by default) in the last hour, the next attempt is
refused before anything reaches our API: the buyer reads Too many payment attempts. Please
wait an hour before trying again, and the order gets a note. A refused attempt is not
counted. 0 turns the limit off. Saving a card from My account also waits 10 seconds between
new setup intents (filter worthypayments_setup_intent_delay) and allows 10 new ones per
account and day (filter worthypayments_setup_intents_per_day).
The IP address counted is the connection's (REMOTE_ADDR): a header such as
X-Forwarded-For can be written by anyone, so it is not believed by default. Behind a proxy
or CDN that sets the visitor's address, name its header: the WORTHYPAYMENTS_TRUSTED_IP_HEADER
constant in wp-config.php (CF-Connecting-IP, True-Client-IP, X-Real-IP or
X-Forwarded-For, whose last address is the one your proxy added), or the
worthypayments_trusted_ip_header filter. Without it, every buyer behind the proxy counts as
one address.
The counts are kept as WordPress transients, named by a salted hash, so no IP address or e-mail is stored in plain text. Our own fraud screening (velocity and card-testing rules) still applies to every attempt that gets through. Raise the limit if many of your buyers share one IP address (a campus, a corporate network).
Disputes#
A dispute is answered in your WorthyPayments dashboard; the order shows where it stands, as Stripe's WooCommerce plugin does.
A dispute.* delivery is only a trigger: before the order changes, the plugin reads the
dispute again with the order's own key (GET /v1/disputes/{id},
disputes:read), and the dispute as it stands now decides. A late or repeated delivery cannot
undo a newer outcome, and a delivery signed with the other mode's secret cannot read this
mode's dispute at all.
| The dispute now | Order |
|---|---|
| Open (needs a response, under review…) on a Processing or Completed order | On hold, with a note: amount, reason, respond-by date, and the stage, case number and acquirer when the delivery names them. The status it had is kept on the order |
A Visa RDR case (rdr: true, from your acquirer) | Treated as lost at once, as below: the card issuer has already credited the cardholder from your funds, and an RDR cannot be contested. The note says so |
| Won | Back to the status it had before the hold, with a note. Won after it was lost (funds reinstated): back from Failed to the status it had before the dispute, with a note that the money came back |
| Lost (or accepted) | No WooCommerce refund is recorded, as with WooCommerce's own Stripe gateway. Lost for the whole payment (or for no amount in the order's currency): the order is marked Failed. Lost for part of it: the hold comes off and the order keeps its status. Either way the note names the dispute and the amount taken back, and the activity log has a row for it |
| A loss recorded by plugin 2.11 or earlier | Such a version recorded a lost dispute as a WooCommerce refund that moved no money (reason Dispute lost: the card network took this amount back…, or the meta _worthypayments_dispute_loss). It is left as it is, never deleted. If that dispute is won after all, the order goes back to its status from before the dispute (from Refunded too), and the note says the refund record moved no money and stays |
Closed with no winner (dispute.closed from a processor that reports none, Nuvei or PayArc; expired) | Back to the status it had before the hold, no loss recorded; the note asks you to check the outcome in your dashboard. The dispute.closed that comes with a dispute.won or dispute.lost changes nothing more |
No longer naming this order's sale (dispute.updated with linkChange UNLINKED or RELINKED) | This order's hold comes off (and a loss recorded on it no longer stands: back from Failed); the sale it names now, if it is one of your orders, follows the dispute instead |
A pre-dispute alert (Ethoca, CDRN, RDR, pre_dispute_alert.*), or an alert-sourced or related-case row | A note only. An alert's note gives its deadline to refund before (slaDueAt), its source, category and ARN. Each new alert is also delivered as dispute.created: that copy is ignored, so an alert is noted once |
The order keeps each dispute's state by its id, so one decision delivered twice moves the
order once. The order is found through the sale the dispute names: our charge id, or its
gateway transaction id (dispute.nmiTransactionId, best-effort, see
Dispute payloads); a dispute that names none yet changes
no order. Once a day the disputes changed since the day before are read and applied the same
way (see Missed webhooks), so an order on hold for a dispute that ended
without a webhook is released too.
A status set by a dispute sends the customer none of WooCommerce's order e-mails (processing, completed, on hold, failed, refunded, partially refunded): a dispute won is not a new "your order is being processed", and a lost one is not a failed payment the customer should be asked to pay again. The e-mails WooCommerce sends to your store are kept.
An order whose dispute was lost is never charged again by the plugin, and nothing it reads later (the buyer opening the payment's return address again, a webhook delivered late, a sync) marks it paid. A lost dispute of a whole subscription payment marks its order Failed, so WooCommerce Subscriptions handles the subscription as for any failed payment: it goes On hold, and with Retry Failed Payments on, a retry is scheduled. That retry charges nothing: the order gets a note that charging the card again would bill the cardholder for what they disputed. Reactivate or cancel the subscription yourself.
The order screen#
An order paid with the plugin shows, under its general details:
- the card it was paid with: brand and last four, and for Apple Pay / Google Pay the wallet and the buyer's own card inside it, as the wallet stated it (never the device token's digits; "Google Pay" alone when the wallet did not state the card);
- what the gateway answered, for your fraud review before you ship and as dispute evidence: the auth code, the address check (AVS) and security-code check (CVV) with the issuer's letters, the 3-D Secure liability (shifted to the card issuer, or not), the card's expiry, the acquirer that processed it, and the gateway's answer text;
- for a payment only authorized, the date it must be captured by, after which the authorization is cancelled;
- the mode, and a link to the transaction in your WorthyPayments dashboard.
The transaction id next to Payment via links there too. A test-mode order opens in the dashboard's test mode only: switch the dashboard to test mode first. Our API reports no fee or net amount per charge, so the order screen shows neither.
A WorthyPayments activity box on the order (and on a subscription) lists that order's rows of the payment activity log, newest first.
Payment activity#
WooCommerce → WorthyPayments activity (shop managers) records every money step the plugin takes or learns about, from version 2.7.0 on:
| Row | When |
|---|---|
| Charge sent (saved card) | A renewal or a saved-card payment was sent to our API: the amount, the idempotency key, whether the key is new or the resend of an earlier request (a resend cannot charge twice), and the answer (succeeded, requires_action, a decline code, or no_answer). |
| Charge not sent | The plugin did not send a charge: the order was already paid (already_paid), or another request was charging it at that moment (order_busy). |
| Duplicate renewal stopped | A second scheduled payment action ran for a subscription whose next payment is not due (see Renewals). |
| Payment taken, authorized, pending, declined, voided or expired | The outcome the checkout, the buyer's return, a webhook or a scheduled re-check applied to the order. A charge is listed once, whichever of them reported it first. |
| Amount differs from the order total | The charge's amount is not the order's; the order went on hold. |
| Payment captured, Capture failed, Authorization voided, Void failed | Capture and void, with the error and request id of a failure. |
| Refunded, Refund not made, Refunded outside WooCommerce | A refund from WooCommerce (its re_… id), one our API refused or never answered, and one made in your dashboard or the gateway portal that the plugin recorded. |
| Dispute opened, won, lost, closed or moved off this order | Disputes as read again on each delivery (and daily); a loss says whether it took the whole payment (the order marked Failed) or part of it, and a win after a loss that the order is back to its status from before the dispute. No refund is recorded or removed. |
| Webhook received, Webhook rejected, Webhook not applied | Each delivery applied to an order (its event id and type; "from history, no e-mails" for one more than a day old), deliveries refused for their signature or clock (at most one row a minute) or signed in the other mode than their order, and deliveries our API refused the read they needed for (401, 403, 404), or that still failed after the retries. |
| Payment for a deleted order | A payment whose metadata names an order of this store that no longer exists; also listed in the double-charge watch. |
| Subscription moved, moved back, unpaid renewal handed over | wp worthypayments migrate-subscriptions and revert-subscriptions. |
Each row names the order and the subscription (a renewal or sign-up order names both), the source (checkout, renewal, buyer's return, webhook, scheduled re-check, store admin, WP-CLI), the mode, the charge (linked to your dashboard), and the user who did it when a person did.
At the top of the screen, per mode and for the last 24 hours, 7 or 30 days:
- Payments taken (count, and the amount per currency), Authorized, not captured, Saved-card charges sent, Declined, Refunded, Disputes opened, Disputes lost, Charges stopped, and Needs a look (failed captures, voids and refunds, amount mismatches, rejected webhooks). Refreshed every five minutes.
- The last webhook received in that mode. If none ever arrived, refunds and disputes made in your dashboard do not reach WooCommerce: see Webhooks.
- Double-charge watch: an order whose money was taken by more than one charge, a subscription paid by two orders less than 12 hours apart, and money taken for an order that was deleted. Check each one in your dashboard and refund what was taken twice (or for nothing).
Below, every row, filtered by event, source, mode, order or subscription number, and dates (the site's time zone), 50 a page. Export these rows (CSV) downloads what the filters match. Times in the export are given in UTC and in the site's time zone. A payment row names the card (and the wallet) and the gateway's own transaction id, which your gateway portal searches by; a webhook row names the charge it was about. Details are shown in your site's language.
The watch lists a subscription only when its two payments paid renewal orders made less than 12 hours apart, so a renewal paid late by a retry followed by the next period's on time is not a double charge.
Rows are kept for Payment activity kept for days (Settings, default 365, at least 30); a
daily job removes older ones. Order notes and WooCommerce's own records are never removed.
Deleting the plugin keeps the log unless WC_REMOVE_ALL_DATA is set in wp-config.php.
Running beside another gateway#
The plugin only ever touches orders paid with it. You can keep your current gateway plugin active while you move over: each method appears in the payment list, and each handles its own orders, refunds and webhooks. The plugin stores the gateway's own transaction id on each order so a sale can be matched in both systems.
Troubleshooting#
- The method does not appear at checkout. Both keys of the active mode are required, and their prefixes must match the mode.
- The card form is blank, or says the form could not load. The page's hostname is not in the publishable key's allowed domains, or your Content-Security-Policy blocks the frame. See Content-Security-Policy.
- A saved card asks for the card again. It was saved before 2026-10-18 or imported from your NMI vault, so there is no encrypted copy to authenticate it with, and your 3-D Secure rules asked for it. Once the buyer enters it, the next 3-D Secure on that card happens in place.
- Webhooks answer 400. The signing secret does not match the endpoint's mode, or this server's clock is off (the admin notice says which). Use WooCommerce → Status → Tools → Set up the WorthyPayments webhook again, or tick Remove the saved value under the signing secret and save with Set up the webhook for me on: the plugin rolls the endpoint's secret and saves the new one.
- A notice says payments were taken but no webhook arrived. Check the endpoint's failed
deliveries in Dashboard → Developers → Webhooks and that it is enabled. Until it works, the
hourly event pull (
events:read) and the daily reconciliation keep orders up to date. - "Too many payment attempts." The buyer reached Payment attempts per hour. It clears an hour after their oldest counted attempt; raise the limit if your buyers share an IP address.
- An order stays On hold. The outcome was
processingand the webhook has not arrived: check the endpoint's delivery log in your dashboard. The plugin reads the intent again itself for 12 hours (see When the buyer does not come back). An order on hold for a dispute stays there until the dispute is decided, closed or moved to another sale (checked daily as well). - A subscription renews manually. No card was saved with its first order (the note on the subscription names why). The customer's next renewal paid on the site, or a new card under Change payment in their account, switches it back.
- Save this card does not appear. The buyer is not signed in, the cart starts a
subscription (its card is always saved, so there is nothing to tick), or the plugin could not
create their customer (check the log; the secret key needs
customers:write). - "The payment form is still loading." Place order was pressed before the card form had loaded; nothing was submitted. If it never loads, see the blank card form above.
- The express Apple Pay / Google Pay buttons do not appear. Apple Pay and Google Pay are off for your account in this mode; the buyer's device or browser has neither (Apple Pay needs Safari or a supported browser with a card in Wallet, Google Pay a Chromium browser); the buyer is a guest and the store allows neither guest checkout nor accounts created with a generated password; or there is nothing to pay now. Apple Pay alone missing: the domain is not registered or does not serve the verification file (see Apple Pay on your domain), or the cart starts a subscription.
- "Tax could not be calculated for this order." Tax rates is set to WorthyPayments and the
rates could not be read: check the log, the
tax:readscope, and that your rates'inclusiveflag matches WooCommerce's "Prices entered with tax". - The migration reports
no_vault_idfor every subscription. Your NMI plugin keeps the vault id somewhere else: try--source=subscription-metaor--source=user-metawith its meta key, in a dry run. - What did the plugin charge, and when? WooCommerce → WorthyPayments activity (see Payment activity), or the activity box on the order.
- Logs. Turn on Debug log, then WooCommerce → Status → Logs, source
worthypayments. Keys, client secrets, e-mails, addresses and anything shaped like a card number are never logged. A refused webhook is logged at most once a minute. - Support asks for the system status. WooCommerce → Status → Get system report: its WorthyPayments section holds what they need, and no key.
Privacy#
Tools → Export Personal Data includes "WorthyPayments payments": for each order and subscription of the e-mail address paid through the plugin, the payment's id, the card's brand and last four, the mode, and the WorthyPayments customer and saved-card ids, plus the customer ids kept on the buyer's account.
Tools → Erase Personal Data removes those customer ids and an unfinished card set-up from the buyer's account. Orders and subscriptions follow WooCommerce's own setting (Settings → Accounts & Privacy → Remove personal data from orders on request): when it is on, the card's brand and last four, the gateway's answer, the charge request, client secrets and the customer and saved-card ids are removed from them as well, except the card a subscription that still renews is charged with. The payment's id and its refunds stay, as the record of a payment. The buyer's customer record and saved cards at WorthyPayments are not erased from WordPress: delete the customer in your dashboard.
A saved-card or renewal charge request is kept on its order whole only until it is answered;
then only its amount, currency and card id stay (plugin 2.12.0 and later). Requests answered
under an earlier version are cut down the same way once, in the background, after the update:
an Action Scheduler job (worthypayments_strip_settled_attempts) reads your orders 100 at a
time and changes only that order meta (no order is saved, moved or deleted). A request still
waiting for its answer is kept whole, since it may be sent again.
The plugin suggests a paragraph for your privacy policy (Settings → Privacy), and keeps its
order meta (_worthypayments_…) out of WooCommerce's REST API: the payment's id is there as the
order's transaction_id.
PCI and card data#
A new card is entered in our iframe and encrypted by Evervault before it leaves the browser.
WordPress and your server only handle intent ids, charge ids, the client secret of the
payment, saved-card ids (pm_…), and the card's brand, last four digits and expiry. A saved
card is charged by its pm_… id; the card number stays in our vault.
One part runs on your page, not in our frame: when your 3-D Secure rules challenge a saved
card, the challenge is opened on the checkout (or pay-for-order) page by the plugin's
three-ds-challenge.js, which loads Evervault's browser SDK (https://js.evervault.com/v2)
into that page. No card number passes through it (the card is already in our vault); it shows
the issuer's challenge.
Your store can stay on the PCI SAQ A path, which under PCI DSS v4.0.1 still asks something
of the pages that embed the payment form: you must manage the scripts those pages load
(requirement 6.4.3: an inventory of each script, why it is needed, and how its integrity is
assured) and detect unauthorised changes to them and to the page's security headers
(requirement 11.6.1). The scripts this plugin adds are worthypayments.umd.js (our SDK,
version-pinned), the plugin's own scripts under /wp-content/plugins/woocommerce-worthypayments/assets/,
and, only on a saved card's challenge, js.evervault.com/v2. A Content-Security-Policy that
lists them (see Content-Security-Policy) is the
usual way to meet both. Confirm your SAQ with your acquirer or QSA.
