# 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 a new card's
3-D Secure runs inside the same frame. **No card data reaches WordPress or your server**, so
your store can stay on PCI SAQ A (see [PCI and card data](#pci-and-card-data) for what that
asks of your checkout page's scripts).

It works on the classic checkout, the Checkout block, the pay-for-order page, and for
headless clients that check out through the WooCommerce Store API (a mobile app, or a
dashboard of your own). Signed-in buyers can save cards, at checkout or from **My account →
Payment methods**, and [WooCommerce Subscriptions](#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 the plugin from your dashboard: `/plugins/woocommerce-worthypayments-2.13.0.zip`
   (each version has its own file; [`/plugins/woocommerce-worthypayments.json`](#updates) names
   the current one). 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 payment method shows at checkout only when the store's currency (on a pay-for-order page,
the order's) is one WorthyPayments settles in the mode the payment is made in. The plugin reads
the list (`settleable_currencies` of [`GET /v1/ping`](./11-health.md)) with each mode's secret
key when you save the settings, keeps it for a week and reads it again in the background; until
it has been read once, nothing is hidden. When the store's currency is not on the active mode's
list, the settings page says so.

Later versions arrive through WordPress's own update screens: see [Updates](#updates).

The v1 plugin (`woocommerce-tailoredpay`) is a different plugin folder. Deactivate and delete
it once v2 is configured; the two do not share settings.

**Deleting the plugin** (Plugins → Delete) removes its settings, its caches, its scheduled
jobs and webhook events still waiting, and from your users an unfinished card set-up and their
WorthyPayments customer id (found again by e-mail if you reinstall), on every site of a
network. Orders and their payment details, subscriptions and the buyers' saved cards stay, so
payments remain refundable and disputes answerable if you install it again.
The webhook endpoint registered for this site stays at WorthyPayments too: remove it in
Dashboard → Developers → Webhooks if the store will not come back.

⚠️ **Deactivating or deleting the plugin while subscriptions renew through it** switches them
to manual renewal (WooCommerce Subscriptions has no gateway to charge them with): their buyers
are e-mailed to pay each renewal instead of being charged. Reactivating the plugin brings
automatic renewal back.

---

## Updates

From version 2.9.0 the plugin updates like a plugin from WordPress.org, from your
WorthyPayments dashboard:

- **Plugins** and **Dashboard → Updates** show "There is a new version of WorthyPayments for
  WooCommerce available" when one is published. **View version … details** shows its
  changelog; **Update now** installs it.
- **Enable auto-updates** on the plugin's row lets WordPress install new versions itself. On
  WordPress 6.6 and later, an automatic update that breaks the site on load is rolled back to
  the version before.
- WordPress checks for a new version twice a day; **Dashboard → Updates → Check again** checks
  now. The plugin reads `https://app.paygeyt.com/plugins/woocommerce-worthypayments.json`
  (the plugin's `Update URI`, so WordPress never confuses it with a WordPress.org plugin of
  the same name). If that address cannot be reached, no update is offered and nothing else
  changes; after 72 hours of that, Plugins and Dashboard → Updates say so.
- **Every release is signed by WorthyPayments.** The plugin carries WorthyPayments' public
  keys and trusts a release only when its version, checksum and download address are signed
  with one of them (Ed25519). The private keys are kept offline, never on the server that
  serves the files, so a change to what that server serves cannot reach your store: an
  unsigned or wrongly signed release is not offered, and a version older than the one you run
  is never installed.
- The update is installed only if the downloaded zip has the signed SHA-256 checksum, comes
  from `https://app.paygeyt.com/plugins/woocommerce-worthypayments-<version>.zip`, and holds
  the signed version. Otherwise the update stops with a message saying which check failed, and
  the installed version keeps running: try again later. The download stops past 20 MB (a
  release is far smaller), so a server sending more cannot fill your disk.
- The plugin installs its own updates: WordPress is given a placeholder
  (`worthypayments-update:<version>`) that only the plugin can turn into the file. An update
  run without the plugin's code (`wp plugin update --skip-plugins`) fails and changes nothing.
  **On a multisite network, network-activate the plugin**: an update started from the network
  admin needs the plugin's code there.

**Updating from 2.8.0 or earlier**: those versions cannot see updates. Install the current
version once by hand: download its zip (see [Install](#install)) and use **Plugins → Add New →
Upload Plugin**, which offers **Replace current with uploaded**. Settings, orders, saved cards
and subscriptions are kept.

**Renewals during an update.** WordPress replaces the plugin's files while the store keeps
running, so a renewal charged at that moment can be cut off. The first admin or scheduled
request on the new version finishes what was cut off:

- a charge that was sent but whose answer was never recorded is read back with the same
  idempotency key, so it is recorded once and never sent twice;
- a renewal order that Subscriptions' schedule created while the plugin was being replaced, and
  that was never charged, is charged then, with a note on the order. A pending renewal order a
  shop manager made by hand (**Create pending renewal order**) is never charged this way.

Each step is recorded in [Payment activity](#payment-activity).

---

## Configure

The settings come in sections, in the order you fill them in: **General**, **Test keys**,
**Live keys**, **Webhooks and protection**, **Checkout**, **Advanced**. Each key section says
where its keys come from and which scopes the secret key needs.

| Setting | What to enter |
|---|---|
| **Enable** | Turn the method on. |
| **Title / Description** | What the buyer sees in the payment list. Apple Pay and Google Pay appear in the payment form by themselves when your account has them on. |
| **Test mode** | On: the test keys are used. Off: the live keys. Test payments need your own NMI sandbox key: see [Test, then go live](#test-then-go-live). |
| **Test / Live publishable key** | `pk_test_…` / `pk_live_…`. Loaded into the checkout page. |
| **Test / Live secret key** | `sk_test_…` / `sk_live_…`. Stays on your server, and is never shown again once saved: the field stays empty and says a key is saved (its last four characters). Leave it empty to keep the saved key; tick **Remove the saved value** to clear it. A key that does not start with its field's prefix (a publishable key in a secret field, a live key in a test field) is not saved: the key you had stays, and the field says why. |
| **Test / Live webhook signing secret** | Filled in for you by **Webhook set-up**, or from the endpoint you register (see [Webhooks](#webhooks)). Write-only like the secret key. |
| **Webhook set-up** | **Set up the webhook for me**, on by default: saving with a mode's signing secret empty registers this site's endpoint in that mode and saves its secret. See [Webhooks](#webhooks). |
| **Payment attempts per hour** | Card-testing protection, `10` by default, `0` for off. See [Card testing](#card-testing). |
| **Buyer IP address from** | The connection (default), or a header your proxy or CDN sets on every request: `CF-Connecting-IP`, `True-Client-IP`, `X-Real-IP` or `X-Forwarded-For`. Pick a header only when the site is really behind that proxy: anyone can send these headers to a site that is not. The `WORTHYPAYMENTS_TRUSTED_IP_HEADER` constant in `wp-config.php` overrides it. |
| **Express checkout** | Off by default: Apple Pay and Google Pay buttons on product, cart and checkout pages. See [Express checkout](#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`. |
| **Payment activity kept for** | Days a row of the [payment activity log](#payment-activity) is kept: 365 by default, at least 30. |

Both keys of the active mode must be filled in, or the method does not appear at checkout;
while the method is enabled without them, every admin screen says so and names the mode.
The plugin checks the prefixes when you save, then checks every key that is filled in with
[`GET /v1/ping`](./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; with it off, that no
  signing secret is saved or no endpoint sends to this site (deliveries would all be refused
  or never come); and either way, that the endpoint for this site does not send an event the
  plugin uses, is disabled at WorthyPayments, or sends to an address this site no longer
  answers on (after a permalink change: a `/wp-json/…` endpoint needs pretty permalinks);
- while Apple Pay is on for your account, whether this site serves Apple's domain
  verification file (see [Apple Pay on your domain](#apple-pay-on-your-domain)).

What passed (a key works, the webhook is set up) is shown too, even when something else on
the screen needs fixing. A key that cannot be checked right now (our API unreachable) is saved
anyway, and the screen says so.

While the method is on in test mode, the Dashboard, the Plugins screen and WooCommerce's own
screens show a **WorthyPayments is in test mode** notice to shop managers, with a link to turn
it off.

**Allowed domains.** The publishable key's allowed domains must list every hostname your
checkout runs on, or the payment form stays blank. Each entry is a hostname, `localhost`, an
IP address, or `*.` plus a name (`*.example.com`). A Unicode name is stored as punycode, a
key holds at most 50 entries, and an entry that can never match an origin is refused when
you save. See
[Allow your domain](./08b-payment-element.md#before-anything-renders-allowlist-your-domain).

**Hosts.** From 2.13.0 the plugin loads the SDK from `https://app.worthypayments.com/sdk/v1/`
and calls `https://api.worthypayments.com/v1`; the card frame is
`https://element.worthypayments.com`. If your site sets a Content-Security-Policy, allow those
three ([Content-Security-Policy](./08-embedded-sdk.md#content-security-policy)). To stay on
paygeyt.com, the same API and SDK ([Two domains](./README.md#two-domains)), put both lines in
`wp-config.php`:

```php
define( 'WORTHYPAYMENTS_SDK_URL', 'https://app.paygeyt.com/sdk/v1/worthypayments.umd.js' );
define( 'WORTHYPAYMENTS_API_BASE', 'https://api.paygeyt.com/v1' );
```

Updates still come from `app.paygeyt.com` (see [Updates](#updates)).

**Secret key scopes.** `payment_intents:create`, `payments:read`, `payments:charge`
(saved-card payments, renewals, capture and void), `refunds:create`, `customers:read`,
`customers:write` (customers, and the setup intents that save a card with no payment),
`customer_sessions:create` (saved cards), `disputes:read` (a dispute is read again before its
order changes) and `events:read` (webhooks that never arrived are recovered), and `tax:read`
when **Tax rates** is set to WorthyPayments. For **Set up the webhook for me**, also
`webhooks:read` and `webhooks:write`. See [Authentication](./02-authentication.md). A key
without `disputes:read` leaves dispute deliveries as order notes (the order is not put on hold
on the delivery's word alone), and the admin notices name the missing scope.

An order keeps the mode it was paid in: refunds and captures of a test order always use the
test keys, even after you switch the store to live.

---

## Test, then go live

**Test once, end to end:**

1. **Your own NMI sandbox account.** Test payments run on it, never on a shared one. Create one
   at [guide.nmi.com](https://guide.nmi.com/) (**Create Sandbox Account**), copy its API
   security key, switch your WorthyPayments dashboard to test mode and paste the key under
   **Settings → Integrations**, on the NMI card. Without it every test payment is refused
   `sandbox_key_required`; the order's note says what to do. See
   [Your own NMI sandbox key](./15-testing.md#your-own-nmi-sandbox-key).
2. **Test keys.** Dashboard in test mode → **Developers → API keys**: a publishable key whose
   allowed domains list this site, and a secret key with the scopes above. Paste them, keep
   **Test mode** on and **Set up the webhook for me** on, and save: the screen confirms each
   key and the webhook.
3. **A test order.** Pay with NMI's test cards, not Stripe's: `4111 1111 1111 1111` (Visa),
   any future expiry, CVV `123`. The full list, and the 3-D Secure cards, are in
   [Test cards](./15-testing.md#test-cards-verified-2026-09-06-against-nmis-sandbox). The order
   goes to **Processing**; **WooCommerce → WorthyPayments activity** lists the payment.
4. **A refund** from the order screen, and, with WooCommerce Subscriptions, a sign-up and one
   renewal: **WooCommerce → Status → Scheduled Actions**, find the subscription's
   `woocommerce_scheduled_subscription_payment` and **Run** it. The renewal order is charged
   with the saved card.

**Then go live:**

1. Dashboard in live mode → **Developers → API keys**: the same two keys, live. Paste them in
   **Live keys** and save once with **Set up the webhook for me** on, so the live webhook is
   registered.
2. Apple Pay on a live domain is registered by WorthyPayments support (see
   [Apple Pay on your domain](#apple-pay-on-your-domain)).
3. Turn **Test mode** off and save. Test orders keep their test keys for refunds. A test order
   still unpaid (no payment taken or held) is paid live if the buyer pays it now; a live order
   is never paid in test mode. Subscriptions signed up in test mode are not renewed live: see
   [WooCommerce Subscriptions](#woocommerce-subscriptions).
4. Place one small real order with your own card, refund it, and check both in **Payment
   activity** and in your dashboard. See also [Going live](./21-going-live.md).

---

## 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` / `wc_site`, a short hash of your store's address / `site_url`, plus
   `subscription_id` when the order starts a subscription, which the charge and its webhooks
   carry too). The intent also states what the order is: its products as `line_items`, the
   `shipping_address` when it ships, and the buyer's first and last name as `billing_details`
   (see [What a payment says about the order](#what-a-payment-says-about-the-order)).
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. A decline's note quotes
the gateway's own words (`last_payment_error.message`) when our API states them.

After five declined attempts our API closes the intent (`canceled`, `cancellation_reason:
failed_attempts`): the order is marked **Failed** (still payable) with the last decline in the
note, the buyer reads that the payment was declined five times and closed, and placing the
order again starts a new intent under a new idempotency key; a closed intent is never reused.
An intent the order no longer uses is closed (`POST /v1/payment_intents/{id}/cancel`): when
the order is cancelled (`abandoned`, so a payment page left open cannot pay it), and when the
order is paid another way, with a saved card or a new intent (`duplicate`). An intent that is
processing or has succeeded cannot be closed: that is a payment going through, so nothing else
is charged and the order follows that payment (a cancelled order it paid goes **On hold** for
review).

### What a payment says about the order

Every payment the plugin makes, through the Element, with a saved card or as a renewal, tells
our API what was bought and where it goes. These never change what is charged: the order's
total is the amount, and nothing else is compared with it.

| Field | What it holds |
|---|---|
| `line_items` | The order's products, up to 100: `name`, `amount` (the unit price in cents, tax included, after discounts: the line's total divided by its quantity), `quantity`, and the product's `sku` and first category when it has them. Shipping and fees are not lines. |
| `shipping_address` | Where the order ships (`name`, `phone`, `line1`, `line2`, `city`, `state`, `postal_code`, `country`), when it ships somewhere. |
| Buyer's name | First and last name apart: `billing_details` on an intent, `customer.first_name` / `last_name` on a charge. |

Fraud screening scores the payment on them (Kount judges the real basket and where the goods
go), and they are kept with the payment as evidence for a dispute: the transaction in your
dashboard shows the order (its number links to the order in your store's admin), its products
and the shipping address, and `GET /v1/charges/{id}` returns them. A product filed only under
WooCommerce's default category (Uncategorized) is sent with no category. Text is cut to our API's limits (a name or address field at 255 characters,
a first or last name at 100, a SKU or category at 100) before it is sent.

When the cart changes before the buyer pays, the plugin updates the open intent: a part the
order no longer has (the shipping address removed, say) is sent as `null`, which removes it.

**An API older than the plugin.** If our API answers that it does not know one of these fields
(`400 parameter_unknown` naming it), the plugin sends the same payment again without them, under
the same idempotency key for a charge (the refusal consumed nothing), and leaves them out of
every payment for the next 12 hours. A value our API refuses for one order only costs that
order's request its details: the next order sends them again.

**When the buyer does not come back.** A buyer may close the page right after 3-D Secure, or
lose the redirect back to your store. The webhook settles the order then. In case it is
missing or late, the plugin also reads the order's intent itself, with your secret key, 10
minutes after the buyer was given it, then after 30 minutes, 2 hours and 12 hours, and stops
as soon as the order is settled or the intent is paid or expired. A $0 sign-up's setup intent
is read the same way. The looks run on WooCommerce's Action Scheduler (**WooCommerce → Status
→ Scheduled Actions**, hook `worthypayments_reconcile_intent`).

**Hold stock.** When WooCommerce's **Hold stock** time runs out (**WooCommerce → Settings →
Products → Inventory**), WooCommerce cancels the unpaid orders. The plugin reads each of its
own orders' intent first: an order whose payment succeeded or is still settling is moved to
the right status instead, and never cancelled. If the intent cannot be read (our API
unreachable), the order is kept and asked about again at the next cleanup. An intent the buyer
never paid does not keep the order. A payment that still lands on an order already cancelled
moves it to **On hold** with a note asking you to fulfil or refund it.

---

## Appearance

The card form is the WorthyPayments Payment Element, a frame on our domain, so your theme's
CSS cannot reach into it. With **Payment form look** set to `Match my theme` (the default), the
plugin reads what your checkout already shows and passes it to the form as
[appearance variables](./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 → Payment methods → Digital Wallets**; in live mode, ask WorthyPayments
   support.

Google Pay needs no domain step. Both wallets need the site served over HTTPS.

---

## Saved cards

A signed-in buyer sees **Save this card for future payments** under the card form in the
Element. It is never pre-ticked: the buyer's tick is the consent, and the card is saved with
that payment. A cart that starts a subscription has no box: the card is always saved (see
[WooCommerce Subscriptions](#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 (looked up with
  `POST /v1/customers/search`, so the e-mail is never in a URL) and keeps the `cus_…` id on the
  WordPress user (one per mode). The intent names that customer, so the card is saved to it.
  A WordPress account's e-mail is not verified, so a customer another account of the site
  already holds is not adopted: that buyer pays without the offer to save the card. A
  customer deleted in your dashboard is forgotten, and its create key is never used again
  (`409 customer_deleted`); its e-mail stays reserved to it, so that buyer pays without the
  offer to save the card.
- **The card.** The plugin reads the save result from the intent with your secret key
  (`card_save`) and adds the card to the buyer's account as a WooCommerce saved card:
  brand, last four and expiry, with 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}`), except a live card deleted on a copy of
  your store (from plugin 2.8.0): when WooCommerce Subscriptions flags the site as a duplicate
  of the live one, or `wp_get_environment_type()` is not `production`, the card is removed
  from that copy only, so a staging clean-up never stops the real store's renewals.
- **Add payment method** (on that page) saves a new card with no payment. The Element is
  mounted in [setup mode](./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.
- **A card removed or re-issued at WorthyPayments** (`payment_method.detached`,
  `payment_method.updated`): a removed card leaves the buyer's account too (without calling our
  API again), each subscription that renews with it gets a note, and for a week an admin notice
  lists those subscriptions: their next renewal is declined until the customer changes the
  payment method. A re-issued card's brand, last four and expiry are updated on its saved card.
  Only cards of the event's mode (and customer) are touched.
- Cards are kept per mode: test cards are hidden while the store is in live mode.
- Wallet payments (Apple Pay, Google Pay) are not saved as cards, except a subscription paid
  with Google Pay through [Express checkout](#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 (we do not save a wallet's device token for renewals yet); 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", "first_name": "Jane", "last_name": "Doe", "phone": "+14155550142" },
  "billing_address": { "line1": "510 Townsend St", "city": "San Francisco", "state": "CA", "postal_code": "94103", "country": "US" },
  "shipping_address": { "name": "Jane Doe", "line1": "510 Townsend St", "city": "San Francisco", "state": "CA", "postal_code": "94103", "country": "US" },
  "line_items": [{ "name": "Coffee beans (monthly)", "amount": 2000, "quantity": 1, "sku": "BEANS-1KG", "category": "Coffee" }],
  "metadata": { "order_id": "5001", "order_number": "5001", "wc_site": "5c5a5c4cf85a", "site_url": "https://shop.example.com", "subscription_id": "4990" }
}
```

The buyer's first and last name are sent apart as well as together (our API would otherwise
split the full name on its last space, which cuts "Juan Carlos de la Cruz" wrongly), with the
order's shipping address when it ships somewhere and its products as `line_items` (see
[What a payment says about the order](#what-a-payment-says-about-the-order)). Empty fields are
left out.

Every payment the plugin makes carries the same `metadata` keys, so a charge and its webhooks
name the order the same way whichever way it was paid: `order_id` (WooCommerce's order id),
`order_number` (what the buyer and the admin see), `wc_site` (a short hash of the store's
address: two stores on one account have the same order ids) and `site_url`, plus
`subscription_id` on a renewal and on the order a subscription starts with. An intent's
metadata is copied onto its charge; a saved-card payment and a renewal send it on
`POST /v1/charges`. Before 2.4.0, saved-card payments and renewals carried `wc_order_id`,
`wc_subscription_id` and `wc_site` instead, and charges made then keep those keys: read
`order_id` and fall back to `wc_order_id`.

- **Always 2D.** A renewal is merchant-initiated: it never sends `three_ds`, never runs 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)).
- **A paid renewal is never charged again.** A new attempt starts only while the renewal
  order, read again under the order's lock, still needs payment: a second run of the same
  renewal that read the order before the first run paid it sends nothing.
- **A duplicate scheduled renewal is skipped.** WooCommerce Subscriptions renews a
  subscription whenever its scheduled payment action runs, and does not check that the payment
  is due; a second action for the same subscription would create a second renewal order and
  charge the same period again. For a subscription renewed by WorthyPayments, an action that
  finds the next payment date still ahead (more than 10 minutes) while the subscription's next
  payment action is still scheduled creates no renewal order and charges nothing; the
  subscription gets a note. Never stopped: **Process renewal** on the subscription screen, a
  retry of a failed renewal, and the subscription's only payment action run early by hand
  (WooCommerce → Status → Scheduled Actions → **Run**), which renews it now.
- **A 3-D Secure challenge finished too late charges nothing.** A saved-card payment waiting for
  the buyer's challenge is sent only while its order still needs payment: a challenge finished
  after the order was cancelled (unpaid stock released) or paid another way is not charged.
  Before it is sent, the order's own payment on the site is read, as for a renewal: a buyer who
  paid the order with the card form in another tab meanwhile (its webhook not here yet) is not
  charged by the saved card too, and a card form still open there is closed first.
- **Retries are WooCommerce Subscriptions'.** A declined renewal is marked **Failed** with a
  note naming our decline code. If **Retry Failed Payments** is on (WooCommerce → Settings →
  Subscriptions; it is off by default), Subscriptions retries by its own rules; otherwise it
  e-mails the customer to pay the renewal themselves. The plugin adds no retry of its own, and WorthyPayments does not retry a
  renewal your store charges through the API, so there is only one retry schedule.
- **A card whose bank wants the customer to confirm the payment.** A renewal never runs 3-D
  Secure, so such a card is declined `soft_decline_3ds`, with nothing charged. As Stripe's
  plugin does, the customer then gets the plugin's own e-mail, **Renewal payment needs
  confirmation (WorthyPayments)** (WooCommerce → Settings → Emails, where you can turn it off
  or reword it): their bank needs them to confirm the payment, with a **Confirm and pay the
  renewal** link to the renewal order's payment page, where they pay with the card present and
  the bank can ask them to confirm it. Subscriptions' failed-renewal and retry e-mails, and
  WooCommerce's failed-order e-mail, are not sent for that renewal, so the customer gets one
  e-mail. From plugin 2.8.0, Subscriptions' scheduled retries of that renewal do not charge
  the same card again (it would be declined the same way, and card networks penalise repeated
  retries): the renewal fails again with a note, and Subscriptions keeps its own schedule. Once
  the customer changes the card, the next retry charges the new one. The order's **Retry
  Renewal Payment** action still sends the charge.

| What the API answers | Renewal order |
|---|---|
| Captured | **Processing** / **Completed**; the subscription continues |
| Authorized only (a Capture node on your route) | **On hold**, with Capture / Void actions |
| `402` `soft_decline_3ds` | **Failed**, note; the customer gets the **Renewal payment needs confirmation** e-mail with the payment link |
| Another `402` decline, or another refusal | **Failed**, note with the code; Subscriptions retries if its retry system is on, otherwise e-mails the customer a link to pay it |
| `pending`, a `5xx`, or no answer | **On hold**; the plugin asks again with the same key (every 10 minutes, a few times), or the webhook settles it. If it still cannot tell, a note asks you to look the payment up in your dashboard |

**Changing the card.** In My account → Subscriptions → **Change payment**, the customer picks
one of their saved cards, or **Use a new payment method**: the Element saves the new card with
no payment (a setup intent for the subscription's customer, as for **Add payment method**), the
plugin re-reads it with your secret key, and renewals charge the new card from then on. Nothing
is charged now. The subscription moves to WorthyPayments only once the new card is saved: a
card that is declined or not saved leaves it on the payment method and card it had. Ticking
*Update the payment method used for all of my current subscriptions* moves the others to the
same card. An admin can edit the subscription's **WorthyPayments card** (`pm_…`) and
**WorthyPayments customer** (`cus_…`) on the subscription screen; the customer must be the
subscriber's own (the one on their account, the subscription's current one, or one of their
saved cards'), and the plugin checks with WorthyPayments that the card belongs to it before
saving.

**Subscriptions created in test mode** keep their test card. Once the store is live, their
renewals are not charged (a test card takes no money, and the renewal would show as paid):
each fails with a note saying the customer must add a card, and every admin screen says how
many such subscriptions are active. When the customer adds a card (**Change payment**) or
pays the renewal on the site, it renews live.

**A failed renewal** can be paid by the customer from their account, with a saved card or a
new one in the Element (always saved, as for the first order); renewals then continue on the
card they paid with.

**The card on show.** My account → Subscriptions, and a subscription's own page, name the card
renewals charge: *Via Visa ending in 4242* rather than the method's title. The admin's
subscription list and screen add it to the title: *Credit or debit card (Visa ending in
4242)*. The brand and last four come from the customer's saved card holding the subscription's
`pm_…`; a card that is not one of their saved cards shows the title alone.

Subscriptions that renew through another gateway (your NMI plugin, during the move) are
never touched by this plugin, except by the migration command below when you run it.

---

## Moving subscriptions from your NMI plugin

If your subscriptions renew through an NMI gateway plugin today, WorthyPayments can import the
cards in your NMI Customer Vault as saved cards on our side (no card is re-entered, and
nothing is changed or deleted at NMI). After the import, WorthyPayments support gives you a
**mapping file**: one row per imported vault entry, with the NMI `customer_vault_id`, our
`customer` (`cus_…`) and `payment_method` (`pm_…`), and the card's brand, last four, expiry
and whether its first NMI transaction was found (`anchor`). It holds no card number. JSON
lines or CSV with a header row:

```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. With
`--report`, every subscription gets a row in a CSV file you can sort and share (subscription,
outcome, the vault id's last four, the card, the next payment date in UTC, the recurring total)
instead of a terminal line each:

```bash
wp worthypayments migrate-subscriptions --mapping=/root/vault-mapping.jsonl --dry-run --report=/root/preview.csv
```

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

```bash
wp worthypayments migrate-subscriptions --mapping=/root/vault-mapping.jsonl --log=/root/migration.jsonl --limit=10
wp worthypayments migrate-subscriptions --mapping=/root/vault-mapping.jsonl --log=/root/migration.jsonl --report=/root/run.csv
```

| Option | Meaning |
|---|---|
| `--mapping=<file>` | The mapping file. A file missing a column or with a malformed row is refused whole, with the line number. |
| `--dry-run` | Report only; no subscription is changed. |
| `--log=<file>` | Required without `--dry-run`. JSON lines: each subscription's record before the move, then the result. |
| `--report=<file>` | One CSV row per subscription (and one per unused mapping row) instead of a terminal line each; the terminal shows one progress line per page. |
| `--gateway-from=<id>` | Your NMI plugin's gateway id. Default `nmi-for-woocommerce`. |
| `--source=token` (default) | The vault id is the token of your NMI plugin's saved card attached to the subscription (the last one attached), or the buyer's only saved card of that gateway. |
| `--source=subscription-meta` / `user-meta` with `--source-key=<key>` | The vault id is in that meta key on the subscription / on its user. |
| `--mode=test\|live` | Which keys the cards belong to. Default: the store's active mode. |
| `--batch=<n>` | Subscriptions read per page, 1 to 1000 (default 200). |
| `--rate=<n>` | WorthyPayments API calls per second at most, 1 to 50 (default 10). |
| `--from-id=<id>` | Start after this subscription id: the value the last progress line printed. |
| `--limit=<n>` | Stop after moving (or, in a dry run, finding) this many. |

The command reads the **active**, **on-hold** and **pending-cancel** subscriptions paid with
`--gateway-from` (a pending-cancel one renews again if the buyer reactivates it), page by page
in subscription id order. It first prints which mode, secret key (its last four characters) and
old gateway it uses, and stops at once if WorthyPayments refuses the key. For each subscription:

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, in one database transaction: stores the `cus_…` on the WordPress user
   (when it has none), adds the card to the buyer's saved cards, switches the subscription to
   WorthyPayments with the `pm_…`, keeps on the subscription what it held before (for a
   revert), and adds a note naming the vault id's last four characters and the card. A
   subscription set to manual renewal stays manual. A renewal already created on NMI and still
   unpaid (a failed renewal waiting for Subscriptions' retry) moves with it, so the retry is
   charged through WorthyPayments; it gets a note too.

**Large stores.** The command is built for any number of subscriptions:

- **Memory stays flat.** The mapping file is streamed into a working table
  (`{prefix}worthypayments_vault_map`) once, and a later run with the same file reuses it; each
  page of subscriptions is read by id, decided and dropped. Measured on a test store (posts
  storage): 220,000 subscriptions previewed at 134 MB, the same peak as 20,000, with every page
  costing the same at the end as at the start; 20,000 moved at about 200 a second under 150 MB.
  Those runs read few distinct customers, so the API checks below were not the limit.
- **API calls are paced.** The checks read each mapped customer once per page, at most
  `--rate` calls a second. Your key's ceiling (100,000 requests an hour, see
  [Rate limits](./14-rate-limits.md)) is shared with your checkout and renewals: the default
  of 10 a second uses about a third of it. A `429` is waited out (2, 4 … 64 seconds); if the API
  still refuses, the command stops and says where to resume. A call that gets no answer or a
  `5xx` is made again (1, 2, 4 seconds) before the subscription counts as `error`. With one
  customer per subscription, budget two calls per subscription: 100,000 subscriptions take
  about 5½ hours at the default rate, and the move can run in several sittings.
- **Stop and resume at any time.** Each subscription is moved in its own database transaction,
  so a run that is stopped (Ctrl-C, a lost SSH session, the server restarting) leaves every
  subscription either moved or untouched. Every page ends with
  `To resume after an interruption: --from-id=<id>`; run the same command with it (with
  `--report`, the same file is added to). Running again from the start is also safe: what was
  moved reports `already_ours`, and an unpaid renewal a stopped run did not hand over yet is
  handed over then. The command warns before it starts when the store cannot give that
  guarantee: tables that are not InnoDB, `WC_USE_TRANSACTIONS` off, or WooCommerce's HPOS
  compatibility sync on (it commits each save itself); a re-run completes what a stop left.
- **One run at a time.** A second `migrate-subscriptions`, `revert-subscriptions` or
  `forget-mapping` on the same site is refused while one runs.

One line (or CSV row) per subscription, then totals:

| Outcome | Meaning |
|---|---|
| `would_migrate` / `migrated` | Moved (or would be, in a dry run). |
| `already_ours` | Moved by an earlier run. Re-running is safe; an unpaid renewal still on NMI is handed over (the line says so). |
| `no_vault_id` | No vault id where `--source` looks: check the source with a dry run. |
| `no_mapping` | The vault id is not in the mapping (the import could not take that card): the subscription stays on NMI until the buyer updates their card. |
| `ambiguous` | The mapping lists the vault id with two different cards. Ask support. |
| `customer_conflict` | The user already has a different WorthyPayments customer (also when another of their subscriptions was moved to a different one earlier in the same run). |
| `email_mismatch` | The mapped customer's e-mail is not the buyer's. Nothing is attached to a card that may belong to someone else. |
| `card_not_customers` | The mapped card is not that customer's card. |
| `renewal_in_progress` | Subscriptions is charging its renewal right now (checked when the subscription is decided and again just before it is moved). Run again later. |
| `error` | Our API could not be read, WooCommerce refused a write, or could not load the subscription. The line says why; the run goes on. |

The totals also say how many moved cards carry their stored-credential anchor (renewals on
those reference the buyer's original NMI transaction), the recurring amount that moves per
currency, how many moved subscriptions renew within the next 24 hours (their next renewal is
the first one charged through WorthyPayments), how many API calls were made, and, once a run
reaches the last subscription, the mapping rows no subscription used (20 on the terminal, all
in the report). Run in a quiet window, or pause Action Scheduler, so that no NMI renewal runs
during the move; a subscription whose renewal is being charged at that moment is skipped
(`renewal_in_progress`).

**Nothing is deleted.** NMI is never called, and your NMI plugin's saved cards and meta stay on
every subscription and user. Deleting a card that was imported from your NMI vault never
deletes it at NMI. To move subscriptions back, re-activate your NMI plugin and run:

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

It reads, on every subscription the migration moved, what it held before, so it needs no log
file to read and covers every run; `--log` (required without `--dry-run`) records what it puts
back. `--batch`, `--from-id` and `--limit` work as for the move. A subscription is put back only
while it still renews with the card the migration gave it (`changed_since` when a card change
since then wins), and only when the old gateway is active (`old_gateway_missing`). Its unpaid
renewals go back too, with your NMI plugin's token copied onto them so its retry works: the one
that moved with it and one created on WorthyPayments since, except one whose charge through
WorthyPayments has no answer yet. A subscription whose renewal is being charged at that moment
is left for a later run (`renewal_in_progress`); one that cannot be put back is reported
`error`; the run goes on either way. Subscriptions moved by plugin 2.5 carry no such record and
are reported `no_record`; put them back by hand (set the subscription's payment method back to
your NMI plugin). The saved card stays in the buyer's account. When you are done, drop the
working table:

```bash
wp worthypayments forget-mapping
```

Deleting the plugin drops it too.

**Where the money goes after the move.** A moved subscription renews through WorthyPayments,
and WorthyPayments charges the card in your NMI Customer Vault under the NMI account that holds
it, whatever your Payment Workflow route would pick (a vault entry exists only in that account).
Within that account, the processor is the one your route's Payment Workflow sends; check the
route before the first renewal if your NMI account has several processors. Each renewal
references the card's first NMI transaction (where the import found it). Nothing at NMI changes,
and the vault entry keeps any card update NMI applies to it.

**Before you move:**

- If you also set up recurring billing **in NMI itself** (NMI's Recurring / Subscriptions, not
  WooCommerce Subscriptions), NMI keeps charging those plans on its own schedule whatever
  WooCommerce does. Cancel any plan that covers a subscription you move, or the buyer is charged
  twice.
- Run the dry run in a quiet window and read its report: every `would_migrate` row is a
  subscription whose next renewal is charged through WorthyPayments.

**After you move:**

- Turn the NMI payment method off at checkout (WooCommerce → Settings → Payments), but keep the
  NMI plugin **active**: WooCommerce refunds an order through the gateway that took it, so the
  orders your NMI plugin took stay refundable from the order screen only while that plugin is
  active. Deactivate it only once no active subscription uses it and you no longer expect to
  refund its orders; after that, refund them in your NMI merchant portal.
- Subscriptions the move left on NMI (`no_mapping`, `email_mismatch` and so on) keep renewing
  through the NMI plugin while it is active. If you deactivate it, WooCommerce Subscriptions
  switches them to manual renewal: the buyer is e-mailed to pay each renewal.

---

## 3-D Secure

The Element runs every challenge; nothing is configured in the plugin. By default our rules
decide when a payment is stepped up (see [3-D Secure](./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 **Dashboard → Developers → Webhooks** with these
events and paste each endpoint's signing secret into the matching field: `charge.succeeded`,
`charge.authorized`, `charge.failed`, `charge.canceled`, `charge.expired`, `charge.refunded`,
`dispute.created`, `dispute.updated`, `dispute.won`, `dispute.lost`, `dispute.closed`,
`dispute.funds_withdrawn`, `dispute.funds_reinstated`, `pre_dispute_alert.created`,
`pre_dispute_alert.updated`, `setup_intent.succeeded`, `setup_intent.setup_failed`,
`payment_method.detached` and `payment_method.updated`.

An endpoint registered by an earlier version lacks `dispute.closed` and the two
`payment_method.*` events. When the secret key has `webhooks:write`, the plugin adds what is
missing itself (`POST /v1/webhook-endpoints/{id}` with every event it uses, keeping any others
the endpoint already sends): once after each plugin update, in the 12-hourly check, when you
save the settings, and from **Set up the WorthyPayments webhook again**. An endpoint that was
deleted at WorthyPayments is registered again and its new signing secret saved. Without the
scope, or on a platform version that cannot update an endpoint yet, the settings screen and an
admin notice name the events it does not send: add them in Dashboard → Developers → Webhooks.

The plugin verifies every delivery as described in
[Verifying a delivery](./16-webhooks.md#verifying-a-delivery) (the `X-Webhook-Mode` header picks
the secret, both parts of a rolled secret are accepted, a delivery whose timestamp is more than
5 minutes from the server's clock is refused). Then:

- **The delivery belongs to the mode whose secret verified it.** It acts only on an order of
  that mode: a test delivery never touches a live order (it is answered `200`, ignored, and
  recorded as **Webhook rejected**, `mode_mismatch`). A body whose signed `livemode` says the
  other mode is ignored the same way.
- **A delivery is applied once**, recognised by the event `id` in its signed body (the same
  `evt_…` in every retry and in `GET /v1/events`), never by the unsigned `X-Webhook-Id` header.
- **It is answered at once and applied in the background** (an Action Scheduler job,
  `worthypayments_webhook_event`): the request only verifies, dedupes and queues. A job that
  cannot apply its event yet (no answer from our API, a `5xx`, the order busy, a refund whose
  sale the order does not know yet) runs again after 1, 5 and 30 minutes, 2 and 6 hours; a
  refusal that would repeat (`401`, `403`, `404`) is recorded as **Webhook not applied** and
  not retried. Without Action Scheduler, or when its queue has stalled (WP-Cron off and no real
  cron: a job more than 10 minutes late), the delivery is applied in the request itself, with
  a couple of the stuck ones; then `500` asks our retry to deliver it again.
- **A webhook only tells the plugin to look**: the order changes after it re-reads the payment,
  the refund or the dispute with your secret key.
- **An event more than a day old** (recovered, or delivered late) brings the order up to date
  without sending any e-mail, to the buyer or to you.

| Event | What the plugin does |
|---|---|
| `charge.succeeded`, `charge.authorized`, `charge.failed` | Re-reads the order's intent and charge (for a renewal or a saved-card payment, the charge) and moves the order |
| `charge.canceled`, `charge.expired` | An authorized order becomes **Cancelled**; a paid order whose captured charge was voided before it settled goes **On hold** (*do not ship*) and the admin is e-mailed |
| `charge.refunded` | Each refund made in your WorthyPayments dashboard or in the gateway's portal is recorded on the order as its own WooCommerce refund; the note (or reason) you gave it goes into a private order note (see [Refunds](#refunds)), and its ARN is noted when the refund is of this order's own charge. A refund that arrives before the order knows its sale reads the order's payment first, and waits for it rather than being dropped |
| `setup_intent.succeeded`, `setup_intent.setup_failed` | For a $0 subscription sign-up: re-reads the order's setup intent; once it saved the card, the card is recorded and the order completed (a buyer who closed the page before the return) |
| `dispute.*` | The dispute is read again and the order follows it (see [Disputes](#disputes)) |
| `pre_dispute_alert.*` | An order note; manage the alert in your dashboard |
| `payment_method.detached`, `payment_method.updated` | See [Saved cards](#saved-cards) |

Deliveries for orders the plugin did not take (another gateway's, another store's) are
answered `200` and ignored. A refund is matched by its order reference only when its payment's
`metadata.wc_site` names this store: two stores on one account both have an "Order #5". A
payment of this store whose order was deleted is recorded (**Payment for a deleted order**)
and listed in the [double-charge watch](#payment-activity).

### Missed webhooks

Our webhooks are retried for about 34 hours, then given up. The plugin does not depend on them
alone:

- **Every hour** it reads `GET /v1/events` since the last time (`events:read`), in each mode
  whose secret key is saved, and hands each event to the same handler as a webhook: one the
  webhook already applied is a duplicate, anything else is applied now. The feed also lists the
  events of our historical imports, which are never sent as webhooks and must not reach your
  buyers: an event sends WooCommerce's e-mails only when the feed marks it `replay: false` (and
  it is less than a day old); one marked `replay: true`, or from a platform version that does
  not mark them, is applied without any e-mail. A long backlog continues a page at a time in
  follow-up jobs; an event that still cannot be applied after 6 runs is given up with an
  activity row.
- **Every day** it reads the disputes changed since the day before (`GET /v1/disputes`), and
  each order created in the last 30 days that is still Processing, Completed or On hold has its
  charge read again: an authorization captured, voided or expired since, a refund made outside
  WooCommerce and a dispute still open are brought in line, without e-mails.
- **WooCommerce → Status → Tools**: **Pull missed WorthyPayments events now**, and **Set up the
  WorthyPayments webhook again** (a new signing secret for this site's endpoint in each mode,
  or the endpoint registered, and the events it lacks added; the previous secret keeps working
  for 24 hours).

### Webhook and key health

On the Dashboard, the Plugins screen and WooCommerce's screens, shop managers see a notice for
the active mode when:

- payments were taken, but no webhook has arrived since (2 hours on);
- deliveries are being refused, and why: a signature that does not match the secret saved
  here (three in a row, so one stray request is not reported), this server's clock more than
  5 minutes off, no secret for their mode, or a delivery of one mode claiming the other;
- the endpoint for this site is disabled at WorthyPayments (its deliveries kept failing, or you
  turned it off), there is none, or it does not send an event the plugin uses and could not be
  given it;
- the secret key is refused (revoked, expired, invalid) or lacks a scope, or cannot read events;
- our API has not answered several requests in a row.

The key and the endpoint are checked every 12 hours, in the background. The WorthyPayments
section of **WooCommerce → Status** (the System Status report) shows the same: the mode, the
kinds of key saved (never a key), the last accepted and refused delivery, the last key check
and event pull, and the plugin's waiting and failed jobs.

---

## Refunds

Refund from the order screen as usual (**Refund → Refund via WorthyPayments**), in full or in
part. Each refund calls [`POST /v1/refunds`](./06-refunds.md) under the order's lock, with an
idempotency key derived from the order, the refunds it already has, the refusals so far and
the refund's exact body (amount, reason, note). A refund that was refused (an open dispute or
alert, more than is left to refund) can be tried again as soon as its cause is gone: the next
try is a new request, not a replay of the refusal. An authorized, uncaptured payment cannot be
refunded: void it.

**A refund that got no answer** (a timeout, a `5xx`, an answer that could not be read, or our
API's `idempotency_error`) may have gone through, so WooCommerce does not record it and the
plugin sends nothing new on the order until its outcome is known:

- **Refund the same amount again** to complete it: the plugin resends that exact request under
  its key (the words you type the second time are not sent), so our API answers it once,
  whether or not the first one went through.
- **Another amount** first looks the unanswered refund up (`GET /v1/refunds?charge=`). Found:
  it is recorded on the order as the refund you made, and you are told so; nothing new is sent.
  Not found yet: you are asked to refund the first amount again or wait. After 24 hours (the
  life of its key) a refund that is still not found never went through, and the new one is sent.
- The webhook, or a re-read the plugin schedules, records it as soon as it shows up. The next
  refund you try is then told once that the earlier one went through, instead of refunding it
  a second time.

The **Reason for refund** you type goes with the refund as its `note` (up to 500 characters)
and shows on the refund in your dashboard. A reason that is exactly one of our API's codes
(`requested_by_customer`, `duplicate`, `fraudulent`) is sent as `reason` instead.

The other way round: a refund you make in your WorthyPayments dashboard (or through the API),
or in your gateway's own portal once our gateway sync has seen it (`source: "gateway"`),
reaches the order with the `charge.refunded` webhook. The plugin reads the charge's refunds
(`GET /v1/refunds?charge=`) under the order's lock and records each one that went through
(`succeeded`) and that the order does not have yet as **one WooCommerce refund of its own
amount**, which keeps the WorthyPayments refund id (`_worthypayments_refund_id`). Totals are
never compared: a refund still in flight, a refund you recorded by hand in WooCommerce and a
lost dispute that plugin 2.11 or earlier recorded as a refund that moved no money do not
change what is recorded (since 2.12 a lost dispute records no refund at all). Two
deliveries at once record it once: the second finds the order busy and is delivered again.

What the customer sees stays neutral. WooCommerce shows a refund's reason on My account, the
order-received page and the *Refunded order* e-mail, so a refund made outside WooCommerce gets
the reason "Refunded by the store through WorthyPayments." The note (or reason) you gave it in
your dashboard goes into a **private** order note, as plain text (tags removed), with its
amount and refund id.

**A refund that is `pending`** (the gateway has not confirmed it) is recorded at once and read
again every 15 minutes. If it fails, its WooCommerce refund is removed from the order with a
note saying no money went back, a fully refunded order goes back **On hold**, and the site's
admin e-mail is told (filter `worthypayments_admin_alert_recipient` to send it elsewhere).
Each refund is recorded by its id, once, whichever way it was made.

---

## Capture

Whether a payment is captured at once or only authorized is set by the **Capture** node of
your Payment Workflow route (see [Authorize & capture](./17-auth-capture.md)). An authorized
order is **On hold** with two order actions: **Capture charge** and **Void authorization**.

Moving an authorized order to **Processing** or **Completed** also captures it, and cancelling
it voids it, as with Stripe's plugin. The capture takes the order's total at that moment, so
lower the total first (remove an item, add a negative fee) to capture less than was
authorized: what was captured is measured against the lowered total, and the order is paid.
If the capture fails, the order goes back **On hold** with a note saying why: an order is
never left marked for fulfilment while its money was not taken. A refused capture can be tried
again (with the cause fixed, or the total lowered): the next try is a new request. The plugin
had a **Capture** setting for this until 2.5.1; the behaviour is now always on.

**A partial capture in your dashboard** (say $60 of a $100 authorization) leaves the order
**On hold** with a note: *captured $60.00 of the order's $100.00*. The money taken is the
order's (refunds and disputes follow the charge), but it is not paid in full: lower the total
to what was captured and mark it **Processing**, collect the rest, or refund. The same goes for
a payment that captured less than the order total as the buyer paid: back from the payment
page or a saved card's 3-D Secure challenge, the buyer is shown the order, never asked to pay
again, and a card saved with that payment is kept for the order and its subscriptions.

**A captured payment voided before it settled** (a void in your dashboard, a bulk void, a
fraud reversal) brings no money. The order, **Processing** or **Completed** until then, goes
**On hold** with the note *the payment was voided after it was captured … Do not ship it*, and
the site's admin e-mail is told.

---

## Double charges

What the plugin does so that one order is paid once (plugin 2.8.0 and later):

- **One request at a time per order.** The return from the payment, a webhook, a scheduled
  re-check and a renewal apply to an order one after another, under a lock only one request
  can hold. A webhook that finds the order busy is not recorded as applied: its job runs
  again later (or, applied in the request, it answers `500` and our retry delivers it again).
- **A charge is sent once.** A saved-card or renewal charge is stored with its idempotency key
  before it is sent; a resend after no answer uses the same key, so it cannot charge twice. An
  order that no longer needs payment is never charged.
- **The same cart submitted twice at once** (a double click, two tabs) gives WooCommerce two
  orders before either is paid. A new payment takes the cart's lock first (the cart and the
  buyer): the second submission is told *Your payment for this cart is already being
  processed* and charges nothing. The cart's lock lasts up to 10 minutes, longer than the
  slowest new payment, so a slow answer from our API never lets the second one through.
- **A checkout submitted again for a cart just paid.** When the buyer's page never showed the
  result and they press **Place order** again, WooCommerce makes a second order for the same
  cart. If that cart was paid within the last 5 minutes (remembered with the buyer's session
  and beyond it), the second order takes no payment: it is cancelled with a note and the buyer
  is shown the paid order. An earlier order paid through the card form whose webhook has not
  arrived yet is read first, so it counts. The same cart bought again after 5 minutes goes
  through.
- **A renewal already paid on the site** (the customer used the e-mailed link and the webhook
  has not synced it yet) is not charged again by the next scheduled try: the plugin reads the
  order's own payment first, and leaves it alone while it is paid, settling or in its 3-D
  Secure challenge. So does a saved card's 3-D Secure challenge the buyer finishes after
  paying the same order with the card form in another tab.
- **A pay link opened while the order's card is being charged** (a renewal, or a saved card)
  starts no second payment. The card form's payment is made under the order's lock, so a
  charge always sees it before it sends anything; while a charge holds the lock, or was sent
  and has no answer yet, the buyer is told *A payment for this order is already being
  processed* and nothing is created. A saved card still waiting on its 3-D Secure challenge
  charged nothing, so the buyer can pay with the card form instead.
- **An intent whose key cannot answer** (our API holds another body under it after an answer
  was lost) is created again under the next attempt's key. An intent moves no money.
- **A second charge is flagged, never hidden.** If money is taken for an order that is already
  paid (a payment form left open in another tab, an earlier payment attempt the buyer
  completed, or the order paid with another payment method since), the order keeps its first
  charge for refunds and disputes, gets a note naming the second charge, and is listed in the
  [double-charge watch](#payment-activity); every admin screen shows a warning while the watch
  of the last 7 days lists a case you have not reviewed. Nothing is refunded automatically:
  check both charges in your dashboard, refund the one taken twice, then **Mark as reviewed**
  (on the warning or the watch): the warning comes back only for a case recorded after that.
  Developers can act on the
  `worthypayments_second_charge` action (`$order`, `$charge_id`, `$charge`).
- **A payment made through an earlier payment attempt** of the order is found by its
  `metadata` (`order_id` and `wc_site`) and applied: an unpaid order is paid by it.

---

## Card testing

Card testing is someone trying stolen card numbers, one after another, through your checkout.
Every payment attempt passes through the plugin (a new intent, a retry after a decline, a saved
card, a $0 card check, the pay-for-order page's payment form, a card saved from My account),
and the plugin counts it under every identity it knows: the IP address, the buyer's
WooCommerce session, the billing e-mail, the account and the order. When any of them has made
**Payment attempts per hour** attempts (10 by default) in the last hour, the next attempt is
refused before anything reaches our API: the buyer reads *Too many payment attempts. Please
wait an hour before trying again*, and the order gets a note. A refused attempt is not
counted. `0` turns the limit off. Saving a card from My account also waits 10 seconds between
new setup intents (filter `worthypayments_setup_intent_delay`) and allows 10 new ones per
account and day (filter `worthypayments_setup_intents_per_day`).

The IP address counted is the connection's (`REMOTE_ADDR`): a header such as
`X-Forwarded-For` can be written by anyone, so it is not believed by default. Behind a proxy
or CDN that sets the visitor's address, name its header: the `WORTHYPAYMENTS_TRUSTED_IP_HEADER`
constant in `wp-config.php` (`CF-Connecting-IP`, `True-Client-IP`, `X-Real-IP` or
`X-Forwarded-For`, whose last address is the one your proxy added), or the
`worthypayments_trusted_ip_header` filter. Without it, every buyer behind the proxy counts as
one address.

The counts are kept as WordPress transients, named by a salted hash, so no IP address or e-mail
is stored in plain text. Our own fraud screening (velocity and card-testing rules) still applies
to every attempt that gets through. Raise the limit if many of your buyers share one IP address
(a campus, a corporate network).

---

## Disputes

A dispute is answered in your WorthyPayments dashboard; the order shows where it stands, as
Stripe's WooCommerce plugin does.

A `dispute.*` delivery is only a trigger: before the order changes, the plugin reads the
dispute again with the order's own key ([`GET /v1/disputes/{id}`](./16-webhooks.md#disputes-the-same-poll),
`disputes:read`), and the dispute as it stands now decides. A late or repeated delivery cannot
undo a newer outcome, and a delivery signed with the other mode's secret cannot read this
mode's dispute at all.

| The dispute now | Order |
|---|---|
| Open (needs a response, under review…) on a **Processing** or **Completed** order | **On hold**, with a note: amount, reason, respond-by date, and the stage, case number and acquirer when the delivery names them. The status it had is kept on the order |
| A Visa RDR case (`rdr: true`, from your acquirer) | Treated as lost at once, as below: the card issuer has already credited the cardholder from your funds, and an RDR cannot be contested. The note says so |
| Won | Back to the status it had before the hold, with a note. Won after it was lost (funds reinstated): back from **Failed** to the status it had before the dispute, with a note that the money came back |
| Lost (or accepted) | No WooCommerce refund is recorded, as with WooCommerce's own Stripe gateway. Lost for the whole payment (or for no amount in the order's currency): the order is marked **Failed**. Lost for part of it: the hold comes off and the order keeps its status. Either way the note names the dispute and the amount taken back, and the [activity log](#payment-activity) has a row for it |
| A loss recorded by plugin 2.11 or earlier | Such a version recorded a lost dispute as a WooCommerce refund that moved no money (reason *Dispute lost: the card network took this amount back…*, or the meta `_worthypayments_dispute_loss`). It is left as it is, never deleted. If that dispute is won after all, the order goes back to its status from before the dispute (from **Refunded** too), and the note says the refund record moved no money and stays |
| Closed with no winner (`dispute.closed` from a processor that reports none, Nuvei or PayArc; expired) | Back to the status it had before the hold, no loss recorded; the note asks you to check the outcome in your dashboard. The `dispute.closed` that comes with a `dispute.won` or `dispute.lost` changes nothing more |
| No longer naming this order's sale (`dispute.updated` with `linkChange` UNLINKED or RELINKED) | This order's hold comes off (and a loss recorded on it no longer stands: back from **Failed**); the sale it names now, if it is one of your orders, follows the dispute instead |
| A pre-dispute alert (Ethoca, CDRN, RDR, `pre_dispute_alert.*`), or an alert-sourced or related-case row | A note only. An alert's note gives its deadline to refund before (`slaDueAt`), its source, category and ARN. Each new alert is also delivered as `dispute.created`: that copy is ignored, so an alert is noted once |

The order keeps each dispute's state by its id, so one decision delivered twice moves the
order once. The order is found through the sale the dispute names: our charge id, or its
gateway transaction id (`dispute.nmiTransactionId`, best-effort, see
[Dispute payloads](./16-webhooks.md#dispute-payloads)); a dispute that names none yet changes
no order. Once a day the disputes changed since the day before are read and applied the same
way (see [Missed webhooks](#missed-webhooks)), so an order on hold for a dispute that ended
without a webhook is released too.

A status set by a dispute sends the customer none of WooCommerce's order e-mails (processing,
completed, on hold, failed, refunded, partially refunded): a dispute won is not a new "your
order is being processed", and a lost one is not a failed payment the customer should be asked
to pay again. The e-mails WooCommerce sends to your store are kept.

An order whose dispute was lost is never charged again by the plugin, and nothing it reads
later (the buyer opening the payment's return address again, a webhook delivered late, a sync)
marks it paid. A lost dispute of a whole subscription payment marks its order **Failed**, so
WooCommerce Subscriptions handles the subscription as for any failed payment: it goes **On
hold**, and with **Retry Failed Payments** on, a retry is scheduled. That retry charges
nothing: the order gets a note that charging the card again would bill the cardholder for what
they disputed. Reactivate or cancel the subscription yourself.

---

## The order screen

An order paid with the plugin shows, under its general details:

- the card it was paid with: brand and last four, and for Apple Pay / Google Pay the wallet
  and the buyer's own card inside it, as the wallet stated it (never the device token's
  digits; "Google Pay" alone when the wallet did not state the card);
- what the gateway answered, for your fraud review before you ship and as dispute evidence:
  the auth code, the address check (AVS) and security-code check (CVV) with the issuer's
  letters, the 3-D Secure liability (shifted to the card issuer, or not), the card's expiry,
  the acquirer that processed it, and the gateway's answer text;
- for a payment only authorized, the date it must be captured by, after which the
  authorization is cancelled;
- the mode, and a link to the transaction in your WorthyPayments dashboard.

The transaction id next to **Payment via** links there too. A test-mode order opens in the
dashboard's test mode only: switch the dashboard to test mode first. Our API reports no fee or
net amount per charge, so the order screen shows neither.

A **WorthyPayments activity** box on the order (and on a subscription) lists that order's
rows of the payment activity log, newest first.

---

## Payment activity

**WooCommerce → WorthyPayments activity** (shop managers) records every money step the
plugin takes or learns about, from version 2.7.0 on:

| Row | When |
|---|---|
| Charge sent (saved card) | A renewal or a saved-card payment was sent to our API: the amount, the idempotency key, whether the key is new or the resend of an earlier request (a resend cannot charge twice), and the answer (`succeeded`, `requires_action`, a decline code, or `no_answer`). |
| Charge not sent | The plugin did not send a charge: the order was already paid (`already_paid`), or another request was charging it at that moment (`order_busy`). |
| Duplicate renewal stopped | A second scheduled payment action ran for a subscription whose next payment is not due (see [Renewals](#woocommerce-subscriptions)). |
| Payment taken, authorized, pending, declined, voided or expired | The outcome the checkout, the buyer's return, a webhook or a scheduled re-check applied to the order. A charge is listed once, whichever of them reported it first. |
| Amount differs from the order total | The charge's amount is not the order's; the order went on hold. |
| Payment captured, Capture failed, Authorization voided, Void failed | Capture and void, with the error and request id of a failure. |
| Refunded, Refund not made, Refunded outside WooCommerce | A refund from WooCommerce (its `re_…` id), one our API refused or never answered, and one made in your dashboard or the gateway portal that the plugin recorded. |
| Dispute opened, won, lost, closed or moved off this order | Disputes as read again on each delivery (and daily); a loss says whether it took the whole payment (the order marked Failed) or part of it, and a win after a loss that the order is back to its status from before the dispute. No refund is recorded or removed. |
| Webhook received, Webhook rejected, Webhook not applied | Each delivery applied to an order (its event id and type; "from history, no e-mails" for one more than a day old), deliveries refused for their signature or clock (at most one row a minute) or signed in the other mode than their order, and deliveries our API refused the read they needed for (`401`, `403`, `404`), or that still failed after the retries. |
| Payment for a deleted order | A payment whose metadata names an order of this store that no longer exists; also listed in the double-charge watch. |
| Subscription moved, moved back, unpaid renewal handed over | `wp worthypayments migrate-subscriptions` and `revert-subscriptions`. |

Each row names the order and the subscription (a renewal or sign-up order names both), the
source (checkout, renewal, buyer's return, webhook, scheduled re-check, store admin, WP-CLI),
the mode, the charge (linked to your dashboard), and the user who did it when a person did.

At the top of the screen, per mode and for the last 24 hours, 7 or 30 days:

- **Payments taken** (count, and the amount per currency), **Authorized, not captured**,
  **Saved-card charges sent**, **Declined**, **Refunded**, **Disputes opened**, **Disputes
  lost**, **Charges stopped**, and **Needs a look** (failed captures, voids and refunds,
  amount mismatches, rejected webhooks). Refreshed every five minutes.
- **The last webhook received** in that mode. If none ever arrived, refunds and disputes made
  in your dashboard do not reach WooCommerce: see [Webhooks](#webhooks).
- **Double-charge watch**: an order whose money was taken by more than one charge, a
  subscription paid by two orders less than 12 hours apart, and money taken for an order that
  was deleted. Check each one in your dashboard and refund what was taken twice (or for
  nothing).

Below, every row, filtered by event, source, mode, order or subscription number, and dates
(the site's time zone), 50 a page. **Export these rows (CSV)** downloads what the filters
match. Times in the export are given in UTC and in the site's time zone. A payment row names
the card (and the wallet) and the gateway's own transaction id, which your gateway portal
searches by; a webhook row names the charge it was about. Details are shown in your site's
language.

The watch lists a subscription only when its two payments paid renewal orders made less than
12 hours apart, so a renewal paid late by a retry followed by the next period's on time is not
a double charge.

Rows are kept for **Payment activity kept for** days (Settings, default 365, at least 30); a
daily job removes older ones. Order notes and WooCommerce's own records are never removed.
Deleting the plugin keeps the log unless `WC_REMOVE_ALL_DATA` is set in `wp-config.php`.

---

## Running beside another gateway

The plugin only ever touches orders paid with it. You can keep your current gateway plugin
active while you move over: each method appears in the payment list, and each handles its own
orders, refunds and webhooks. The plugin stores the gateway's own transaction id on each order
so a sale can be matched in both systems.

---

## Troubleshooting

- **The method does not appear at checkout.** Both keys of the active mode are required, and
  their prefixes must match the mode.
- **The card form is blank, or says the form could not load.** The page's hostname is not
  in the publishable key's allowed domains, or your Content-Security-Policy blocks the frame.
  See [Content-Security-Policy](./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, or this
  server's clock is off (the admin notice says which). Use **WooCommerce → Status → Tools → Set
  up the WorthyPayments webhook again**, or tick **Remove the saved value** under the signing
  secret and save with **Set up the webhook for me** on: the plugin rolls the endpoint's secret
  and saves the new one.
- **A notice says payments were taken but no webhook arrived.** Check the endpoint's failed
  deliveries in Dashboard → Developers → Webhooks and that it is enabled. Until it works, the
  hourly event pull (`events:read`) and the daily reconciliation keep orders up to date.
- **"Too many payment attempts."** The buyer reached **Payment attempts per hour**. It clears
  an hour after their oldest counted attempt; raise the limit if your buyers share an IP
  address.
- **An order stays On hold.** The outcome was `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, closed or moved to
  another sale (checked daily as well).
- **A subscription renews manually.** No card was saved with its first order (the note on the
  subscription names why). The customer's next renewal paid on the site, or a new card under
  **Change payment** in their account, switches it back.
- **Save this card does not appear.** The buyer is not signed in, the cart starts a
  subscription (its card is always saved, so there is nothing to tick), or the plugin could not
  create their customer (check the log; the secret key needs `customers:write`).
- **"The payment form is still loading."** Place order was pressed before the card form had
  loaded; nothing was submitted. If it never loads, see the blank card form above.
- **The express Apple Pay / Google Pay buttons do not appear.** Apple Pay and Google Pay are
  off for your account in this mode; the buyer's device or browser has neither (Apple Pay needs
  Safari or a supported browser with a card in Wallet, Google Pay a Chromium browser); the
  buyer is a guest and the store allows neither guest checkout nor accounts created with a
  generated password; or there is nothing to pay now. Apple Pay alone missing: the domain is
  not registered or does not serve the verification file (see
  [Apple Pay on your domain](#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.
- **What did the plugin charge, and when?** **WooCommerce → WorthyPayments activity** (see
  [Payment activity](#payment-activity)), or the activity box on the order.
- **Logs.** Turn on **Debug log**, then **WooCommerce → Status → Logs**, source
  `worthypayments`. Keys, client secrets, e-mails, addresses and anything shaped like a card
  number are never logged. A refused webhook is logged at most once a minute.
- **Support asks for the system status.** **WooCommerce → Status → Get system report**: its
  WorthyPayments section holds what they need, and no key.

---

## Privacy

**Tools → Export Personal Data** includes "WorthyPayments payments": for each order and
subscription of the e-mail address paid through the plugin, the payment's id, the card's brand
and last four, the mode, and the WorthyPayments customer and saved-card ids, plus the customer
ids kept on the buyer's account.

**Tools → Erase Personal Data** removes those customer ids and an unfinished card set-up from
the buyer's account. Orders and subscriptions follow WooCommerce's own setting (**Settings →
Accounts & Privacy → Remove personal data from orders on request**): when it is on, the card's
brand and last four, the gateway's answer, the charge request, client secrets and the customer
and saved-card ids are removed from them as well, except the card a subscription that still
renews is charged with. The payment's id and its refunds stay, as the record of a payment.
The buyer's customer record and saved cards at WorthyPayments are not erased from WordPress:
delete the customer in your dashboard.

A saved-card or renewal charge request is kept on its order whole only until it is answered;
then only its amount, currency and card id stay (plugin 2.12.0 and later). Requests answered
under an earlier version are cut down the same way once, in the background, after the update:
an Action Scheduler job (`worthypayments_strip_settled_attempts`) reads your orders 100 at a
time and changes only that order meta (no order is saved, moved or deleted). A request still
waiting for its answer is kept whole, since it may be sent again.

The plugin suggests a paragraph for your privacy policy (**Settings → Privacy**), and keeps its
order meta (`_worthypayments_…`) out of WooCommerce's REST API: the payment's id is there as the
order's `transaction_id`.

---

## PCI and card data

A new card is entered in our iframe and encrypted by Evervault before it leaves the browser.
WordPress and your server only handle intent ids, charge ids, the client secret of the
payment, saved-card ids (`pm_…`), and the card's brand, last four digits and expiry. A saved
card is charged by its `pm_…` id; the card number stays in our vault.

One part runs on your page, not in our frame: when your 3-D Secure rules challenge a **saved
card**, the challenge is opened on the checkout (or pay-for-order) page by the plugin's
`three-ds-challenge.js`, which loads Evervault's browser SDK (`https://js.evervault.com/v2`)
into that page. No card number passes through it (the card is already in our vault); it shows
the issuer's challenge.

Your store can stay on the PCI **SAQ A** path, which under PCI DSS v4.0.1 still asks something
of the pages that embed the payment form: you must manage the scripts those pages load
(requirement 6.4.3: an inventory of each script, why it is needed, and how its integrity is
assured) and detect unauthorised changes to them and to the page's security headers
(requirement 11.6.1). The scripts this plugin adds are `worthypayments.umd.js` (our SDK,
version-pinned), the plugin's own scripts under `/wp-content/plugins/woocommerce-worthypayments/assets/`,
and, only on a saved card's challenge, `js.evervault.com/v2`. A Content-Security-Policy that
lists them (see [Content-Security-Policy](./08-embedded-sdk.md#content-security-policy)) is the
usual way to meet both. Confirm your SAQ with your acquirer or QSA.
