# Paysio API Reference

## Base URL

All API endpoints are relative to the base URL for your environment:

    https://paysio.com/api/v1

Example: To list products, call GET https://paysio.com/api/v1/products

Use your API keys in the Authorization header. Live keys (sk_live_*, pk_live_*) are for production. Test/sandbox keys (sk_test_*, pk_test_*) can be used against the same base URL for testing.

For the client-side Paysio.js SDK, include the script from https://paysio.com/paysio.js. See the Paysio.js section below.

---

## Authentication

All API requests require an API key sent as a Bearer token in the Authorization header.

    Authorization: Bearer sk_live_your_api_key

### API Key Types

| Prefix    | Type        | Mode       | Use for                                    |
|-----------|-------------|------------|--------------------------------------------|
| sk_live_  | Secret      | Production | Server-side. Full access to all endpoints. |
| sk_test_  | Secret      | Sandbox    | Server-side testing. Uses sandbox data.    |
| pk_live_  | Publishable | Production | Client-side. Read-only access.             |
| pk_test_  | Publishable | Sandbox    | Client-side testing. Sandbox read-only.    |

Secret keys (sk_*) are required for write operations. Never expose secret keys in client-side code.

## Pagination

List endpoints use cursor-based pagination.

| Parameter      | Type    | Description                              |
|----------------|---------|------------------------------------------|
| limit          | integer | Number of items to return (1-100, default 10) |
| starting_after | string  | Cursor — ID of the last item from the previous page |

Response format:
    { "data": [...], "has_more": true }

## Errors

| Code | Meaning                                        |
|------|------------------------------------------------|
| 400  | Bad request — invalid parameters               |
| 401  | Unauthorized — missing or invalid API key       |
| 402  | Payment failed — charge was declined            |
| 403  | Forbidden — secret key required for this endpoint |
| 404  | Not found — resource doesn't exist              |
| 409  | Conflict — resource already exists              |

Error response format:
    { "error": "Customer not found" }

---

## Paysio.js (Client-Side SDK)

Paysio.js is a client-side JavaScript SDK for collecting payments. It handles card tokenization securely — card data never touches your backend.

### How it works

1. Your page loads paysio.js and creates a Paysio instance with your **publishable key** (pk_test_... or pk_live_...).
2. Mount card input fields using paysio.mountCardInputs() — renders secure, PCI-compliant card fields.
3. When the user submits, call paysio.createToken() to tokenize the card data and get a token.
4. Send that token to YOUR server, which calls the Paysio API with your **secret key** (sk_*) to vault or charge.

### Prerequisites

- A Paysio account with payment gateway credentials configured.
- A **publishable API key** (pk_test_... or pk_live_...) — create one in Settings > API Keys.
- A **secret API key** (sk_test_... or sk_live_...) for your backend.
- Your page **must be served over HTTPS** in production. Use cloudflared tunnel for local HTTPS testing.

### Environments

### Step 1 — Include the script

    <script src="https://paysio.com/paysio.js"></script>

This exposes a global Paysio() function.

### Step 2 — Initialize

    const paysio = Paysio('pk_live_your_publishable_key');

    // Custom domain or proxy (override auto-detected base)
    const paysio = Paysio('pk_test_...', {
      apiBase: 'https://your-domain.com/api/v1'
    });

The apiBase option is only needed if you're using a custom domain or proxying API calls through your own backend. Otherwise, the SDK handles it automatically.

NEVER use your secret key (sk_*) in client-side code. The publishable key can only tokenize cards.

### Step 3 — Mount card inputs and tokenize (recommended)

Use mountCardInputs() to render secure card fields. This works with all payment processors (Stripe, NMI, Paysio Debit & Payouts). The fields render inside secure iframes on the Paysio origin (Stripe Elements for Stripe, Paysio Hosted Fields for NMI and Debit & Payouts), so card data never enters your page's DOM — you stay in the lightest PCI scope (SAQ A). The mounted fields ALWAYS include number, expiry, and CVC on every processor — never render your own CVC field. On Debit & Payouts workspaces createToken() returns a single-use ptok_ token (valid 15 minutes) that carries the CVC internally; wallets (Apple Pay / Google Pay) are not available there.

    <form id="payment-form">
      <div id="card-fields"></div>
      <button type="submit">Pay $29.99</button>
    </form>

    <script>
      const paysio = Paysio('pk_test_YOUR_KEY');

      // Mount secure card input fields
      await paysio.mountCardInputs('#card-fields', {
        onReady: () => console.log('Card fields ready'),
        onChange: ({ complete, error }) => {
          document.querySelector('button').disabled = !complete;
        },
      });

      document.getElementById('payment-form').addEventListener('submit', async (e) => {
        e.preventDefault();

        try {
          const { token, card } = await paysio.createToken();

          console.log('Token:', token);
          console.log('Card:', card.brand, card.last4);

          // Send token to YOUR server
          await fetch('/api/checkout', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ token }),
          });
        } catch (err) {
          console.error(err.message);
        }
      });
    </script>

You can customize the appearance (e.g. dark mode) by passing a style option:

    await paysio.mountCardInputs('#card-fields', {
      style: {
        backgroundColor: '#1a1a1a',
        textColor: '#ffffff',
        placeholderColor: '#666666',
        borderColor: '#333333',
        focusBorderColor: '#3b82f6',
        iconColor: '#666666',
        fontSize: '14px',
      },
    });

