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_token for 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-sdk
import { createTonder, AppError, ErrorKeyEnum } from '@tonder.io/web-sdk';

No bundler? Load the browser global build from the environment CDN:

EnvironmentCDN URL
Stagehttps://zplit-stage.s3.us-east-1.amazonaws.com/web-sdk/v1/tonder-web-sdk.min.js
Productionhttps://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.

FieldRequiredDescription
api_keyYesPublic Tonder key for browser integrations.
environmentYes'sandbox', 'stage', or 'production'.
session.customerFor pay() and saved-card operationsCustomer identity. Omit for read-only return pages that only call getTransaction().
session.customer.phoneFor saved cards, on businesses configured to require a phoneCustomer 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_tokenFor saved-card operationsShort-lived token minted by your backend.
presentation_modeNo'redirect' by default, or 'embedded' for SDK-owned modal presentation.
events.presentationNoon_open / on_close callbacks for the embedded hosted view.
customization.card_fieldsNoLabels, 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:

ModeBehavior
redirectThe browser navigates to the hosted page. Use return_url, getTransaction(), and webhooks to confirm final status.
embeddedThe 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 email and phone in session.customer. The email identifies the customer; the phone enables their saved cards.
  • Without phone, saved cards are unavailable: canSaveCards() returns false, and getCustomerCards(), enrollCard() and pay({ payment_method: { type: 'saved_card' } }) throw SAVE_CARDS_UNAVAILABLE without sending a request.
  • A new-card payment (pay({ payment_method: { type: 'card' } })) still works without phone: 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 enabled

enrollCard() 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.

  1. Your server authenticates the user and mints a secure_token. It builds session.customer from the account: email always; phone (E.164) when your business requires it.
  2. createTonder({ session: { customer, secure_token } }) → init().
  3. If canSaveCards() is false, hide saved-card UI and offer only a new card.
  4. getCustomerCards() → show card_scheme, masked card_number and expiry.
  5. Mount the new-card form for "use another card".
  6. If the shopper picks a saved card without subscription_id, mount its CVV (unmount_context: 'none').
  7. pay() with { type: 'saved_card', card_id } or { type: 'card' }.
  8. Offer "remove card" with removeCustomerCard(card_id) and refresh the list.
  9. Recreate the SDK when the user changes or the token expires (1 hour).

API reference

POST
/secure-token/

Authorization

SecretKeyAuth
Authorization<token>

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:

FlowDemo
Card paymentweb/card-payment
Card enrollmentweb/enroll-card
Saved cardsweb/saved-cards
Payment methodsweb/payment-methods
SafetyPay banksweb/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_reference is required: it's your order reference and appears in dashboards, exports, webhooks, and transaction reports.
  • Use a stable idempotency_key per checkout attempt so retries don't create duplicate charges. Do not reuse client_reference as 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

Was this page helpful?

On this page