Build a custom checkout with Elements

Use Paypercut Elements with the Checkout Sessions API to build a payment page whose layout and order flow you control. Elements renders secure payment fields in your frontend, while your backend creates and confirms the Checkout Session with the final order amount.

This integration is a good fit when you need a custom checkout page but do not want sensitive card data to pass through your application. For a Paypercut-managed payment page, use hosted or embedded Checkout instead.

What Elements owns

The integration has three distinct owners:

Actor Responsibility
Your frontend Mount Elements, keep the displayed amount current, submit the Payment Element, and send the returned Payment Method ID to your backend.
Your backend Validate the cart or order, calculate the final amount, create and confirm the Checkout Session, store Paypercut IDs, and process webhooks.
Paypercut Host the sensitive payment fields, tokenize payment details, perform supported customer authentication, create the Payment Method, and process the payment.

Your secret API key and final order calculation must remain on your backend. The browser uses only a publishable key.

How the payment flow works

Creating an Elements group does not create a Checkout Session, Payment Intent, merchant order, or charge. paymentElement.submit() validates and authenticates the payment details and returns an opaque Payment Method reference for your backend to use.

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 can validate and persist your order before payment;
  • 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 JavaScript SDK

Install the Paypercut Checkout JavaScript SDK from npm:

npm install @paypercut/checkout-js

Import Paypercut in your checkout code:

import { Paypercut } from '@paypercut/checkout-js';

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 passed to Elements are used during payment authentication. Your backend must later create the Checkout Session with the exact same amount and currency. If the order total changes before submission, update Elements with the new amount before asking the customer to submit again:

elements.update({ amount: updatedOrder.amount });

Currency is fixed for the lifetime of an Elements group. Destroy the existing group and create a new one if the order currency changes.

3. Mount the Payment Element

Add a container and a submit button to your payment form:

<form id="payment-form">
  <div id="payment-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 the Payment 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: 'en',
  appearance: {
    theme: 'light',
    inputs: 'spaced',
    labels: 'above',
  },
});

const paymentElement = elements.create('payment');
const payButton = document.querySelector('#pay-button');
const errorMessage = document.querySelector('#payment-error');

function showPaymentError(error) {
  const messages = {
    elements_payment_details_incomplete: 'Complete your payment details.',
    elements_payment_details_invalid: 'Check your payment details and try again.',
  };
  const code = error && typeof error === 'object' ? error.code : undefined;

  errorMessage.textContent =
    messages[code] || 'We could not prepare this payment. Try again.';
}

paymentElement.on('ready', () => {
  payButton.disabled = false;
});

paymentElement.on('error', ({ code, recoverable }) => {
  showPaymentError({ code, recoverable });
});

paymentElement.mount('#payment-element');

Wait for the ready event before enabling payment submission. Call elements.destroy() when the checkout page or component is permanently unmounted.

4. Submit the Payment Element

Call paymentElement.submit() in response to the customer submitting your form. It resolves to a narrow reference with this shape:

{
  "type": "payment_method",
  "id": "01KXXXXXXXXXXXXXXXXXXXXXXX"
}

Treat the ID as an opaque, sensitive payment credential. It is bound to your Paypercut account and environment and is normally used for one payment attempt. Do not put it in the DOM, a URL, analytics, or application logs.

document.querySelector('#payment-form').addEventListener('submit', async (event) => {
  event.preventDefault();
  payButton.disabled = true;
  errorMessage.textContent = '';

  try {
    const paymentMethod = await paymentElement.submit();
    const attemptId = crypto.randomUUID();

    const response = await fetch(`/api/orders/${preparedOrder.order_id}/pay`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        payment_method: paymentMethod.id,
        attempt_id: attemptId,
      }),
    });

    const result = await response.json();
    if (!response.ok) throw new Error(result.message || 'Payment failed.');

    window.location.assign(result.return_url);
  } catch (error) {
    showPaymentError(error);
    if (!error || typeof error !== 'object' || error.recoverable !== false) {
      payButton.disabled = false;
    }
  }
});