For full theming control on NMI and Debit & Payouts hosted fields, use the appearance option (every part of the inputs is themeable, including custom fonts):

    await paysio.mountCardInputs('#card-fields', {
      appearance: {
        variables: {
          fontFamily: '"Inter", sans-serif', fontSize: '15px', fontWeight: '450',
          colorText: '#f4f4f5', colorTextPlaceholder: '#71717a',
          colorBackground: '#18181b', colorBorder: '#3f3f46',
          colorBorderFocus: '#6366f1', colorBorderInvalid: '#ef4444',
          colorTextInvalid: '#ef4444', colorIcon: '#71717a',
          caretColor: '#6366f1', selectionColor: 'rgba(99,102,241,0.3)',
          borderRadius: '12px', borderWidth: '1px',
          inputHeight: '40px', inputPaddingX: '12px',
          boxShadow: 'none', boxShadowFocus: '0 0 0 3px rgba(99,102,241,0.25)',
          rowGap: '8px',       // > 0 renders 3 separate boxes; 0 = fused group (default)
          showLockIcon: false, // hide the CVC lock icon
        },
        rules: { '.input::placeholder': { fontStyle: 'italic' } },
        fonts: [{ cssSrc: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;450;500' }],
      },
      placeholders: { number: 'Card number', expiry: 'MM/YY', cvc: 'Security code' },
    });

Because the fields live inside an iframe, page CSS does not cascade into them — load custom fonts via appearance.fonts (https only). The simple style keys map onto the same variables and can be mixed with appearance (appearance wins).

You can also mount each field into separate containers for custom layouts:

    await paysio.mountCardInputs({
      cardNumber: '#card-number',
      cardExpiry: '#card-expiry',
      cardCvc: '#card-cvc',
    });

### Step 3 (alternative) — Raw card data (NMI and Debit & Payouts)

If your payment processor is NMI or Paysio Debit & Payouts, you can optionally build your own card form and pass raw card data. This does NOT work with Stripe — use mountCardInputs() instead.

    const { token, card } = await paysio.createToken({
      number: '4111111111111111',
      exp_month: '12',
      exp_year: '29',
      cvv: '123',
    });

### Step 4 — Vault the token on your server

On your backend, use the token with your secret key:

    POST /api/v1/customers/{customer_id}/payment-methods
    Authorization: Bearer sk_live_your_secret_key
    Content-Type: application/json

    { "payment_token": "ptok_abc123..." }

Response includes:
- data.billing_id — reference for this specific card
- data.customer_vault_id — the customer's vault ID

### Step 5 — Charge the saved card

    POST /api/v1/charges
    Authorization: Bearer sk_live_your_secret_key
    Content-Type: application/json

    { "customer_id": "customer-uuid", "amount": 2999, "currency": "USD" }

### Backend proxy setup

If your frontend proxies Paysio API calls through your own backend, proxy **all** /api/v1/* requests to Paysio. All SDK calls (tokenization, 3DS, tokens) use the same /api/v1/ base URL. Initialize the SDK with:

    const paysio = Paysio('pk_test_...', { apiBase: '/api/v1' });

Your backend proxy should forward all /api/v1/* requests to https://paysio.com/api/v1/* with your secret key as the Authorization header. Make sure to preserve query parameters (needed for 3DS poll requests).

### React integration

When using Paysio.js in React, use useRef for the Paysio instance and event handlers to avoid stale closures. The walletPayment event handler is registered once but your form state changes — use a ref to always access the latest state:

    const processRef = useRef(null);
    const processPayment = useCallback(async (token) => { ... }, [email, ...]);
    processRef.current = processPayment;  // keep ref in sync

    // In useEffect:
    elements.on('walletPayment', (data) => processRef.current(data.token));

See the complete React example in the developer docs at /developers.

### API Reference

| Method | Description |
|--------|-------------|
| Paysio(key, options?) | Create instance. key = publishable key. options.apiBase overrides API URL. |
| paysio.mountCardInputs(target, opts?) | Mount secure iframe card fields. target = selector, element, or { cardNumber, cardExpiry, cardCvc }. opts: onReady, onChange, style, appearance ({ variables, rules, fonts }), placeholders. Returns Promise<{ unmount, focus, clear, updateStyle }>. |
| paysio.createToken(cardData?) | Tokenize from mounted card fields (no arguments), or pass raw card data { number, exp_month, exp_year, cvv } (NMI / Debit & Payouts only). Returns Promise<{ token, card }>. |
| paysio.elements() | Returns an Elements instance for mounting wallet buttons (Apple Pay / Google Pay). |
| paysio.threeDS() | Returns a new ThreeDS instance for 3D Secure card verification (standalone). |
| elements.mountWallets(target, opts) | Renders Apple Pay / Google Pay buttons. opts: { amount, currency, country, collectShipping } |
| elements.updateAmount(amount) | Updates the amount displayed in wallet payment sheets. |
| elements.on(event, fn) | Listen for 'ready' (wallets loaded), 'error', or 'walletPayment' events. |
| elements.unmount() | Removes wallet buttons and cleans up. |
| threeDS.authenticate(opts) | Initiates 3DS verification. Pass { amount, token } (ptok_ from createToken(), not consumed) or { amount, cardNumber, cardExp }. Returns Promise with { status, eci, authenticationValue, dsTransId }. |
| threeDS.cancel() | Cancels in-progress 3DS authentication and removes iframes. |
| threeDS.reset() | Resets to idle state for a new authentication attempt. |
| threeDS.getStatus() | Returns current status: idle, authenticating, fingerprinting, challenging, success, failure. |

### Test card numbers

| Card | Number | Expiry | CVV |
|------|--------|--------|-----|
| Visa (success) | 4111 1111 1111 1111 | 12/29 | 123 |
| Mastercard (success) | 5431 1111 1111 1111 | 12/29 | 123 |
| Visa (decline) | 4111 1111 1111 1129 | 12/29 | 123 |

Use these with a pk_test_... key. Any future expiry date and any 3-digit CVV will work.

### Test bank account (ACH — Debit & Payouts)

For testing ACH / bank_account flows on Paysio Debit & Payouts, use this example routing number with account type `bank_account`. The account number can be anything (4-17 digits).

| Field | Value |
|-------|-------|
| Routing number (ABA) | 021000021 |
| Account number | any 4-17 digits |
| Account type | bank_account |

### Troubleshooting

**401 — "Invalid or missing API key"**
- Verify your API key is correct and hasn't been revoked in Settings > API Keys.
- Test with curl:

    curl -H "Authorization: Bearer pk_live_YOUR_KEY" https://paysio.com/api/v1/tokenization-key

**CORS errors**
- The SDK auto-detects the API base from the script tag URL. No apiBase needed unless using a proxy.
- All /api/v1/* endpoints support CORS from any origin.
- If behind a custom proxy/domain, set apiBase to your proxy URL.

**"Paysio is not defined"**
- Ensure the script tag loads before your code. Place it in <head> or before your scripts.
- In React/Vue, access window.Paysio inside useEffect/onMounted.

---

## Apple Pay & Google Pay (Wallets)

Paysio.js supports Apple Pay and Google Pay wallet buttons. When enabled for your workspace (Settings > Gateway), wallet buttons render alongside your card fields.

### Prerequisites

- Apple Pay and/or Google Pay must be enabled in your workspace gateway settings.
- Apple Pay requires HTTPS and a verified domain. **It will not work on localhost** — Paysio.js automatically skips Apple Pay on localhost/HTTP to prevent errors.
- Google Pay works on Chrome and supported browsers, including localhost for testing.
- Wallet buttons only appear when the customer's device/browser supports them.

### Mount wallet buttons

    const paysio = Paysio('pk_live_your_key');
    const elements = paysio.elements();

    // Mount wallet buttons
    elements.mountWallets('#wallet-buttons', {
      amount: 29.99,         // Amount in dollars
      currency: 'USD',
      country: 'US',
      collectShipping: true, // Optional: request shipping address
    });

    // Listen for wallet payments
    elements.on('walletPayment', (data) => {
      console.log('Wallet type:', data.walletType); // 'apple_pay' or 'google_pay'
      console.log('Token:', data.token);

      // Send token to your server
      fetch('/your-server/process-payment', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ token: data.token, wallet_type: data.walletType }),
      });
    });

    // Update amount dynamically (e.g. after quantity change)
    elements.updateAmount(59.98);

### Important: Wallet payments do NOT include customer email

The walletPayment event only provides { token, walletType }. Unlike card form payments where you collect the customer's email, wallet payments (Apple Pay / Google Pay) do NOT return the customer's email address.

**On your server, charge wallet tokens directly using POST /charges with payment_token — do NOT attempt to create a customer first** (POST /customers requires email and will fail). Example:

    // ✅ CORRECT — Charge wallet token directly
    POST /api/v1/charges
    Authorization: Bearer sk_live_your_secret_key
    { "payment_token": "ptok_wallet_abc...", "amount": 2999, "currency": "USD" }

    // ❌ WRONG — Do NOT create a customer for wallet payments (email is unavailable)
    POST /api/v1/customers
    { "email": ???, "payment_token": "ptok_wallet_abc..." }  // Will fail — no email from wallet

If you need to associate the charge with a customer, create the customer separately (e.g. from a signup form where you collect the email), then pass both customer_id and payment_token to POST /charges.

### HTML layout

    <!-- Wallet buttons (Apple Pay / Google Pay) -->
    <div id="wallet-buttons"></div>
    <div style="text-align:center;color:#94a3b8;margin:12px 0;">— or pay with card —</div>
    <!-- Your own card form -->
    <input type="text" id="card-number" placeholder="Card number" />
    <input type="text" id="card-exp" placeholder="MM / YY" />
    <input type="text" id="card-cvv" placeholder="CVV" />
    <button id="pay-btn">Pay $29.99</button>

### Styling tip: make wallet buttons look native

The Apple Pay / Google Pay buttons rendered by the gateway are hard to restyle — their look is locked down by Apple and Google. The trick we use on Paysio's own checkout is an **overlay**: draw your own good-looking button underneath, then mount the *real* wallet button on top, invisible and stretched to fill the box, so it captures the tap. The customer sees your button; the genuine wallet sheet still opens from a real user gesture on the real button.

    <!-- A positioned wrapper. Your skin sits underneath; Paysio mounts the
         real wallet button on top, transparent, and it captures the tap. -->
    <div class="wallet-wrap">
      <!-- 1) Your own button — purely visual, never clicked directly -->
      <div class="wallet-skin" aria-hidden="true">
        <svg class="wallet-logo" viewBox="0 0 24 24" fill="currentColor"><!-- logo --></svg>
        <span>Pay</span>
      </div>
      <!-- 2) Paysio mounts the real Apple Pay / Google Pay button here -->
      <div id="wallet-buttons"></div>
    </div>

    <style>
      .wallet-wrap { position: relative; height: 44px; border-radius: 8px; overflow: hidden; }

      /* Your styled button — fills the box, sits underneath, ignores clicks */
      .wallet-skin {
        position: absolute; inset: 0; z-index: 0;
        display: flex; align-items: center; justify-content: center; gap: 6px;
        background: #111; color: #fff;
        font: 500 16px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
        pointer-events: none;            /* clicks fall through to the real button */
      }
      .wallet-logo { width: 20px; height: 20px; }

      /* The REAL wallet button — on top, invisible, stretched to fill, hittable */
      #wallet-buttons {
        position: absolute; inset: 0; z-index: 1;
        opacity: 0.001;                  /* NOT 0 — some browsers won't tap a fully-transparent node */
        cursor: pointer;
      }
      /* Force the gateway's injected button (and any iframe) to cover the box */
      #wallet-buttons * { width: 100% !important; height: 100% !important; min-height: 0 !important; }
    </style>

Then mount as usual, and reveal your skin only once the real button has mounted:

    const paysio = Paysio('pk_live_your_key');
    const elements = paysio.elements();

    // Hide your skin until the real button has mounted, so the area is
    // never "dead" (looks clickable but isn't wired up yet).
    const skin = document.querySelector('.wallet-skin');
    skin.style.opacity = '0';
    elements.on('ready', () => { skin.style.opacity = '1'; });

    elements.mountWallets('#wallet-buttons', { amount: 29.99, currency: 'USD', country: 'US' });
    elements.on('walletPayment', (data) => {
      // send data.token + data.walletType to your server to charge it
    });

Gotchas that make wallet buttons "not work":

- **Let the real button capture the tap.** Put it on top (higher z-index) and do NOT add your own onclick to fake it — Apple Pay / Google Pay only open from a genuine gesture on their own button.
- **Use opacity: 0.001, not 0 / display:none / visibility:hidden.** A fully-hidden element won't receive the tap on some browsers.
- **Stretch the injected node to fill the box** (the `#wallet-buttons * { width/height:100% }` rule) so the whole visual area is tappable, not just a small button in a corner.
- **Reveal your skin on the `ready` event** so customers never tap a button before it's mounted.
- **One wallet shows per device** — Apple Pay on Safari/iOS, Google Pay on Chrome/Android — so a single skin behind `#wallet-buttons` lines up with the one button that renders.
- **Match your card/submit button's height and border-radius** on `.wallet-wrap` for a consistent look.

---

## 3D Secure (3DS)

3D Secure adds extra verification for card payments, reducing fraud and enabling liability shift. 3DS is a **standalone step**. Pass threeDS.authenticate() either the payment token from paysio.createToken() (recommended — works with the secure hosted card fields, so raw card data never touches your page) or the raw card number and expiry. Paysio.js provides a client-side threeDS API that handles the entire flow — authentication, device fingerprinting, and challenge iframes.

**Recommended order: tokenize first, then run 3DS with the token.**

    1. Mount the secure card fields with paysio.mountCardInputs()
    2. Tokenize with paysio.createToken() → get a ptok_ payment token
    3. Run 3DS with threeDS.authenticate({ amount, token }) → get 3DS result (the token is NOT consumed by 3DS)
    4. Send both the token AND the 3DS result to your server to create a charge

Only Paysio tokens (ptok_...) work with the token option — Stripe workspaces don't need this API because Stripe runs 3DS itself during the charge. If you collect card data in your own form instead of the hosted fields, pass cardNumber + cardExp directly (run 3DS before tokenizing in that case).

### How 3DS works

1. Your page calls threeDS.authenticate() with the amount and a payment token (or raw card details).
2. Paysio contacts the card issuer's 3DS server to verify the cardholder.
3. **Frictionless flow:** If approved silently, you get a success result immediately.
4. **Challenge flow:** If interaction is required, an iframe renders in your specified container for the cardholder to complete verification (OTP, biometric, etc).
5. The Promise resolves with 3DS data (eci, authenticationValue, dsTransId) to pass to the charge endpoint.

### Client-side 3DS with Paysio.js

    const paysio = Paysio('pk_live_your_key');
    await paysio.mountCardInputs('#card-fields');

    // Step 1: Tokenize the card from the secure hosted fields
    const { token } = await paysio.createToken(); // "ptok_..."

    // Step 2: Run 3DS with the token — no raw card data on your page.
    // The token is NOT consumed; you still charge it afterward.
    const threeDS = paysio.threeDS();
    let threeDsResult = null;
    try {
      threeDsResult = await threeDS.authenticate({
        amount: 29.99,
        token: token,
        email: '[email protected]',
        name: 'John Doe',
        billing: {
          firstName: 'John', lastName: 'Doe',
          addressLine1: '123 Main St', city: 'New York',
          state: 'NY', postalCode: '10001', country: 'US',
        },
        iframeTarget: '#threeds-container',
      });

      console.log('3DS Status:', threeDsResult.status); // 'Y' or 'A' = success
      console.log('ECI:', threeDsResult.eci);
      console.log('CAVV:', threeDsResult.authenticationValue);
    } catch (err) {
      console.error('3DS failed:', err.message);
      // You may still proceed without 3DS (no liability shift)
    }

    // Step 3: Send token + 3DS result to your server
    await fetch('/your-server/charge', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ token, threeDsResult }),
    });

