# Paysio API Reference

## Base URL

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

    https://paysio.com/api/v1

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

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

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

---

## Authentication

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

    Authorization: Bearer sk_live_your_api_key

### API Key Types

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

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

## Pagination

List endpoints use cursor-based pagination.

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

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

## Errors

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

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

---

## Paysio.js (Client-Side SDK)

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

### How it works

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

### Prerequisites

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

### Environments

### Step 1 — Include the script

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

This exposes a global Paysio() function.

### Step 2 — Initialize

    const paysio = Paysio('pk_live_your_publishable_key');

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

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

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

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

Use mountCardInputs() to render secure card fields. This works with all payment processors (Stripe, NMI).

    <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',
      },
    });

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 only)

If your payment processor is NMI, 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.createToken(cardData) | Tokenize card data from your form. Takes { number, exp_month, exp_year, cvv }. 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. 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.

### Troubleshooting

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

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

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

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

---

## Apple Pay & Google Pay (Wallets)

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

### Prerequisites

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

### Mount wallet buttons

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

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

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

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

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

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

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

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

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

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

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

### HTML layout

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

---

## 3D Secure (3DS)

3D Secure adds extra verification for card payments, reducing fraud and enabling liability shift. 3DS is a **standalone step** that uses the raw card number and expiry — since you build your own card form with Paysio.js, you already have these values. Paysio.js provides a client-side threeDS API that handles the entire flow — authentication, device fingerprinting, and challenge iframes.

**IMPORTANT: 3DS must be performed BEFORE tokenization.** 3DS requires the raw (untokenized) card number and expiry to authenticate with the card issuer. Once you tokenize the card, the raw data is no longer available. The correct order is:

    1. Collect card details from your form (number, expiry, CVV)
    2. Run 3DS authentication with the RAW card number and expiry → get 3DS result
    3. Tokenize the card with paysio.createToken() → get payment token
    4. Send both the token AND the 3DS result to your server to create a charge

Do NOT tokenize first and then attempt 3DS — it will fail because the token cannot be used for 3DS authentication.

### How 3DS works

1. Your page calls threeDS.authenticate() with the raw card details and amount.
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');
    const threeDS = paysio.threeDS();

    // Step 1: Run 3DS FIRST (with raw card data from your form)
    try {
      const result = await threeDS.authenticate({
        amount: 29.99,
        cardNumber: '4111111111111111',  // Raw card number — NOT a token
        cardExp: '1225',  // MMYY format
        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:', result.status); // 'Y' or 'A' = success
      console.log('ECI:', result.eci);
      console.log('CAVV:', result.authenticationValue);
    } catch (err) {
      console.error('3DS failed:', err.message);
      // You may still proceed without 3DS (no liability shift)
    }

    // Step 2: THEN tokenize the card
    const { token } = await paysio.createToken({
      number: '4111111111111111',
      exp_month: '12',
      exp_year: '25',
      cvv: '123',
    });

    // 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: result }),
    });

### 3DS result fields

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

---

## Products

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

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

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

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

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

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

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

---

## Customers

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

### GET /customers
List customers. Automatically filtered by sandbox/live based on API key mode.
Auth: Any API key
Query: limit (integer), starting_after (string)

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

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

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

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

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

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

---

## Payment Methods

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

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

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

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

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

### GET /customers/:id/payment-methods
List all saved payment methods for a customer. Returns masked card details.
Auth: Any API key

Response:
    { "data": [{ "billing_id": "1234567890", "card_last_four": "1111", "card_type": "visa", "cc_exp": "1228" }] }

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

---

## Tokens

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

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

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

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

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

---

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

---

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

  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

  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:
  - 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

Response:
    {
      "data": {
        "id": "uuid",
        "amount": 2999,
        "currency": "USD",
        "status": "pending_settlement",
        "cardType": "visa",
        "cardLastFour": "4242",
        "nmiTransactionId": "123456789",
        "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_*)
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_*)

---

## Tokenization Key

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

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

---

## 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     |
| payment.refunded        | A transaction was refunded               |
| payment.voided          | A pending transaction was voided         |
| subscription.created          | A subscription was created via the API         |
| subscription.scheduled        | A scheduled subscription was created (starts in the future) |
| subscription.activated        | A scheduled subscription was activated by the renewal worker |
| subscription.renewed          | A subscription was automatically renewed       |
| subscription.cancel_scheduled | A subscription is scheduled to cancel at period end |
| subscription.cancelled        | A subscription was cancelled                   |
| subscription.paused           | A subscription was paused                      |
| subscription.resumed          | A paused subscription was resumed              |

### Webhook Payload

All webhook payloads follow this structure:

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

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

### Event Data Fields

#### payment.completed

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

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

Example:

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

#### payment.refunded

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

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

Example:

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

#### payment.voided

Fired when a pending (unsettled) transaction is voided.

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

Example:

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

#### subscription.created

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

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

Example:

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

#### subscription.scheduled

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

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

Example:

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

#### subscription.activated

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

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

Example:

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

#### subscription.renewed

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

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

Example:

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

#### subscription.cancel_scheduled

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

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

Example:

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

#### subscription.cancelled

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

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

Example:

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

#### subscription.paused

Fired when a subscription is paused.

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

Example:

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

#### subscription.resumed

Fired when a paused subscription is resumed.

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

Example:

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

### Verifying Signatures

Every webhook includes a Paysio-Signature header:

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

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

### Retry Policy

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

Deliveries time out after 10 seconds.