Embedded Checkout
Add secure card fields and Apple Pay or Google Pay to your own checkout.
Put secure card fields inside your own checkout. You own the form and the submit button; Tapaya handles card validation, tokenization, and 3-D Secure. Embedded payments are live for approved merchants, with sandbox available for testing. This guide uses Tapaya.js 0.2.0 and the Node.js SDK 0.3.0.
- 1. Your frontend to Your backend: Prepare payment.
- 2. Your backend to Tapaya: Create or resume payment.
- 3. Tapaya to Your backend: Payment and client secret.
- 4. Your backend to Your frontend: Client secret and status.
- 5. Your frontend to Tapaya: Confirm card and 3-D Secure.
- 6. Tapaya to Your frontend: Payment outcome.
- 7. Your backend to Tapaya: Read order's payment.
- 8. Tapaya to Your backend: Payment status.
- 9. Your backend to Your frontend: Show verified result.
Payment methods
Embedded Checkout supports cards, Apple Pay, and Google Pay. Use your own Pay button for cards and the official wallet buttons for Apple Pay and Google Pay. See Add Apple Pay and Google Pay.
Before you start
Install the SDK
One package contains the server client, the browser SDK, and the React hook:
npm install @tapayadot/checkout| Import | Where |
|---|---|
@tapayadot/checkout | Your server only. Importing it in a browser build throws, so your API key never reaches a page. |
@tapayadot/checkout/client | Your checkout page. |
@tapayadot/checkout/react | Your checkout page, if you use React. |
Configure your server
Embedded payments do not need return URLs. The server examples below come in HTTP (fetch), Node.js SDK, and cURL versions, which all call the same merchant API and leave the browser card fields unchanged.
| Node.js SDK | HTTP (fetch) and cURL | |
|---|---|---|
| Key | TAPAYA_SECRET_KEY, read by new Tapaya() | TAPAYA_CHECKOUT_API_KEY |
| Environment | TAPAYA_ENVIRONMENT=sandbox or production. Without it, a tapaya_live_ key selects production and other keys default to sandbox. | TAPAYA_CHECKOUT_API_URL: https://api.sandbox.tapaya.com or https://api.tapaya.com |
| Platform mode | Pass the order's merchantToken to every call, or set it with new Tapaya(key, { merchantToken }) | Send X-Tapaya-Merchant-Token on every request. The fetch helper reads it from TAPAYA_MERCHANT_TOKEN. |
A merchant checkout key needs nothing else. In platform mode, use the organization Secret key, and have a backend that serves several merchants pick the token from the authenticated order. See Checkout authentication. Client secrets are specific to each payment.
Complete onboarding with online payments and check that the merchant is Ready in Checkout settings.
Allow Tapaya in your CSP
The loader fetches Tapaya.js 0.2.0 from https://js.tapaya.com/checkout/0.2.0/checkout.js; the browser script
version is independent of the Node.js SDK version. Add Tapaya's asset origin to your existing Content Security
Policy:
script-src https://js.tapaya.com
frame-src https://js.tapaya.comThe browser receives the publishable key and the payment-specific client secret. Your secret merchant API key
remains on your server, and card data and payment-provider scripts stay inside Tapaya's frame. For local testing,
serve Tapaya.js yourself and pass its origin to the loader, for example
loadTapaya(publishableKey, { apiOrigin, origin: 'http://localhost:4191' }).
How it works
- Your page mounts Tapaya's card fields and points them at an endpoint on your server.
- When the page loads and when the shopper pays, the card fields call that endpoint. It returns the order's
payment as a
clientSecret: a one-payment credential the card fields use to confirm the payment directly with Tapaya. Your page code never reads it. - Your page follows the payment's
outcome. When it completes, your server verifies the payment before you fulfill the order.
Accept a payment
Get the browser settings
Fetch the browser settings once on your server and add them to your frontend configuration.
Keep this helper on your server. It checks HTTP errors, treats a missing order payment as null when requested,
and does not retry automatically.
const apiOrigin = new URL(process.env.TAPAYA_CHECKOUT_API_URL).origin;
const apiKey = process.env.TAPAYA_CHECKOUT_API_KEY;
const merchantToken = process.env.TAPAYA_MERCHANT_TOKEN;
export async function paymentRequest(path, { method = 'GET', body, key, allowMissing = false } = {}) {
const response = await fetch(new URL(path, apiOrigin), {
method,
headers: {
Authorization: `Bearer ${apiKey}`,
...(merchantToken ? { 'X-Tapaya-Merchant-Token': merchantToken } : {}),
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
...(key ? { 'Idempotency-Key': key } : {}),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
redirect: 'error',
cache: 'no-store',
signal: AbortSignal.timeout(35000),
});
if (allowMissing && response.status === 404) return null;
if (!response.ok) throw new Error(`Payment request failed (${response.status})`);
return response.json();
}
const { publishableKey } = await paymentRequest('/merchant/payments/configuration');
export const config = { publishableKey, apiOrigin };The API returns publishableKey and environment. Set apiOrigin from the API URL you called; it is not a field
in the configuration response.
Both values are safe to include in your page. Publishable keys use pk_test_ with test gateway keys and pk_live_
with live gateway keys. Always pass the apiOrigin returned by your server to the loader, so the browser and server
stay on the same environment.
Add the card fields
Add a container to your existing checkout form:
<form id="checkout">
<!-- Your other checkout inputs go here. -->
<div id="card-fields"></div>
<button type="submit" disabled>Pay</button>
<p id="message" role="status"></p>
</form>Load Tapaya and mount the fields. prepare is the endpoint you add in the next step.
import { loadTapaya } from '@tapayadot/checkout/client'
import { config } from './config' // { publishableKey, apiOrigin } from your server
const form = document.querySelector<HTMLFormElement>('#checkout')!
const button = form.querySelector<HTMLButtonElement>('button')!
const message = document.querySelector<HTMLElement>('#message')!
const tapaya = await loadTapaya(config.publishableKey, { apiOrigin: config.apiOrigin })
const payment = tapaya.createPayment({
prepare: '/api/payments',
onChange: ({ canSubmit, outcome }) => {
button.disabled = !canSubmit
if (outcome === 'completed') location.assign('/checkout/confirmation')
if (outcome === 'ended') message.textContent = 'This payment cannot be completed. Start a new checkout.'
},
})
await payment.mount('#card-fields')When the fields mount, they call prepare to resume the order's existing payment. A reload during a payment never
charges again: the page shows the payment's current state instead.
Return the order's payment
Connect this handler to POST /api/payments. getCheckoutOrder() is your own application function: authenticate
the shopper, check access to their order, and return its stored id, amount, currency, and status. Apply
your usual CSRF protection to the route.
Read the existing payment before creating an attempt. Reuse an unresolved payment, and create another attempt
only after a decline or cancellation with retryAllowed: true.
import { paymentRequest } from './tapaya.js';
import { getCheckoutOrder } from './orders.js';
export async function findPayment(order) {
const query = new URLSearchParams({ merchantOrderId: order.id });
const payment = await paymentRequest(`/merchant/payments?${query}`, { allowMissing: true });
if (payment && (payment.merchantOrderId !== order.id ||
payment.amount !== order.amount || payment.currency !== order.currency)) {
throw new Error('Payment does not match the order');
}
return payment;
}
export function browserStatus(payment) {
if (payment.status === 'successful') return 'completed';
if (['declined', 'cancelled'].includes(payment.status) && !payment.retryAllowed) return 'ended';
return 'open';
}
export async function POST(request) {
const order = await getCheckoutOrder(request);
const payable = order.status === 'pending_payment';
let payment = await findPayment(order);
if (payable && (!payment ||
(['declined', 'cancelled'].includes(payment.status) && payment.retryAllowed))) {
// Derive the same key on retries, including after a lost creation response.
let key = `${order.id}:${payment ? `after:${payment.id}` : 'initial'}`;
if (/[^\x21-\x7e]/.test(key)) {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(key));
key = `prepare:${Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('')}`;
}
payment = await paymentRequest('/merchant/payments', {
method: 'POST', key,
body: { merchantOrderId: order.id, amount: order.amount, currency: order.currency },
});
if (payment.merchantOrderId !== order.id || payment.amount !== order.amount || payment.currency !== order.currency) {
throw new Error('Payment does not match the order');
}
}
if (!payment) return Response.json({ error: 'Order cannot accept payment' }, { status: 409 });
return Response.json(
{ clientSecret: payable ? payment.clientSecret : null, status: browserStatus(payment) },
{ headers: { 'Cache-Control': 'no-store' } },
);
}Use an uppercase currency code and a stored order ID of at most 200 characters. Keep the order details unchanged after payment starts. On a timeout, read the order's payment again before deciding whether it needs a new attempt. Never generate a new key just because a response was lost.
The REST API returns payment states. The Node SDK maps them to the status the card fields expect; fetch and cURL
integrations apply the same mapping in their own prepare endpoint:
API status | Prepare endpoint status |
|---|---|
successful | completed |
declined or cancelled, with retryAllowed: false | ended |
ready, pending, action_required, or a retryable decline or cancellation | open |
Submit from your handler
Add this to the same script. Your handler validates your form and calls submit():
form.addEventListener('submit', async (event) => {
event.preventDefault()
if (!form.reportValidity() || !payment.snapshot.canSubmit) return
await payment.submit().catch(() => {
// The card fields show the reason. The shopper can try again when canSubmit allows it.
})
})submit() calls prepare for the payment, then validates the card, runs 3-D Secure, and confirms the payment
with Tapaya.
Show the outcome
onChange receives the payment's snapshot whenever it changes, including when the page loads with a payment that
has already settled. Follow snapshot.outcome:
outcome | What to show |
|---|---|
open | The Pay button, enabled while canSubmit is true. A decline stays open, and the next submit() gets a new attempt. |
pending | Progress. The result is not known yet, so nothing can be paid again. The SDK checks the status automatically while the page is visible. |
completed | Your confirmation page. Your server verifies the order there. |
ended | A new checkout. No further attempt is possible for this order. |
Call payment.destroy() when your checkout view unmounts. For React, use usePayment() from
@tapayadot/checkout/react. It takes the same options plus publishableKey and apiOrigin, and returns a
fieldRef for the container, the current snapshot, and submit().
Verify before fulfilling
The browser result is for display. On your confirmation page, read the order's payment on your server, and fulfill only when it has completed:
import { findPayment } from './payments.js';
const order = await getCheckoutOrder(request);
const payment = await findPayment(order); // Checks order ID, amount, and currency.
if (payment?.status === 'successful') await markOrderPaid(order.id, payment.id);Make markOrderPaid() record the payment once, even when it is called repeatedly, and trigger fulfillment from that
stored state.
Reconcile orders whose shoppers leave
A shopper can close the tab before the confirmation page opens. Run a scheduled job that calls verify() or
reads GET /merchant/payments?merchantOrderId=... for unpaid orders, using the same checks. Declined and
cancelled attempts allow a new attempt, so an unpaid order stays open: release it on your own schedule, and
only while its paymentStatus is not pending. A failed request or timeout is not a decline, so keep the order
reserved until the payment has a known outcome.
Add Apple Pay and Google Pay
Wallets appear inside Tapaya's frame next to the card fields. Before they show, check that:
- Wallets are enabled in Checkout settings.
- Apple Pay: the shop has an active shop domain.
- Google Pay in production: a Google Pay merchant ID is configured.
- The shopper's device, browser, and wallet support the method.
Pass wallets with the checkout total in minor units. Tapaya shows the available methods, and unsupported or
disabled wallets stay hidden. Use this instead of the card-only createPayment() example:
const payment = tapaya.createPayment({
prepare: '/api/payments',
wallets: { amount: order.totalInMinorUnits, currency: order.currency },
onWalletClick: async () => {
if (!form.reportValidity()) throw new Error('Complete the checkout details.')
// Validate the cart and reserve the order on your server.
const response = await fetch('/api/payments', { method: 'POST' })
if (!response.ok) throw new Error('Could not prepare the payment.')
return response.json() // { clientSecret, status }
},
onChange: (snapshot) => {
button.hidden = snapshot.paymentMethod !== 'card'
button.disabled = !snapshot.canSubmit
if (snapshot.outcome === 'completed') location.assign('/checkout/confirmation')
},
})
await payment.mount('#card-fields')The total displayed in the browser must come from your server, which still calculates and validates the amount when it prepares the payment.
Wallet clicks
The official wallet button opens the native wallet sheet from the shopper's click. onWalletClick validates your
checkout and reserves its payment when that button is clicked, never on mount or method selection. Without this
callback, wallets use prepare or clientSecret. If you reserve the order only on click, make the mount-time
prepare callback resume an existing order without creating one.
Keep your own Pay button visible only when snapshot.paymentMethod is card; wallets use their branded buttons
instead of payment.submit(). Wallet outcomes use the same verification and recovery flow as cards. Closing the
wallet sheet leaves the checkout available; an uncertain payment stays pending and blocks another charge.
Update the total
Pass wallets: { amount, currency, disabled: true } while required checkout details are incomplete. Update the
total or enable the buttons without remounting:
payment.update({ wallets: { amount: 2450, currency: 'EUR', disabled: false } })Pass wallets: null to hide wallets until the total is ready. Tapaya refuses confirmation if the prepared payment
differs from the wallet total. The React usePayment() hook accepts these options and updates the total when
wallets changes.
Your own method selector
If your page owns the method selector, pass paymentMethod: 'card', 'apple_pay', or 'google_pay' to render
only that method. An unavailable selected wallet shows the walletUnavailable label and reports
snapshot.error.code: 'wallet_unavailable' when another payment is safe to start; offer another method in your
selector. Lock the selector while snapshot.busy is true or the payment is pending or action_required.
Frames and CSP
The SDK adds allow="payment" to its frame. If your shop is inside another frame, that outer frame must also allow
payment. Cross-origin Apple Pay requires Safari 17 or later. Wallet provider scripts stay inside Tapaya's frame, so
your page needs no additional provider origins in its CSP.
Handle expired browser access
The client secret expires after 24 hours, and clientSecret is then null. A payment that was never submitted is
then cancelled, and the next prepare() starts a new attempt for the same order. A pending payment stays pending
in the browser, and the SDK keeps calling your endpoint until your server reports its outcome as status. Server
reads still work, so your scheduled job settles it either way.
Diagnose server errors
The Node SDK throws PaymentsApiError for rejected API requests. Inspect its status, code, message,
fieldErrors, and requestId on your server. Keep credentials and client secrets out of logs, and show your own
shopper-facing text.
Match your branding
The card fields use the merchant's effective checkout color from Checkout
settings as their focus accent (colorFocus).
Values you pass in appearance.variables override it, and only the variables you set are sent.