Using your own card form instead? Pass cardNumber: '4111111111111111' and cardExp: '1225' (MMYY) in place of token — everything else is identical.

### 3DS result fields

| Field | Description |
|-------|-------------|
| status | Y = fully authenticated, A = attempted, N = denied, U = unavailable, R = rejected |
| eci | Electronic Commerce Indicator — pass to payment gateway for liability shift |
| authenticationValue | CAVV/AAV — cryptographic proof of authentication |
| dsTransId | Directory Server Transaction ID |
| threeDSServerTransID | 3DS Server Transaction ID |

---

## Products

Products are not mode-dependent — they exist across both live and test modes.

### GET /products
List all active (non-archived) products.
Auth: Any API key
Query: limit (integer), starting_after (string)

### GET /products/:id
Retrieve a single product by ID.
Auth: Any API key

### POST /products
Create a new product.
Auth: Secret key (sk_*)
Body:
  - name (string, required) — Product name
  - description (string) — Product description
  - images (string[]) — Array of image URLs
  - pricingType (string) — "one_time" (default), "recurring", or "open_ended"
  - price (integer) — Price in cents (for one_time / recurring)
  - currency (string) — 3-letter currency code (default "USD")
  - billingPeriod (string) — For recurring: "daily", "weekly", "monthly", "every_3_months", "every_6_months", "yearly"
  - defaultPrice (integer) — For open_ended: default price in cents
  - minPrice (integer) — For open_ended: minimum price in cents
  - maxPrice (integer) — For open_ended: maximum price in cents
  - trackInventory (boolean) — Enable inventory tracking (default false)
  - stockQuantity (integer) — Stock count (when trackInventory is true)
  - collectTax (boolean) — Enable tax collection (default false)
  - taxType (string) — "fixed" or "percentage"
  - taxValue (number) — Tax amount (cents if fixed, percentage if percentage)
  - tags (string[]) — Product tags
  - category (string) — Product category
  - options (array) — Product options, e.g. [{ "name": "Size", "values": ["S", "M", "L"] }]

Response (201):
    { "data": { ...product } }

### PATCH /products/:id
Update an existing product. Only included fields are updated.
Auth: Secret key (sk_*)
Body: Same fields as POST (all optional), plus:
  - archived (boolean) — Archive or unarchive the product

Response:
    { "data": { ...product } }

---

## Customers

Customers are sandbox-aware — test keys only see sandbox customers. You can store contact, billing, and shipping details.

### GET /customers
List customers. Automatically filtered by sandbox/live based on API key mode.
Auth: Any API key
Query: limit (integer), starting_after (string), processor (string — filter by gateway: "nmi", "stripe", or "aptpay"; use processor=aptpay to list customers that can receive payouts)

Each customer includes a processorType field ("nmi", "stripe", or "aptpay") identifying which gateway their saved payment methods live on. Only "aptpay" (Paysio Debit & Payouts) customers can be payout recipients via saved payment methods.

### GET /customers/:id
Retrieve a single customer by ID.
Auth: Any API key

### POST /customers
Create or update a customer with optional card vaulting in a single call. If a customer with the same email already exists, their info is updated and the response includes "existing": true (no 409 error — safe to call repeatedly).
Auth: Secret key (sk_*)
Body:
  Contact:
  - email (string, required) — Customer email address
  - first_name (string) — First name
  - last_name (string) — Last name
  - phone (string) — Phone number
  Billing address:
  - billing_address_1 (string) — Street address
  - billing_city (string) — City
  - billing_state (string) — State/province
  - billing_postal_code (string) — ZIP/postal code
  - billing_country (string) — Country (2-letter ISO code, e.g. "US")
  Shipping address:
  - shipping_first_name (string) — Recipient first name
  - shipping_last_name (string) — Recipient last name
  - shipping_address_1 (string) — Street address
  - shipping_city (string) — City
  - shipping_state (string) — State/province
  - shipping_postal_code (string) — ZIP/postal code
  - shipping_country (string) — Country (2-letter ISO code)
  Payment method (optional — vault a card at creation):
  - payment_token (string) — Token from paysio.createToken()
  - cc_number (string) — Card number (use payment_token instead when possible)
  - cc_exp_month (string) — Expiry month (01-12), required with cc_number
  - cc_exp_year (string) — Expiry year (e.g. 2028), required with cc_number

Response (with payment):
    { "data": { ... customer fields ... }, "payment_method": { "billing_id": "...", "customer_vault_id": "..." } }

Response (without payment):
    { "data": { ... customer fields ... }, "payment_method": null }

409 Response:
    { "error": "Customer with this email already exists", "existing_id": "uuid" }

### PATCH /customers/:id
Update an existing customer's details. Only included fields are updated.
Auth: Secret key (sk_*)
Body: Same fields as POST (except email), all optional.

---

## Payment Methods

Manage saved payment methods on a customer. Vault a card, then charge it later via POST /charges.

### POST /customers/:id/payment-methods
Save a payment method to a customer. If the customer has no vault yet, one is created automatically.
Auth: Secret key (sk_*)
Body (provide ONE of these options):

Option A — Paysio.js token (from paysio.createToken()):
  - payment_token (string) — Token from paysio.createToken()

Option B — Raw card details (server-to-server only):
  - cc_number (string) — Card number
  - cc_exp_month (string) — Expiry month (01-12)
  - cc_exp_year (string) — Expiry year (e.g. 2028)

Option C — Bank account (ACH — Paysio Debit & Payouts or NMI workspaces):
  - type (string) — Set to "bank_account" ("card" is the default)
  - routing_number (string) — 9-digit ABA routing number
  - account_number (string) — 4-17 digit account number
  - account_type (string) — "checking" (default) or "saving" (optional; only used by NMI)
  - account_holder_type (string) — "personal" (default) or "business" (optional; NMI eCheck only, ignored by Paysio Debit & Payouts)
  - description (string) — Optional label for this bank account

