openapi: 3.1.0
info:
  title: WorthyPayments Public API
  version: '2026-01-01'
  description: |
    Accept card payments with fraud screening, smart routing, and 3-D Secure applied
    automatically on every charge.

    **Base URL:** `https://api.worthypayments.com` — every path below includes the `/v1` prefix
    (so `GET /v1/ping` is the full path). The same API answers on `https://api.paygeyt.com`.

    **Auth:** `x-api-key: sk_live_…` or `Authorization: Bearer sk_live_…`. Test vs live is
    the key prefix (`sk_test_` / `sk_live_`) — there is no separate sandbox host.
    Publishable keys (`pk_…`) may only call scope-none browser config routes.

    **Conventions that hold everywhere:**
    - All amounts are integers in the smallest currency unit (cents). `$49.99` is `4999`.
    - A raw PAN is never accepted. Server-side charges take either Evervault tokens
      (`ev:`) in `card.number` + `card.cvv`, or one `wp:v1:` envelope in `card.wp_token`
      (PAN, expiry and CVV sealed together — there is no separate `wp:` CVV field).
      In the browser, the Payment Element seals the card inside our iframe.
    - Send `Idempotency-Key` on payment intents, charges, capture, void, refunds,
      subscriptions, and 3DS sessions; pin `API-Version` to keep a stable contract.
      Every response carries a `Request-Id`.

    **Browser integration (PCI SAQ A):** your server creates a payment intent with a secret
    key; your page mounts the Payment Element against the returned `client_secret` with a
    publishable key. The amount is never sent from the browser.

    Full guides: https://app.worthypayments.com/developers
servers:
  - url: https://api.worthypayments.com
  - url: https://api.paygeyt.com
    description: The same API on paygeyt.com; integrations built on it keep working.
security:
  - ApiKey: []
  - BearerKey: []
