# WooCommerce

The **WorthyPayments for WooCommerce** plugin (v2) takes card, Apple Pay and Google Pay
payments in your WooCommerce store through the [Payment Element](./08b-payment-element.md).
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](#woocommerce-subscriptions) renewals charge
the saved card: a subscription always saves the card it starts with, a $0 free trial included.
Optionally: [Express checkout](#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](#tax-rates-from-worthypayments), and a WP-CLI
command that [moves your NMI plugin's subscriptions](#moving-subscriptions-from-your-nmi-plugin)
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

| Setting | What to enter |
|---|---|
| **Enable** | Turn the method on. |
| **Title / Description** | What the buyer sees in the payment list. |
| **Test mode** | On: the test keys are used. Off: the live keys. |
| **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. |
| **Test / Live webhook signing secret** | Filled in for you by **Webhook set-up**, or from the endpoint you register (see [Webhooks](#webhooks)). |
| **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](#webhooks). |
| **Payment attempts per hour** | Card-testing protection, `10` by default, `0` for off. See [Card testing](#card-testing). |
| **Capture** | `Automatic` (default) or `Manual`. See [Capture](#capture). |
| **Express checkout** | Off by default: Apple Pay and Google Pay buttons on product, cart and checkout pages. See [Express checkout](#express-checkout). |
| **Tax rates** | `WooCommerce's own tax rates` (default) or `My WorthyPayments tax rates`. See [Tax rates from WorthyPayments](#tax-rates-from-worthypayments). |
| **Payment form look** | `Match my theme` (default) or `WorthyPayments default`. See [Appearance](#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`. |

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`](./11-health.md) 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](#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](./08b-payment-element.md#before-anything-renders-allowlist-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](./02-authentication.md).

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](#express-checkout) on, the wallet buttons are at the top of the page
   instead). It is mounted before any order exists
   ([deferred intent](./08b-payment-element.md), `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](./08b-payment-element.md#1-create-the-intent-your-server) 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](./08b-payment-element.md#saving-a-card-without-a-payment-setup-intents)): 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.

**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](./08-embedded-sdk.md#appearance):

| 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-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](./08-embedded-sdk.md#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()`](./08-embedded-sdk.md#mountexpresscheckouttarget-opts)). 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](#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](./18-customers.md) 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`](./03-charges.md#charge-a-saved-card), `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](./07-3ds.md#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](./24-device-data-collection.md#device-session-the-buyers-ip-and-browser)).
  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](./08b-payment-element.md#saving-a-card-without-a-payment-setup-intents);
  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](#express-checkout). Guests cannot save cards.

---

## WooCommerce Subscriptions

The plugin supports [WooCommerce Subscriptions](https://woocommerce.com/products/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"`](./08b-payment-element.md#always-saving-the-card-subscriptions)
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](#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](./08b-payment-element.md#saving-a-card-without-a-payment-setup-intents)
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](./12-idempotency.md)).
- **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 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. 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
```

| 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; 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\|live` | Which 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:

| Outcome | Meaning |
|---|---|
| `would_migrate` / `migrated` | Moved (or would be, in a dry run). |
| `already_ours` | Moved by an earlier run. Re-running is safe. |
| `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. |
| `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. Run again later. |
| `error` | Our 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](./07-3ds.md#when-does-a-step-up-happen)).

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 );
```

| 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](#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](./08-embedded-sdk.md#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](#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" }
]
```

| 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.

```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](./08b-payment-element.md#before-anything-renders-allowlist-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"
  }
}
```

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

```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](./07-3ds.md#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`](./16-webhooks.md) 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](./16-webhooks.md#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.

| 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** |
| `charge.refunded` | A refund made in your WorthyPayments dashboard is recorded on the order |
| `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.created`, `dispute.won`, `dispute.lost` | A chargeback puts the order **On hold**, a dispute won restores its status, a dispute lost marks it **Failed** (see [Disputes](#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`](./06-refunds.md) 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](./17-auth-capture.md)). 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:

| Delivery | Order |
|---|---|
| `dispute.created` for a chargeback on a **Processing** or **Completed** order | **On hold**, with a note: amount, reason, respond-by date. The status it had is kept on the order |
| `dispute.won` | Back to the status it had before the hold, with a note |
| `dispute.lost` | **Failed**, 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_reinstated` | A note only |
| `dispute.closed` | Nothing (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](./16-webhooks.md#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](./08-embedded-sdk.md#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](#how-a-payment-works)). 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](#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.
