Build a custom checkout with Elements
Use Paypercut Elements to collect payment details inside a checkout page whose layout and order flow you control. Secure fields are hosted by Paypercut, while your backend remains responsible for the final amount, Checkout Session confirmation, order state, and fulfillment.
This guide requires @paypercut/checkout-js 1.4.0 or later and covers a one-time card payment with a custom Checkout Session. Use hosted or embedded Checkout when Paypercut should own the complete payment form.
Choose an Element
An Elements Group can render these payment surfaces:
| Surface | Create it with | Use it when |
|---|---|---|
| Card Element | elements.create('card') |
You want one combined, secure card form. This is the recommended starting point. |
| Split card fields | cardNumber, cardExpiry, and cardCvc |
Your layout requires independently placed card fields. |
| Express Checkout Element | elements.create('expressCheckout') |
You want Apple Pay and Google Pay wallet buttons. See Express Checkout. |
Version 1.4.0 does not provide elements.create('payment'). The payment name is reserved for a future multi-payment-method selector.
Responsibilities
| Actor | Responsibility |
|---|---|
| Your frontend | Mount Elements, keep the displayed amount current, validate the group, create a Payment Method, and pass its ID to your backend. |
| Your backend | Validate the order, calculate the final amount, create and confirm the Checkout Session, store Paypercut IDs, and process webhooks. |
| Paypercut | Host sensitive payment fields, create the Payment Method, perform supported customer authentication, and process the payment. |
Use a publishable key in the browser. Your secret API key and authoritative order calculation must remain on your backend.
How the payment flow works
Creating or mounting an Element does not create a Payment Method, Checkout Session, Payment Intent, merchant order, or charge. The browser-side lifecycle is deliberately split into three operations:
elements.submit()validates the current Elements Group and returnsPromise<void>.paypercut.createPaymentMethod({ elements })creates an opaque Payment Method.paypercut.confirmPayment({ clientSecret })completes supported client-side actions for a payment your backend already started.
Before you begin
You need:
- a Paypercut account with a publishable key and secret API key;
- an HTTPS checkout page in production;
- a backend endpoint that validates and persists orders;
- a webhook endpoint that verifies Paypercut signatures;
- sandbox credentials and test cards.
Never put the secret API key in browser code, logs, URLs, or mobile application bundles.
1. Install the SDK
Install version 1.4.0 or later from npm:
npm install @paypercut/checkout-js@^1.4.0
Import the named Paypercut client:
import { Paypercut } from '@paypercut/checkout-js';
For a classic browser script, pin the exact version in production:
<script src="https://cdn.jsdelivr.net/npm/@paypercut/checkout-js@1.4.0/dist/paypercut-checkout.iife.min.js"></script>
The script exposes window.Paypercut.
2. Prepare the order on your backend
Before mounting Elements, ask your backend to validate the cart and return a persisted order identifier, amount, and currency. Calculate discounts, tax, shipping, and the final total on the backend.
{
"order_id": "order_1042",
"amount": 2999,
"currency": "EUR"
}
Amounts use the currency's minor unit. For example, 2999 represents EUR 29.99.
The amount and currency sent to Elements must match the Checkout Session your backend later confirms. If the amount changes before Payment Method creation, update the group and ask the customer to submit again:
elements.update({ amount: updatedOrder.amount });
Currency is immutable for an Elements Group. Destroy the group and create a new one if the order currency changes.
3. Mount the Card Element
Add merchant-owned fields for billing details and a container for the secure Card Element:
<form id="payment-form">
<label>
Name on card
<input id="billing-name" autocomplete="cc-name" />
</label>
<label>
Email
<input id="billing-email" type="email" autocomplete="email" />
</label>
<div id="card-element"></div>
<p id="payment-error" role="alert"></p>
<button id="pay-button" type="submit" disabled>Pay</button>
</form>
Create one Elements Group for the payment attempt, then create and mount a Card Element:
const preparedOrder = await fetch('/api/orders/prepare', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}).then((response) => response.json());
const paypercut = Paypercut({
publishableKey: 'YOUR_PUBLISHABLE_KEY',
});
const elements = paypercut.elements({
mode: 'payment',
amount: preparedOrder.amount,
currency: preparedOrder.currency,
locale: 'auto',
appearance: {
theme: 'light',
inputs: 'condensed',
labels: 'auto',
},
});
const card = elements.create('card');
const payButton = document.querySelector('#pay-button');
const errorMessage = document.querySelector('#payment-error');
card.on('ready', () => {
payButton.disabled = false;
});
card.on('error', ({ code }) => {
errorMessage.textContent = messageForElementsError(code);
});
card.mount('#card-element');
Wait for ready before enabling submission. Each on() call returns an unsubscribe function.
4. Validate and create a Payment Method
Call elements.submit() when the customer submits your form. A successful call validates only the current group; it does not create a server-side payment resource.
Then call paypercut.createPaymentMethod() to create the opaque Payment Method:
document.querySelector('#payment-form').addEventListener('submit', async (event) => {
event.preventDefault();
payButton.disabled = true;
errorMessage.textContent = '';
try {
await elements.submit();
const paymentMethod = await paypercut.createPaymentMethod({
elements,
params: {
billing_details: {
name: document.querySelector('#billing-name').value,
email: document.querySelector('#billing-email').value,
},
},
});
const response = await fetch(`/api/orders/${preparedOrder.order_id}/pay`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ payment_method: paymentMethod.id }),
});
const serverResult = await response.json();
if (!response.ok) throw new Error(serverResult.message || 'Payment failed.');
if (serverResult.clientSecret) {
const result = await paypercut.confirmPayment({
clientSecret: serverResult.clientSecret,
});
const paymentState = result.checkoutSession.paymentObjectStatus;
if (!['succeeded', 'processing', 'requires_capture'].includes(paymentState ?? '')) {
throw new Error(`Payment status: ${paymentState}`);
}
}
window.location.assign(serverResult.returnUrl);
} catch (error) {
const code = error && typeof error === 'object' ? error.code : undefined;
errorMessage.textContent = messageForElementsError(code);
if (!error || typeof error !== 'object' || error.recoverable !== false) {
payButton.disabled = false;
}
}
});
createPaymentMethod() resolves to:
{
"type": "payment_method",
"id": "01KXXXXXXXXXXXXXXXXXXXXXXX"
}
Treat the ID as an opaque, sensitive credential. Do not put it in the DOM, a URL, analytics, or application logs.
Validation is tied to the current input revision. If the customer edits a secure field, an Element remounts, or the group configuration changes, call elements.submit() again before creating a Payment Method.
5. Create and confirm the Checkout Session on your backend
After your backend receives the Payment Method ID, reload the order from your database and validate it again. Do not accept the amount, currency, customer, or line items from the browser as authoritative.
Create a Checkout Session with ui_mode=custom and a stable idempotency key:
curl https://api.paypercut.io/v1/checkouts \
-X POST \
-H "Authorization: Bearer YOUR_SECRET_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order:1042:checkout:create:v3" \
-d '{
"mode": "payment",
"ui_mode": "custom",
"amount": 2999,
"currency": "EUR",
"payment_method_types": ["card"],
"return_url": "https://merchant.example.com/orders/order_1042",
"client_reference_id": "order_1042",
"metadata": {
"order_id": "order_1042"
}
}'
Store the Checkout Session ID against your order before confirmation. Confirm the stored Session with a separate, stable idempotency key:
curl https://api.paypercut.io/v1/checkouts/01KCHECKOUTXXXXXXXXXXXXXXX/confirm \
-X POST \
-H "Authorization: Bearer YOUR_SECRET_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order:1042:checkout:confirm:ATTEMPT_ID" \
-d '{
"payment_method": "01KXXXXXXXXXXXXXXXXXXXXXXX"
}'
A custom Checkout Session response can include client_secret. This is an opaque client-side capability for that Session, not your secret API key. Return it only to the untrusted client instance and customer that own the payment attempt, using your application's response shape:
{
"clientSecret": "CLIENT_SECRET_FROM_CHECKOUT_SESSION",
"returnUrl": "https://merchant.example.com/orders/order_1042"
}
Do not log the client secret, embed it in UI markup, put it in a URL or analytics, persist it in client-side storage, or expose it to a different customer or payment attempt.
If a create or confirm response is lost, retrieve the stored Checkout Session before retrying. Do not create a replacement Session until the previous attempt is known to be terminal. Reuse an idempotency key only for an exact retry of the same operation and payload.
6. Continue client-side authentication
If your backend returns a Checkout Session client secret, pass it to the SDK:
const result = await paypercut.confirmPayment({ clientSecret });
In a web integration, confirmPayment() performs supported client-side authentication when required and resolves with the latest Checkout Session state. Treat the client secret as opaque. Other client platforms should pass it only to their supported Paypercut client SDK.
handleNextAction({ clientSecret }) is an exact advanced alias. Prefer confirmPayment() for ordinary integrations.
The result has this shape:
{
checkoutSession: {
id,
status,
paymentStatus,
paymentObjectStatus,
},
}
Use paymentObjectStatus to choose the client-facing experience:
| Status | Frontend action |
|---|---|
succeeded |
Show completion UI or navigate to your order page. |
requires_capture |
Show that authorization succeeded. Your backend still owns capture. |
processing |
Show a pending state. Do not start another payment. |
requires_payment_method |
Ask the customer to start a deliberate new payment attempt after your backend reconciles the current one. |
requires_action or requires_confirmation |
Keep the order pending and reconcile through your backend. Do not report success. |
canceled or expired |
Reconcile the old attempt before creating a new Elements Group and Checkout Session. |
The client-side result is for customer-facing navigation and status. Fulfill orders only from an authenticated backend confirmation or retrieval response, or a verified webhook.
If a recoverable continuation fails, call confirmPayment() again with the same client secret. It starts by retrieving current state. After the client restarts or reloads, obtain the same active client secret from your backend instead of persisting it in client-side storage.
7. Reconcile with webhooks
Handle the server confirmation response immediately. Fulfill a paid order, keep a processing order pending, or record a terminal failure according to the authenticated response.
Use webhooks to complete asynchronous payments and recover when your server does not receive a definitive confirmation response. Verify every webhook signature and make event handling idempotent.
Store:
- your order ID and revision;
- the Checkout Session ID;
- the Payment Intent and Payment IDs when present;
- the client attempt ID and idempotency keys;
- the latest authoritative status applied to the order.
Use client_reference_id or metadata.order_id to correlate the Checkout Session with your order. See webhook signatures.
Handle errors and retries
Elements errors include a machine-readable code and a recoverable flag. Own and localize the customer-facing message, and keep a fallback for codes introduced by newer hosted components.
function messageForElementsError(code) {
const messages = {
elements_payment_details_incomplete: 'Complete your payment details.',
elements_payment_details_invalid: 'Check your payment details and try again.',
elements_action_canceled: 'Authentication was canceled. You can try again.',
};
return messages[code] || 'We could not complete this payment. Try again.';
}
| Code or condition | Action |
|---|---|
elements_payment_details_incomplete or elements_payment_details_invalid |
Keep the group mounted, let the customer correct the fields, and call elements.submit() again. |
elements_submit_required |
Validate the current input revision with elements.submit() before calling createPaymentMethod(). |
elements_not_ready |
Wait for the next ready event before submitting. |
elements_submit_indeterminate or an indeterminate creation timeout |
Do not create another Payment Method blindly. Reconcile server state, then create a new group only for a deliberate new attempt. |
elements_action_canceled with recoverable: true |
Keep the payment pending and retry confirmPayment() with the same client secret if the customer chooses to continue. |
elements_client_secret_invalid |
Ask your backend for the active secret associated with this customer's Checkout Session. Do not reuse a secret from another attempt. |
Keep only one Payment Method creation in flight for a group. Concurrent calls share the same attempt; a successful result is returned again without creating a second Payment Method.
Save a card for later use
Prepare a reusable card only after the customer has agreed to future use. Declare the intended reuse when creating the Elements Group:
const elements = paypercut.elements({
mode: 'payment',
amount: 2999,
currency: 'EUR',
setupFutureUsage: 'on_session', // or 'off_session'
});
on_session means later use while the customer is present. off_session means your backend may attempt later use without the customer present. This option prepares authentication context; it does not create a mandate, attach a Customer, or guarantee that a future authorization succeeds.
Associate the Checkout Session with a Paypercut Customer and configure future usage on your backend:
{
"customer": "01KCUSTOMERXXXXXXXXXXXXXXXX",
"saved_payment_method_options": {
"payment_method_save": "enabled"
},
"payment_intent_data": {
"setup_future_usage": "on_session"
}
}
Then include "save_payment_method": true when confirming the Checkout Session. Store only the resulting reusable Payment Method ID and non-sensitive display details needed by your application.
setup and subscription Elements modes automatically prepare the card for off-session use. They still require amount and currency in version 1.4.0, and browser confirmPayment() supports payment-mode Checkout Sessions only.
Use split card fields
Use split fields when you need to place card number, expiration, and security code separately:
const cardNumber = elements.create('cardNumber', { showIcon: true });
const cardExpiry = elements.create('cardExpiry');
const cardCvc = elements.create('cardCvc');
cardNumber.mount('#card-number');
cardExpiry.mount('#card-expiry');
cardCvc.mount('#card-cvc');
cardNumber.on('change', ({ complete, empty, brand, error }) => {
updateCardNumberState({ complete, empty, brand, error });
});
await elements.submit();
const paymentMethod = await paypercut.createPaymentMethod({ elements });
A split field never exposes raw card data or its own submit() method. Its change event contains only safe state such as completeness, card brand, and validation errors.
One group accepts one field of each split type. Do not mix split fields with a Card Element in the same group.
Update appearance and amount
Configure Appearance before the first Element mounts:
const elements = paypercut.elements({
mode: 'payment',
amount: 2999,
currency: 'EUR',
appearance: {
theme: 'dark',
inputs: 'spaced',
labels: 'above',
variables: {
colorPrimary: '#635bff',
borderRadius: '8px',
},
rules: {
'.Input:focus': { borderColor: '#635bff' },
'.Error': { color: '#b42318' },
},
},
});
mode, currency, locale, and setupFutureUsage are immutable. Amount remains mutable with elements.update({ amount }).
Appearance is locked after the first mount by default. appearanceUpdates: 'remount' is intended for integration previews, not routine theme changes on a live checkout. It remounts secure fields and clears entered payment details.
Enable Developer Assistant
Version 1.4.0 can load the optional Developer Assistant for configuration previews:
const paypercut = Paypercut({
publishableKey: 'YOUR_PUBLISHABLE_KEY',
developerAssistant: true,
});
developerAssistant is a strict boolean and defaults to false. Enable it only in development or an authenticated, restricted preview. It is not an access-control boundary; exposing it on a public checkout makes its controls visible to customers.
The SDK loads a separate, exact-version artifact when a compatible Card or Express Checkout Element mounts. A blocked or unavailable artifact disables only Developer Assistance; payment collection continues and the SDK emits a sanitized warning.
Lifecycle, SSR, and CSP
Initialize and mount Elements only in a browser-side lifecycle when using server-side rendering.
Destroy the group when the owning page or framework component permanently unmounts:
window.addEventListener('pagehide', () => elements.destroy(), { once: true });
elements.destroy() destroys every Element in the group. Destroying browser UI does not cancel or reverse a server-side payment. Reconcile the order before starting another attempt.
If your page uses Content Security Policy, allow the SDK distribution origin plus the Paypercut frame and connection origins required for your account. Do not copy internal frame URLs into application code.
Common mistakes
- Calling
elements.create('payment')instead ofelements.create('card'). - Expecting
elements.submit()to return a Payment Method. - Calling
createPaymentMethod()without validating the current input revision. - Trusting the browser to supply the final order amount or currency.
- Starting a replacement payment after an ambiguous response without reconciling the existing Session.
- Parsing or logging a Payment Method ID or Checkout Session client secret.
- Fulfilling an order from client-side state or a redirect instead of an authenticated backend response or verified webhook.
- Exposing Developer Assistance to ordinary customers.

