# 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:///.well-known/apple-developer-merchantid-domain-association` (served over HTTPS, content unmodified). 3. Register the domain (secret key): POST /api/v1/wallets/apple-pay/domains Authorization: Bearer sk_live_your_secret_key { "domain": "shop.example.com" } Paysio checks the file is being served, registers the domain with Apple (Apple crawls it), and returns the registered list. Manage domains with: | Endpoint | Description | |----------|-------------| | GET /wallets/apple-pay/domains | List registered domains + status. | | POST /wallets/apple-pay/domains | Register a domain. Body: { domain }. Verifies the association file first (live mode). | | DELETE /wallets/apple-pay/domains/:domain | Remove a domain. Paysio-managed domains can't be removed. | Domains are per environment: a test-mode key manages the sandbox registration (where Apple skips verification entirely — any domain works without this API), a live key manages production. You can also manage domains in the dashboard under Settings > Apple Pay domains (in the Developers group), which includes a download button for the association file. Merchant validation itself (`POST /wallets/apple-pay-session`) is called by paysio.js automatically with your publishable key — you never call it directly. ### Testing wallets in test mode Mount the buttons exactly as you would in live mode — there is nothing extra to add for sandbox. A test-mode key puts both wallets in their own sandbox, and the payment runs through the same charge pipeline against a test card. **Google Pay:** nothing special. In test mode the Google sheet serves Google's canned test cards to any signed-in Google account — click the button and pay. **Apple Pay:** Apple's sandbox needs a one-time setup with a sandbox Apple ID: 1. In App Store Connect, create a Sandbox Tester (Users and Access > Sandbox > Testers). Any never-used email works — a plus-address like you+applepay@example.com is fine. Any Apple developer account can mint testers; there is no tie to Paysio. 2. On the iPhone, sign into iCloud with that sandbox Apple ID in Settings (the top slot — the App Store "Sandbox Account" slot does NOT apply to Wallet). 3. Add Apple's universal test cards to Wallet from developer.apple.com/apple-pay/sandbox-testing: | Network | Number | Expiry | Security code | |---------|--------|--------|---------------| | Visa | 4622 9431 2318 9285 | 12/2028 | 096 | | Mastercard | 5204 2452 5046 0049 | 01/30 | 111 | | Amex (US) | 3727 3572 3032 000 | 12/28 | 7777 | | Discover | 6011 0009 9446 2780 | 01/30 | 111 | 4. Pay on any test-mode page. Non-Safari browsers show Apple's QR code — the iPhone that scans it is the one that needs the sandbox account. Test mode skips domain verification, so any HTTPS domain (tunnels, previews) works. ### HTML layout
— or pay with card —
### Button appearance Paysio.js renders the **official** Apple Pay and Google Pay buttons, and every customization knob Apple and Google offer passes straight through `mountWallets()`. You never need overlay/skin tricks: pick from the official options below (both brands' guidelines require the genuine button appearance, and the official variants cover the full sanctioned range — colors, labels, radius, locale, size). elements.mountWallets('#wallet-buttons', { amount: 29.99, currency: 'USD', country: 'US', // Layout of the two buttons inside your container buttonHeight: 48, // px (or CSS string) — applied to both buttons. Default 40. gap: 8, // px between the buttons. Default 8. direction: 'column', // 'column' (stacked, default) or 'row' (side by side) // Apple Pay — the official options applePay: { buttonStyle: 'black', // 'black' (default) | 'white' | 'white-outline' buttonType: 'buy', // see the full list below. Default 'buy'. locale: 'en-US', // any Apple-supported locale, e.g. 'fr-FR'. Default 'en-US'. borderRadius: 8, // px or CSS string. Apple's default is ~7.5px. height: 48, // overrides buttonHeight for Apple only. Min 30px. padding: '0px 0px', // CSS padding shorthand (--apple-pay-button-padding) }, // Google Pay — the official createButton() ButtonOptions googlePay: { buttonColor: 'black', // 'default' (Google picks) | 'black' | 'white'. Paysio defaults to 'black'. buttonType: 'buy', // see the full list below. Default 'buy'. buttonRadius: 8, // px, 0 to half the button height. Google's default is 4. buttonLocale: 'en', // ISO 639-1 code, e.g. 'fr'. Defaults to the browser language. buttonSizeMode: 'fill', // 'fill' (default — button fills buttonHeight/width) | 'static' (Google's fixed size) buttonBorderType: 'default_border', // or 'no_border' personalized: true, // false = never show Google's card preview on buy/pay/plain (see below) height: 48, // overrides buttonHeight for Google only (fill mode) }, }); **What each `buttonType` actually renders** — the wallets add their own wording around their logo, so pick by the literal label, not the name: | Apple `buttonType` | Renders | |---|---| | `plain` | The Apple Pay mark alone — Apple logo + "Pay". **This is the "just logo + Pay" option.** | | `buy` | "Buy with (Apple logo) Pay" | | `pay` | "Pay with (Apple logo) Pay" | | `check-out` | "Check out with (Apple logo) Pay" | | `continue` | "Continue with (Apple logo) Pay" | | `order` / `donate` / `subscribe` / `book` / `contribute` / `support` / `tip` / `rent` / `reload` / `add-money` / `top-up` | " with (Apple logo) Pay" | | `set-up` | "Set up (Apple logo) Pay" | | Google `buttonType` | Renders | Can personalize? | |---|---|---| | `plain` | The G Pay mark alone | **Yes** | | `buy` | "Buy with G Pay" | **Yes** | | `pay` | "Pay with G Pay" | **Yes** | | `checkout` | "Checkout with G Pay" | No | | `order` / `donate` / `subscribe` / `book` | " with G Pay" | No | **Google's card preview (the "personalized" button):** when the signed-in buyer has an eligible saved card, Google replaces the `buy`/`pay`/`plain` label with a preview of their card (network + last 4). This is Google's behavior, not Paysio's, and there is no Google flag to turn it off per type — but it is driven by a payment-method hint Paysio passes to the button. Set `googlePay.personalized: false` and Paysio withholds that hint, so those labels always render their generic form. Payment behavior is unchanged. **Apple Pay `locale` values**: ar-AB, ca-ES, cs-CZ, da-DK, de-DE, el-GR, en-AU, en-GB, en-US, es-ES, es-MX, fi-FI, fr-CA, fr-FR, he-IL, hi-IN, hr-HR, hu-HU, id-ID, it-IT, ja-JP, ko-KR, ms-MY, nb-NO, nl-NL, pl-PL, pt-BR, pt-PT, ro-RO, ru-RU, sk-SK, sv-SE, th-TH, tr-TR, uk-UA, vi-VN, zh-CN, zh-HK, zh-TW. **Google Pay `buttonLocale` values**: en, ar, bg, ca, cs, da, de, el, es, et, fi, fr, hr, id, it, ja, ko, ms, nl, no, pl, pt, ru, sk, sl, sr, sv, th, tr, uk, zh. **Recipes** — the combinations integrators actually ask for: // Logo-only pair, side by side, no extra wording, no card preview elements.mountWallets('#wallet-buttons', { amount: 29.99, direction: 'row', buttonHeight: 44, applePay: { buttonType: 'plain', borderRadius: 10 }, googlePay: { buttonType: 'plain', personalized: false, buttonRadius: 10 }, }); // "Buy with ..." pair matching a 12px-radius card button elements.mountWallets('#wallet-buttons', { amount: 29.99, buttonHeight: 48, applePay: { buttonType: 'buy', borderRadius: 12 }, googlePay: { buttonType: 'buy', buttonRadius: 12 }, }); // Keep Google's card preview (higher conversion for returning buyers) — the default elements.mountWallets('#wallet-buttons', { amount: 29.99 }); Sizing and layout notes: - **Minimums (brand guidelines):** Apple requires at least 30px height and 140px width; Google's static button is 40px tall. Keep `buttonHeight` at 40-48px for a native feel. - **Google `buttonRadius` caps at half the button height** (e.g. max 20 on a 40px button). - **Matching radii:** set `applePay.borderRadius` and `googlePay.buttonRadius` to the same value as your card/submit button for a consistent row. - **`buttonColor: 'default'`** lets Google pick black or white to match the user's theme; Apple has no equivalent — choose `buttonStyle` per your background (`white-outline` for white-on-white). - **Both wallets can render on the same device** — Apple Pay works beyond Safari via Apple's QR handoff — so `direction: 'row'` gives you a balanced two-button row, or keep the default stacked column. - The buttons only mount when available (e.g. Google Pay hides if `isReadyToPay` is false), so an unavailable wallet collapses instead of leaving a dead box. --- ## 3D Secure (3DS) 3D Secure adds extra verification for card payments, reducing fraud and enabling liability shift. 3DS is a **standalone step** that runs against a card in the Paysio vault — raw card data never touches your page. Paysio.js provides a client-side threeDS API that handles the entire flow: initialization, device fingerprinting, challenge iframes, and polling. **You can 3D-Secure a NEW card or a card already SAVED on file.** Identify the card with exactly one of: | Option | Use it for | Where the value comes from | |--------|-----------|----------------------------| | token | A new card being entered now | paysio.createToken() → `ptok_...`. NOT consumed by 3DS — charge it afterward as usual. | | paymentMethodId | A card already saved on the customer | GET /customers/:id/payment-methods → `id` (`vault_...` or the bare uuid). The same id you pass to POST /charges. | | cardId | A Paysio vault card id you already hold | Server-side vault integrations. | Raw PAN is **not** accepted by 3DS — tokenize first. Stripe workspaces don't need this API at all; Stripe runs 3DS itself during the charge. ### 3DS on a SAVED card (card on file) This is the flow to use when a returning customer picks a stored card. It is the same three steps as a new card, with `paymentMethodId` in place of `token`: 1. List the customer's cards: GET /customers/:id/payment-methods 2. Run 3DS: threeDS.authenticate({ amount, paymentMethodId }) → 3DS result 3. Charge: POST /charges with { customer_id, payment_method_id, amount, three_ds } Requirements and gotchas: - The customer must have **already used that payment method at your business**. A card they saved at a different merchant cannot be authenticated (or charged) with your API key — you get a 400. - Pass the **same** `payment_method_id` to /three-ds/authenticate and to /charges. Authenticating one card and charging another produces a mismatched cryptogram and no liability shift. - Merchant-initiated charges (renewals, retries, dunning) have no cardholder present to complete a challenge — 3DS is for customer-initiated payments where the buyer is on the page. ### You MUST send the 3DS result to the charge — nothing is attached automatically There is no server-side association between an authentication and a later charge. **If you do not pass the 3DS result to POST /charges, the charge is authorized with no cryptogram, gets no liability shift, and the transaction records no 3DS data** (the 3DS panel stays empty in the dashboard). This applies identically to new cards and to saved cards. **Send the entire result. Do not cherry-pick fields.** Pass the whole object through as `three_ds` and let Paysio decide what the acquirer needs — the required set differs by gateway and by protocol version, and Paysio maps and forwards the right subset for the gateway that ends up routing the authorization. Dropping a field that looks unnecessary can silently cost you the liability shift on a gateway you did not have in mind. ### Recommended order for a new card: 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 full 3DS result to your server to create a charge ### How 3DS works 1. Your page calls threeDS.authenticate() with the amount and a card reference (token, paymentMethodId, or cardId). 2. Paysio initializes the authentication and renders the issuer's hidden device-fingerprint iframe. 3. Paysio contacts the card issuer's 3DS server to verify the cardholder. 4. **Frictionless flow:** If approved silently, you get a terminal result immediately. 5. **Challenge flow:** If interaction is required, an iframe renders in your specified container for the cardholder to complete verification (OTP, biometric, etc), and Paysio polls until the result is terminal. 6. The Promise resolves with the full 3DS result (status, eci, authenticationValue, dsTransId, threeDSServerTransID, threeDsVersion) to pass to the charge endpoint. ### Client-side 3DS with Paysio.js — NEW card 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, 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 + the WHOLE 3DS result to your server await fetch('/your-server/charge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, threeDsResult }), }); ### Client-side 3DS with Paysio.js — SAVED card const paysio = Paysio('pk_live_your_key'); // Step 1: your server returns the customer's saved cards from // GET /customers/:id/payment-methods; the buyer picks one. const paymentMethodId = 'vault_2f6c…'; // the method's "id", opaque // Step 2: Run 3DS against the SAVED card. No card fields to mount, // nothing to tokenize — the vault already holds the card. const threeDS = paysio.threeDS(); let threeDsResult = null; try { threeDsResult = await threeDS.authenticate({ amount: 29.99, paymentMethodId: paymentMethodId, billing: { country: 'US' }, // same country you send on the charge iframeTarget: '#threeds-container', }); } catch (err) { console.error('3DS failed:', err.message); } // Step 3: Send the method id + the WHOLE 3DS result to your server await fetch('/your-server/charge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paymentMethodId, threeDsResult }), }); Then on your server: await fetch('https://api.paysio.com/v1/charges', { method: 'POST', headers: { 'Authorization': 'Bearer sk_live_your_secret_key', 'Content-Type': 'application/json', }, body: JSON.stringify({ customer_id: customerId, payment_method_id: paymentMethodId, // the card you authenticated amount: 2999, // cents three_ds: threeDsResult, // the FULL result, verbatim }), }); ### 3DS result fields Forward **all** of these to the charge. Paysio sends whatever the routed gateway requires; you should not filter by gateway yourself. | Field | Description | |-------|-------------| | status | Y = fully authenticated, A = attempted, N = denied, U = unavailable, R = rejected | | eci | Electronic Commerce Indicator | | authenticationValue | CAVV/AAV — cryptographic proof of authentication | | dsTransId | Directory Server Transaction ID | | threeDSServerTransID | 3DS Server Transaction ID (XID) | | threeDsVersion | Protocol version, e.g. "2.2.0" | Passing the object as `three_ds` sends all of them in one field. If you map them yourself to the flat `three_ds_*` fields, map every one — sending only a subset (for example eci + cavv without dsTransId) can leave the authorization without a usable authentication on some gateways. ### API reference The Paysio.js threeDS.authenticate() helper wraps the REST endpoints below — it calls POST /three-ds/initialize, renders device fingerprinting, calls POST /three-ds/authenticate, polls POST /three-ds/finish, 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. The Paysio vault and 3DS must be enabled for the workspace. Every endpoint below identifies the card the same way — with ONE of `token`, `payment_method_id`, or `card_id` — and you must use the SAME one across initialize, authenticate, and finish. ### POST /three-ds/initialize Start the issuer device-fingerprint step. Returns the transaction_info you echo to /three-ds/authenticate, plus hidden-iframe HTML to render. Render device_fingerprint_html, wait about 3 seconds, then authenticate. Paysio.js does this sequencing for you. Auth: Publishable key (pk_*) or secret key (sk_*) Body: - token (string) — A ptok_ payment token from POST /tokens or paysio.createToken(). Provide one of token / payment_method_id / card_id. - payment_method_id (string) — A SAVED payment method id from GET /customers/:id/payment-methods ("vault_..." or the bare uuid). - card_id (string) — A Paysio vault card id. Response: { "data": { "transaction_info": { "xid": "uuid", "merchantTransactionId": "uuid" }, "device_fingerprint_html": "