Response (card):
    { "data": { "billing_id": "1234567890", "customer_vault_id": "abc-vault-id" } }

Response (bank account):
    { "data": { "id": "12345", "billing_id": "12345", "type": "bank_account", "bank_last_four": "6789", "routing_number": "021000021", "customer_vault_id": "identity-id" } }

NOTE: bank_account payment methods require a bank-capable processor — Paysio Debit & Payouts (AptPay) or NMI — and are routed by the workspace's bank processor automatically (400 if neither is configured). The request shape is identical on both gateways. Saved bank instruments can be charged via POST /charges with rail: "ach". Only Paysio Debit & Payouts instruments are eligible as POST /payouts destinations.

### GET /customers/:id/payment-methods
List all saved payment methods for a customer. Returns masked card details. On bank-capable workspaces (Paysio Debit & Payouts or NMI) the list can mix cards and bank accounts — bank instruments have "type": "bank_account" with a masked account number in bank_last_four.
Auth: Any API key

Each method includes:
  - processor (string) — The gateway the instrument is vaulted on ("nmi", "stripe", or "aptpay")
  - payout_eligible (boolean) — true only for Paysio Debit & Payouts (aptpay) instruments. Only payout_eligible methods can be used as POST /payouts destinations; Stripe pm_ ids and NMI billing ids will be rejected.

Response:
    {
      "data": [
        { "billing_id": "1234567890", "type": "card", "processor": "aptpay", "payout_eligible": true, "card_last_four": "1111", "bank_last_four": null, "card_type": "visa", "cc_exp": "1228" },
        { "billing_id": "12345", "type": "bank_account", "processor": "aptpay", "payout_eligible": true, "card_last_four": null, "bank_last_four": "6789", "card_type": null, "cc_exp": null }
      ]
    }

### DELETE /customers/:id/payment-methods/:billingId
Remove a saved payment method from a customer's vault.
Auth: Secret key (sk_*)

---

## Tokens

Tokenize raw card data. This endpoint validates the card with the payment gateway and returns a single-use token (valid for 15 minutes) that you pass as payment_token to vault or charge endpoints.

### POST /tokens
Tokenize raw card data. Validates the card with the payment gateway and returns a token.
Auth: Any API key (pk_* or sk_*)
Body:
  - number (string, required) — Full card number (spaces are stripped)
  - exp_month (string, required) — Expiry month (01-12)
  - exp_year (string, required) — Expiry year (2 or 4 digits, e.g. "29" or "2029")
  - cvv (string) — CVV/CVC code

Response:
    { "data": { "token": "ptok_abc123...", "card": { "last4": "1111", "brand": "visa", "exp_month": "12", "exp_year": "29" } } }

Error (card invalid):
    { "error": "DECLINE CVV2/CID FAIL" }

NOTE: Tokens are single-use and expire after 15 minutes. Use paysio.createToken() on the client side instead of calling this endpoint directly.

Paysio Debit & Payouts notes:
  - cvv is optional at tokenization for payout destinations (they never need it). For charges, include cvv in the token — the token then carries the CVC and the charge needs no cvc param.
  - The resulting ptok_ token works everywhere: POST /charges, POST /customers/:id/payment-methods, POST /payouts, and POST /account-checks.

---

## Checkout Sessions

Create hosted checkout pages programmatically.

### POST /checkout-sessions
Create a new checkout session. Returns a URL your customer can visit to pay.
Auth: Secret key (sk_*)
Body:
  - line_items (array, required) — Array of line items. Each item can be:
    - { product_id, quantity? } — reference an existing product
    - { name, amount, currency?, quantity? } — create an ad-hoc product inline (amount in cents)
  - success_url (string) — URL to redirect to after successful payment
  - discount_code (string) — Apply a discount code to the checkout (optional)
  - metadata (object) — Arbitrary key-value metadata. Stored on the checkout session and propagated to the resulting transaction and subscription records. Included in the payment.completed webhook payload. Useful for tracking external references (e.g., workspace IDs, plan names, order refs).

Response:
    {
      "data": {
        "id": "uuid",
        "slug": "api-a1b2c3d4e5f6...",
        "url": "https://paysio.com/pay/api-a1b2c3d4e5f6...",
        "success_url": "https://yoursite.com/success",
        "metadata": { "order_ref": "12345" },
        "sandbox": true,
        "status": "open",
        "created_at": "2026-01-15T10:30:00.000Z"
      }
    }

### GET /checkout-sessions/:id
Get the status of a checkout session.
Auth: Any API key

---

## Transactions

View, void, and refund transactions. Transactions are sandbox-aware and support metadata filtering.

### GET /transactions
List transactions, sorted by most recent.
Auth: Any API key
Query:
  - limit (integer) — Max results per page (default: 20)
  - starting_after (string) — Cursor for pagination
  - subscription_id (string) — Filter by subscription ID
  - customer_id (string) — Filter by customer ID
  - metadata[key] (string) — Filter by metadata key-value pairs. You can pass multiple metadata filters.

Examples:
  - `GET /transactions?customer_id=cus_abc123` — All transactions for a customer
  - `GET /transactions?metadata[workspace_id]=ws_abc123` — All transactions with matching metadata
  - `GET /transactions?metadata[paysio_billing]=true&metadata[workspace_id]=ws_abc` — Multiple metadata filters

### GET /transactions/:id
Retrieve a single transaction. Includes full metadata object.
Auth: Any API key

### POST /transactions/:id/void
Void a pending transaction. Only works on transactions with status 'pending_settlement'.
Auth: Secret key (sk_*)

### POST /transactions/:id/refund
Refund a settled transaction. Supports partial refunds.
Auth: Secret key (sk_*)
Body:
  - amount (integer) — Amount to refund in cents. Omit for full refund.

NOTE (Paysio Debit & Payouts): refunds are executed as reverse payouts back to the original instrument — the response includes a refund_payout object and payment.refunded fires immediately. Requires the original charge to have a saved instrument; otherwise returns 400.

---

## Charges

Charge a customer's saved payment method (card vault) or a one-time payment token.

### POST /charges
Charge a saved card or a one-time token. Returns 402 if the charge is declined.
Auth: Secret key (sk_*)
Body (provide customer_id, payment_token, or both):
  Payment method (at least one required):
  - customer_id (string) — The customer ID to charge (uses their saved vault)
  - payment_token (string) — A one-time token from paysio.createToken() or a Collect.js/wallet token (Google Pay, Apple Pay)

  Amount (choose ONE mode — they are mutually exclusive):
  - amount (integer) — Amount in cents (e.g. 2999 for $29.99). Required if product_id and line_items are not provided.
  - product_id (string) — Charge based on an existing product's price. Only one_time products.
  - line_items (array) — Array of items to charge. Each item: { product_id, quantity? } or { name, amount, quantity? }.

  Seamless one-call charge (works identically on NMI, Stripe, and Debit & Payouts):
  - email (string) — Customer email. When passed without customer_id, Paysio finds or creates the customer and links the charge to it. This is what lets a token+email charge work on Debit & Payouts (which needs a customer identity) without a separate POST /customers call.
  - save_payment_method (boolean) — Vault the card to the customer in this same call, then charge the saved instrument. Does the processor-correct vault exactly once (a single-use token is never reused across two requests). Requires customer_id or email.
  - first_name, last_name, phone (string) — Used when creating the customer inline (with email).
  - billing_address_1, billing_city, billing_state, billing_postal_code, billing_country (string) — Billing address used when creating the customer inline. billing_postal_code helps Debit & Payouts resolve the ZIP for card rails. On Debit & Payouts, ALWAYS send a complete, valid US billing address (billing_country "US"): the processor runs strict address/AVS validation. A missing address can fail card payouts later; a mismatched address (e.g. a non-US postal code with billing_country "US") is rejected up front with "Request validation failed". Send the cardholder's real billing address — do not omit or guess it.

  Optional:
  - currency (string) — Currency code (default: USD)
  - description (string) — Description for this charge (only with amount mode)
  - quantity (integer) — Quantity when using product_id (default: 1)
  - wallet_type (string) — Set to "apple_pay" or "google_pay" when charging a wallet token
  - metadata (object) — Arbitrary key-value metadata to attach to the transaction. Stored on the transaction record and returned in GET responses. Filterable via GET /transactions?metadata[key]=value

  ACH bank debit (rail: "ach" — Paysio Debit & Payouts or NMI workspaces):
  ACH is routed by the workspace's bank processor automatically; the request shape is identical on both gateways (400 if no bank processor is configured).
  - rail (string) — "card" (default) or "ach". "ach" debits a bank account instead of a card.
  - payment_method_id (string) — Charge a specific saved instrument (card or bank account) instead of the customer's default.
  - routing_number (string) — 9-digit routing number for an inline ACH debit (with rail: "ach")
  - account_number (string) — 4-17 digit account number for an inline ACH debit (with rail: "ach")
  - account_type (string) — "checking" (default) or "saving" (with rail: "ach"; only used by NMI)
  - account_holder_type (string) — "personal" (default) or "business" (with rail: "ach"; NMI eCheck only, ignored by Paysio Debit & Payouts)

  Paysio Debit & Payouts only:
  - cvc (string) — Card security code. Required ONLY when charging a saved card (vault charge: customer_id without a fresh token). NEVER pass cvc with payment_token — the token already carries the CVC collected by the Paysio.js secure fields, and you must not render your own CVC field. Passed through to the network, never stored.
  - reference_id (string) — Optional idempotency key. A duplicate reference_id returns 409.

  3DS authentication (pass threeDS.authenticate() result directly):
  - three_ds (object) — Pass the SDK result directly: { status, eci, authenticationValue, dsTransId, threeDsVersion }

  Or use flat snake_case fields:
  - three_ds_status (string) — 3DS authentication status (e.g., "Y", "A", "U")
  - three_ds_eci (string) — Electronic Commerce Indicator
  - three_ds_cavv (string) — Cardholder Authentication Verification Value (SDK: authenticationValue)
  - three_ds_xid (string) — 3DS 1.x Transaction ID
  - three_ds_directory_server_id (string) — 3DS 2.x Directory Server Transaction ID (SDK: dsTransId)
  - three_ds_version (string) — 3DS protocol version (e.g., "2.2.0")
  - three_ds_cardholder_auth (string) — Cardholder auth value for NMI