tags:
  - name: Meta
    description: Ping / key introspection.
  - name: Charges
    description: Create / retrieve / list / capture / void charges.
  - name: Refunds
    description: Create / retrieve / list refunds.
  - name: Customers
    description: Customer master records.
  - name: PaymentMethods
    description: Vaulted cards on a customer.
  - name: Commerce
    description: >-
      Checkout links, products, tax rates, tax calculation, wallet config. Tax rates are yours to
      create and attach to products; `POST /v1/tax/calculate` then returns the tax each cart line
      owes. `tax:write` is server-only; publishable keys are refused.
  - name: Stores
    description: Storefront ("market") read + aggregate stats.
  - name: Metrics
    description: Sales / KPI roll-up (volume, approval rate, counts).
  - name: Config
    description: Front-end config surfaces (Evervault, wallet) + TEST token minting.
  - name: 3D Secure
    description: >-
      The 3DS endpoints. For the walkthrough — when to run 3DS, how the challenge flow fits into a
      checkout, and what to do with each outcome — read [3-D Secure](#tag/3-d-secure) first; this
      section is the reference for the calls it describes.


      Run 3DS in your own checkout, then thread the authoritative result into a charge. Requires a
      key whose exec-mode can charge (`atomic` | `payment_only`); a `fraud_only` key is refused.
      `threeds:create` is a server-side scope (publishable keys cannot open sessions).
  - name: Subscriptions
    description: >-
      Recurring billing on a stored card. Creating or cancelling moves recurring money, so
      `subscriptions:write` is a server-side scope (publishable keys are refused) and the route
      requires a key whose exec-mode can charge. Cancellation defaults to at-period-end. Create a
      plan with `POST /v1/plans` (or in the merchant dashboard) then pass the plan id as `plan` on
      `POST /v1/subscriptions`. `plans:write` is server-only; publishable keys are refused.
  - name: Invoices
    description: >-
      Recurring and one-off invoices issued by this merchant. List and retrieve with
      `invoices:read`. Email an OPEN invoice with `POST /v1/invoices/{id}/send` (`invoices:write`,
      server-only). `hosted_invoice_url` is null on a draft and stamped at finalize as
      `https://app.worthypayments.com/invoices/{id}/view?token=…` (there is no `invoice.paygeyt.com`
      host). A successful CHARGE_AUTOMATICALLY renewal (including test bill-now) writes a PAID row;
      opening create does not mint an invoice in this release. Finalize / pay / void stay on the
      dashboard. There is no `invoice.payment_failed` event — a failed card renewal is
      `subscription.past_due`.
  - name: Webhooks
    description: Merchant webhook endpoints + delivered events.
  - name: Payment Intents
    description: >-
      The server-side half of the Payment Element. Your server names the amount with a secret key
      and receives a `client_secret`; the browser receives only that secret, so a buyer can never
      name their own price. `payment_intents:create` is server-side (publishable keys are refused)
      and the route requires a key whose exec-mode can charge. Setup intents are the same pattern
      with no amount: your server opens one for a customer (`customers:write`), and the Element
      mounted with its `client_secret` saves and verifies the buyer's card with no payment.
  - name: Customer Sessions
    description: >-
      Lets the Payment Element show one returning buyer their saved cards. `customers:read` stays
      closed to publishable keys — it lists the merchant's whole book — so your server names a
      single customer and gets a signed, 30-minute grant scoped to that customer alone.
  - name: Disputes
    description: >-
      Chargebacks and their recovery, read-only on this edge. Both collections poll by change time
      rather than page through history: pass the timestamp of your last sync and you get what moved
      since. `disputes:read` covers this section and pre-dispute alerts alike.
  - name: PreDisputeAlerts
    description: >-
      Alerts raised before a chargeback exists, so a refund can settle the complaint while it is
      still cheap. Same `disputes:read` scope and the same changed-since polling as Disputes, and an
      alert can be fetched by our id or by the provider's own external id.
  - name: Payment Element
    description: >-
      The hosted card form. This section holds the framing check the element's own page calls before
      it renders: it answers whether a given origin is allowlisted to embed the element for a
      publishable key, which is what stops any site on the internet from putting a card form in
      front of a buyer on our origin.
  - name: Fraud
    description: >-
      Score a checkout before you charge it, and maintain your own blocklist. `POST /v1/check` is
      advisory and transaction-independent — it returns a verdict without creating or touching a
      charge, so calling it never moves money and never commits you to anything. `POST
      /v1/check/recheck` re-runs the same evaluation once the final amount is known (after shipping
      or tax), which matters because a verdict is bound to the amount it was issued for. To make a
      verdict binding on the charge itself, thread its `risk_decision_token` into `POST
      /v1/charges`, which verifies it strictly and rejects the charge on a mismatch — sending a
      token asserts provenance. `POST /v1/report` and `GET /v1/blocklist` maintain your merchant
      blocklist; the list returns hashed emails only, so it can be cached client-side without
      exposing buyer addresses. `GET` / `POST` / `DELETE /v1/customers/{id}/three_ds` read, set and
      clear a customer's Require 3DS flag (`fraud:read` / `fraud:write`), and `GET /v1/fraud/rules`
      and `GET /v1/payment_workflow/routes` show the rules and routes that decide 3DS and routing,
      read-only. Our rules stay authoritative: a rule outranks a request.
paths:
  /v1/openapi.json:
    get:
      tags:
        - Meta
      operationId: getOpenapiSpec
      summary: This specification, served by the API itself.
      description: |
        The machine-readable contract, always matching the deployed edge: it is generated
        from the canonical spec at build time and shipped inside the image, so it cannot
        lag behind the code it describes.

        **No authentication** — this is a public document, like a hosted `/openapi.json`
        on any public API. Cached for 5 minutes.

        Point a client generator here rather than at a copy you downloaded once.
      security: []
      responses:
        '200':
          description: The OpenAPI 3.1 document.
          content:
            application/json:
              schema:
                type: object
  /v1/docs:
    get:
      tags:
        - Meta
      operationId: getReferencePage
      summary: A hosted API reference page, served by the API itself.
      description: |
        An HTML reference rendered from the specification above, for reading the contract
        straight off the API host. **No authentication.**

        The full documentation — guides as well as endpoints — is at
        `https://app.worthypayments.com/developers`.
      security: []
      responses:
        '200':
          description: An HTML page.
          content:
            text/html:
              schema:
                type: string
  /v1/ping:
    get:
      tags:
        - Meta
      operationId: ping
      summary: Authenticated ping — echoes key mode, type, and merchant.
      description: |
        The cheapest way to confirm a key works and to see what it actually is. It runs
        under `RequireScopes("none")`, so a publishable key reaches it too, which makes it
        usable as a browser-side smoke test. The response names the key's mode (`test` or
        `live`), its type (`secret` or `publishable`) and the merchant account it resolves
        to — enough to catch a key that points somewhere other than you expect before you
        debug it as a charge failure.
      responses:
        '200':
          description: OK
          content:
            application/json:
              example:
                object: ping
                mode: test
                key_type: secret
                merchant_account_id: acct_9fZ2sQ
                api_version: '2026-01-01'
        '401':
          description: 'connectrpc: unauthenticated — missing/invalid API key.'
  /v1/charges:
    post:
      tags:
        - Charges
      operationId: createCharge
      summary: Create a charge (tokenized payment method only).
      description: >
        Proxies to `PaymentsOrchestrationService/Charge`. `capture_method: manual` (or

        `capture: false`) creates an auth-only charge you later `/capture`. A real decline

        is a **402** `card_error`. The `Idempotency-Key` header (or `idempotency_key` body

        field) makes retries safe.


        **The buyer's IP when your server sends the charge (`device_session`).** The IP a

        charge carries for fraud scoring (per-IP velocity, the IP blocklist, the cascade) is

        always the connection we see, never a body field. With a **publishable key** in the

        buyer's browser that is the buyer. With a **secret or restricted key** it is your

        server, so it is recorded as your server's address and never counted as a buyer IP

        (one server address shared by every buyer of your store must not block them all at

        once). To give such a charge the buyer's own IP and browser, call

        `getDeviceSession()` in worthypayments.js when the buyer pays (it calls

        `POST /v1/device_sessions` with your publishable key), post the `ds_…` id to your

        server, and send it as `device_session`. Optional and additive: a charge without one

        behaves as before. A session belongs to the account and mode that created it, lasts

        30 minutes and may be sent on more than one charge in that time (an idempotent retry,

        a second card after a decline). An unknown or expired session is a `400` with

        `param: device_session`, and that refusal never consumes the `Idempotency-Key`: retry

        with a fresh session, or without one. If our fraud service cannot read the session the

        charge goes ahead without it rather than failing.


        **Capture and the merchant's Payment Workflow.**


        - **`capture_method: manual` is always honoured.** The charge comes back
          `status: requires_capture`, `captured: false`, and stays that way until you
          `/capture` or `/void` it.
        - **`automatic` (the default) can be held by the merchant's route.** If the route the
          charge takes has a Capture step set to **Manual**, the charge is authorized only and
          the merchant captures it later. If the step is **Delayed**, the charge is authorized
          only and captured automatically after the route's delay (1–168 hours). Either way
          it comes back `status: authorized`, `captured: false`.
        - **Read the outcome, not your request.** Check `captured` on the response before
          treating the funds as settled.

        **Declines, including gateway refusals.** Every decline is a **402** with `type:

        card_error`, `code: card_declined` and a `decline_code`. An issuer decline carries the

        issuer's reason (`insufficient_funds`, `do_not_honor`, …). When the gateway itself

        refuses the request — the card was never sent to the card network — the charge is

        declined at once (not left `pending`) and `decline_code` is one of

        `duplicate_transaction` (the gateway already holds an identical recent payment),

        `invalid_merchant`, `invalid_transaction` or `processing_error`, with the gateway's own

        text in `message` (e.g. `Duplicate transaction REFID:3154897712` — quote the REFID to

        gateway support). Do not retry `duplicate_transaction`: look the payment up with

        `GET /v1/charges` first, it may already be taken. A real gateway fault is not a

        decline: it stays a **502** `api_error` with generic wording.


        **Payment Workflow rules are declines too.** A route that requires a CVV, AVS or

        postal-code match, an amount range or a currency, or that blocks anonymous IPs,

        refuses before the gateway with the same **402** `card_error` / `card_declined` and one

        `decline_code`: `incorrect_cvc`, `incorrect_zip`, `incorrect_address`,

        `amount_too_large`, `amount_too_small`, `currency_not_supported` or `fraudulent` (the

        anonymous-IP rule), with a buyer-facing `message`. Nothing was charged; the same

        `Idempotency-Key` replays the refusal, so retry a corrected request under a new key.


        **Fraud blocks are not declines here.** A charge our fraud screening refuses is a

        **400** `invalid_request_error`, `code: failed_precondition`, with a `message` that

        starts `FRAUD_BLOCKED` (see the 400 below). A block by the scored fraud screen is

        recorded as a failed charge, and the error carries **`charge`**, that charge's id

        (`TXN-…`): `GET /v1/charges/{id}` reads it back as `status: failed`, `failure_code:

        fraudulent`, `failure_message: "Your card was declined."`, `outcome.type: blocked`, and

        your `charge.failed` / `transaction.failed` webhooks fire for it with

        `decline.declineOrigin: "PLATFORM"`. It is refused before any 3-D Secure step-up, never

        first sent through a challenge. A fraud block 400 **without** `charge` is otherwise

        identical, and the card was never charged. It is either a fraud pre-screen (velocity)

        block, or a rare scored block for which the payments service returned no charge id:

        its record may not have been written, or it was written but the answer naming it was

        lost. In that second case the failed charge exists and its `charge.failed` /

        `transaction.failed` webhooks fire for a `TXN-…` this 400 never named. The 400 is

        stored under the `Idempotency-Key` and replayed exactly for 24 h, `charge` (or its

        absence) included, with no second charge and no second webhook. Do not retry a fraud

        block under the same key: it replays the same refusal. A new key is a new attempt: it

        is screened again, and a second refusal is a second failed charge.


        **An unknown outcome is not stored as the key's answer.** When the payments service

        cannot tell yet whether the card was charged, this answers the charge with `status:

        pending` and releases the `Idempotency-Key` instead of storing that `pending`: a retry

        with the SAME key asks again and gets the real outcome (the charge, or a 402), never a

        frozen `pending`. Never retry it under a new key — that is a second charge if the first

        lands. A pending ACH debit (accepted, in transit) is a real answer and is still

        replayed for 24 h.


        **PCI — accepted card / payment_method shapes.** This edge is outside the card-data

        environment, so a raw card number is refused on *every* field with

        `raw_pan_rejected` (400). Tokenize first. Each field is allowlisted to the grammar

        its rail actually uses — two card rails are first-class:


        | field | accepts |

        |---|---|

        | `card.number` + `card.cvv` (and `token` / `vault_token` / `cvv_token`) | Evervault tokens:
        `ev:` + at least 16 token characters. The number rail is handed to the gateway as the card
        number; CVV is a separate `ev:` token. |

        | `card.wp_token` | one `wp:v1:` HPKE envelope that seals PAN, expiry and CVV together.
        Prefer this over `number`+`cvv` when present. There is **no** separate `wp:` CVV field — it
        rides inside the envelope. |

        | `payment_method_id`, or a bare string `payment_method` | a saved payment-method id issued
        by this API. |

        | `nmi_vault_id` | a gateway vault id issued by this API. |

        | `wallet_token` | an Evervault wallet token. A wallet payment usually arrives as the
        `wallet` block instead. |


        A value that is not a raw PAN but is not a recognized token either is rejected with

        `pan_encryption_required` (card rail) or `parameter_invalid` (vault/wallet rails) —

        the shapes are allowlisted, so an unrecognized token is never forwarded.


        **A publishable key cannot charge a saved card.** `payment_method_id`, `nmi_vault_id`

        and a bare string `payment_method` are refused for a `pk_` key with `403

        permission_denied` (`param: payment_method`), before the card is looked up. The key is

        public, so it would let anyone charge any of your saved cards. Charge a saved card from

        your server with a secret key, or confirm a PaymentIntent with a customer session.

        Fresh `ev:` / `wp:` cards and wallets are unchanged.


        **3DS step-up.** A usable challenge returns `requires_action` with a nonempty

        `next_action.three_ds.session_id`. Authentication can precede charge creation:

        omit the charge `id` until authentication completes; preserve any real transaction id.

        Resubmit the same amount, currency and instrument with `three_ds.session_id` and

        the **same Idempotency-Key**. `requires_action` is not stored as a terminal response.

        A saved card your server charges here without `initiated_by: customer` is

        merchant-initiated and is never stepped up: it is charged without 3DS whatever asked

        (route, risk band, Require-3DS customer, a fraud rule); a blocking rule still blocks it.

        With the buyer paying (payment intent, hosted checkout, or a saved card charged here

        with `initiated_by: customer`) a saved card is authenticated on its kept encrypted

        number; one with none is declined `soft_decline_3ds` (402 `card_error/card_declined`)

        with nothing charged, and the Payment Element (or your checkout) re-collects it.

        Authenticated payments, ACH and wallets retain their exemptions.

        Deterministic provider rejection is `processing_error`; temporary initiation or

        session-storage failure is a sanitized 503 `api_error`. An unusable challenge is

        a 502 `api_error` ("3-D Secure could not be started").


        **Requesting 3DS.** `three_ds.request` asks for 3DS on this charge even when your

        rules would not. Each value is the EMV 3DS challenge indicator the issuer receives:


        | `three_ds.request` | 3DS runs | The issuer is told |

        |---|---|---|

        | `automatic` (default) | only when your fraud rules or the route's Require 3DS ask | a
        rule-driven challenge is mandated (04) |

        | `any` | always | no challenge preference (01): frictionless whenever the issuer allows |

        | `challenge` | always | a challenge is requested (03); the issuer may still authenticate
        frictionlessly |

        | `challenge_mandated` | always | a challenge is mandated (04) |


        A rule outranks a request: when your rules also ask for 3DS, the challenge is

        mandated whatever `request` says, so `any` cannot soften a challenge your risk

        rules demanded. The step-up then works exactly as above (`requires_action`, run the

        challenge, resubmit with `three_ds.session_id` and the same key; repeating `request`

        on the resubmit is fine). 3DS can only run on a card entered for this payment

        (`card.number` as an `ev:` token): a request on a saved card, a gateway vault id, a

        `wp:` envelope or a wallet is a 400 `parameter_invalid` (`param:

        three_ds.request`). To authenticate a saved card, collect it again (the Payment

        Element, or `tokenize`) and send it as `card.number`; a payment intent does this

        for you. Omit it, or send `automatic`, to charge those as before. An

        unknown key inside `three_ds` is refused too, so a misspelling cannot silently

        charge without the 3DS you asked for.


        **3D Secure threading.** If you ran 3DS in your own checkout (see `/v1/3ds/*`),

        pass the session id as `three_ds.session_id`. The edge fetches that session's

        **authoritative** result (`GetThreeDsSession`, cavv/eci from the provider — never a

        browser value) and threads the real `cavv`, `eci`, `ds_trans_id`, protocol

        `version` and `cardholder_auth` into the gateway request so liability shifts. A

        session id that is not your merchant's is rejected upstream, never charged as

        un-authenticated.


        **A session that did not authenticate never charges.** Nothing reaches the card

        network unless the session authenticated with a cryptogram (fully, or an

        "attempted" authentication that carries one):


        - **Failed** — 402 `card_error` / `card_declined`, `param: three_ds.session_id`.
          `decline_code` is `three_ds_failed` (the cardholder failed or canceled the
          challenge, or the issuer rejected it), `three_ds_card_not_enrolled` (the card
          cannot be authenticated: charge it again without `three_ds` if you accept no
          liability shift, or ask for another card) or `processing_error` (a 3DS
          network fault: run a new session). `GET /v1/3ds/sessions/{id}` names the exact
          `failure_reason`. Stored under the Idempotency-Key like any decline; a new
          attempt is a new session and a new key.
        - **Not finished** — 409 `three_ds_session_incomplete`: the challenge is still
          open. Not stored; resubmit with the same key once it completes.
        - **Result unreadable** — 503 `three_ds_result_unavailable`: the session
          authenticated but its result could not be fetched just now. Not stored; retry
          with the same key.

        **Wallet Pay (Apple Pay / Google Pay).** Send a `wallet` block *instead of* a card

        instrument — the wallet **is** the instrument:


        ```json

        {
          "amount": 1999, "currency": "usd",
          "wallet": {
            "type": "apple_pay",
            "token": "ev:…",       // encrypted network token (DPAN) from the wallet sheet
            "exp_month": 12, "exp_year": 2029,
            "cryptogram": "ev:…",  // encrypted TAVV
            "eci": "5",            // as the wallet emitted it; we zero-pad for the gateway
            "card_last4": "4973",  // optional: the buyer's own card inside the wallet
            "card_brand": "visa"   // optional: that card's network
          }
        }

        ```


        Prefer the Payment Element for wallets — it collects Apple Pay / Google Pay inside

        our iframe (public `mountPaymentElement`). If you build a custom wallet path, forward

        the wallet credential as the wallet provider emits it. Both `token` and `cryptogram`

        are Evervault ciphertext and are decrypted in transit; a raw DPAN never reaches this

        API.


        `card_last4` / `card_brand` are optional: the last four digits and network of the

        buyer's **own card** inside the wallet (the funding card), which the wallet sheet

        states beside the device token (`mountWalletButton` puts them on the credential). The

        token carries other digits; this is the number the buyer's bank, a receipt and a

        dispute alert name, and the charge reports it back as `card.wallet.card_last4`. They

        are never refused: anything other than exactly four digits, or a brand that is not a

        plain network name, is dropped and the charge runs as it would without them. Neither

        wallet hands out a BIN, so there is no `card_bin`.


        Rules, each a **400** naming the offending field:


        - `wallet` alongside `card` / `payment_method` is refused. The gateway would charge
          the wallet and drop the card, so we will not guess which you meant.
        - `wallet.cryptogram` is **required for Apple Pay** (always a network token).
          Google Pay may omit it — that is the `PAN_ONLY` shape, charged as an ordinary
          card sale. The SDK does not offer PAN_ONLY by default: it is not
          SCA-satisfied, so a buyer your rules flag for 3DS would be declined after
          completing the Google sheet with no way to recover inside it.
        - `wallet` with `three_ds.session_id` is refused: the wallet cryptogram already
          satisfies SCA, and the gateway rejects a wallet sale carrying 3DS fields.

        No 3DS step-up ever runs on a wallet charge. Availability is per merchant and per

        buyer device — check `/v1/wallet-config` before offering the button, and let the

        SDK decide whether the device can actually pay.


        The charge you get back — here, and on every later read — reports the wallet as

        `card.wallet: { "type": "apple_pay", "card_last4": "4973", "card_brand": "visa" }` (or

        `"google_pay"`), and the webhook raised when the charge is decided (`charge.succeeded`

        on an automatic capture, `charge.authorized`, `charge.failed`) carries it as

        `detail.walletType`, `detail.walletCardLast4` and `detail.walletCardBrand`. On a wallet

        charge `card.last4` / `card.bin` belong to the device token, not the buyer's physical

        card; `card.wallet.card_last4` is the physical card's, `null` when the wallet did not

        state it.
      parameters:
        - in: header
          name: Idempotency-Key
          description: >-
            Required. A repeat of the same key replays the first response instead of charging twice;
            the replay carries `X-Idempotency-Replayed: true`. Keys are stored for 24h. The
            `idempotency_key` body field is accepted instead of this header.
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - amount
              oneOf:
                - required:
                    - card
                - required:
                    - payment_method
                - required:
                    - wallet
              description: >-
                Exactly one instrument: `card`, `payment_method` or `wallet`. Two of them is a 400,
                none of them is a 400 - the gateway would charge one and drop the other.
              properties:
                amount:
                  type: integer
                  minimum: 50
                  description: >-
                    Minor units. Below 50 is refused, and so is a value too large to represent
                    exactly - JSON.parse would round it and charge a different number.
                currency:
                  type: string
                  description: ISO 4217. A currency this account cannot settle is refused.
                card:
                  type: object
                  description: >-
                    Tokenized card. Never a raw PAN - every field is allowlisted to its rail's
                    grammar.
                  properties:
                    number:
                      type: string
                      description: An `ev:` token, not a card number.
                    cvv:
                      type: string
                      description: A separate `ev:` token.
                    wp_token:
                      type: string
                      description: >-
                        A `wp:v1:` HPKE envelope sealing PAN, expiry and CVV together. There is no
                        separate `wp:` CVV.
                    exp_month:
                      description: Month, string or number.
                    exp_year:
                      description: Year, string or number.
                    name:
                      type: string
                    last4:
                      type: string
                    bin:
                      type: string
                payment_method:
                  description: A saved payment-method id, a gateway vault id, or an object naming one.
                recurring:
                  type: boolean
                  default: false
                  description: >-
                    Set true for a subscription renewal using a saved payment method
                    (payment_method.payment_method_id, pm_<uuid> or bare UUID). For saved-card
                    charges, omitted or false means a subsequent unscheduled merchant-initiated
                    transaction. True with a fresh card, wallet, wp_token or ACH is 400
                    parameter_invalid with param recurring. Included in the idempotency fingerprint;
                    omitted and false are equivalent. Saved-card MITs are never sent to 3-D Secure:
                    a customer flagged Require 3-D Secure, the 3DS risk band, a fraud rule asking
                    for 3DS or the route's Attempt 3DS charges them without 3DS; a blocking fraud
                    rule still blocks them. Each saved-card charge references the payment the card
                    was saved from, when one was recorded. Installments are not supported.
                initiated_by:
                  type: string
                  enum:
                    - customer
                    - merchant
                  default: merchant
                  description: >-
                    Saved payment methods only. "customer" when the cardholder is paying now with a
                    card they saved (on your checkout): the charge goes out as customer-initiated on
                    a stored credential, and your 3-D Secure rules and `three_ds.request` apply to
                    it as in the Payment Element. The card is authenticated on the encrypted copy
                    kept when it was saved (requires_action; resubmit with three_ds.session_id under
                    the same Idempotency-Key), or, with no such copy, declined 402 card_declined /
                    soft_decline_3ds with nothing charged. "merchant" (the default) is a
                    merchant-initiated transaction, never sent to 3-D Secure. 400 parameter_invalid
                    with param initiated_by on a fresh card, wallet, wp_token or ACH, with recurring
                    true and "customer" (a renewal is merchant-initiated), or for any other value.
                    Included in the idempotency fingerprint; omitted and "merchant" are equivalent.
                wallet:
                  type: object
                  description: Apple Pay / Google Pay credential. The wallet IS the instrument.
                  properties:
                    type:
                      type: string
                      enum:
                        - apple_pay
                        - google_pay
                    token:
                      type: string
                      description: Encrypted network token (`ev:`), never a raw DPAN.
                    exp_month:
                      description: Month, string or number.
                    exp_year:
                      description: Year, 2 or 4 digits, string or number.
                    cryptogram:
                      type: string
                      description: Encrypted TAVV (`ev:`). Required for Apple Pay.
                    eci:
                      type: string
                      description: As the wallet emitted it.
                    card_last4:
                      type: string
                      description: >-
                        Optional. Last four digits of the buyer's own card inside the wallet (the
                        funding card), as the sheet stated them. Not exactly four digits = dropped,
                        never refused.
                    card_brand:
                      type: string
                      description: >-
                        Optional. That card's network as the wallet named it (`visa`, `mastercard`).
                        Dropped, never refused, when unusable.
                customer:
                  type: object
                  properties:
                    email:
                      type: string
                    name:
                      type: string
                    first_name:
                      type: string
                    last_name:
                      type: string
                    phone:
                      type: string
                billing_address:
                  type: object
                  additionalProperties:
                    type: string
                shipping_address:
                  type: object
                  additionalProperties:
                    type: string
                device:
                  type: object
                  properties:
                    fingerprint:
                      type: string
                    kount_session_id:
                      type: string
                device_session:
                  type: string
                  pattern: ^ds_[A-Za-z0-9]{1,61}$
                  description: >-
                    A device session id from worthypayments.js getDeviceSession() in the buyer's
                    browser (POST /v1/device_sessions). The charge then carries the buyer's IP and
                    user agent instead of your server's, and the session's fingerprint when
                    `device.fingerprint` is absent. Optional; unknown or expired is a 400 with param
                    device_session.
                risk_decision_token:
                  type: string
                  description: >-
                    Signed receipt from a prior POST /v1/check. Optional, but strictly verified when
                    sent - a mismatch rejects the charge.
                three_ds:
                  type: object
                  additionalProperties: false
                  description: >-
                    Only these two keys; any other key (a misspelling, a client-built `eci` /
                    `cavv`) is a 400.
                  properties:
                    session_id:
                      type: string
                      description: >-
                        The edge reads the authoritative result from this session; browser-supplied
                        cavv/eci are never trusted. A session that did not authenticate never
                        charges.
                    request:
                      type: string
                      enum:
                        - automatic
                        - any
                        - challenge
                        - challenge_mandated
                      default: automatic
                      description: >-
                        Ask for 3DS on this charge: `any` (EMV 01, no challenge preference),
                        `challenge` (03, challenge requested), `challenge_mandated` (04).
                        `automatic` leaves it to your rules. Only on a card the buyer is paying with
                        now: an `ev:` card entered for this payment, or a saved card with
                        `initiated_by: customer`.
                description:
                  type: string
                capture_method:
                  type: string
                  enum:
                    - automatic
                    - manual
                  description: >-
                    `manual` is always honoured. `automatic` (default) may still come back
                    authorized-only when the merchant's Payment Workflow route holds it (Manual or
                    Delayed capture) — see the operation description.
                capture:
                  description: 'Boolean or string. `false` is the same as `capture_method: manual`.'
                metadata:
                  type: object
                  maxProperties: 50
                  additionalProperties:
                    type: string
                    maxLength: 500
                  description: >-
                    Your own key/value pairs. Stored on the charge and returned on the create
                    response, GET /v1/charges/{id}, GET /v1/charges and the charge webhooks
                    (data.object.detail.metadata). Up to 50 keys; a key is 1-40 characters without
                    square brackets; a value is a string of up to 500 characters (a number or
                    boolean is stored as its text; an empty value stores no key). Anything else is
                    400 parameter_invalid with param metadata. Cleared if the customer is deleted
                    (GDPR), like the billing details.
                idempotency_key:
                  type: string
                  description: Accepted instead of the `Idempotency-Key` header.
            example:
              amount: 1999
              currency: usd
              payment_method:
                type: card
                token: ev:card_tok_9c2b…
                last4: '4242'
                exp_month: 12
                exp_year: 2030
              customer:
                email: buyer@example.com
                name: Ada Lovelace
              description: Order 1234
              capture_method: automatic
              risk_decision_token: cmR0MXxtYV8xfGNoa19hYmN8YWxsb3d8NDk5OXx1c2Q.9f8e7d…
              metadata:
                order_id: '1234'
      responses:
        '201':
          description: >-
            Charge object (succeeded, requires_capture when auth-only, or requires_action for 3DS).
            The charge id is absent when authentication precedes charge creation.
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    required:
                      - id
                      - object
                      - status
                    properties:
                      id:
                        type: string
                        minLength: 1
                      object:
                        type: string
                        enum:
                          - charge
                      status:
                        type: string
                        enum:
                          - succeeded
                          - requires_capture
                          - authorized
                          - pending
                  - type: object
                    required:
                      - object
                      - status
                      - next_action
                    properties:
                      id:
                        type: string
                        minLength: 1
                        description: Optional. Authentication can precede charge creation.
                      object:
                        type: string
                        enum:
                          - charge
                      status:
                        type: string
                        enum:
                          - requires_action
                      next_action:
                        type: object
                        required:
                          - type
                          - three_ds
                        properties:
                          type:
                            type: string
                            enum:
                              - three_ds_challenge
                          three_ds:
                            type: object
                            required:
                              - session_id
                            properties:
                              session_id:
                                type: string
                                minLength: 1
              example:
                id: tx_3PkC9d2eZvKY
                object: charge
                amount: 1999
                currency: usd
                status: succeeded
                captured: true
                payment_method: pm_1Qabcd
                customer: 4023ec41-ca5f-4153-859f-c30c960a599c
                created: 1767268800
                metadata:
                  order_id: '1234'
        '400':
          description: >
            `parameter_invalid` — amount < 50 cents, both/neither card & payment_method,

            unsettleable currency, or a vault/wallet ref that is not a recognized token.

            `raw_pan_rejected` — a raw card number was sent on any payment_method field.

            `pan_encryption_required` — the card rail got a value that is neither `ev:` nor
            `wp:v1:`.

            `parameter_invalid` with `param: three_ds.request` — an unknown value, or a request

            on an instrument 3DS cannot run on (saved card, gateway vault id, `wp:` envelope,

            wallet); with `param: three_ds.<key>` — a key `three_ds` does not take.


            Risk decision token (only when one was supplied):

            `risk_decision_token_invalid` — bad signature, unknown format, or issued to a

            different account.

            `risk_decision_token_expired` — older than 15 minutes; run POST /v1/check again.

            `risk_decision_token_amount_mismatch` — the charge amount or currency differs from

            the evaluated one.

            `risk_decision_token_card_mismatch` — a different card than the one evaluated.

            `risk_decision_token_device_mismatch` — a different device fingerprint.

            `risk_decision_token_reused` — already spent; a token is single use.


            Device session (only when one was supplied), all with `param: device_session`:

            `parameter_invalid` — not a `ds_…` id.

            `resource_missing` — no such session for this account and mode (a test session on a

            live key reads the same as an unknown id).

            `device_session_expired` — older than 30 minutes; get a fresh one in the browser.

            None of the three consumes the `Idempotency-Key`.


            A `deny` verdict presented on a charge is a 402 `card_declined`, not a 400: you did

            what the API asked and the answer was no.


            Fraud block: `failed_precondition`, `message` starting `FRAUD_BLOCKED` (the

            screening refused the charge before it reached the card network). `charge`

            (string, optional, `TXN-` + 26 characters): the id of the failed charge the

            refusal was recorded as. Every scored fraud block is recorded, so it carries one;

            it is never on any other error. It is absent on a fraud pre-screen block, and on

            the rare scored block for which the payments service returned no id: the record

            may not have been written, or it was written and only its id was lost, in which

            case the failed charge and its webhooks exist without this 400 naming them. Read

            it back with `GET /v1/charges/{id}`. A same-key retry replays this 400 exactly for

            24 h, with the same `charge` or the same absence of one.
          content:
            application/json:
              examples:
                rawPan:
                  summary: A raw card number was sent
                  value:
                    error:
                      type: invalid_request_error
                      code: raw_pan_rejected
                      param: payment_method.nmi_vault_id
                      message: >-
                        Raw card numbers are not accepted. The PAN must be encrypted client-side
                        with the Evervault SDK (ev:…) before it is sent to this API.
                fraudBlocked:
                  summary: Fraud screening refused the charge, and the refusal was recorded
                  value:
                    error:
                      type: invalid_request_error
                      code: failed_precondition
                      message: 'FRAUD_BLOCKED: decision=BLOCK provider=internal'
                      charge: TXN-01JABCDEFGHJKMNPQRSTVWXYZ0
        '402':
          description: |
            Card declined (`card_error` envelope). `decline_code` is the issuer's reason, or —
            when the gateway refused the request before it reached the card network —
            `duplicate_transaction`, `invalid_merchant`, `invalid_transaction` or
            `processing_error` with the gateway's own text in `message`.
            An unsupported instrument that needs 3DS returns `soft_decline_3ds` with
            buyer-safe instructions to pay again with the card. A deterministic 3DS
            provider rejection returns `processing_error` with a sanitized message.
            A `three_ds.session_id` that did not authenticate returns `three_ds_failed`,
            `three_ds_card_not_enrolled` or `processing_error` with `param:
            three_ds.session_id`; nothing was charged.
          content:
            application/json:
              examples:
                threeDsFailed:
                  summary: The 3-D Secure session did not authenticate
                  value:
                    error:
                      type: card_error
                      code: card_declined
                      decline_code: three_ds_failed
                      param: three_ds.session_id
                      message: >-
                        Your bank could not verify this payment with 3-D Secure. Nothing was
                        charged. Please try again or use another card.
                issuerDecline:
                  summary: The issuer declined the card
                  value:
                    error:
                      type: card_error
                      code: card_declined
                      decline_code: insufficient_funds
                      message: Insufficient funds
                gatewayRefusal:
                  summary: The gateway refused the request (never reached the network)
                  value:
                    error:
                      type: card_error
                      code: card_declined
                      decline_code: duplicate_transaction
                      message: Duplicate transaction REFID:3154897712
        '403':
          description: >-
            `sandbox_key_required` (test mode only): this account has no NMI sandbox key of its own.
            Add it in the dashboard under Settings > Integrations, on the NMI card, while the
            dashboard is in test mode; no NMI sandbox account yet: create one at
            https://guide.nmi.com/ (Create Sandbox Account). Nothing reached the gateway, and the
            refusal is not stored under the Idempotency-Key: retry the same request once the key is
            saved. Also `account_not_activated` (a live key on an account that has not completed
            onboarding) and `permission_denied` (missing scope).
        '409':
          description: |
            `idempotency_in_progress` — the same Idempotency-Key is already running.
            Retry once the first request has returned; the completed response is then
            replayed with `X-Idempotency-Replayed: true`. Raised whether the edge or
            the payments service detects the concurrent key.

            `three_ds_session_incomplete` — the `three_ds.session_id` challenge has not
            finished. Nothing was charged and the key is not used up: resubmit with the
            same Idempotency-Key once the challenge completes.
          content:
            application/json:
              examples:
                inProgress:
                  summary: The same Idempotency-Key is already running
                  value:
                    error:
                      type: invalid_request_error
                      code: idempotency_in_progress
                      message: >-
                        CONCURRENT_CHARGE_IN_PROGRESS: a request with this Idempotency-Key is
                        already in flight.
                threeDsIncomplete:
                  summary: The 3-D Secure challenge is still open
                  value:
                    error:
                      type: invalid_request_error
                      code: three_ds_session_incomplete
                      param: three_ds.session_id
                      message: >-
                        The customer has not finished 3-D Secure for this session. Nothing was
                        charged; resubmit with the same Idempotency-Key once the challenge
                        completes.
        '502':
          description: api_error — 3-D Secure could not be started (missing usable session).
        '503':
          description: |
            api_error — 3-D Secure is temporarily unavailable; retry the payment with the same
            Idempotency-Key. `three_ds_result_unavailable`: the `three_ds.session_id` session
            authenticated but its result could not be fetched just now — nothing was charged,
            retry with the same key.
    get:
      tags:
        - Charges
      operationId: listCharges
      summary: List charges (keyset pagination), newest first.
      description: |
        Lists both immediate sales and authorizations. An authorization you have not
        captured yet **is** a charge here and lists with `status: requires_capture`, the
        same status `POST /v1/charges` returned for it — so a manual-capture integration
        sees its charges. (Conventional semantics. The legacy API filtered to sales only, which
        hid manual-capture charges permanently.) Refunds are not charges; list them at
        `GET /v1/refunds`.
      parameters:
        - in: query
          name: limit
          description: Page size. Defaults to 10 and is capped at 100.
          schema:
            type: integer
            default: 10
            maximum: 100
        - in: query
          name: starting_after
          description: >-
            Cursor for the next page: the id of the last row you received. Omit it on the first
            request, and keep paging while `has_more` is true.
          schema:
            type: string
        - in: query
          name: status
          description: |
            Filter by status. Accepts the same values this API *returns* on a charge —
            `succeeded` covers every captured state (including a refunded charge, which
            did succeed), `failed` covers declines and errors. The raw processor
            statuses (`APPROVED`, `DECLINED`, `REFUNDED`, …) are also accepted for
            backwards compatibility. An unrecognized value is a 400, not a 500 — including
            an inherited JavaScript property name such as `constructor` or `__proto__`.

            `requires_action` is accepted as a filter (it resolves to the same in-flight
            rows as `pending`), but READS never emit it — see the note on the 200 below.
          schema:
            type: string
            enum:
              - succeeded
              - failed
              - requires_capture
              - authorized
              - requires_action
              - pending
              - canceled
              - refunded
              - PENDING
              - AUTHORIZED
              - APPROVED
              - CAPTURED
              - SETTLED
              - REFUNDED
              - VOIDED
              - DECLINED
              - ERROR
        - in: query
          name: created
          style: deepObject
          explode: true
          description: |
            Only charges created in this window, in Unix **seconds**, sent as bracketed keys:
            `created[gte]=1733600000&created[lt]=1733686400`. `created[gte]` and
            `created[lte]` are inclusive, `created[gt]` and `created[lt]` exclusive, and a bare
            `created=1733600000` matches that one second. A bound in milliseconds, or not a
            whole number of seconds, is a 400 `parameter_invalid_integer` naming it; a bound
            sent twice, or `created=` together with `created[...]`, is a 400
            `parameter_invalid`. This is creation time, not last change: `GET /v1/events` is
            the change feed.
          schema:
            type: object
            properties:
              gte:
                type: integer
                description: Inclusive lower bound, Unix seconds.
              gt:
                type: integer
                description: Exclusive lower bound, Unix seconds.
              lte:
                type: integer
                description: Inclusive upper bound, Unix seconds.
              lt:
                type: integer
                description: Exclusive upper bound, Unix seconds.
      responses:
        '200':
          description: |
            List of charges.

            **A read never returns `requires_action`.** An in-flight charge — one whose
            gateway outcome is not yet known, including the row pre-inserted before the
            gateway call — reports `pending`, from BOTH `GET /v1/charges` and
            `GET /v1/charges/:id`, and carries no `next_action`. `requires_action` with a
            `next_action.three_ds.session_id` is emitted only by `POST /v1/charges`, which
            is where an SDK gets the session to mount a challenge against.

            **A failed row says why, as the retrieve does.** Each row carries the charge's
            decline: `failure_code` (e.g. `insufficient_funds`, `duplicate_transaction`) and
            `failure_message`, and its `outcome` names who answered — a charge the gateway
            refused before it reached the card network reads `network_status:
            not_sent_to_network`, `type: invalid` (see `GET /v1/charges/{id}`), a charge our fraud
            screening blocked `not_sent_to_network` / `blocked` with `failure_code: fraudulent`,
            an issuer decline `declined_by_network` / `issuer_declined`. A list row and a
            retrieve of the same charge agree.
          content:
            application/json:
              example:
                object: list
                data:
                  - id: tx_3PkC9d2eZvKY
                    object: charge
                    amount: 1999
                    currency: usd
                    status: succeeded
                    paid: true
                    captured: true
                  - id: tx_3PkC9d2eZvKZ
                    object: charge
                    amount: 500
                    currency: usd
                    status: requires_capture
                    paid: true
                    captured: false
                has_more: false
        '400':
          description: >-
            parameter_invalid — an unrecognized `status` value, a `created` bound sent twice, or
            `created=` together with `created[...]`. parameter_invalid_integer — a `created` bound
            that is not Unix seconds.
          content:
            application/json:
              example:
                error:
                  type: invalid_request_error
                  code: parameter_invalid
                  param: status
                  message: >-
                    Unknown status "paid". Accepted: succeeded, failed, requires_capture,
                    authorized, requires_action, pending, canceled, refunded, PENDING, AUTHORIZED,
                    APPROVED, CAPTURED, SETTLED, REFUNDED, VOIDED, DECLINED, ERROR.
  /v1/device_sessions:
    post:
      tags:
        - Charges
      operationId: createDeviceSession
      summary: Record the buyer's own connection for a charge your server sends (publishable key).
      description: |
        Called from the **buyer's browser** by worthypayments.js `getDeviceSession()`; you
        normally never call it yourself. We record the IP this request comes from, its
        `User-Agent` header and, optionally, the browser's fingerprint, and answer an opaque
        `ds_…` id. Your server sends that id as `device_session` on `POST /v1/charges`, and the
        charge then carries the buyer's IP and browser instead of your server's. Neither the IP
        nor the user agent can be named in the body: an `ip` field is refused.

        ```
        POST /v1/device_sessions      Authorization: Bearer pk_..
          { "fingerprint": "8f3c1d2e" }
        → { "id": "ds_K7Q2M4ZJ3WXH5N6P8R2T4V6YB3", "object": "device_session",
            "livemode": false, "created": 1760000000, "expires_at": 1760001800 }
        ```

        The session belongs to the account and mode (test or live) of the key that created it
        and lasts 30 minutes; it may be presented on more than one charge in that time.
        **Publishable keys only:** called with a secret or restricted key, the connection would
        be your server again, so it is refused with `403`. The id holds no buyer data and is
        useless without your secret key.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                fingerprint:
                  type: string
                  pattern: ^[A-Za-z0-9._:-]{1,200}$
                  description: The value worthypayments.js getFingerprint() returned. Optional.
            example:
              fingerprint: 8f3c1d2e
      responses:
        '201':
          description: Device session.
          content:
            application/json:
              example:
                id: ds_K7Q2M4ZJ3WXH5N6P8R2T4V6YB3
                object: device_session
                livemode: false
                created: 1760000000
                expires_at: 1760001800
        '400':
          description: >-
            `parameter_invalid` (`param: fingerprint`) — not an opaque id. `parameter_unknown` — any
            other body field, `ip` included.
        '403':
          description: >-
            `permission_denied` — a secret or restricted key; create device sessions in the buyer's
            browser with your publishable key.
  /v1/charges/{id}/risk:
    get:
      tags:
        - Charges
      operationId: retrieveChargeRisk
      summary: Retrieve a charge's verification and fraud result.
      description: |
        AVS/CVV result codes plus the fraud score and verdict for a charge. These live on the
        transaction row (02 stamps the fraud snapshot back from scoring), so this is a
        projection of the same data as `GET /v1/charges/:id`. Requires `payments:read`.

        `fraud_assessed` is the gate: when it is `false` the charge was never scored, and
        `fraud_score` / `fraud_decision` / `fraud_case_id` are `null` (a 0 score and "not
        scored" are different facts). AVS/CVV codes are emitted whenever the gateway returned
        them, independent of scoring.
      parameters:
        - in: path
          name: id
          description: The charge whose risk evaluation you want.
          required: true
          schema:
            type: string
      responses:
        '200':
          description: charge_risk
          content:
            application/json:
              example:
                id: TXN-7b4c5802
                object: charge_risk
                avs_result: MATCH
                cvv_result: MATCH
                avs_result_code: 'Y'
                cvv_result_code: M
                fraud_assessed: true
                fraud_score: 12
                fraud_decision: APPROVE
                fraud_reason_codes: []
                fraud_case_id: case_9f2
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `payments:read` scope (`permission_denied`).
        '404':
          description: No such charge for this key's merchant and env-mode (`resource_missing`).
  /v1/charges/{id}:
    get:
      tags:
        - Charges
      operationId: retrieveCharge
      summary: Retrieve a charge.
      parameters:
        - in: path
          name: id
          description: The charge id.
          required: true
          schema:
            type: string
        - in: query
          name: expand[]
          description: |
            `attempts` adds the charge's `attempts` list (see below). Also read as `expand[0]`
            or `expand`; repeat it for more than one value. A value this endpoint cannot expand
            is ignored, not refused. Not offered on `GET /v1/charges`.
          required: false
          schema:
            type: array
            items:
              type: string
              enum:
                - attempts
          style: form
          explode: true
      description: >
        **Acquirer identity is stamped from Payment Workflow, not invented.** At

        charge time the merchant's Payment Workflow picks which acquirer this sale

        goes to (the routes on that road, the conditions of this payment). Those

        processors all ride one NMI security key. The NMI `processor_id` on the sale

        is that acquirer's **Connector code (`processor_id`)** / charge code. Public

        `processor` is that same row's **Code** (`NMI`, `PAYARC`, `NUVEI`,

        `MAVERICK`, or any later registry row). **Connector codes** map raw gateway

        keys onto the acquirer. Those two admin fields are not this public

        `processor` value.



        The gateway's own answer also rides on the charge: `processor_transaction_id`,

        `mid` when the row has one, the response and processor-response pairs, the

        `avs_result` / `cvv_result` verdicts with their raw issuer letters, the statement

        descriptor and the per-state timestamps. They are reflected, not computed, and

        each is **omitted when the row has nothing in it** rather than emitted empty.

        `response_code` / `response_message` are the gateway's own pair; a fresh

        `POST /v1/charges` response reports them too once the gateway has answered (a

        `pending` charge carries neither).


        An authorized, uncaptured charge carries **`capture_before`**: the last moment it can

        be captured. That is the card network's window, 4 days 18 hours for a Visa

        merchant-initiated charge and 7 days otherwise (Stripe's

        `payment_method_details.card.capture_before`). Past it the charge is canceled

        (`status: canceled`, **`cancellation_reason: expired`**) and `charge.expired` fires

        with `charge.canceled`. A void you requested carries no `cancellation_reason`.



        **`metadata`** is the `metadata` you sent on `POST /v1/charges`, as stored; `{}` for a

        charge that carried none, one made before it was stored, and a sale synced from your

        gateway. `GET /v1/charges` returns it on every item too.



        `outcome` says who answered. A `failed` charge the gateway refused before it reached

        the card network reads `{ "network_status": "not_sent_to_network", "type": "invalid",

        "seller_message": "The payment was not processed: <the gateway's text>", "risk_level":

        "normal" }` (`"The payment was not processed."` when the gateway gave no text). A

        `failed` charge our fraud screening blocked before any gateway call (the payments

        service's decline origin `PLATFORM`) reads `{ "network_status": "not_sent_to_network",

        "type": "blocked", "seller_message": "This payment was blocked by fraud screening.",

        "risk_level": "highest" }`, with `failure_code: fraudulent` and `failure_message: "Your

        card was declined."`. That is the charge a fraud-blocked `POST /v1/charges` 400 names

        in `charge`. Every other failure (an issuer decline, including an issuer `processing_error`)
        reads

        `declined_by_network` / `issuer_declined`. `GET /v1/charges` reports the same

        `outcome` for the same charge.



        ⚠️ `arn`, `rrn` and `settlement` are absent on a fresh charge by design: they come

        from the acquirer days later. Read them back here after settlement.



        AVS and CVV are the *gateway* answering. Your own engine's score, decision, reason

        codes and case id are on `GET /v1/charges/{id}/risk`, behind `payments:read`.



        `created` is the charge's own creation time (unix seconds) and is stable across

        polls. An uncaptured authorization reports `requires_capture`, the same status

        `POST /v1/charges` returned for it.



        `card.wallet` is `{ "type": "apple_pay" }` or `{ "type": "google_pay" }` when the

        charge was an Apple Pay / Google Pay payment: one we processed (a `wallet` block on

        `POST /v1/charges`, the Payment Element or hosted checkout), or an approved live sale

        we synced from your NMI gateway once NMI has named its wallet. A new synced sale is

        tagged usually a few minutes after it syncs; one synced before this existed is tagged

        when a one-off history check reaches it; no webhook is sent when it arrives. Until then,

        and on every other charge (a declined or test-mode synced sale included), it is `null`:

        we never guess a wallet. On a wallet charge `card.last4` and `card.bin` belong to the
        **device token**,

        not the buyer's physical card — match a dispute or a receipt accordingly.

        `GET /v1/charges` and `POST /v1/charges` report the same value.



        `card.wallet.card_last4` / `card.wallet.card_brand` are the buyer's **own card** inside

        the wallet — the last four and network the wallet sheet stated beside the device token,

        and the number an issuer's dispute alert for this charge names. Both are `null` when

        the wallet did not state them (a charge made before this was recorded, a custom

        integration that does not forward them, or a sale synced from your gateway, which names

        the wallet but not the card inside it); `card_brand` is also `null` for a network

        outside the ones `card.brand` publishes. There is never a BIN: neither wallet hands

        one out.



        **`expand[]=attempts`** adds `attempts`: every processor this charge was tried on, in

        the order Payment Workflow tried them, and how each attempt ended. A soft decline

        (`200`, `201`, `202`, `260`, `05`, `51`, `61`, `65`, `N7`) is retried on the next

        processor in the route's cascade; `203`, `204` and `250` never are, and a wallet or

        `wp:` token charge gets one attempt. Without `expand[]` the charge is exactly as above.

        Needs a secret or restricted key with `payments:read`; a publishable key never sees it.


        ```json

        "attempts": [
          { "processor": "PAYARC", "processor_label": "Payarc Main", "outcome": "declined",
            "reconciled": false, "decline_code": "insufficient_funds", "response_code": "202",
            "cascade": false, "final": false, "attempted_at": "2026-10-19T09:15:00.120Z",
            "duration_ms": 210 },
          { "processor": "MAVERICK", "processor_label": null, "outcome": "approved",
            "reconciled": false, "decline_code": null, "response_code": "100",
            "cascade": true, "final": true, "attempted_at": "2026-10-19T09:15:00.400Z",
            "duration_ms": 95 }
        ]

        ```


        Every key is present on every attempt; `null` means not known.

        `processor` / `processor_label` name the processor the way the charge's own pair does.

        `outcome` is `approved`, `declined`, `error` (the gateway refused the request or

        answered something unusable), `timed_out` (no answer came back) or `pending` (still in

        flight). `reconciled: true` marks an attempt whose own answer was lost and whose

        approval was then confirmed by looking the sale up at the gateway; its `outcome` is

        that approval. `decline_code` is the normalized decline (the vocabulary of

        `failure_code`); `response_code` is the gateway's code for that attempt (the

        vocabulary of the charge's `response_code`). `cascade` is true on every attempt after

        the first. `final` marks the attempt that produced the charge's result. `attempted_at`

        is when it was sent (RFC 3339) and `duration_ms` how long it took.


        A charge with one attempt answers one entry. A charge our fraud screening blocked

        answers `[]`: no processor saw it. On a charge made before attempts were published,

        the attempts carry no `processor_label`, `attempted_at` or (except on the final

        attempt) `decline_code`: those were not recorded then. A charge with no recorded

        trail (made before 2026-07-31, or a sale

        synced from your gateway) answers the single attempt its own record proves, with

        `cascade: true` when it was reached by a cascade whose earlier attempts were not kept.
      responses:
        '200':
          description: Charge
          content:
            application/json:
              example:
                id: tx_3PkC9d2eZvKY
                object: charge
                amount: 1999
                currency: usd
                status: succeeded
                paid: true
                captured: true
                created: 1767268800
                processor: NMI
                processor_label: NMI (Worthy)
                mid: '9876543'
                processor_transaction_id: '10234567890'
                network_transaction_id: 024attmpn7fk3d0
                auth_code: '123456'
                response_code: '100'
                response_message: Approved
                avs_result: MATCH
                avs_result_code: 'Y'
                cvv_result: MATCH
                cvv_result_code: M
                statement_descriptor: WORTHY* STORE
                payment_method_type: card
                authorized_at: '2026-08-31T09:15:00Z'
                captured_at: '2026-08-31T09:15:01Z'
                card:
                  brand: visa
                  last4: '4242'
                  bin: '424242'
                  wallet: null
        '404':
          description: resource_missing — no such charge under this key's tenancy.
  /v1/charges/{id}/capture:
    post:
      tags:
        - Charges
      operationId: captureCharge
      summary: Capture an authorized charge (Idempotency-Key required).
      description: >
        Optional partial `amount` (cents). Proxies to `PaymentsOrchestrationService/Capture` (atomic
        claim).


        **Secret key only.** A publishable key is refused with `403 permission_denied`

        before anything reaches upstream: it is public, and a buyer holding their own charge

        id could otherwise capture a few cents of the authorization and release the rest.
      parameters:
        - in: path
          name: id
          description: The authorized charge to capture.
          required: true
          schema:
            type: string
        - in: header
          name: Idempotency-Key
          description: >-
            Required. A repeat of the same key replays the first response instead of charging twice;
            the replay carries `X-Idempotency-Replayed: true`. Keys are stored for 24h. The
            `idempotency_key` body field is accepted instead of this header.
          required: true
          schema:
            type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              required: []
              description: Send an empty object to capture the full authorized amount. `amount` is optional.
              properties:
                amount:
                  type: integer
                  description: Minor units to capture. Omit for the full authorization.
                currency:
                  type: string
                  description: ISO 4217. Omit to keep the charge currency.
            example:
              amount: 1500
              currency: usd
      responses:
        '201':
          description: Charge
          content:
            application/json:
              example:
                id: tx_3PkC9d2eZvKY
                object: charge
                amount: 1999
                amount_captured: 1500
                status: succeeded
                captured: true
        '400':
          description: failed_precondition — charge not in an authorizable/capturable state.
        '402':
          description: >-
            The gateway refused the capture (`card_error` / `card_declined` + `decline_code`). The
            authorization is still held; the same Idempotency-Key replays this answer.
        '403':
          description: >-
            `sandbox_key_required` (test mode only): this account has no NMI sandbox key of its own;
            add it in the dashboard (Settings > Integrations, NMI card, in test mode). Nothing
            reached the gateway; retry once the key is saved.
        '502':
          description: 'Gateway error: the capture''s outcome is not known. Retry with the same Idempotency-Key.'
  /v1/charges/{id}/void:
    post:
      tags:
        - Charges
      operationId: voidCharge
      summary: Void an authorized charge (Idempotency-Key required).
      description: |
        Cancels an authorization before it is captured, proxying to
        `PaymentsOrchestrationService/Void`. Requires `payments:charge` and a key whose
        exec-mode can charge. The `Idempotency-Key` is required here, so a retried void
        resolves to the first answer instead of issuing a second one.

        An authorization is released. A captured charge whose batch has not settled yet is
        voided at the gateway too, so it never reaches the buyer's statement. A settled charge
        (or one the gateway refuses to void because its batch closed) is reversed with a full
        refund instead.

        **Secret key only.** Because a void can become a full refund, a publishable key is
        refused with `403 permission_denied`, exactly as it is on `POST /v1/refunds`. The
        key is public and the buyer sees their own charge id, so allowing it would let a
        buyer refund themselves after delivery.
      parameters:
        - in: path
          name: id
          description: The authorized charge to void.
          required: true
          schema:
            type: string
        - in: header
          name: Idempotency-Key
          description: >-
            Required. A repeat of the same key replays the first response instead of charging twice;
            the replay carries `X-Idempotency-Replayed: true`. Keys are stored for 24h. The
            `idempotency_key` body field is accepted instead of this header.
          required: true
          schema:
            type: string
      responses:
        '201':
          description: Charge (status becomes canceled/voided).
          content:
            application/json:
              example:
                id: tx_3PkC9d2eZvKY
                object: charge
                status: canceled
        '400':
          description: >-
            `void_declined` (+ `decline_code`): the gateway refused the void and the authorization
            is still held; the same Idempotency-Key replays this answer. `failed_precondition`
            (`INVALID_STATE_FOR_VOID`): the charge cannot be voided.
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: >-
            The key lacks the `payments:charge` scope, or is a publishable key
            (`permission_denied`). In test mode, `sandbox_key_required`: this account has no NMI
            sandbox key of its own; add it in the dashboard (Settings > Integrations, NMI card, in
            test mode).
        '404':
          description: No such charge for this key's merchant and env-mode (`resource_missing`).
        '502':
          description: 'Gateway error: the void''s outcome is not known. Retry with the same Idempotency-Key.'
  /v1/subscriptions:
    post:
      tags:
        - Subscriptions
      operationId: createSubscription
      summary: Start a subscription (server-only scope; Idempotency-Key required).
      description: |
        `customer` and `plan` are required. Unless the plan is trialing this takes the
        opening charge on the customer's stored payment method, so it rides the same
        money-path fence as `/v1/charges`: a FRAUD_ONLY key can never reach it, and a
        publishable key can never hold `subscriptions:write`.

        A retried POST with the same `Idempotency-Key` resolves to the SAME subscription
        rather than starting a second one on the same card.

        When the customer has no saved card, or when `payment_behavior` is
        `default_incomplete`, the response is `status: incomplete` and
        `latest_invoice.payment_intent.client_secret` for the existing Payment Element.
        Completing that secret pays the opening invoice and activates the subscription.
        Do not put `plan` or `interval` on the Element.

        Proxies to `BillingService/CreateSubscription`.
      parameters:
        - in: header
          name: Idempotency-Key
          description: >-
            Required. A repeat of the same key replays the first response instead of charging twice;
            the replay carries `X-Idempotency-Replayed: true`. Keys are stored for 24h. The
            `idempotency_key` body field is accepted instead of this header.
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - customer
                - plan
              properties:
                customer:
                  type: string
                  description: Customer id to bill.
                plan:
                  type: string
                  description: Plan id. Plans are created in the dashboard.
                quantity:
                  type: integer
                  description: Units of the plan. Defaults to 1 upstream.
                coupon:
                  type: string
                default_payment_method:
                  type: string
                  description: Saved payment-method id to charge.
                collection_method:
                  type: string
                payment_behavior:
                  type: string
                  enum:
                    - default_incomplete
                  description: >
                    `default_incomplete` skips the opening charge even when a card is on file and
                    returns `status: incomplete` plus `latest_invoice.payment_intent.client_secret`
                    for the Payment Element. Omit it to charge a saved card immediately.
            example:
              customer: cus_9fK2pQ
              plan: plan_4mN7xB
              payment_behavior: default_incomplete
      responses:
        '201':
          description: The subscription.
          content:
            application/json:
              example:
                id: sub_7hT2wQ
                object: subscription
                status: incomplete
                customer: cus_9fK2pQ
                plan: plan_4mN7xB
                quantity: 1
                latest_invoice:
                  object: invoice
                  payment_intent:
                    object: payment_intent
                    status: requires_payment_method
                    client_secret: pi_abc_secret_sess_elem_1
                cancel_at_period_end: false
        '400':
          description: '`idempotency_key_required` — this route requires an `Idempotency-Key`.'
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `subscriptions:write` scope (`permission_denied`).
    get:
      tags:
        - Subscriptions
      operationId: listSubscriptions
      summary: List subscriptions.
      description: |
        Lists subscriptions belonging to the key's merchant and env-mode, paged with
        `limit` and `starting_after`. Reading is `subscriptions:read` — unlike create and
        cancel, which move recurring money and are server-side `subscriptions:write`.
        Narrow the page with `customer` for a single buyer, or with `status`.
      parameters:
        - in: query
          name: limit
          description: Page size. Defaults to 10 and is capped at 100.
          schema:
            type: integer
            default: 10
            maximum: 100
        - in: query
          name: starting_after
          description: >-
            Cursor for the next page: the id of the last row you received. Omit it on the first
            request, and keep paging while `has_more` is true.
          schema:
            type: string
        - in: query
          name: customer
          description: Return only subscriptions belonging to this customer id.
          schema:
            type: string
        - in: query
          name: status
          description: Lifecycle filter — active, trialing, incomplete, past_due, paused, canceled, suspended.
          schema:
            type: string
      responses:
        '200':
          description: A page of subscriptions.
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `subscriptions:read` scope (`permission_denied`).
  /v1/subscriptions/{id}:
    get:
      tags:
        - Subscriptions
      operationId: retrieveSubscription
      summary: Retrieve a subscription.
      description: |
        Fetches one subscription, scoped to the key's merchant and env-mode. Requires
        `subscriptions:read`, the same scope as the list.
      parameters:
        - in: path
          name: id
          description: The subscription id.
          required: true
          schema:
            type: string
      responses:
        '200':
          description: The subscription.
        '404':
          description: No such subscription.
    patch:
      tags:
        - Subscriptions
      operationId: updateSubscription
      summary: Change the plan or the card, pause, or schedule a cancellation (server-only scope).
      description: |
        Send **exactly one** of `plan`, `paused`, `cancel_at_period_end`, or
        `default_payment_method`. Sending none, or more than one, is refused with
        `parameter_invalid` — each maps to a different billing operation and combining them
        has no defined order.

        - `plan` moves the subscription to another plan. Upstream prorates.
        - `paused: true` pauses; `false` resumes. `resumes_at` (ISO-8601) is only
          accepted alongside `paused: true` and schedules the automatic resume.
        - `cancel_at_period_end: true` schedules the cancellation. **There is no undo** —
          `false` is refused rather than accepted as a no-op, because accepting it would
          read as "this un-cancelled" while the subscription still lapsed at period end.
        - `default_payment_method` (`pm_…`) switches the card later renewals charge, with
          no charge now. Typically the card a setup intent just saved: create a setup intent
          for the subscription's customer, let the buyer save the new card with the Payment
          Element, then send its `payment_method` here. The card must be an active saved card
          of the subscription's own customer (another customer's card, or a detached one, is
          404 `resource_missing`). A canceled subscription is refused. A `past_due`
          subscription keeps its retry schedule, and the next retry charges the new card.

        No `Idempotency-Key`: every operation here is naturally idempotent (pausing a
        paused subscription, or moving to the plan already in force, is a no-op), and a
        key would only add a way for a legitimate correction to be swallowed as a
        duplicate.
      parameters:
        - in: path
          name: id
          description: The subscription to update.
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Exactly one of `plan`, `paused`, `cancel_at_period_end` or `default_payment_method`.
              properties:
                plan:
                  type: string
                  description: Move to this plan id.
                paused:
                  type: boolean
                  description: '`true` pauses, `false` resumes.'
                resumes_at:
                  type: string
                  description: 'ISO-8601. Only with `paused: true`.'
                cancel_at_period_end:
                  type: boolean
                  description: '`true` schedules cancellation at period end. `false` is refused.'
                default_payment_method:
                  type: string
                  description: >-
                    `pm_…`: the card later renewals charge, switched with no charge now. An active
                    saved card of the subscription's customer.
            examples:
              changePlan:
                summary: Move to another plan
                value:
                  plan: plan_pro_monthly
              changeCard:
                summary: Switch the card (after a setup intent saved it)
                value:
                  default_payment_method: pm_6b1d2c3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e
      responses:
        '200':
          description: The updated subscription.
        '400':
          description: >-
            None, or more than one, of the four fields was set (`parameter_invalid`), or the change
            does not fit the subscription's status (a canceled subscription's card, for one).
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `subscriptions:write` scope (`permission_denied`).
        '404':
          description: >-
            No such subscription, or (`default_payment_method`) no active saved card of that id for
            the subscription's customer.
    delete:
      tags:
        - Subscriptions
      operationId: cancelSubscription
      summary: Cancel a subscription (server-only scope).
      description: |
        Defaults to cancelling AT PERIOD END — the customer keeps the time they have
        already paid for and the subscription simply stops renewing. Pass
        `{"immediately": true}` to terminate mid-period instead.

        Proxies to `BillingService/CancelSubscription`.
      parameters:
        - in: path
          name: id
          description: The subscription to cancel.
          required: true
          schema:
            type: string
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              required: []
              description: >-
                Optional. Send no body to cancel at period end. `immediately` and `reason` are
                optional.
              properties:
                immediately:
                  type: boolean
                  description: Cancel now instead of at period end.
                reason:
                  type: string
            example:
              immediately: false
              reason: customer_request
      responses:
        '200':
          description: The updated subscription.
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `subscriptions:write` scope (`permission_denied`).
        '404':
          description: No such subscription for this key's merchant and env-mode (`resource_missing`).
  /v1/plans:
    post:
      tags:
        - Subscriptions
      operationId: createPlan
      summary: Create a subscription plan.
      description: |
        Proxies `BillingService/CreatePlan`. Same object the dashboard writes. Amount is
        integer minor units. Interval is `day` / `week` / `month` / `year` (case-insensitive;
        stored and listed as `DAY` / `WEEK` / `MONTH` / `YEAR`). There is no `minute` or
        `second` interval. Requires `plans:write`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - name
                - amount
                - interval
              properties:
                name:
                  type: string
                description:
                  type: string
                amount:
                  type: integer
                  minimum: 50
                  description: Minor units. Below 50 is refused (processor floor).
                currency:
                  type: string
                  description: ISO 4217. Defaults to usd.
                interval:
                  type: string
                  enum:
                    - day
                    - week
                    - month
                    - year
                    - DAY
                    - WEEK
                    - MONTH
                    - YEAR
                interval_count:
                  type: integer
                  minimum: 1
                  default: 1
                trial_days:
                  type: integer
                  minimum: 0
                  default: 0
            example:
              name: Pro
              amount: 1999
              currency: usd
              interval: month
      responses:
        '201':
          description: The created plan.
          content:
            application/json:
              example:
                id: plan_2f9d1a77-6c30-4be1-9a54-0f7c8b21d4e3
                object: plan
                name: Pro
                amount: 1999
                currency: usd
                interval: MONTH
                interval_count: 1
                trial_days: 0
                active: true
                archived: false
        '400':
          description: parameter_invalid / parameter_unknown.
        '401':
          description: api_key_required / api_key_invalid.
        '403':
          description: permission_denied — the key lacks `plans:write`.
    get:
      tags:
        - Subscriptions
      operationId: listPlans
      summary: List subscription plans.
      description: |
        The plans this merchant has defined, and the way to resolve the `plan` id that
        `POST /v1/subscriptions` requires. Proxies `BillingService/ListPlans`. Cursor-paged
        with `starting_after` (same shape as GET /v1/charges). Scoped to the key's merchant.
        Requires `plans:read`. Create with `POST /v1/plans` or in the dashboard.

        `amount` is in MINOR UNITS like every other money field on this API, and carries the
        currency the plan was priced in.

        Archived plans are hidden unless `include_archived=true`: an archived plan cannot
        take a new subscription, so listing one by default would hand back an id that
        `POST /v1/subscriptions` then refuses.
      parameters:
        - in: query
          name: limit
          description: Page size. Defaults to 10 and is capped at 100.
          schema:
            type: integer
            default: 10
            maximum: 100
        - in: query
          name: starting_after
          description: >-
            Cursor for the next page: the id of the last row you received. Omit it on the first
            request, and keep paging while `has_more` is true.
          schema:
            type: string
        - in: query
          name: active
          description: '`true` returns only plans currently accepting subscriptions.'
          schema:
            type: boolean
        - in: query
          name: include_archived
          description: Send `true` to include archived plans alongside live ones. Defaults to false.
          schema:
            type: boolean
            default: false
        - in: query
          name: interval
          description: Return only plans that bill on this interval.
          schema:
            type: string
            enum:
              - DAY
              - WEEK
              - MONTH
              - YEAR
      responses:
        '200':
          description: A page of plans.
          content:
            application/json:
              example:
                object: list
                data:
                  - id: plan_2f9d1a77-6c30-4be1-9a54-0f7c8b21d4e3
                    object: plan
                    name: Pro monthly
                    description: ''
                    amount: 2900
                    currency: usd
                    interval: MONTH
                    interval_count: 1
                    trial_days: 14
                    usage_type: LICENSED
                    billing_scheme: PER_UNIT
                    seat_limit: 0
                    metering_unit: null
                    active: true
                    archived: false
                    subscription_count: 42
                    created: 1788100472
                    updated: 1788100472
                has_more: false
        '401':
          description: api_key_required / api_key_invalid.
        '403':
          description: permission_denied — the key lacks `plans:read`.
  /v1/plans/{id}:
    get:
      tags:
        - Subscriptions
      operationId: retrievePlan
      summary: Fetch a plan.
      description: |
        Proxies `BillingService/GetPlan`. Scoped to the key's merchant. Requires
        `plans:read`. The id accepts either the prefixed `plan_...` form the subscription
        object shows or the bare uuid. A plan belonging to another merchant or the other
        env mode answers `404`, not `403` — the id is not evidence the caller may see it.
      parameters:
        - in: path
          name: id
          description: The plan id.
          required: true
          schema:
            type: string
      responses:
        '200':
          description: The plan.
        '401':
          description: api_key_required / api_key_invalid.
        '403':
          description: permission_denied — the key lacks `plans:read`.
        '404':
          description: resource_missing — no such plan for this merchant.
  /v1/invoices:
    get:
      tags:
        - Invoices
      operationId: listInvoices
      summary: List invoices.
      description: |
        Proxies `BillingService/ListInvoices`. Cursor-paged with `starting_after`.
        Requires `invoices:read`. Amounts are integer minor units.
        `hosted_invoice_url` is null on a draft and
        `https://app.worthypayments.com/invoices/{id}/view?token=…` after finalize.
        A successful CHARGE_AUTOMATICALLY renewal (including test bill-now)
        writes a PAID row. Opening create does not mint an invoice in this
        release. An empty `data` array is a successful list, not a missing route.
      parameters:
        - in: query
          name: limit
          description: Page size. Defaults to 10 and is capped at 100.
          schema:
            type: integer
            default: 10
            maximum: 100
        - in: query
          name: starting_after
          description: 'Cursor: the last invoice id from the previous page.'
          schema:
            type: string
        - in: query
          name: customer
          description: Return only invoices for this customer id.
          schema:
            type: string
        - in: query
          name: status
          description: Filter by status, e.g. `open`, `paid`, `void`.
          schema:
            type: string
      responses:
        '200':
          description: A page of invoices.
          content:
            application/json:
              example:
                object: list
                data: []
                has_more: false
        '401':
          description: api_key_required / api_key_invalid.
        '403':
          description: permission_denied — the key lacks `invoices:read`.
  /v1/invoices/{id}:
    get:
      tags:
        - Invoices
      operationId: retrieveInvoice
      summary: Fetch an invoice.
      description: |
        Proxies `BillingService/GetInvoice`. Accepts `in_<uuid>` or the bare uuid.
        `hosted_invoice_url` is null on a draft; after finalize it is
        `https://app.worthypayments.com/invoices/{id}/view?token=…`.
      parameters:
        - in: path
          name: id
          description: The invoice id.
          required: true
          schema:
            type: string
      responses:
        '200':
          description: The invoice.
          content:
            application/json:
              example:
                id: in_7c1e0b2a-4d83-4f11-9c44-2a6f8d91e0b3
                object: invoice
                status: PAID
                amount_due: 0
                total: 1999
                currency: usd
                hosted_invoice_url: >-
                  https://app.worthypayments.com/invoices/7c1e0b2a-4d83-4f11-9c44-2a6f8d91e0b3/view?token=…
        '401':
          description: api_key_required / api_key_invalid.
        '403':
          description: permission_denied — the key lacks `invoices:read`.
        '404':
          description: resource_missing — no such invoice for this merchant.
  /v1/invoices/{id}/send:
    post:
      tags:
        - Invoices
      operationId: sendInvoice
      summary: Email this invoice (server-only `invoices:write`).
      description: |
        Proxies `BillingService/SendInvoice`. Accepts `in_<uuid>` or the bare uuid.
        OPEN or later only — a draft is refused (`finalize first`; finalize stays
        on the dashboard). Re-send is allowed and emits `invoice.sent` again.
        A publishable key is refused. There is no `invoice.payment_failed`
        event; a declined card renewal is `subscription.past_due`.
      parameters:
        - in: path
          name: id
          description: The invoice to send.
          required: true
          schema:
            type: string
      responses:
        '200':
          description: The invoice after send.
          content:
            application/json:
              example:
                id: in_7c1e0b2a-4d83-4f11-9c44-2a6f8d91e0b3
                object: invoice
                status: OPEN
                amount_due: 1999
                total: 1999
                currency: usd
                hosted_invoice_url: >-
                  https://app.worthypayments.com/invoices/7c1e0b2a-4d83-4f11-9c44-2a6f8d91e0b3/view?token=…
        '400':
          description: failed_precondition — draft (finalize first) or void.
        '401':
          description: api_key_required / api_key_invalid.
        '403':
          description: permission_denied — the key lacks `invoices:write`, or is a publishable key.
        '404':
          description: resource_missing — no such invoice for this merchant.
  /v1/payment_intents:
    post:
      tags:
        - Payment Intents
      operationId: createPaymentIntent
      summary: Create a payment intent (server-only scope; Idempotency-Key required).
      description: >
        Decide the amount on YOUR server, then hand the browser only the returned

        `client_secret`. Nothing downstream ever reads an amount from the buyer's side.


        ```

        POST /v1/payment_intents        Authorization: Bearer sk_..
          { "amount": 4999, "currency": "usd" }
        → { "id": "pi_...", "client_secret": "pi_..._secret_...", "status":
        "requires_payment_method" }

        ```


        `amount` is an integer in the smallest currency unit — `4999` is $49.99. A float

        is rejected rather than rounded, because `49.99` would otherwise charge 49 cents.


        The intent is stored as a checkout session, which is what makes the amount

        authoritative: the charge path reads the total from the session, not the request.

        `description` names the single ad-hoc line; `reference` is your own order or cart

        id and is echoed back to you, never shown to the buyer.


        **Metadata.** `metadata` is your own key/value pairs, with the same rules as

        `POST /v1/charges`: up to 50 keys, keys 1–40 characters without square brackets,

        string values up to 500 characters (a number or boolean is stored as its text), and

        a key sent with an empty value is not stored. It is answered with `reference`: on

        create and update, and on retrieve to secret and restricted keys only, never to the

        publishable key the buyer's page holds. The charge the intent makes **copies it once,

        when the charge is made**, so `GET /v1/charges/{id}`, the charge list and the

        `charge.*` webhooks carry it; changing the intent's metadata later does not change a

        charge already made. `checkout.session.completed` carries it too. A GDPR customer

        deletion clears it. `PATCH /v1/payment_intents/{id}` merges into it.


        **3DS.** `three_ds.request` is the same request `POST /v1/charges` takes (`automatic`

        | `any` | `challenge` | `challenge_mandated`, EMV 3DS indicators 01 / 03 / 04), set

        here by your server and applied to the charge the Payment Element confirms; the

        Element runs the challenge itself. Your rules still outrank it: when they ask for

        3DS the challenge is mandated whatever `request` says. It applies to a card the

        buyer enters for this payment on the `ev:` rail (the Element's default), and to a

        saved card: one saved with its encrypted number kept (every card saved from

        2026-10-18) answers `requires_action` and is authenticated in place. A saved card with

        no kept number (saved earlier, or imported) is declined `soft_decline_3ds` with

        nothing charged (200 `requires_payment_method`) and the Element asks the buyer to

        re-enter that card, which is then authenticated and charged (and, when it is the same

        card, keeps its encrypted number for next time). The same happens when your rules ask

        for 3DS on a saved card (the route's Attempt 3DS, the 3DS risk band, a customer marked

        Require 3DS, another fraud rule): the buyer is at the Element, so a saved card is not

        treated as merchant-initiated here; on a re-entry you receive `charge.failed` for the

        declined attempt, then the events of the re-entered card. A wallet (its own SCA) or a
        `wp:`-sealed card pays without it. Every

        read of the intent answers it back as `three_ds.request` (`automatic` when unset).


        **Saving the card for later payments.** `setup_future_usage` (`off_session` or

        `on_session`) saves the card this intent is paid with to `customer` once the payment is

        approved, whatever the buyer's page sends: the Payment Element draws no "save this

        card" box for such an intent, and `save_payment_method` on confirm changes nothing.

        Use `off_session` when you will charge the card without the buyer present (a

        subscription renewal), `on_session` when the buyer will pay with it again at your

        checkout; both save the card the same way, and this payment is the card's initial

        stored-credential transaction (customer-initiated), whose network id later

        merchant-initiated charges on the card carry. `customer` is required with it, or the

        create is a 400 `parameter_missing` with `param: customer`. The outcome is the

        intent's `card_save`, as for a ticked box: a fresh card is `saved` (or `failed`); a

        wallet payment (Apple Pay, tokenised Google Pay) cannot be saved and answers

        `skipped` with `failure_code: card_not_saveable`, while the payment still succeeds; a

        card the buyer picks from their saved cards is already saved and carries no

        `card_save`. Tell the buyer at your checkout that the card is kept for later payments.

        Every read answers it back as `setup_future_usage` (`null` when unset).
      parameters:
        - in: header
          name: Idempotency-Key
          description: >-
            Required. A repeat of the same key replays the first response instead of charging twice;
            the replay carries `X-Idempotency-Replayed: true`. Keys are stored for 24h. The
            `idempotency_key` body field is accepted instead of this header.
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - amount
              properties:
                amount:
                  type: integer
                  minimum: 50
                  description: >-
                    Minor units. The price your server names; the browser never sends it. The floor
                    is the same 50 cents `POST /v1/charges` enforces — an intent below it could be
                    created and never paid. Sending none, or less, is a 400 `parameter_invalid`.
                currency:
                  type: string
                  description: ISO 4217.
                description:
                  type: string
                customer:
                  type: string
                  description: >-
                    Customer id the payment and any saved card belong to. It must be an existing
                    customer of this key's merchant and mode: the `cus_<uuid>` id `GET
                    /v1/customers` returns, the bare uuid, or the `CUS-…` number. The intent answers
                    with the `cus_<uuid>` form, a `CUS-…` number included. Any other value (unknown,
                    deleted, or another account's or mode's) is a 400 `resource_missing` with
                    `param: customer`. Authoritative: the email the buyer types on confirm is only
                    the receipt address and never moves the payment to another customer. Without it,
                    the confirm email decides (find-or-create).
                receipt_email:
                  type: string
                  description: >-
                    Receipt destination. Supply this or `billing.email` on confirm, or the confirm
                    fails `failed_precondition`.
                reference:
                  type: string
                  description: Your own order or cart id.
                metadata:
                  type: object
                  additionalProperties:
                    type: string
                    maxLength: 500
                  maxProperties: 50
                  description: >-
                    Your own key/value pairs, with the `POST /v1/charges` rules (see the operation
                    description). Copied onto the charge when it is made. Answered to server keys
                    only.
                three_ds:
                  type: object
                  additionalProperties: false
                  properties:
                    request:
                      type: string
                      enum:
                        - automatic
                        - any
                        - challenge
                        - challenge_mandated
                      default: automatic
                      description: >-
                        Ask for 3DS on the charge this intent makes (see the operation description).
                        `three_ds.session_id` belongs to confirm, not create.
                setup_future_usage:
                  type: string
                  nullable: true
                  enum:
                    - off_session
                    - on_session
                  description: >-
                    Save the card this intent is paid with to `customer`, whatever the buyer chooses
                    (see the operation description). Requires `customer`. `null` or omitted saves it
                    only when the buyer ticks the save box.
                idempotency_key:
                  type: string
                  description: Accepted instead of the `Idempotency-Key` header.
            example:
              amount: 4999
              currency: usd
              description: Order
              reference: ord_1234
              receipt_email: jane@example.com
              customer: cus_3f0c9d1e-7a2b-4c5d-8e9f-0a1b2c3d4e5f
              metadata:
                order_id: '8812'
                order_number: '1234'
              setup_future_usage: off_session
              three_ds:
                request: challenge
      responses:
        '201':
          description: Payment intent, with the client secret for the browser.
          content:
            application/json:
              example:
                id: pi_111111112222433384445555
                object: payment_intent
                amount: 4999
                currency: usd
                status: requires_payment_method
                client_secret: pi_111111112222433384445555_secret_tok_xxx
                customer: cus_3f0c9d1e-7a2b-4c5d-8e9f-0a1b2c3d4e5f
                reference: ord_1234
                metadata:
                  order_id: '8812'
                  order_number: '1234'
                latest_charge: null
                three_ds:
                  request: challenge
                setup_future_usage: off_session
        '400':
          description: >-
            parameter_invalid on `three_ds.request` (an unknown value) or `three_ds.<key>` (a key
            create does not take, `session_id` included), parameter_invalid on `setup_future_usage`
            (not `off_session`, `on_session` or null), parameter_invalid with `param: metadata` (the
            `POST /v1/charges` metadata rules and messages), and parameter_missing with `param:
            customer` (`setup_future_usage` without a customer) are refused before the
            Idempotency-Key is used, so a corrected request may reuse the key. Otherwise:
            parameter_invalid (amount not a positive integer / currency not settleable / `customer`
            not a string), or resource_missing with `param: customer` when `customer` names no
            customer of this key's merchant and mode. The message is `No such customer: '<customer
            as sent, trimmed>'`. That 400 is stored on the Idempotency-Key like any other answer, so
            the same key replays it; correct the id and send a new key.
          content:
            application/json:
              example:
                error:
                  type: invalid_request_error
                  code: resource_missing
                  message: 'No such customer: ''cus_5d87e946-1c2b-4d3e-8f40-a1b2c3d4e5f6'''
                  param: customer
                  request_id: req_123
        '401':
          description: api_key_required / api_key_invalid.
        '403':
          description: >-
            permission_denied (a publishable key, or missing `payment_intents:create`) or
            mode_not_allowed (`fraud_only`).
        '409':
          description: idempotency_in_progress — the same key is in flight.
        '503':
          description: >-
            api_error / upstream_error: a service behind the API did not answer. With `customer`
            sent, that includes the customer check not answering in time, in which case no intent
            was created. A 503 is not stored on the key: retry with the same Idempotency-Key.
  /v1/payment_intents/{client_secret}:
    get:
      tags:
        - Payment Intents
      operationId: retrievePaymentIntent
      summary: Retrieve a payment intent by its client secret (browser-safe).
      description: >-
        Scope `none`, so the Element can poll it after confirming to learn the outcome. `reference`
        (your order id from create) and `metadata` (your key/value pairs, `{}` when none) are
        answered to secret and restricted keys only; a publishable key, which the buyer's page
        holds, never receives them.


        **This is what you poll after a `processing` confirm.** When the intent's charge outcome is
        not known yet, this read first asks for it — **it never charges** — and answers the result:
        `succeeded` (with `latest_charge`), `requires_payment_method` with
        `last_payment_error.decline_code: payment_not_completed` (nothing was charged; the buyer can
        pay again on the same intent), `requires_action` with `next_action.three_ds.session_id`
        while the buyer is still in a 3-D Secure challenge (an intent awaiting its customer is never
        reported as not charged; a challenge left for 15 minutes counts as abandoned), or still
        `processing`. Poll every few seconds; any other intent is read as it stands, with nothing
        extra done.


        **Warning:** Keyed on the **client secret**, not the `pi_...` id. The id is not secret — it
        appears in dashboards, logs and webhook payloads — so accepting it here would turn every
        leaked id into a readable intent. The secret is the only credential that addresses one
        intent, which is exactly the capability-URL contract the hosted checkout session already
        uses.


        The response omits `client_secret`: the caller already holds it, and echoing it on every
        read only widens where it can be logged. Tenancy-scoped — an intent belonging to another
        merchant or the other env mode 404s.


        **A `succeeded` intent carries its receipt and card-save result.** For a `succeeded` intent
        with a `latest_charge` (read as it stands, or just resolved by the refresh above), this read
        makes one transaction read under your key's merchant and mode and adds `charge` (the same
        named receipt subset confirm returns), `card_save` and, when the card was saved, the
        top-level `payment_method` — the same object confirm answered (see `PaymentIntentCardSave`).
        That read is bounded at 5 seconds: when it fails or does not answer in time, the intent is
        returned as it stands, still `succeeded`, without `charge`, `card_save` and
        `payment_method`. Every other status makes no extra call and carries none of the three.
      parameters:
        - in: path
          name: client_secret
          required: true
          schema:
            type: string
          description: The `client_secret` from create.
      responses:
        '200':
          description: >-
            Payment intent, without the client secret. A `succeeded` intent also carries `charge`,
            and `card_save` / `payment_method` when the payment asked to save the card.
          content:
            application/json:
              schema:
                type: object
                description: >-
                  The card-save fields of a payment intent (the other fields are as in the
                  examples). Both are absent unless the intent is `succeeded`.
                properties:
                  card_save:
                    $ref: '#/components/schemas/PaymentIntentCardSave'
                  payment_method:
                    type: string
                    description: >-
                      `pm_…` id of the card this payment saved: equal to `card_save.payment_method`,
                      and present only when that is not null. A payment made with an already saved
                      card carries none.
              examples:
                succeededCardSaved:
                  summary: Paid, and the buyer's card was saved
                  value:
                    id: pi_111111112222433384445555
                    object: payment_intent
                    amount: 4999
                    currency: usd
                    status: succeeded
                    customer: cus_3f0c9d1e-7a2b-4c5d-8e9f-0a1b2c3d4e5f
                    latest_charge: TXN-01J8Z9
                    charge:
                      id: TXN-01J8Z9
                      object: charge
                      amount: 4999
                      currency: usd
                      status: succeeded
                      paid: true
                      captured: true
                      card:
                        brand: visa
                        last4: '4242'
                    card_save:
                      status: saved
                      payment_method: pm_6b1d2c3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e
                      failure_code: null
                      failure_message: null
                    payment_method: pm_6b1d2c3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e
                    three_ds:
                      request: automatic
                    setup_future_usage: null
                processing:
                  summary: Outcome not known yet — no receipt, no card-save result
                  value:
                    id: pi_111111112222433384445555
                    object: payment_intent
                    amount: 4999
                    currency: usd
                    status: processing
                    customer: null
                    latest_charge: null
                    three_ds:
                      request: challenge
                    setup_future_usage: null
        '401':
          description: api_key_required / api_key_invalid.
        '404':
          description: resource_missing — no such intent for this merchant (tenancy-scoped).
  /v1/payment_intents/{id}:
    patch:
      tags:
        - Payment Intents
      operationId: updatePaymentIntent
      summary: >-
        Update a payment intent's amount, reference, customer, receipt email or metadata
        (server-only scope).
      description: |
        The cart changed. Write the new total onto the intent instead of minting a
        second one and leaving the first to expire unpaid. Or what the intent says about
        the payment changed: its `reference`, `customer`, `receipt_email` or `metadata`.
        Send only what changes; everything you leave out stays as it is.

        **Keyed on the `pi_...` id, NOT the client secret** — the opposite of every other
        route on this resource, and deliberately so. The secret lives in the buyer's page,
        so a secret-addressed re-price would hand the buyer the one decision the Payment
        Element exists to keep on your server. The id is not a capability, and reaching
        this route also needs the server-only scope `payment_intents:create` — the same
        scope that named the first price, because naming a new one is the same authority.

        **The client secret does not change.** An element already mounted against this
        intent keeps working and simply sees the new amount on its next read; there is
        nothing to re-mount. The response omits `client_secret` for that reason.

        Only an unpaid intent moves. Once a charge is in flight or complete, or the intent
        expired, the call fails (`failed_precondition`) rather than silently doing nothing,
        so a late cart edit can never disagree with what the card was charged. The same
        holds for every field here. Retrying is safe — the body states what the intent
        should END UP as, so applying it twice lands on the same intent, which is why no
        `Idempotency-Key` is required.

        **The details.**

        - `metadata` is **merged**, as on Stripe: a key you send is set, a key you send
          with an empty value (`""`) is removed, and keys you do not send are kept. There
          is no way to remove every key at once (`metadata: null` is a 400); send each
          with `""`. The `POST /v1/charges` rules apply to what you send, and the intent
          can hold at most 50 keys after the merge.
        - `reference` and `receipt_email` replace what the intent holds; `""` or `null`
          clears them.
        - `customer` names a customer the same way create does, with the same check (an
          existing customer of this key's merchant and mode). It replaces the intent's
          customer; it cannot be removed (`null` or `""` is a 400). **It cannot change once
          a payment attempt on the intent started**, even one that was declined: that
          attempt, and any card it saved, belong to the customer the intent had then.
          Sending the customer the intent already has is not a change and is accepted.
          Changing it never moves a card already saved for any customer.
        - A charge copies the intent's `reference` and `metadata` when it is made: an edit
          after a declined attempt reaches the next attempt's charge, never one already made.

        With an `amount` and details in one request, the details are applied first, then
        the amount. Everything is validated before either, and a refused customer changes
        nothing; if the amount then fails (the intent stopped being open in between) the
        details stay changed, and sending the same request again finishes it. A request
        with neither an `amount` nor a detail is refused on `amount`, as before.
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
          description: The `pi_...` id from create (not the client secret).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                amount:
                  type: integer
                  description: >-
                    New total in minor units. `7500` is $75.00. Decimals are rejected. Required when
                    `currency` or `description` is sent, or when no detail is.
                currency:
                  type: string
                  description: ISO 4217. Omit to keep the intent's existing currency.
                description:
                  type: string
                  description: Re-labels the line. Omitted, it falls back to "Payment".
                reference:
                  type: string
                  nullable: true
                  description: Your own order or cart id. `""` or `null` clears it.
                customer:
                  type: string
                  description: >-
                    The customer the payment belongs to, as on create. Cannot be removed, and cannot
                    change once a payment attempt on the intent started.
                receipt_email:
                  type: string
                  nullable: true
                  description: Receipt destination. `""` or `null` clears it.
                metadata:
                  type: object
                  additionalProperties:
                    type: string
                    maxLength: 500
                  description: >-
                    Merged into the intent's metadata: a key with `""` is removed, keys not sent are
                    kept. At most 50 keys after the merge.
            examples:
              reprice:
                summary: The cart changed
                value:
                  amount: 7500
                  currency: usd
                  description: Order
              details:
                summary: Name the order and the customer
                value:
                  reference: ord_1234
                  customer: cus_3f0c9d1e-7a2b-4c5d-8e9f-0a1b2c3d4e5f
                  metadata:
                    order_id: '8812'
                    coupon: ''
      responses:
        '200':
          description: >-
            The updated payment intent, without the client secret, with its `reference` and
            `metadata`.
          content:
            application/json:
              example:
                id: pi_111111112222433384445555
                object: payment_intent
                amount: 7500
                currency: usd
                status: requires_payment_method
                customer: cus_3f0c9d1e-7a2b-4c5d-8e9f-0a1b2c3d4e5f
                receipt_email: jane@example.com
                reference: ord_1234
                metadata:
                  order_id: '8812'
                latest_charge: null
        '400':
          description: >-
            parameter_invalid (amount not a positive integer / currency not settleable; a
            `reference`, `receipt_email` or `customer` that is not a string; a `customer` sent as
            `null` or `""`; `metadata` breaking the `POST /v1/charges` rules, `null` included, or
            holding more than 50 keys after the merge, with `param: metadata` and the message
            `metadata can have at most 50 keys.`); resource_missing with `param: customer` when
            `customer` names no customer of this key's merchant and mode (`No such customer:
            '<customer as sent, trimmed>'`); failed_precondition with `param: customer` when the
            customer would change after a payment attempt on the intent started; or
            failed_precondition: the intent is no longer open (a charge is in flight or done, or it
            expired), so nothing about it changes.
        '401':
          description: api_key_required / api_key_invalid.
        '403':
          description: >-
            permission_denied (a publishable key, or missing `payment_intents:create`) or
            mode_not_allowed (`fraud_only`).
        '404':
          description: >-
            resource_missing — no such intent for this merchant (tenancy-scoped), a malformed `pi_`
            id, or a `pi_` id built from a checkout session that is not a payment intent (a payment
            link's, an invoice's, a subscription's first payment, a store cart): nothing about that
            session changes.
        '503':
          description: >-
            api_error / upstream_error: a service behind the API did not answer. With `customer`
            sent, that includes the customer check not answering in time, in which case nothing
            changed. Send the same request again.
  /v1/payment_intents/{client_secret}/confirm:
    post:
      tags:
        - Payment Intents
      operationId: confirmPaymentIntent
      summary: Confirm a payment intent from the browser.
      description: >
        Attach an instrument and charge the intent. Scope `none` — the capability is

        the client secret, so this is the one write the browser makes.


        **Confirming from your server.** The IP the payment is scored on is the connection we

        see. With your publishable key in the buyer's browser that is the buyer. With a secret

        or restricted key it is your server, so it is recorded as your server's address and never

        counted as a buyer IP, as on `POST /v1/charges`. To give a server-side confirm the

        buyer's own IP and browser, send `device_session` (from worthypayments.js

        `getDeviceSession()` in the buyer's browser). An unknown or expired session is a `400`

        with `param: device_session` and nothing is charged; if our fraud service cannot read it,

        the confirm goes ahead without it.


        **Important:** `amount` and `currency` are refused here, not ignored. The price was

        fixed when your server created the intent. Silently dropping the field would

        let an integrator who believes they are changing the price ship a checkout

        that charges the original amount forever, with nothing to notice.


        Pass **exactly one** instrument:


        | Field | What it is |

        |---|---|

        | `card` | `ev:` field tokens (from `POST /test/tokens` in TEST, or an Evervault-encrypted
        card rail) |

        | `wp_token` | a `wp:v1:` HPKE envelope |

        | `payment_method` | a saved card id from a customer session |

        | `wallet` | an Apple/Google Pay credential from the wallet sheet |


        There are **four**, not three. The refusal you get for sending none names all four

        (`pass \`card\`, \`wp_token\`, \`payment_method\`, or \`wallet\``), and a table that

        listed three sent readers looking for a wallet route that does not exist.


        Prefer `mountPaymentElement` for browser payments (SAQ A). Do not call non-public

        SDK helpers such as `tokenize()` from the host page.


        Two is refused rather than resolved: downstream picks a single instrument,

        so the other would be dropped silently and the buyer could be billed

        something the caller did not intend.


        **3-D Secure.** A `requires_action` response carries

        `next_action.three_ds.session_id`. Run the challenge, then call this endpoint

        again with the **same instrument** plus `three_ds.session_id`. The instrument

        is required on every confirm, including this one. `three_ds` takes only

        `session_id` here: `three_ds.request` is your server's choice at create, and on

        confirm it is a 400 `parameter_invalid` (`param: three_ds.request`).



        You do not need to withhold it to be safe: substitution is prevented

        server-side, where the 3DS session is bound to the amount it authenticated

        and the charge key is derived from the session.


        A decline is a normal outcome, not an error: you get `200` with

        `status: "requires_payment_method"` and `last_payment_error`, and the buyer

        can try another card against the same intent.



        `last_payment_error` is `{ type: "card_error", code: "card_declined", decline_code,

        message, network_status }`. `decline_code` is the normalized reason

        (`insufficient_funds`, `duplicate_transaction`, `payment_not_completed`, …), absent

        when none is known. `message` is the human text: the issuer's, or the gateway's own

        words when the gateway refused the request (e.g. `Duplicate transaction REFID:…`).

        `network_status` (a WorthyPayments extension, the vocabulary of a charge's

        `outcome.network_status`) is `not_sent_to_network` when the gateway refused the request

        before it reached the card network, `declined_by_network` when the issuer or network

        declined it, and absent when not known (`payment_not_completed` included). Branch the

        buyer's wording on it: `processing_error` is also an issuer answer, so the code alone

        cannot say whether the card was ever seen.


        **The four statuses this can return** (always `200`):


        | `status` | What it means |

        |---|---|

        | `succeeded` | The money moved. Fulfil the order. |

        | `requires_action` | 3-D Secure challenge — see above. |

        | `requires_payment_method` | Declined. `last_payment_error` says why; the buyer can retry
        on the same intent. |

        | `processing` | **The outcome is not known yet.** Poll the retrieve endpoint, never this
        one — see below. |


        **The charge rides along.** When the confirm resolves to one, the response carries

        `charge` — what a receipt shows. It is read back after the payment, so it is absent

        — never partial, never wrong — when that read does not answer.



        ⛔ **A named subset, because this route is browser-reachable.** Confirm runs under

        `RequireScopes("none")`: a publishable key calls it, and a publishable key is

        public. The server charge carries more — the merchant's acquiring identity

        (`mid`, `processor` = Admin → Acquirers → Code of the acquirer Payment Workflow

        chose for this sale),

        the references another system asks for later (`network_transaction_id`, `arn`,

        `rrn`, `settlement`) and the gateway's `avs_result` / `cvv_result` verdicts. None

        of that answers "what did the buyer just pay", and publishing verification

        verdicts to a page the buyer can read is how a card-testing script learns which

        addresses pass. Read them from `GET /v1/charges/{id}` with a secret key.



        No instrument reference crosses either: no token, no vault id, no PAN, no CVV, and

        no `card.bin`.



        **Whether the card was saved.** When the buyer consented (`save_payment_method: true`),

        a `succeeded` intent carries `card_save` (`PaymentIntentCardSave`) and, when the card

        was saved, the top-level `payment_method` (`pm_…`). Both are read from the persisted

        transaction in the same bounded read as `charge`, before the receipt is filtered, and

        `charge` never carries them. The landed replay of an already-paid intent and a

        confirm recovered after a forwarding fault answer the same value. They are

        **absent** — never `null` — when the payment did not ask to save the card, when the

        intent is not `succeeded` (a decline, a challenge, `processing`), and when the read

        fails or does not answer within 5 seconds (then `charge` is absent too; the payment's

        status and HTTP code are unchanged). `card_save.status: "pending"` means the save may

        still be running or its outcome is unknown, and **it can stay pending**: no later read

        of this intent moves it. A save that completes later sends the webhook

        `payment_method.attached` only for a newly saved card, `payment_method.updated` when

        it renewed a card the customer already had (same card, new expiry), and no event when

        that card was already saved with the same expiry. So settle a lasting `pending` on your

        server with `GET /v1/customers/{id}/payment_methods` (secret key, `customers:read`);

        never wait for `payment_method.attached`.



        **The returned `pm_…` id is the saved card's public id.** Charge it from your server

        with `POST /v1/charges` (`payment_method.payment_method_id`) or find it in

        `GET /v1/customers/{id}/payment_methods`. A later browser confirm with a saved card

        takes it as `payment_method`, together with the `X-Customer-Session` header of the

        customer who owns it. Two places carry the same card's bare id, without the `pm_`

        prefix: the customer-session list (`GET /v1/customer_sessions/payment_methods`, or the

        SDK's `savedCards()`) and the `payment_method.attached` / `payment_method.updated` /

        `payment_method.detached` webhooks. Confirm takes the bare id too.



        `processing` is reachable and must be handled. It means the gateway did not tell us

        in time whether the card was charged, or that an earlier confirm of this intent is

        still charging (a buyer who pressed Pay again while the first one was in flight). Confirm
        answers it at once — it does not wait —

        and it is final for this call. Do **not** fulfil on it, and do **not** poll this

        endpoint: confirming is a payment attempt, not a status read. Learn the outcome by

        polling `GET /v1/payment_intents/{client_secret}` every few seconds — that read asks

        for the outcome without charging — or wait for the `charge.succeeded` webhook. The

        intent moves to `succeeded`, or to `requires_payment_method` with

        `last_payment_error.decline_code: payment_not_completed` when nothing was charged

        (while a 3-D Secure challenge is still open it reads `requires_action`).

        Treating `processing` as success is the one mistake that ships goods for money that

        never arrived.
      parameters:
        - in: path
          name: client_secret
          description: The `client_secret` of the intent to confirm, taken from the create response.
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: []
              oneOf:
                - required:
                    - card
                - required:
                    - wp_token
                - required:
                    - payment_method
                - required:
                    - wallet
              description: >-
                The amount is NOT here - it is fixed on the intent. Send exactly one instrument:
                none is a 400 `parameter_missing`, two is a 400 `parameter_invalid`. A `card` counts
                only when `card.number` is set.
              properties:
                client_secret:
                  type: string
                  description: Echo of the path value; the path is authoritative.
                card:
                  type: object
                  description: An `ev:` card token from tokenize().
                  properties:
                    number:
                      type: string
                    cvv:
                      type: string
                    exp_month:
                      type: string
                    exp_year:
                      type: string
                    name:
                      type: string
                wp_token:
                  type: string
                  description: A `wp:` HPKE envelope, instead of the per-field `ev:` tokens.
                payment_method:
                  type: string
                  description: >-
                    A saved card, by its `pm_…` id (as `GET /v1/customers/{id}/payment_methods` and
                    `card_save.payment_method` give it) or the bare id `GET
                    /v1/customer_sessions/payment_methods` lists, sent with the `X-Customer-Session`
                    header of the customer who owns it. A card the session customer does not own is
                    refused with `customer_session_invalid`.
                save_payment_method:
                  type: boolean
                  description: >-
                    The buyer's explicit consent to save this card for future payments. Only `true`
                    turns saving on; absent or `false` stores the card nowhere. Never pre-tick the
                    control that sets it. The result is `card_save` on a `succeeded` intent.
                device_session:
                  type: string
                  pattern: ^ds_[A-Za-z0-9]{1,61}$
                  description: >-
                    For a confirm sent from your server: a device session id from worthypayments.js
                    getDeviceSession() in the buyer's browser, so the payment carries the buyer's IP
                    and user agent instead of your server's. A browser confirm needs none. Unknown
                    or expired is a 400 with param device_session.
                wallet:
                  type: object
                  description: >-
                    Apple Pay / Google Pay credential, as the SDK's `mountWalletButton` emits it
                    (`kind`, `token`, `exp_month`, `exp_year`, `cryptogram`, `eci`, and the optional
                    funding card `card_last4` / `card_brand`, which are dropped rather than refused
                    when unusable).
                billing:
                  type: object
                  description: >-
                    Billing contact and address. `email` here also satisfies the confirm email
                    precondition. The SDK fills it from the Element's `defaultValues` and, on an
                    Apple Pay / Google Pay payment, from the contact the wallet sheet returned
                    (`defaultValues` win).
                  properties:
                    email:
                      type: string
                    name:
                      type: string
                    phone:
                      type: string
                      description: The buyer's phone, stored on the payment's customer step.
                    address:
                      type: object
                      description: >-
                        `line1`, `line2`, `city`, `state`, `postal_code`, `country` (ISO 3166-1
                        alpha-2). The address is what AVS checks.
                      additionalProperties:
                        type: string
            example:
              card:
                number: ev:AQEB...
                cvv: ev:AQEB...
                exp_month: '12'
                exp_year: '2029'
      responses:
        '200':
          description: >-
            The intent's new state — succeeded, a challenge, a decline, or `processing` (the outcome
            is not known yet; poll the retrieve endpoint). A `succeeded` intent carries `card_save`
            / `payment_method` when the buyer asked to save the card.
          content:
            application/json:
              schema:
                type: object
                description: >-
                  The card-save fields of a payment intent (the other fields are as in the
                  examples). Both are absent unless the intent is `succeeded`.
                properties:
                  card_save:
                    $ref: '#/components/schemas/PaymentIntentCardSave'
                  payment_method:
                    type: string
                    description: >-
                      `pm_…` id of the card this payment saved: equal to `card_save.payment_method`,
                      and present only when that is not null. A payment made with an already saved
                      card carries none.
              examples:
                succeeded:
                  summary: The money moved
                  value:
                    id: pi_111111112222433384445555
                    object: payment_intent
                    status: succeeded
                    latest_charge: txn_01J8Z9
                    charge:
                      id: txn_01J8Z9
                      object: charge
                      amount: 4999
                      currency: usd
                      status: succeeded
                      processor_transaction_id: TEST-982e43e5
                      auth_code: TESTOK
                      paid: true
                      captured: true
                      amount_captured: 4999
                      card:
                        brand: visa
                        last4: '4242'
                      billing_details:
                        name: Jane Doe
                        email: jane@example.com
                      created: 1767225600
                succeededCardSaved:
                  summary: Paid, and the card the buyer asked to save was saved
                  value:
                    id: pi_111111112222433384445555
                    object: payment_intent
                    status: succeeded
                    latest_charge: txn_01J8Z9
                    charge:
                      id: txn_01J8Z9
                      object: charge
                      amount: 4999
                      currency: usd
                      status: succeeded
                      paid: true
                      captured: true
                      card:
                        brand: visa
                        last4: '4242'
                    card_save:
                      status: saved
                      payment_method: pm_6b1d2c3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e
                      failure_code: null
                      failure_message: null
                    payment_method: pm_6b1d2c3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e
                succeededCardSavePending:
                  summary: >-
                    Paid; the save's outcome is not known yet (it can stay pending — read the
                    customer's payment methods)
                  value:
                    id: pi_111111112222433384445555
                    object: payment_intent
                    status: succeeded
                    latest_charge: txn_01J8Z9
                    card_save:
                      status: pending
                      payment_method: null
                      failure_code: null
                      failure_message: null
                succeededCardSaveSkipped:
                  summary: Paid; the card was not saved because the payment had no customer
                  value:
                    id: pi_111111112222433384445555
                    object: payment_intent
                    status: succeeded
                    latest_charge: txn_01J8Z9
                    card_save:
                      status: skipped
                      payment_method: null
                      failure_code: customer_missing
                      failure_message: null
                succeededCardSaveFailed:
                  summary: Paid; the gateway refused to store the card
                  value:
                    id: pi_111111112222433384445555
                    object: payment_intent
                    status: succeeded
                    latest_charge: txn_01J8Z9
                    card_save:
                      status: failed
                      payment_method: null
                      failure_code: VAULT_ADD_REFUSED
                      failure_message: Sandbox merchants are limited to 25 Customer Vault entries
                gatewayRefusal:
                  summary: The gateway refused the payment before it reached the card network
                  value:
                    id: pi_111111112222433384445555
                    object: payment_intent
                    status: requires_payment_method
                    latest_charge: null
                    last_payment_error:
                      type: card_error
                      code: card_declined
                      decline_code: duplicate_transaction
                      message: Duplicate transaction REFID:3154897712
                      network_status: not_sent_to_network
                issuerDecline:
                  summary: The issuer declined the card
                  value:
                    id: pi_111111112222433384445555
                    object: payment_intent
                    status: requires_payment_method
                    latest_charge: null
                    last_payment_error:
                      type: card_error
                      code: card_declined
                      decline_code: insufficient_funds
                      message: Insufficient funds
                      network_status: declined_by_network
        '400':
          description: >
            `parameter_unknown` (amount/currency sent) or `parameter_invalid` (zero or two
            instruments).


            Device session (only when one was supplied), with `param: device_session`:
            `parameter_invalid` (not a `ds_…` id), `resource_missing` (no such session for this
            account and mode) or `device_session_expired` (older than 30 minutes). Nothing was
            charged; send the confirm again with a fresh session or without one.


            `payment_method_unusable` — `This saved card can no longer be charged.` The owned card
            has no usable vault token. Nothing was charged; ask the buyer to choose another card.


            `failed_precondition` — `customer email is required`. The intent carries no buyer email
            and one is needed before the charge: without it there is no receipt destination and
            nothing to resolve a customer by. **Nothing was charged** and the intent is still
            confirmable, so supply an email and send the request again.


            Either end of the flow can supply it — `receipt_email` on `POST /v1/payment_intents`, or
            `billing.email` in this request's body. Sending neither is the only way to reach this
            error, and it is why `receipt_email` is optional on its own but not optional across the
            pair.
        '401':
          description: >-
            api_key_required / api_key_invalid. For saved cards, customer_session_invalid (missing
            or invalid grant, another merchant/environment, or a card not owned by the session
            customer) / customer_session_expired (create a new customer session).
        '403':
          description: >-
            `sandbox_key_required` (test mode only): this account has no NMI sandbox key of its own;
            add it in the dashboard (Settings > Integrations, NMI card, in test mode), or create an
            NMI sandbox account first at https://guide.nmi.com/. Nothing reached the gateway.
        '404':
          description: resource_missing — no such intent for this merchant.
  /v1/setup_intents:
    post:
      tags:
        - Payment Intents
      operationId: createSetupIntent
      summary: >-
        Create a setup intent — save and verify a card with no payment (server-only scope;
        Idempotency-Key required).
      description: >
        For a $0 trial sign-up, "add a payment method" in an account page, or a new card for a

        subscription: your server opens a setup intent for an existing customer and hands the

        browser its `client_secret`; the Payment Element mounted with that secret collects the

        card and confirms it. Nothing is charged.


        ```

        POST /v1/setup_intents        x-api-key: sk_..
          { "customer": "cus_...", "usage": "off_session" }
        → { "id": "seti_...", "client_secret": "seti_..._secret_...", "status":
        "requires_payment_method" }

        ```


        The confirm verifies the card with a $0 authorization (an NMI `validate`) and saves it

        to `customer` in the same gateway request, flagged as the card's initial

        stored-credential transaction (customer-initiated). Later merchant-initiated charges on

        the saved card (`recurring: true`, `initiated_by: "merchant"` on `POST /v1/charges`)

        refer to that verification. The card is saved exactly as a card saved with a payment

        is: you receive `payment_method.attached` (or `payment_method.updated` when the customer

        already had that card) and `setup_intent.succeeded`.


        `usage` is what you will do with the card: `off_session` (the default) when you will

        charge it without the buyer present, `on_session` when the buyer will pay with it again

        at your checkout. Both save the card the same way. `three_ds.request` (`automatic` |

        `any` | `challenge` | `challenge_mandated`) asks for 3-D Secure on the verification; the

        Element runs the challenge. Your other 3DS rules apply when the saved card is later

        charged.
      parameters:
        - in: header
          name: Idempotency-Key
          description: >-
            Required. A repeat of the same key replays the first response; the replay carries
            `X-Idempotency-Replayed: true`. Keys are stored for 24h. The `idempotency_key` body
            field is accepted instead of this header.
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - customer
              additionalProperties: false
              properties:
                customer:
                  type: string
                  description: >-
                    The customer the card is saved to: an existing customer of this key's merchant
                    and mode (`cus_<uuid>` or the bare uuid). An unknown one is a 400
                    `resource_missing` with `param: customer`.
                usage:
                  type: string
                  enum:
                    - off_session
                    - on_session
                  default: off_session
                  description: What you will do with the saved card. Both save it the same way.
                three_ds:
                  type: object
                  additionalProperties: false
                  properties:
                    request:
                      type: string
                      enum:
                        - automatic
                        - any
                        - challenge
                        - challenge_mandated
                      default: automatic
                      description: >-
                        Ask for 3-D Secure on the verification. `three_ds.session_id` belongs to
                        confirm.
                idempotency_key:
                  type: string
                  description: Accepted instead of the `Idempotency-Key` header.
            example:
              customer: cus_3f0c9d1e-7a2b-4c5d-8e9f-0a1b2c3d4e5f
              usage: off_session
      responses:
        '201':
          description: The setup intent, with the client secret for the browser.
          content:
            application/json:
              example:
                id: seti_111111112222433384445555aaaabbbb
                object: setup_intent
                status: requires_payment_method
                customer: cus_3f0c9d1e-7a2b-4c5d-8e9f-0a1b2c3d4e5f
                usage: off_session
                payment_method: null
                last_setup_error: null
                cancellation_reason: null
                three_ds:
                  request: automatic
                created: '2026-10-18T10:00:00Z'
                client_secret: seti_111111112222433384445555aaaabbbb_secret_tok_xxx
        '400':
          description: >-
            parameter_missing (`customer`), parameter_invalid (`usage`, `three_ds.request`, or
            `three_ds.session_id` sent on create) or parameter_unknown (any other field) are refused
            before the Idempotency-Key is used. resource_missing with `param: customer` when the
            customer is not one of this key's merchant and mode.
        '401':
          description: api_key_required / api_key_invalid.
        '403':
          description: >-
            permission_denied (a publishable key, or missing `customers:write`) or mode_not_allowed
            (`fraud_only`).
        '409':
          description: idempotency_in_progress — the same key is in flight.
    get:
      tags:
        - Payment Intents
      operationId: listSetupIntents
      summary: List setup intents, newest first (server-only scope).
      description: |
        Your server's view of the setup intents it created, in this key's merchant and mode,
        newest first, optionally for one customer. Cursor-paged like `GET /v1/customers`: pass
        `next_cursor` back as `starting_after` while `has_more` is true. No item carries a
        `client_secret`.

        ```
        GET /v1/setup_intents?customer=cus_...&limit=10        x-api-key: sk_..
        → { "object": "list", "data": [ { "id": "seti_...", "status": "canceled", ... } ],
            "has_more": true, "next_cursor": "..." }
        ```
      parameters:
        - in: query
          name: customer
          schema:
            type: string
          description: Only this customer's setup intents (`cus_<uuid>` or the bare uuid).
        - in: query
          name: limit
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 10
          description: Page size, 1-100 (default 10).
        - in: query
          name: starting_after
          schema:
            type: string
          description: The `next_cursor` of the previous page.
      responses:
        '200':
          description: One page of setup intents.
          content:
            application/json:
              example:
                object: list
                data:
                  - id: seti_111111112222433384445555aaaabbbb
                    object: setup_intent
                    status: canceled
                    customer: cus_3f0c9d1e-7a2b-4c5d-8e9f-0a1b2c3d4e5f
                    usage: off_session
                    payment_method: null
                    last_setup_error: null
                    cancellation_reason: abandoned
                    three_ds:
                      request: automatic
                    created: '2026-10-18T10:00:00Z'
                has_more: true
                next_cursor: eyJ2IjoiMjAyNi0xMC0xOFQxMDowMDowMC4wMDBaIiwiaWQiOiIxMTEifQ
        '400':
          description: 'parameter_invalid with `param: starting_after` — not a cursor this list issued.'
        '401':
          description: api_key_required / api_key_invalid.
        '403':
          description: permission_denied (a publishable key, or missing `customers:read`).
  /v1/setup_intents/{id}/cancel:
    post:
      tags:
        - Payment Intents
      operationId: cancelSetupIntent
      summary: Cancel a setup intent (server-only scope).
      description: |
        Ends an open setup intent: it takes no further confirm (a confirm of it is 400
        `setup_intent_unexpected_state`). Use it when the buyer abandons the form or you
        created a duplicate. Keyed on the `seti_` id, never the client secret.

        ```
        POST /v1/setup_intents/seti_.../cancel        x-api-key: sk_..
          { "cancellation_reason": "abandoned" }
        → { "id": "seti_...", "status": "canceled", "cancellation_reason": "abandoned", ... }
        ```

        - A canceled intent is answered as it stands; the first reason is kept.
        - A `succeeded` intent cannot be canceled (400 `setup_intent_unexpected_state`): its
          card is saved, so detach the card instead (`DELETE /v1/customers/{id}/payment_methods/{pmId}`).
        - While a confirm of the intent is running it is 409 `setup_intent_in_progress`:
          that attempt may be saving a card. Retrieve the intent, then cancel it if it is
          still open.
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
          description: The setup intent's `seti_` id. Anything else is a 404.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                cancellation_reason:
                  type: string
                  enum:
                    - abandoned
                    - duplicate
                    - requested_by_customer
                  description: Why it was canceled, answered back as `cancellation_reason`. Optional.
            example:
              cancellation_reason: abandoned
      responses:
        '200':
          description: The canceled setup intent.
          content:
            application/json:
              example:
                id: seti_111111112222433384445555aaaabbbb
                object: setup_intent
                status: canceled
                customer: cus_3f0c9d1e-7a2b-4c5d-8e9f-0a1b2c3d4e5f
                usage: off_session
                payment_method: null
                last_setup_error: null
                cancellation_reason: abandoned
                three_ds:
                  request: automatic
                created: '2026-10-18T10:00:00Z'
        '400':
          description: >-
            parameter_invalid (`cancellation_reason`), parameter_unknown (any other field), or
            setup_intent_unexpected_state (the intent succeeded: its card is saved).
        '401':
          description: api_key_required / api_key_invalid.
        '403':
          description: permission_denied (a publishable key, or missing `customers:write`).
        '404':
          description: resource_missing — no such setup intent for this merchant and mode.
        '409':
          description: setup_intent_in_progress — a confirm of this intent is running.
  /v1/setup_intents/{client_secret}:
    get:
      tags:
        - Payment Intents
      operationId: retrieveSetupIntent
      summary: Retrieve a setup intent by its client secret (browser-safe).
      description: >-
        Scope `none`, so the Element (or your page) can read it with a publishable key. Keyed on the
        client secret, never the `seti_` id, and tenancy-scoped: another merchant's or the other
        mode's intent is a 404. The answer omits `client_secret`. A `succeeded` intent names the
        saved card in `payment_method`; read its brand, last four and expiry with `GET
        /v1/customers/{id}/payment_methods`. An intent whose last attempt failed is still
        `requires_payment_method` and carries `last_setup_error`.
      parameters:
        - in: path
          name: client_secret
          required: true
          schema:
            type: string
          description: The `client_secret` from create.
      responses:
        '200':
          description: The setup intent, without the client secret.
          content:
            application/json:
              example:
                id: seti_111111112222433384445555aaaabbbb
                object: setup_intent
                status: succeeded
                customer: cus_3f0c9d1e-7a2b-4c5d-8e9f-0a1b2c3d4e5f
                usage: off_session
                payment_method: pm_6b1d2c3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e
                last_setup_error: null
                cancellation_reason: null
                three_ds:
                  request: automatic
                created: '2026-10-18T10:00:00Z'
        '401':
          description: api_key_required / api_key_invalid.
        '404':
          description: resource_missing — no such setup intent for this merchant and mode.
  /v1/setup_intents/{client_secret}/confirm:
    post:
      tags:
        - Payment Intents
      operationId: confirmSetupIntent
      summary: Confirm a setup intent with the buyer's card (browser-safe; the Payment Element calls this).
      description: |
        The Payment Element mounted with the setup intent's `client_secret` calls this for you;
        call it yourself only with your own Evervault-encrypted fields. The card must be
        Evervault-encrypted (`ev:`): a raw number or a `wp:` envelope is refused. The confirm
        runs one $0 verification that saves the card to the intent's customer.

        - **Saved:** 200 `succeeded` with `payment_method` (`pm_…`). Confirming a succeeded
          intent again answers the same card and verifies nothing.
        - **Declined or not verifiable:** 200 `requires_payment_method` with
          `last_setup_error` — `code` `card_declined` (with the issuer's `decline_code`) or
          `processing_error` (the card could not be read or verified right now). The buyer can
          try again on the same intent; after five failed attempts it accepts no more (400
          `setup_intent_attempts_exceeded`) and you create a new one. You receive
          `setup_intent.setup_failed`.
        - **Refused by fraud screening:** before the card is verified, the attempt meets the
          same fraud screening as a payment: your blocked customers and cards, card-testing
          detection, and your velocity rules (per IP, email, card). A refusal is a declined
          attempt, as a payment's fraud block is: 200 `requires_payment_method` with
          `last_setup_error` `card_declined` / `decline_code` `fraudulent`, counted toward the
          five, and nothing is sent to the card network. Which rule fired is never answered.
        - **Canceled:** a canceled intent takes no card (400 `setup_intent_unexpected_state`).
        - **3-D Secure:** when the intent was created with a `three_ds.request` other than
          `automatic`, the first confirm answers 200 `requires_action` with
          `next_action.three_ds.session_id`; the Element runs the challenge and confirms again
          with the same card and `three_ds.session_id`. A session that did not authenticate is
          402 `card_error` / `card_declined` with `decline_code` `three_ds_failed` (or
          `three_ds_card_not_enrolled`), and nothing is verified.
      parameters:
        - in: path
          name: client_secret
          required: true
          schema:
            type: string
          description: The `client_secret` from create.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - card
              additionalProperties: false
              properties:
                card:
                  type: object
                  required:
                    - number
                    - exp_month
                    - exp_year
                  properties:
                    number:
                      type: string
                      description: Evervault-encrypted card number (`ev:…`).
                    cvv:
                      type: string
                      description: >-
                        Evervault-encrypted CVC (`ev:…`); verified by the issuer when sent, never
                        stored.
                    exp_month:
                      type: string
                      description: 1-12.
                    exp_year:
                      type: string
                      description: Two or four digits.
                    name:
                      type: string
                billing:
                  type: object
                  description: The billing address is sent with the verification for AVS; it is not stored.
                  properties:
                    address:
                      type: object
                      properties:
                        line1:
                          type: string
                        line2:
                          type: string
                        city:
                          type: string
                        state:
                          type: string
                        postal_code:
                          type: string
                        country:
                          type: string
                three_ds:
                  type: object
                  additionalProperties: false
                  properties:
                    session_id:
                      type: string
                      description: The 3-D Secure session the buyer just finished (the resubmit only).
                device:
                  type: object
                  description: Accepted and ignored (the SDK's card form sends it).
                device_session:
                  type: string
                  pattern: ^ds_[A-Za-z0-9]{1,61}$
                  description: >-
                    For a confirm sent from your server: a device session id from worthypayments.js
                    getDeviceSession(), so fraud screening sees the buyer's IP. With a secret or
                    restricted key and no session, no IP is screened (your server's address is never
                    counted as a buyer's). Unknown or expired is a 400 with param device_session.
            example:
              card:
                number: ev:Tk9D:number:QUJDREVGR0hJSktMTU5PUA==:$
                cvv: ev:Tk9D:cvc:MTIz:$
                exp_month: '12'
                exp_year: '2034'
      responses:
        '200':
          description: The setup intent after the attempt.
          content:
            application/json:
              examples:
                saved:
                  summary: Verified and saved
                  value:
                    id: seti_111111112222433384445555aaaabbbb
                    object: setup_intent
                    status: succeeded
                    customer: cus_3f0c9d1e-7a2b-4c5d-8e9f-0a1b2c3d4e5f
                    usage: off_session
                    payment_method: pm_6b1d2c3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e
                    last_setup_error: null
                    cancellation_reason: null
                    three_ds:
                      request: automatic
                    created: '2026-10-18T10:00:00Z'
                declined:
                  summary: Declined; the buyer can try another card
                  value:
                    id: seti_111111112222433384445555aaaabbbb
                    object: setup_intent
                    status: requires_payment_method
                    customer: cus_3f0c9d1e-7a2b-4c5d-8e9f-0a1b2c3d4e5f
                    usage: off_session
                    payment_method: null
                    last_setup_error:
                      code: card_declined
                      decline_code: insufficient_funds
                      message: Your card was declined.
                    cancellation_reason: null
                    three_ds:
                      request: automatic
                    created: '2026-10-18T10:00:00Z'
                requiresAction:
                  summary: 3-D Secure to run first
                  value:
                    id: seti_111111112222433384445555aaaabbbb
                    object: setup_intent
                    status: requires_action
                    customer: cus_3f0c9d1e-7a2b-4c5d-8e9f-0a1b2c3d4e5f
                    usage: off_session
                    payment_method: null
                    last_setup_error: null
                    cancellation_reason: null
                    three_ds:
                      request: challenge
                    created: '2026-10-18T10:00:00Z'
                    next_action:
                      type: three_ds_challenge
                      three_ds:
                        session_id: tds_1f2e3d4c
        '400':
          description: >-
            parameter_missing (`card`), parameter_invalid (`card.exp_month`, `card.exp_year`,
            `three_ds.request` on confirm), pan_encryption_required (`card.number` / `card.cvv` not
            `ev:`), parameter_unknown (any other field), setup_intent_attempts_exceeded (five failed
            attempts: create a new setup intent), setup_intent_unexpected_state (the intent is
            canceled), or, with `param: device_session`, parameter_invalid / resource_missing /
            device_session_expired (nothing was verified).
        '401':
          description: api_key_required / api_key_invalid.
        '402':
          description: >-
            card_error / card_declined with decline_code three_ds_failed or
            three_ds_card_not_enrolled — the 3-D Secure session did not authenticate. Nothing was
            verified.
        '403':
          description: >-
            `sandbox_key_required` (test mode only): this account has no NMI sandbox key of its own;
            add it in the dashboard (Settings > Integrations, NMI card, in test mode), or create an
            NMI sandbox account first at https://guide.nmi.com/. Nothing was verified or saved, the
            attempt is not counted, and the intent stays confirmable.
        '404':
          description: resource_missing — no such setup intent for this merchant and mode.
        '409':
          description: >-
            setup_intent_in_progress — another confirm of this intent is running (retrieve it to
            learn the outcome), or three_ds_session_incomplete — the challenge is still open.
  /v1/customer_sessions:
    post:
      tags:
        - Customer Sessions
      operationId: createCustomerSession
      summary: Mint a scoped grant to read one customer's saved cards (server-only).
      description: |
        Your server names the customer; the browser gets a token that can read that
        customer's payment methods and nothing else. Pass it to the Element as
        `customerSessionSecret`.

        ```
        POST /v1/customer_sessions      Authorization: Bearer sk_..
          { "customer": "cus_..." }
        → { "object": "customer_session", "customer": "cus_...",
            "client_secret": "...", "expires_at": 1760000000 }
        ```

        Expires in 30 minutes. It grants **read only**, on **one** customer: it cannot
        charge, cannot modify anything, and cannot name a different customer. That is why
        `customers:read` itself stays closed to publishable keys — that scope lists every
        customer you have.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - customer
              properties:
                customer:
                  type: string
                  description: >-
                    The one customer this grant may read: `cus_<uuid>` or the bare uuid. Missing or
                    blank is a 400.
            example:
              customer: cus_ae7babf2-8c52-43fe-b2ae-c3395b17add8
      responses:
        '201':
          description: Customer session.
        '400':
          description: parameter_invalid (customer missing) / customer_sessions_unavailable (not configured).
        '403':
          description: permission_denied — a publishable key, or missing `customer_sessions:create`.
  /v1/customer_sessions/payment_methods:
    get:
      tags:
        - Customer Sessions
      operationId: listSessionPaymentMethods
      summary: List the session customer's saved cards (browser-safe).
      description: >-
        Scope `none`: the grant is the `X-Customer-Session` header, not the API key.


        Two independent checks must pass. The signature proves the token is one we minted; a tenancy
        check then proves it belongs to the calling key — without that second step a merchant could
        replay another merchant's valid token and read their customer's cards.


        Returns brand, last4 and expiry only. Vault ids, `ev:`/`wp:` tokens and fingerprints are
        deliberately absent: the browser selects a card by OUR id, and resolving it to an instrument
        happens server-side on the charge.


        `id` is the bare payment-method id, without the `pm_` prefix that `GET
        /v1/customers/{id}/payment_methods` and a payment intent's `card_save.payment_method` carry.
        Pass it as confirm's `payment_method`; confirm takes the `pm_…` form of the same card too.
      parameters:
        - in: header
          name: X-Customer-Session
          required: true
          schema:
            type: string
          description: The `client_secret` returned by `POST /v1/customer_sessions`.
      responses:
        '200':
          description: A list of payment methods.
          content:
            application/json:
              example:
                object: list
                data:
                  - id: 6b1d2c3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e
                    object: payment_method
                    type: card
                    card:
                      brand: visa
                      last4: '4242'
                      exp_month: 12
                      exp_year: 2029
        '401':
          description: customer_session_invalid (bad, forged, or another tenant's) / customer_session_expired.
  /v1/refunds:
    post:
      tags:
        - Refunds
      operationId: createRefund
      summary: Create a refund (server-only scope; Idempotency-Key required).
      description: |
        `charge` is required. Omit `amount` for a full refund of what remains, or pass cents for
        a partial.
        - **One charge, one card.** Each request refunds exactly one charge, back to the card
          that charge was paid with. There is no bulk refund and no refund by customer.
        - **Partial refunds.** You may refund one charge several times until its captured amount
          is used up.
        - **Gateway-synced charges.** A charge synced from your gateway is refundable like one
          made through the API.
        - **Rate limit.** No per-minute refund budget; only the general per-key request ceiling
          applies. On a `429 rate_limit_exceeded`, back off by `Retry-After`.
        - **Refunding past spending instead of a payout.** See the **Refund a customer's past
          charges** guide.

        **Refused before any money moves** (`400`; the message names the reason):
        - `ALREADY_FULLY_REFUNDED` (`failed_precondition`): nothing is left to refund.
        - `REFUND_EXCEEDS_REMAINING` (`invalid_argument`): `amount` is more than what remains.
          Platform refunds and refunds made in your gateway's own portal both count.
        - `TRANSACTION_DISPUTED` (`failed_precondition`): a chargeback or a matched pre-dispute
          alert is open on the charge. The cardholder may already be getting the money back, so
          a refund on top would pay twice.
        - `REFUND_WINDOW_EXPIRED` (`failed_precondition`): the charge is more than 180 days old.
          For a gateway-synced charge, that means 180 days from the gateway's own date.
        - `INVALID_STATE_FOR_REFUND` (`failed_precondition`): the charge never captured any
          money.
        - `REFUND_CURRENCY_MISMATCH` (`invalid_argument`): `currency` is not the charge's.

        Proxies to `PaymentsOrchestrationService/Refund`.
      parameters:
        - in: header
          name: Idempotency-Key
          description: >-
            Required, as a header. A repeat of the same key replays the first response instead of
            refunding twice; the replay carries `X-Idempotency-Replayed: true`. Keys are stored for
            24h. Derive it from what you are refunding (e.g. `payout-1042:TXN-…`), not from the
            moment you send it.
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - charge
              properties:
                charge:
                  type: string
                  description: The charge to refund (its `id`). Missing is a 400.
                amount:
                  type: integer
                  description: Minor units (cents), a whole number ≥ 1. Omit for a full refund of what remains.
                currency:
                  type: string
                  description: >-
                    ISO 4217, read only with `amount`. When omitted it defaults to `usd`, so a
                    partial refund of a non-USD charge must pass the charge's own currency. A
                    different currency is refused (`REFUND_CURRENCY_MISMATCH`).
                reason:
                  type: string
                  enum:
                    - requested_by_customer
                    - duplicate
                    - fraudulent
                  description: Optional. Any other value is a `400 parameter_invalid`.
            example:
              charge: tx_3PkC9d2eZvKY
              amount: 500
              currency: usd
              reason: requested_by_customer
      responses:
        '201':
          description: Refund object
          content:
            application/json:
              example:
                id: re_tx_3PkC9d2eZvKY
                object: refund
                amount: 500
                currency: usd
                charge: tx_3PkC9d2eZvKY
                status: succeeded
                reason: requested_by_customer
        '400':
          description: >-
            `idempotency_key_required` (no `Idempotency-Key` header; a body field does not count);
            `parameter_invalid` (no `charge`, an `amount` that is not a whole number ≥ 1, an unknown
            `reason`); or a refusal named in the message: `ALREADY_FULLY_REFUNDED`,
            `REFUND_EXCEEDS_REMAINING`, `TRANSACTION_DISPUTED`, `REFUND_WINDOW_EXPIRED`,
            `INVALID_STATE_FOR_REFUND`, `REFUND_CURRENCY_MISMATCH` (see the description); or
            `refund_declined` (+ `decline_code`): the gateway refused the refund, no money moved,
            and the same Idempotency-Key replays this answer.
        '403':
          description: >-
            `sandbox_key_required` (test mode only): this account has no NMI sandbox key of its own;
            add it in the dashboard (Settings > Integrations, NMI card, in test mode). No money
            moved; retry the same request once the key is saved.
        '404':
          description: '`resource_missing` — no such charge for this key (`TRANSACTION_NOT_FOUND`).'
        '409':
          description: idempotency_in_progress.
        '429':
          description: '`rate_limit_exceeded` — the general per-key request ceiling; back off by `Retry-After`.'
    get:
      tags:
        - Refunds
      operationId: listRefunds
      summary: List refunds (type=REFUND; optional parent charge filter).
      description: |
        Lists refunds for the key's merchant and env-mode, paged with `limit` and
        `starting_after`.

        ★ The scope is `payments:read`, not a refunds-specific one. A key granted only
        `refunds:create` can issue refunds and will still be refused here — worth checking
        first if this returns 403 on a key that demonstrably refunds.
      parameters:
        - in: query
          name: charge
          schema:
            type: string
          description: >-
            Parent charge id filter. It lists the refunds made through the API. A refund made in
            your gateway's own portal points at the gateway's id and is not listed under the
            charge's `id`. Read the charge's `amount_refunded` for the full total.
        - in: query
          name: limit
          description: Page size. Defaults to 10 and is capped at 100.
          schema:
            type: integer
        - in: query
          name: starting_after
          description: >-
            Cursor for the next page: the id of the last row you received. Omit it on the first
            request, and keep paging while `has_more` is true.
          schema:
            type: string
      responses:
        '200':
          description: List of refunds
          content:
            application/json:
              example:
                object: list
                data:
                  - id: re_tx_3PkC9d2eZvKY
                    object: refund
                    amount: 500
                    currency: usd
                    charge: tx_3PkC9d2eZvKY
                    status: succeeded
                has_more: false
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `payments:read` scope (`permission_denied`).
  /v1/refunds/{id}:
    get:
      tags:
        - Refunds
      operationId: retrieveRefund
      summary: Get refund by id (type=REFUND under tenancy).
      description: |
        Fetches one refund, scoped to the key's merchant and env-mode. Takes the same
        `payments:read` scope as the list, not `refunds:create`.
      parameters:
        - in: path
          name: id
          description: The refund id.
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Refund
          content:
            application/json:
              example:
                id: re_tx_3PkC9d2eZvKY
                object: refund
                amount: 500
                currency: usd
                charge: tx_3PkC9d2eZvKY
                status: succeeded
        '404':
          description: resource_missing.
  /v1/pre_dispute_alerts:
    get:
      tags:
        - PreDisputeAlerts
      operationId: listPreDisputeAlerts
      summary: List pre-dispute alerts changed since a timestamp (polling).
      description: |
        The pull side of the `pre_dispute_alert.*` webhooks. Use it to catch up after a missed
        delivery, or instead of running an endpoint at all.

        The object here is a **superset** of the webhook payload: it carries the same lifecycle
        fields (`state`, `amount`, `external_id`, `reason_code`) and adds the match evidence,
        the sub-merchant MID, the processor and the linked sale's gateway id. Reconcile the two
        on `id` — but do not assume the field sets are identical.

        **How to poll.** Keep the largest `updated_at` you have seen and pass it back as
        `updated_since`. Page with `starting_after` until `has_more` is false, then sleep.

        Ordering is by `updated_at` ascending, so an alert updated while you are paging moves
        to the END of the sequence rather than shifting the rows ahead of it. You may therefore
        see the same alert twice — dedupe on `(id, updated_at)`, exactly as for the webhooks —
        but you cannot miss one.

        An `updated_since` that is neither unix seconds nor RFC3339 is rejected with 400 rather
        than ignored: treating a typo as "from the beginning" would re-deliver your whole alert
        history on a routine poll.

        **`auto_rule`** tells you whether the platform resolved the alert by itself and why. On
        an `AUTO_REFUNDED` alert it names the rule that moved the money (`rule_id`,
        `rule_label`, `evaluated_at`). When nothing fired it carries `decision: NONE` and a
        `reason` — including the refusals no rule can override, such as `sale-already-refunded`
        or `operator-excluded`. It is `null` only on alerts the engine never evaluated.

        **`match_evidence.card_bin` / `card_last4` are the card the issuer reported**, and they
        may differ from the card you hold for the linked sale without the link being wrong.
        Apple Pay and Google Pay charge a device token rather than the card, and virtual card
        numbers and reissued cards do the same. Such an alert is linked on the network's own
        identifiers for the payment, `arn` and `auth_code`, with the amount, the date and the
        card network. Reconcile on `transaction_id`, `arn` or `auth_code`, never on the card
        digits.

        Requires the `disputes:read` scope. New keys get it by default; an older key may
        predate that — add it in Dashboard → Developers if a call answers 403.
      parameters:
        - in: query
          name: updated_since
          schema:
            type: string
          description: Unix seconds or RFC3339. Exclusive lower bound on `updated_at`.
        - in: query
          name: state
          schema:
            type: string
          description: NEW | AUTO_REFUNDED | REFUNDED | DISMISSED | ESCALATED.
        - in: query
          name: source
          schema:
            type: string
          description: ETHOCA | CDRN | RDR.
        - in: query
          name: limit
          schema:
            type: integer
          description: 1–100, default 25.
        - in: query
          name: starting_after
          schema:
            type: string
          description: Opaque cursor — echo back `next_cursor`.
      responses:
        '200':
          description: List of pre-dispute alerts
          content:
            application/json:
              example:
                object: list
                data:
                  - id: al_7Kd9m2QpT4
                    object: pre_dispute_alert
                    external_id: ETH-9821
                    source: ETHOCA
                    state: AUTO_REFUNDED
                    amount: 12500
                    currency: usd
                    reason: fraud
                    priority: HIGH
                    transaction_id: tx_3PkC9d2eZvKY
                    nmi_transaction_id: '9182736450'
                    customer_id: 4023ec41-ca5f-4153-859f-c30c960a599c
                    processor: payarc
                    submerchant_kind: KOUNT_MERCHANT
                    submerchant_id: '554630119933755'
                    network_status: SENT
                    sla_status: OK
                    resolution_note: ''
                    match_evidence:
                      arn: '74126789012345678901234'
                      auth_code: '041273'
                      card_bin: '414720'
                      card_last4: '4242'
                      order_id: ord_8812
                      transaction_date: '2026-08-15T09:12:44Z'
                      rung: arn
                      strength: strong
                      outcome: MATCHED
                    auto_rule:
                      decision: AUTO_REFUND
                      rule_id: rule_9
                      rule_label: ethoca under $200
                      reason: ''
                      evaluated_at: 1786964940
                    received_at: 1786953600
                    sla_due_at: 1787040000
                    resolved_at: 1786957200
                    updated_at: 1786965000
                has_more: true
                next_cursor: eyJ2YWx1ZSI6IjIwMjYtMDgtMTdUMTE6MzA6MDAuMDAwWiIsImlkIjoiYWxfN0tkOW0yUXBUNCJ9
        '400':
          description: parameter_invalid — updated_since was not a timestamp.
  /v1/pre_dispute_alerts/{id}:
    get:
      tags:
        - PreDisputeAlerts
      operationId: retrievePreDisputeAlert
      summary: Get one pre-dispute alert by id or external id.
      description: |
        `id` accepts either this API's alert id or the provider's own `external_id`
        (ETH-9821 / CDRN-4408 / RDR-2841) — whichever you kept. Both are unique per merchant.

        An alert owned by another merchant answers 404, the same as one that does not exist.
      parameters:
        - in: path
          name: id
          description: The pre-dispute alert id.
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Pre-dispute alert
          content:
            application/json:
              example:
                id: al_7Kd9m2QpT4
                object: pre_dispute_alert
                external_id: ETH-9821
                source: ETHOCA
                state: AUTO_REFUNDED
                amount: 12500
                currency: usd
                reason: fraud
                priority: HIGH
                transaction_id: tx_3PkC9d2eZvKY
                received_at: 1786953600
                updated_at: 1786965000
        '404':
          description: resource_missing.
  /v1/disputes/stats:
    get:
      tags:
        - Disputes
      operationId: retrieveDisputeStats
      summary: Aggregate dispute counters and recovery amounts.
      description: |
        Roll-up of the merchant's disputes: open/won/lost counts, amount at risk, amount
        recovered, and win/recovery rates. The dashboard companion to `GET /v1/disputes`.
        Counts every dispute case received in the window — chargebacks, inquiries and retrieval
        requests, and later rounds such as pre-arbitration — so `needs_response`, `won` and `lost`
        include every case you answer, each at its current status. Pre-dispute alerts and
        PayArc RDR resolutions, reversals and credits are not dispute cases and are not counted.
        Optional `start_date` / `end_date` (YYYY-MM-DD) window and `processor` / `country`
        filters mirror the list. Requires `disputes:read`. Money is in minor units; rates are
        whole percents (`63` means 63%). `win_rate` is null while no case in the window is
        won or lost.
      parameters:
        - in: query
          name: start_date
          schema:
            type: string
          description: YYYY-MM-DD inclusive lower bound.
        - in: query
          name: end_date
          schema:
            type: string
          description: YYYY-MM-DD inclusive upper bound (end of day).
        - in: query
          name: processor
          description: Count only disputes from this processor. Omit for every processor.
          schema:
            type: string
        - in: query
          name: country
          description: Count only disputes from this issuing country. Omit for every country.
          schema:
            type: string
      responses:
        '200':
          description: dispute_stats
          content:
            application/json:
              example:
                object: dispute_stats
                total: 42
                needs_response: 5
                won: 20
                lost: 12
                total_amount: 189900
                recovered_amount: 84000
                win_rate: 63
                recovery_rate: 44
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `disputes:read` scope (`permission_denied`).
  /v1/disputes:
    get:
      tags:
        - Disputes
      operationId: listDisputes
      summary: List disputes changed since a timestamp (polling).
      description: |
        The pull side of the `dispute.*` webhooks. Same cursor as `GET /v1/pre_dispute_alerts`:
        keep the largest `updated_at` you have seen and pass it back as `updated_since`. Page
        with `starting_after` until `has_more` is false.

        Requires `disputes:read`, granted by default on new keys (add it in Dashboard →
        Developers if an older key answers 403).
      parameters:
        - in: query
          name: updated_since
          schema:
            type: string
          description: Unix seconds or RFC3339. Exclusive lower bound on `updated_at`.
        - in: query
          name: status
          description: |
            Filter by lifecycle state. An unrecognised value is ignored (the filter is
            dropped and the full list is returned) rather than rejected — so check your
            spelling if a filtered poll returns more than you expected.

            `BUYER_REVIEW`, `APPEALABLE` and `BANK_REVIEW` are deprecated and never emitted;
            they are kept in the enum for wire compatibility only (WRT-152). Filtering on one
            returns an empty page. A dispute that is with the issuing bank is `UNDER_REVIEW`.

            `RELATED_CASE` is a PayArc related case (a reversal, resolution record,
            pre-arbitration, credit or retrieval fulfilment) filed under the dispute that
            carries its family's state; its `parent_dispute_id` names that dispute.
          schema:
            type: string
            enum:
              - NEEDS_RESPONSE
              - NEEDS_MANUAL_MATCH
              - UNDER_REVIEW
              - WON
              - LOST
              - ACCEPTED
              - EXPIRED
              - PROCESSING
              - BUYER_REVIEW
              - BANK_REVIEW
              - APPEALABLE
              - STALE
              - CLOSED
              - RELATED_CASE
        - in: query
          name: stage
          description: |
            Filter by escalation stage. An unrecognised value is ignored (the filter is
            dropped and the full list is returned) rather than rejected.
          schema:
            type: string
            enum:
              - INQUIRY
              - CHARGEBACK
              - PRE_ARBITRATION
              - ARBITRATION
              - UNCLASSIFIED
        - in: query
          name: limit
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
          description: 1–100, default 25.
        - in: query
          name: starting_after
          schema:
            type: string
          description: Opaque cursor — echo back `next_cursor`.
      responses:
        '200':
          description: List of disputes
          content:
            application/json:
              schema:
                type: object
                properties:
                  object:
                    type: string
                    enum:
                      - list
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Dispute'
                  has_more:
                    type: boolean
                    description: True when another page exists — fetch it with `starting_after`.
                  next_cursor:
                    type: string
                    nullable: true
                    description: Pass as `starting_after`. Absent once you are caught up.
              example:
                object: list
                data:
                  - id: 25758130-bb0d-46bd-b65d-b22bd8bf7ccb
                    object: dispute
                    transaction_id: 9f1c6a2e-77d1-4f0a-9a3e-2b6c4d8e1a55
                    nmi_transaction_id: '12437218480'
                    billing_name: Ada Lovelace
                    billing_email: ada@example.com
                    reason_message: Cardholder does not recognise the charge
                    card_bin: '424242'
                    alert_source: ETHOCA
                    cardholder_name: A. LOVELACE
                    transaction_date: 1786966800
                    respond_by: 1787656800
                    closed_at: null
                    status: NEEDS_RESPONSE
                    parent_dispute_id: ''
                    stage: INQUIRY
                    outcome: ''
                    reason: FRAUDULENT
                    reason_code: '10.4'
                    amount: 13550
                    currency: usd
                    source_acquirer: Payarc
                    processor_dispute_id: cb_9f2210
                    kount_alert_id: f34a64b7-b8ac-4d4c-9245-efb5437d7261
                    card_brand: visa
                    card_last4: '4242'
                    dispute_date: 1787052000
                    due_date: 1787656800
                    created_at: 1787052300
                    updated_at: 1787066400
                has_more: false
                next_cursor: null
        '400':
          description: parameter_invalid — updated_since was not a timestamp.
        '401':
          description: api_key_required / api_key_invalid.
        '403':
          description: permission_denied — the key is missing the `disputes:read` scope.
  /v1/disputes/{id}:
    get:
      tags:
        - Disputes
      operationId: retrieveDispute
      summary: Get one dispute by id, processor id, or Kount request id.
      description: |
        `id` accepts this API's dispute id, the processor's dispute id, or
        `kount_alert_id` (the Ethoca/CDRN request id).
      parameters:
        - in: path
          name: id
          description: The dispute id.
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Dispute
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Dispute'
        '401':
          description: api_key_required / api_key_invalid.
        '403':
          description: permission_denied — the key is missing the `disputes:read` scope.
        '404':
          description: |
            resource_missing. Also returned for a dispute that belongs to another merchant —
            the two are deliberately indistinguishable, so an id cannot be confirmed by a
            caller who may not read it.
  /v1/customers:
    post:
      tags:
        - Customers
      operationId: createCustomer
      summary: Create a customer.
      description: |
        `name` is split into first/last for the domain. Proxies to
        `CustomerService/CreateCustomer`. Customers are unique per email (per merchant and
        env-mode): an email that already has a customer is a `409` `already_exists`, and
        `GET /v1/customers?email=` finds it. A retry with the same body `idempotency_key`
        and the same email returns the customer the first call created.

        Only `email`, `name`, `phone` and `idempotency_key` are read; any other field
        (`external_id`, `metadata`, `address`) is ignored, not refused.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - email
              properties:
                email:
                  type: string
                  description: >-
                    Required. The customer record is keyed on it; sending none is a 400
                    `invalid_argument`.
                name:
                  type: string
                phone:
                  type: string
                idempotency_key:
                  type: string
                  description: >-
                    Read from the body only: this route ignores an `Idempotency-Key` header. Without
                    one, every call is a new create.
            example:
              email: buyer@example.com
              name: Ada Lovelace
              phone: '+15551230000'
              idempotency_key: 8f14e45f-ceea-467d-9c9b-2b3f0a1c9d21
      responses:
        '201':
          description: Customer
          content:
            application/json:
              example:
                id: cus_1QwXyZ
                object: customer
                email: buyer@example.com
                name: Ada Lovelace
                phone: '+15551230000'
                metadata: {}
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `customers:write` scope (`permission_denied`).
        '409':
          description: >-
            The email already has a customer (`already_exists`). Find it with `GET
            /v1/customers?email=`.
          content:
            application/json:
              example:
                error:
                  type: invalid_request_error
                  code: already_exists
                  message: customer already exists for email buyer@example.com
    get:
      tags:
        - Customers
      operationId: listCustomers
      summary: List customers (keyset pagination).
      description: |
        Lists customer records for the key's merchant and env-mode, paged with `limit` and
        `starting_after`. Requires `customers:read`, which stays closed to publishable keys
        because it exposes the merchant's whole book. To let a browser show one returning
        buyer their saved cards, mint a customer session instead and use the scoped grant.
      parameters:
        - in: query
          name: limit
          description: Page size. Defaults to 10 and is capped at 100.
          schema:
            type: integer
            default: 10
            maximum: 100
        - in: query
          name: starting_after
          description: >-
            Cursor for the next page: the id of the last row you received. Omit it on the first
            request, and keep paging while `has_more` is true.
          schema:
            type: string
        - in: query
          name: created
          style: deepObject
          explode: true
          description: |
            Only customers created in this window, in Unix **seconds**, sent as bracketed keys:
            `created[gte]=1733600000&created[lt]=1733686400`. `created[gte]` and
            `created[lte]` are inclusive, `created[gt]` and `created[lt]` exclusive, and a bare
            `created=1733600000` matches that one second. A bound in milliseconds, or not a
            whole number of seconds, is a 400 `parameter_invalid_integer` naming it; a bound
            sent twice, or `created=` together with `created[...]`, is a 400
            `parameter_invalid`.
          schema:
            type: object
            properties:
              gte:
                type: integer
                description: Inclusive lower bound, Unix seconds.
              gt:
                type: integer
                description: Exclusive lower bound, Unix seconds.
              lte:
                type: integer
                description: Inclusive upper bound, Unix seconds.
              lt:
                type: integer
                description: Exclusive upper bound, Unix seconds.
      responses:
        '200':
          description: List
          content:
            application/json:
              example:
                object: list
                data:
                  - id: cus_1QwXyZ
                    object: customer
                    email: buyer@example.com
                    name: Ada Lovelace
                    phone: null
                    metadata: {}
                has_more: false
        '400':
          description: >-
            parameter_invalid_integer — a `created` bound that is not Unix seconds.
            parameter_invalid — a `created` bound sent twice, or `created=` together with
            `created[...]`.
          content:
            application/json:
              example:
                error:
                  type: invalid_request_error
                  code: parameter_invalid_integer
                  param: created[gte]
                  message: >-
                    created[gte] looks like milliseconds. Use Unix SECONDS, e.g.
                    Math.floor(Date.now() / 1000).
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `customers:read` scope (`permission_denied`).
  /v1/customers/{id}:
    get:
      tags:
        - Customers
      operationId: retrieveCustomer
      summary: Retrieve a customer.
      description: Proxies to `CustomerService/GetCustomer` under `(merchantAccountId, envMode)` from the key.
      parameters:
        - in: path
          name: id
          description: The customer id.
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Customer
          content:
            application/json:
              example:
                id: cus_1QwXyZ
                object: customer
                email: buyer@example.com
                name: Ada Lovelace
                phone: '+15551230000'
                metadata: {}
        '404':
          description: resource_missing — no such customer under this tenancy.
    post:
      tags:
        - Customers
      operationId: updateCustomer
      summary: Update a customer (POST-to-update, by convention).
      description: |
        Only explicit fields are forwarded (never a raw body spread — blocks a
        `merchantAccountId` spoof). Send `name` (auto-split) or `first_name`/`last_name`.
        Proxies to `CustomerService/UpdateCustomer`.

        **How `name` is split.** The last whitespace-separated token becomes
        `last_name`; everything before it becomes `first_name`. So `Mehmet Ali Yılmaz`
        is stored as `Mehmet Ali` / `Yılmaz`, and a single-token name is stored as
        `first_name` with an empty `last_name`. It is a heuristic, not a parser: a
        double surname such as `José García López` keeps only `López`. Send
        `first_name` and `last_name` yourself when you know the split — doing so
        skips the heuristic entirely.
      parameters:
        - in: path
          name: id
          description: The customer to update.
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: []
              description: Partial update. Only the fields sent are written; none are required.
              properties:
                email:
                  type: string
                name:
                  type: string
                  description: >-
                    Full name. Split on the LAST space — `Mehmet Ali Yılmaz` becomes `Mehmet Ali` /
                    `Yılmaz`. Send `first_name` / `last_name` instead when you know the split.
                phone:
                  type: string
                first_name:
                  type: string
                last_name:
                  type: string
            example:
              email: ada2@example.com
              name: Ada Byron
              phone: '+15551239999'
      responses:
        '201':
          description: Updated customer
          content:
            application/json:
              example:
                id: cus_1QwXyZ
                object: customer
                email: ada2@example.com
                name: Ada Byron
                phone: '+15551239999'
                metadata: {}
        '404':
          description: resource_missing.
  /v1/customers/{id}/state:
    get:
      tags:
        - Customers
      operationId: retrieveCustomerState
      summary: Everything needed to decide what this customer may use.
      description: |
        One call in place of a state machine. Without it, granting access means listing
        subscriptions, filtering to the live ones, mapping plan ids to your own tiers, and
        keeping that derived state correct against a stream of subscription events — the
        same code, with the same edge cases, in every integration.

        Returns `object: "customer_state"` with the customer's identity (`id` is
        `cus_<uuid>`, as `GET /v1/customers` returns it), the **entitling** subscriptions
        only (`active_subscriptions`), and the saved cards as brand + last4. Cacheable
        against your own user id; refresh it when a customer or subscription webhook
        arrives.

        Requires `customers:read`.
      parameters:
        - in: path
          name: id
          description: 'The customer id: `cus_<uuid>` as `GET /v1/customers` returns it, or the bare uuid.'
          required: true
          schema:
            type: string
      responses:
        '200':
          description: The customer state.
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `customers:read` scope (`permission_denied`).
        '404':
          description: No such customer.
  /v1/customers/external/{externalId}/state:
    get:
      tags:
        - Customers
      operationId: retrieveCustomerStateByExternalId
      summary: The same state, keyed by your own customer id.
      description: |
        Identical response to `GET /v1/customers/{id}/state`, addressed by the customer's
        `external_id`. No API call sets `external_id` today (`POST /v1/customers` and
        `POST /v1/customers/{id}` do not read it), so a customer you create has none and
        this route answers `404` for any id you choose: use `/v1/customers/{id}/state`.

        A separate path, not a query flag, on purpose: `?id_type=external` silently falls
        back to our-id when the parameter is dropped or misspelled, and a lookup in the
        wrong namespace does not error — it returns the wrong customer, or none.

        Requires `customers:read`.
      parameters:
        - in: path
          name: externalId
          description: Your own customer id, as stored in `external_id`.
          required: true
          schema:
            type: string
      responses:
        '200':
          description: The customer state.
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `customers:read` scope (`permission_denied`).
        '404':
          description: 'No customer carries that `external_id` (`resource_missing`, `param: external_id`).'
  /v1/customers/{id}/transactions:
    get:
      tags:
        - Customers
      operationId: listCustomerTransactions
      summary: List one customer's charges.
      description: |
        The charges of a single customer, newest first, in the same row shape as
        `GET /v1/charges`.

        **Which rows.**
        - Charges only: sales and authorizations, including those synced from your gateway.
        - Never a refund, void or capture row. A refund is not a charge; read refunds with
          `GET /v1/refunds?charge=`.

        **What each row says.**
        - `amount_refunded` is what the charge has already given back. Refunds made through
          the API and refunds made in your gateway's own portal are both included.
        - `refunded` is `true` once it covers the whole captured amount.
        - `disputed` is `true` while a chargeback or a matched pre-dispute alert is open on the
          charge. `POST /v1/refunds` refuses such a charge (`TRANSACTION_DISPUTED`).
        - `livemode` follows your key.
        - `card` is the card the charge was paid with: `brand`, `bin` (left out when none is
          held) and `last4`.
        - A failed charge carries `failure_code`, `failure_message` and an `outcome` that says
          whether the gateway refused it before it reached the card network
          (`not_sent_to_network`), our fraud screening blocked it (`not_sent_to_network`,
          `type: blocked`, `failure_code: fraudulent`) or the issuer declined it
          (`declined_by_network`), as `GET /v1/charges/{id}` does.

        Together these are what you need to work out how much of a customer's past spending
        you can still refund (see the **Refund a customer's past charges** guide).

        **One customer, several cards.** A customer's charges can have been paid with more than
        one card, and a refund goes back to the card of the charge it refunds. Refund only the
        charges on the card(s) of the person you owe, matched by `brand`, `bin` and `last4`.

        **Visibility.** Merchant sale exclusions apply before pagination, using the same
        policy as `GET /v1/charges`. Hidden sales are absent. If the policy is unavailable,
        the request fails with `503`; it never falls back to unfiltered customer history.

        **Paging.** Pass the previous page's `next_cursor` as `starting_after`.

        **Unknown customer.** An unknown customer id returns an empty list, not a `404`.

        ⚠️ Requires **`payments:read`**, not `customers:read`: the rows are payment data,
        so the scope follows the data rather than the path.
      parameters:
        - in: path
          name: id
          description: The customer id.
          required: true
          schema:
            type: string
        - in: query
          name: limit
          description: Page size, 1-100. Defaults to 10.
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 10
        - in: query
          name: starting_after
          description: >-
            The previous page's `next_cursor` (an opaque cursor, not a charge id). A charge id is
            not decoded and returns the first page again.
          required: false
          schema:
            type: string
      responses:
        '200':
          description: A cursor-paged list of the customer's charges.
          content:
            application/json:
              example:
                object: list
                data:
                  - id: TXN-8ac4796a-1940-4c6a-ba54-f151606330e3
                    object: charge
                    amount: 10000
                    amount_refunded: 2500
                    refunded: false
                    disputed: false
                    currency: usd
                    status: succeeded
                    livemode: true
                    card:
                      brand: visa
                      bin: '424242'
                      last4: '4242'
                has_more: true
                next_cursor: eyJjIjoiMjAyNi0xMC0wMVQxMjowMDowMFoiLCJpIjoiVFhOLTEifQ
        '400':
          description: A blank customer id is rejected; it never becomes an all-customer query.
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `payments:read` scope (`permission_denied`).
        '503':
          description: Merchant visibility policy unavailable (`upstream_error`).
  /v1/customers/{id}/payment_methods:
    post:
      tags:
        - PaymentMethods
      operationId: attachPaymentMethod
      summary: Attach (vault) a card to a customer.
      description: |
        Accepts an Evervault `ev:` token (`token`, `pan`, or `payment_method.token`) — never a
        raw PAN (`raw_pan_rejected`). `set_default` makes it the customer's default. Proxies to
        `CustomerService/VaultCard`.
      parameters:
        - in: path
          name: id
          description: The customer to attach the card to.
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: []
              anyOf:
                - required:
                    - payment_method
                - required:
                    - token
                - required:
                    - pan
              description: >-
                One card, as a token. A raw PAN is refused. Name it with `payment_method`, `token`
                or `pan` - they are read in that order of precedence, and with none of them the call
                is a 400.
              properties:
                payment_method:
                  description: A payment-method object or id naming the card to vault.
                token:
                  type: string
                  description: An `ev:` card token.
                pan:
                  type: string
                  description: Tokenized value only - this edge is outside the card-data environment.
                set_default:
                  type: boolean
                  description: Make this the customer default after vaulting.
            example:
              payment_method:
                token: ev:card_tok_9c2b…
                exp_month: 12
                exp_year: 2030
              set_default: true
      responses:
        '201':
          description: Vaulted payment_method
          content:
            application/json:
              example:
                id: pm_1Qabcd
                object: payment_method
                customer: cus_1QwXyZ
                type: card
                card:
                  brand: visa
                  last4: '4242'
        '400':
          description: parameter_invalid / raw_pan_rejected — a card token is required, raw PANs rejected.
        '403':
          description: >-
            `sandbox_key_required` (test mode only): this account has no NMI sandbox key of its own;
            add it in the dashboard (Settings > Integrations, NMI card, in test mode), or create an
            NMI sandbox account first at https://guide.nmi.com/. Nothing was saved.
    get:
      tags:
        - PaymentMethods
      operationId: listCustomerPaymentMethods
      summary: List a customer's vaulted cards.
      description: Proxies to `CustomerService/ListPaymentMethods`.
      parameters:
        - in: path
          name: id
          description: The customer whose saved cards you want.
          required: true
          schema:
            type: string
      responses:
        '200':
          description: List of payment methods
          content:
            application/json:
              example:
                object: list
                data:
                  - id: pm_1Qabcd
                    object: payment_method
                    customer: cus_1QwXyZ
                    type: card
                    card:
                      brand: visa
                      last4: '4242'
                has_more: false
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `customers:read` scope (`permission_denied`).
        '404':
          description: No such customer for this key's merchant and env-mode (`resource_missing`).
  /v1/customers/{customerId}/payment_methods/{pmId}:
    delete:
      tags:
        - PaymentMethods
      operationId: deleteCustomerPaymentMethod
      summary: Detach (soft-delete) a card from a customer — nested route.
      description: |
        Proxies to `CustomerService/RemovePaymentMethod`. Soft-delete only (the pm id keeps
        resolving for downstream consumers).
      parameters:
        - in: path
          name: customerId
          description: The customer the saved card belongs to.
          required: true
          schema:
            type: string
        - in: path
          name: pmId
          description: The payment method to detach.
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Deletion acknowledgement
          content:
            application/json:
              example:
                id: pm_1Qabcd
                object: payment_method
                deleted: true
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `customers:write` scope (`permission_denied`).
        '404':
          description: >-
            No such customer or payment method for this key's merchant and env-mode
            (`resource_missing`).
  /v1/customers/payment_methods/{pmId}:
    delete:
      tags:
        - PaymentMethods
      operationId: deletePaymentMethod
      summary: Detach a card by pm id (blueprint flat route; customer via ?customer=).
      description: >
        Same effect as the nested route. When used, pass the owning customer via the

        `customer` query param so tenancy resolves. Proxies to
        `CustomerService/RemovePaymentMethod`.
      parameters:
        - in: path
          name: pmId
          description: The payment method to detach.
          required: true
          schema:
            type: string
        - in: query
          name: customer
          description: >-
            Required in practice. This route carries no customer in its path, so the id goes here;
            without it the lookup runs against an empty customer and always answers 404. Prefer
            `DELETE /v1/customers/{customerId}/payment_methods/{pmId}`, which cannot be called
            wrong.
          schema:
            type: string
      responses:
        '200':
          description: Deletion acknowledgement
          content:
            application/json:
              example:
                id: pm_1Qabcd
                object: payment_method
                deleted: true
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `customers:write` scope (`permission_denied`).
        '404':
          description: >-
            No such customer or payment method (`resource_missing`). Also what you get when the
            `customer` query parameter is omitted: the lookup then runs against an empty customer id
            and can never match.
  /v1/checkout-links:
    post:
      tags:
        - Commerce
      operationId: createCheckoutLink
      summary: Create a hosted checkout link.
      description: |
        Proxies to `CommerceCheckoutService/CreatePaymentLink`. `name` is required (min length 1).
        `link_type` FIXED requires `amount`. `link_type` SUBSCRIPTION also requires `plan` (an
        11-billing plan id); omitting it is `400 parameter_missing`. There is no idempotency
        key on the domain RPC.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: []
              anyOf:
                - required:
                    - name
                - required:
                    - description
              description: >-
                `name` is required, but `description` is accepted in its place - the edge falls back
                to it. With neither, the call is a 400 `parameter_invalid`.
              properties:
                amount:
                  type: integer
                  description: Minor units. Ignored for a DONATION link.
                currency:
                  type: string
                  description: ISO 4217.
                name:
                  type: string
                description:
                  type: string
                link_type:
                  type: string
                  enum:
                    - FIXED
                    - DONATION
                    - SUBSCRIPTION
                plan:
                  type: string
                  description: >-
                    Required when `link_type` is SUBSCRIPTION. A `plan_…` id from POST/GET
                    /v1/plans.
                idempotency_key:
                  type: string
            example:
              name: Order 1234
              amount: 1999
              currency: usd
              link_type: FIXED
              description: One pipe wrench
      responses:
        '201':
          description: checkout_link
          content:
            application/json:
              example:
                id: df62a7a3-e2de-4f47-8635-e3c9adfebddf
                object: checkout_link
                url: https://app.worthypayments.com/checkout/lKHx64iW
                code: lKHx64iW
                active: true
        '400':
          description: >-
            parameter_invalid — name is required. parameter_missing — `plan` is required when
            `link_type` is SUBSCRIPTION.
    get:
      tags:
        - Commerce
      operationId: listCheckoutLinks
      summary: List checkout links.
      description: |
        Proxies to `CommerceCheckoutService/ListPaymentLinks`. Cursor-paged with `starting_after`
        (same shape as GET /v1/charges). Scoped to the key's merchant. Requires
        `checkout_links:read`. `active` and `link_type` are optional filters; an absent `active`
        lists both active and inactive links. Money fields are in minor units, absent for a
        DONATION link with no fixed amount.
      parameters:
        - in: query
          name: limit
          description: Page size. Defaults to 10 and is capped at 100.
          schema:
            type: integer
            default: 10
            maximum: 100
        - in: query
          name: starting_after
          description: >-
            Cursor for the next page: the id of the last row you received. Omit it on the first
            request, and keep paging while `has_more` is true.
          schema:
            type: string
        - in: query
          name: active
          description: Send `true` for live links only or `false` for inactive ones. Omit for both.
          schema:
            type: boolean
        - in: query
          name: link_type
          description: Return only links of this type.
          schema:
            type: string
            enum:
              - FIXED
              - DONATION
              - SUBSCRIPTION
      responses:
        '200':
          description: List
          content:
            application/json:
              example:
                object: list
                data:
                  - id: df62a7a3-e2de-4f47-8635-e3c9adfebddf
                    object: checkout_link
                    url: https://app.worthypayments.com/checkout/lKHx64iW
                    code: lKHx64iW
                    name: Order 1234
                    link_type: FIXED
                    currency: usd
                    amount: 1999
                    times_used: 3
                    active: true
                has_more: false
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `checkout_links:read` scope (`permission_denied`).
  /v1/checkout-links/{id}:
    get:
      tags:
        - Commerce
      operationId: retrieveCheckoutLink
      summary: Fetch a checkout link.
      description: |
        Proxies to `CommerceCheckoutService/GetPaymentLink`. Scoped to the key's merchant.
        Requires `checkout_links:read`.
      parameters:
        - in: path
          name: id
          description: The checkout link id.
          required: true
          schema:
            type: string
      responses:
        '200':
          description: checkout_link
          content:
            application/json:
              example:
                id: df62a7a3-e2de-4f47-8635-e3c9adfebddf
                object: checkout_link
                url: https://app.worthypayments.com/checkout/lKHx64iW
                code: lKHx64iW
                name: Order 1234
                link_type: FIXED
                currency: usd
                amount: 1999
                times_used: 3
                active: true
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `checkout_links:read` scope (`permission_denied`).
        '404':
          description: No such checkout link for this key's merchant and env-mode (`resource_missing`).
  /v1/products:
    get:
      tags:
        - Commerce
      operationId: listProducts
      summary: List products.
      description: |
        Lists the merchant's catalogue products for the key's env-mode, paged with `limit`
        and `starting_after`. Requires `products:read`.
      parameters:
        - in: query
          name: limit
          description: Page size. Defaults to 10 and is capped at 100.
          schema:
            type: integer
            default: 10
            maximum: 100
        - in: query
          name: starting_after
          description: >-
            Cursor for the next page: the id of the last row you received. Omit it on the first
            request, and keep paging while `has_more` is true.
          schema:
            type: string
      responses:
        '200':
          description: List
          content:
            application/json:
              example:
                object: list
                data:
                  - id: prod_9aZ
                    object: product
                    name: Pipe wrench
                    active: true
                    url: https://pay.paygeyt.com/p/9aZ
                has_more: false
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `products:read` scope (`permission_denied`).
  /v1/products/{id}/tax_rates:
    post:
      tags:
        - Commerce
      operationId: attachProductTaxRate
      summary: Attach a tax rate to a product.
      description: |
        Pins one of your tax rates onto a product, so `POST /v1/tax/calculate` taxes that
        product's lines with it. Proxies `BillingService/SetProductTaxRate`. Requires
        `tax:write` (server-only; publishable keys are refused).

        A product can carry several rates; they stack (for example a state rate plus a city
        rate). Attaching the same rate twice is harmless and returns the same mapping.

        The product id is stored as a reference and is **not** checked against the catalog,
        so use the id you will pass as `line_items[].product` to `POST /v1/tax/calculate` —
        normally the catalog id from `GET /v1/products`, which is also what the hosted
        checkout sends. An inactive or deleted rate stays attached but is never applied.
      parameters:
        - in: path
          name: id
          required: true
          description: The product id, as you pass it to `POST /v1/tax/calculate`.
          schema:
            type: string
        - in: header
          name: Idempotency-Key
          required: false
          description: >-
            Optional. A repeat of the same key replays the first response; the replay carries
            `X-Idempotency-Replayed: true`. Keys are stored for 24h.
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - tax_rate
              additionalProperties: false
              properties:
                tax_rate:
                  type: string
                  description: The id of one of your tax rates.
            example:
              tax_rate: 6f1c2a4e-1d7b-4b8e-9b0a-2f5d3c9e7a11
      responses:
        '200':
          description: The mapping.
          content:
            application/json:
              example:
                id: 0d3e9f62-58a4-4c71-8f2b-7e1a6c5d4b30
                object: product_tax_rate
                product: 4b1e77a0-2c93-45de-8f0a-6d5c39ab72e1
                tax_rate: 6f1c2a4e-1d7b-4b8e-9b0a-2f5d3c9e7a11
                created: 1790856000
        '400':
          description: '`parameter_invalid` (no `tax_rate`) or `parameter_unknown`.'
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `tax:write` scope (`permission_denied`).
        '404':
          description: '`resource_missing` — no live tax rate with that id for this key''s account and mode.'
  /v1/products/{id}/tax_rates/{tax_rate}:
    delete:
      tags:
        - Commerce
      operationId: detachProductTaxRate
      summary: Detach a tax rate from a product.
      description: |
        Removes the product-to-rate mapping. The tax rate itself is untouched and stays
        attached to any other product. Proxies `BillingService/DeleteProductTaxRate`.
        Requires `tax:write`.

        Once no rate is attached to a product, its lines fall back to your location rates
        (country / state / city), then to your default rate, in `POST /v1/tax/calculate`.
      parameters:
        - in: path
          name: id
          required: true
          description: The product id the rate was attached to.
          schema:
            type: string
        - in: path
          name: tax_rate
          required: true
          description: The id of the attached tax rate.
          schema:
            type: string
        - in: header
          name: Idempotency-Key
          required: false
          description: >-
            Optional. A repeat of the same key replays the first response instead of answering 404
            the second time. Keys are stored for 24h.
          schema:
            type: string
      responses:
        '200':
          description: The removed mapping.
          content:
            application/json:
              example:
                id: 0d3e9f62-58a4-4c71-8f2b-7e1a6c5d4b30
                object: product_tax_rate
                product: 4b1e77a0-2c93-45de-8f0a-6d5c39ab72e1
                tax_rate: 6f1c2a4e-1d7b-4b8e-9b0a-2f5d3c9e7a11
                created: 1790856000
                deleted: true
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `tax:write` scope (`permission_denied`).
        '404':
          description: '`resource_missing` — that rate is not attached to that product.'
  /v1/tax_rates:
    post:
      tags:
        - Commerce
      operationId: createTaxRate
      summary: Create a tax rate.
      description: |
        Creates a tax rate on your account, in the key's mode. Proxies
        `BillingService/UpsertTaxRate`, the same call the dashboard's tax-rate page makes.
        Requires `tax:write` (server-only; publishable keys are refused).

        `percentage` is a **percent**, not a fraction: `8.25` means 8.25 %. Send it as a JSON
        number or a decimal string, between 0 and 100 with at most two decimals; it is always
        returned as a decimal string (`"8.25"`), never a float.

        Where it applies: attach it to products with `POST /v1/products/{id}/tax_rates`, or
        give it a `country` (and optionally `state` / `city`) so it applies to every line
        shipped there that has no product rate of its own. A new rate is `active` unless you
        send `active: false`. Default and global rates are set in the dashboard.
      parameters:
        - in: header
          name: Idempotency-Key
          required: true
          description: >-
            Required. A retried create would otherwise make a second rate. A repeat of the same key
            replays the first response with `X-Idempotency-Replayed: true`; the same key with a
            different body is `idempotency_error`. Keys are stored for 24h.
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - display_name
                - percentage
              additionalProperties: false
              properties:
                display_name:
                  type: string
                  description: Shown on receipts and in the tax breakdown.
                description:
                  type: string
                percentage:
                  type:
                    - number
                    - string
                  description: Percent, 0–100, at most two decimals. `8.25` = 8.25 %.
                inclusive:
                  type: boolean
                  default: false
                  description: '`true` when your prices already include this tax.'
                tax_type:
                  type: string
                  enum:
                    - VAT
                    - GST
                    - SALES_TAX
                    - HST
                    - PST
                    - CUSTOM
                    - OTHER
                  description: Case-insensitive.
                jurisdiction:
                  type: string
                country:
                  type: string
                  description: ISO 3166-1 alpha-2, e.g. `US`.
                state:
                  type: string
                  description: State or province code, e.g. `CA`.
                city:
                  type: string
                active:
                  type: boolean
                  default: true
            example:
              display_name: CA sales tax
              percentage: 7.25
              tax_type: SALES_TAX
              country: US
              state: CA
      responses:
        '200':
          description: The created tax rate.
          content:
            application/json:
              example:
                id: 6f1c2a4e-1d7b-4b8e-9b0a-2f5d3c9e7a11
                object: tax_rate
                display_name: CA sales tax
                description: null
                percentage: '7.25'
                inclusive: false
                tax_type: SALES_TAX
                jurisdiction: null
                country: US
                state: CA
                city: null
                active: true
                is_default: false
                created: 1790856000
                updated: 1790856000
        '400':
          description: >-
            `idempotency_key_required`, `parameter_invalid` (missing `display_name` / `percentage`,
            a percentage out of range or with a third decimal, an unknown `tax_type`) or
            `parameter_unknown`.
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `tax:write` scope (`permission_denied`).
        '409':
          description: '`idempotency_in_progress` — the same key is still being processed.'
    get:
      tags:
        - Commerce
      operationId: listTaxRates
      summary: List tax rates.
      description: |
        Your account's tax rates in the key's mode, newest first, including inactive ones
        unless `active=true`. Deleted rates are never listed. Proxies
        `BillingService/ListTaxRates`. Requires `tax:read`.

        Cursor-paged: pass the previous page's `next_cursor` as `starting_after` and keep
        paging while `has_more` is true.
      parameters:
        - in: query
          name: limit
          description: Page size. Defaults to 10 and is capped at 100.
          schema:
            type: integer
            default: 10
            maximum: 100
        - in: query
          name: starting_after
          description: The opaque `next_cursor` from the previous page. Not a tax rate id.
          schema:
            type: string
        - in: query
          name: active
          description: '`true` returns only active rates.'
          schema:
            type: boolean
      responses:
        '200':
          description: A page of tax rates.
          content:
            application/json:
              example:
                object: list
                data:
                  - id: 6f1c2a4e-1d7b-4b8e-9b0a-2f5d3c9e7a11
                    object: tax_rate
                    display_name: CA sales tax
                    description: null
                    percentage: '7.25'
                    inclusive: false
                    tax_type: SALES_TAX
                    jurisdiction: null
                    country: US
                    state: CA
                    city: null
                    active: true
                    is_default: false
                    created: 1790856000
                    updated: 1790856000
                has_more: false
        '400':
          description: '`invalid_request` — the `starting_after` cursor is not one this list issued.'
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `tax:read` scope (`permission_denied`).
  /v1/tax_rates/{id}:
    get:
      tags:
        - Commerce
      operationId: retrieveTaxRate
      summary: Retrieve a tax rate.
      description: |
        One tax rate by id, scoped to the key's account and mode. A rate of another account
        or the other mode, or a deleted one, is `404`. Requires `tax:read`.

        The response is the same `tax_rate` object the list and create return. `is_default`
        is read-only here: the default rate is chosen in the dashboard.
      parameters:
        - in: path
          name: id
          required: true
          description: The tax rate id.
          schema:
            type: string
      responses:
        '200':
          description: The tax rate.
          content:
            application/json:
              example:
                id: 6f1c2a4e-1d7b-4b8e-9b0a-2f5d3c9e7a11
                object: tax_rate
                display_name: CA sales tax
                description: null
                percentage: '7.25'
                inclusive: false
                tax_type: SALES_TAX
                jurisdiction: null
                country: US
                state: CA
                city: null
                active: true
                is_default: false
                created: 1790856000
                updated: 1790856000
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `tax:read` scope (`permission_denied`).
        '404':
          description: '`resource_missing` — no such tax rate for this key.'
    post:
      tags:
        - Commerce
      operationId: updateTaxRate
      summary: Update a tax rate.
      description: |
        Changes only the fields you send; every other field keeps its stored value,
        including the ones this API does not expose (default, global, stacking). Accepts
        the same fields as create, none of them required. Send `null` or `""` to clear a
        text field. Requires `tax:write`.

        A changed `percentage` applies to every calculation from now on; tax already
        recorded on completed orders is not recomputed.
      parameters:
        - in: path
          name: id
          required: true
          description: The tax rate id.
          schema:
            type: string
        - in: header
          name: Idempotency-Key
          required: false
          description: >-
            Optional. A repeat of the same key replays the first response; the same key with a
            different body is `idempotency_error`. Keys are stored for 24h.
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                display_name:
                  type:
                    - string
                    - 'null'
                description:
                  type:
                    - string
                    - 'null'
                percentage:
                  type:
                    - number
                    - string
                  description: Percent, 0–100, at most two decimals.
                inclusive:
                  type: boolean
                tax_type:
                  type:
                    - string
                    - 'null'
                  enum:
                    - VAT
                    - GST
                    - SALES_TAX
                    - HST
                    - PST
                    - CUSTOM
                    - OTHER
                    - null
                jurisdiction:
                  type:
                    - string
                    - 'null'
                country:
                  type:
                    - string
                    - 'null'
                state:
                  type:
                    - string
                    - 'null'
                city:
                  type:
                    - string
                    - 'null'
                active:
                  type: boolean
            example:
              percentage: '7.5'
      responses:
        '200':
          description: The updated tax rate.
          content:
            application/json:
              example:
                id: 6f1c2a4e-1d7b-4b8e-9b0a-2f5d3c9e7a11
                object: tax_rate
                display_name: CA sales tax
                description: null
                percentage: '7.5'
                inclusive: false
                tax_type: SALES_TAX
                jurisdiction: null
                country: US
                state: CA
                city: null
                active: true
                is_default: false
                created: 1790856000
                updated: 1790942400
        '400':
          description: '`parameter_invalid` or `parameter_unknown`.'
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `tax:write` scope (`permission_denied`).
        '404':
          description: '`resource_missing` — no such tax rate for this key.'
    delete:
      tags:
        - Commerce
      operationId: deleteTaxRate
      summary: Delete a tax rate.
      description: |
        Deletes the rate: it stops applying to every calculation at once and disappears
        from the list. The record is kept (soft delete) so tax already recorded against it
        stays explained. Product attachments to it stop applying with it. Proxies
        `BillingService/DeleteTaxRate`. Requires `tax:write`.

        To pause a rate you may want back, send `active: false` on update instead.
      parameters:
        - in: path
          name: id
          required: true
          description: The tax rate id.
          schema:
            type: string
        - in: header
          name: Idempotency-Key
          required: false
          description: >-
            Optional. A repeat of the same key replays the first response instead of answering 404
            the second time. Keys are stored for 24h.
          schema:
            type: string
      responses:
        '200':
          description: The deleted tax rate.
          content:
            application/json:
              example:
                id: 6f1c2a4e-1d7b-4b8e-9b0a-2f5d3c9e7a11
                object: tax_rate
                deleted: true
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `tax:write` scope (`permission_denied`).
        '404':
          description: '`resource_missing` — no such tax rate for this key.'
  /v1/tax/calculate:
    post:
      tags:
        - Commerce
      operationId: calculateTax
      summary: Calculate tax for a cart, line by line.
      description: |
        Returns the tax each line owes and the cart totals, from your own tax rates.
        Writes nothing, so it needs `tax:read` and no `Idempotency-Key`; call it as often as
        the cart changes. Amounts are integer minor units (cents).

        Each line resolves its rates on its own: a rate set to apply globally first, then the
        rates attached to the line's `product`, then your rates for the ship-to `address`
        (country / state / city), then your default rate. So a cart can hold a taxed line
        and an untaxed one. Tax on an `inclusive` rate is already inside the line `amount`
        and is not added again: `amount_total` = `amount_subtotal` + `tax_amount_exclusive`.

        At most 100 line items. Shipping is not taxed by this call.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - line_items
              additionalProperties: false
              properties:
                currency:
                  type: string
                  default: usd
                  description: ISO 4217.
                line_items:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items:
                    type: object
                    required:
                      - amount
                    additionalProperties: false
                    properties:
                      amount:
                        type: integer
                        minimum: 0
                        description: The line total in minor units (unit price × quantity).
                      quantity:
                        type: integer
                        minimum: 1
                        default: 1
                      product:
                        type: string
                        description: The product id its tax rates are attached under.
                      reference:
                        type: string
                        description: Your own line id (for example a cart item key), echoed back.
                address:
                  type: object
                  additionalProperties: false
                  description: The ship-to address the location rates resolve on.
                  properties:
                    country:
                      type: string
                      description: ISO 3166-1 alpha-2.
                    state:
                      type: string
                    city:
                      type: string
                    postal_code:
                      type: string
            example:
              currency: usd
              address:
                country: US
                state: CA
                city: Los Angeles
                postal_code: '90001'
              line_items:
                - product: 4b1e77a0-2c93-45de-8f0a-6d5c39ab72e1
                  amount: 2000
                  quantity: 2
                  reference: cart-item-1
                - product: 9c2d1b30-7e44-4a5f-b8d6-1f0e3a2c5b97
                  amount: 500
      responses:
        '200':
          description: The calculation.
          content:
            application/json:
              example:
                object: tax_calculation
                currency: usd
                amount_subtotal: 2500
                tax_amount_exclusive: 181
                tax_amount_inclusive: 0
                amount_total: 2681
                line_items:
                  - reference: cart-item-1
                    product: 4b1e77a0-2c93-45de-8f0a-6d5c39ab72e1
                    quantity: 2
                    amount: 2000
                    amount_tax: 145
                    tax_breakdown:
                      - tax_rate: 6f1c2a4e-1d7b-4b8e-9b0a-2f5d3c9e7a11
                        display_name: CA sales tax
                        tax_type: SALES_TAX
                        percentage: '7.25'
                        jurisdiction: null
                        country: US
                        state: CA
                        city: null
                        inclusive: false
                        taxable_amount: 2000
                        amount: 145
                  - reference: null
                    product: 9c2d1b30-7e44-4a5f-b8d6-1f0e3a2c5b97
                    quantity: 1
                    amount: 500
                    amount_tax: 36
                    tax_breakdown:
                      - tax_rate: 6f1c2a4e-1d7b-4b8e-9b0a-2f5d3c9e7a11
                        display_name: CA sales tax
                        tax_type: SALES_TAX
                        percentage: '7.25'
                        jurisdiction: null
                        country: US
                        state: CA
                        city: null
                        inclusive: false
                        taxable_amount: 500
                        amount: 36
        '400':
          description: >-
            `parameter_invalid` (no or too many `line_items`, a non-integer `amount`, a zero
            `quantity`, an unknown `currency` or `address.country`) or `parameter_unknown`.
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `tax:read` scope (`permission_denied`).
  /v1/wallet-config:
    get:
      tags:
        - Commerce
      operationId: retrieveWalletConfig
      summary: Public wallet config for the checkout SDK (Apple/Google Pay).
      description: |
        Whether Apple Pay and Google Pay are on for this merchant. Wallets run on the
        Evervault rail.

        - `EVERVAULT` — the wallet sheet returns an encrypted network token plus a
          cryptogram, which you post to `/v1/charges` as the `wallet` block.
          `evervaultMerchantId` is what the button mounts against, so a wallet is only
          reported enabled when that id is present.
        - `NONE` — no wallet is available for this account.

        Fails soft: if an upstream errors, the response degrades to "no wallet" rather
        than failing the call. Treat the flags as **fail-closed** — mount a button only
        on a literal `true`.

        These flags describe the *account*. Whether the buyer's device can actually pay
        is decided at mount time: Apple Pay is gated on the SDK's availability check, and
        Google Pay's web flow runs only in Chromium browsers. A mount that renders
        nothing is a normal outcome, not an error.

        No secrets are returned — only publishable ids.
      responses:
        '200':
          description: wallet_config
          content:
            application/json:
              example:
                object: wallet_config
                mode: test
                provider: EVERVAULT
                applePay: true
                googlePay: true
                evervaultMerchantId: merchant_2f9a1c4d8e
        '401':
          description: Missing or invalid API key (`authentication_required`).
  /v1/wp-config:
    get:
      tags:
        - Config
      operationId: retrieveWpConfig
      summary: The public half of the WorthyPayments card keypair.
      description: |
        What the browser SDK needs to seal a card into a `wp:` envelope. Scope `none`,
        so a publishable key reads it.

        **Publishing the public key is the design, not a leak.** Anyone can mint a
        well-formed `wp:` token; what stops abuse is everything behind it — velocity,
        throttling, the audience binding, and the single-use `jti`. Secrecy of this
        value was never part of the defence.

        Like `/v1/evervault-config`, this is **not per-merchant**: one platform
        keypair serves everyone, and the envelope's `aud` claim is what binds a token
        to a merchant, enforced in 03-gateway-adapter.

        `alg` is a suite string a client must recognise or refuse — never ignore it and
        encrypt anyway. Cached for an hour, which is safe across a rotation because 03
        also accepts the previous key for the overlap window.

        Returns `503` when no key is configured, and that response is deliberately not
        cached: a cached 503 would outlive the deploy that fixes it.
      security:
        - ApiKey: []
        - BearerKey: []
      responses:
        '200':
          description: The active card-envelope key.
          content:
            application/json:
              example:
                kid: 1
                public_key: BEys...65-byte-P256-point-base64
                alg: HPKE-P256-HKDF-SHA256-AES256GCM
                info: worthy-card-v1
                token_ttl_seconds: 900
        '401':
          description: api_key_required / api_key_invalid.
        '503':
          description: wp_not_configured — no card key on this environment.
  /v1/element-frameable:
    get:
      tags:
        - Payment Element
      operationId: checkElementFrameable
      summary: May this origin embed the Payment Element?
      description: |
        The element's own server calls this before serving the card frame, and the
        answer becomes that response's `frame-ancestors`. Scope `none`.

        **A boolean, never the domain list.** Returning `allowed_domains` would let
        anyone holding a publishable key — a public string — enumerate every host a
        merchant runs: staging, internal tools, whatever was allowlisted once and
        forgotten. The caller already knows the origin it is asking about, so a yes/no
        tells it nothing it did not bring.

        Matches the same allowlist that governs which origins may charge with the key
        (exact host, or a single-level `*.` wildcard).

        **Fails closed on an unconfigured key.** No allowlist means no element —
        "any site may render a card form on our origin" is not a safe default for a
        merchant who simply never filled the field in. This differs on purpose from
        the charge-time domain check, where an empty list means "no origin lock" and
        is correct for a server-to-server call.

        Query `origin` is optional. When it is omitted, the `Origin` header is the
        origin under test. Prefer the header for loopback parents
        (`http://localhost`, `http://127.0.0.1`): a `?origin=http://localhost…`
        query is HTML 403 at the public edge, and never reaches this handler.
        The element's in-cluster proxy already sends `Origin` and omits the
        query for loopback.
      security:
        - ApiKey: []
        - BearerKey: []
      parameters:
        - in: query
          name: origin
          required: false
          schema:
            type: string
          description: >
            The embedding origin, e.g. `https://shop.example.com`. https only, except loopback http.
            Optional when the `Origin` header is set.
      responses:
        '200':
          description: Whether that origin may frame this key's element.
          content:
            application/json:
              example:
                object: element_frameable
                allowed: true
        '401':
          description: api_key_required / api_key_invalid.
  /v1/evervault-config:
    get:
      tags:
        - Config
      operationId: retrieveEvervaultConfig
      summary: Evervault team/app ids for the front-end relay.
      description: |
        Publishable-key-safe (`RequireScopes("none")`) — this is the endpoint the browser SDK
        reads, NOT `/v1/3ds/config`, which requires `threeds:read`: a scope publishable keys
        cannot hold, because opening a 3DS session bills the merchant's acquirer.

        Ids are resolved per mode: a test key gets the platform's test Evervault app, a live
        key its live app.
        An `ev:` token is encrypted against one Evervault app and only that app can decrypt it,
        so hardcoding ids — or reusing a test token in live — produces a charge that fails at
        the gateway with an opaque error.

        Falls back to the edge's own per-mode ids when payments is unreachable, so a transient
        upstream fault still yields the pair for your key's mode rather than an SDK that cannot
        encrypt a card. Returns null ids when nothing is configured.

        Wallet availability lives on `GET /v1/wallet-config`, also scope `none`.
      responses:
        '200':
          description: evervault_config
          content:
            application/json:
              example:
                object: evervault_config
                mode: test
                team_id: team_abc123
                app_id: app_xyz789
        '401':
          description: Missing or invalid API key (`authentication_required`).
  /v1/kount-config:
    get:
      tags:
        - Config
      operationId: retrieveKountConfig
      summary: Kount device data collection coordinates for the browser SDK.
      description: |
        Publishable-key-safe (`RequireScopes("none")`). Returns the issued client id and which
        Kount environment to collect against — no credentials. The browser SDK calls this and
        attaches the resulting session id to `device.kount_session_id` on the charge.

        Mode comes from the KEY, never a header: a TEST key always collects against the sandbox
        tenant.

        `enabled: false` means "do not attempt collection" — the merchant is not provisioned, or
        collection is switched off. Skip DDC and charge anyway; Kount is a fraud signal, not a
        gate, and a config outage must never block a payment.

        Merchants must allow `frame-src https://*.kaptcha.com` in their CSP. The collector runs
        in a hidden iframe, so without that entry it silently collects nothing.
      responses:
        '200':
          description: kount_config
          content:
            application/json:
              examples:
                enabled:
                  value:
                    object: kount_config
                    mode: test
                    client_id: '900900'
                    environment: sandbox
                    enabled: true
                not_provisioned:
                  value:
                    object: kount_config
                    mode: live
                    client_id: null
                    environment: production
                    enabled: false
        '401':
          description: Missing or invalid API key (`authentication_required`).
  /v1/webhook-endpoints:
    post:
      tags:
        - Webhooks
      operationId: createWebhookEndpoint
      summary: Create a webhook endpoint (signing secret returned once).
      description: |
        Proxies to `WebhookService/CreateWebhook`. The `secret` is returned **only** on create
        (and on roll-secret) — never on list/get. An `Idempotency-Key` header is honored (else
        derived from the url).
      parameters:
        - in: header
          name: Idempotency-Key
          description: >-
            Optional. A repeat of the same key returns the original endpoint rather than creating a
            second one. Omitted, the key is derived from the endpoint URL.
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - url
              properties:
                url:
                  type: string
                  description: >-
                    HTTPS destination. Missing or blank is a 400 `parameter_missing`. The owning
                    service then validates the URL (https, SSRF).
                enabled_events:
                  type: array
                  items:
                    type: string
                  description: Event types to deliver. Omit for the account default.
            example:
              url: https://merchant.example.com/hooks/worthy
              enabled_events:
                - charge.succeeded
                - charge.refunded
      responses:
        '201':
          description: webhook_endpoint (with one-time secret)
          content:
            application/json:
              example:
                id: we_5RtG
                object: webhook_endpoint
                url: https://merchant.example.com/hooks/worthy
                status: enabled
                enabled_events:
                  - charge.succeeded
                  - charge.refunded
                secret: whsec_9f3aC…
        '400':
          description: parameter_missing — `url` was omitted or blank.
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `webhooks:write` scope (`permission_denied`).
    get:
      tags:
        - Webhooks
      operationId: listWebhookEndpoints
      summary: List webhook endpoints (no secrets).
      description: |
        Returns every webhook endpoint configured for the key's merchant and env-mode, and
        requires `webhooks:read`.

        Unlike the other collections on this API it takes no `limit` or `starting_after`:
        the handler accepts no paging parameters and the whole list comes back in one
        response.
      responses:
        '200':
          description: List
          content:
            application/json:
              example:
                object: list
                data:
                  - id: we_5RtG
                    object: webhook_endpoint
                    url: https://merchant.example.com/hooks/worthy
                    status: enabled
                    enabled_events:
                      - charge.succeeded
                has_more: false
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `webhooks:read` scope (`permission_denied`).
  /v1/webhook-endpoints/{id}:
    get:
      tags:
        - Webhooks
      operationId: retrieveWebhookEndpoint
      summary: Retrieve a webhook endpoint (no secret).
      description: Proxies to `WebhookService/GetWebhook`.
      parameters:
        - in: path
          name: id
          description: The webhook endpoint id.
          required: true
          schema:
            type: string
      responses:
        '200':
          description: webhook_endpoint
          content:
            application/json:
              example:
                id: we_5RtG
                object: webhook_endpoint
                url: https://merchant.example.com/hooks/worthy
                status: enabled
                enabled_events:
                  - charge.succeeded
        '404':
          description: resource_missing — no such webhook endpoint.
    delete:
      tags:
        - Webhooks
      operationId: deleteWebhookEndpoint
      summary: Delete a webhook endpoint.
      description: Proxies to `WebhookService/DeleteWebhook`. Reports the domain's real `deleted` flag.
      parameters:
        - in: path
          name: id
          description: The webhook endpoint to delete.
          required: true
          schema:
            type: string
        - in: header
          name: Idempotency-Key
          description: >-
            Optional. A repeat of the same key returns the original endpoint rather than creating a
            second one. Omitted, the key is derived from the endpoint URL.
          schema:
            type: string
      responses:
        '200':
          description: Deletion acknowledgement
          content:
            application/json:
              example:
                id: we_5RtG
                object: webhook_endpoint
                deleted: true
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `webhooks:write` scope (`permission_denied`).
        '404':
          description: No such webhook endpoint for this key's merchant and env-mode (`resource_missing`).
  /v1/webhook-endpoints/{id}/roll-secret:
    post:
      tags:
        - Webhooks
      operationId: rollWebhookEndpointSecret
      summary: Rotate a webhook endpoint's signing secret (new secret returned once).
      description: Proxies to `WebhookService/RegenerateSecret`. The new `secret` is shown exactly once.
      parameters:
        - in: path
          name: id
          description: The webhook endpoint whose signing secret to roll.
          required: true
          schema:
            type: string
        - in: header
          name: Idempotency-Key
          description: >-
            Optional. A repeat of the same key returns the original endpoint rather than creating a
            second one. Omitted, the key is derived from the endpoint URL.
          schema:
            type: string
      responses:
        '201':
          description: webhook_endpoint with the new secret
          content:
            application/json:
              example:
                id: we_5RtG
                object: webhook_endpoint
                url: https://merchant.example.com/hooks/worthy
                status: enabled
                enabled_events:
                  - charge.succeeded
                secret: whsec_NEW7k2…
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `webhooks:write` scope (`permission_denied`).
        '404':
          description: No such webhook endpoint for this key's merchant and env-mode (`resource_missing`).
  /v1/events:
    get:
      tags:
        - Webhooks
      operationId: listEvents
      summary: List delivered events (webhook deliveries; page-based).
      description: |
        Proxies to `WebhookService/ListDeliveries`. Pagination is **page-based** (not keyset):
        `starting_after` is treated as a 1-based page number and `next_cursor` is the next page
        number when a full page is returned.

        **A list item WRAPS the delivered envelope; it is not the same shape as the pushed
        body.** The item has five keys (`id`, `object`, `type`, `created`, `event_id`) and
        `data` holds the whole envelope that was POSTed, so the resource sits at
        `data.data.object` — one level deeper than in a webhook body — and `api_version` /
        `livemode` are at `data.api_version` / `data.livemode`.

        **`id` and `event_id` are different ids.** `id` names this *delivery* (fan-out writes
        one row per subscribed endpoint) and is the only value `GET /v1/events/{id}` resolves.
        `event_id` names the *occurrence*: it is the `X-Webhook-Id` header and the `id` inside
        the delivered envelope, shared by every delivery of that occurrence. Dedupe retries on
        `id` and fan-out on `event_id`; `GET /v1/events/{X-Webhook-Id}` answers `404`.

        `created` is **unix seconds**, like every other resource here.
      parameters:
        - in: query
          name: limit
          description: Page size. Defaults to 10 and is capped at 100.
          schema:
            type: integer
            default: 20
            maximum: 100
        - in: query
          name: starting_after
          description: >-
            Cursor for the next page: the id of the last row you received. Omit it on the first
            request, and keep paging while `has_more` is true.
          schema:
            type: string
            description: 1-based page number.
        - in: query
          name: type
          schema:
            type: string
          description: >-
            Filter to one event type (`charge.succeeded`). Applied in SQL, so `limit` still means up
            to N MATCHING rows.
        - in: query
          name: created_after
          schema:
            type: string
          description: >-
            Unix seconds or RFC3339. Anything else is rejected with `parameter_invalid` rather than
            silently ignored.
      responses:
        '200':
          description: List of events
          content:
            application/json:
              example:
                object: list
                data:
                  - id: evt_3f2b4a86-b0e7-4d41-9c6f-2a915c1d90a7
                    object: event
                    type: charge.succeeded
                    created: 1733600000
                    event_id: evt_5c1d90a7-3f2b-4a86-b0e7-8d41c6f2a915
                    data:
                      id: evt_5c1d90a7-3f2b-4a86-b0e7-8d41c6f2a915
                      object: event
                      api_version: '2026-01-01'
                      created: 1733600000
                      livemode: false
                      type: charge.succeeded
                      data:
                        object:
                          transactionId: TXN-8ac4796a-1940-4c6a-ba54-f151606330e3
                          amount:
                            minorUnits: '2000'
                            currencyCode: USD
                has_more: true
                next_cursor: '2'
        '400':
          description: parameter_invalid — `created_after` is neither unix seconds nor RFC3339.
  /v1/events/{id}:
    get:
      tags:
        - Webhooks
      operationId: retrieveEvent
      summary: Retrieve a single delivered event.
      description: |
        The `evt_` prefix is stripped to the underlying **delivery** id. Proxies to
        `WebhookService/GetDelivery`.

        ⚠️ **Keyed on the delivery id — the `id` from `GET /v1/events`, not the `event_id`
        and not the `X-Webhook-Id` header.** Both are `evt_`-prefixed, which is what makes
        them easy to confuse; passing the occurrence id here answers `404`. To find the
        polled row for a pushed body, list with `type` + `created_after` and match on
        `event_id`.

        The response has the same shape as one list item: `data` is the whole delivered
        envelope, so the resource is at `data.data.object`, and `created` is unix seconds.
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
          description: The delivery id (`evt_…`) from `GET /v1/events`.
      responses:
        '200':
          description: event
          content:
            application/json:
              example:
                id: evt_3f2b4a86-b0e7-4d41-9c6f-2a915c1d90a7
                object: event
                type: charge.succeeded
                created: 1733600000
                event_id: evt_5c1d90a7-3f2b-4a86-b0e7-8d41c6f2a915
                data:
                  id: evt_5c1d90a7-3f2b-4a86-b0e7-8d41c6f2a915
                  object: event
                  api_version: '2026-01-01'
                  created: 1733600000
                  livemode: false
                  type: charge.succeeded
                  data:
                    object:
                      transactionId: TXN-8ac4796a-1940-4c6a-ba54-f151606330e3
                      amount:
                        minorUnits: '2000'
                        currencyCode: USD
        '404':
          description: >-
            resource_missing — no such delivery for this merchant (tenancy-scoped), or an occurrence
            id was passed instead of a delivery id.
  /v1/test/subscriptions/{id}/bill:
    post:
      tags:
        - Subscriptions
      operationId: billTestSubscriptionNow
      summary: Run this subscription's renewal now (TEST keys only).
      description: |
        Stripe Test Clock analogue. Proxies to `BillingService/BillSubscriptionNow`,
        the same function the calendar renew worker uses. A LIVE key is refused with
        `test_mode_only` before 11 is touched. Idempotency-Key required. Scope
        `subscriptions:write`.

        CHARGE_AUTOMATICALLY (default): charges the saved card for the next period
        and writes a PAID invoice listed on `GET /v1/invoices`. SEND_INVOICE:
        finalizes and emails an OPEN invoice, no card charge. A decline is
        `subscription.past_due`, not `invoice.payment_failed`.
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
          description: Subscription id (`sub_…` or bare uuid).
        - in: header
          name: Idempotency-Key
          description: UUIDv4. A replay returns the stored result instead of billing twice.
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Subscription after the renewal attempt
        '400':
          description: '`test_mode_only` on a LIVE key; `idempotency_key_required` without the header.'
        '403':
          description: The key lacks `subscriptions:write`.
  /v1/test/tokens:
    post:
      tags:
        - Config
      operationId: createTestTokens
      summary: Mint deterministic fake Evervault tokens (TEST mode only).
      description: |
        Publishable-key-safe. Rejects a LIVE key with `test_mode_only`. Returns fake `ev:` tokens
        for building test charges without the Evervault SDK. Never accepts or echoes a real PAN.

        ⚠️ A TEST charge reaches NMI's sandbox, which accepts **seven** card numbers and
        refuses everything else with `Bad Request. Please use a valid test card or test ACH
        account from the documentation.` The brand aliases below mint exactly those. Verified
        2026-09-06; the full table, including the AVS/CVV and decline triggers, is in
        [Testing](../developers/15-testing.md).
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              required: []
              description: >-
                TEST mode only. A live key is a 400 `test_mode_only`. Fields optional; omitted
                `number`/`card` mints the sandbox Visa 4111111111111111 (label `1111`). `card` is a
                brand alias (`visa`, `mastercard`, `mc`, `amex`, `discover`, `jcb`, `diners`,
                `maestro`).
              properties:
                number:
                  type: string
                  description: >-
                    Full test PAN, or a brand alias (`visa` / `mastercard` / `mc` / `amex` /
                    `discover` / `jcb` / `diners` / `maestro`). Last 4 digits become the token
                    label.
                card:
                  type: string
                  description: >-
                    Brand alias: visa, mastercard, mc, amex, discover, jcb, diners, maestro. Unknown
                    value is 400 parameter_invalid. ⚠️ visa, mastercard, mc and amex all mint label
                    1111 — the sandbox Visa, MasterCard and Amex end in the same four digits, and
                    the charge runs as Visa. Type the real number into the Payment Element to
                    exercise the other two brands.
                cvc:
                  type: string
                  description: Request field only. The response names it `cvv`, the way /charges reads it.
            example:
              number: '4111111111111111'
              cvc: '123'
      responses:
        '201':
          description: >-
            test_token. The `card` object is shaped to go STRAIGHT into `POST /v1/charges` or the
            Element confirm — `cvv` is named the way both of them read it, so there is nothing to
            rename in between.


            **No expiry here, and that is not an omission.** A test token stands in for the card
            SECRET, not the card, so the expiry is yours to supply. Any future date works; the
            platform's own default is month `12` of the current year + 2.
          content:
            application/json:
              example:
                object: test_token
                card:
                  number: ev:test_token_pan_1111
                  cvv: ev:test_token_cvc_xxx
                  last4: '1111'
        '400':
          description: test_mode_only — a LIVE key was used.
  /v1/check:
    post:
      tags:
        - Fraud
      operationId: createFraudCheck
      summary: Pre-checkout fraud evaluation (transaction-independent, advisory).
      description: |
        Evaluate a buyer BEFORE a card is tokenized — no transaction_id / card token required.
        The buyer `email` is HMAC-hashed at the edge (raw PII never reaches Fraud-Scoring) and
        `ip` falls back to the trusted client IP. Proxies to `ScoringService/CheckStandalone`.

        After the inline Cascade check and before velocity, an active device/IP bypass
        link to a currently blocked customer returns `blocked`/`deny`, risk score 99,
        and the fixed reason `BLOCKED_BYPASS_LINK`. Reasons never include a customer
        or anchor identifier. Cascade sync-block gates both link axes; device-enabled
        additionally gates device links. The inline-IP switch does not gate IP links.
        Missing/NULL device or sync-block settings default on; stored false disables
        the matching protection. Earlier allowlist and fraud-disabled decisions win.

        ADVISORY ONLY: the real `POST /v1/charges` runs its own fraud pass server-side, so a
        developer who skips or ignores /check can never bypass fraud enforcement. FAIL-OPEN —
        an unavailable Fraud-Scoring RPC degrades to `clean`/`allow`. Within the engine,
        a cascade-config read error keeps the armed defaults, so a link can still deny;
        a link-probe error skips only that probe.

        When `EMAIL_HASH_KEY` is unset on TEST, the edge still calls CheckStandalone with an
        empty `emailHash` so IP / device / BIN / velocity still score. The verdict is 04's;
        `reasons` also contains `fraud_hash_unavailable` so this is not Radar-on-by-default.
        LIVE without the hash key is 400 `fraud_hash_unavailable`.

        Requires scope `check:read` and an API key whose exec-mode is `ATOMIC` or `FRAUD_ONLY`
        (a `PAYMENT_ONLY` key is rejected with `mode_not_allowed`). The mode is read from the
        KEY, never the request body.

        **`risk_decision_token`** — a signed receipt for this evaluation, present when token
        signing is configured. Echo it on `POST /v1/charges` to record which decision a charge
        followed. It is EVIDENCE, not authorization: the charge runs the full fraud engine
        either way, and a token can only ever cause a charge to be REJECTED (see below), never
        to skip screening.

        The token binds this merchant, the verdict, and whichever of `amount`+`currency`,
        `card_bin`+`last4` and `fingerprint` you supplied here. Fields you omit are not bound,
        and the matching check is skipped at charge time rather than silently assumed. Send
        `last4` alongside `card_bin` if you want the card binding enforced. Valid for 15
        minutes, single use.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - email
              description: >-
                Advisory and transaction-independent. Calling it never moves money. Missing `email`
                is a 400 `email_required`.
              properties:
                email:
                  type: string
                ip:
                  type: string
                  description: The buyer IP. Omit it and your egress IP is scored instead.
                fingerprint:
                  type: string
                card_bin:
                  type: string
                bin:
                  type: string
                last4:
                  type: string
                  description: >-
                    Not scored. With the bin it binds the verdict to one card, so a token cannot be
                    replayed onto another.
                billing_country:
                  type: string
                amount:
                  type: integer
                  description: Minor units. A verdict is bound to the amount it was issued for.
                currency:
                  type: string
                transaction_type:
                  type: string
            example:
              email: buyer@example.com
              ip: 203.0.113.9
              fingerprint: fp_9a1c2b
              card_bin: '424242'
              last4: '4242'
              billing_country: US
              amount: 4999
              currency: usd
              transaction_type: PURCHASE
      responses:
        '201':
          description: Fraud check verdict (advisory).
          content:
            application/json:
              examples:
                clean:
                  value:
                    object: fraud_check
                    status: clean
                    action: allow
                    requires_3ds: false
                    reasons: []
                    risk_score: 0
                    check_id: 2a5aed3a-bca4-4c4c-a5f9-cd6ff64703bf
                    risk_decision_token: cmR0MXxtYV8xfGNoa19hYmN8YWxsb3d8NDk5OXx1c2Q.9f8e7d…
                blocked:
                  value:
                    object: fraud_check
                    status: blocked
                    action: deny
                    requires_3ds: false
                    reasons:
                      - BLOCKED_EMAIL
                    risk_score: 99
                    check_id: 7daaf05a-3864-4901-8dbe-d7eff9b96aa3
                    risk_decision_token: cmR0MXxtYV8xfGNoa194eXp8ZGVueXw0OTk5fHVzZA.1a2b3c…
                hash_unavailable_test:
                  summary: TEST without EMAIL_HASH_KEY — IP/BIN still scored; email axis named.
                  value:
                    object: fraud_check
                    status: blocked
                    action: deny
                    requires_3ds: false
                    reasons:
                      - BLOCKED_IP
                      - fraud_hash_unavailable
                    risk_score: 99
                    check_id: 9c1e0b2a-4d7f-4a11-8c3e-2b8f1a0d5e77
        '400':
          description: >-
            `email_required` when `email` is missing. LIVE without `EMAIL_HASH_KEY` is
            `fraud_hash_unavailable` (`invalid_request_error`). TEST without the key is 201, not
            400.
        '401':
          description: api_key_required — missing / invalid key.
        '403':
          description: permission_denied (scope) or mode_not_allowed (PAYMENT_ONLY key).
  /v1/check/recheck:
    post:
      tags:
        - Fraud
      operationId: recheckFraud
      summary: Re-evaluate with the final amount (same stateless CheckStandalone).
      description: |
        Identical to `POST /v1/check`; call it once the buyer confirms the final amount. Same
        scope (`check:read`) and exec-mode gate. No state is kept between /check and /recheck.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - email
              description: >-
                Same body as POST /v1/check, re-run with the final amount. Missing `email` is a 400
                `email_required`.
              properties:
                email:
                  type: string
                ip:
                  type: string
                fingerprint:
                  type: string
                card_bin:
                  type: string
                bin:
                  type: string
                last4:
                  type: string
                billing_country:
                  type: string
                amount:
                  type: integer
                  description: The final amount, after shipping and tax.
                currency:
                  type: string
                transaction_type:
                  type: string
            example:
              email: buyer@example.com
              amount: 5200
              currency: usd
      responses:
        '201':
          description: Fraud check verdict (advisory) — see POST /v1/check.
          content:
            application/json:
              example:
                object: fraud_check
                status: flagged
                action: review
                requires_3ds: true
                reasons:
                  - CARD_VELOCITY_1M
                risk_score: 60
                check_id: b099ecb2-db7f-490b-9b07-a0f49c75c9f2
        '400':
          description: email_required.
        '403':
          description: permission_denied / mode_not_allowed.
  /v1/report:
    post:
      tags:
        - Fraud
      operationId: reportBlocklistDecision
      summary: Report a BLOCK / UNBLOCK to the merchant blocklist (server-only).
      description: |
        Push a fraud decision to the merchant's own blocklist. `action` ∈
        `BLOCK | UNBLOCK | BLOCK_IP | UNBLOCK_IP`. Email actions require `email` (hashed at the
        edge); IP actions require `ip`. Proxies to `ScoringService/AddToBlocklist` or
        `RemoveFromBlocklist`.

        Requires scope `report:write`, which is server-only — a publishable key can never call
        it. No exec-mode gate (this is neither the charge nor the fraud-eval flow).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - action
              description: >-
                The email actions need `email`, the IP actions need `ip`. Sending the wrong pair is
                a 400.
              properties:
                action:
                  type: string
                  enum:
                    - BLOCK
                    - UNBLOCK
                    - BLOCK_IP
                    - UNBLOCK_IP
                  description: >-
                    Matched case-insensitively, so `block` works; these are the canonical spellings.
                    Anything else is a 400 `invalid_action`.
                email:
                  type: string
                  description: Required for BLOCK / UNBLOCK. Hashed at the edge; the list returns hashes only.
                ip:
                  type: string
                  description: Required for BLOCK_IP / UNBLOCK_IP.
                reason:
                  type: string
            examples:
              block_email:
                value:
                  action: BLOCK
                  email: fraud@evil.com
                  reason: chargeback
              unblock_ip:
                value:
                  action: UNBLOCK_IP
                  ip: 198.51.100.7
      responses:
        '201':
          description: Report applied.
          content:
            application/json:
              example:
                object: fraud_report
                status: ok
                action: blocked
                type: email
        '400':
          description: invalid_action / email_required / ip_required.
        '403':
          description: permission_denied — a publishable key or missing report:write.
  /v1/blocklist:
    get:
      tags:
        - Fraud
      operationId: listBlocklistEntries
      summary: List blocklist entries for local SDK cache sync (hashed emails only).
      description: |
        Paginated blocklist for a developer's offline cache. Email entries expose only the
        HMAC hash — plaintext is never returned. IP values are returned as stored.
        Proxies to `ScoringService/ListMerchantBlocklist` (tenant-scoped). The operator
        `ListBlocklist` RPC is admin-only and is not this path.
        Requires scope `blocklist:read` (publishable keys are denied).
      parameters:
        - in: query
          name: type
          schema:
            type: string
          description: Optional type filter (EMAIL | IP | CARD_BIN | DEVICE).
        - in: query
          name: cursor
          description: >-
            Opaque page cursor from the previous response. This endpoint pages with `cursor` and
            `limit`, not `starting_after`, and its `limit` defaults to 100 with a ceiling of 500.
          schema:
            type: string
        - in: query
          name: limit
          schema:
            type: integer
          description: Page size, clamped 1..500 (default 100).
      responses:
        '200':
          description: Paginated blocklist.
          content:
            application/json:
              example:
                object: list
                data:
                  - object: blocklist_entry
                    id: a46d7680-037e-4983-9ca2-af0e04deeb29
                    type: email
                    value: 9f2c...hashed...e1
                    reason: chargeback
                    expires_at: null
                    created_at: '2026-07-19T10:59:58.290055Z'
                    is_global: false
                  - object: blocklist_entry
                    id: b7e1c2d0-11aa-4f00-9c44-0a1b2c3d4e5f
                    type: ip
                    value: 198.51.100.7
                    reason: probe
                    expires_at: null
                    created_at: '2026-08-31T12:00:00.000Z'
                    is_global: false
                has_more: false
        '403':
          description: >-
            permission_denied — missing blocklist:read. A key that has the scope is a merchant
            principal and must 200.
  /v1/customers/{id}/three_ds:
    parameters:
      - in: path
        name: id
        description: 'The customer id: `cus_<uuid>` as `GET /v1/customers` returns it, or the bare uuid.'
        required: true
        schema:
          type: string
    get:
      tags:
        - Fraud
      operationId: retrieveCustomerThreeDs
      summary: Read a customer's Require 3DS flag.
      description: |
        The same flag the dashboard shows under **Restrictions** on the customer's page and
        sets with **Require 3DS** / **Require 3D Secure**. While it is on, every card charge
        for this customer is stepped up to 3DS. `require_3ds` is true whether the flag was
        set by you, by the dashboard or by a fraud detector — it is the value the charge
        path reads. TEST and LIVE keep separate flags; the key decides which one you read.

        Answers only for this flag. A fraud rule or a Payment Workflow route can still ask
        for 3DS on a charge when this reads false — see `GET /v1/fraud/rules` and
        `GET /v1/payment_workflow/routes`.

        Requires `fraud:read` (publishable keys are denied).
      responses:
        '200':
          description: The current flag.
          content:
            application/json:
              example:
                object: customer_three_ds
                customer: cus_5f0c1f7e-8d2a-4c1b-9a3e-2b7d6c5e4f10
                require_3ds: true
                livemode: true
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks `fraud:read` (`permission_denied`).
        '404':
          description: '`resource_missing` — no such customer for this account and mode.'
    post:
      tags:
        - Fraud
      operationId: setCustomerThreeDs
      summary: Turn on Require 3DS for a customer.
      description: |
        Turns the customer's Require 3DS flag on, exactly as the dashboard's control does:
        from the next charge on, every card charge for this customer is stepped up to 3DS
        until the flag is cleared. Setting it again is harmless and answers the same state,
        so no `Idempotency-Key` is needed. The response is the flag read back after the
        write, not an echo of the request.

        The write is recorded against your API key (source `API`), so it can be told apart
        from a dashboard change. ACH, Apple Pay and Google Pay with a network token are
        never stepped up, whatever the flag says (the wallet's own cryptogram stands).

        Requires `fraud:write`, a server-side scope (publishable keys are refused).
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                reason:
                  type: string
                  maxLength: 500
                  description: >-
                    Why the flag was set, stored with it. Optional. Any other field is a 400
                    `parameter_unknown`.
            examples:
              with_reason:
                value:
                  reason: Chargeback on file
      responses:
        '200':
          description: The flag after the write.
          content:
            application/json:
              example:
                object: customer_three_ds
                customer: cus_5f0c1f7e-8d2a-4c1b-9a3e-2b7d6c5e4f10
                require_3ds: true
                livemode: true
        '400':
          description: >-
            `parameter_unknown` (a field other than `reason`) or `parameter_invalid` (`reason` not a
            string, or longer than 500).
        '403':
          description: A publishable key, or a key without `fraud:write` (`permission_denied`).
        '404':
          description: '`resource_missing` — no such customer for this account and mode. Nothing is written.'
    delete:
      tags:
        - Fraud
      operationId: clearCustomerThreeDs
      summary: Clear a customer's Require 3DS flag.
      description: |
        Clears the flag, as **Turn off** under **Restrictions** does in the dashboard —
        whoever set it, including a fraud detector. Clearing a flag that is not set answers
        the same cleared state.

        This lifts only the flag. It is not an override: a fraud rule or a Payment Workflow
        route that asks for 3DS still steps the charge up, because a platform rule always
        outranks a flag or a request. If `require_3ds` comes back true, something set it
        again between the write and the read.

        Requires `fraud:write`, a server-side scope (publishable keys are refused).
      responses:
        '200':
          description: The flag after the write.
          content:
            application/json:
              example:
                object: customer_three_ds
                customer: cus_5f0c1f7e-8d2a-4c1b-9a3e-2b7d6c5e4f10
                require_3ds: false
                livemode: true
        '403':
          description: A publishable key, or a key without `fraud:write` (`permission_denied`).
        '404':
          description: '`resource_missing` — no such customer for this account and mode. Nothing is written.'
  /v1/fraud/three_ds_policy:
    get:
      tags:
        - Fraud
      operationId: retrieveThreeDsPolicy
      summary: Read the risk-score 3DS step-up.
      description: |
        Our fraud engine gives every card charge a risk score from 0 to 100. While
        `risk_step_up.enabled` is true, a charge whose score falls between `min_score` and
        `max_score` (inclusive; 70 and 95 unless you changed them) is stepped up to 3DS,
        whatever your own rules decide. These are the values the next charge uses. TEST and
        LIVE keep separate settings; the key decides which one you read.

        Requires `fraud:read` (publishable keys are denied).
      responses:
        '200':
          description: The current setting.
          content:
            application/json:
              example:
                object: three_ds_policy
                risk_step_up:
                  enabled: true
                  min_score: 70
                  max_score: 95
                livemode: true
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks `fraud:read` (`permission_denied`).
    post:
      tags:
        - Fraud
      operationId: updateThreeDsPolicy
      summary: Turn the risk-score 3DS step-up off, on, or move its band.
      description: |
        If you decide 3DS yourself with `three_ds.request`, send `risk_step_up.enabled:
        false` so your rules are the only trigger. The change applies from the next charge.

        The body sets the whole band: a bound you leave out goes back to its default (70 or
        95), so the request alone says what applies. Sending the same body again is harmless
        and answers the same setting, so no `Idempotency-Key` is needed. The response is
        the setting read back after the write.

        Turning it off lifts only the score band. A charge is still stepped up when a fraud
        rule asks for it, the customer is marked Require 3DS, or the route has Attempt 3DS
        on; and a score above your decline threshold still declines.

        Requires `fraud:write`, a server-side scope (publishable keys are refused).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required:
                - risk_step_up
              properties:
                risk_step_up:
                  type: object
                  additionalProperties: false
                  required:
                    - enabled
                  properties:
                    enabled:
                      type: boolean
                      description: Step a charge up to 3DS when its risk score is inside the band.
                    min_score:
                      type: integer
                      minimum: 0
                      maximum: 100
                      description: Lowest score in the band. Left out, 70.
                    max_score:
                      type: integer
                      minimum: 0
                      maximum: 100
                      description: Highest score in the band. Left out, 95. Must not be below `min_score`.
            examples:
              turn_off:
                value:
                  risk_step_up:
                    enabled: false
              narrow_band:
                value:
                  risk_step_up:
                    enabled: true
                    min_score: 85
                    max_score: 95
      responses:
        '200':
          description: The setting after the write.
          content:
            application/json:
              example:
                object: three_ds_policy
                risk_step_up:
                  enabled: false
                  min_score: 70
                  max_score: 95
                livemode: true
        '400':
          description: >-
            `parameter_unknown` (any other field) or `parameter_invalid`: `risk_step_up` missing,
            `enabled` not a boolean, a score that is not a whole number from 0 to 100, or
            `min_score` above `max_score` (the default counts for a bound you leave out). Nothing is
            written.
        '403':
          description: A publishable key, or a key without `fraud:write` (`permission_denied`).
  /v1/fraud/rules:
    get:
      tags:
        - Fraud
      operationId: listFraudRules
      summary: List the fraud rules that apply to your account (read-only).
      description: |
        The rules on the dashboard's Fraud Rules page, in evaluation order (`priority`
        ascending). Each carries its `conditions` (the `field` / `operator` / `value`
        triples the engine evaluates, joined by `logical_operator`), its `action` and
        whether it is `enabled`. When several rules match one charge the strongest action
        wins: DECLINE, then REVIEW, then STEP_UP_3DS, then ROUTE, then APPROVE.

        `requires_3ds` is true for a rule whose action is `STEP_UP_3DS`. `global: true`
        marks a platform rule you cannot edit; it applies to every account. Every Payment
        Workflow route keeps one rule in step with the canvas — `route` names it, and
        `null` means a rule you authored.

        **Read-only.** Creating, editing or disabling rules is not available from the API:
        it is a large security surface (a leaked key could switch your fraud rules off)
        and stays in the dashboard. Requires `fraud:read` (publishable keys are denied).
      parameters:
        - in: query
          name: cursor
          description: >-
            Opaque page cursor from the previous response's `next_cursor`. This endpoint pages with
            `cursor`, not `starting_after`.
          schema:
            type: string
        - in: query
          name: limit
          description: Page size, clamped 1..100 (default 10).
          schema:
            type: integer
      responses:
        '200':
          description: One page of rules. `next_cursor` is omitted on the last page.
          content:
            application/json:
              example:
                object: list
                data:
                  - object: fraud_rule
                    id: 0f8b6c2e-3a7d-4e1f-9b2c-5d6e7f8a9b0c
                    name: High risk score
                    description: ''
                    logical_operator: AND
                    created_at: '2026-10-01T09:00:00Z'
                    updated_at: '2026-10-02T11:30:00Z'
                    conditions:
                      - field: risk_score
                        operator: gte
                        value: 80
                    action: STEP_UP_3DS
                    requires_3ds: true
                    route: null
                    score: 0
                    priority: 10
                    enabled: true
                    global: false
                has_more: false
        '400':
          description: A `cursor` this endpoint did not issue.
        '403':
          description: The key lacks `fraud:read` (`permission_denied`).
  /v1/payment_workflow/routes:
    get:
      tags:
        - Fraud
      operationId: listPaymentWorkflowRoutes
      summary: List your Payment Workflow routes (read-only).
      description: |
        The routes drawn on the dashboard's Payment Workflow canvas, in the order they are
        evaluated (`order`, from 1). The catch-all "All other conditions" branch is the
        route with `catch_all: true` and is always last. Each route carries its
        `conditions`, its outcome (`action`: `route` through `processor` and then
        `fallback_processors`, or `decline`) and its gates.

        `require_3ds` is the route's 3DS toggle (labelled **Attempt 3DS** on the canvas):
        when the winning route has it on, the charge is stepped up. `require_cvv` /
        `require_avs` decline a charge whose CVV or AVS check fails. `min_amount` /
        `max_amount` are integer cents; 0 means no bound. Routes have no on/off switch;
        every route returned is saved in the workflow. The whole list comes back in one
        response.

        **Read-only.** Editing routes from the API is not available: routing decides
        where money goes, and opening it to API keys needs a separate decision. Requires
        `fraud:read` (publishable keys are denied).
      responses:
        '200':
          description: Every route, in evaluation order.
          content:
            application/json:
              example:
                object: list
                data:
                  - object: payment_workflow_route
                    key: high_value
                    name: High value
                    catch_all: false
                    conditions:
                      - field: amount
                        operator: gte
                        value: 50000
                    action: route
                    processor: nmi_primary
                    fallback_processors:
                      - nmi_backup
                    require_3ds: true
                    require_cvv: true
                    require_avs: false
                    min_amount: 0
                    max_amount: 0
                    order: 1
                  - object: payment_workflow_route
                    key: default
                    name: default
                    catch_all: true
                    conditions: []
                    action: route
                    processor: nmi_primary
                    fallback_processors: []
                    require_3ds: false
                    require_cvv: false
                    require_avs: false
                    min_amount: 0
                    max_amount: 0
                    order: 2
                has_more: false
        '403':
          description: The key lacks `fraud:read` (`permission_denied`).
  /v1/3ds/config:
    get:
      tags:
        - 3D Secure
      operationId: get3dsConfig
      summary: Evervault SDK init data + 3DS readiness for the key's environment.
      description: >-
        Returns the 3DS provider client config (team/app id, provider) and an `enabled` flag.
        `enabled` is true when the platform's 3DS provider is configured for the key's environment
        (test or live); it is a per-environment platform flag, with no per-merchant or acquirer
        check. Test mode is configured. When `enabled` is false, `POST /v1/3ds/sessions` and `POST
        /v1/3ds/retry-decline` return 409 `three_ds_not_enabled` — they do not call the 3DS
        provider. Surface the flag to the SDK before you try to open a session. Scope
        `threeds:read`; exec-mode `atomic | payment_only`.

        **Wallet Pay.** The same response carries `apple_pay_enabled`, `google_pay_enabled` and
        `evervault_merchant_id`. A wallet is on only when the platform flag, the per-merchant toggle
        **and** a provisioned merchant id all agree; otherwise the flags are `false` and the id is
        empty.

        Treat these as **fail-closed**: mount a wallet button only on a literal `true`. An older
        response that omits the fields must mount nothing rather than assume availability. These
        flags say what the *account* has enabled — whether the buyer's device can actually pay is
        decided at mount time by the SDK.
      security:
        - ApiKey: []
        - BearerKey: []
      responses:
        '200':
          description: 3DS client config + wallet enablement.
          content:
            application/json:
              example:
                object: three_ds_config
                team_id: team_...
                app_id: app_...
                provider: evervault
                enabled: true
                apple_pay_enabled: false
                google_pay_enabled: false
                evervault_merchant_id: ''
        '401':
          description: api_key_required / api_key_invalid — missing or bad key.
        '403':
          description: >-
            permission_denied (missing `threeds:read`) or mode_not_allowed (a `fraud_only` key
            cannot access the 3DS surface).
  /v1/3ds/sessions:
    post:
      tags:
        - 3D Secure
      operationId: create3dsSession
      summary: Open a 3D Secure session for the authenticated merchant.
      description: >-
        Creates a 3DS session against the merchant's acquirer. When `GET /v1/3ds/config` reports
        `enabled: false`, this route returns **409** `three_ds_not_enabled` and does not call the
        3DS provider. The billed merchant + env are derived from the API-key **principal**, never
        the request body — a `merchant_account_id` in the body is ignored, so a developer can never
        open (and bill) a session against another merchant's acquirer. The card must be an Evervault
        `ev:` token (or omitted for a browser-SDK-supplied card); a raw PAN is rejected. Idempotent
        on the `Idempotency-Key` header — a replay returns the same session. Scope `threeds:create`
        (server-side); exec-mode `atomic | payment_only`.
      security:
        - ApiKey: []
        - BearerKey: []
      parameters:
        - in: header
          name: Idempotency-Key
          required: true
          schema:
            type: string
          description: Replay-safe key; a duplicate returns the same session.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - amount
              properties:
                amount:
                  type: integer
                  description: Minor units. Missing is a 400.
                currency:
                  type: string
                  description: ISO 4217.
                card:
                  type: object
                  properties:
                    number:
                      type: string
                      description: An `ev:` token only - a raw PAN is refused.
                    exp_month:
                      description: String or number.
                    exp_year:
                      description: String or number.
                    bin:
                      type: string
                    last4:
                      type: string
                customer:
                  type: object
                  properties:
                    email:
                      type: string
                    ref:
                      type: string
                request:
                  type: string
                  enum:
                    - automatic
                    - any
                    - challenge
                    - challenge_mandated
                  default: automatic
                  description: >-
                    The challenge preference the issuer is sent — the vocabulary of
                    `three_ds.request` on a charge: `any` (EMV 01, no preference), `challenge` (03,
                    challenge requested), `challenge_mandated` (04). `automatic` / absent sends no
                    preference, as before; the issuer decides.
                idempotency_key:
                  type: string
            example:
              amount: 5000
              currency: usd
              card:
                number: ev:RFVC1234567890123456
                exp_month: '12'
                exp_year: '2030'
                bin: '411111'
                last4: '1111'
              customer:
                email: buyer@example.com
              request: challenge
      responses:
        '201':
          description: Session opened (status `pending` until the challenge completes).
          content:
            application/json:
              example:
                object: three_ds_session
                id: ev3ds_569113638
                status: pending
                enrolled: true
                three_ds_version: 2.2.0
        '400':
          description: >-
            parameter_invalid — amount missing, unsettleable currency, a raw-PAN card.number (must
            be an `ev:` token), or an unknown `request` (`param: request`).
        '401':
          description: api_key_required / api_key_invalid.
        '403':
          description: >-
            permission_denied (missing `threeds:create`, e.g. a publishable key) or mode_not_allowed
            (a `fraud_only` key).
        '409':
          description: >-
            three_ds_not_enabled — GET /v1/3ds/config reports `enabled: false` for this merchant. No
            provider session is opened.
  /v1/3ds/sessions/{id}:
    get:
      tags:
        - 3D Secure
      operationId: retrieve3dsSession
      summary: Retrieve the authoritative 3D Secure result.
      description: >-
        Returns the **authoritative** authentication result for a session — `cavv`, `eci`,
        `ds_trans_id`, `cardholder_auth`, `liability_shift` — fetched from the 3DS provider, NOT any
        browser-submitted value. This response is the sole proof of authentication; pass its session
        id to `POST /v1/charges` under `three_ds.session_id` to bind it to a charge. Tenancy-scoped:
        a session id that isn't this key's merchant 404s. Scope `threeds:read`; exec-mode `atomic |
        payment_only`.

        `status` is `pending`, `authenticated` or `failed`. A charge proceeds only on
        `authenticated: true` with a `cavv`; any other session is refused by `POST /v1/charges`. A
        failed session names why in `failure_reason`, empty otherwise: `authentication_failed` (the
        cardholder did not pass), `canceled` (closed the challenge), `abandoned` (the challenge
        timed out), `rejected` (the issuer refused to authenticate), `card_not_enrolled` (the card
        cannot be authenticated: not enrolled, unknown to the issuer, or no supported 3DS version),
        `challenge_required` (a challenge was needed and the session was opened not to show one) or
        `processing_error` (a 3DS server, directory or issuer-ACS fault).
      security:
        - ApiKey: []
        - BearerKey: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
          description: The 3DS session id returned by create.
      responses:
        '200':
          description: Authoritative session result.
          content:
            application/json:
              examples:
                authenticated:
                  summary: Authenticated — bind it to a charge
                  value:
                    object: three_ds_session
                    id: ev3ds_sessionA
                    status: authenticated
                    authenticated: true
                    cardholder_auth: verified
                    eci: '05'
                    cavv: AAABBWFlmQAAAABjRWWZEEFgFz+=
                    ds_trans_id: f38e6948-5388-41a6-bca4-b49723c19437
                    three_ds_version: 2.2.0
                    xid: ''
                    liability_shift: true
                    failure_reason: ''
                failed:
                  summary: Failed — a charge with this session is refused
                  value:
                    object: three_ds_session
                    id: ev3ds_sessionB
                    status: failed
                    authenticated: false
                    cardholder_auth: ''
                    eci: ''
                    cavv: ''
                    ds_trans_id: 0b9f6a52-1c4e-4f7d-9a3b-6e2d8c1f4a70
                    three_ds_version: 2.2.0
                    xid: ''
                    liability_shift: false
                    failure_reason: card_not_enrolled
        '401':
          description: api_key_required / api_key_invalid.
        '403':
          description: permission_denied (missing `threeds:read`) or mode_not_allowed (`fraud_only`).
        '404':
          description: resource_missing — no such session for this merchant (tenancy-scoped).
  /v1/3ds/sessions/{id}/status:
    get:
      tags:
        - 3D Secure
      operationId: retrieve3dsSessionStatus
      summary: Poll 3D Secure liveness from the browser (publishable-safe).
      description: >-
        The **browser** view of a session: is the challenge still running, did it finish, did it
        fail. Scope `none`, so a publishable key can read it — which the authoritative `GET
        /v1/3ds/sessions/{id}` deliberately cannot be, since that one returns `cavv` and a `pk_`
        ships in your page source.


        Returns liveness only: `status`, `authenticated`, and `cryptogram_present` — a **boolean**,
        never the cryptogram — plus `failure_reason` on a failed session (the same values as the
        authoritative route), so a page can tell the buyer something true. The authentication value
        stays server-side; `POST /v1/charges` fetches it by session id when you pass
        `three_ds.session_id`.


        This is what the JS SDK's `handle3DS()` polls. Reach for it directly only if you drive the
        challenge yourself from the browser. From a server, use the authoritative endpoint instead —
        it carries the proof.


        Tenancy-scoped exactly like the authoritative route: a session id that isn't this key's
        merchant 404s. Exec-mode `atomic | payment_only`.
      security:
        - ApiKey: []
        - BearerKey: []
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
          description: The 3DS session id returned by create.
      responses:
        '200':
          description: Session liveness. No authentication value.
          content:
            application/json:
              example:
                object: three_ds_session
                id: ev3ds_sessionA
                status: authenticated
                authenticated: true
                cryptogram_present: true
                failure_reason: ''
        '401':
          description: api_key_required / api_key_invalid.
        '403':
          description: mode_not_allowed (a `fraud_only` key cannot touch the 3DS surface).
        '404':
          description: resource_missing — no such session for this merchant (tenancy-scoped).
  /v1/3ds/retry-decline:
    post:
      tags:
        - 3D Secure
      operationId: retryThreeDsOnDecline
      summary: Re-open a 3DS session after a soft gateway decline.
      description: >-
        A soft-decline recovery: re-opens a 3DS session for the original amount so a step-up can be
        attempted before giving up on the sale. The original charge is looked up first: an unknown
        `original_transaction_id` is **404** `resource_missing` and no provider session is opened.
        When the charge exists but `GET /v1/3ds/config` reports `enabled: false`, the route is
        **409** `three_ds_not_enabled`. Idempotent on the `Idempotency-Key` header (default derived
        from `original_transaction_id`) — a duplicate retry returns the same session and never
        double-bills the acquirer. Same principal-derived tenancy as create. Scope `threeds:create`;
        exec-mode `atomic | payment_only`.
      security:
        - ApiKey: []
        - BearerKey: []
      parameters:
        - in: header
          name: Idempotency-Key
          required: false
          schema:
            type: string
          description: Defaults to `original_transaction_id` when omitted.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - amount
                - original_transaction_id
              description: >-
                The create body plus the decline context. A repeat returns the SAME session rather
                than minting a second one. Unknown `original_transaction_id` is 404
                `resource_missing` before 3DS readiness is checked.
              properties:
                amount:
                  type: integer
                  description: Minor units, the original amount.
                currency:
                  type: string
                card:
                  type: object
                  properties:
                    number:
                      type: string
                      description: An `ev:` token only.
                    exp_month:
                      description: String or number.
                    exp_year:
                      description: String or number.
                    bin:
                      type: string
                    last4:
                      type: string
                customer:
                  type: object
                  properties:
                    email:
                      type: string
                    ref:
                      type: string
                original_transaction_id:
                  type: string
                  description: >-
                    The declined charge. Also accepted as the idempotency key when no header is
                    sent.
                decline_code:
                  type: string
                request:
                  type: string
                  enum:
                    - automatic
                    - any
                    - challenge
                    - challenge_mandated
                  default: automatic
                  description: The challenge preference, as on `POST /v1/3ds/sessions`.
                idempotency_key:
                  type: string
            example:
              amount: 5000
              currency: usd
              original_transaction_id: txn_9f2c
              decline_code: '05'
              card:
                number: ev:RFVC1234567890123456
                exp_month: '12'
                exp_year: '2030'
      responses:
        '201':
          description: Retry session opened.
          content:
            application/json:
              example:
                object: three_ds_session
                id: ev3ds_569144422
                status: pending
                enrolled: true
                three_ds_version: 2.2.0
                retryable: true
                original_transaction_id: txn_9f2c
                decline_code: '05'
        '400':
          description: parameter_invalid — amount/currency/card as for create.
        '401':
          description: api_key_required / api_key_invalid.
        '403':
          description: permission_denied (missing `threeds:create`) or mode_not_allowed (`fraud_only`).
        '404':
          description: >-
            resource_missing — no such `original_transaction_id` for this merchant and env-mode.
            Checked before 3DS readiness, so a fake TXN is 404 even when 3DS is disabled.
        '409':
          description: >-
            three_ds_not_enabled — the original charge exists but GET /v1/3ds/config reports
            `enabled: false`. No provider session is opened.
  /v1/stores/stats:
    get:
      tags:
        - Stores
      operationId: retrieveStoreStats
      summary: Aggregate storefront counters + revenue.
      description: |
        Roll-up across the merchant's storefronts: total/active/inactive stores, product count,
        order count, revenue. Proxies `CommerceCheckoutService/GetStoreStats`. Requires
        `stores:read`. Money in minor units. Declared before `/v1/stores/{id}` so "stats" is
        not read as a store id.
      responses:
        '200':
          description: store_stats
          content:
            application/json:
              example:
                object: store_stats
                total_stores: 3
                active_stores: 2
                inactive_stores: 1
                total_products: 47
                total_orders: 0
                total_revenue: 0
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `stores:read` scope (`permission_denied`).
  /v1/stores:
    get:
      tags:
        - Stores
      operationId: listStores
      summary: List storefronts.
      description: |
        The merchant's storefronts (the "markets" in product terms). Cursor-paged with
        `starting_after` (same shape as GET /v1/charges). `active` is an optional filter; an
        absent one lists both. Read-only, scoped to the key. Requires `stores:read`. Proxies
        `CommerceCheckoutService/ListStores`.
      parameters:
        - in: query
          name: limit
          description: Page size. Defaults to 10 and is capped at 100.
          schema:
            type: integer
            default: 10
            maximum: 100
        - in: query
          name: starting_after
          description: >-
            Cursor for the next page: the id of the last row you received. Omit it on the first
            request, and keep paging while `has_more` is true.
          schema:
            type: string
        - in: query
          name: active
          description: Send `true` for open storefronts only or `false` for closed ones. Omit for both.
          schema:
            type: boolean
      responses:
        '200':
          description: List
          content:
            application/json:
              example:
                object: list
                data:
                  - id: store_9x2
                    object: store
                    slug: acme-shop
                    name: Acme Shop
                    active: true
                    product_count: 12
                has_more: false
                next_cursor: null
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `stores:read` scope (`permission_denied`).
  /v1/stores/{id}:
    get:
      tags:
        - Stores
      operationId: retrieveStore
      summary: Fetch a storefront.
      description: |
        Proxies `CommerceCheckoutService/GetStore`. Scoped to the key's merchant. Requires
        `stores:read`.
      parameters:
        - in: path
          name: id
          description: The store id.
          required: true
          schema:
            type: string
      responses:
        '200':
          description: store
          content:
            application/json:
              example:
                id: store_9x2
                object: store
                slug: acme-shop
                name: Acme Shop
                description: Everything Acme
                active: true
                show_prices: true
                product_count: 12
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `stores:read` scope (`permission_denied`).
        '404':
          description: No such store for this key's merchant and env-mode (`resource_missing`).
  /v1/metrics:
    get:
      tags:
        - Metrics
      operationId: retrieveMetrics
      summary: Sales / KPI roll-up.
      description: |
        The same KPI tiles the dashboard shows: gross / approved / net volume, refunds,
        approval rate and transaction counts. Proxies `AnalyticsService/GetKpiTiles`. Requires
        `metrics:read`. `period` is today|7d|30d|90d|all (default 30d); a custom window is
        `start_date` / `end_date` (YYYY-MM-DD). Money is in minor units; `approval_rate` is a
        0..1 fraction and `approval_rate_bps` the same figure in basis points.
      parameters:
        - in: query
          name: period
          description: >-
            Preset window, defaulting to `30d`. Ignored when `start_date` and `end_date` are
            supplied.
          schema:
            type: string
            enum:
              - today
              - 7d
              - 30d
              - 90d
              - all
            default: 30d
        - in: query
          name: start_date
          description: Start of a custom window, `YYYY-MM-DD`. Use with `end_date` instead of `period`.
          schema:
            type: string
        - in: query
          name: end_date
          description: End of a custom window, `YYYY-MM-DD`. Use with `start_date` instead of `period`.
          schema:
            type: string
      responses:
        '200':
          description: metrics
          content:
            application/json:
              example:
                object: metrics
                gross_volume: 4820000
                approved_volume: 4510000
                net_volume: 4390000
                refunds: 120000
                approval_rate_bps: 9356
                approval_rate: 0.9356
                tx_count: 512
                approved_count: 479
                declined_count: 33
                refund_count: 8
                dispute_count: 2
        '401':
          description: Missing or invalid API key (`authentication_required`).
        '403':
          description: The key lacks the `metrics:read` scope (`permission_denied`).
components:
  securitySchemes:
    ApiKey:
      type: apiKey
      in: header
      name: x-api-key
      description: 'Merchant secret/publishable key, e.g. `x-api-key: sk_live_…`.'
    BearerKey:
      type: http
      scheme: bearer
      description: 'Same key sent as `Authorization: Bearer sk_live_…`.'
  schemas:
    Dispute:
      type: object
      description: |
        A formal dispute (inquiry or chargeback). Returned by `GET /v1/disputes` and
        `GET /v1/disputes/{id}`.

        This is the REST object. It is NOT the shape of the `dispute.*` webhook payload —
        the webhook carries the event as the disputes engine emits it, with camelCase keys
        and `amount` as an object. See docs/developers/16-webhooks.md.

        Money is an integer of minor units (cents), and `currency` is lowercase — the same
        convention as `charge`. Timestamps are unix seconds, and are `null` (not omitted)
        when the underlying column is empty. String fields are `""` when unknown, never
        absent, so a field access never throws.
      properties:
        id:
          type: string
          format: uuid
          description: This API's dispute id. Stable for the life of the dispute.
        object:
          type: string
          enum:
            - dispute
        transaction_id:
          type: string
          description: |
            The disputed sale's id in this API. Empty string when the dispute has not been
            matched to a sale yet — alert-sourced rows often arrive unmatched.
        nmi_transaction_id:
          type: string
          description: |
            The linked sale's gateway transaction id, projected from the matched sale (it is
            not a column on the dispute itself). Empty when unmatched.
        billing_name:
          type: string
          description: |
            Cardholder name on the linked sale. Projected from the matched sale, like
            `nmi_transaction_id` — not a column on the dispute. Empty when the dispute has no
            matched sale, or when the sale carries no name.
        billing_email:
          type: string
          description: |
            Billing email on the linked sale. Same projection and the same empty cases as
            `billing_name`. Populated for roughly 4.2M historical sales as of 2026-08-19; older
            or unmatched rows may still be empty, so treat it as best-effort rather than
            guaranteed.
        reason_message:
          type: string
          description: |
            The provider's own wording for the reason, alongside the normalised `reason` and
            `reason_code`. Populated on ~71% of disputes.
        card_bin:
          type: string
          description: First six digits of the PAN. Populated on effectively every dispute.
        alert_source:
          type: string
          description: |
            The pre-dispute network this dispute ORIGINATED from (`ETHOCA`, `RDR`, `VERIFI`);
            `""` for an ordinary acquirer chargeback. Distinct from `source_acquirer`, which is
            who reported it. Populated on ~52%.
        cardholder_name:
          type: string
          description: |
            Cardholder name as the PROVIDER sent it on the dispute — not the same field as
            `billing_name`, which comes from the linked sale. They disagree often enough to be
            worth carrying both. Populated on ~15%.
        transaction_date:
          type: integer
          format: int64
          nullable: true
          description: |
            Unix seconds. When the underlying sale happened — lets you age a dispute without
            fetching the charge. Populated on ~48%.
        respond_by:
          type: integer
          format: int64
          nullable: true
          description: |
            Unix seconds. The provider's stated evidence deadline. `due_date` is ours; this is
            theirs. Populated on ~59%.
        closed_at:
          type: integer
          format: int64
          nullable: true
          description: Unix seconds. When the dispute reached a terminal state. Populated on ~35%.
        status:
          type: string
          description: |
            Lifecycle state. `BUYER_REVIEW`, `APPEALABLE` and `BANK_REVIEW` are deprecated and
            never emitted; they are kept in the enum for wire compatibility only (WRT-152). A
            dispute that is with the issuing bank is `UNDER_REVIEW`.

            `RELATED_CASE`: a PayArc related case (a reversal, resolution record,
            pre-arbitration, credit or retrieval fulfilment). It is not a dispute of its own:
            the dispute named by `parent_dispute_id` carries the family's state and outcome.
          enum:
            - NEEDS_RESPONSE
            - NEEDS_MANUAL_MATCH
            - UNDER_REVIEW
            - WON
            - LOST
            - ACCEPTED
            - EXPIRED
            - PROCESSING
            - BUYER_REVIEW
            - BANK_REVIEW
            - APPEALABLE
            - STALE
            - CLOSED
            - RELATED_CASE
        parent_dispute_id:
          type: string
          description: |
            Set only when `status` is `RELATED_CASE`: the dispute that carries the family's
            state, so an id you already hold leads to it. `""` for every other dispute.
        stage:
          type: string
          description: |
            How far the dispute has escalated. `UNCLASSIFIED` means the provider's case type
            was not recognised as one of the known liable families; treat it as non-liable
            until a clearer value arrives.
          enum:
            - INQUIRY
            - CHARGEBACK
            - PRE_ARBITRATION
            - ARBITRATION
            - UNCLASSIFIED
        outcome:
          type: string
          description: Terminal result once decided; empty string while still open.
        reason:
          type: string
          description: Normalised reason family, e.g. `FRAUDULENT`.
        reason_code:
          type: string
          description: >-
            The card network's raw reason code, e.g. `10.4`. Empty when the provider did not send
            one.
        amount:
          type: integer
          format: int64
          description: Disputed amount in minor units (cents). `13550` is $135.50.
        currency:
          type: string
          description: Lowercase ISO-4217, e.g. `usd`.
        source_acquirer:
          type: string
          description: >-
            Which acquirer reported it — `Payarc`, `Maverick Payments`, `NMI`, `Nuvei`. Empty if
            unknown.
        processor_dispute_id:
          type: string
          description: |
            The processor's own id for this case. Unique per merchant and env, not globally —
            accepted by `GET /v1/disputes/{id}`.
        kount_alert_id:
          type: string
          description: |
            The Ethoca/CDRN request id when this dispute originated from a pre-dispute alert.
            Accepted by `GET /v1/disputes/{id}`, and joins to `pre_dispute_alert.id`.
        card_brand:
          type: string
          description: Lowercase brand, e.g. `visa`. Empty when unknown.
        card_last4:
          type: string
          description: Last four PAN digits, digits only. Empty when unknown.
        dispute_date:
          type: integer
          format: int64
          nullable: true
          description: Unix seconds. When the network raised the dispute.
        due_date:
          type: integer
          format: int64
          nullable: true
          description: Unix seconds. Evidence deadline; `null` when the provider set none.
        created_at:
          type: integer
          format: int64
          nullable: true
          description: Unix seconds. When this row was first created here.
        updated_at:
          type: integer
          format: int64
          nullable: true
          description: |
            Unix seconds. The polling high-water mark — keep the largest value you have seen
            and feed it back as `updated_since`. It moves on matcher sweeps and ingest, not
            only on the changes that emit a webhook.
      required:
        - id
        - object
        - status
        - stage
        - amount
        - currency
        - updated_at
      example:
        id: 25758130-bb0d-46bd-b65d-b22bd8bf7ccb
        object: dispute
        transaction_id: 9f1c6a2e-77d1-4f0a-9a3e-2b6c4d8e1a55
        nmi_transaction_id: '12437218480'
        billing_name: Ada Lovelace
        billing_email: ada@example.com
        status: NEEDS_RESPONSE
        parent_dispute_id: ''
        stage: INQUIRY
        outcome: ''
        reason: FRAUDULENT
        reason_code: '10.4'
        amount: 13550
        currency: usd
        source_acquirer: Payarc
        processor_dispute_id: cb_9f2210
        kount_alert_id: f34a64b7-b8ac-4d4c-9245-efb5437d7261
        card_brand: visa
        card_last4: '4242'
        dispute_date: 1787052000
        due_date: 1787656800
        created_at: 1787052300
        updated_at: 1787066400
    PaymentIntentCardSave:
      type: object
      description: |
        Whether a payment intent's charge saved the buyer's card, as the platform recorded it on
        the transaction. Present only on a `succeeded` intent whose confirm sent
        `save_payment_method: true`; absent (never `null`) otherwise, and absent when the read
        behind it failed or did not answer within 5 seconds. The payment's outcome never
        depends on it.

        - `pending` — the save may still be running, or its outcome is unknown. **It can stay
          pending**: no later read of the intent moves it. A save that completes later sends
          `payment_method.attached` only for a newly saved card, `payment_method.updated` when
          it renewed a card the customer already had (same card, new expiry), and no event when
          that card was already saved with the same expiry. Settle a lasting `pending` on your
          server with `GET /v1/customers/{id}/payment_methods` (secret key, `customers:read`);
          never by waiting for `payment_method.attached`.
        - `saved` — the card is saved for the customer (newly, renewed, or already saved).
          `payment_method` is its `pm_…` id.
        - `skipped` — saving was not attempted; `failure_code` says why.
        - `failed` — saving was attempted and failed; the payment stands.

        `payment_method` is the WorthyPayments payment-method id, never a gateway vault id. It
        is chargeable only with your secret key or that buyer's own customer session. A browser
        confirm takes it as `payment_method`, and takes the bare id the customer session lists
        too.
      required:
        - status
        - payment_method
        - failure_code
        - failure_message
      properties:
        status:
          type: string
          enum:
            - pending
            - saved
            - skipped
            - failed
        payment_method:
          type:
            - string
            - 'null'
          description: '`pm_…` id of the saved card; set only when `status` is `saved`, else null.'
        failure_code:
          type:
            - string
            - 'null'
          description: >-
            Set only when `status` is `skipped` or `failed`, else null. Skipped: `customer_missing`
            (the payment had no customer), `card_not_saveable` (the instrument is not a fresh card:
            a wallet, ACH or an already saved card), `payment_not_approved` (the charge was not
            approved). Failed: the vault's reason (`VAULT_ADD_REFUSED`, `CARD_FACTS_UNAVAILABLE`, …)
            or `VAULT_SERVICE_UNAVAILABLE`.
        failure_message:
          type:
            - string
            - 'null'
          description: The gateway's own words when it gave some, else null.
      example:
        status: saved
        payment_method: pm_6b1d2c3e-4f5a-4b6c-8d7e-9f0a1b2c3d4e
        failure_code: null
        failure_message: null

