Tapaya
Online PaymentsIntegration GuideEmbedded Checkout

Embedded Checkout

Add secure card fields to your form and submit from your own button.

Secure card fields for your checkout. You own the form and submit button. Tapaya handles card validation, tokenization, and 3-D Secure.

Choose HTTP (fetch), Node.js SDK, or cURL in the server examples below. All three use the same merchant API. The browser card fields stay the same, and embedded payments do not need return URLs.

Install the SDK

One package contains the server client, the browser SDK, and the React hook:

npm install @tapayadot/checkout
ImportWhere
@tapayadot/checkoutYour server only. Importing it in a browser build throws, so your API key never reaches a page.
@tapayadot/checkout/clientYour checkout page.
@tapayadot/checkout/reactYour checkout page, if you use React.

Store your key in the server's TAPAYA_SECRET_KEY environment variable. new Tapaya() reads it automatically. Embedded calls use the same environment or baseUrl as hosted calls. Use sandbox, the default environment. The SDK rejects embedded calls in production.

A merchant checkout key needs nothing else. In platform mode, use the organization Secret key and pass the order's merchantToken to every server SDK call, or set it with new Tapaya(key, { merchantToken }). See Checkout authentication. Client secrets are specific to each payment.

For Fetch and cURL, set TAPAYA_CHECKOUT_API_URL to https://api.sandbox.tapaya.com. Load your key into TAPAYA_CHECKOUT_API_KEY on your server. In platform mode, also send X-Tapaya-Merchant-Token on every request. The Fetch helper below reads it from TAPAYA_MERCHANT_TOKEN; a backend serving several merchants must select that value from the authenticated order.

How it works

  1. Your page mounts Tapaya's card fields and points them at an endpoint on your server.
  2. 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.
  3. Your page follows the payment's outcome. When it completes, your server verifies the payment before you fulfill the order.

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>

Get 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.

server/tapaya.js
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. Pass both to the loader: the card fields require apiOrigin, because publishable keys start with pk_dev_ in every environment and cannot select the API.

Load Tapaya and mount the fields. prepare is the endpoint you add in Return the order's payment.

checkout.ts
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.

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.

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.

server/payments.js
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 session status expected by the card fields. Fetch and cURL integrations must apply the same mapping in their own prepare endpoint:

API statusPrepare endpoint status
successfulcompleted
declined or cancelled, with retryAllowed: falseended
ready, pending, action_required, or a retryable decline or cancellationopen

An order that is already paid, or that your scheduled job settled, still answers with its payment's status, so a reload shows the result instead of an error. It never receives a client secret, so the shopper cannot pay an order you no longer accept payment for.

prepare() returns the order's payment. It creates the first attempt, returns an unresolved one unchanged, and creates the next attempt after a decline. Every decline sets retryAllowed, so the shopper can try another card for the same order. An order can have only one successful payment, shared with hosted Checkout. It derives idempotency keys itself, so calling it on every page load and submission never creates a second charge.

Take amount from your stored order, in integer minor units, never from the browser. Freeze the total once payment starts: prepare() throws TapayaOrderMismatchError if the order's amount or currency no longer match its payment. Send the client secret only to the shopper authorized to pay this order, and never log it.

The card fields POST to prepare on your own origin with cookies and no body. They reject redirects and never retry the request automatically. For custom headers, CSRF tokens, or a request body, pass a function that returns the same JSON instead of the URL: prepare: async () => (await fetch(…)).json().

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:

outcomeWhat to show
openThe Pay button, enabled while canSubmit is true. A decline stays open, and the next submit() gets a new attempt.
pendingProgress. The result is not known yet, so nothing can be paid again. The SDK checks the status automatically while the page is visible.
completedYour confirmation page. Your server verifies the order there.
endedA 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:

server/confirmation.js
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);

verify() never creates a payment and returns null before the first attempt. It throws TapayaOrderMismatchError when the amount or currency differ from your order. Make markOrderPaid() record the payment once, even when it is called repeatedly, and trigger fulfillment from that stored state.

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: keep the order reserved until the payment has a known outcome.

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.

Allow the hosted fields

The loader fetches Tapaya.js from https://js.tapaya.com. That origin is not deployed yet. For local development, serve Tapaya.js yourself and pass its origin to the loader, for example loadTapaya(publishableKey, { apiOrigin, origin: 'http://localhost:4191' }).

Add Tapaya's asset origin to your existing Content Security Policy:

script-src https://js.tapaya.com
frame-src https://js.tapaya.com

The browser receives the publishable key and the payment-specific client secret. Your secret merchant API key remains on your server. Card data and payment-provider scripts stay inside Tapaya's frame.