Notes:
  - Switching gateways needs zero integration changes: the canonical seamless charge is { email, payment_token, amount, save_payment_method: true }. The same request works on NMI, Stripe, and Debit & Payouts — Paysio resolves/creates the customer, vaults the card on the correct processor, and charges it. No cvc field is needed: the token from paysio.createToken() already carries the CVC on every gateway. save_payment_method is idempotent — charging the same card again with it reuses the saved instrument instead of failing.
  - If only customer_id → charges their saved card (vault)
  - If only payment_token → charges the token directly (no customer needed)
  - If both → charges the token and links the transaction to the customer
  - Wallet tokens (Google Pay / Apple Pay) should use payment_token directly, and include wallet_type
  - amount, product_id, and line_items are mutually exclusive — use only one
  - 3DS data is passed to NMI for authenticated transactions and stored on the transaction record
  - ACH (rail: "ach") works on both Paysio Debit & Payouts and NMI workspaces using the same request fields. On NMI it runs as an eCheck; save_payment_method vaults the bank account to the customer and charges it, and a saved bank account can be charged later with { customer_id, payment_method_id, rail: "ach", amount }. ACH approvals are returned as "pending_settlement". Payouts remain Paysio Debit & Payouts only.

Paysio Debit & Payouts behavior (asynchronous settlement):
  - Charges return immediately with status "pending_settlement" — money movement is asynchronous. The terminal state arrives via merchant webhooks: payment.completed (settled), payment.failed, or payment.disputed.
  - Vault charges of a SAVED card require cvc (missing cvc → 400). Token charges (payment_token) need NO cvc — the ptok_ carries the CVC from the secure fields. Do not collect the CVC twice.
  - ACH debit from a saved bank instrument: { customer_id, payment_method_id, rail: "ach", amount }. No cvc needed.
  - ACH debit from inline bank details: { customer_id, rail: "ach", routing_number, account_number, account_type?, amount }.
  - Recipients must be US-based and charges are USD-only.
  - Duplicate reference_id → 409 (idempotency conflict).
  - Wallets (Apple Pay / Google Pay) and 3DS are not available on Debit & Payouts.

Response:
    {
      "data": {
        "id": "uuid",
        "amount": 2999,
        "currency": "USD",
        "status": "pending_settlement",
        "cardType": "visa",
        "cardLastFour": "4242",
        "nmiTransactionId": "123456789",
        "createdAt": "2026-01-15T10:30:00.000Z"
      }
    }

Response (Debit & Payouts — settles asynchronously via webhook):
    {
      "data": {
        "id": "uuid",
        "amount": 2999,
        "currency": "USD",
        "status": "pending_settlement",
        "rail": "card",
        "cardType": "mastercard",
        "cardLastFour": "5702",
        "aptpayTransactionId": "123456",
        "createdAt": "2026-01-15T10:30:00.000Z"
      }
    }

---

## Subscriptions

View and manage subscriptions. Subscriptions can be created through checkout sessions with recurring products, or directly via the API using a stored customer's payment method. Subscriptions support arbitrary metadata that is persisted and returned in all responses.

### POST /subscriptions
Create a subscription by charging a stored customer's payment method. The first payment is charged immediately via the customer's vaulted card.
Auth: Secret key (sk_*)

NOTE (Paysio Debit & Payouts): this endpoint returns 400 on Debit & Payouts workspaces — the processor requires the card security code for every charge and the API never stores it. Use hosted Checkout Sessions for recurring billing instead. GET/list, cancel, pause, and resume remain available for subscriptions created through hosted checkout.
Body:
  - customer_id (string, required) — ID of the customer with a stored payment method
  - product_id (string, required) — ID of a recurring product
  - start_date (string) — ISO 8601 date to schedule the subscription start in the future (optional). If provided and in the future, creates a scheduled subscription that activates automatically.
  - metadata (object) — Arbitrary key-value metadata to attach to the subscription (optional). Stored on the subscription record and returned in all GET responses and webhook payloads. Useful for tracking external references (e.g., workspace IDs, plan names, order refs).

Response (immediate):
  ```json
  {
    "data": {
      "id": "sub_uuid",
      "customer_id": "cus_uuid",
      "product_id": "prod_uuid",
      "status": "active",
      "amount": 2700,
      "currency": "USD",
      "billing_period": "monthly",
      "current_period_start": "2026-04-11T00:00:00.000Z",
      "current_period_end": "2026-05-11T00:00:00.000Z",
      "next_billing_date": "2026-05-11T00:00:00.000Z",
      "transaction_id": "tx_uuid",
      "metadata": { "plan": "basic", "workspace_id": "ws_abc123" }
    }
  }
  ```

Response (scheduled with start_date):
  ```json
  {
    "data": {
      "id": "sub_uuid",
      "customer_id": "cus_uuid",
      "product_id": "prod_uuid",
      "status": "scheduled",
      "amount": 2700,
      "currency": "USD",
      "billing_period": "monthly",
      "start_date": "2026-05-11T00:00:00.000Z",
      "current_period_start": "2026-05-11T00:00:00.000Z",
      "current_period_end": "2026-06-11T00:00:00.000Z",
      "next_billing_date": "2026-05-11T00:00:00.000Z",
      "metadata": { "plan": "basic", "workspace_id": "ws_abc123" }
    }
  }
  ```

Errors:
  - 402 Payment failed — the customer's stored payment method was declined
  - 404 Customer/product not found
  - 400 Product must be a recurring product

### GET /subscriptions
List subscriptions with customer and product details. Supports metadata filtering.
Auth: Any API key
Query:
  - limit (integer) — Max results per page (default: 20)
  - starting_after (string) — Cursor for pagination
  - metadata[key] (string) — Filter by metadata key-value pairs. You can pass multiple metadata filters.

Examples:
  - `GET /subscriptions?metadata[workspace_id]=ws_abc123` — All subscriptions with matching metadata
  - `GET /subscriptions?metadata[plan]=basic` — All subscriptions for a specific plan

### GET /subscriptions/:id
Retrieve a single subscription. Includes cancel_at_period_end field and metadata.
Auth: Any API key

### POST /subscriptions/:id/cancel
Cancel a subscription. By default, active subscriptions with remaining time are scheduled for cancellation at the end of the current billing period. The customer keeps access until then.
Auth: Secret key (sk_*)
Body:
  - reason (string) — Cancellation reason (optional)
  - at_period_end (boolean) — If true, schedule cancellation at period end. If false, cancel immediately. Default: true for active subscriptions with time remaining, false otherwise.
  - immediate (boolean) — Shorthand for at_period_end: false. If true, cancels immediately regardless of remaining time.

Response (immediate):
  ```json
  { "data": { "id": "uuid", "status": "cancelled" } }
  ```

Response (at_period_end):
  ```json
  { "data": { "id": "uuid", "status": "active", "cancel_at_period_end": true, "cancel_at": "2026-05-11T00:00:00.000Z" } }
  ```

### POST /subscriptions/:id/pause
Pause an active subscription. Billing is suspended until resumed.
Auth: Secret key (sk_*)

### POST /subscriptions/:id/resume
Resume a paused subscription. Billing dates are extended by the paused duration.
Auth: Secret key (sk_*)

---

## Payouts (Paysio Debit & Payouts)

Send money to a customer's debit card (push-to-card), bank account (ACH), or over real-time rails (RTP via TCH or FedNow). Available only on Paysio Debit & Payouts workspaces — all payout endpoints return 400 on other workspaces.

Amounts are fee-inclusive: `amount` is what leaves your balance; the recipient receives `amount - fee`. Payouts settle asynchronously — the create response has `"status": "pending"` and the terminal state arrives via the payout.paid / payout.failed webhooks. US recipients and USD only.

