# 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 }

Every response is wrapped in a top-level "data" key — unwrap it exactly once:
  - Single resource: { "data": { ...the object } }       → data IS the object
  - List:            { "data": [ ... ], "has_more": bool } → data IS the array
Do not unwrap twice (a list's "data" is the array itself, not { data: [...] }).
Every resource object also includes an "object" field naming its type (e.g.
"product", "customer", "transaction") so responses are self-describing.

## Errors

| HTTP | type                   | Meaning                                        |
|------|------------------------|------------------------------------------------|
| 400  | invalid_request_error  | Bad request — invalid or missing parameters    |
| 401  | authentication_error   | Missing or invalid API key                     |
| 402  | card_error             | Payment failed — charge was declined           |
| 403  | permission_error       | Secret key required for this endpoint           |
| 404  | not_found_error        | Resource doesn't exist                          |
| 409  | idempotency_error      | Conflict — resource or idempotency key reused   |
| 429  | rate_limit_error       | Too many requests                               |
| 5xx  | api_error              | Something went wrong on Paysio's side           |

Error response format:
    { "error": "Customer not found", "type": "not_found_error", "code": "resource_missing" }

- "error" is a human-readable message (safe to log; wording may change over time).
- "type" is a stable, machine-readable category derived from the cases above —
  branch on this rather than parsing the message.
- "code" is a more specific machine-readable code where one applies
  (e.g. "resource_missing", "parameter_missing", "blocked"). Not every error
  carries a distinct "code", but "type" is always present.

---

## 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);
      console.log('Payer:', data.wallet);           // { payerName, payerEmail, payerPhone }

      // Recommended: also send data.wallet (not just token + wallet type) so the
      // charge can attach a customer and the card last-4 to the transaction.
      fetch('/your-server/process-payment', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ token: data.token, wallet_type: data.walletType, wallet: data.wallet }),
      });
    });

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

### Tip: the wallet button can stand on its own (no billing form needed)

We recommend letting the wallet button work on its own — like Paysio's hosted checkout, where you click Apple Pay / Google Pay and pay in one step. You generally don't need to require your own name/email/billing-address fields before the wallet button, because the wallet sheet already collects the buyer's name, email, phone, and full billing address (Paysio.js requests them: Apple Pay requiredBillingContactFields + contactFields; Google Pay billingAddressRequired + emailRequired). Those details come back on data.wallet (data.wallet.billingInfo holds the address), which you can forward to /charges (see below).

Your own billing form is typically only needed for the manual card path, so you can keep the two independent — the wallet button can work with zero fields filled.

### Recommended for Apple Pay & Google Pay: forward the "wallet" object to /charges

We recommend forwarding the wallet payer contact to POST /charges. If you send only { payment_token, wallet_type } and omit both "wallet" and "email", the charge still succeeds — but the transaction won't have a customer or card last-4 (the buyer shows as "Unknown" and the card isn't saved). Forwarding "wallet" is what makes the V1 API behave like the hosted checkout.

Apple Pay and Google Pay return the buyer's name, email, and phone. The walletPayment event surfaces them as data.wallet = { payerName, payerEmail, payerPhone } (Stripe) or the raw NMI wallet object. You can forward data.wallet to your server, then include it in the /charges body.

**Forwarding "wallet" (and "wallet_type") to POST /charges** lets Paysio find-or-create the customer, store the card as a reusable payment method, and populate the transaction (last-4, brand, buyer) — like the hosted checkout does for wallet payments.

    // Charge the wallet token AND forward the wallet payer contact
    POST /api/v1/charges
    Authorization: Bearer sk_live_your_secret_key
    {
      "payment_token": "ptok_wallet_abc...",
      "wallet_type": "apple_pay",
      "wallet": { "payerName": "Jane Doe", "payerEmail": "[email protected]", "payerPhone": "+15555550123" },
      "amount": 2999,
      "currency": "USD"
    }

If you omit both "wallet" and "email", the charge still succeeds — but with no email there is nothing to attach a customer to, so no customer is created and the card is not saved (the transaction shows no buyer). To attach the charge to a known customer instead, 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 |

### API reference

The Paysio.js threeDS.authenticate() helper wraps the REST endpoints below — it calls POST /three-ds/authenticate, polls GET /three-ds/poll, and renders the challenge iframe for you. Call them directly only if you are building your own 3DS flow. Authenticate with a publishable key (the browser SDK uses pk_*) or a secret key. 3DS must be enabled for the workspace.

### POST /three-ds/authenticate
Start a 3D Secure authentication. Provide either a ptok_ payment token (recommended — the card is resolved server-side and the token is NOT consumed, so you can still charge it) or the raw card number and expiry. Returns a terminal status for frictionless flows, or fingerprint/challenge data (methodURL / acsURL, creq) when the issuer requires it — poll GET /three-ds/poll until the status is terminal.
Auth: Publishable key (pk_*) or secret key (sk_*)

Field formats (the 3DS server is strict — Paysio normalizes the common pitfalls, but match these if you build the request yourself):
  - amount is a NUMBER in major units (e.g. 29.99), not a string and not cents.
  - browser.browserJavaEnabled and browserJavaScriptEnabled are BOOLEANS (false, not "false"). The spec key is capital-S browserJavaScriptEnabled; the lowercase alias and string booleans are coerced for you.
  - billing.country: send the ISO alpha-2 code (US) — it is converted to the numeric code the provider needs.
  - billing.state: use the ISO 3166-2 subdivision code (NY), not the full name.
  - Omit blank fields: an empty string (e.g. addressLine1: "") is a malformed value to the 3DS spec, not "unset", and triggers a format error. Paysio strips blank billing fields for you, so just leave out what you don't have.
