WooCommerce

View as Markdown

The 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 3-D Secure runs inside the same frame. No card data reaches WordPress or your server, so your store stays on PCI SAQ A.

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#

  1. Download woocommerce-worthypayments.zip from your dashboard (/plugins/woocommerce-worthypayments.zip). It is not on the WordPress.org directory; if you cannot find it, contact support.
  2. WordPress admin → Plugins → Add New → Upload Plugin → choose the zip → Install Now → Activate.
  3. WooCommerce → Settings → Payments → WorthyPayments.

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 and its scheduled jobs, nothing else. 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.


Configure#

SettingWhat to enter
EnableTurn the method on.
Title / DescriptionWhat the buyer sees in the payment list.
Test modeOn: the test keys are used. Off: the live keys.
Test / Live publishable keypk_test_… / pk_live_…. Loaded into the checkout page.
Test / Live secret keysk_test_… / sk_live_…. Stays on your server.
Test / Live webhook signing secretFilled in for you by Webhook set-up, or from the endpoint you register (see Webhooks).
Webhook set-upSet 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 hourCard-testing protection, 10 by default, 0 for off. See Card testing.
CaptureAutomatic (default) or Manual. See Capture.
Express checkoutOff by default: Apple Pay and Google Pay buttons on product, cart and checkout pages. See Express checkout.
Tax ratesWooCommerce's own tax rates (default) or My WorthyPayments tax rates. See Tax rates from WorthyPayments.
Payment form lookMatch my theme (default) or WorthyPayments default. See Appearance.
Accent colour / Corner radiusOptional. Used instead of what the form takes from your theme.
Debug logWrites API calls to WooCommerce → Status → Logs, source worthypayments.

Both keys of the active mode must be filled in, or the method does not appear at checkout. 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, or that the endpoint for this site does not send an event the plugin uses;
  • while Apple Pay is on for your account, whether this site serves Apple's domain verification file (see Apple Pay on your domain).

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, every admin screen shows 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. See Allow your domain.

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) and customer_sessions:create (saved cards), 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.

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.


How a payment works#

  1. 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.
  2. 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, and metadata order_id / order_number, which the charge and its webhooks carry too).
  3. 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.
  4. 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 saysOrder status
CapturedProcessing (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
DeclinedStays 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.

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 checkoutBecomes
A billing field's background and text colourcolorBackground, colorText
Its label colourcolorTextSecondary
Its border colour (when it has a border), corner radius, font and font sizecolorBorder, borderRadius, fontFamily, fontSizeBase
The Place order button's colourscolorPrimary, 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-p3 or oklch());
  • 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_appearance receives the settings as ['variables' => […]] and may return theme, variables and rules. 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.hooks filter worthypayments.elementAppearance gets the final appearance and has the last word.
php
add_filter( 'worthypayments_element_appearance', function ( $appearance ) {
	$appearance['variables']['colorPrimary'] = '#0f766e';
	$appearance['rules']['.Input']           = array( 'boxShadow' => 'none' );
	return $appearance;
} );
js
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:

  1. The sheet opens with the total WooCommerce computed for the cart (on a product page, for that product alone).
  2. 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.
  3. 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).
  4. 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:

  1. 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.
  2. Have the domain registered for your account: in test mode, add it in Dashboard → Settings → Checkout Settings → 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 and keeps the cus_… id on the WordPress user (one per mode). The intent names that customer, so the card is saved to it.
  • 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 our pm_… id as the token. A save that is still pending is 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 your worthypayments_three_ds_request filter. 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 declined soft_decline_3ds with 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 from worthypayments.js (getFingerprint(), getKountSessionId()) with the choice, and the plugin passes them on as the charge's device for 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's device_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}).
  • 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.
  • 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 (a wallet's device token cannot be saved and renewed); 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:

http
POST /v1/charges
Idempotency-Key: <a UUIDv4 stored on the renewal order>
json
{
  "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" },
  "billing_address": { "line1": "510 Townsend St", "postal_code": "94103", "country": "US" },
  "metadata": { "order_id": "5001", "order_number": "5001", "subscription_id": "4990" }
}

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) and order_number (what the buyer and the admin see), plus subscription_id on a renewal. 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 your worthypayments_three_ds_request filter, 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).
  • 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. Retries of the same card are declined the same way.
