Web
Integrate the Web SDK: secure card fields, saved cards and alternative payment methods.
Tonder's Web SDK (@tonder.io/web-sdk) is a
browser TypeScript SDK for accepting payments: secure card fields, new-card and saved-card
payments, hosted/3DS presentation, payment-method discovery, transaction lookup, and
webhook-friendly transaction responses.
Want an AI agent to do the integration for you? Install the Tonder Web SDK plugin in Claude Code, Claude Desktop, or Codex.
Before you start
You need:
- Your Tonder public API key. Never put secret keys in browser code.
- A modern browser: Chrome, Safari, Firefox, or Edge.
- A server endpoint that mints a short-lived
secure_tokenfor signed-in users, if you'll use saved cards / Card-on-File. - A webhook endpoint for reliable payment fulfillment.
Install
npm install @tonder.io/web-sdkimport { createTonder, AppError, ErrorKeyEnum } from '@tonder.io/web-sdk';No bundler? Load the browser global build from the environment CDN:
| Environment | CDN URL |
|---|---|
| Stage | https://zplit-stage.s3.us-east-1.amazonaws.com/web-sdk/v1/tonder-web-sdk.min.js |
| Production | https://zplit-prod.s3.us-east-1.amazonaws.com/web-sdk/v1/tonder-web-sdk.min.js |
<script src="https://zplit-stage.s3.us-east-1.amazonaws.com/web-sdk/v1/tonder-web-sdk.min.js"></script>
<script>
const { createTonder } = window.Tonder;
</script>In a TypeScript app that uses the CDN, you can install @tonder.io/web-sdk as a devDependency
for types only (npm install -D @tonder.io/web-sdk + import type), keeping the runtime on
the CDN. Do not import runtime code from the package in that setup.
Quick start: card payment
Add containers for the card fields
<form id="checkout-form">
<div id="collect-cardholder-name" class="card-field"></div>
<div id="collect-card-number" class="card-field"></div>
<div id="collect-expiration-month" class="card-field"></div>
<div id="collect-expiration-year" class="card-field"></div>
<div id="collect-cvv" class="card-field"></div>
<button type="submit">Pay</button>
</form>Cap each container's height so the secure iframe does not visually grow before it settles into the input layout:
.card-field {
width: 100%;
max-height: 90px;
}Initialize, mount, and pay
import { createTonder } from '@tonder.io/web-sdk';
const tonder = createTonder({
api_key: 'pk_test_...',
environment: 'sandbox',
session: {
customer: {
email: 'ada@example.com',
first_name: 'Ada',
last_name: 'Lovelace',
},
},
});
await tonder.init();
const card_fields = tonder.create('card_fields');
await card_fields.mount();
const transaction = await tonder.pay({
amount: 150,
currency: 'MXN',
return_url: 'https://yourstore.example/checkout/return',
client_reference: 'order_1001',
metadata: { cart_id: 'cart_789' },
payment_method: { type: 'card' },
});Handle the result
if (transaction.status === 'Success' || transaction.status === 'Authorized') {
// Show confirmation.
} else if (transaction.status === 'Pending') {
// The customer may need to complete 3DS or an asynchronous payment method.
// Confirm final state with webhooks or getTransaction().
} else {
// Show a recoverable payment message.
console.warn(transaction.decline_code, transaction.decline_reason);
}Declines are not thrown as errors — they're returned as transactions: read
transaction.status. SDK failures are thrown as AppError (see
Errors).
Web SDK amounts are decimal units (150 = MXN 150.00). See
Money, currencies, and amounts.
Configuration
createTonder(config) creates one SDK instance for one shopper/session. Recreate the SDK if the
customer, secure_token, or environment changes.
| Field | Required | Description |
|---|---|---|
api_key | Yes | Public Tonder key for browser integrations. |
environment | Yes | 'sandbox', 'stage', or 'production'. |
session.customer | For pay() and saved-card operations | Customer identity. Omit for read-only return pages that only call getTransaction(). |
session.customer.phone | For saved cards, on businesses configured to require a phone | Customer phone in E.164 format (+52 + 10 digits). It doesn't replace email, which always identifies the customer. See Businesses that require a phone for saved cards. |
session.secure_token | For saved-card operations | Short-lived token minted by your backend. |
presentation_mode | No | 'redirect' by default, or 'embedded' for SDK-owned modal presentation. |
events.presentation | No | on_open / on_close callbacks for the embedded hosted view. |
customization.card_fields | No | Labels, placeholders, styles, and validation-message overrides for the secure fields. |
The full customization tables (per-field styles, error messages, card icon) are in the Web SDK Reference.
Presentation mode
When a payment requires a hosted step (3DS, APM instructions), the SDK uses
presentation_mode:
| Mode | Behavior |
|---|---|
redirect | The browser navigates to the hosted page. Use return_url, getTransaction(), and webhooks to confirm final status. |
embedded | The SDK opens a full-screen modal. Card 3DS waits for a final transaction; APM/SPEI hosted instructions may return Pending immediately. |
Saved cards (secure_token)
Saved-card operations (getCustomerCards(), enrollCard(), removeCustomerCard(), paying with
saved_card) require session.customer and session.secure_token. Mint the token by
calling /api/secure-token/ from your backend with your secret key:
Saved cards / Card-on-File require @tonder.io/web-sdk 0.1.11 or later.
fetch("https://stage.tonder.io/api/secure-token/", {
method: 'POST',
headers: {
'Authorization': 'Token YOUR_SECRET_KEY',
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(result => {
const secureToken = result.access;
// Pass it to the frontend as session.secure_token
});fetch("https://app.tonder.io/api/secure-token/", {
method: 'POST',
headers: {
'Authorization': 'Token YOUR_SECRET_KEY',
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(result => {
const secureToken = result.access;
});The generated secure_token is valid for 1 hour. Use it within that window; if you cache or
reuse it after that, mint a new one.
Only offer saved cards to signed-in users. Saved cards belong to the customer in
session.customer. Build session.customer (email and, when required, phone) on your server from
the authenticated account — never from a field the shopper can edit — and only mint a
secure_token for authenticated sessions. Guest checkouts should pay with a new card and skip
secure_token, getCustomerCards() and enrollCard().
Businesses that require a phone for saved cards
Saved cards always belong to the customer identified by session.customer.email. Some businesses
are configured by Tonder to also require the customer's phone for saved cards. For those
businesses:
- Pass both
emailandphoneinsession.customer. The email identifies the customer; the phone enables their saved cards. - Without
phone, saved cards are unavailable:canSaveCards()returnsfalse, andgetCustomerCards(),enrollCard()andpay({ payment_method: { type: 'saved_card' } })throwSAVE_CARDS_UNAVAILABLEwithout sending a request. - A new-card payment (
pay({ payment_method: { type: 'card' } })) still works withoutphone: the card is charged but not saved. - The phone does not replace the email: the same phone with a different email is a different customer and won't see those cards.
const tonder = createTonder({
api_key: 'pk_test_...',
environment: 'sandbox',
session: {
customer: {
email: 'carlos@example.com', // identifies the customer
phone: '+525512345678', // required for saved cards on businesses that require a phone
first_name: 'Carlos',
last_name: 'Mendoza',
},
secure_token: await getSecureTokenFromYourBackend(),
},
});
await tonder.init();
if (tonder.canSaveCards()) {
const cards = await tonder.getCustomerCards();
// render the saved cards and the "save card" option
} else {
// hide the saved cards list and the "save card" option
}The SDK never asks the shopper for a phone and shows no message: take the phone from the signed-in
user's account on your server, and use canSaveCards() to decide whether to show saved-card UI.
Phone number format
Send the phone in E.164 format (+52 followed by 10 digits) and always send the same value for
the same user. Tonder doesn't normalize it: +525512345678 and 5512345678 are not the same value.
Example for Mexico:
function normalizeMxPhone(value) {
const d = String(value || '').replace(/\D/g, '');
if (d.length === 10) return '+52' + d; // 5512345678
if (d.length === 12 && d.startsWith('52')) return '+' + d; // 525512345678
if (d.length === 13 && d.startsWith('521')) return '+52' + d.slice(3); // legacy +521 mobile prefix
return null; // invalid: don't offer saved cards
}Pay with a saved card
The CVV container for a saved card is #collect-cvv-<card_id> (or pass your own with
fields: [{ field: 'cvv', container_id: '#my-cvv' }]):
<div id="collect-cvv-CARD_ID"></div>const tonder = createTonder({
api_key: 'pk_test_...',
environment: 'sandbox',
session: {
customer: { email: 'ada@example.com' },
secure_token: await getSecureTokenFromYourBackend(),
},
});
await tonder.init();
const cards = await tonder.getCustomerCards();
const selected_card = cards[0];
// Mount saved-card CVV only when the card cannot be charged through an
// existing Card-on-File subscription.
if (!selected_card.subscription_id) {
const cvv = tonder.create('card_fields', {
card_id: selected_card.card_id,
fields: ['cvv'],
});
await cvv.mount();
}
const transaction = await tonder.pay({
amount: 150,
currency: 'MXN',
return_url: 'https://yourstore.example/checkout/return',
client_reference: 'order_1001',
payment_method: { type: 'saved_card', card_id: selected_card.card_id },
});If the new-card form is on the same screen, keep it mounted while you mount the saved card's CVV
— with the default unmount_context ('all') it is unmounted:
const cvv = tonder.create('card_fields', {
card_id: selected_card.card_id,
fields: ['cvv'],
unmount_context: 'none', // keep the new-card form mounted if it's on the same screen
});If a card has no subscription_id and its CVV isn't mounted and filled in, pay() throws
MOUNT_COLLECT_ERROR.
Save a new card
// Requires session.customer and session.secure_token.
const card_fields = tonder.create('card_fields');
await card_fields.mount();
// After the shopper fills in the fields:
const enrollment = await tonder.enrollCard();
// { card_id: '…', subscription_id: '…' } // subscription_id is present when Card-on-File is enabledenrollCard() saves the card without charging it. After it resolves, the new-card fields are
cleared. Refresh the list with getCustomerCards().
Card-on-File on a new-card payment
When Card-on-File is enabled for your business, a new-card payment also saves the card for
session.customer; the next time it appears in getCustomerCards() with a subscription_id. Show
the appropriate consent text in your checkout.
The one exception: on a business that requires a phone, a new-card payment without
session.customer.phone is charged but not saved — see
Businesses that require a phone for saved cards.
Recommended saved-cards flow
- Your server authenticates the user and mints a
secure_token. It buildssession.customerfrom the account:emailalways;phone(E.164) when your business requires it. createTonder({ session: { customer, secure_token } })→init().- If
canSaveCards()isfalse, hide saved-card UI and offer only a new card. getCustomerCards()→ showcard_scheme, maskedcard_numberand expiry.- Mount the new-card form for "use another card".
- If the shopper picks a saved card without
subscription_id, mount its CVV (unmount_context: 'none'). pay()with{ type: 'saved_card', card_id }or{ type: 'card' }.- Offer "remove card" with
removeCustomerCard(card_id)and refresh the list. - Recreate the SDK when the user changes or the token expires (1 hour).
API reference
Authorization
SecretKeyAuth Your SECRET key (not the public API key), with the word Token and a space in front of it. Example: Token e6440b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a. In this playground you may paste just the key; the prefix is added for you.
In: header
Response Body
application/json
curl -X POST "https://example.com/secure-token/"{
"access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzM1Njg5NjAwLCJpYXQiOjE3MzU2ODYwMDAsImp0aSI6IkVYQU1QTEVfSlRJIiwidXNlcl9pZCI6MH0.EXAMPLE_SIGNATURE_NOT_A_REAL_TOKEN"
}Alternative payment methods
If your checkout already knows which method to offer, pass the method code directly to pay():
const transaction = await tonder.pay({
amount: 150,
currency: 'MXN',
return_url: 'https://yourstore.example/checkout/return',
client_reference: 'order_1001',
payment_method: { type: 'oxxopay' },
});Use getPaymentMethods() (optional) to render the methods enabled for your business, and
getPaymentMethodBanks() for bank-backed SafetyPay methods:
const banks = await tonder.getPaymentMethodBanks();
const bank = banks.cash[0];
const transaction = await tonder.pay({
amount: 150,
currency: 'MXN',
return_url: 'https://yourstore.example/checkout/return',
client_reference: 'order_1001',
payment_method: {
type: 'safetypayCash',
config: {
country: bank.country, // e.g. 'Mexico'
channel: bank.channel, // 'WP' cash, 'OL' transfer
bank_ids: [{ id: bank.code }], // e.g. [{ id: '8186' }]
},
},
});APM/SPEI methods often settle asynchronously. Use webhooks for fulfillment.
Demos
Try every flow in the SDK demos portal:
| Flow | Demo |
|---|---|
| Card payment | web/card-payment |
| Card enrollment | web/enroll-card |
| Saved cards | web/saved-cards |
| Payment methods | web/payment-methods |
| SafetyPay banks | web/safetypay-banks |
The legacy demos (Web SDK Lite and Inline) use previous SDK versions and are kept for reference only — use the demos above for new integrations. If you are still on the previous SDK, follow the migration guide.
Reconciliation
client_referenceis required: it's your order reference and appears in dashboards, exports, webhooks, and transaction reports.- Use a stable
idempotency_keyper checkout attempt so retries don't create duplicate charges. Do not reuseclient_referenceas the idempotency key. - Web SDK webhooks use the flat payload (top-level fields) with
event_type: payment_Success/payment_Pending. See the events catalog.
Next steps
Web SDK Reference
Every method, type, error, and payment status.
SDK demos portal
Interactive demos per flow + legacy demos (Lite and Inline).
Tonder Web SDK plugin (AI)
Let Claude Code or Codex do this integration for you.
Webhooks
Confirm payments reliably on your backend.
Refunds — from your backend
Refund a card payment with one server-side call.