Keep one in-flight submit() call per mounted Element. If submission fails with recoverable: true, keep the form mounted and let the customer correct or retry the attempt. If the error is not recoverable, recreate the Elements group before starting a deliberate new attempt. Never automatically retry elements_submit_timeout or elements_submit_indeterminate.

5. Create a custom Checkout Session on your backend

After your backend receives the Payment Method ID, reload and validate the order from your own database. Do not accept the amount, currency, customer, or line items from the browser as authoritative.

Create a Checkout Session with ui_mode=custom. Use a stable idempotency key for the order revision so a network retry cannot create a second Session.

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 returned Checkout Session ID against your order before attempting confirmation. Also store the order revision and the client attempt ID that selected this Payment Method.

6. Confirm the Checkout Session

Confirm the stored Session with the Payment Method ID returned by Elements. Use a separate, stable idempotency key for this confirmation attempt.

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"
  }'

Do not create a replacement Session when a create or confirm response is lost. Retrieve the stored Session first and classify its authoritative state. Reuse the same idempotency key only for an exact retry of the same operation and payload.

Use the response fields together:

State Backend action
payment_status=paid Record the Paypercut identifiers, mark the order paid, and fulfill it. The authenticated confirmation response is authoritative.
payment_status=processing Keep the order pending and wait for a webhook or retrieve the Session again. Do not start another payment.
payment_status=unpaid with a terminal failure Mark the attempt failed and let the customer start a new attempt with a new Payment Method and attempt ID.
status=expired Create a new Session only after confirming the previous Session cannot complete.

Elements performs supported card authentication before returning the Payment Method. The current Elements API does not expose a post-confirmation method for completing a reactive next_action, so the production path described here requires the Session amount and currency to match the latest Elements state.

If confirmation is ambiguous, retrieve the Checkout Session and wait for webhook delivery when processing is asynchronous. If the authoritative Session still contains next_action, do not redirect to it, report success, or repeat confirmation blindly. Keep the order pending for reconciliation and do not start a replacement payment until you have confirmed that the existing attempt cannot settle.

7. Reconcile with webhooks

Checkout Session confirmation is a synchronous server-to-server request. Handle its response immediately: fulfill a paid order, keep a processing order pending, or record a terminal failure. You do not need to wait for a webhook before fulfilling a payment that the confirmation response reports as paid.

Use webhooks to complete asynchronous processing payments and to recover when your server does not receive a definitive confirmation response. They also provide an independent reconciliation path if your application is temporarily unavailable when a payment changes state.

Verify every webhook signature and make event handling idempotent. Use client_reference_id or metadata.order_id to find your order, and store the Checkout Session, Payment Intent, and Payment IDs for reconciliation. Before applying a delayed or unexpected event, retrieve the Checkout Session and compare its current state with your stored payment attempt.

See webhook signatures for verification requirements.

Save the Payment Method for later use

Save a Payment Method only after the customer has explicitly agreed to future use. Associate the Checkout Session with a Paypercut Customer and declare future usage when you create the Session:

{
  "customer": "01KCUSTOMERXXXXXXXXXXXXXXXX",
  "saved_payment_method_options": {
    "payment_method_save": "enabled"
  },
  "payment_intent_data": {
    "setup_future_usage": "on_session"
  }
}

Then include "save_payment_method": true in the confirmation request. Store only the resulting reusable Payment Method ID and non-sensitive display details needed by your application.

Keep the integration safe

  • Use a publishable key only in the frontend and a secret key only on your backend.
  • Calculate and validate the final order total on your backend.
  • Keep the amount and currency used by Elements consistent with the Session you confirm.
  • Treat Payment Method IDs as opaque and avoid exposing them beyond the active checkout flow.
  • Persist the Checkout Session ID before confirmation.
  • Use separate stable idempotency keys for create and confirm operations.
  • Reconcile an unknown response by retrieving the existing Session before any new financial mutation.
  • Use verified webhooks, not the browser redirect, to fulfill the order.