What the API answersRenewal order
CapturedProcessing / Completed; the subscription continues
Authorized only (a Capture node on your route)On hold, with Capture / Void actions
402 soft_decline_3dsFailed, note; the customer gets the Renewal payment needs confirmation e-mail with the payment link
Another 402 decline, or another refusalFailed, 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 answerOn 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. An admin can edit the subscription's WorthyPayments card (pm_…) and WorthyPayments customer (cus_…) on the subscription screen; the plugin checks with WorthyPayments that the card belongs to that customer before saving.

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:

json
{"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:

bash
wp worthypayments migrate-subscriptions --mapping=/root/vault-mapping.jsonl --dry-run

Then a first small run, then the rest. Every change is appended to the log you name:

bash
wp worthypayments migrate-subscriptions --mapping=/root/vault-mapping.jsonl --log=/root/migration.jsonl --limit=10
OptionMeaning
--mapping=<file>The mapping file. A file missing a column or with a malformed row is refused whole, with the line number.
--dry-runReport only; nothing is written.
--log=<file>Required without --dry-run. JSON lines: each subscription's state before the move, then the result. revert-subscriptions reads it.
--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|liveWhich keys the cards belong to. Default: the store's active mode.
--batch=<n>Subscriptions read per page (default 100).
--limit=<n>Stop after moving (or, in a dry run, finding) this many.

The command reads the active and on-hold subscriptions paid with --gateway-from, and for each one:

  1. reads its NMI vault id and looks it up in the mapping;
  2. 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;
  3. outside a dry run: 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 the pm_…, and adds a note naming the vault id's last four characters and the card. A subscription set to manual renewal stays manual.

One line per subscription, then totals:

OutcomeMeaning
would_migrate / migratedMoved (or would be, in a dry run).
already_oursMoved by an earlier run. Re-running is safe.
no_vault_idNo vault id where --source looks: check the source with a dry run.
no_mappingThe 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.
ambiguousThe mapping lists the vault id with two different cards. Ask support.
customer_conflictThe user already has a different WorthyPayments customer.
email_mismatchThe mapped customer's e-mail is not the buyer's. Nothing is attached to a card that may belong to someone else.
card_not_customersThe mapped card is not that customer's card.
renewal_in_progressSubscriptions is charging its renewal right now. Run again later.
errorOur API could not be read, or WooCommerce refused a write. The note 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), and list the mapping rows no subscription used. Run in a quiet window, or pause Action Scheduler, so that no NMI renewal runs during the move.

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:

bash
wp worthypayments revert-subscriptions --log=/root/migration.jsonl --dry-run
wp worthypayments revert-subscriptions --log=/root/migration.jsonl

A subscription is put back only while it still renews with the card the migration gave it (a card change since then wins), and only when the old gateway is active. The saved card stays in the buyer's account. Disable the NMI plugin once no active subscription uses it, and keep it installed for one full billing cycle.


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):

php
add_filter( 'worthypayments_three_ds_request', function ( $request, WC_Order $order ) {
    if ( $order->get_total() >= 500 ) {
        return 'challenge';
    }
    return $request; // 'automatic'
}, 10, 2 );
ValueMeaning
automaticOur rules decide (default). Nothing extra is sent.
anyAuthenticate, no challenge preference.
challengeAuthenticate, ask the issuer for a challenge.
challenge_mandatedAuthenticate, 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:read scope 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 inclusive flag 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:

json
"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):

http
POST /wc/store/v1/checkout
Cart-Token: eyJ0eXAiOiJKV1Qi…
Content-Type: application/json
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:

json
{
  "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:

json
"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" }
]
KeyMeaning
worthypayments_intent_typepayment_intent (something to pay) or setup_intent (a $0 subscription sign-up: save the card, charge nothing)
worthypayments_payment_intent / worthypayments_setup_intentThe intent's id (pi_… / seti_…), the one this order owns
worthypayments_client_secretWhat the Element mounts with (step 2)
worthypayments_return_urlWhere the browser goes once the Element is done (step 3); also redirect_url
worthypayments_save_cardauto: 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_usageoff_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_subscriptionyes 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.

