# Changelog

The public API is dated. The current version is **`2026-01-01`**.

There is only one behavioural version today. Sending `API-Version: 2026-01-01`
records intent for the first time a second version ships; it does not select a
different contract yet. See [Versioning](./20-versioning.md). Every change below
therefore applies to every request, whatever `API-Version` it sends.

## What is listed here

Every change that can alter what an existing, correctly-written integration observes is
listed on this page: a response field, HTTP status, error `code` or `type` that changes; a
request that was accepted and is now refused, or the reverse; a webhook payload; a
`worthypayments.js` callback shape or default. Each entry is dated the day it went live in
production (UTC) and tagged:

| Tag | Meaning |
|---|---|
| **Breaking** | Could break a correctly-written integration, as defined in [What counts as a breaking change](./20-versioning.md#what-counts-as-a-breaking-change). |
| **Changed** | Behaves differently, but an integration that follows these guides keeps working. |
| **Added** | A new field, value, event, parameter or callback property. Additive; ignore what you do not use. |
| **Fixed** | The API now does what these guides already said. **Fixed (docs)** means the guide was wrong and now describes what the API does. |

## 2026-11-02

- **Changed.** Webhook `detail.settlementBatchId` is omitted instead of `"0"` on
  refunded, enriched and capture events that carry stored transaction detail.
  `GET /v1/charges/{id}` also omits `settlement.batch_id` instead of `"0"`; when
  the batch was the only settlement value, the whole `settlement` object is absent.
  Real batch ids are unchanged. The guides already say empty fields are omitted.
- **Fixed.** `GET /v1/customers/{id}/three_ds` returns an HTTP 500 or 503 API error
  (`type: api_error`, `code: upstream_error`) when enforcement state cannot be read,
  instead of reporting `require_3ds: false`. `POST` and `DELETE` on the same endpoint
  can return that error after the flag change has already been applied, if the
  read-back fails. Retrying the same operation is idempotent; a 5xx does not mean
  the write was rolled back.

## 2026-10-30

- **Added.** `appearance.rules` accepts four more selectors for the saved-cards-across-stores
  (Link) steps of the Payment Element: `.CodeInput` (the 6-digit code field), `.Action` and
  `.Action:hover` (the **Change**, **Not you?**, **Send a new code** and **Use a different
  card** text buttons) and `.Badge` (the **Default** mark). Nothing changes until you name
  them. See [Payment Element](./08b-payment-element.md#saved-cards-across-stores-link).
- **Changed.** In the Payment Element, the email a shopper types for their saved cards also
  fills the element's billing email field while that field is empty, so they type it once. An
  address they type into the billing field themselves is kept. Saved cards now show their
  network's mark and name ("Mastercard") the way a typed card does.
- **Fixed.** The Payment Element's field labels follow `colorText`. They were drawn near-black
  whatever the theme, so on `night` or any dark `colorText` they were unreadable. A saved
  card's brand name had the same problem. With the default theme the labels are now the
  default text colour (`#30313d`), a shade lighter than before.
- **Fixed.** An Apple Pay or Google Pay payment made with a device token (a cryptogram) in the
  Payment Element or the express element is charged. It used to be refused inside the platform
  before it reached the processor and was left without an outcome. A wallet charge, here and on
  `/v1/charges`, now also records the card network as `card_brand`. Nothing changes in what you
  send. See [Payment Element](./08b-payment-element.md).
- **Fixed.** A card charge made in the Payment Element, the express element or the hosted
  checkout, and a charge on a saved payment method (`pm_…`), now records the card's expiry: the
  webhook `detail`'s `cardExpMonth` / `cardExpYear` were `0` before. A card charge also keeps
  its `card.bin`, `card.last4` and `card.brand` where they used to be left empty. Nothing
  changes in what you send. See [Webhooks](./16-webhooks.md).
- **Fixed.** A Payment Element payment sealed in `wp:` mode records its card's `card.bin`,
  `card.last4`, `card.brand` and expiry, which were empty: worthypayments.js now sends the sealed
  card's `card.bin`, `card.last4` and expiry beside `wp_token` on the confirm. If you confirm a
  `wp:` token yourself, send them too. See [Payment Element](./08b-payment-element.md).
- **Added.** Error code `wp_token_card_mismatch` (400, do not retry): a `wp:` charge whose sealed
  card does not start with the `bin` or end with the `last4` sent beside it is refused, on the
  confirm and on `POST /v1/charges`. Both are optional; a charge that sends neither is not
  checked. On `POST /v1/charges` a `wp:` card's `card.brand` is now derived from `card.bin`. See
  [Tokenization error codes](./30-error-codes.md).

## 2026-10-29

- **Added.** The Payment Element can offer a shopper's saved cards from other WorthyPayments
  checkouts (Link). It is off until you turn it on in Dashboard > Settings > Checkout >
  Payment methods, per mode. When it is on, the element asks for the shopper's email, looks it
  up by itself (no button; an address you pass in `defaultValues.billingDetails.email` as soon
  as the element shows), emails them a 6-digit code, and lists the cards they can pay you
  with. Your code, the element's
  options and its callbacks do not change. Before the shopper pays, your page sees none of
  the email, the code or the cards. A completed payment reports the email they paid with in
  `billing_details.email`, as any card payment does. Turning it on means we email your
  shoppers a code at their real address. See
  [Payment Element](./08b-payment-element.md#saved-cards-across-stores-link).

## 2026-10-28

- **Added.** A webhook's `detail` carries `fromSavedPaymentMethod: true` when the charge was made on
  a saved payment method (a `pm_…`): a subscription renewal, a retry of a declined renewal, a
  charge on a stored card. Such a charge was not approved in a wallet for that payment, even when
  `walletType` names the wallet the method was saved from. It is left out otherwise, including on
  the payment that saved the method. See [Webhooks](./16-webhooks.md).
- **Changed.** A charge on a saved Apple Pay payment method, such as a subscription renewal, is
  reported as an Apple Pay charge: `card.wallet` is `{ "type": "apple_pay" }` with the
  `card_last4` / `card_brand` of the buyer's own card, and the webhook's `walletType` is
  `apple_pay`. Before, it read as a card with no wallet. `card.last4` and `card.bin` still
  belong to the token. Nothing changes in what you send. See
  [Charges](./03-charges.md).
- **Changed.** An Apple Pay payment on a `recurring` Payment Element or express element is
  saved for renewals.
  Its confirm now answers `card_save: saved` with a `pm_…` (before: `skipped` /
  `card_not_saveable`); charge each renewal on that `pm_…` with `recurring: true`, a
  merchant-initiated charge that refers to this first payment. The wallet credential carries
  a new `recurring: true` when the sheet was opened as a recurring payment request:
  `mountWalletButton` sets it from the wallet's answer and the Payment Element forwards it.
  Only an Apple Pay payment with it, on a confirm that saves, is saved. A one-off Apple Pay
  sheet and tokenised Google Pay answer as before. See
  [Payment Element](./08b-payment-element.md#subscriptions-paid-by-wallet).
- **Added.** Every payment intent object carries `last_payment_error` and
  `cancellation_reason`. `GET /v1/payment_intents/{client_secret}` answers the refusal of the
  intent's latest payment attempt (`type: card_error`, `code: card_declined`, `message`,
  `decline_code`, `network_status`) while the intent is `requires_payment_method` or
  `canceled`; before, only the declined confirm said why, so your server could not read back a
  decline the buyer saw in the Element. Both are `null` otherwise. See
  [Payment Element](./08b-payment-element.md).
- **Changed.** A payment intent takes at most five payment attempts that end without a
  payment. The confirm that is the fifth answers `200` `canceled` with
  `cancellation_reason: failed_attempts` and its `last_payment_error`; a later confirm is
  `400 payment_intent_attempts_exceeded`, and a confirm of an intent you canceled (or that
  expired) is `400 payment_intent_unexpected_state`. Nothing is charged on either. The same cap
  holds on a hosted checkout or payment link session. A buyer who needs more tries needs a new
  intent. `worthypayments.js` resolves that fifth confirm as `requires_payment_method` with its
  `last_payment_error`, so the Payment Element shows the decline (it read the new `canceled`
  as `processing` before).
- **Added.** `POST /v1/payment_intents/{id}/cancel` (`payment_intents:create`) cancels an open
  payment intent, with `cancellation_reason` `requested_by_customer` (default), `duplicate`,
  `fraudulent` or `abandoned`. A processing or succeeded intent is
  `400 payment_intent_unexpected_state`.
- **Changed.** `POST /v1/setup_intents` refuses an eleventh setup intent for the same customer
  within 24 hours: `429 setup_intent_limit_exceeded` (`param: customer`). Reuse an open intent
  rather than creating one per page view.
- **Fixed.** `POST /v1/customers` repeated with the `idempotency_key` of a customer that was
  deleted since answered the deleted customer as if it were live. It now answers
  `409 customer_deleted` (`param: idempotency_key`).
- **Added.** `POST /v1/customers/search` with `{ "email": "…" }` looks a customer up by email
  with the email in the body, out of load balancer access logs. `GET /v1/customers?email=`
  keeps working. See [Customers](./18-customers.md).
- **Added.** `GET /v1/ping` answers `settleable_currencies`, the currencies a charge or
  payment intent is accepted in (today `["usd"]`). Check your store's currency against it
  when you save your keys.
- **Fixed.** A webhook endpoint's `status` was always `enabled`. It is now `disabled` for an
  endpoint that receives no deliveries, with `disabled_reason`: `delivery_failures` (the
  platform switched it off after a sustained run of failed deliveries) or
  `disabled_by_merchant`; `null` while enabled. Roll-secret answers the real status too.
- **Changed.** Creating a webhook endpoint or rolling its secret without an `Idempotency-Key`
  is a new request every time. Before, the key was derived from the url or the endpoint id, so
  a second roll answered the first roll's secret instead of rolling again, and an endpoint
  deleted and created again came back as the deleted one with its old secret. An
  `Idempotency-Key` you send answers for 24 hours.
- **Changed.** A webhook endpoint `url` that names `localhost` or a loopback address is
  refused in test mode and live mode alike: `400 url_invalid`. An endpoint whose name resolves
  to a loopback address is not delivered to. A `url` that is not `https` was already refused;
  it now answers `400 url_invalid` too.
- **Fixed.** The `400 idempotency_error` and `409 idempotency_in_progress` envelopes now carry
  `request_id`, as every other error does.
- **Added.** `GET /v1/charges/{id}` answers `amount_refunded_pending`: the part of
  `amount_refunded` whose refund the gateway has not confirmed yet (a refund request answered
  `409 idempotency_in_progress`). A `201` refund is confirmed and never fails later. See
  [Refunds](./06-refunds.md).
- **Added.** `POST /v1/webhook-endpoints/{id}` (`webhooks:write`) changes an endpoint's `url`
  and/or `enabled_events` (the list is replaced). The answer is the endpoint as
  `GET /v1/webhook-endpoints/{id}` returns it; the signing secret is neither changed nor
  returned. Another merchant's, the other mode's or a deleted endpoint is `404
  resource_missing`. See [Webhooks](./16-webhooks.md).
- **Added.** Every event from `GET /v1/events` and `GET /v1/events/{id}` carries `replay`:
  `true` when it was written during a backfill or historical replay (it records the past, and
  no webhook was sent for it), `false` otherwise. Always present.
- **Fixed.** `GET /v1/refunds?charge=` lists the charge's refunds made in your gateway's own
  portal (synced from the gateway) as refund objects of that charge, beside those made through
  the API. Every refund object gains `source` (`platform` or `gateway`) and `created` (Unix
  seconds). Before, a portal refund counted in the charge's `amount_refunded` but was listed
  under no charge.
- **Fixed.** `GET /v1/events` and `GET /v1/events/{id}` answer an event with the same fields
  masked as its webhook: card security codes, card-vault tokens (`ev:…`), a card number found in
  free text and the other card-data fields read `[REDACTED]`. They answered an event that had not
  been delivered to an endpoint unmasked. Identifiers (transaction, gateway, dispute, alert,
  order ids, ARN) are unchanged.
- **Fixed.** A buyer whose customer you deleted can pay again. Their email still belonged to
  the deleted customer, so hosted checkout, and a payment intent created without a
  `customer`, failed with an error whenever the buyer's email was that customer's. The
  payment now goes ahead and the buyer becomes a new customer. See
  [Customers](./18-customers.md#create-a-customer).
- **Changed.** A deleted customer no longer holds its email. `POST /v1/customers` with that
  email creates a new customer with a new `id`, where it answered `409 already_exists`, and
  `GET /v1/customers?email=` finds the new customer. `POST /v1/customers/:id` can likewise
  change a customer's `email` to it. An email that belongs to a customer that is not deleted
  is still a `409`. The deleted customer stays deleted, with its `id` and history.
- **Fixed.** Paying an invoice on its `hosted_invoice_url` page charges what the invoice
  says is due. If your account had a default, global or location tax rate, that rate was
  added again on top of the invoice total, which already includes the invoice's own tax, and
  an untaxed invoice was taxed too. The invoice was still marked paid at its own amount, so
  the extra showed only on the charge: its `amount` was above the invoice's `amount_due`. A
  payment page opened before this change and not yet paid is corrected as well. See
  [Invoices](./18c-invoices.md).
- **Fixed.** A subscription's opening payment taken through its payment session is taxed
  exactly as its renewals are. This covers an incomplete subscription
  (`payment_behavior=default_incomplete`, or no card on file) confirmed with
  `latest_invoice.payment_intent.client_secret`, and a subscription invited by email from
  the dashboard. The tax comes from the customer's stored address, and there is none when the
  subscription was created without tax collection. It used to be taxed like a one-off
  checkout, so a rate that depends on the customer's address could be missed, and a
  dashboard subscription with tax collection off was still charged your default or global
  rate. See [Subscriptions](./18b-subscriptions.md).
- **Changed.** A promo code is refused on an invoice's payment page and on a subscription's
  opening payment session (`coupon not applicable`). The hosted page never offered the field
  there, but the code could still be sent, and the buyer then paid less than the invoice or
  the first period while the invoice stayed part-paid. A subscription's own coupon, set when it
  is created, still applies.
- **Added.** An incomplete subscription (`payment_behavior=default_incomplete`, or no card on
  file) whose customer completes the payment now lists that first period as a **PAID**
  invoice on `GET /v1/invoices` and sends `invoice.paid`, as a subscription whose card is
  charged at create already did. It carries the period's tax and the subscription's coupon,
  and its payment is the checkout's charge. See [Invoices](./18c-invoices.md).
- **Fixed.** A subscription `coupon` is counted as redeemed when the first period is paid,
  whichever way it is paid. For an incomplete subscription it used to be counted only at the
  first renewal, so a coupon with a redemption limit could be used past it in the meantime,
  and a coupon meant for one period only discounted the first renewal as well. A subscription that is never paid
  does not use up a redemption.
- **Fixed (docs).** [Subscriptions](./18b-subscriptions.md) and [Invoices](./18c-invoices.md)
  said opening create mints no first invoice. Since 12 October 2026 a card charged at create
  writes a PAID invoice for the first period, and a `SEND_INVOICE` subscription that starts at
  create gets its first period's OPEN invoice.
- **Fixed.** A subscription taxed at an inclusive rate (the price already contains the tax,
  such as VAT included) is charged its price. Its renewals, a card charged at create,
  `SEND_INVOICE` renewal invoices, the opening payment session and the preview added the
  included tax on top again: a 120.00 plan with 20% VAT included was charged 140.00. Only
  exclusive rates add tax now, as a one-off checkout already did. A subscription invoice's tax
  shows only the tax added on top; tax included in the price stays in the charge's tax record.
  See [Subscriptions](./18b-subscriptions.md).
- **Fixed.** An invoice that is still owed has one payment session for that amount. A payment
  link copied from the dashboard opened a new session each time, beside the one behind
  `hosted_invoice_url`, and each could be paid in full: a customer who paid two of them was
  charged twice while the invoice counted one payment. Every link now opens the same session
  until it is paid or expires; after a partial payment the next link is for what is left. See
  [Invoices](./18c-invoices.md).
- **Added.** The `hosted_invoice_url` page and its PDF state the tax included in a
  subscription's price ("Includes tax") when the rate is inclusive, such as VAT included. The
  total does not change: a 120.00 plan with 20% VAT included shows a total of 120.00 that
  includes 20.00 of tax. The invoice's own tax amount still counts only tax added on top.
- **Fixed.** Voiding an invoice closes its payment page's checkout, and cancelling an incomplete
  subscription closes its opening payment session, so neither takes a payment any more. They
  used to stay payable until they expired, and a payment made on them was recorded nowhere.
  This also holds when the subscription is cancelled because its checkout link expired or
  because payment recovery ran out, and for a checkout that was mid-payment at the time and was
  then declined. No webhook is sent for a closed checkout.
- **Fixed.** After a partial payment, or a credit note applied to part of an invoice, the Pay
  button on the invoice's `hosted_invoice_url` page opens a checkout for what is still owed, and
  the earlier checkout for the full amount is closed. It used to stay payable for the full
  amount, so a customer could pay the whole invoice again. A Pay link in an invoice email sent
  before that payment now opens a closed checkout; the `hosted_invoice_url` page always has the
  current one. See [Invoices](./18c-invoices.md).
- **Added.** The charge webhooks' `data.object.detail` carries the shipping address the charge
  was made with: `shippingName`, `shippingPhone`, `shippingLine1`, `shippingCity`,
  `shippingState`, `shippingPostal` and `shippingCountry`, each omitted when you sent no value.
  They ride every event that carries the charge's `detail`: `charge.authorized`,
  `charge.succeeded` (automatic or manual capture), `charge.failed`, `transaction.enriched`
  and a `charge.refunded` for a refund issued here, with their `transaction.*` twins. A sale or
  refund synced from your gateway carries none, and `charge.canceled` / `charge.expired` carry
  no buyer details at all. Before, a webhook-only integration had no way to see where a
  charge's goods went. See [Webhooks](./16-webhooks.md#the-detail-block).
- **Added.** `POST /v1/payment_intents` and `PATCH /v1/payment_intents/{id}` take
  `shipping_address` (the flat shape `POST /v1/charges` takes), `line_items` (up to 100:
  `name`, `amount` in minor units, `quantity` 1–10000, `sku`, `category`) and
  `billing_details` (`first_name`, `last_name`). The charge the intent makes sends the shipping
  address and the lines to fraud scoring, as a server-side charge already could, and the intent
  keeps them as dispute evidence. None of them changes the amount, tax or a shipping charge;
  `PATCH` replaces each one sent. `shipping_address` is answered on create, update and
  retrieve to secret and restricted keys (`null` when none). Confirm takes
  `billing.first_name` / `billing.last_name`, which, like the intent's `billing_details`, are
  used for the charge's billing name and the customer record instead of splitting
  `billing.name`. Before, an intent's charge carried the intent's single `description` line as
  its basket, no shipping address, and a name split on its last space. See
  [Payment Element](./08b-payment-element.md#buyer-details-shipping-name-order-lines).
- **Added.** `note` on `POST /v1/refunds`: your own words for why you refunded, up to 500
  characters, stored on the refund and shown in your dashboard. Every refund object now has a
  `note` field: the create response, `GET /v1/refunds/{id}` and `GET /v1/refunds` return the
  stored note, `null` when there is none. A note over 500 characters, or one that is not a
  string, is `400 parameter_invalid`, `param: "note"`. See [Refunds](./06-refunds.md).
- **Fixed.** `GET /v1/refunds/{id}` and `GET /v1/refunds` return the refund's `reason`, as the
  Refunds guide showed; they read `reason: null` for every refund. It is the value the create
  takes (`requested_by_customer`, `duplicate`, `fraudulent`), and `null` for a refund made
  without one, in your gateway's own portal, or in your dashboard as *Product Issue*,
  *Service Issue* or *Other*, which none of the three describes. See
  [Refunds](./06-refunds.md#retrieve-a-refund).
- **Added.** `line_items` on `POST /v1/charges`: up to 100 `{ name, amount, quantity?, sku?,
  category? }` items, `amount` the unit price in cents, sent to fraud scoring as the order's
  basket. They never change what is charged. They are stored on the charge and returned as
  `line_items`, in the same shape, on the create response, `GET /v1/charges/{id}` and the
  charge a capture or void answers with: `[]` when there are none. `GET /v1/charges` does not
  return them. An invalid item is `400 parameter_invalid` naming it, e.g.
  `param: "line_items[2].amount"`. See [Charges](./03-charges.md#retrieve-a-charge).
- **Added.** `shipping` on `GET /v1/charges/{id}`, `GET /v1/charges` and
  `GET /v1/customers/{id}/transactions`: the shipping address the charge was made with, as
  stored, `{ name, phone, line1, line2, city, state, postal_code, country }`, with `line2`
  folded into `line1` (so `line2` is `null`) and `"[redacted]"` for a customer you erased,
  as `billing_details` reads. `null` when there is none. Before, only the create response
  echoed it. The create response now carries `shipping: null` when you sent no
  `shipping_address`, where the key used to be absent. See
  [Charges](./03-charges.md#retrieve-a-charge).
- **Changed.** WooCommerce plugin 2.13.0 loads the SDK from `app.worthypayments.com/sdk/v1/`,
  calls `api.worthypayments.com/v1` and frames `element.worthypayments.com`: the same API, keys
  and data as paygeyt.com. A site that sets a Content-Security-Policy allows those three hosts,
  or stays on paygeyt.com with `WORTHYPAYMENTS_SDK_URL` and `WORTHYPAYMENTS_API_BASE` in
  `wp-config.php`. The order screen's dashboard link opens `app.worthypayments.com`. Updates
  still come from `app.paygeyt.com`. See [Configure](./09-woocommerce.md#configure).
- **Changed.** WooCommerce plugin 2.12.0: a webhook acts only on an order of the mode whose
  secret signed it, is deduped on the signed event id, and a dispute is read again
  (`GET /v1/disputes/{id}`) before its order changes; missed webhooks are recovered from
  `GET /v1/events` hourly and by a daily reconciliation, so the secret key also needs
  `disputes:read` and `events:read` (both default-on for a new secret key). Refunds made in
  the dashboard or the gateway's portal are recorded one refund object at a time; a captured
  charge voided before it settled puts its order On hold; an unanswered refund is resent under
  its key, never re-minted. Updates install only a release signed with a key the plugin pins.
  A lost dispute records no WooCommerce refund any more: lost for the whole payment, its order
  is marked **Failed** (as WooCommerce's own Stripe gateway does); for part of it, the order
  keeps its status; a win puts it back. The plugin never charges that order again.
  See [Webhooks](./09-woocommerce.md#webhooks), [Refunds](./09-woocommerce.md#refunds),
  [Disputes](./09-woocommerce.md#disputes) and [Updates](./09-woocommerce.md#updates).
- **Changed.** WooCommerce plugin 2.11.0: every payment sends the order's products as
  `line_items` (name, unit price, quantity, SKU, category); a payment intent also sends the
  `shipping_address` and the buyer's first and last name as `billing_details`, and an open
  intent's update removes a part the order no longer has; a refund's typed reason is sent as
  its `note`, and a refund made in your dashboard reaches the order with its note as the
  WooCommerce refund's reason. An API that does not know these fields yet gets the payment
  again without them.
  See [What a payment says about the order](./09-woocommerce.md#what-a-payment-says-about-the-order)
  and [Refunds](./09-woocommerce.md#refunds).
- **Changed.** WooCommerce plugin 2.10.0: saved-card and renewal charges send the buyer's
  `customer.first_name` / `last_name` and phone, and a `shipping_address` when the order
  ships; every payment's `metadata` adds `site_url`, and `subscription_id` on the order a
  subscription starts with; customers are created with their account's billing phone. The
  order screen shows the gateway's answer (auth code, AVS, CVV, 3-D Secure liability, card
  expiry, acquirer, capture deadline). See
  [Configure](./09-woocommerce.md#configure) and [The order screen](./09-woocommerce.md#the-order-screen).
- **Fixed.** WooCommerce plugin 2.10.0: an Apple Pay / Google Pay order no longer records the
  device token's last four digits as the buyer's card when the wallet did not state the card;
  a refund made in your dashboard reaches the order by the sale it refunds
  (`parentTransactionId`); a Visa RDR case is recorded as lost when it opens; a pre-dispute
  alert's note gives its deadline (`slaDueAt`); a key with the wrong prefix is no longer saved
  over the working one; the block checkout shows the load error instead of an empty payment
  box when the payment form cannot load. Two requests charging one order at the same moment
  can no longer both send a charge (the first keeps the order until its answer is recorded); a
  payment after a voided or expired authorization pays the order instead of being flagged as a
  second charge; a declined or voided other charge no longer replaces the paid order's charge.
- **Added.** WooCommerce plugin 2.9.0 updates through WordPress's own screens: a new version
  shows on Plugins and Dashboard → Updates with its changelog, installs in one click or by
  WordPress's auto-updates, and is installed only when its SHA-256 checksum matches the one
  published with it. A renewal cut off by an update is finished on the next request, with
  the same idempotency key, so it is never charged twice. Versions 2.8.0 and earlier are
  updated to 2.9.0 once by hand. See [Updates](./09-woocommerce.md#updates).
- **Changed.** WooCommerce plugin 2.8.0: payments carry one more `metadata` key, `wc_site` (a
  short hash of the store's address), on the payment intent and on saved-card and renewal
  charges, so a charge is proven to be this store's order. A checkout submitted again for a
  cart the same session paid in the last 5 minutes is cancelled instead of charged. A
  renewal retry is not sent to a card that was declined `soft_decline_3ds` for that renewal.
  A live saved card deleted on a copy of the store is not deleted at WorthyPayments. See
  [Double charges](./09-woocommerce.md#double-charges).
- **Fixed.** WooCommerce plugin 2.8.0: money taken for an order that was already paid is noted
  on the order and listed in the double-charge watch instead of silently replacing the
  order's charge; a webhook that finds its order busy is retried instead of dropped; two
  requests can no longer both hold an order's lock.
- **Added.** WooCommerce plugin 2.7.0: **WooCommerce → WorthyPayments activity** records every
  money step the plugin takes or learns about (charges sent with their idempotency key,
  payments taken, declines, captures, voids, refunds, disputes, the charges it stopped,
  webhooks, subscription moves), with a summary per mode and period, a double-charge watch,
  filters and a CSV export, and an activity box on each order and subscription. See
  [Payment activity](./09-woocommerce.md#payment-activity).
- **Fixed.** WooCommerce plugin 2.6.0: `wp worthypayments migrate-subscriptions` finishes on a
  store with more subscriptions than `--batch`; it used to read the first page again and again
  and never end. A run that is stopped part way no longer leaves a subscription half moved: each
  one moves (and reverts) in its own database transaction. See
  [Moving subscriptions from your NMI plugin](./09-woocommerce.md#moving-subscriptions-from-your-nmi-plugin).
- **Changed.** WooCommerce plugin 2.6.0: the subscription migration works at any store size.
  The mapping file is loaded into a working table and subscriptions are read by id, so memory
  stays flat; the API checks are paced to `--rate` (10 a second by default) and a `429` is
  waited out; every page prints the `--from-id` to resume from; one command runs at a time.
  `revert-subscriptions` reads what each subscription held before from the subscription itself:
  `--log` is no longer read and is required only to record a real revert, and subscriptions
  moved by plugin 2.5 (which carry no such record) are put back by hand. An unpaid renewal still
  on NMI moves with its subscription, so its retry is charged through WorthyPayments; a revert
  gives its unpaid renewals back to NMI with the NMI token. A subscription that cannot be put
  back is reported `error` instead of stopping the revert. `--batch` defaults to 200.
- **Fixed.** WooCommerce plugin 2.6.0: on a store whose orders use WooCommerce's own tables
  (HPOS), a renewal order no longer starts with the subscription's card, customer and
  migration details copied onto it; it now starts as it already did on posts storage.
- **Added.** WooCommerce plugin 2.6.0: `migrate-subscriptions --report=<file>` (one CSV row per
  subscription), `--from-id`, `--rate`; totals show the recurring amount that moves and the
  renewals due within 24 hours; `wp worthypayments forget-mapping` drops the working table.
- **Fixed.** WooCommerce plugin 2.5.1: a subscription renewal is charged once. A second run of
  the same renewal that had read the order before the first run paid it no longer sends a
  second charge, and a duplicate WooCommerce Subscriptions scheduled payment action (one that
  runs while the subscription's next payment is still ahead and still scheduled) no longer
  creates a second renewal order and charges the same period again; the subscription gets a
  note instead. **Process renewal** on the subscription screen, retries of failed renewals and
  the only payment action run early by hand are unchanged. A saved-card payment whose 3-D
  Secure challenge the buyer finishes after the order was cancelled, or paid another way, is
  no longer charged. See [WooCommerce Subscriptions](./09-woocommerce.md#woocommerce-subscriptions).
- **Fixed.** WooCommerce plugin 2.5.1: a lost dispute no longer marks the order **Failed**. On a
  subscription renewal, Failed made WooCommerce Subscriptions put the subscription on hold,
  e-mail the buyer to pay and, with failed-payment retries on, charge the card again for the
  period the cardholder had just taken back. The lost amount is now recorded as a refund that
  moves no money (a full loss leaves the order Refunded). See [Disputes](./09-woocommerce.md#disputes).
- **Changed.** WooCommerce plugin 2.5.1: moving an authorized-only order to Processing or
  Completed always captures it, and cancelling it always voids it; a capture that fails puts
  the order back On hold. The **Capture** setting, which turned this on, is gone: with it on
  Automatic (the default), an authorized order could be fulfilled without its money being
  taken. See [Capture](./09-woocommerce.md#capture).
- **Fixed.** WooCommerce plugin 2.5.1: a refund refused by the API (an open dispute, more than
  is left) can be tried again for the same amount once its cause is gone; it used to replay
  the refusal for 24 hours. The "Renewal payment needs confirmation" e-mail is sent once per
  renewal, not again with every retry.
- **Added.** WooCommerce plugin 2.5.1: an admin notice while the method is enabled but hidden
  at checkout because the active mode's keys are not both set; on save, an error when no
  webhook signing secret is saved or no endpoint sends to the site (with automatic set-up
  off).
- **Fixed.** A payment is retried on another processor only when its route asks for it: a
  Payment Workflow route with fallback processors, or a Failover routing rule with cascade on
  (only on the processors it lists). A payment that matched no such route used to be retried,
  after a do-not-honor decline, on every other processor of the account. No live payment was
  retried that way. See [Which processors it was tried on](./03-charges.md#which-processors-it-was-tried-on-expandattempts).

## 2026-10-27

- **Changed.** Ethoca and Verifi CDRN pre-dispute alerts received through Kount are now answered
  to the card network before their deadline, not only the alerts the platform refunded: 4 hours
  before an Ethoca deadline (24 h) and 6 hours before a Verifi one (72 h), 90 minutes before it
  while a rule's refund waits for a Visa RDR or a wallet answer. The answer follows the evidence:
  previously refunded when the linked sale was already refunded in full, declined when your rule
  or an operator decided not to refund, "not refunded, unmatched" when the alert could not be tied
  to one of your sales (never "transaction not found"), and "not refunded" otherwise. Not
  answered: alerts held for a manual link review while the review is open, alerts already
  answered with the dashboard's Kount accept or decline, and alerts Kount closed (these read
  `SKIPPED`). An answer you make directly in Kount 360 is not visible to the platform. On
  `GET /v1/pre_dispute_alerts`, `network_status` therefore reads `PENDING`
  and then `SENT` on these alerts too, and a `NEW` alert can read `SENT`; `FAILED` if the answer
  could not be sent before the deadline. Each change moves `updated_at`, as a refund's report
  always did. No webhook is sent for a network answer. See
  [Webhooks](./16-webhooks.md#pre-dispute-alerts-poll-by-timestamp-instead).
- **Changed.** An alert whose linked sale was already refunded in full when the alert arrived,
  or when a refund the platform tried was refused because the sale had been refunded another way,
  now moves to `REFUNDED` straight away: `pre_dispute_alert.updated` is sent with
  `outcome: REFUNDED` and `refundStatus: SETTLED`, and `auto_rule.reason` still says why
  (`sale-already-refunded`, `sale-refunded-outside-platform`). It used to stay `NEW` until
  someone closed it.
- **Added.** `auto_rule.reason: network-already-answered`: the card network has already been told
  this alert was not refunded (by the platform, or by your dashboard Kount decline), so no rule
  refunds it, and a refund from the dashboard is refused (a refund after that answer cannot be
  reported and would not stop the chargeback). An alert whose earlier answer was a refund that
  then failed can still be refunded. While an alert's answer is being sent, a refund or dismiss
  from the dashboard is refused for a few minutes; try again. The dashboard's Kount accept or
  decline is refused once the alert already has an answer.
- **Added.** `auto_rule.reason: sla-window-closing`: with less than 30 minutes of the alert's
  response window left, a rule no longer starts a refund, since it could complete after the
  deadline and not stop the chargeback; the alert is answered as not refunded instead.
- **Fixed.** A refund the platform completed through its automatic lane is reported to the card
  network and announced with `pre_dispute_alert.updated` (`refundStatus: SETTLED`) every time. When
  the payment processor's own confirmation arrived before the refund was recorded, both could be
  skipped.
- **Changed.** A pre-dispute alert we could not tie to a sale when it arrived is matched again,
  by the rule a new alert meets, while its response window is still open: when the sale's details
  arrive later (its auth code, card BIN, ARN or settlement) and on a regular sweep. When it links
  you receive `pre_dispute_alert.updated` with `outcome: MATCHED`, exactly as for a match on
  arrival, and your auto-refund rules judge the alert then, so a rule may refund it before its
  deadline. See [Webhooks](./16-webhooks.md#alert-payloads).
- **Changed.** An alert whose response deadline has passed is no longer linked automatically when
  its sale learns its ARN later. Before, such an alert could still receive a late
  `pre_dispute_alert.updated` with `outcome: MATCHED`; now it stays unlinked unless an operator links
  it. An RDR alert (Visa has already refunded it, so it has no deadline) is still linked when its
  sale learns a new ARN — at that moment only, as before — so your auto-refund rules never refund a
  sale Visa already refunded; you receive `pre_dispute_alert.updated` with `outcome: MATCHED` for it
  as before. When that ARN arrives through one of our historical replays, the RDR is linked too but
  nothing is sent to you.
- **Changed.** When an alert is linked because its sale learned its ARN later, your dispute
  escalation rules no longer run on the alert's dispute at that moment, as for an alert matched on
  arrival. They used to.
- **Changed.** An alert that was imported from a provider export, or replayed, is not matched again
  automatically after it is stored; an operator can still link it.

## 2026-10-26

- **Changed.** Payment Workflow cascade no longer retries `200` (declined by processor) on the
  next processor; only do not honor (`201`, `05`) is retried. `200` is the gateway's catch-all
  and can stand for an issuer answer the card networks forbid retrying, and the gateway does not
  return the issuer's own code with the decline. A retry after a decline is also sent only while
  the card has fewer than 10 declines in the last 24 hours and fewer than 15 in the last 30 days
  on your account, this charge's own declines included. The card is recognised by its card
  fingerprint or its BIN and last four; a charge with neither is not retried after a decline. A
  charge that is not retried ends with its first decline, as before. See
  [Charges](./03-charges.md#which-processors-it-was-tried-on-expandattempts).
- **Added.** A pre-dispute auto-rule's wallet hold can cover fraud alerts only:
  `walletHoldScope: "fraud"` beside `skipWalletSales: true`. A customer dispute or an alert that
  names no category is then decided by the rule, and `auto_rule.hold_waived` says why
  (`non-fraud-alert`, `unknown-alert-category`), as it does for a waived 3-D Secure hold.
  Without the key, or with `"all"`, every alert on a wallet sale is held as before; any other
  value refuses the rule (`malformed-rule`). See
  [Webhooks](./16-webhooks.md#pre-dispute-alerts-poll-by-timestamp-instead).
- **Added.** `three_ds.cryptogram_validation` can be `not_recorded`: the charge carries 3-D
  Secure, but no issuer check of the cryptogram was recorded, so `liability` rests on the ECI
  alone. It used to be omitted in that case, which read the same as a charge with no 3-D
  Secure. Expect it on every Mastercard charge, on sales recorded before 23 October 2026, and
  on a new charge until the gateway's result is read (the create response included). `liability`
  and `liability_reason` do not change.
- **Changed.** A charge that threads `three_ds.session_id` is refused with `400
  invalid_request_error`, `code: three_ds_card_mismatch`, `param: three_ds.session_id` when
  its card states a different last four (`card.last4`) or expiry (`exp_month`, `exp_year`)
  from the card the cardholder authenticated. Nothing is charged. A detail that either the
  session or the charge does not state is not compared. A charge on the card that was
  authenticated is unaffected.
- **Changed.** A Google Pay `PAN_ONLY` payment (the buyer's own card, no cryptogram) made
  through the Payment Element, `mountCheckout` or hosted checkout is now recorded as Google
  Pay: the charge reads `card.wallet: { "type": "google_pay" }` and its webhooks carry
  `walletType: "google_pay"`, where both used to be empty. It is still charged, scored and
  stepped up to 3-D Secure as a card, and `card.last4` / `card.bin` are the buyer's own card.
  A wallet-alert hold set on your auto-refund rules now applies to these payments too, as it
  already did when your gateway ran them. Load the SDK from `/sdk/v1/` to get it.
- **Added.** `card.wallet_type` on `POST /v1/charges` and on a payment intent confirm:
  `"google_pay"` marks a card as a Google Pay `PAN_ONLY` credential. Any other value is
  `400 parameter_invalid`, `param: "card.wallet_type"`.
- **Fixed (docs).** A `wallet` block without a cryptogram is refused; the guides said a Google
  Pay one was charged as a card. Send that card as `card` with `card.wallet_type`. The Payment
  Element guide no longer calls `PAN_ONLY` the usual case on iOS (our Google Pay button runs
  in Chromium browsers only), and says how to accept device tokens only: `wallets: 'never'`
  and `mountWalletButton` with `allowedAuthMethods: ['CRYPTOGRAM_3DS']`; the Element has no
  such option.

## 2026-10-25

- **Changed.** Hosted checkout pages and payment links take their look (logo, icon, brand and
  accent color, font, corner radius) from **Settings → Branding**, the same in test and live
  mode. The accent color is the Pay button and links; the brand color is the order summary's
  background. A session's own design, such as a payment link's colors, still wins field by
  field, and an unset field keeps the default. **Settings → Checkout** is now **Settings →
  Payment methods** and holds only the wallets. Nothing changes in your integration.
- **Fixed.** `worthypayments.js` dropped a Google Pay card's expiry month and year. The wallet
  sends them as numbers and the SDK read them as text, so the `wallet` credential `onToken`
  received carried empty `exp_month` and `exp_year`, and a charge that 3-D Secure had to
  authenticate was declined
  ("3-D Secure authentication could not be started"). The expiry is now kept whether it
  arrives as a number or as text. Load the SDK from `/sdk/v1/` to get the fix; nothing
  changes in your integration.

## 2026-10-24

- **Fixed.** `charge.succeeded`, `charge.captured` and `transaction.succeeded` for a charge your
  gateway processed directly (made outside WorthyPayments and synced from the gateway) omitted
  `ip` and `instrument.bin` even when the gateway reported them. They now carry both, as the
  [example payload](./16-webhooks.md#omitted-values) shows; a value the gateway did not report is
  still omitted. Webhooks already delivered are not sent again.
- **Added.** Pre-dispute alerts say what they are about. `GET /v1/pre_dispute_alerts` (and
  `/{id}`) add `reason_code`, the provider's own code (Kount's alert type such as
  `ETHOCA_DISPUTE`, or the CDRN issuer reason code such as `13.1`), and `category`, that code
  read as `fraud`, `non-fraud` or `unknown`. `reason` stays `FRAUDULENT` on every Kount-fed
  alert as before. The `pre_dispute_alert.created` / `.updated` webhooks carry the same two
  fields in `data.object.alert` (`reasonCode`, `category`), and the `AUTO_REFUNDED` update
  now also carries `autoRule`, the decision that refunded the alert, as `GET` states it. See
  [Webhooks](./16-webhooks.md#alert-payloads).
- **Fixed.** Kount Ethoca alerts imported from a Kount export had no `reason_code`, so they read
  `category: unknown`. They now carry Kount's alert type (`ETHOCA_FRAUD`, `ETHOCA_DISPUTE`) like
  the alerts Kount sends us directly; existing alerts were updated without changing their
  `updated_at`. The flat `reasonCode` of a Kount alert's `MATCHED` update said `FRAUDULENT`; it
  is now the provider's code, the same value as `data.object.alert.reasonCode`. An alert decision
  recorded before this date keeps the category it was judged on, so its
  `auto_rule.alert_category` can differ from the alert's `category` now.
- **Fixed.** A pre-dispute alert whose CDRN code is one of Visa's three-level conditions
  (`12.6.1` duplicate processing, `12.6.2` paid by other means) read `category: unknown`. It now
  reads the category of its dispute group, as `12.6` and `13.1` already did: `non-fraud` for
  `12.x`, `fraud` for `10.x`. `GET /v1/pre_dispute_alerts` answers the new category for existing
  alerts too; webhooks already delivered are not sent again.
- **Changed.** `POST /v1/refunds`: when your gateway credential could not be read just then
  (our credential service did not answer), the refund answered `403 account_not_activated`, as
  if your account had no gateway key. It now answers `503 upstream_error`. Nothing was sent to
  the gateway; retry with the same `Idempotency-Key`. A refusal stated by the credential
  service (no key, `sandbox_key_required`) answers as before. See
  [Refunds](./06-refunds.md).
- **Fixed (docs).** The API reference still listed the old cascade set (`202`, `260`, `51`,
  `61`, `65`, `N7` as retried on another processor) and showed an insufficient-funds decline
  cascading. Only `200` and `201` / `05` cascade, as the Charges guide already said; the
  example now cascades a do-not-honor decline. A wallet charge is single-attempt only when it
  carries a cryptogram: a Google Pay `PAN_ONLY` card may cascade like any card. The decline
  table now lists `240`, `460` and `300`, and no `224`. See
  [Charges](./03-charges.md#which-processors-it-was-tried-on-expandattempts).
- **Fixed (docs).** `pre_dispute_alert.*` webhooks do not carry the same object as
  `GET /v1/pre_dispute_alerts`: the polled alert adds `auto_rule`, the processor, the sale's
  gateway id and the received / resolved times. Every `auto_rule.reason` value is now listed
  with what it means. `reason` reads `FRAUDULENT` on every Kount-fed alert, whatever its type.
  See [Webhooks](./16-webhooks.md#alert-payloads).
- **Fixed (docs).** Charge webhooks carry the raw 3-D Secure facts (`threeDsEci`,
  `threeDsVersion`, `threeDsCavvResult`, `threeDsLiabilityShift`), not the `authentication` /
  `liability` reading `GET /v1/charges/{id}` adds. The 2026-10-23 entry named the CAVV key in
  snake case; the webhook key is `threeDsCavvResult`.
- **Fixed (docs).** `three_ds.authenticated` is also `true` when no ECI can be read and the
  3-D Secure provider reported a shift; Mastercard ECI `06` and `07` read as
  `not_authenticated`.
- **Fixed (docs).** A void that becomes a refund refunds what is left of the charge, also on
  an unsettled charge already partly refunded or disputed, and answers `status: succeeded`
  with `amount_refunded`.
- **Fixed (docs).** Signed requests: the nonce is spent before the signature is checked, so
  retry with a new `X-Nonce`; a nonce store outage answers `503 service_degraded`. Rate
  limits are the same for every key, with no per-merchant setting. `three_ds_session_used`
  also answers the same key after 24 hours. `customer.*` `disputeCount` counts alerts and
  RDR records too. The `frameAncestors` mount option is in the options tables, and
  `PATCH /v1/payment_intents/{id}` lists `parameter_unknown` and the 50-minor-unit floor.
- **Fixed.** A blocklist IP written with upper-case letters (an IPv6 address such as
  `2001:DB8::1`) never matched a charge, because the charge-time check compares lower case.
  `POST /v1/report` now stores IP values in lower case, and `GET /v1/blocklist` returns them
  that way, existing entries included. Phone and BIN blocklist entries are stored as digits and
  are now checked on every charge; a list type that no charge check reads (country, email
  domain) is refused instead of being stored.

## 2026-10-23

- **Changed.** Wallet alert holds restored following the partner's decision on 2026-10-23:
  `skipWalletSales: true` holds every alert on a known Apple Pay / Google Pay sale, with or
  without ECI, including fraud, customer disputes and unknown categories (`wallet-sale`).
  Unknown wallets still wait for NMI V4 (`wallet-check-pending`). This supersedes the wallet
  evidence and fraud-only restriction recorded below; the option remains off by default.
  The 3-D Secure hold remains fraud-only with its CAVV checks. Visa RDR waits and the
  charged-back-sale guard are unchanged. See
  [Webhooks](./16-webhooks.md#pre-dispute-alerts-poll-by-timestamp-instead).

- **Fixed.** `worthypayments.js` TypeScript definitions: `/sdk/v1/worthypayments.d.ts` and
  `/sdk/worthypayments.d.ts` now include the types the bundle already exposes.
  `mountExpressCheckout()` reached the bundle without them, so a TypeScript project calling it
  failed to compile against the published file. Added: `mountExpressCheckout` on the client,
  and `ExpressCheckoutOptions`, `ExpressCheckoutHandle`, `ExpressCheckoutUpdate`,
  `ExpressPayment`, `ExpressBilling`, `ExpressShipping`, `ShippingMethod`, `ShippingAddress`,
  `ShippingSheet`, `ShippingUpdate`, `SheetLineItem` and `WalletsDrawn`. The script itself is
  unchanged. See [mountExpressCheckout](./08-embedded-sdk.md#mountexpresscheckouttarget-opts).
- **Changed.** Payment Workflow cascade retries a declined charge on the next processor only for
  a generic decline: `200` (declined by processor), `201` and `05` (do not honor). Insufficient
  funds (`202`, `51`), amount and frequency limits (`61`, `65`), a CVV failure (`N7`) and a
  decline with further instructions (`260`) are no longer retried on another processor: the same
  issuer answers them the same way seconds later, and each retry counts as a reattempt with the
  card networks. A route's own cascade codes can still only narrow this set. See
  [Charges](./03-charges.md#which-processors-it-was-tried-on-expandattempts).
- **Added.** `frameAncestors` on `mountPaymentElement`: the origins of the pages that frame your
  checkout, nearest first. With it, a checkout framed two levels deep renders in a Firefox older
  than 148, which reports only the page directly above; Chrome, Edge, Safari and Firefox 148 or
  later report the whole chain without it. Each origin must be on the key's allowed domains. See
  [Payment Element](./08b-payment-element.md#before-anything-renders-allowlist-your-domain).
- **Fixed (docs).** A checkout inside an iframe shows Apple Pay and Google Pay only when every
  iframe above the mounting page carries `allow="payment"`; Apple Pay needs Safari 17 or later
  there. The guide now says so. See
  [Payment Element](./08b-payment-element.md#before-anything-renders-allowlist-your-domain).
- **Fixed (docs).** The guides said a wallet's device token "cannot be saved" and that the
  gateway had to accept wallet tokens first. Saving an Apple Pay payment (or a Google Pay device
  token) for renewals is a current WorthyPayments limitation, not the gateway's: we do not store a
  wallet's token today. What the API does is unchanged. See
  [Subscriptions paid by wallet](./08b-payment-element.md#subscriptions-paid-by-wallet).
- **Changed.** Pre-dispute alert rules: a 3-D Secure or wallet hold now holds only a fraud alert
  (Ethoca fraud, or a CDRN fraud reason code). 3-D Secure and wallet cryptograms shift liability
  for fraud only, so a customer-dispute alert on a protected sale now follows the rule like any
  other alert, and its `auto_rule` names the rule that refunded it. A wallet sale is held only
  when its authentication is on record. See
  [Webhooks](./16-webhooks.md#pre-dispute-alerts-poll-by-timestamp-instead).
- **Changed.** For a merchant that receives Visa RDR, a rule's refund of an Ethoca or CDRN alert on
  a Visa sale waits until 4 hours before the alert's deadline, in case Visa RDR refunds the
  cardholder first. While it waits, `auto_rule.reason` is `rdr-wait`.
- **Added.** `auto_rule.reason` values `sale-already-charged-back` (the sale already has a
  chargeback, so the alert is not refunded: the cardholder has the money back) and
  `rdr-after-platform-refund` (on an RDR alert whose sale the platform had already refunded).
- **Added.** `auto_rule.alert_category` (`fraud`, `non-fraud`, `unknown`) and
  `auto_rule.hold_waived` (`non-fraud-alert`, `unknown-alert-category`) on a pre-dispute alert:
  how the alert was read when a rule's 3-D Secure or wallet hold matched, and why the hold stood
  aside. Both are `""` when no hold matched. See
  [Webhooks](./16-webhooks.md#pre-dispute-alerts-poll-by-timestamp-instead).
- **Added.** `rdr` on the dispute object (`GET /v1/disputes`, `GET /v1/disputes/{id}` and the
  nested `dispute` of every `dispute.*` webhook): `true` for a Visa Rapid Dispute Resolution
  record, which the issuer has already credited from your funds and which is not a chargeback.
  An acquirer's RDR case used to look like an accepted chargeback, with `alert_source` `""`. We
  never counted these as chargebacks; with `rdr` your own counts can leave them out too. See
  [Webhooks](./16-webhooks.md).
- **Changed.** A 3-D Secure session authenticates one charge. `POST /v1/charges` with
  `three_ds.session_id` binds the session to the first `Idempotency-Key` that presents it; the
  same key may present it again for 24 hours (the resubmit after a challenge, a retry), and a
  charge under any other key, or the same key later, is refused before anything is charged with
  `400 invalid_request_error`,
  `code: three_ds_session_used`, `param: three_ds.session_id`. It used to be accepted, carrying
  the first payment's authentication to a second one. An integration that resubmits with the same
  key, as these guides describe, is unaffected. See [3-D Secure](./07-3ds.md).
- **Added.** `three_ds.authentication` (`authenticated`, `attempted`, `not_authenticated`,
  `unknown`) and `three_ds.liability` (`shifted`, `not_shifted`, `unknown`) on a charge read and
  the charge returned by `POST /v1/charges`. `liability: "unknown"` is a charge with 3-D Secure
  whose verdict was never recorded, which an omitted `liability_shift` could not tell apart from
  "not shifted". See [Charges](./03-charges.md#three_ds-what-the-authentication-says-and-who-carries-a-fraud-chargeback).
- **Fixed.** `three_ds.authenticated` was `true` on any charge carrying an ECI, including an
  attempt (`06`, Mastercard `01`) and an ECI that means no authentication (`07`, Mastercard `00`).
  It is now `true` only for an authenticated ECI, and `card.three_d_secure.result` reads
  `attempt_acknowledged` for an attempt instead of `authenticated`.
- **Added.** `three_ds.cavv_result` and `three_ds.cryptogram_validation` (`passed`, `failed`,
  `information_only`, `not_validated`, `unknown`) on a charge read: the issuer's verification of
  the 3-D Secure cryptogram at authorization, as the gateway reported it (Visa, Amex and Discover;
  never Mastercard). The `transaction.enriched` webhook's `detail` carries the same result as
  `threeDsCavvResult`, and `transaction.updated` names `three_ds_cavv_result` in
  `changedFields` when it lands.
  See [Charges](./03-charges.md#three_ds-what-the-authentication-says-and-who-carries-a-fraud-chargeback).
- **Added.** `three_ds.liability_reason` on a charge read and the charge returned by
  `POST /v1/charges`: why `liability` reads as it does (`authenticated`, `attempt_acknowledged`,
  `provider_reported`, `issuer_check_denied`, `not_authenticated`, `not_granted`,
  `not_recorded`). See
  [Charges](./03-charges.md#three_ds-what-the-authentication-says-and-who-carries-a-fraud-chargeback).
- **Changed.** The liability verdict now accounts for that verification. A charge whose
  cryptogram the issuer failed (`1`, `4`, `7`, `9`, `I`), passed for information only (`B`), or
  could not check because invalid data was sent (`0`) reads `three_ds.liability: "not_shifted"`
  and omits `liability_shift`, even with an authenticated ECI: such a charge does not move a fraud
  chargeback to the issuer. The dashboard's "Liability" reads "Merchant" on it. A result the
  issuer did not validate (`C`, `D`, `6`, `U`) leaves the verdict as the ECI gave it.

## 2026-10-22

- **Changed.** Absolute links the platform hands out now use `https://app.worthypayments.com`:
  the `url` of a checkout link and a payment link, a charge's `receipt_url`, the hosted pay link on
  invoices and subscriptions, and the links in the emails we send. Links issued earlier on
  `https://app.paygeyt.com` keep working; both hosts serve the same pages. If your code checks the
  host of these URLs, or a Content-Security-Policy on your site lists it, allow
  `app.worthypayments.com` as well.
- **Changed.** The inbound webhook URLs the dashboard shows you to paste into a provider portal
  (PayArc, Kount) now use `https://webhooks.worthypayments.com`. A URL you registered earlier on
  `https://webhooks.paygeyt.com` keeps working; both hosts deliver to the same place, so nothing
  needs to change in a portal that already has one.
- **Changed.** The Node SDK (`@worthypayments/node`) defaults `baseUrl` to
  `https://api.worthypayments.com`. `https://api.paygeyt.com` serves the same API with the same
  keys; pass `{ baseUrl: "https://api.paygeyt.com" }` to stay on it.
- **Fixed (docs).** [Charges](./03-charges.md#client-ip-fraud-signal-not-a-body-field),
  [Integration hard rules](./27-integration-hard-rules.md#1-client-ip) and three other guides
  still warned that a card-testing burst could auto-block your server's IP on a server-side
  charge, so renewals needed an allowlist. That stopped being true on 2026-10-19: a charge sent
  with a secret key records your server's address apart from the buyer's and never scores or
  blocklists it. Nothing to allowlist; send a
  [`device_session`](./24-device-data-collection.md#device-session-the-buyers-ip-and-browser)
  to score a server-built charge on the buyer's IP.

## 2026-10-21

- **Added.** The platform answers on worthypayments.com: `https://api.worthypayments.com/v1`,
  `https://app.worthypayments.com/sdk/v1/worthypayments.umd.js` and
  `https://element.worthypayments.com`, and these guides now show those hosts. The paygeyt.com
  hosts are the same API, keys, data and SDK file and keep working, so an existing integration
  needs no change. See [Two domains](./README.md#two-domains).
- **Added.** `worthypayments.js` loaded from `app.worthypayments.com/sdk/v1/` defaults to
  `https://api.worthypayments.com/v1` and `https://element.worthypayments.com`, so a page that
  loads the SDK from worthypayments.com stays on that domain. Loaded from
  `app.paygeyt.com/sdk/v1/`, bundled from npm, or hosted on your own site, it keeps the
  paygeyt.com hosts it always used. `apiBase` and `elementOrigin` still override both. See
  [Embedded SDK](./08-embedded-sdk.md).
- **Added.** WooCommerce plugin 2.2.0: an order whose buyer never came back after paying (the
  page closed after 3-D Secure, a lost redirect) is settled even without a webhook. The plugin
  reads the order's intent itself 10 minutes after the checkout handed it out, then after 30
  minutes, 2 hours and 12 hours. Before WooCommerce's **Hold stock** cleanup cancels an unpaid
  order, the plugin reads its intent once more, and an order whose payment succeeded or is
  still settling is never cancelled. See [How a payment works](./09-woocommerce.md#how-a-payment-works).
- **Changed.** WooCommerce plugin 2.2.0: `dispute.created` for a chargeback puts a paid order
  **On hold**, `dispute.won` restores the status it had, and `dispute.lost` marks it
  **Failed**, as Stripe's WooCommerce plugin does. A pre-dispute alert and the other dispute
  events still only add an order note, and `dispute.closed` no longer adds one (it repeats the
  `dispute.won` / `dispute.lost` it comes with). These status changes send the customer no
  WooCommerce order e-mail. See [Disputes](./09-woocommerce.md#disputes).
- **Added.** WooCommerce plugin 2.2.0: the order screen shows the card the order was paid with
  (brand and last four), its mode, and a link to the transaction in your dashboard; the
  transaction id next to **Payment via** links there too. See
  [The order screen](./09-woocommerce.md#the-order-screen).
- **Added.** WooCommerce plugin 2.3.0: saving the settings checks every key with
  `GET /v1/ping`. It reports a refused key, a key of the other mode, each missing secret-key
  scope, and a publishable key whose allowed domains do not list the site. While the method is
  on in test mode, every admin screen shows a test-mode notice. See
  [Configure](./09-woocommerce.md#configure).
- **Added.** WooCommerce plugin 2.3.0: **Set up the webhook for me** (on by default) registers
  the store's endpoint with `POST /v1/webhook-endpoints` when a mode's signing secret is empty,
  with the `charge.*`, `dispute.*`, `pre_dispute_alert.*` and `setup_intent.*` events the
  plugin uses, and saves its signing secret; an existing endpoint for the same URL gets its
  secret rolled instead. Needs `webhooks:read` and `webhooks:write` on the secret key. See
  [Webhooks](./09-woocommerce.md#webhooks).
- **Added.** WooCommerce plugin 2.3.0: card-testing protection. A buyer, counted by IP
  address, WooCommerce session and billing e-mail, may make 10 payment attempts an hour (the
  **Payment attempts per hour** setting; `0` turns it off); past that, checkout refuses the
  attempt before it reaches our API. See [Card testing](./09-woocommerce.md#card-testing).
- **Added.** WooCommerce plugin 2.4.0: a renewal declined `soft_decline_3ds` (the bank wants the
  customer to confirm the payment, and a renewal never runs 3-D Secure) e-mails the customer
  **Renewal payment needs confirmation**, with a link to the renewal's payment page, as Stripe's
  plugin does; WooCommerce Subscriptions' failed-renewal and retry e-mails and WooCommerce's
  failed-order e-mail are not sent for it. See
  [WooCommerce Subscriptions](./09-woocommerce.md#woocommerce-subscriptions).
- **Added.** WooCommerce plugin 2.4.0: My account → Subscriptions and the admin's subscription
  screens name the card renewals charge (*Via Visa ending in 4242*).
- **Breaking.** WooCommerce plugin 2.4.0: saved-card payments and renewals carry the same
  `metadata` keys as payments made in the Element: `order_id` and `order_number`, plus
  `subscription_id` on a renewal. They carried `wc_order_id`, `wc_subscription_id` and
  `wc_site`; read the new keys in `charge.*` webhooks and `GET /v1/charges`. Charges made
  before the update keep their old keys, so code that reads an order id from a charge should
  read `order_id` and fall back to `wc_order_id`.
- **Changed.** WooCommerce plugin 2.4.0, classic checkout and the pay-for-order page: placing
  an order with a saved card waits up to 2 seconds for the buyer's device signals, as the
  Checkout block already did, so the charge carries a current device session.
- **Added.** Payment Element: while 3-D Secure runs for a confirm from the card form, the
  element covers the page with a dimmed layer, a spinner and "Verifying your card…" in the
  element's `locale`. The bank's challenge sits above it; clicks on the page are blocked and
  focus is held, so the page's own submit button cannot be pressed twice. It comes down
  before `onSuccess` or `onError` runs. On by default, with no option, as Stripe draws its own
  3-D Secure UI. See [The 3-D Secure overlay](./08b-payment-element.md#the-3-d-secure-overlay).
- **Changed.** Payment Element: `onEscape` is not called while 3-D Secure runs, and Escape
  pressed on the page then does not reach the page's own key handlers. A dialog that closed on
  Escape tore the element down in the middle of the authentication.
- **Changed.** Payment Element: a confirm waits at most 2.5 s for the device fingerprint and
  the Kount session, and the element computes the fingerprint while the buyer types. A
  buyer's first Pay could wait up to 10 s for the fingerprint library and then for the
  fingerprint itself with no limit. A confirm made before the element's fingerprint library had
  loaded also no longer goes out without a fingerprint.
- **Fixed.** Checkout links (hosted checkout): the Apple Pay sheet asks the buyer for their
  name, email and billing address. It asked for none of them, so when the checkout had no
  customer email yet (none typed on the page), the buyer confirmed in the sheet and the
  payment was then refused with nothing charged, and Apple Pay payments reached you with no
  customer name and no billing address for AVS. Google Pay is unchanged: it already asked for
  the email and the full billing address.
- **Added.** `worthypayments.js`: `mountExpressCheckout()`, Apple Pay and Google Pay buttons
  with no card form, for a product page, a cart or above your checkout (Stripe's Express
  Checkout Element). The buyer picks the shipping address and method inside the sheet; your
  `onAddressChange` / `onMethodChange` answer the new total. Nothing is created until the
  buyer authorizes: `createIntent` then receives the buyer's email, name, phone, billing and
  shipping address and the chosen method, so a guest can pay. A total that differs from the
  one the sheet showed last charges nothing (`amount_changed`), and an address you cannot ship
  to is refused before `createIntent` runs (`shipping_unavailable`). See
  [mountExpressCheckout](./08-embedded-sdk.md#mountexpresscheckouttarget-opts).
- **Fixed.** `worthypayments.js` Apple Pay buttons (Payment Element): tapping the button
  opens the Apple Pay sheet. The SDK asked for the buyer's details in a shape Evervault's
  Apple Pay no longer accepts, and the sheet failed before it opened.
- **Fixed.** `worthypayments.js` Google Pay buttons (Payment Element): the sheet asks for the
  full billing address and phone, and the payment carries the buyer's name and address for
  AVS, as the guides said. Only the email came back.
- **Changed.** `worthypayments.js`: with `recurring` set, Google Pay offers only the cards it
  hands back as the card itself (`PAN_ONLY`), which are saved and can renew. A card on the
  buyer's Android device alone (a device token, `card_not_saveable`) is no longer offered on a
  subscription. See
  [Subscriptions paid by wallet](./08b-payment-element.md#subscriptions-paid-by-wallet).
- **Changed.** WooCommerce plugin 2.5.0: **Express checkout** shows the Apple Pay and Google
  Pay buttons themselves on product, cart and checkout pages and in the Cart and Checkout
  blocks, instead of a button that opened a payment sheet. Guests can pay, and the buyer picks
  the shipping address and method in the wallet sheet, each priced by WooCommerce; the order is
  placed with the sheet's e-mail, name, phone, billing and shipping address only when the buyer
  authorizes. With it on, the checkout's own payment form shows no second set of wallet
  buttons. See [Express checkout](./09-woocommerce.md#express-checkout).
- **Added.** WooCommerce plugin 2.5.0: a subscription can be paid with Google Pay through
  Express checkout; its card is saved and renewals charge it. Apple Pay is not offered for a
  subscription (its token cannot be saved yet). See
  [Express checkout](./09-woocommerce.md#express-checkout).
- **Added.** WooCommerce plugin 2.5.0: the plugin serves Apple's domain verification file at
  `/.well-known/apple-developer-merchantid-domain-association`, and saving the settings while
  Apple Pay is on checks that the site serves it. See
  [Apple Pay on your domain](./09-woocommerce.md#apple-pay-on-your-domain).
- **Added.** WooCommerce plugin 2.5.0: deleting the plugin removes its settings, caches and
  scheduled jobs, never an order, payment record or saved card; a translation template ships
  in `languages/`. The plugin now requires WordPress 6.2 (WooCommerce 8.0's own minimum). See
  [Install](./09-woocommerce.md#install).

## 2026-10-20

- **Added.** `GET` and `POST /v1/fraud/three_ds_policy`: read, turn off, or move the
  risk-score 3DS step-up (by default a charge scoring 70–95 is stepped up). If you decide
  3DS yourself with `three_ds.request`, send `{ "risk_step_up": { "enabled": false } }` so
  your rules are the only trigger. Needs `fraud:read` / `fraud:write`. Nothing changes until
  you call it. See [Fraud checks](./23-fraud-checks.md#the-risk-score-3ds-step-up).
- **Fixed.** Payment Element: an `element.update()` called before the element has finished
  loading is no longer lost. An early `update({ clientSecret })` left the element confirming
  the previous intent, and an early `locale`, `appearance` or `defaultValues` did not show.
  See [Payment Element](./08b-payment-element.md#2-mount-the-element-browser).
- **Fixed.** Payment Element: `element.update({ defaultValues })` followed by
  `element.submit()` in the same tick now confirms the billing details the update carried.
  The submit used the values from before the update, so a page that hands over its own
  billing form at submit confirmed with no billing address, and the issuer had nothing to
  check for AVS. In the same way `update({ clientSecret })` followed by `submit()` confirmed
  the previous intent, and a `submit()` sent as the element finished loading was dropped.
  See [Payment Element](./08b-payment-element.md#three-ready-made-forms).
- **Fixed.** Payment Element: `defaultValues.billingDetails.address.line2` reaches the
  payment, from the card form and as the Apple Pay / Google Pay default. It was dropped, and
  the `BillingDefaultValues` type did not accept it. It rides with `line1`, and is not sent
  when the buyer types a different street.
- **Changed.** Payment Element: when 3-D Secure does not authenticate the card
  (`three_ds_failed`) or the buyer closes the challenge (`three_ds_cancelled`), the form and
  `onError`'s `message` give the buyer's sentence in the element's `locale`, as for a
  decline. They gave an English diagnostic ("3DS challenge cancelled.", "3DS authentication
  failed (…)."); `code` and `type` are unchanged. A decline with `decline_code:
  three_ds_failed` now reads the same sentence instead of "Your card was declined. Try another
  card." The form's "Please complete the payment details." is in Spanish on a Spanish form.
  See [Payment Element](./08b-payment-element.md#errors-worth-recognising).
- **Fixed.** Payment Element: a 3-D Secure authentication that completes without a challenge
  no longer grows the element to 620 px for a moment. The page below it jumped by about
  400 px and back.
- **Fixed.** Payment Element: the iframe's height is set with `!important`, so a page
  stylesheet such as `iframe { height: auto !important; }` no longer draws the form at the
  browser's default 150 px, clipped.
- **Fixed.** A charge with an attempted 3-D Secure authentication on Mastercard (ECI `01`) is
  now recorded as shifting liability, as an attempt on Visa, Amex and Discover (ECI `06`)
  already was: `three_ds.liability_shift` is `true` on `GET /v1/charges/{id}`, the `charge.*`
  webhooks' `detail.threeDsLiabilityShift` is `true`, and the dashboard counts the charge as
  liability-shifted. It was recorded as not shifted. Charges recorded before this change are
  corrected; no webhook is sent for them. See [3-D Secure](./07-3ds.md#liability-shift).
- **Changed.** While your account is not yet live, the test-mode transactions we sync back from
  NMI come from your own NMI sandbox account, the one whose key you added under Settings >
  Integrations. They used to come from the sandbox account the platform shared between
  accounts. Without your own sandbox key nothing is synced back in test mode. Test transactions
  synced earlier from the shared account are not changed or removed. Live mode is unchanged.
  See [Testing](./15-testing.md#your-own-nmi-sandbox-key).
- **Changed.** WooCommerce plugin 2.1.0: an order that starts or renews a subscription always
  saves its card. Its payment intent is created with `setup_future_usage: "off_session"` and the
  order's customer (so a buyer who creates their account at checkout is covered), the Element
  draws no **Save this card** box and no Apple Pay / Google Pay buttons for such a cart, and the
  checkout tells the buyer the card is kept for the renewals. For such an order the Store API's
  `worthypayments_save_card` is now `never`, and the new `worthypayments_setup_future_usage` is
  `off_session`. See [WooCommerce Subscriptions](./09-woocommerce.md#woocommerce-subscriptions).
- **Added.** WooCommerce plugin 2.1.0: a $0 subscription sign-up (a free trial) saves its card
  with no payment, through a [setup intent](./08b-payment-element.md#saving-a-card-without-a-payment-setup-intents),
  on the classic checkout, the Checkout block and the Store API. The Store API answers
  `worthypayments_setup_intent` (`seti_…`) and its client secret instead of a payment intent;
  the second call sends `payment_data` `worthypayments_setup_intent`. The plugin completes such
  an order on `setup_intent.succeeded` too: add the `setup_intent.*` events to your store's
  webhook endpoint. See [Headless checkout](./09-woocommerce.md#headless-checkout-store-api).
- **Added.** WooCommerce plugin 2.1.0: **My account → Payment methods → Add payment method**
  saves a new card with no payment, and a subscription's **Change payment** page takes a new
  card as well as a saved one. See [Saved cards](./09-woocommerce.md#saved-cards).
- **Added.** WooCommerce plugin 2.1.0: every pending Store API answer carries
  `worthypayments_intent_type` (`payment_intent` or `setup_intent`), and
  `GET /wc/store/v1/cart` carries `extensions.worthypayments.subscription` (the cart starts a
  subscription). See [Headless checkout](./09-woocommerce.md#headless-checkout-store-api).
- **Changed.** WooCommerce plugin 2.1.0, classic checkout and Checkout block: Place order is
  answered *The payment form is still loading* (and nothing is placed) until the card form has
  loaded; while a payment confirms, the rest of the form is frozen and a busy line shows; a card
  form that loads late clears its "could not load" notice; and a declined `soft_decline_3ds`
  saved card switches the Checkout block to the new-card form.
- **Fixed (docs).** WooCommerce's Store API second call must repeat the whole first body
  (`billing_address` and `payment_method` are required on every call) with an unchanged cart,
  or `POST /wc/store/v1/checkout/{order_id}` finishes one particular order. The guide said to
  post the payment data alone. See [Headless checkout](./09-woocommerce.md#headless-checkout-store-api).

## 2026-10-19

- **Fixed (docs).** An attempted 3-D Secure authentication on Mastercard (ECI `01`) normally
  shifts liability, as an attempt does on Visa, Amex and Discover (ECI `06`). The guide said
  Mastercard's did not. `liability_shift` on the 3DS session, which is the network's own
  answer, is unchanged. See [3-D Secure](./07-3ds.md#what-the-eci-says).
- **Fixed (docs).** Wallets: a Google Pay `PAN_ONLY` card carries no cryptogram, does not
  satisfy SCA and can be stepped up like a card, and a Google Pay payment shifts liability
  only on ECI `05` (Visa) or `02` (Mastercard). The guides said every wallet satisfied SCA and
  carried its own shift. See [Charges](./03-charges.md) and [3-D Secure](./07-3ds.md).
- **Fixed (docs).** The capture windows (7 days; 4 days 18 hours for a Visa merchant-initiated
  charge) are ours and fit inside the card networks' own limits; the guides called them the
  networks' windows. An authorization canceled at the end of its window is not voided at the
  gateway, so the buyer's hold lasts until their bank drops it; the guide said the bank had
  already released it. See [Authorize and capture](./17-auth-capture.md).
- **Fixed (docs).** `description` does not reach the buyer's card statement, and a charge's
  `statement_descriptor` is the descriptor we stored, not necessarily the text the bank
  prints. The guide said both did. See [Charges](./03-charges.md).
- **Fixed (docs).** Subscription renewals are merchant-initiated and charged without 3-D
  Secure; the guide said every renewal ran it. See [Subscriptions](./18b-subscriptions.md).
  In [WooCommerce](./09-woocommerce.md), Subscriptions retries a declined renewal only when
  its **Retry Failed Payments** setting is on, which is off by default.
- **Added.** `metadata` on payment intents: your own key/value pairs on
  `POST /v1/payment_intents`, with the `POST /v1/charges` rules and error messages. It is
  answered with `reference` (on create, on update, and on retrieve to secret and restricted
  keys; never to a publishable key), copied onto the charge the intent makes when the buyer
  pays (so `GET /v1/charges/{id}` and the `charge.*` webhooks' `detail.metadata` carry it),
  and carried by `checkout.session.completed`. A GDPR customer deletion clears it.
- **Added.** `PATCH /v1/payment_intents/{id}` takes `reference`, `customer`, `receipt_email`
  and `metadata`, as well as the amount, while the intent is still open (the same states as
  a re-price; a paid, in-flight or expired intent is still a `400 failed_precondition`).
  `metadata` is merged as on Stripe: a key sent as `""` is removed, keys not sent are kept,
  at most 50 keys after the merge. `reference` and `receipt_email` are cleared with `""` or
  `null`. `customer` is checked like on create, cannot be removed, and cannot change once a
  payment attempt on the intent started (`400 failed_precondition`, `param: customer`).
  `amount` is no longer required when the request changes a detail; a request with neither
  is refused on `amount` as before. The WooCommerce plugin now sends the order's `order_id`
  and `order_number` as the intent's metadata. See
  [Payment Element](./08b-payment-element.md#metadata-and-the-other-details).
- **Fixed.** `PATCH /v1/payment_intents/{id}` changes payment intents only, as documented.
  A `pi_…` id built from another checkout session (a payment link's, an invoice's, a
  subscription's first payment, a store cart) is now a `404` `resource_missing` and nothing
  changes. It used to re-price that open session, so its buyer could be charged an amount
  other than the link or invoice they were sent.
- **Changed.** Apple Pay and Google Pay run on the `EVERVAULT` rail only.
  `GET /v1/wallet-config` no longer has `collectJsTokenizationKey`, and its `provider` is
  `EVERVAULT` or `NONE`. In `worthypayments.js`, `WalletProvider` is `"EVERVAULT" | "NONE"`,
  and `mountWalletButton()` no longer loads the gateway's Collect.js script. A wallet that
  works today keeps working. See [Payment Element](./08b-payment-element.md).
- **Added.** Device sessions give a charge your server sends the buyer's own IP address and
  browser. `wp.getDeviceSession()` in worthypayments.js calls the new
  `POST /v1/device_sessions` from the buyer's browser with your publishable key and resolves a
  `ds_…` id; send it as the new optional `device_session` on `POST /v1/charges`. A session
  lasts 30 minutes, belongs to the account and mode that created it, and may go on more than
  one charge in that time. An unknown or expired one is a `400` with
  `param: "device_session"` (`resource_missing` or `device_session_expired`) that does not
  consume the `Idempotency-Key`. A charge without `device_session` behaves as before, except
  for what it is scored on: with a secret or restricted key, the IP we see is your server's,
  and it is now recorded as your server's address and no longer counted as a buyer IP by
  per-IP velocity, the IP blocklist or the cascade, so one server address can no longer get
  every buyer of your store blocked. Responses do not change. The WooCommerce plugin sends the
  session with a saved-card payment. See
  [Device data collection](./24-device-data-collection.md#device-session-the-buyers-ip-and-browser).
- **Added.** `device_session` on `POST /v1/payment_intents/{client_secret}/confirm` and
  `POST /v1/setup_intents/{client_secret}/confirm`, for a confirm your server sends. The same
  rule as on charges now applies to both: confirmed with a secret or restricted key, your
  server's IP is no longer scored as the buyer's. A confirm from the buyer's browser, the
  Payment Element's included, is unchanged. Responses do not change; an unknown or expired
  session is a `400` with `param: "device_session"` and nothing is charged or verified.
- **Added.** `GET /v1/charges/{id}?expand[]=attempts` lists every processor the charge was
  tried on, in order: the processor, how the attempt ended (`approved`, `declined`, `error`,
  `timed_out` or `pending`, with `reconciled: true` when a lost answer was confirmed
  approved at the gateway), its `decline_code` and `response_code`, whether it was a cascade
  step, when it was sent and how long it took. `final` marks the attempt that produced the
  result. A fraud-blocked charge answers `[]`. Without `expand[]` the charge is unchanged,
  and an `expand[]` value we do not have is ignored. Not on `GET /v1/charges`, and never
  with a publishable key. See
  [Charges](./03-charges.md#which-processors-it-was-tried-on-expandattempts).
- **Fixed.** `created[gte]`, `created[gt]`, `created[lte]` and `created[lt]` on
  `GET /v1/charges` and `GET /v1/customers` now filter the list, as
  [Pagination](./13-pagination.md#filtering-by-creation-time) describes. Until now these bounds
  were ignored: the list came back unfiltered, newest first, as if no `created` had been
  sent. Only a bare `created=<seconds>` worked. If your integration sends these bounds and
  relied on getting every row back, it now gets only the rows created in that window. The
  bounds are now also checked: one in milliseconds, or not a whole number of seconds, is a
  `400` `parameter_invalid_integer` naming it, and a bound sent twice, or `created=` sent
  together with `created[...]`, is a `400` `parameter_invalid`. A list call without
  `created[...]` behaves as before.

## 2026-10-18

- **Breaking.** Test mode needs your own NMI sandbox key. A test-mode call that reaches the
  gateway (a charge, a payment intent or setup intent confirm, saving a card, a capture, void
  or refund) on an account without one is refused with `403` `sandbox_key_required`. It used
  to run on a sandbox account the platform shared between every account, whose saved-card
  storage had filled up, so test card saves were failing for everyone. Add your key in the
  dashboard under Settings > Integrations (NMI card, test mode); no NMI sandbox account yet:
  create one at [guide.nmi.com](https://guide.nmi.com/). Test cards saved before this change
  sit in the shared account and can no longer be charged: remove them and save them again.
  Live mode is unchanged. See [Testing](./15-testing.md#your-own-nmi-sandbox-key).
- **Fixed.** `wp.getFingerprint()` now returns the browser fingerprint on your own pages, as
  [Device data collection](./24-device-data-collection.md#browser-fingerprint) describes. On
  any page outside our domain it resolved `null`, because the browser refused the fingerprint
  library the SDK loads, so a charge you build server-side carried no `device.fingerprint`.
  Your code does not change. The Payment Element was not affected.
- **Fixed.** A refusal by the card gateway that is not about the billing address (a duplicate,
  an invalid expiry, a vault the gateway would not write) no longer reads as an address
  failure: its `decline_code` is `processing_error` with "The transaction could not be
  processed.", where it was `avs_rejected` with "The billing address could not be verified."
  A refusal by your gateway's address rule is still `avs_rejected`. This shows on a setup
  intent's `last_setup_error`.
- **Changed.** `POST /v1/charges` on a saved card with `"initiated_by": "customer"` (the buyer
  paying now on your checkout) now applies your 3-D Secure rules, as the Payment Element does:
  the route's **Attempt 3DS**, the 3DS risk band, a customer marked **Require 3DS** and a fraud
  rule asking for 3DS. The card is authenticated on the encrypted copy kept when it was saved:
  the charge answers `requires_action` with `next_action.three_ds.session_id`, and you resubmit
  it with `three_ds.session_id` under the same `Idempotency-Key`. A card with no such copy
  (saved before 2026-10-18, or imported from your gateway vault) is declined `402
  card_declined`, `decline_code: soft_decline_3ds`, with nothing charged: ask the buyer to enter
  the card again. These charges used to go through without 3DS. `three_ds.request` is now
  accepted on such a charge; it is still a `400` on a merchant-initiated saved-card charge,
  which stays without 3DS. See [3DS on a saved card](./07-3ds.md#3ds-on-a-saved-card).
- **Fixed.** A hosted-checkout or Payment Element payment left on "processing" by an attempt
  that died before reaching the gateway now resolves as not charged after 15 minutes, so the
  buyer can pay again; it used to stay processing for up to 24 hours. An attempt that was paid
  meanwhile resolves as paid.
- **Fixed.** A decline-recovery retry that stalled without taking any money no longer blocks its
  recovery: it is retried at the next scheduled time, or the recovery ends at its last attempt
  (`decline_recovery.failed`). A recovery whose original payment has since been paid stops
  without charging again.
- **Fixed.** A capture or void that stalled and finds the payment already moved on (a later
  void, an expiry, a void that completed meanwhile) books nothing and answers the same
  `failed_precondition` refusal as any charge that cannot be captured or voided, instead of
  overwriting the newer state.
- **Fixed.** A charge whose gateway answer arrived after our background reconciliation had
  already settled it is no longer booked a second time: the charge answers with the settled
  result, and an approval the reconciliation had closed as not found takes the payment back.
- **Fixed.** A charge on a saved card is sent with the gateway account that holds the card. A
  saved card exists in one processor account only, and a charge routed to another account used
  to fail; now the charge moves to the card's own account, and a fallback route is tried only
  on that same account. Responses are unchanged.
- **Changed.** Cards imported from your existing NMI customer vault keep the stored-credential
  series your own NMI integration began, so their renewals reference the original transaction.
  Removing an imported card here never deletes it at NMI; your own integration may still use it.
- **Added.** `worthypayments.js`: a `recurring` option on `mountPaymentElement` for a payment
  that starts a subscription. Apple Pay opens as a recurring payment request (your billing
  agreement, cadence, optional trial and a management link), and a wallet payment asks to save
  its payment method, as a ticked "save my card" does. A Google Pay card returned as the plain
  card is saved and can renew; Apple Pay and tokenised Google Pay still answer
  `card_save: skipped` / `card_not_saveable` for now. Amounts are in minor units. See
  [Payment Element](./08b-payment-element.md).
- **Added.** Tax rates for your own store (secret or restricted keys): `GET`/`POST
  /v1/tax_rates`, `GET`/`POST`/`DELETE /v1/tax_rates/{id}` (delete is a soft delete), attach
  and detach a rate on a product with `POST /v1/products/{id}/tax_rates` and
  `DELETE /v1/products/{id}/tax_rates/{tax_rate}`, and `POST /v1/tax/calculate` for up to 100
  lines. Rates are percent strings with at most two decimals (`"7.25"`). New scopes `tax:read`
  and `tax:write` (server-only), not on new keys by default.
- **Added.** Fraud controls for your own tools (secret or restricted keys): `GET`, `POST` and
  `DELETE /v1/customers/{id}/three_ds` read, set and clear a customer's Require 3DS flag;
  `GET /v1/fraud/rules` lists your fraud rules and `GET /v1/payment_workflow/routes` your payment
  routes, read-only. New scopes `fraud:read` and `fraud:write` (server-only). A platform rule
  still outranks a flag or a request. See [Fraud checks](./23-fraud-checks.md).
- **Changed.** A 3-D Secure session left pending for more than 24 hours can no longer be
  completed; start a new one. Deleting a customer now also clears the buyer details kept on
  their pending 3DS sessions. See [3-D Secure](./07-3ds.md).
- **Changed.** 3-D Secure now applies to a saved card the buyer pays with in the Payment
  Element or on the hosted checkout page. Your own rules apply to it (a route with Require
  3DS on, your 3DS policy, a fraud rule asking for a challenge), and so does
  `three_ds.request` on a payment intent; such a card used to be charged without 3-D Secure.
  The card is authenticated where it is: the answer is `requires_action`, and the buyer
  completes the bank's check without re-entering the card. A card saved before this date, or
  imported, has no copy to authenticate: that attempt is declined `soft_decline_3ds` with
  nothing charged, the buyer enters the card once more, and it is kept for the next time.
  On `POST /v1/charges`, a buyer-present saved-card charge follows the same rules (see the
  `initiated_by` entry above); a merchant-initiated one still refuses `three_ds.request`
  (`400`), and the error now says to collect the card again. See
  [3DS on a saved card](./07-3ds.md#3ds-on-a-saved-card).
- **Added.** `setup_future_usage` (`off_session` | `on_session`) on
  `POST /v1/payment_intents`: the card the intent is paid with is saved to the intent's
  `customer` whatever the buyer chooses, as the card's initial stored-credential payment. The
  Payment Element draws no "save this card" box for such an intent; a `mode: 'payment'` mount
  takes `setupFutureUsage` for the same. `customer` is required with it (400
  `parameter_missing`, `param: customer`). Every read of the intent answers
  `setup_future_usage` (`null` when unset). See
  [Always saving the card](./08b-payment-element.md#always-saving-the-card-subscriptions).
- **Added.** Setup intents save and verify a card with no payment: `POST /v1/setup_intents`
  (secret key, `customers:write`), `GET /v1/setup_intents/{client_secret}` and
  `POST /v1/setup_intents/{client_secret}/confirm`. The confirm runs a $0 verification that
  saves the card to the customer as its initial stored-credential transaction, with 3-D Secure
  when `three_ds.request` asks for it. The Payment Element mounted with a setup intent's
  `client_secret` (`seti_…`) is a card form with a "Save card" button, and `onSuccess`
  receives `setupIntentId` and `payment_method`. New webhook events `setup_intent.succeeded`
  and `setup_intent.setup_failed`. See
  [Saving a card without a payment](./08b-payment-element.md#saving-a-card-without-a-payment-setup-intents).
- **Added.** `POST /v1/setup_intents/{id}/cancel` (secret key, `customers:write`) cancels an
  open setup intent, with an optional `cancellation_reason` (`abandoned` | `duplicate` |
  `requested_by_customer`); a canceled intent takes no card (400
  `setup_intent_unexpected_state`). `GET /v1/setup_intents` (secret key, `customers:read`)
  lists them newest first, cursor-paged, optionally for one `customer`. Every setup intent
  now answers `cancellation_reason` (`null` unless canceled) and may read `status: canceled`.
- **Added.** A setup intent's confirm meets the same fraud screening as a payment (your
  blocked customers and cards, card-testing detection, your velocity rules) before the card
  is verified. A refusal is a declined attempt: 200 `requires_payment_method` with
  `last_setup_error` `card_declined` / `decline_code` `fraudulent`, counted toward the
  intent's five attempts.
- **Added.** `PATCH /v1/subscriptions/{id}` takes `default_payment_method` (`pm_…`): the card
  later renewals charge, switched with no charge now, typically the one a setup intent just
  saved. It must be an active saved card of the subscription's customer (else 404). See
  [Changing a subscription's card](./08b-payment-element.md#changing-a-subscriptions-card).
- **Added.** `worthypayments.js`: `mountPaymentElement({ mode: 'setup', createIntent })`
  mounts the element before its setup intent exists; `createIntent` resolves the `seti_…`
  secret your server created. A deferred mount now refuses a secret of the other kind
  (`invalid_client_secret`): a setup intent's on `mode: 'payment'`, or a payment intent's on
  `mode: 'setup'`.
- **Changed.** A merchant-initiated charge on a saved card (a subscription renewal, an invoice
  charge, or `POST /v1/charges` on a saved card) is never sent to 3-D Secure: there is no buyer
  to complete a challenge, so it is charged without one. A customer flagged Require 3-D Secure
  or a fraud rule asking for a challenge used to decline it `soft_decline_3ds`. A fraud rule
  that blocks still blocks it. See [3-D Secure](./07-3ds.md).
- **Changed.** Automatic decline recovery now retries only charges we schedule ourselves with
  no buyer present: renewals of our subscriptions and invoice charges. A declined
  `POST /v1/charges` is no longer retried by us, so your own billing system's retry rules are
  the only retry and a renewal cannot be charged twice. A saved card the buyer chooses at the
  hosted checkout or in the Payment Element is not retried later either: the buyer sees the
  decline and can pay another way. Responses are unchanged. See [Charges](./03-charges.md).
- **Added.** `worthypayments.js` mounts the Payment Element before a payment intent exists:
  `mountPaymentElement(target, { mode: 'payment', amount, currency, createIntent })`, Stripe's
  `elements({ mode: 'payment', amount, currency })`. `createIntent` creates the intent on your
  server when the buyer pays (Pay, `element.submit()` or an approved wallet sheet) and resolves
  its `client_secret`; the element then confirms it as before. `amount` is the wallet sheet
  total; the intent's amount is the one charged. `element.update({ amount, currency })` redraws
  the wallet buttons on such a mount. New error codes: `intent_creation_failed` on `onError`;
  `client_secret_with_mode`, `invalid_mode`, `invalid_amount`, `invalid_currency`,
  `missing_create_intent` at mount, and `update_not_supported` from `update()`. A
  `clientSecret` mount is unchanged. See
  [Mount before the order exists](./08b-payment-element.md#mount-before-the-order-exists).
- **Added.** `three_ds.request` on `POST /v1/charges`, `POST /v1/payment_intents` and
  `POST /v1/3ds/sessions`: `any`, `challenge` or `challenge_mandated` asks for 3-D Secure on
  that payment even when no rule does (`automatic`, the default, leaves it to your rules). A
  rule always outranks a request. Only on a card entered for this payment. `worthypayments.js`
  types it on `CreateChargeParams`. See [3-D Secure](./07-3ds.md).
- **Breaking.** A charge resubmitted with a `three_ds.session_id` whose session did not
  authenticate is refused instead of being charged without authentication: `402`
  `three_ds_failed` or `three_ds_card_not_enrolled` (stored under the `Idempotency-Key`), `409`
  `three_ds_session_incomplete` while it is still pending, and `503`
  `three_ds_result_unavailable` when the result cannot be read (both released for a retry).
  An unknown key inside `three_ds` (or `session_id: null`) is a `400`.
- **Added.** `failure_reason` on `GET /v1/3ds/sessions/{id}`: a normalised reason such as
  `card_not_enrolled` or `authentication_failed`.
- **Added.** A charge's `metadata` is stored: `GET /v1/charges/{id}`, the charge list and the
  charge webhooks (`detail.metadata`, including `charge.canceled` and `charge.expired`) return
  what you sent on `POST /v1/charges`; it used to come back only on the create response. It is
  cleared if the customer is deleted (GDPR), everywhere it is copied.
- **Breaking.** `metadata` on `POST /v1/charges` is validated: up to 50 keys, each 1-40
  characters without square brackets, each value a string of up to 500 characters. Anything
  else is a `400` `parameter_invalid` with `param: metadata`; such a body was accepted and
  ignored before.
- **Changed.** A charge on a saved card (API, Payment Element, subscription renewal, invoice)
  now sends the card network the transaction id of the payment the card was saved from,
  when one was recorded, and a decline-recovery retry goes out as a merchant-initiated
  charge on the stored card. Responses are unchanged.
- **Added.** `initiated_by` on `POST /v1/charges` with a saved payment method: `customer`
  when the cardholder is paying now with a card they saved, `merchant` (the default) for a
  merchant-initiated charge. Sending it with a fresh card, a wallet, `wp_token` or ACH, or
  `customer` with `recurring: true`, is a `400` `parameter_invalid`.
- **Changed.** Card networks now receive the credential-on-file indicators they expect: the
  charge that saves a card is flagged as the stored credential's first use, a saved card used
  by the buyer at checkout is customer-initiated, and the first charge of a subscription
  (hosted checkout, or a store or checkout-link sale of a subscription product) opens the
  recurring series. Responses are unchanged.
- **Added.** `worthypayments.js`: appearance selectors for placeholders
  (`.Input::placeholder`), the save-this-card checkbox (`.Checkbox`, `.CheckboxInput`,
  `.CheckboxLabel`), the card brand mark (`.CardBrand`), a saved card under the pointer
  (`.SavedItem:hover`) and the Pay button's states (`.Button--disabled`, `.Button--loading`,
  `.Button--paid`), plus the rule property `accentColor`. See
  [Embedded SDK](./08-embedded-sdk.md#rule-selectors).
- **Added.** `worthypayments.js`: the `walletButtons` option sets the Apple Pay and Google
  Pay buttons' theme and type presets and their height (40-55 px). See
  [Wallet buttons](./08-embedded-sdk.md#wallet-buttons).
- **Fixed.** `worthypayments.js`: an Apple Pay button takes the theme it is given (it stayed
  black), and the "Or pay with card" line under the wallet buttons uses your page's font.
- **Changed.** `worthypayments.js`: the Pay button inside the Payment Element is filled with
  the `colorPrimary` you name (a `.Button` `backgroundColor` rule wins over it). It kept a
  fixed green gradient whatever the appearance said. A theme's own primary does not repaint
  it, and an element with no `colorPrimary` looks as before.
- **Added.** `worthypayments.js`: the `payButton` option. `{ label }` replaces "Pay" on the
  idle button (1-40 characters), and `{ display: 'never' }` draws no button so your own calls
  `element.submit()`. See [Payment Element](./08b-payment-element.md).
- **Changed.** `worthypayments.js`: a `fontFamily` naming Inter, Roboto, Open Sans, Lato,
  Montserrat or Poppins renders in that font, because the frame now serves them itself. It
  fell back to the system font. Other families still fall back down your stack.
- **Fixed.** A payment intent answers the `reference` it was created with: on create and
  update, and on retrieve to a secret or restricted key. A publishable key never receives
  it, because the reference is your own order id. It was documented as echoed back and never
  was.
- **Fixed.** A Payment Element on a page that another of your sites frames (for example
  `shop.example.com` inside `dash.example.com`) renders the card form when every host in the
  chain is on the key's allowed domains. Only the page directly above the form was allowed,
  so the browser refused the frame. A Firefox older than 148 reports only the frame directly
  above. See
  [Payment Element](./08b-payment-element.md).
- **Added.** A secret or restricted key can be limited to your server IP addresses and can
  require signed requests (HMAC), both set in the dashboard when the key is created. The
  signing secret is shown once. See [Authentication](./02-authentication.md).
- **Fixed (docs).** `initiated_by: "merchant"` is accepted together with `recurring: true`;
  only `"customer"` with `recurring: true` is refused. The guide said any `initiated_by` with
  `recurring: true` was a `400`. See [Charges](./03-charges.md#charge-a-saved-card).
- **Fixed (docs).** `POST /v1/customers` does not read `external_id`, and no other call sets
  it, so `GET /v1/customers/external/{externalId}/state` finds no customer you created. Use
  `GET /v1/customers/{id}/state`. The guides said to pass `external_id` on create. See
  [Customers](./18-customers.md).
- **Fixed (docs).** An email that already has a customer makes `POST /v1/customers` answer
  `409` with `code: "already_exists"`, not `resource_already_exists`, and the reference no
  longer describes the call as a find-or-create that returns the existing customer: only a
  retry with the same body `idempotency_key` does. See [Customers](./18-customers.md).

## 2026-10-17

- **Breaking.** `POST /v1/customer_sessions` (`customer`) and `GET /v1/customers/{id}/state`
  and `GET /v1/customers/external/{externalId}/state` (`id`) answer the customer id as
  `cus_<uuid>`, as `GET /v1/customers` does; they answered the bare uuid. Requests accept
  either form, as before. Still the bare uuid, unchanged: `customer` on charges (including
  `GET /v1/customers/{id}/transactions`), `customer_id` on pre-dispute alerts, and webhook
  `customerId`.
- **Fixed (docs).** The id table in [Versioning](./20-versioning.md) gives the customer's id
  as `cus_<uuid>` and lists where the bare uuid still appears, and the
  [Events](./19-events.md) example shows the bare uuid.
- **Fixed (docs).** There is no per-minute refund budget: only the per-key request ceilings
  apply, and `Retry-After` names the window that was hit. The guides said 5 refunds a minute
  on a live key.
- **Fixed (docs).** Card-testing auto-block of an IP is an account setting per mode, not per
  key, and server-side saved-card renewals are not exempt from it. See
  [Integration hard rules](./27-integration-hard-rules.md).
- **Fixed (docs).** An attempted 3-D Secure authentication shifts liability on Visa, Amex and
  Discover (ECI 06), not on Mastercard (ECI 01). See [3-D Secure](./07-3ds.md).

## 2026-10-16

- **Changed.** `POST /v1/payment_intents` checks `customer`. A customer this key's merchant
  and mode do not hold is a `400` `invalid_request_error`, `code: resource_missing`,
  `param: customer`, replayed on the same `Idempotency-Key`. A `CUS-…` number is accepted
  and answered as `cus_…`. While the customer lookup is unavailable the create answers
  `503`, which is not stored on the key.
- **Fixed.** A browser confirm with a saved card accepts the `pm_…` id that
  `GET /v1/customers/{id}/payment_methods` returns. It answered `401`
  `customer_session_invalid`.
- **Changed.** A payment intent's `customer` is always answered as `cus_<uuid>` on create,
  retrieve, update and confirm; some intents answered the bare uuid. A `customer` that is
  not a string is a `400` `parameter_invalid`.
- **Fixed.** An intent created with `customer: "cus_…"` now saves the buyer's card to that
  customer; the save used to fail silently. `GET /v1/customers/{id}/state` accepts the
  `cus_…` id.
- **Fixed.** Hosted checkout pages use the appearance set in Settings → Checkout (logo,
  colors, font, corner radius, favicon) and link the terms and privacy pages set in
  Settings → Company.

## 2026-10-15

- **Fixed.** The API's CORS policy allows the `X-Customer-Session` header, so browsers load
  a returning buyer's saved cards in the Payment Element and can pay with one. Browsers
  dropped both requests before.
- **Fixed.** A refusal by a Payment Workflow rule (CVV, AVS or postal-code match, amount
  limits, currency, anonymous IP) is a `402` `card_error`, `code: card_declined`, with a
  `decline_code` such as `incorrect_cvc` or `amount_too_large`. `POST /v1/charges` answered
  `500` and a payment intent confirm `503` `upstream_error`. See
  [Payment Workflow rules](./10-errors.md#payment-workflow-rules).
- **Fixed.** A second confirm sent while the first charge is still running answers
  `status: processing` instead of `503`.
- **Fixed.** A fraud block on a 3-D Secure resubmit is the same `fraudulent` decline as on
  the first submit. It was a `503`.
- **Changed.** The `customer` named on a payment intent owns the payment and the saved card,
  whatever email the buyer types. The email used to decide.
- **Changed.** `worthypayments.js`: an Apple Pay / Google Pay decline reaches `onError` as
  `type: "card_error"`, `code: "card_declined"`, with `declineCode` and the card form's
  sentence in the element's `locale`. A wallet `onSuccess` receives the same shape as a
  card payment.
- **Changed.** `worthypayments.js`: a card-form decline in `onError` has
  `type: "card_error"`, as documented; it was `api_error`. `code` is still
  `card_declined`, and other frame failures stay `api_error`.
- **Added.** `worthypayments.js`: when the intent's amount changed after the wallet buttons
  were drawn, the wallet charges nothing and reports
  `type: "invalid_request_error"`, `code: "amount_changed"`, then redraws the buttons with
  the new total.
- **Changed.** `worthypayments.js`: a confirm waits at least 55 s (it was 15 s). If that
  runs out, the SDK retrieves the intent, which never charges, and reports `succeeded` or
  `processing`. A wallet buyer's email, name, phone and address, and billing `phone` and
  `address.line2`, now reach the payment. Error messages no longer contain the client
  secret.
- **Added.** A charge refused by the scored fraud screen is recorded as a failed charge. The
  `400` from `POST /v1/charges` names it in `charge`; `GET /v1/charges/{id}` reads
  `failure_code: fraudulent` and `outcome.type: blocked`; `charge.failed` fires with
  `decline.declineOrigin: "PLATFORM"`. See [Fraud blocks](./10-errors.md#fraud-blocks-fraud_blocked).
- **Fixed (docs).** The Payment Element guides describe `onLoadError`, the full decline
  table, and matching webhooks on `detail.orderReference`. `update()` keeps the same client
  secret.
- **Fixed (docs).** Sandbox test declines use an amount below 1.00 (the sandbox refuses
  `4000000000000002`), and the sandbox Amex number is `341111111111111`.
- **Fixed (docs).** A `PATCH` on a closed intent is a `400` `failed_precondition`, not
  `412`. A 3-D Secure resubmit with another amount is refused with `400`
  `three_ds_amount_mismatch`. A key's allowed domains can be edited after it is created.

## 2026-10-14

- **Breaking.** A publishable key (`pk_…`) can no longer charge a saved card, capture or
  void: `403 permission_denied`. Use a secret key. A publishable key is public, and a void
  can become a full refund.
- **Fixed.** `POST /v1/charges` passes `billing_address`, `shipping_address`, the customer's
  name and phone and `device.kount_session_id` to the payment, so AVS and fraud screening
  see them. `GET /v1/charges/{id}` returns the stored `billing_details`. The charge is
  attached to the customer with the given email.
- **Fixed.** A payment intent confirmed with a saved card records the card's brand, BIN and
  last four on the charge.
- **Fixed.** A 3-D Secure resubmit is bound to the attempt that stepped up. A
  `three_ds.session_id` from another payment is refused as an unknown session, and nothing
  is charged.

## 2026-10-13

- **Changed.** `POST /v1/check` can deny with `BLOCKED_BYPASS_LINK` when a device or IP
  links to a blocked customer. On charges, that link check obeys your Cascade switches. See
  [Linked-account blocks](./23-fraud-checks.md#linked-account-blocks).

## 2026-10-11

- **Added.** `recurring` on `POST /v1/charges` marks a saved-card charge as a subscription
  renewal. It is accepted only with a saved payment method; otherwise it is a `400`
  `parameter_invalid`. Saved-card charges are merchant-initiated and exempt from your 3-D
  Secure policy. A customer flagged "Require 3-D Secure" declines with
  `soft_decline_3ds`.
- **Fixed.** A customer session lists the customer's saved cards, and confirming with one
  charges it. The list was empty and confirm answered `401` `customer_session_invalid`.
- **Fixed.** A saved card that needs 3-D Secure but cannot run it is declined with `402`
  `soft_decline_3ds`. It answered `requires_action` with an empty `id` and session.
- **Added.** `card_save` on a payment intent confirm, on the retrieve of a succeeded intent
  and in `onSuccess`, with the `pm_…` `payment_method` when the card was saved. See
  [Did the card get saved?](./08b-payment-element.md#did-the-card-get-saved).
- **Added.** `capture_before` and `cancellation_reason` on charges.
- **Changed.** An authorization nobody captured is canceled when the card network's window
  ends (4 days 18 hours for a Visa merchant-initiated charge, 7 days otherwise). The charge
  reads `canceled`, and `charge.expired` and `charge.canceled` fire. See
  [Auth & capture](./17-auth-capture.md).
- **Changed.** A delayed auto-capture runs at your delay or 6 hours before the authorization
  expires, whichever comes first. A failed capture is retried with growing waits, up to an
  hour apart.
- **Fixed.** `capture_method: "manual"` is honoured on a route whose Capture block is
  Instant. Those charges were captured at once.
- **Fixed.** The buyer's IP address is read through our proxies. Fraud IP rules, your key's
  IP allowlist and per-IP throttling saw a proxy address on every API, Payment Element and
  hosted checkout request.
- **Fixed.** Payment Workflow routes are first-match. A catch-all "All other conditions →
  Decline" route no longer declines a charge that an earlier route accepted.

## 2026-10-10

- **Breaking.** When the charge outcome is not yet known, a payment intent confirm answers
  `status: processing`; it was `503` `upstream_error`. `worthypayments.js` now calls
  `onSuccess({ status: "processing" })` instead of `onError` with `payment_unresolved`.
  Check `status` before showing a success page, and fulfil from `charge.succeeded`.
  Retrieving the intent resolves the outcome without charging.
- **Fixed.** Hosted checkout shows a completed payment as completed and opens 3-D Secure
  when asked. It could report a completed payment as failed.
- **Changed.** Webhook endpoints that keep failing are throttled to one request a minute
  after 10 failures in a row. They are auto-disabled only after 24 hours of continuous
  failure, then probed hourly, and the account owner is emailed. Events are queued, not
  dropped. See [Webhooks](./16-webhooks.md#when-an-endpoint-keeps-failing).
- **Fixed.** On payment intents and hosted checkout, a fraud pre-screen block declines with
  `fraudulent`, and an account that cannot take payments declines with
  `merchant_unavailable`. Both were service errors. Hosted checkout charges the total the
  Pay button shows after shipping and tax.
- **Fixed.** A void on a captured charge whose batch has not settled is a real void: the
  charge reads `canceled`, not refunded. A settled charge is still refunded in full.
- **Fixed.** A refund the gateway refuses is a `400` `refund_declined`; it was `201` with
  `status: failed`. A refused capture is a `402`, and a refused void a `400`
  `void_declined`. `description` no longer triggers a duplicate-order refusal.
- **Changed.** There is no per-minute cap on charges, captures, confirms or refunds. It was
  5 a minute on a live key and 50 on a test key. See [Rate limits](./14-rate-limits.md).
- **Changed.** `worthypayments.js`: the Apple Pay / Google Pay sheet stays open until the
  charge answers. `mountWallet`'s `onToken` may return a promise.
- **Fixed.** A payment the gateway itself refuses is an immediate `402` `card_declined` with
  `decline_code` `duplicate_transaction`, `invalid_merchant`, `invalid_transaction` or
  `processing_error`. It was a `502` with the charge left pending. Payment Element
  declines carry `declineCode`.
- **Added.** `outcome` on every charge read: `network_status`, `type`, `seller_message`,
  `risk_level`.
- **Changed.** `GET /v1/disputes/stats` counts dispute cases only. Pre-dispute alerts and
  PayArc RDR resolutions, reversals and credits are left out. `win_rate` is `null` until a
  case is won or lost. **Fixed (docs):** rates are whole percents.
- **Fixed.** `created[lte]`, `created[lt]` and `created=` list filters include rows from
  within the boundary second.

## 2026-10-09

- **Fixed.** Retrying `POST /v1/charges` with the same `Idempotency-Key` after an unknown
  outcome resolves the earlier attempt instead of charging again. While it is unresolved
  the retry answers `409` `idempotency_in_progress`. An unknown outcome reads
  `status: pending`; it was `unknown`.
- **Fixed.** After a decline, a new card on the same payment intent or checkout is charged.
  It used to receive the first card's decline again.

## 2026-10-08

- **Fixed.** `GET /v1/customers/{id}/transactions` lists charges only, with real
  `amount_refunded`, `disputed` and `livemode`. **Fixed (docs):** the refund errors are
  documented. See [Refund a customer's past charges](./06b-refund-past-charges.md).

## 2026-10-07

- **Added.** `card.wallet` is set on approved live sales synced from your NMI gateway once
  NMI names the wallet, usually a few minutes after the sale. No webhook is sent when it
  arrives.

## 2026-10-06

- **Added.** `card.wallet.card_last4` and `card.wallet.card_brand`, the buyer's own card
  inside an Apple Pay / Google Pay wallet. Send them as `wallet.card_last4` /
  `wallet.card_brand` on `POST /v1/charges`; `worthypayments.js` does this for you.
  Webhooks carry `walletCardLast4` / `walletCardBrand`.
- **Added.** `walletType`, `walletCardLast4` and `walletCardBrand` are on a later manual
  capture's `charge.succeeded` and on refunds of a wallet sale.
- **Fixed (docs).** A pre-dispute alert's `matchEvidence` card digits are the issuer's and
  can differ from the sale's card (wallets, virtual cards). Reconcile on `transactionId`,
  the ARN or the auth code.

## 2026-10-05

- **Added.** `card.wallet` (`{ "type": "apple_pay" }` or `{ "type": "google_pay" }`) on a
  wallet charge we processed, and `walletType` on its webhooks.

## 2026-10-02

- **Added.** Dispute `status` `RELATED_CASE` and `parent_dispute_id`, for a PayArc related
  case filed under its head dispute.

## 2026-01-01

Initial public contract for the Worthy Payments API:

- Charges, refunds, customers, payment methods, and payment intents
- Checkout links, products, and subscriptions
- Webhooks, 3-D Secure, and fraud checks
- Browser Payment Element (`worthypayments.js`) and `wp:` tokenization

Pin this date with the `API-Version` request header. Responses echo the resolved
version and a `Request-Id`.

The interactive reference is at [/developers/api-reference](/developers/api-reference).