### POST /payouts
Create a payout to a customer.
Auth: Secret key (sk_*)
Body:
  - amount (integer, required) — Amount in cents that leaves your balance (fee-inclusive)
  - customer_id (string, required) — The recipient customer
  - Destination (exactly ONE required):
    - payment_method_id (string) — A saved card or bank instrument on the customer. MUST be a Paysio Debit & Payouts instrument: only methods with payout_eligible: true from GET /customers/:id/payment-methods work here. Stripe pm_ ids and NMI billing ids are rejected with 400 — instruments vaulted on other gateways cannot receive funds. Find eligible recipients with GET /customers?processor=aptpay.
    - payment_token (string) — A ptok_ card token from POST /tokens or paysio.createToken(). Works for any customer, regardless of which gateway their saved cards are on.
    - bank_account (object) — Inline bank destination: { routing_number (9 digits), account_number (4-17 digits), account_type? ("checking" | "saving") }. Also works for any customer.
  - currency (string) — Only "USD" (default: USD)
  - rail (string) — "card", "ach", or "rtp". Default: "card" for card destinations, "ach" for bank destinations. "rtp" requires a bank destination.
  - rtp_network (string) — "TCH" or "FedNow". Only valid with rail: "rtp".
  - descriptor (string) — Statement descriptor, max 10 characters
  - reference_id (string) — Optional idempotency key. A duplicate returns 409.
  - metadata (object) — Arbitrary key-value metadata, filterable on GET /payouts

Response:
    {
      "data": {
        "id": "uuid",
        "object": "payout",
        "payout_number": 17,
        "amount": 5000,
        "currency": "USD",
        "status": "pending",
        "rail": "card",
        "rtp_network": null,
        "card_last_four": "5702",
        "card_brand": "mastercard",
        "descriptor": "ACME PAY",
        "customer_id": "cus_uuid",
        "purpose": "payout",
        "refund_for_transaction_id": null,
        "reference_id": "payout-2026-001",
        "error_code": null,
        "error_message": null,
        "metadata": {},
        "livemode": false,
        "created_at": "2026-01-15T10:30:00.000Z",
        "updated_at": "2026-01-15T10:30:00.000Z"
      }
    }

Payout statuses:
  - pending — accepted and in flight (also covers network-acknowledged payouts)
  - paid — settled; funds delivered
  - failed — rejected by the network; error_code and error_message explain why
  - canceled — canceled by the network before settlement

Common errors:
  - 400 — Insufficient balance, missing recipient ZIP for card payouts, invalid destination, or not a Debit & Payouts workspace
  - 400 — payment_method_id belongs to another gateway (e.g. a Stripe pm_ id). Payout destinations must be Paysio Debit & Payouts instruments — use GET /customers?processor=aptpay and payout_eligible: true methods, or pass a payment_token / bank_account destination instead.
  - 404 — Customer or payment method not found
  - 409 — Duplicate reference_id (idempotency conflict)
  - Webhook-time failures (payout.failed) carry network codes such as M009 (card cannot receive funds) or M011 (ineligible card)

### GET /payouts
List payouts, sorted by most recent.
Auth: Any API key
Query:
  - limit (integer) — Max results per page (default: 10, max: 100)
  - starting_after (string) — Cursor for pagination
  - customer_id (string) — Filter by recipient customer
  - status (string) — Filter by status: pending, paid, failed, or canceled
  - metadata[key] (string) — Filter by metadata key-value pairs

Response:
    { "data": [ { ...payout objects... } ], "has_more": false }

### GET /payouts/:id
Retrieve a single payout.
Auth: Any API key

---

## Balance (Paysio Debit & Payouts)

### GET /balance
Returns your merchant balance. `available` is the spendable balance (settled funds minus in-flight payouts); `pending_payouts` is the total currently reserved by in-flight payouts. Both in cents. Only on Paysio Debit & Payouts workspaces (400 otherwise).
Auth: Any API key (publishable or secret)
Query:
  - currency (string) — Currency code (default: USD)

Response:
    { "data": { "object": "balance", "available": 125000, "pending_payouts": 5000, "currency": "USD", "livemode": false } }

---

## Account Checks (Paysio Debit & Payouts)

Pre-flight eligibility checks for payout destinations. Card checks verify whether a card can receive push-to-card funds, on which network, and how fast. Bank checks verify which real-time rails (TCH / FedNow) the bank supports. ACH has no pre-flight check — ineligibility surfaces later as an ACH return. Only on Paysio Debit & Payouts workspaces (400 otherwise).

### POST /account-checks
Auth: Secret key (sk_*)
Body — Card check (provide ONE destination):
  - payment_token (string) — A ptok_ token from POST /tokens
  - customer_id + payment_method_id (strings) — A saved card instrument
  - card (object) — Raw card: { number, exp_month, exp_year }
  - amount (integer) — Hypothetical amount in cents (default: 100)
  - currency (string) — Default: USD

Body — Bank check:
  - bank_account (object) — { routing_number (9 digits) }

Response (card):
    {
      "data": {
        "object": "account_check",
        "type": "card",
        "eligible": true,
        "receiving": true,
        "sending": true,
        "network": "Visa",
        "funds_availability": "immediate",
        "card_type": "debit",
        "country": "US",
        "currency": "USD"
      }
    }

Response (bank):
    {
      "data": {
        "object": "account_check",
        "type": "bank",
        "routing_number": "021000021",
        "bank_name": "JPMORGAN CHASE",
        "rtp": { "tch_credit": true, "tch_debit": false, "fednow_credit": true, "fednow_debit": false },
        "ach_eligible": true
      }
    }

---

## Tokenization Key

### GET /tokenization-key
Returns the workspace's client-side tokenization configuration for Paysio.js: the processor type, the NMI public key or Stripe publishable key, and wallet and 3DS feature flags.
Auth: Any API key (publishable or secret)

Response:
    {
      "data": {
        "processor_type": "nmi",
        "tokenization_key": "...",
        "stripe_publishable_key": null,
        "mode": "test",
        "gateway_host": "https://sandbox.nmi.com",
        "apple_pay_enabled": false,
        "google_pay_enabled": false,
        "three_ds_enabled": false
      }
    }

Gateway auto-detection: Paysio.js reads processor_type from this endpoint and renders the correct card fields automatically (Stripe Elements for Stripe; Paysio secure inputs for NMI and Debit & Payouts) — you never hardcode or branch on the gateway, and the mounted fields always include the CVC so you never render your own. When a workspace switches processors in the dashboard, the integration adapts on the next load (or after paysio.reset()). Server-side, call GET /tokenization-key with a publishable key to read processor_type ("nmi" | "stripe" | "aptpay") — useful e.g. to know that aptpay charges settle asynchronously.

---

## Chat Widget

Embed the Paysio support chat on any website. Visitors can start conversations
with your team (or AI agent), and you respond from the Paysio inbox. The widget
runs entirely in the browser — no API key is needed, only your workspace ID.

### Quick start

Drop these two tags into your site, anywhere before `</body>`:

    <script>
      window.PaysioChat = { workspaceId: "YOUR_WORKSPACE_ID" };
    </script>
    <script src="https://paysio.com/chat.js" async></script>

That's it. The floating chat bubble will appear in the corner of every page,
using the appearance, position, and office hours configured in your workspace
settings (Settings → Support Chats).

Where to find your workspace ID
-------------------------------
The workspace ID is a UUID (e.g. `a1b2c3d4-5678-...`), **not** the slug that
appears in the dashboard URL. To get it:

1. Open the Paysio dashboard.
2. Go to **Settings → Support Chats**.
3. Scroll to **Install on your website** — the workspace ID is shown there
   with a copy button, alongside the ready-to-paste snippet (with your ID
   already filled in).

### Configuration

Set these on `window.PaysioChat` before `chat.js` loads, or as
`data-*` attributes on the script tag.

| Option        | Type    | Description                                                                                       |
|---------------|---------|---------------------------------------------------------------------------------------------------|
| workspaceId   | string  | Required. Your Paysio workspace ID.                                                               |
| hideLauncher  | boolean | Hide the floating bubble. The widget only opens when you call `PaysioChat.open()`.              |
| customerId    | string  | Link the visitor to a Paysio customer ID so conversations follow them across domains.             |

Equivalent attribute form:

    <script
      src="https://paysio.com/chat.js"
      data-workspace-id="YOUR_WORKSPACE_ID"
      data-hide-launcher="true"
      async
    ></script>

### Programmatic control

After `chat.js` loads, `window.PaysioChat` exposes a small API so you can
open or close the widget from your own UI — for example, wiring a "Contact
support" button to the chat panel.

| Method                   | Description                                              |
|--------------------------|----------------------------------------------------------|
| PaysioChat.open()        | Open the chat panel.                                     |
| PaysioChat.close()       | Close the chat panel.                                    |
| PaysioChat.toggle()      | Toggle open / close.                                     |
| PaysioChat.isOpen()      | Returns `true` if the panel is currently open.         |
| PaysioChat.on(evt, fn)   | Listen for `"ready"`, `"open"`, or `"close"`.      |