js
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:

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",
  "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:

http
POST /wc/store/v1/checkout/1042
json
{
  "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):

json
{
  "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"
  }
}
ResultAnswer
Paid, authorized, or still processing; a setup intent that saved the card200, 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 mismatch400 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:

json
{ "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:

json
"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:

json
{ "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:

json
"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 Settings → 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.funds_withdrawn, dispute.funds_reinstated, pre_dispute_alert.created, pre_dispute_alert.updated, setup_intent.succeeded and setup_intent.setup_failed. Every save checks the endpoint registered for this site and names any of them it does not send.

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 older than 5 minutes is refused) and ignores a repeated X-Webhook-Id. A webhook only tells the plugin to look: the order changes after it re-reads the payment with your secret key.

EventWhat the plugin does
charge.succeeded, charge.authorized, charge.failedRe-reads the order's intent and charge (for a renewal or a saved-card payment, the charge) and moves the order
charge.canceled, charge.expiredAn authorized order becomes Cancelled
charge.refundedA refund made in your WorthyPayments dashboard is recorded on the order
setup_intent.succeeded, setup_intent.setup_failedFor 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.created, dispute.won, dispute.lostA chargeback puts the order On hold, a dispute won restores its status, a dispute lost marks it Failed (see Disputes)
Other dispute.*, pre_dispute_alert.*An order note; manage the dispute in your dashboard

Deliveries for orders the plugin did not take (another gateway's, another store's) are answered 200 and ignored.


Refunds#

Refund from the order screen as usual (Refund → Refund via WorthyPayments), in full or in part. Each refund calls POST /v1/refunds with an idempotency key derived from the order, so retrying a refund that timed out does not refund twice. An authorized, uncaptured payment cannot be refunded: void it.


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.

With the plugin's Capture setting on Manual, the plugin also captures when you move the order to Processing or Completed, and voids when you cancel it. Set the route's Capture node to manual for this to apply.


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), and the plugin counts it three ways: by the order's IP address, by the buyer's WooCommerce session and by the billing e-mail. When any of the three 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.

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:

DeliveryOrder
dispute.created for a chargeback on a Processing or Completed orderOn hold, with a note: amount, reason, respond-by date. The status it had is kept on the order
dispute.wonBack to the status it had before the hold, with a note
dispute.lostFailed, with a note (the disputed amount was taken back, so WooCommerce's reports stop counting the order)
dispute.created for a pre-dispute alert (Ethoca, CDRN, RDR), pre_dispute_alert.*, dispute.updated, dispute.funds_withdrawn, dispute.funds_reinstatedA note only
dispute.closedNothing (it repeats the dispute.won / dispute.lost it comes with)

The order keeps each dispute's state by its disputeId, so one decision delivered twice moves the order once. The order is found through the sale the dispute names, by its gateway transaction id (dispute.nmiTransactionId, best-effort, see Dispute payloads); a dispute that names none yet changes no order.

A status set by a dispute sends the customer none of WooCommerce's order e-mails (processing, completed, on hold, failed): a dispute won is not a new "your order is being processed". The e-mails WooCommerce sends to your store are kept.

A Failed order is what WooCommerce Subscriptions treats as a failed payment: a subscription whose order is marked failed is put on hold, and Subscriptions may e-mail the customer to pay that order.


The order screen#

An order paid with the plugin shows, under its general details, the card it was paid with (brand and last four, the buyer's own card for Apple Pay / Google Pay), 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.


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. Empty the field and save with Set up the webhook for me on: the plugin rolls the endpoint's secret and saves the new one.
  • "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 processing and 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.
  • 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:read scope, and that your rates' inclusive flag matches WooCommerce's "Prices entered with tax".
  • The migration reports no_vault_id for every subscription. Your NMI plugin keeps the vault id somewhere else: try --source=subscription-meta or --source=user-meta with its meta key, in a dry run.
  • Logs. Turn on Debug log, then WooCommerce → Status → Logs, source worthypayments. Keys, client secrets, e-mails and addresses are never logged.

PCI and card data#

The 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. Your store stays on the PCI SAQ A path.