If the provider rejects the request, this endpoint returns 400 with the provider's exact message (error + code: "three_ds_provider_error"). A /three-ds/poll response that contains an error field means authentication failed — stop polling.
Body:
  - amount (number, required) — Charge amount in major units (e.g. 29.99), used for the 3DS risk assessment
  - browser (object, required) — Device fingerprint collected in the browser: { browserUserAgent, browserLanguage, browserColorDepth, browserScreenHeight, browserScreenWidth, browserTZ, browserJavaEnabled, ... }. Paysio.js builds this for you.
  - token (string) — A ptok_ payment token from POST /tokens or paysio.createToken(). Resolves the card server-side; not consumed by 3DS. Provide this OR pan/month/year.
  - pan (string) — Raw card number. Required when no token is supplied.
  - month (string) — 2-digit expiry month (MM). Required with pan.
  - year (string) — 2-digit expiry year (YY). Required with pan.
  - billing (object) — Billing address: { country (ISO alpha-2), addressLine1, city, state, postalCode, ... }
  - email (string) — Cardholder email, forwarded to the issuer risk check
  - cardHolderName (string) — Cardholder name
  - threeDSRequestorURL (string) — Your site URL, used as the requestor origin (default: https://paysio.com)
  - challengeWindowSize (string) — ACS challenge window size code (default: "05" = full page)
  - messageCategory (string) — "01" payment authentication or "02" non-payment (default: "01")

Response:
    {
      "data": {
        "status": "C",
        "threeDSServerTransID": "uuid",
        "authToken": "Bearer eyJhbGciOi...",
        "eci": "05",
        "authenticationValue": "AAABBWcSNIQAAAAAAAAAAAAAAAA=",
        "dsTransId": "uuid",
        "methodURL": "https://acs.example.com/3ds/method",
        "threeDSMethodData": "eyJ0aHJlZURTU2Vy...",
        "acsURL": "https://acs.example.com/3ds/challenge",
        "creq": "eyJ0aHJlZURTU2Vy..."
      }
    }

status is Y / A / N / U / R when terminal, or C / PENDING while device fingerprinting or a challenge is still in progress (poll for the terminal result). methodURL / threeDSMethodData and acsURL / creq are only present when the issuer requires fingerprinting or a challenge.

### GET /three-ds/poll
Long-poll for status updates while the issuer is fingerprinting the device or the cardholder is completing a challenge. Returns immediately with the cached terminal result once one exists; otherwise holds the connection open (~25s) waiting for an update, then returns status "PENDING" for you to poll again.
Auth: Publishable key (pk_*) or secret key (sk_*)
Query:
  - transactionId (string, required) — The threeDSServerTransID returned by /three-ds/authenticate
  - authToken (string) — The authToken returned by /three-ds/authenticate (falls back to the stored session token if omitted)
  - lastUpdated (string) — Cursor from the previous poll response, so only newer updates are returned
  - phase (string) — Set to "challenging" while the challenge iframe is open — uses a shorter timeout tuned for the challenge phase

Response:
    {
      "data": {
        "status": "Y",
        "eci": "05",
        "authenticationValue": "AAABBWcSNIQAAAAAAAAAAAAAAAA=",
        "dsTransId": "uuid",
        "acsURL": null,
        "creq": null,
        "lastUpdated": "2026-01-15T10:30:05.000Z"
      }
    }

### GET /three-ds/result/:transactionId
Retrieve the persisted result of a completed 3DS authentication. Useful for reconciling on your server before creating the charge.
Auth: Publishable key (pk_*) or secret key (sk_*)

Response:
    {
      "data": {
        "status": "Y",
        "eci": "05",
        "authenticationValue": "AAABBWcSNIQAAAAAAAAAAAAAAAA=",
        "dsTransId": "uuid",
        "threeDSServerTransID": "uuid",
        "threeDsVersion": "2.2.0"
      }
    }

---

## 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"] }]
  - active (boolean) — Whether the product is purchasable (default true). Setting active:false creates it archived/hidden.

Every product object includes object: "product" and a computed active boolean
(the inverse of the internal archived flag): active:true means purchasable,
active:false means archived/hidden.

Response (201):
    { "data": { "object": "product", "active": true, ...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:
  - active (boolean) — Set false to archive (hide) the product, true to unarchive
  - archived (boolean) — Legacy alias for the inverse of active; still accepted. If both are sent, active wins.

Response:
    { "data": { "object": "product", "active": true, ...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
  Optional:
  - gateway (string) — Per-request gateway override: "nmi", "paysio", "stripe", or "aptpay" (Paysio Debit & Payouts). Omit to use your workspace default. Customers are split per gateway (same email = one row per gateway), so this keys the customer — and any card vaulted here — to that gateway. Must be configured (else 400).

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.

PAYOUT-ELIGIBLE CARDS: a saved card is vaulted on the CUSTOMER's processor. It is
payout-eligible (usable as a POST /payouts destination) only when that processor is
Paysio Debit & Payouts. On a Debit & Payouts workspace, customers are created on
that rail automatically, so saving a card just works. But if your workspace runs
cards on Stripe/NMI, a card saved to one of those customers vaults on Stripe/NMI
and is NOT payout-eligible. In that case either (a) save the card to a Debit &
Payouts customer, or (b) skip saving and pass the card straight to POST /payouts as
a one-time `payment_token` (ptok_) — that always rides the Debit & Payouts rail.

### 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:
  - id (string) — The instrument id. Same value as billing_id; pass this as payment_method_id when creating a payout.
  - billing_id (string) — Alias of id (kept for backwards compatibility).
  - 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": [
        { "id": "1234567890", "billing_id": "1234567890", "type": "card", "processor": "aptpay", "payout_eligible": true, "card_last_four": "1111", "bank_last_four": null, "card_type": "visa", "cc_exp": "1228" },
        { "id": "12345", "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
  - gateway (string) — Per-request gateway override: "nmi", "paysio", "stripe", or "aptpay" (Paysio Debit & Payouts). Omit to use your workspace default. The token becomes sticky to this gateway, so charge it with the same gateway. Must be configured (else 400).

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
  - gateway (string) — Per-request gateway override for a NEW-card charge: "nmi", "paysio", "stripe", or "aptpay" (Paysio Debit & Payouts). Omit to use your workspace's configured default. Must be configured for your workspace (else 400). A saved method (customer_id) stays locked to the gateway it was vaulted on — passing a conflicting gateway returns 400, since vaults are not portable across gateways. Use the same value on GET /tokenization-key so the client SDK collects the card for the matching gateway.

  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",
        "orderNumber": 19,
        "orderLabel": "#00019",
        "amount": 2999,
        "currency": "USD",
        "status": "pending_settlement",
        "cardType": "visa",
        "cardLastFour": "4242",
        "nmiTransactionId": "123456789",
        "createdAt": "2026-01-15T10:30:00.000Z"
      }
    }

The response includes orderNumber (the sequential order number shown in the Paysio dashboard, e.g. 19) and orderLabel (the zero-padded display form, "#00019") so you can show a human-friendly reference on your own UI instead of the UUID. Both are also returned by GET /transactions and GET /transactions/:id.

Response (Debit & Payouts — settles asynchronously via webhook):
    {
      "data": {
        "id": "uuid",
        "orderNumber": 20,
        "orderLabel": "#00020",
        "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 (plus /balance and /account-checks) return 400 on other workspaces.

IMPORTANT — Debit & Payouts is enabled PER MODE. Live and test are provisioned
separately, so a test (sk_test_) key can return balances and send payouts while a
live (sk_live_) key on the same account returns "Paysio Debit & Payouts is not
enabled for this workspace in live mode" — and vice versa. If one key works and
the other 400s, that's why: enable Debit & Payouts for the missing mode in your
Paysio dashboard. (This does NOT depend on your card/bank processor — you can run
cards on Stripe/NMI and still use Debit & Payouts for balance and payouts.)

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.

### Sending a payout — end to end

1. Resolve the recipient: GET /customers?processor=aptpay (existing Debit &
   Payouts customers) or POST /customers to create one. On a Debit & Payouts
   workspace a new customer is created on the AptPay rail automatically.
2. Choose a destination — three options, any one works:
   a. One-time card — POST /tokens with the card (or paysio.createToken() in the
      browser) → pass the ptok_ as `payment_token`. No saving required.
   b. Saved card — POST /customers/:id/payment-methods (raw card or a ptok_) to
      vault it, then pass its `payment_method_id`. The card must be vaulted on
      Debit & Payouts to be payout-eligible (see Payment Methods → payout_eligible).
   c. Bank account — pass `bank_account` { routing_number, account_number } inline.
3. (Optional) Pre-check a card with POST /account-checks to confirm it can
   receive push-to-card funds before you send.
4. POST /payouts with `amount`, `customer_id`, and exactly ONE destination.
   The response is `status: "pending"`; the final result arrives via the
   payout.paid / payout.failed webhook (see Webhooks).

### 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. Pass the `id` (same value as `billing_id`) returned by GET /customers/:id/payment-methods. 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,
        "payout_label": "#00017",
        "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

---

## Payout Links (Paysio Debit & Payouts)

Send money via a claim link — the money-out counterpart to Checkout Sessions. The recipient opens a hosted page and chooses the debit card the money lands on (you never touch their card details). Only on Paysio Debit & Payouts workspaces, enabled per mode (see the note under Payouts). Two types:
  - directed — a fixed amount sent to a specific recipient_email. Creating it HOLDS amount + fee against your spendable balance and emails the recipient a claim link. Fee-exclusive: the recipient receives the full amount and you pay the fee.
  - request — a reusable form anyone can open and submit a payout request on. No amount and no hold up front; money moves only when you approve a submission (see Payout Requests below).

Lifecycle: a directed link goes pending → claiming → paid (or cancelled / expired / failed). When the recipient claims, the underlying payout fires the usual payout.created → payout.paid / payout.failed webhooks, each carrying payout_link_id (and payout_request_id for approved requests) so you can correlate the payout back to the link — the same way payment.completed carries checkout_session_id.

### POST /payout-links
Create a payout link.
Auth: Secret key (sk_*)
Body:
  - type (string) — "directed" (default) or "request"
  - amount (integer) — Directed only, required. Cents the recipient receives (you also pay the fee — the link holds amount + fee)
  - recipient_email (string) — Directed only, required. The link is locked to this email
  - currency (string) — Only "USD" (default: USD)
  - note (string) — Message shown to the recipient (max 300 chars)
  - allowed_rails (array) — Rails offered on the claim page. Default ["card"]
  - custom_fields (array) — Checkout-style fields to collect: { label, type (text|textarea|number|select|checkbox), required?, options? }
  - expires_in_days (integer) — Directed only. 1–90, default 7. Request links never expire
  - restrict_to_existing_customers (boolean) — Request only. Reject submissions from emails that aren't already your customer
  - slug (string) — Optional custom URL slug (auto-generated, unguessable, when omitted)
  - metadata (object) — Arbitrary key-value metadata

Response:
    {
      "data": {
        "id": "uuid",
        "object": "payout_link",
        "payout_link_number": 1,
        "payout_link_label": "#00001",
        "type": "directed",
        "slug": "k3f9d2a1b",
        "url": "https://paysio.com/payout/k3f9d2a1b",
        "amount": 5000,
        "currency": "USD",
        "recipient_email": "[email protected]",
        "note": "Your refund",
        "allowed_rails": ["card"],
        "custom_fields": [],
        "status": "pending",
        "payout_id": null,
        "expires_at": "2026-01-22T10:30:00.000Z",
        "metadata": {},
        "sandbox": true,
        "created_at": "2026-01-15T10:30:00.000Z"
      }
    }

Common errors:
  - 400 — Insufficient balance (directed needs amount + fee), missing recipient_email/amount, or not a Debit & Payouts workspace
  - 409 — Requested custom slug is taken or reserved

### GET /payout-links
List payout links, most recent first. Sandbox-aware.
Auth: Any API key
Query:
  - limit (integer) — 1-100, default 10
  - starting_after (string) — Pagination cursor (id of the last item on the previous page)
  - type (string) — Filter by "directed" or "request"
  - status (string) — Filter by status

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

### GET /payout-links/:id
Retrieve a single payout link (current status + linked payout id once claimed).
Auth: Any API key

### POST /payout-links/:id/cancel
Cancel a pending directed link (or an active request link) and release the held balance. 409 if already claimed / no longer cancellable.
Auth: Secret key (sk_*)

## Payout Requests (Paysio Debit & Payouts)

Submissions made against a reusable request-type payout link. Each submission captures the requester's debit card, so approving one runs the money-out to it. Approving checks your spendable balance at that moment (no up-front hold).

### GET /payout-requests
List submissions across your request links.
Auth: Any API key
Query:
  - limit (integer) — 1-100, default 10
  - starting_after (string) — Pagination cursor
  - payout_link_id (string) — Only submissions to this request link
  - status (string) — Filter by status: pending, approved, rejected, failed

Response:
    { "data": [ { "id": "uuid", "object": "payout_request", "payout_link_id": "link-uuid", "requester_email": "[email protected]", "amount": 2500, "currency": "USD", "status": "pending", "card_last_four": "4242", "payout_id": null, "created_at": "2026-01-15T10:30:00.000Z" } ], "has_more": false }

### GET /payout-requests/:id
Retrieve a single payout request.
Auth: Any API key

### POST /payout-requests/:id/approve
Approve a pending request and pay the requester. Returns the request with status "approved" and the resulting payout_id. 409 if no longer pending; 400 if balance is insufficient (the request reverts to pending so you can retry).
Auth: Secret key (sk_*)

### POST /payout-requests/:id/reject
Reject a pending request. No money moves.
Auth: Secret key (sk_*)

---

## 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 — enabled per mode (see the per-mode note under Payouts); a 400 means Debit & Payouts isn't active for this key's mode.
Auth: Any API key (publishable or secret)
Query:
  - currency (string, optional) — 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)

Query (optional):
  - gateway (string) — Per-request gateway override: "nmi", "paysio", "stripe", or "aptpay" (Paysio Debit & Payouts). Omit to use your workspace's configured default. Returns that gateway's client config (its iframe/keys are gateway-specific), so pass the SAME gateway value to POST /charges when you charge the resulting card. Must be configured for your workspace (else 400).

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              |
| order.fulfilled               | An order was marked fulfilled                  |
| order.cancelled               | An order was cancelled                         |

### 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                           |
| payout_label               | string  | Display form of payout_number, e.g. "#00017"       |
| 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"                               |
| payout_link_id             | string  | Source Payout Link (when the payout came from one) |
| payout_request_id          | string  | Source Payout Request (approved request links)     |
| 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,
        "payout_label": "#00017",
        "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,
        "payout_label": "#00018",
        "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 delivery includes two headers:

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

Verification:
1. Extract the timestamp (t) and signature (v1) from the Paysio-Signature header
2. Construct the signed payload: `${t}.${rawBody}`
3. Compute HMAC-SHA256 over that string with your webhook signing secret (the
   whsec_... value shown when you created the endpoint)
4. Compare it to v1 using a constant-time comparison

CRITICAL — use the RAW request body bytes, exactly as received, for the HMAC. Do
NOT JSON.parse and re-stringify it first: re-serialization changes spacing/key
order and the signature will never match. In Express, app.use(express.json())
consumes the raw body before your handler runs, so capture it with a verify hook
(or mount express.raw() on the webhook route only). If your endpoint returns 401,
this is almost always the cause.

Node / Express example:

    const crypto = require('crypto')

    // Capture the raw body ONLY on the webhook route.
    app.post('/webhooks/paysio',
      express.raw({ type: 'application/json' }),
      (req, res) => {
        const header = req.header('Paysio-Signature') || ''
        const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
        const t = parts.t, v1 = parts.v1
        const raw = req.body // a Buffer — the exact bytes Paysio signed

        const expected = crypto
          .createHmac('sha256', process.env.PAYSIO_WEBHOOK_SECRET)
          .update(`${t}.${raw}`)
          .digest('hex')

        const ok = v1 && crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))
        if (!ok) return res.status(401).send('invalid signature')

        // Optional: reject if t is older than ~5 minutes to prevent replay.
        const event = JSON.parse(raw.toString('utf8'))
        // ... handle event.type / event.data ...

        res.sendStatus(200) // ACK fast; only 2xx counts as delivered
      })

Respond 2xx as soon as you've verified and queued the event — anything else is
treated as a failed delivery and retried (see Retry Policy).

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

## AI Prompt Library

Building a Paysio integration with an AI coding assistant (Claude Code, Cursor, and others)? Paysio publishes ready-to-paste prompts plus an interactive prompt builder that assembles a tailored prompt for any combination of integration style (hosted checkout, custom Paysio.js UI, payment link), business model (one-time, subscription, credits, custom amount), product source, stack, wallets, 3D Secure, and webhooks.

- Interactive builder & library: https://paysio.com/docs/prompt-library
- The full set of rendered prompts is included at the end of https://paysio.com/llms-full.txt

Point your assistant at llms-full.txt for the complete API reference before generating integration code.

# AI Prompt Library

Ready-to-paste prompts for building a Paysio integration with an AI coding assistant (Claude Code, Cursor, and others). Copy a prompt below into your assistant, fill in your keys, and let it scaffold the integration. An interactive prompt builder that mixes and matches every option lives at https://paysio.com/docs/prompt-library.

## Combination matrix
Each prompt is composed from these choices:

- **Integration style**: Hosted Checkout / Custom UI (Paysio.js) / Payment Link
- **Business model**: One-time product / Subscription / Credits / top-up / Custom amount
- **Product source**: Existing Paysio products / Create via API / Inline line items
- **Payment methods**: Apple Pay / Google Pay / 3D Secure
- **Extras**: Webhooks / Customers / Save card & charge later / Tax / Discounts / coupons

## Prompts

### Hosted checkout for a one-time product
The fastest path: Paysio hosts the payment page, you just create a session and redirect.

```text
# Build a Paysio payment integration

You are an expert engineer integrating **Paysio** (a payments API) into my app. Follow this spec exactly. Before writing code, read the documentation pages linked under "Reference docs" below — and for anything not covered there, read the full reference at https://paysio.com/llms-full.txt.

## Context
- API base URL: `https://paysio.com/api/v1`
- Server-side requests authenticate with a secret key (`sk_test_...` sandbox / `sk_live_...` live) sent as `Authorization: Bearer sk_...`. NEVER expose the secret key in client code.
- Client-side tokenization uses the publishable key (`pk_test_...` / `pk_live_...`).
- All amounts are integers in the currency's minor units (e.g. cents).

## What I'm building
A Hosted Checkout integration, for one-time product, using inline line items, plus Webhooks.

## Requirements
- Use **Hosted Checkout Sessions**. Server-side, create a session with `POST /v1/checkout-sessions` and redirect the customer to the returned `url`. Paysio hosts the payment page and handles card entry, wallets, and 3D Secure. After payment Paysio redirects to your `success_url`.
- Charge a **one-time payment** for the selected product/amount.
- Pass **inline line items** (name, amount, quantity) directly into the checkout session / charge — no catalog to pre-create.
- Set up **webhooks**: register an endpoint, verify the signature, and react to events (payment succeeded/failed, subscription updated) to drive fulfillment.

## Constraints & gotchas
- Set `success_url` and `cancel_url` when creating the session.
- Do NOT fulfill on the redirect alone — confirm the result server-side (retrieve the session/transaction or wait for a webhook) before granting value.
- Respond 2xx quickly and process events idempotently — webhooks can be re-delivered.

## Reference docs
Open and read these before implementing:
- Interactive docs: https://paysio.com/docs
- Full API reference (plain text, best for AI): https://paysio.com/llms-full.txt
- Checkout Sessions: https://paysio.com/docs/checkout-sessions
- Webhooks: https://paysio.com/docs/webhooks

## Deliverables
1. Working integration code for the requirements above.
2. Sandbox-ready: use `pk_test_`/`sk_test_` keys and Paysio test cards (e.g. `4000000000001000` success, `4000000000001018` 3DS challenge, `4000000000000002` decline).
3. Clear handling of pending / failed / declined states with user-facing feedback.
4. No secret key in client code; all charges happen server-side.
```

### Custom card form with 3D Secure + wallets
Your own checkout UI built on Paysio.js, with Apple/Google Pay and 3DS authentication.

```text
# Build a Paysio payment integration

You are an expert engineer integrating **Paysio** (a payments API) into my app. Follow this spec exactly. Before writing code, read the documentation pages linked under "Reference docs" below — and for anything not covered there, read the full reference at https://paysio.com/llms-full.txt.

## Context
- API base URL: `https://paysio.com/api/v1`
- Server-side requests authenticate with a secret key (`sk_test_...` sandbox / `sk_live_...` live) sent as `Authorization: Bearer sk_...`. NEVER expose the secret key in client code.
- Client-side tokenization uses the publishable key (`pk_test_...` / `pk_live_...`).
- All amounts are integers in the currency's minor units (e.g. cents).

## What I'm building
A Custom UI (Paysio.js) integration, for one-time product, using inline line items, with Apple Pay / Google Pay and 3D Secure.

## Requirements
- Build a **custom checkout UI** with Paysio.js. Load `https://paysio.com/paysio.js`, init it with your publishable key, call `paysio.mountCardInputs(selector)` to render secure hosted card fields, then call `paysio.createToken()` on submit to get a single-use `ptok_` token. Send that token to your backend, which charges it with `POST /v1/charges` using the secret key.
- Charge a **one-time payment** for the selected product/amount.
- Pass **inline line items** (name, amount, quantity) directly into the checkout session / charge — no catalog to pre-create.
- Also accept **Apple Pay & Google Pay**. On hosted checkout these appear automatically. In a custom UI, use `paysio.elements().mountWallets(target)` and handle the `walletPayment` event.
- Enforce **3D Secure**. On hosted checkout it is automatic. In a custom UI, after `createToken()` call `paysio.threeDS().authenticate()`, render the challenge iframe when required, then pass the resulting `eci`, `authenticationValue`, and `dsTransId` to your charge call.

## Constraints & gotchas
- The mounted fields already include the CVC — never render your own CVC field; the CVC travels inside the `ptok_` token.
- Gateway is auto-detected via `GET /v1/tokenization-key` — never hardcode the processor.
- Raw card data must never reach your server; only the `ptok_` token does.
- Apple Pay requires your domain to be verified with Paysio.
- Wallets are not available on Paysio Debit & Payouts workspaces.

## Reference docs
Open and read these before implementing:
- Interactive docs: https://paysio.com/docs
- Full API reference (plain text, best for AI): https://paysio.com/llms-full.txt
- Paysio.js: https://paysio.com/docs/paysio-js
- Charges: https://paysio.com/docs/charges
- Wallets: https://paysio.com/docs/wallets
- 3D Secure: https://paysio.com/docs/three-ds

## Deliverables
1. Working integration code for the requirements above.
2. Sandbox-ready: use `pk_test_`/`sk_test_` keys and Paysio test cards (e.g. `4000000000001000` success, `4000000000001018` 3DS challenge, `4000000000000002` decline).
3. Clear handling of pending / failed / declined states with user-facing feedback.
4. No secret key in client code; all charges happen server-side.
```

### Subscription billing
Recurring billing with the Subscriptions API, customer records, and webhook-driven sync.

```text
# Build a Paysio payment integration

You are an expert engineer integrating **Paysio** (a payments API) into my app. Follow this spec exactly. Before writing code, read the documentation pages linked under "Reference docs" below — and for anything not covered there, read the full reference at https://paysio.com/llms-full.txt.

## Context
- API base URL: `https://paysio.com/api/v1`
- Server-side requests authenticate with a secret key (`sk_test_...` sandbox / `sk_live_...` live) sent as `Authorization: Bearer sk_...`. NEVER expose the secret key in client code.
- Client-side tokenization uses the publishable key (`pk_test_...` / `pk_live_...`).
- All amounts are integers in the currency's minor units (e.g. cents).

## What I'm building
A Hosted Checkout integration, for subscription, using existing paysio products, plus Customers, Webhooks.

## Requirements
- Use **Hosted Checkout Sessions**. Server-side, create a session with `POST /v1/checkout-sessions` and redirect the customer to the returned `url`. Paysio hosts the payment page and handles card entry, wallets, and 3D Secure. After payment Paysio redirects to your `success_url`.
- Set up **recurring billing** with the Subscriptions API. Create a subscription for the customer against a recurring price; Paysio charges automatically each billing cycle.
- Fetch the existing catalog from Paysio with `GET /v1/products` and render it. Reference products by their product/price id at checkout.
- Create and reuse **Customers** (`POST /v1/customers`) so payments, saved cards, and subscriptions attach to one persistent customer record.
- Set up **webhooks**: register an endpoint, verify the signature, and react to events (payment succeeded/failed, subscription updated) to drive fulfillment.

## Constraints & gotchas
- Set `success_url` and `cancel_url` when creating the session.
- Do NOT fulfill on the redirect alone — confirm the result server-side (retrieve the session/transaction or wait for a webhook) before granting value.
- Persist the subscription id and current period end on your side.
- Handle subscription/invoice webhooks (renewal succeeded, payment failed, cancelled) to keep app access in sync.
- Respond 2xx quickly and process events idempotently — webhooks can be re-delivered.

## Reference docs
Open and read these before implementing:
- Interactive docs: https://paysio.com/docs
- Full API reference (plain text, best for AI): https://paysio.com/llms-full.txt
- Checkout Sessions: https://paysio.com/docs/checkout-sessions
- Subscriptions: https://paysio.com/docs/subscriptions
- Products: https://paysio.com/docs/products
- Customers: https://paysio.com/docs/customers
- Webhooks: https://paysio.com/docs/webhooks

## Deliverables
1. Working integration code for the requirements above.
2. Sandbox-ready: use `pk_test_`/`sk_test_` keys and Paysio test cards (e.g. `4000000000001000` success, `4000000000001018` 3DS challenge, `4000000000000002` decline).
3. Clear handling of pending / failed / declined states with user-facing feedback.
4. No secret key in client code; all charges happen server-side.
```

### Credits / wallet top-up system
Let users pay to load an in-app balance, credited idempotently after confirmation.

```text
# Build a Paysio payment integration

You are an expert engineer integrating **Paysio** (a payments API) into my app. Follow this spec exactly. Before writing code, read the documentation pages linked under "Reference docs" below — and for anything not covered there, read the full reference at https://paysio.com/llms-full.txt.

## Context
- API base URL: `https://paysio.com/api/v1`
- Server-side requests authenticate with a secret key (`sk_test_...` sandbox / `sk_live_...` live) sent as `Authorization: Bearer sk_...`. NEVER expose the secret key in client code.
- Client-side tokenization uses the publishable key (`pk_test_...` / `pk_live_...`).
- All amounts are integers in the currency's minor units (e.g. cents).

## What I'm building
A Hosted Checkout integration, for credits / top-up, using inline line items, plus Webhooks.

## Requirements
- Use **Hosted Checkout Sessions**. Server-side, create a session with `POST /v1/checkout-sessions` and redirect the customer to the returned `url`. Paysio hosts the payment page and handles card entry, wallets, and 3D Secure. After payment Paysio redirects to your `success_url`.
- Build a **credits / wallet top-up** system: the customer pays to load an in-app balance. Track the balance in your own database, increase it after a confirmed payment, and decrease it as the user spends.
- Pass **inline line items** (name, amount, quantity) directly into the checkout session / charge — no catalog to pre-create.
- Set up **webhooks**: register an endpoint, verify the signature, and react to events (payment succeeded/failed, subscription updated) to drive fulfillment.

## Constraints & gotchas
- Set `success_url` and `cancel_url` when creating the session.
- Do NOT fulfill on the redirect alone — confirm the result server-side (retrieve the session/transaction or wait for a webhook) before granting value.
- Only credit the balance after the payment is confirmed server-side — never on a client redirect.
- Make crediting idempotent, keyed on the Paysio transaction id, so a replayed webhook cannot double-credit.
- Respond 2xx quickly and process events idempotently — webhooks can be re-delivered.

## Reference docs
Open and read these before implementing:
- Interactive docs: https://paysio.com/docs
- Full API reference (plain text, best for AI): https://paysio.com/llms-full.txt
- Checkout Sessions: https://paysio.com/docs/checkout-sessions
- Charges: https://paysio.com/docs/charges
- Webhooks: https://paysio.com/docs/webhooks

## Deliverables
1. Working integration code for the requirements above.
2. Sandbox-ready: use `pk_test_`/`sk_test_` keys and Paysio test cards (e.g. `4000000000001000` success, `4000000000001018` 3DS challenge, `4000000000000002` decline).
3. Clear handling of pending / failed / declined states with user-facing feedback.
4. No secret key in client code; all charges happen server-side.
```

### Sync & display your Paysio products
Fetch your existing catalog from Paysio and render a storefront with hosted checkout.

```text
# Build a Paysio payment integration

You are an expert engineer integrating **Paysio** (a payments API) into my app. Follow this spec exactly. Before writing code, read the documentation pages linked under "Reference docs" below — and for anything not covered there, read the full reference at https://paysio.com/llms-full.txt.

## Context
- API base URL: `https://paysio.com/api/v1`
- Server-side requests authenticate with a secret key (`sk_test_...` sandbox / `sk_live_...` live) sent as `Authorization: Bearer sk_...`. NEVER expose the secret key in client code.
- Client-side tokenization uses the publishable key (`pk_test_...` / `pk_live_...`).
- All amounts are integers in the currency's minor units (e.g. cents).

## What I'm building
A Hosted Checkout integration, for one-time product, using existing paysio products.

## Requirements
- Use **Hosted Checkout Sessions**. Server-side, create a session with `POST /v1/checkout-sessions` and redirect the customer to the returned `url`. Paysio hosts the payment page and handles card entry, wallets, and 3D Secure. After payment Paysio redirects to your `success_url`.
- Charge a **one-time payment** for the selected product/amount.
- Fetch the existing catalog from Paysio with `GET /v1/products` and render it. Reference products by their product/price id at checkout.

## Constraints & gotchas
- Set `success_url` and `cancel_url` when creating the session.
- Do NOT fulfill on the redirect alone — confirm the result server-side (retrieve the session/transaction or wait for a webhook) before granting value.

## Reference docs
Open and read these before implementing:
- Interactive docs: https://paysio.com/docs
- Full API reference (plain text, best for AI): https://paysio.com/llms-full.txt
- Checkout Sessions: https://paysio.com/docs/checkout-sessions
- Products: https://paysio.com/docs/products

## Deliverables
1. Working integration code for the requirements above.
2. Sandbox-ready: use `pk_test_`/`sk_test_` keys and Paysio test cards (e.g. `4000000000001000` success, `4000000000001018` 3DS challenge, `4000000000000002` decline).
3. Clear handling of pending / failed / declined states with user-facing feedback.
4. No secret key in client code; all charges happen server-side.
```

### Accept Apple Pay & Google Pay
A lightweight wallet-first checkout using Paysio.js elements.

```text
# Build a Paysio payment integration

You are an expert engineer integrating **Paysio** (a payments API) into my app. Follow this spec exactly. Before writing code, read the documentation pages linked under "Reference docs" below — and for anything not covered there, read the full reference at https://paysio.com/llms-full.txt.

## Context
- API base URL: `https://paysio.com/api/v1`
- Server-side requests authenticate with a secret key (`sk_test_...` sandbox / `sk_live_...` live) sent as `Authorization: Bearer sk_...`. NEVER expose the secret key in client code.
- Client-side tokenization uses the publishable key (`pk_test_...` / `pk_live_...`).
- All amounts are integers in the currency's minor units (e.g. cents).

## What I'm building
A Custom UI (Paysio.js) integration, for one-time product, using inline line items, with Apple Pay / Google Pay.

## Requirements
- Build a **custom checkout UI** with Paysio.js. Load `https://paysio.com/paysio.js`, init it with your publishable key, call `paysio.mountCardInputs(selector)` to render secure hosted card fields, then call `paysio.createToken()` on submit to get a single-use `ptok_` token. Send that token to your backend, which charges it with `POST /v1/charges` using the secret key.
- Charge a **one-time payment** for the selected product/amount.
- Pass **inline line items** (name, amount, quantity) directly into the checkout session / charge — no catalog to pre-create.
- Also accept **Apple Pay & Google Pay**. On hosted checkout these appear automatically. In a custom UI, use `paysio.elements().mountWallets(target)` and handle the `walletPayment` event.

## Constraints & gotchas
- The mounted fields already include the CVC — never render your own CVC field; the CVC travels inside the `ptok_` token.
- Gateway is auto-detected via `GET /v1/tokenization-key` — never hardcode the processor.
- Raw card data must never reach your server; only the `ptok_` token does.
- Apple Pay requires your domain to be verified with Paysio.
- Wallets are not available on Paysio Debit & Payouts workspaces.

## Reference docs
Open and read these before implementing:
- Interactive docs: https://paysio.com/docs
- Full API reference (plain text, best for AI): https://paysio.com/llms-full.txt
- Paysio.js: https://paysio.com/docs/paysio-js
- Charges: https://paysio.com/docs/charges
- Wallets: https://paysio.com/docs/wallets

## Deliverables
1. Working integration code for the requirements above.
2. Sandbox-ready: use `pk_test_`/`sk_test_` keys and Paysio test cards (e.g. `4000000000001000` success, `4000000000001018` 3DS challenge, `4000000000000002` decline).
3. Clear handling of pending / failed / declined states with user-facing feedback.
4. No secret key in client code; all charges happen server-side.
```

### Webhooks to fulfill orders
A backend that listens for payment events and fulfills orders idempotently.

```text
# Build a Paysio payment integration

You are an expert engineer integrating **Paysio** (a payments API) into my app. Follow this spec exactly. Before writing code, read the documentation pages linked under "Reference docs" below — and for anything not covered there, read the full reference at https://paysio.com/llms-full.txt.

## Context
- API base URL: `https://paysio.com/api/v1`
- Server-side requests authenticate with a secret key (`sk_test_...` sandbox / `sk_live_...` live) sent as `Authorization: Bearer sk_...`. NEVER expose the secret key in client code.
- Client-side tokenization uses the publishable key (`pk_test_...` / `pk_live_...`).
- All amounts are integers in the currency's minor units (e.g. cents).

## What I'm building
A Hosted Checkout integration, for one-time product, using existing paysio products, plus Webhooks.

## Requirements
- Use **Hosted Checkout Sessions**. Server-side, create a session with `POST /v1/checkout-sessions` and redirect the customer to the returned `url`. Paysio hosts the payment page and handles card entry, wallets, and 3D Secure. After payment Paysio redirects to your `success_url`.
- Charge a **one-time payment** for the selected product/amount.
- Fetch the existing catalog from Paysio with `GET /v1/products` and render it. Reference products by their product/price id at checkout.
- Set up **webhooks**: register an endpoint, verify the signature, and react to events (payment succeeded/failed, subscription updated) to drive fulfillment.

## Constraints & gotchas
- Set `success_url` and `cancel_url` when creating the session.
- Do NOT fulfill on the redirect alone — confirm the result server-side (retrieve the session/transaction or wait for a webhook) before granting value.
- Respond 2xx quickly and process events idempotently — webhooks can be re-delivered.

## Reference docs
Open and read these before implementing:
- Interactive docs: https://paysio.com/docs
- Full API reference (plain text, best for AI): https://paysio.com/llms-full.txt
- Checkout Sessions: https://paysio.com/docs/checkout-sessions
- Products: https://paysio.com/docs/products
- Webhooks: https://paysio.com/docs/webhooks

## Deliverables
1. Working integration code for the requirements above.
2. Sandbox-ready: use `pk_test_`/`sk_test_` keys and Paysio test cards (e.g. `4000000000001000` success, `4000000000001018` 3DS challenge, `4000000000000002` decline).
3. Clear handling of pending / failed / declined states with user-facing feedback.
4. No secret key in client code; all charges happen server-side.
```

### Save a card and charge later
Vault a customer payment method at checkout, then charge it on future purchases.

```text
# Build a Paysio payment integration

You are an expert engineer integrating **Paysio** (a payments API) into my app. Follow this spec exactly. Before writing code, read the documentation pages linked under "Reference docs" below — and for anything not covered there, read the full reference at https://paysio.com/llms-full.txt.

## Context
- API base URL: `https://paysio.com/api/v1`
- Server-side requests authenticate with a secret key (`sk_test_...` sandbox / `sk_live_...` live) sent as `Authorization: Bearer sk_...`. NEVER expose the secret key in client code.
- Client-side tokenization uses the publishable key (`pk_test_...` / `pk_live_...`).
- All amounts are integers in the currency's minor units (e.g. cents).

## What I'm building
A Custom UI (Paysio.js) integration, for one-time product, using existing paysio products, plus Customers, Save card & charge later.

## Requirements
- Build a **custom checkout UI** with Paysio.js. Load `https://paysio.com/paysio.js`, init it with your publishable key, call `paysio.mountCardInputs(selector)` to render secure hosted card fields, then call `paysio.createToken()` on submit to get a single-use `ptok_` token. Send that token to your backend, which charges it with `POST /v1/charges` using the secret key.
- Charge a **one-time payment** for the selected product/amount.
- Fetch the existing catalog from Paysio with `GET /v1/products` and render it. Reference products by their product/price id at checkout.
- Create and reuse **Customers** (`POST /v1/customers`) so payments, saved cards, and subscriptions attach to one persistent customer record.
- Support **save card & charge later**: vault the payment method on the customer at checkout, then charge the saved payment method on future purchases without re-collecting card details.

## Constraints & gotchas
- The mounted fields already include the CVC — never render your own CVC field; the CVC travels inside the `ptok_` token.
- Gateway is auto-detected via `GET /v1/tokenization-key` — never hardcode the processor.
- Raw card data must never reach your server; only the `ptok_` token does.
- Saving a card for later requires a customer to attach the payment method to.

## Reference docs
Open and read these before implementing:
- Interactive docs: https://paysio.com/docs
- Full API reference (plain text, best for AI): https://paysio.com/llms-full.txt
- Paysio.js: https://paysio.com/docs/paysio-js
- Charges: https://paysio.com/docs/charges
- Products: https://paysio.com/docs/products
- Customers: https://paysio.com/docs/customers
- Payment Methods: https://paysio.com/docs/payment-methods

## Deliverables
1. Working integration code for the requirements above.
2. Sandbox-ready: use `pk_test_`/`sk_test_` keys and Paysio test cards (e.g. `4000000000001000` success, `4000000000001018` 3DS challenge, `4000000000000002` decline).
3. Clear handling of pending / failed / declined states with user-facing feedback.
4. No secret key in client code; all charges happen server-side.
```