Example — custom "Contact support" button that opens the chat:

    <button id="contact-support">Contact support</button>

    <script>
      window.PaysioChat = {
        workspaceId: "YOUR_WORKSPACE_ID",
        hideLauncher: true,
      };
    </script>
    <script src="https://paysio.com/chat.js" async></script>

    <script>
      document.getElementById("contact-support").addEventListener("click", function () {
        // Safe to call even before chat.js finishes loading — queued by the loader.
        window.PaysioChat.open();
      });
    </script>

Example — react to events:

    PaysioChat.on("ready", function () { console.log("Chat is ready"); });
    PaysioChat.on("open",  function () { console.log("Chat opened"); });
    PaysioChat.on("close", function () { console.log("Chat closed"); });

### Linking conversations across domains

If the visitor is already a Paysio customer (e.g. signed into your portal),
pass their `customerId` to keep their chat history connected:

    window.PaysioChat = {
      workspaceId: "YOUR_WORKSPACE_ID",
      customerId: "cus_abc123",
    };

### Security & limits

- The widget only needs a workspace ID — no API key is exposed.
- All chat traffic is served over HTTPS and proxied through paysio.com.
- The iframe is rendered with `z-index: 2147483647` so it sits above other
  content. If you have higher-priority overlays, render them above the
  `#paysio-chat-iframe` element.
- The widget auto-hides on pages disabled in workspace settings (Settings →
  Support Chats → Hidden on pages).

---

## Webhooks

Receive real-time notifications when events happen in your account. Configure webhook endpoints in your workspace settings.

### Event Types

| Event                   | Description                              |
|-------------------------|------------------------------------------|
| payment.completed       | A payment was successfully processed (on Paysio Debit & Payouts: fired at settlement) |
| payment.refunded        | A transaction was refunded               |
| payment.voided          | A pending transaction was voided         |
| payment.failed          | A pending payment failed (Paysio Debit & Payouts) |
| payment.disputed        | A payment was disputed — chargeback or ACH return (Paysio Debit & Payouts) |
| payout.created          | A payout was created and accepted (Paysio Debit & Payouts) |
| payout.updated          | A payout was acknowledged by the network (Paysio Debit & Payouts) |
| payout.paid             | A payout settled — funds delivered (Paysio Debit & Payouts) |
| payout.failed           | A payout was rejected by the network (Paysio Debit & Payouts) |
| subscription.created          | A subscription was created via the API         |
| subscription.scheduled        | A scheduled subscription was created (starts in the future) |
| subscription.activated        | A scheduled subscription was activated by the renewal worker |
| subscription.renewed          | A subscription was automatically renewed       |
| subscription.cancel_scheduled | A subscription is scheduled to cancel at period end |
| subscription.cancelled        | A subscription was cancelled                   |
| subscription.paused           | A subscription was paused                      |
| subscription.resumed          | A paused subscription was resumed              |

### Webhook Payload

All webhook payloads follow this structure:

    {
      "id": "event-uuid",
      "type": "payment.completed",
      "created": 1705312200,
      "data": { ..., "sandbox": false }
    }

Every event's `data` object includes a `sandbox` boolean field indicating whether the event originated from a test-mode API key (`true`) or a live-mode key (`false`). Use this to distinguish sandbox events from production events.

### Event Data Fields

#### payment.completed

Fired when a payment is successfully processed. Includes the originating payment link, checkout session info (if created via API), metadata, and any subscription IDs created.

| Field                | Type     | Description                                          |
|----------------------|----------|------------------------------------------------------|
| transaction_id       | string   | The transaction ID                                   |
| amount               | integer  | Amount in cents                                      |
| currency             | string   | Currency code (e.g. "USD")                           |
| status               | string   | Transaction status (e.g. "pending_settlement")       |
| customer_email       | string   | Customer's email address                             |
| order_number         | integer  | Order number                                         |
| payment_link_id      | string   | The payment link ID                                  |
| source               | string   | Origin: "dashboard" or "api"                         |
| checkout_session_id  | string   | Checkout session ID (present when source is "api")   |
| metadata             | object   | Metadata from the checkout session (if any)          |
| subscription_ids     | string[] | Created subscription IDs (if recurring products)     |
| sandbox              | boolean  | Whether this event is from test mode                 |

Example:

    {
      "id": "evt_abc123",
      "type": "payment.completed",
      "created": 1705312200,
      "data": {
        "transaction_id": "txn_def456",
        "amount": 2700,
        "currency": "USD",
        "status": "pending_settlement",
        "customer_email": "[email protected]",
        "order_number": 1042,
        "payment_link_id": "pl_ghi789",
        "source": "api",
        "checkout_session_id": "pl_ghi789",
        "metadata": { "order_ref": "12345" },
        "subscription_ids": ["sub_jkl012"],
        "sandbox": false
      }
    }

#### payment.refunded

Fired when a transaction is refunded (full or partial).

| Field                | Type    | Description                                    |
|----------------------|---------|------------------------------------------------|
| transaction_id       | string  | The transaction ID                             |
| amount               | integer | Original transaction amount in cents           |
| currency             | string  | Currency code                                  |
| refunded_amount      | integer | Amount refunded in this operation (cents)      |
| total_refunded       | integer | Total refunded so far (cents)                  |
| payment_link_id      | string  | The payment link ID (if applicable)            |
| sandbox              | boolean | Whether this event is from test mode           |

Example:

    {
      "id": "evt_abc123",
      "type": "payment.refunded",
      "created": 1705312200,
      "data": {
        "transaction_id": "txn_def456",
        "amount": 2700,
        "currency": "USD",
        "refunded_amount": 1000,
        "total_refunded": 1000,
        "payment_link_id": "pl_ghi789",
        "sandbox": false
      }
    }

#### payment.voided

Fired when a pending (unsettled) transaction is voided.

| Field                | Type    | Description                                    |
|----------------------|---------|------------------------------------------------|
| transaction_id       | string  | The transaction ID                             |
| amount               | integer | Transaction amount in cents                    |
| currency             | string  | Currency code                                  |
| payment_link_id      | string  | The payment link ID (if applicable)            |
| sandbox              | boolean | Whether this event is from test mode           |

Example:

    {
      "id": "evt_abc123",
      "type": "payment.voided",
      "created": 1705312200,
      "data": {
        "transaction_id": "txn_def456",
        "amount": 2700,
        "currency": "USD",
        "payment_link_id": "pl_ghi789",
        "sandbox": false
      }
    }

#### payment.failed (Paysio Debit & Payouts)

Fired when a pending charge fails to settle. On Debit & Payouts, charges are accepted asynchronously — a failure after acceptance arrives via this event.

| Field                | Type    | Description                                    |
|----------------------|---------|------------------------------------------------|
| transaction_id       | string  | The transaction ID                             |
| amount               | integer | Transaction amount in cents                    |
| currency             | string  | Currency code                                  |
| status               | string  | Always "failed"                                |
| rail                 | string  | "card" or "ach"                                |
| error_code           | string  | Network error code (e.g. "T002")               |
| error_message        | string  | Human-readable failure reason                  |
| processor            | string  | Always "aptpay"                                |
| sandbox              | boolean | Whether this event is from test mode           |

Example:

    {
      "id": "evt_abc123",
      "type": "payment.failed",
      "created": 1705312200,
      "data": {
        "transaction_id": "txn_def456",
        "amount": 2700,
        "currency": "USD",
        "status": "failed",
        "rail": "card",
        "error_code": "T002",
        "error_message": "Transaction declined",
        "processor": "aptpay",
        "sandbox": false
      }
    }

#### payment.disputed (Paysio Debit & Payouts)

Fired when a settled payment is disputed — a card chargeback or an ACH return.

| Field                | Type    | Description                                    |
|----------------------|---------|------------------------------------------------|
| transaction_id       | string  | The transaction ID                             |
| amount               | integer | Transaction amount in cents                    |
| currency             | string  | Currency code                                  |
| status               | string  | Always "disputed"                              |
| rail                 | string  | "card" or "ach"                                |
| dispute_kind         | string  | "chargeback" or "ach_return"                   |
| dispute_code         | string  | Network dispute code (e.g. "R01")              |
| dispute_reason       | string  | Human-readable dispute reason                  |
| processor            | string  | Always "aptpay"                                |
| sandbox              | boolean | Whether this event is from test mode           |

Example:

    {
      "id": "evt_abc123",
      "type": "payment.disputed",
      "created": 1705312200,
      "data": {
        "transaction_id": "txn_def456",
        "amount": 2700,
        "currency": "USD",
        "status": "disputed",
        "rail": "ach",
        "dispute_kind": "ach_return",
        "dispute_code": "R01",
        "dispute_reason": "Insufficient funds",
        "processor": "aptpay",
        "sandbox": false
      }
    }

#### payout.created / payout.updated / payout.paid / payout.failed (Paysio Debit & Payouts)

