# 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. ## Rate limits Write endpoints are rate limited per minute. Every response carries your current ceiling, so you never have to guess it: | Header | Meaning | |-----------------------|--------------------------------------------------| | X-RateLimit-Limit | Requests allowed in the current minute | | X-RateLimit-Remaining | Requests left in it | | X-RateLimit-Tier | Your workspace's tier (see below) | Exceeding a limit returns 429 with a "Retry-After" header in seconds. Wait that long and retry; a 429 means the request was not processed, so retrying is always safe (no charge, payout, or token was created). Limits scale with the workspace's own volume, so integrations do not have to ask for a raise as they grow. The tier is recalculated from approved live charges and payouts over the last 30 days; sandbox activity does not count toward it. | Tier | Approved live charges + payouts (30d) | Limit | |-------------|---------------------------------------|----------------| | new | under 50 | base | | established | 50+ | 3x base | | scaled | 500+ | 8x base | | high_volume | 5,000+ | 20x base | Base limits per minute: 100 on POST /charges, 30 on POST /payouts, 60 on POST /tokens, 20 on 3-D Secure authentication. A tier change takes up to 15 minutes to apply. If you need a higher ceiling than your tier gives you, contact support rather than retrying into the 429. ## 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. ### Failed charges A charge that is declined (402) or that could not be completed (503) carries extra fields naming the cause, so the "error" sentence never has to be parsed: { "error": "Charge declined: Insufficient funds (202)", "code": "insufficient_funds", "decline_code": "202", "category": "insufficient_funds", "reference_id": "8134729", "data": { ...transaction... } } - "code" is the stable Paysio code — branch on this. - "decline_code" is the gateway's own code, verbatim (NMI "202", AptPay "D013"). Null when the gateway did not supply one. - "category" is the coarse bucket: | category | What to do | |---------------------|-------------------------------------------------------------------| | declined | Issuer said no. Ask for another card; retrying will not help. | | insufficient_funds | Ask for another card. | | invalid_card | Number or expiry is wrong — have the customer re-enter it. | | cvc | Security code missing or wrong — collect it again. | | unsupported_card | This card type cannot be used on this workspace's gateway. | | duplicate | Gateway matched a recent identical attempt. Wait before retrying. | | configuration | Merchant-account problem, not the card. Contact support. | | gateway_unavailable | Outcome UNKNOWN (503) — see below. | | unknown | Unclassified. Read "error" and quote "reference_id". | The status code tells you which of the three outcomes you got: 402 Definite failure. No money moved. Safe to show the customer the reason and to retry with a NEW reference_id once they fix it. 503 UNKNOWN outcome — the gateway never gave a clean answer. This does NOT mean the charge failed. Do not tell the customer it failed and do not charge again with a NEW reference_id. Retry with the SAME reference_id: that request is refused with 409 while the first attempt is unresolved, which is what stops a double charge. A card problem detected before the charge is ever attempted (an unusable card, a missing security code, a card type the gateway will not accept) is a definite failure and returns 402, not 503 — so an unusable card no longer looks like an outage. - "reference_id" identifies the payment log for support. Always quote it. --- ## Testing Data Sandbox runs on pk_test_ / sk_test_ keys and never touches real money. Which test cards work depends on the gateway behind the workspace — a Stripe card is rejected on an NMI workspace and vice versa. Call GET /tokenization-key with a publishable key and read processor_type: | processor_type | Gateway | Test data to use | |----------------|------------------------------------|-------------------------------| | nmi | NMI | Paysio (NMI) below | | paysio | Paysio High Risk (white-label NMI) | Paysio (NMI) below — identical | | stripe | Stripe | Stripe below | | aptpay | Paysio Debit & Payouts | Debit & Payouts below | With Smart Routing a single POST /charges can land on either gateway depending on the card BIN — the response processorType shows where it went. ### Paysio (NMI) test cards Expiry 10/29 (any future date works), CVV 999. | Brand | Number | CVV | |------------------|---------------------|------| | Visa | 4111111111111111 | 999 | | Mastercard | 5431111111111111 | 999 | | Discover | 6011000991300009 | 999 | | American Express | 341111111111111 | 9997 | | Diners Club | 30205252489926 | 999 | | JCB | 3541963594572595 | 999 | | Maestro | 6799990100000000019 | 999 | Test ACH bank account (type bank_account, then charge with rail "ach"): | Field | Value | |----------------|-----------| | Routing number | 490000018 | | Account number | 24413815 | | Account type | checking | On NMI the amount drives the outcome — the card number almost always approves: | Outcome | How to trigger it | |----------------------|----------------------------------------------------------------| | Approved | amount of 100 ($1.00) or more | | Declined | amount below 100 — e.g. 50 | | Fatal/gateway error | an invalid card number, e.g. 4111111111111129 | | AVS match | billing_address_1 "888" and billing_postal_code "77777" | | CVV match | CVV 999 | Keep test amounts inside roughly $0.25–$130.00 — outside that the staging gateway declines as out of range. Do not combine a trigger amount with an AVS/CVV trigger card; the trigger amount bypasses normal authorization and the results contradict. ### Stripe test cards Any future expiry, any CVC (4 digits for Amex). The card number drives the outcome. | Number | Brand | Result | |------------------|-----------------------|------------------------------------------| | 4242424242424242 | Visa | Success | | 4000056655665556 | Visa (debit) | Success | | 5555555555554444 | Mastercard | Success | | 2223003122003222 | Mastercard (2-series) | Success | | 5200828282828210 | Mastercard (debit) | Success | | 378282246310005 | American Express | Success | | 6011111111111117 | Discover | Success | | 4000000000000002 | Visa | Declined — generic_decline | | 4000000000009995 | Visa | Declined — insufficient_funds | | 4000000000009987 | Visa | Declined — lost_card | | 4000000000009979 | Visa | Declined — stolen_card | | 4000000000006975 | Visa | Declined — card_velocity_exceeded | | 4000000000000069 | Visa | Expired card | | 4000000000000127 | Visa | Incorrect CVC | | 4000000000000119 | Visa | Processing error | | 4242424242424241 | Visa | Incorrect number (fails Luhn) | | 4000000000000101 | Visa | CVC check fails | | 4000000000000036 | Visa | Postal code check fails | | 4000000000000028 | Visa | Address line 1 check fails | Stripe workspaces are card-only on Paysio — bank_account payment methods and rail "ach" charges route through NMI or Debit & Payouts instead. For reference, Stripe's own ACH sandbox uses routing number 110000000 with account 000123456789 (success), 000222222227 (insufficient funds), 000111111113 (account closed), 000111111116 (no account), 000333333335 (debit not authorized). ### Paysio Debit & Payouts test cards Any future expiry, any CVC. "Funds availability" is what POST /account-checks reports for a payout to that card. | Number | Network | Type | Funds availability | |------------------|------------|---------|----------------------| | 5333619503715702 | Mastercard | Debit | Immediate | | 5113400335932393 | Mastercard | Debit | Next business day | | 5374340047244865 | Mastercard | Prepaid | Immediate | | 5407795354428594 | Mastercard | Prepaid | Next business day | | 4111111110124511 | Mastercard | Credit | Not payout eligible | | 4916900573237001 | Visa | Debit | Immediate | | 4729260119078493 | Visa | Debit | Next business day | | 4703063492858097 | Visa | Prepaid | Immediate | | 4539803462059337 | Visa | Prepaid | Next business day | | 4916451102321668 | Visa | Credit | Not payout eligible | | 5525630721424710 | Interac | Debit | Immediate (CAD) | Credit cards are accepted for charges but never for payouts: POST /account-checks returns ACCOUNT_NOT_ELIGIBLE and the saved method reports payout_eligible: false. Send a complete US billing address with every card — a card saved without a billing postal code cannot be paid out to later. For ACH and RTP, use routing number (ABA) 021000021 with any 4–17 digit account number. ### Debit & Payouts payout failure triggers Sandbox keys the payout outcome off the amount. Send one of these amounts (in cents) to POST /payouts and the matching failure arrives on the payout.failed webhook with that error_code. Any other amount settles normally to payout.paid. | amount | Code | Meaning | |--------|------|--------------------------------------------------| | 51 | M001 | Account not eligible | | 52 | M002 | Invalid input value | | 53 | M003 | Declined | | 54 | M004 | System error | | 55 | M005 | Invalid input length | | 56 | M006 | Invalid input format | | 57 | M007 | Missing required input | | 58 | M008 | Limit exceeded | | 59 | M009 | Recipient account not eligible to receive funds | | 60 | M010 | Amount exceeds network limit | | 61 | M011 | Invalid card type | | 62 | M012 | Reversal | | 63 | M013 | Chargeback | | 64 | M014 | Representment | | 65 | M015 | Network error | | 66 | M016 | Cutoff timing | | 67 | M017 | Amount mismatch | | 68 | M018 | Unspecified error | | 69 | M019 | Service error | | 72 | M021 | Cancelled by the receiving party | Example — trigger a declined payout (M003): POST /payouts { "customer_id": "customer-uuid", "payment_method_id": "instrument-id", "amount": 53, "rail": "card" } → 200 { "status": "pending", ... } → webhook payout.failed { "error_code": "M003", "error_message": "Declined" } ACH debits return asynchronously with standard NACHA return codes — R01 insufficient funds, R02 account closed, R03 no account, R04 invalid account number, R07 authorization revoked, R08 payment stopped, R10 not authorized, R16 account frozen — delivered as payment.disputed with dispute_kind "ach_return". RTP failures use ISO reason codes such as AC04 (account closed), AM04 (insufficient funds), and NOAT (account does not support this message type). ### 3-D Secure test cards Paysio runs 3DS through its own authentication service, independent of the gateway that authorizes the charge, so 3DS has its own card set. These PANs pick the authentication outcome. Any future expiry, any CVC. | Number | Brand | Flow / result | |------------------|------------|----------------------------------------------------------| | 4147463011110134 | Visa | Frictionless approval — status Y, no challenge | | 4016360000000493 | Visa | Challenge with an outcome picker — choose any result | | 5239290700000102 | Mastercard | Frictionless approval — status Y | | 5239290700000151 | Mastercard | Challenge then approval — challenge password secret!33 | | 4147463011110142 | Visa | Denied — status N | | 4147463011110159 | Visa | Attempted — status A | | 4147463011110175 | Visa | Rejected — status R | | 4147463011110167 | Visa | Unavailable — status U | | 5188340000000629 | Mastercard | Denied — status N | | 5188340000000937 | Mastercard | Attempted — status A | | 5188340000000952 | Mastercard | Rejected — status R | | 5188340000000445 | Mastercard | Unable to authenticate — status U | | 4111111111111111 | Visa | Not enrolled — tokenizes fine, but no authentication runs | These 3DS PANs are NOT gateway test cards. They exercise authentication only — the POST /charges that follows is expected to be declined by NMI, Stripe, or Debit & Payouts. To test 3DS and a successful authorization end to end, run the flow twice: once with a 3DS card to check the three_ds result, once with a gateway test card above to check the charge. They are also deliberately not Luhn-valid. The hosted card fields relax number validation in sandbox so they can be entered; your own form should do the same if it validates card numbers client-side. redirect_url must be a real, publicly reachable URL that accepts the challenge POST — localhost is rejected. Use a tunnel when testing locally. --- ## 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 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. ### Embedded Checkout Embedded Checkout mounts Paysio's complete payment block in your site: email, phone, Quick Checkout, saved and new cards, bank accounts when enabled, billing and shipping addresses, Apple Pay and Google Pay, and 3D Secure. Wallet buttons appear automatically when they are enabled in Settings > Checkout and supported by the browser, device, and checkout. There is no separate Embedded Checkout wallet option. Apple Pay also requires HTTPS, a verified parent-page domain, Safari 17 or newer for cross-origin embeds, and a non-recurring live checkout. Create the session on your server with a secret key. allowed_parent_origins is an exact allowlist, not a wildcard: const session = await fetch('https://paysio.com/api/v1/checkout-sessions', { method: 'POST', headers: { Authorization: 'Bearer sk_live_...', 'Content-Type': 'application/json' }, body: JSON.stringify({ ui_mode: 'embedded', allowed_parent_origins: ['https://shop.example.com'], show_order_summary: true, line_items: [{ product_id: 'prod_...', quantity: 1 }], customer_email: 'buyer@example.com', customer_phone: '+15551234567', email_field_mode: 'read_only', phone_field_mode: 'hidden', quick_checkout_behavior: 'on_load', }), }).then(r => r.json()); Pass only data.client_secret to the browser, then mount it: const paysio = Paysio('pk_live_...'); const checkout = await paysio.mountCheckout('#checkout', { clientSecret: session.client_secret, appearance: { variables: { colorPrimary: '#635bff', colorBackground: '#ffffff', colorText: '#18181b', colorTextMuted: '#71717a', colorBorder: '#d4d4d8', borderRadius: '10px', buttonBorderRadius: '999px', fontFamily: 'Inter, sans-serif', fontSize: '16px', }, fonts: [{ cssSrc: 'https://fonts.googleapis.com/css2?family=Inter' }], }, onComplete: result => console.log(result.orderLabel), onError: error => console.error(error.message), }); The iframe resizes automatically. checkout.updateAppearance(appearance) changes the theme after mounting, checkout.on(event, callback) subscribes to ready, complete, error, or resize, and checkout.unmount() removes it. Treat onComplete as browser UX only: fulfill orders from payment.completed webhooks or after retrieving the Checkout Session server-side. ### 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.
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 (data) => { ... }, [email, ...]); processRef.current = processPayment; // keep ref in sync // In useEffect: elements.on('walletPayment', (data) => processRef.current(data)); 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.mountCheckout(target, opts) | Mount the complete Embedded Checkout. opts requires clientSecret and accepts appearance, onReady, onComplete, and onError. onComplete receives transactionId, orderNumber, orderLabel, and confirmationToken. Returns Promise<{ iframe, on, updateAppearance, unmount }>. Includes eligible Apple Pay / Google Pay buttons. | | 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 the official Apple Pay / Google Pay buttons (Paysio Wallets). opts: { amount, currency, country, collectShipping, buttonHeight, gap, direction, applePay: { buttonStyle, buttonType, locale, borderRadius, height, padding }, googlePay: { buttonColor, buttonType, buttonRadius, buttonLocale, buttonSizeMode, buttonBorderType, height } } — see "Button appearance" in the Wallets guide. | | elements.updateAmount(amount) | Updates the amount charged when a wallet sheet is next opened. | | elements.on(event, fn) | Listen for 'ready' (wallets loaded), 'error', or 'walletPayment' events. 'walletPayment' receives { walletType, paysioWallet, wallet, threeDS? } — threeDS is present when a Google Pay 3DS step-up ran; forward it as three_ds. Return a Promise resolving true/false to control the Apple Pay sheet result. | | elements.unmount() | Removes wallet buttons and cleans up. | | threeDS.authenticate(opts) | Initiates 3DS verification. Pass { amount, token } (ptok_ from createToken(), not consumed) for a NEW card, or { amount, paymentMethodId } for a card already SAVED on file. Returns Promise with { status, eci, authenticationValue, dsTransId, threeDSServerTransID, threeDsVersion } — forward the whole result to the charge. | | 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 or before your scripts. - In React/Vue, access window.Paysio inside useEffect/onMounted. --- ## Apple Pay & Google Pay (Wallets) Paysio serves Apple Pay and Google Pay natively — Paysio.js renders the official wallet buttons, the encrypted wallet token is decrypted by Paysio server-side, and the charge settles on your card gateway. No Apple/Google merchant accounts, gateway wallet setup, or certificate work on your side. How a wallet payment flows: 1. `elements.mountWallets()` renders the official Apple Pay / Google Pay buttons on your page. 2. The buyer authorizes in the wallet sheet. Paysio.js emits a `walletPayment` event with a `paysioWallet` payload (the encrypted token) and `wallet` (the buyer's contact from the sheet). 3. Your page sends both to your server, and your server calls **POST /charges** with `paysio_wallet` and `wallet`. Paysio decrypts the token and charges it. ### Prerequisites - **Paysio Wallets** must be enabled for your workspace (Settings > Checkout), plus the Apple Pay / Google Pay toggles for the wallets you want. - Apple Pay requires HTTPS. **It will not work on localhost** — Paysio.js automatically skips Apple Pay on localhost/HTTP. - Apple Pay **live mode** requires your domain to be registered — see "Apple Pay domain registration" below. Test mode needs no registration and works on any HTTPS domain (tunnels included). (Apple verifies the top-level page domain, so this cannot be skipped.) - Google Pay needs **no registration at all**, on any domain, in any mode: the button renders inside an invisible Paysio-hosted frame, which satisfies Google's own domain checks for you. All styling options still apply, and the frame is sized by your `buttonHeight`. - The Apple Pay button renders in every major browser: on Safari/iOS the sheet opens directly; on Chrome/Windows/Android, Apple shows a code/QR dialog the buyer scans with their iPhone. ### If the buttons don't render Work through these in order — the first one answers most cases: 1. **Open the browser console with the Verbose/Debug level visible.** Paysio.js logs a `[Paysio]` line for every skip: workspace not on Paysio Wallets, Apple Pay on localhost/HTTP, no wallet types available for this environment, or a wallet SDK that failed to load. The init line also prints the resolved processor, mode, and 3DS state. 2. **Check Settings > Checkout** — the Apple Pay / Google Pay toggles control per-wallet visibility. 3. **Apple Pay never renders on localhost or plain HTTP** (Google Pay does — sandbox Google Pay works on localhost). 4. **The mount target must be in the DOM** when `mountWallets()` is called; in a SPA, mount from an effect/lifecycle hook, not at module load. 5. **React 18 Strict Mode is supported**: dev-mode double-mounting is handled by the SDK (a stale instance never clears a live one's buttons). Pair each `elements()` instance's `mountWallets()` with `unmount()` in the effect cleanup. ### 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('Payload:', data.paysioWallet); // forward VERBATIM to /charges as paysio_wallet console.log('Payer:', data.wallet); // { payerEmail, billingInfo, shippingInfo, ... } // Return a Promise to control the Apple Pay sheet's success/failure // state: resolve true = sheet shows success, false = failure. return fetch('/your-server/process-payment', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paysio_wallet: data.paysioWallet, wallet: data.wallet, three_ds: data.threeDS, // present when a Google Pay step-up ran }), }).then((res) => res.ok); }); // Update amount dynamically (e.g. after quantity change) elements.updateAmount(59.98); **Charge the token within a couple of minutes.** The `paysioWallet` payload contains a single-use encrypted credential that expires quickly — charge it from your server immediately; never store it. ### 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. ### Charging the wallet payment: POST /charges with paysio_wallet Forward `data.paysioWallet` VERBATIM as `paysio_wallet`, and `data.wallet` as `wallet`: // Charge the wallet payload AND forward the wallet payer contact POST /api/v1/charges Authorization: Bearer sk_live_your_secret_key { "paysio_wallet": { "type": "apple_pay", "apple_payment_data": { ... }, "info": { "card_network": "Visa", "card_details": "Visa 1234" } }, "wallet": { "payerEmail": "jane@example.com", "billingInfo": { "firstName": "Jane", ... } }, "amount": 2999, "currency": "USD" } - `paysio_wallet.type` is `apple_pay` or `google_pay`; the encrypted credential rides `apple_payment_data` (Apple) or `google_token` (Google). The SDK builds this object for you — never modify it. - **Forwarding `wallet`** lets Paysio find-or-create the customer and populate the transaction (buyer name, email, billing address, card last-4). The sheet's `billingInfo` address is used for AVS, stored on the transaction, and saved on the customer and any vaulted card (explicit `billing_*` body fields win when you pass both); `shippingInfo` is recorded on the transaction's shipping fields. If you omit both `wallet` and `email`, the charge still succeeds but has no buyer attached. - `paysio_wallet` cannot be combined with `payment_token`, a `gateway` override, or the `ach` rail — the wallet credential is the complete payment instrument. `three_ds` is accepted only with a **prepared** Google Pay payload (see 3D Secure below); with a device token it is refused, because that credential is already authenticated. - A reusable Google Pay card is saved to the buyer's saved payment methods automatically after an approved charge; Apple Pay tokens are single-use by design and are not saved. - The transaction records the wallet's authentication evidence (`wallet_type`, ECI, cryptogram presence) — the record that backs a fraud-chargeback representment. ### Dynamic totals and discount codes The amount lives in two places, and they have different jobs: - **The wallet sheet shows** whatever `mountWallets({ amount })` / `elements.updateAmount()` last set. Call `updateAmount(newTotal)` whenever the cart changes — quantity changes, a discount code applying, shipping being added. Both wallets read the live amount at click time, so there is no re-mount. - **The charge takes** the `amount` YOUR SERVER passes to POST /charges. That value is authoritative — the sheet never sets the charge amount. So a discount code flow is: validate the code on your server → `elements.updateAmount(discountedTotal)` so the sheet shows it → charge `amount: discountedTotalCents` server-side. Compute the discounted total on the server both times; never trust a total sent up from the browser. (Discount codes are your own logic on direct API charges — Paysio's `discount_code` handling applies to hosted Checkout Sessions.) ### Shipping addresses Pass `collectShipping: true` to `mountWallets()` and both wallet sheets collect a shipping address (plus phone). It comes back on the event as `data.wallet.shippingInfo` ({ firstName, lastName, address1, city, state, postalCode, country, phone }), and when you forward `wallet` to POST /charges it is stored on the transaction's shipping fields and saved to the buyer's profile automatically — nothing else to wire. The billing address from the sheet (`data.wallet.billingInfo`) is likewise consumed automatically: AVS, the transaction record, the customer, and any card saved to the vault. ### 3D Secure and wallet payments Wallet payments and 3DS interact differently per credential — this is by design, not a gap: - **Apple Pay (always) and Google Pay on Android devices** produce a network token with a single-use cryptogram — the buyer already authenticated with Face ID / fingerprint / device passcode. That credential is the wallet's equivalent of a completed 3DS authentication: fraud-dispute liability shifts to the issuer, and Paysio records the evidence on the transaction (`wallet_type`, ECI, cryptogram presence). Running 3DS on top is not possible (one authentication rides per authorization) and not needed — which is why the API rejects `paysio_wallet` combined with 3DS fields. - **Google Pay on desktop/iOS (PAN_ONLY)** returns the buyer's real card number, not a device token — it processes as an ordinary card-not-present charge with no built-in authentication, exactly like a typed card. **Paysio.js steps these up with 3D Secure automatically** when 3D Secure is on for your workspace: after the Google sheet closes it prepares the payment, and if a step-up applies it runs the standard challenge popup before your handler is called. Nothing to wire — just forward `data.threeDS` to the charge (see below). Which kind you received is visible on the transaction: device tokens carry `wallet_eci` + cryptogram evidence; a stepped-up PAN_ONLY charge carries the usual `three_ds_*` fields instead. **Forwarding the 3DS result.** When the popup ran, the `walletPayment` event includes `data.threeDS`. Send it as `three_ds` alongside the wallet payload: { "paysio_wallet": data.paysioWallet, // contains the sealed "prepared" credential "wallet": data.wallet, "three_ds": data.threeDS, // present only when a step-up ran "amount": 2999 } Omitting it is safe — the charge still goes through, just without the liability shift. `three_ds` is accepted ONLY with a prepared wallet payload; sending it with a device token is refused, because that credential is already authenticated. Requirements: 3D Secure enabled for the workspace and the Paysio vault on (the challenge runs against a vaulted card). If either is off, or the card's brand has no acquirer profile, prepare reports no step-up and the wallet charges normally. The prepare response's `three_ds_reason` field names the exact condition that disabled the step-up ("three_ds_disabled" means the Settings > Checkout toggle is off), and paysio.js logs the workspace's 3DS state to the console at init — check both before debugging further. **Doing it yourself (optional).** `POST /v1/wallets/prepare` with `{ paysio_wallet: { type: "google_pay", google_token } }` returns `{ prepared, three_ds_required, card_id, card }`. Authenticate `card_id` with the normal 3DS endpoints, then charge with `paysio_wallet: { type: "google_pay", prepared }` plus `three_ds`. The sealed `prepared` blob is single-use, workspace-bound and expires in 15 minutes — it exists so the Google token is never decrypted twice (a challenge easily outlives the token). ### Apple Pay domain registration (live mode) In live mode, Apple verifies every domain that shows the Apple Pay button. `paysio.com` and your workspace's custom domain are registered automatically — you only need this when embedding the button on YOUR OWN site with paysio.js: 1. Download the domain-association file from https://paysio.com/.well-known/apple-developer-merchantid-domain-association 2. Host it on your domain at exactly `https://