Track the payout lifecycle: payout.created fires when the payout is accepted, payout.updated when the network acknowledges it, payout.paid at settlement, and payout.failed on rejection. All four share the same data shape — the full payout object returned by the Payouts API.

| Field                      | Type    | Description                                        |
|----------------------------|---------|----------------------------------------------------|
| id                         | string  | The payout ID                                      |
| object                     | string  | Always "payout"                                    |
| payout_number              | integer | Sequential payout number                           |
| amount                     | integer | Amount in cents (fee-inclusive)                    |
| currency                   | string  | Currency code                                      |
| status                     | string  | "pending", "paid", "failed", or "canceled"         |
| rail                       | string  | "card", "ach", or "rtp"                            |
| rtp_network                | string  | "TCH" or "FedNow" (rtp payouts only)               |
| card_last_four             | string  | Destination card last 4 (card payouts)             |
| card_brand                 | string  | Destination card brand (card payouts)              |
| descriptor                 | string  | Statement descriptor                               |
| customer_id                | string  | Recipient customer ID                              |
| purpose                    | string  | "payout" or "refund"                               |
| refund_for_transaction_id  | string  | Original transaction (refund payouts only)         |
| reference_id               | string  | Idempotency key passed at creation                 |
| error_code                 | string  | Network error code (payout.failed, e.g. "M009")    |
| error_message              | string  | Human-readable failure reason (payout.failed)      |
| metadata                   | object  | Metadata passed at creation                        |
| livemode                   | boolean | false for sandbox payouts                          |
| sandbox                    | boolean | Whether this event is from test mode               |

Example (payout.paid):

    {
      "id": "evt_abc123",
      "type": "payout.paid",
      "created": 1705312200,
      "data": {
        "id": "po_uuid",
        "object": "payout",
        "payout_number": 17,
        "amount": 5000,
        "currency": "USD",
        "status": "paid",
        "rail": "card",
        "rtp_network": null,
        "card_last_four": "5702",
        "card_brand": "mastercard",
        "descriptor": "ACME PAY",
        "customer_id": "cus_uuid",
        "purpose": "payout",
        "refund_for_transaction_id": null,
        "reference_id": "payout-2026-001",
        "error_code": null,
        "error_message": null,
        "metadata": {},
        "livemode": false,
        "sandbox": true
      }
    }

Example (payout.failed):

    {
      "id": "evt_abc123",
      "type": "payout.failed",
      "created": 1705312200,
      "data": {
        "id": "po_uuid",
        "object": "payout",
        "payout_number": 18,
        "amount": 5000,
        "currency": "USD",
        "status": "failed",
        "rail": "card",
        "error_code": "M009",
        "error_message": "Card is not eligible to receive funds",
        "customer_id": "cus_uuid",
        "purpose": "payout",
        "metadata": {},
        "livemode": false,
        "sandbox": true
      }
    }

#### subscription.created

Fired when a subscription is created via the POST /subscriptions API endpoint.

| Field                | Type    | Description                                    |
|----------------------|---------|------------------------------------------------|
| subscription_id      | string  | The new subscription ID                        |
| customer_id          | string  | The customer ID                                |
| product_id           | string  | The product ID                                 |
| transaction_id       | string  | The initial payment transaction ID             |
| amount               | integer | Subscription amount in cents                   |
| currency             | string  | Currency code                                  |
| metadata             | object  | Metadata passed during creation                |
| sandbox              | boolean | Whether this event is from test mode           |

Example:

    {
      "id": "evt_abc123",
      "type": "subscription.created",
      "created": 1705312200,
      "data": {
        "subscription_id": "sub_new123",
        "customer_id": "cus_abc456",
        "product_id": "prod_def789",
        "transaction_id": "txn_ghi012",
        "amount": 2700,
        "currency": "USD",
        "metadata": { "plan": "basic" },
        "sandbox": false
      }
    }

#### subscription.scheduled

Fired when a subscription is created with a future start_date via the POST /subscriptions API endpoint. The subscription will be activated automatically by the renewal worker when the start date is reached.

| Field                | Type    | Description                                    |
|----------------------|---------|------------------------------------------------|
| subscription_id      | string  | The new subscription ID                        |
| customer_id          | string  | The customer ID                                |
| product_id           | string  | The product ID                                 |
| start_date           | string  | ISO 8601 date when the subscription starts     |
| amount               | integer | Subscription amount in cents                   |
| currency             | string  | Currency code                                  |
| metadata             | object  | Metadata passed during creation                |
| sandbox              | boolean | Whether this event is from test mode           |

Example:

    {
      "id": "evt_abc123",
      "type": "subscription.scheduled",
      "created": 1705312200,
      "data": {
        "subscription_id": "sub_sch456",
        "customer_id": "cus_abc456",
        "product_id": "prod_def789",
        "start_date": "2026-05-11T00:00:00.000Z",
        "amount": 2700,
        "currency": "USD",
        "metadata": { "plan": "basic" },
        "sandbox": false
      }
    }

#### subscription.activated

Fired when a scheduled subscription is activated by the renewal worker. This happens when the subscription's start_date is reached — the first payment is charged and the subscription becomes active.

| Field                | Type    | Description                                    |
|----------------------|---------|------------------------------------------------|
| subscription_id      | string  | The subscription ID                            |
| sandbox              | boolean | Whether this event is from test mode           |

Example:

    {
      "id": "evt_abc123",
      "type": "subscription.activated",
      "created": 1705312200,
      "data": {
        "subscription_id": "sub_sch456",
        "sandbox": false
      }
    }

#### subscription.renewed

Fired when a subscription is automatically renewed and the renewal charge succeeds.

| Field                | Type    | Description                                    |
|----------------------|---------|------------------------------------------------|
| subscription_id      | string  | The subscription ID                            |
| transaction_id       | string  | The renewal transaction ID                     |
| amount               | integer | Renewal amount in cents                        |
| currency             | string  | Currency code                                  |
| sandbox              | boolean | Whether this event is from test mode           |

Example:

    {
      "id": "evt_abc123",
      "type": "subscription.renewed",
      "created": 1705312200,
      "data": {
        "subscription_id": "sub_jkl012",
        "transaction_id": "txn_mno345",
        "amount": 2700,
        "currency": "USD",
        "sandbox": false
      }
    }

#### subscription.cancel_scheduled

Fired when a subscription is scheduled to cancel at the end of the current billing period (using at_period_end: true).

| Field                | Type    | Description                                    |
|----------------------|---------|------------------------------------------------|
| subscription_id      | string  | The subscription ID                            |
| reason               | string  | Cancellation reason                            |
| cancel_at            | string  | ISO 8601 date when the subscription will cancel |
| sandbox              | boolean | Whether this event is from test mode           |

Example:

    {
      "id": "evt_abc123",
      "type": "subscription.cancel_scheduled",
      "created": 1705312200,
      "data": {
        "subscription_id": "sub_jkl012",
        "reason": "Switching to basic plan",
        "cancel_at": "2026-05-11T00:00:00.000Z",
        "sandbox": false
      }
    }

#### subscription.cancelled

Fired when a subscription is cancelled (either immediately or at the end of a scheduled period).

| Field                | Type    | Description                                    |
|----------------------|---------|------------------------------------------------|
| subscription_id      | string  | The subscription ID                            |
| reason               | string  | Cancellation reason                            |
| sandbox              | boolean | Whether this event is from test mode           |

Example:

    {
      "id": "evt_abc123",
      "type": "subscription.cancelled",
      "created": 1705312200,
      "data": {
        "subscription_id": "sub_jkl012",
        "reason": "Customer requested cancellation",
        "sandbox": false
      }
    }

#### subscription.paused

Fired when a subscription is paused.

| Field                | Type    | Description                                    |
|----------------------|---------|------------------------------------------------|
| subscription_id      | string  | The subscription ID                            |
| sandbox              | boolean | Whether this event is from test mode           |

Example:

    {
      "id": "evt_abc123",
      "type": "subscription.paused",
      "created": 1705312200,
      "data": {
        "subscription_id": "sub_jkl012",
        "sandbox": false
      }
    }

#### subscription.resumed

Fired when a paused subscription is resumed.

| Field                | Type    | Description                                    |
|----------------------|---------|------------------------------------------------|
| subscription_id      | string  | The subscription ID                            |
| sandbox              | boolean | Whether this event is from test mode           |

Example:

    {
      "id": "evt_abc123",
      "type": "subscription.resumed",
      "created": 1705312200,
      "data": {
        "subscription_id": "sub_jkl012",
        "sandbox": false
      }
    }

### Verifying Signatures

Every webhook includes a Paysio-Signature header:

    Paysio-Signature: t=1705312200,v1=5257a869e...

Verification:
1. Extract timestamp (t) and signature (v1)
2. Construct signed payload: `${timestamp}.${requestBody}`
3. Compute HMAC-SHA256 with your webhook secret
4. Compare with v1 value

### Retry Policy

Failed deliveries are retried up to 3 times with exponential backoff:
- Attempt 1: Immediate
- Attempt 2: After 5 minutes
- Attempt 3: After 30 minutes

Deliveries time out after 10 seconds.