JSPM

  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 32
  • Score
    100M100P100Q93407F
  • License MIT

Lightweight JavaScript SDK for Paypercut Checkout

Package Exports

  • @paypercut/checkout-js
  • @paypercut/checkout-js/cdn
  • @paypercut/checkout-js/package.json

Readme

Paypercut Checkout JavaScript SDK

A lightweight, framework-agnostic JavaScript SDK for embedding Paypercut Checkout into your web application. Works seamlessly with vanilla JavaScript, TypeScript, React, Vue, Angular, and other modern frameworks.

npm version License: MIT Bundle Size TypeScript


Table of Contents


Installation

Via NPM

npm install @paypercut/checkout-js

Via Yarn

yarn add @paypercut/checkout-js

Via PNPM

pnpm add @paypercut/checkout-js

Via CDN

<!-- jsDelivr (recommended with SRI) -->
<script src="https://cdn.jsdelivr.net/npm/@paypercut/checkout-js@1.0.16/dist/paypercut-checkout.iife.min.js"
        integrity="sha384-..."
        crossorigin="anonymous"></script>

<!-- or UNPKG -->
<script src="https://unpkg.com/@paypercut/checkout-js@1.0.16/dist/paypercut-checkout.iife.min.js"></script>

Quick Start

This returns a checkout session ID like 01KB23M9EC960C50G3AH14FTTT.

1. Embed the Checkout

Use the checkout ID to initialize the checkout on your frontend:

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

// Initialize the checkout
const checkout = PaypercutCheckout({
  id: '01KB23M9EC960C50G3AH14FTTT',              // Your checkout session ID from step 1
  containerId: '#checkout'       // CSS selector or HTMLElement
});

// Listen for payment events
checkout.on('success', () => {
  console.log('Payment successful!');
});

checkout.on('error', () => {
  console.error('Payment failed');
});

// Optional: Listen for when iframe finishes loading (useful for modal implementations)
checkout.on('loaded', () => {
  console.log('Checkout loaded and ready');
});

// Render the checkout
checkout.render();

That's it! The checkout is now embedded in your page.

2. Display Mode

The SDK supports one display mode:

Mode When to Use Configuration
Embedded Checkout is part of your page layout Provide containerId

API Reference

PaypercutCheckout(options)

Creates a new checkout instance.

Options

Option Type Required Default Description
id string Yes Checkout session identifier (e.g., 01KB23M9EC960C50G3AH14FTTT)
containerId string | HTMLElement Yes CSS selector or element where iframe mounts
locale string No 'auto' Locale for checkout UI. Options: 'auto', 'en', 'en-GB', 'bg', 'bg-BG'
lang string No 'auto' Locale for checkout UI. Options: 'auto', 'en', 'en-GB', 'bg', 'bg-BG'
ui_mode 'hosted' | 'embedded' No 'embedded' UI mode for checkout display
wallet_options string[] No ['apple_pay', 'google_pay'] Digital wallet options. Pass [] to disable all wallets
form_only boolean No false Show only payment form (no Pay Now button - use external button with submit())
validate_form boolean No false This indicates that Google Pay/Apple Pay flow to proceed you need to confirm form validation. For EMBEDDED checkouts only)
appearance object No undefined Appearance configuration for the checkout UI
appearance.preset 'modal' | 'inline' No undefined Controls checkout layout preset. 'modal' shows the order summary (TotalCard) and header inside the iframe — use when rendering in a modal/dialog. 'inline' hides the TotalCard — use when the merchant renders their own order summary outside the checkout.

Examples

Basic initialization:

const checkout = PaypercutCheckout({
  id: '01KB23M9EC960C50G3AH14FTTT',
  containerId: '#checkout-container'
});

With all options:

const checkout = PaypercutCheckout({
  id: '01KB23M9EC960C50G3AH14FTTT',
  containerId: '#checkout-container',
  locale: 'en',                              // 'auto' | 'en' | 'en-GB' | 'bg' | 'bg-BG'
  lang: 'en',                              // 'auto' | 'en' | 'en-GB' | 'bg' | 'bg-BG'
  ui_mode: 'embedded',                       // 'hosted' | 'embedded'
  wallet_options: ['apple_pay', 'google_pay'], // Can be empty array [] or contain one/both options
  form_only: false,                          // Set true to hide Pay Now button (use external button),
  validate_form: true,            // Set true to require form validation before wallet payment
  appearance: {
    preset: 'modal',              // 'modal' | 'inline' — 'modal' shows TotalCard + header, 'inline' hides them
  },
});

Disable wallet payments:

const checkout = PaypercutCheckout({
  id: '01KB23M9EC960C50G3AH14FTTT',
  containerId: '#checkout-container',
  wallet_options: []  // No Apple Pay or Google Pay buttons
});

Only Apple Pay:

const checkout = PaypercutCheckout({
  id: '01KB23M9EC960C50G3AH14FTTT',
  containerId: '#checkout-container',
  wallet_options: ['apple_pay']  // Only Apple Pay, no Google Pay
});

Form-only mode (external submit button):

const checkout = PaypercutCheckout({
  id: '01KB23M9EC960C50G3AH14FTTT',
  containerId: '#checkout-container',
  form_only: true  // No Pay Now button inside checkout
});

// Use your own button to trigger payment
document.getElementById('my-pay-button').addEventListener('click', () => {
  checkout.submit();
});

Instance Methods

render()

Mounts and displays the checkout iframe.

checkout.render();

destroy()

Destroys the instance and cleans up all event listeners. Call this when you're done with the checkout instance.

checkout.destroy();

on(event, handler)

Subscribes to checkout events. Returns an unsubscribe function.

const unsubscribe = checkout.on('success', () => {
  console.log('Payment successful!');
});

// Later, to unsubscribe
unsubscribe();

once(event, handler)

Subscribes to a checkout event that automatically unsubscribes after the first emission. Returns an unsubscribe function.

checkout.once('loaded', () => {
  console.log('Checkout loaded - this will only fire once');
});

off(event, handler)

Unsubscribes from checkout events.

const handler = () => console.log('Payment successful!');
checkout.on('success', handler);
checkout.off('success', handler);

isMounted()

Returns whether the checkout is currently mounted.

if (checkout.isMounted()) {
  console.log('Checkout is visible');
}

Events

Subscribe to events using the on() method. You can use string event names or the SdkEvent enum.

Event Reference

Event Enum Description Payload
loaded SdkEvent.Loaded Checkout iframe has finished loading void
processing SdkEvent.Processing Payment processing has started inside the hosted checkout { checkoutId: string }
success SdkEvent.Success Payment completed successfully PaymentSuccessPayload
error SdkEvent.Error Terminal failure (tokenize, confirm, or 3DS) ApiErrorPayload
expired SdkEvent.Expired Checkout session expired void
threeds_started SdkEvent.ThreeDSStarted 3DS challenge flow started object
threeds_complete SdkEvent.ThreeDSComplete 3DS challenge completed object
threeds_canceled SdkEvent.ThreeDSCanceled 3DS challenge canceled by user object
threeds_error SdkEvent.ThreeDSError 3DS challenge error ApiErrorPayload or object

Usage Examples

Using string event names:

const checkout = PaypercutCheckout({ id: '01KB23M9EC960C50G3AH14FTTT', containerId: '#checkout' });

checkout.on('loaded', () => {
  console.log('Checkout loaded');
});

checkout.on('processing', ({ checkoutId }) => {
  console.log('Payment processing started for checkout:', checkoutId);
});

checkout.on('success', (payload) => {
  // PaymentSuccessPayload
  console.log('Payment successful');
  console.log('Card brand:', payload.payment_method.brand);
  console.log('Last 4:', payload.payment_method.last4);
  console.log('Expiry:', payload.payment_method.exp_month + '/' + payload.payment_method.exp_year);
});

checkout.on('error', (err) => {
  console.error('Payment error:', err.code, err.message);
});

checkout.on('expired', () => {
  console.warn('Checkout session expired');
});

Using SdkEvent enum (recommended for TypeScript):

import { PaypercutCheckout, SdkEvent } from '@paypercut/checkout-js';

const checkout = PaypercutCheckout({ id: '01KB23M9EC960C50G3AH14FTTT', containerId: '#checkout' });

checkout.on(SdkEvent.Loaded, () => {
  console.log('Checkout loaded');
});

checkout.on(SdkEvent.Processing, ({ checkoutId }) => {
  console.log('Payment processing started for checkout:', checkoutId);
});

checkout.on(SdkEvent.Success, (payload) => {
  console.log('Payment successful', payload.payment_method);
});

checkout.on(SdkEvent.Error, (err) => {
  console.error('Payment error:', err.code, err.message);
});

checkout.on(SdkEvent.Expired, () => {
  console.warn('Checkout session expired');
});

// 3DS events
checkout.on(SdkEvent.ThreeDSStarted, (ctx) => {
  console.log('3DS challenge started');
});

checkout.on(SdkEvent.ThreeDSComplete, (payload) => {
  console.log('3DS completed');
});

checkout.on(SdkEvent.ThreeDSCanceled, (payload) => {
  console.log('3DS canceled by user');
});

checkout.on(SdkEvent.ThreeDSError, (err) => {
  console.error('3DS error:', err);
});

Success Payload

The success event returns a PaymentSuccessPayload with the payment method details:

type PaymentSuccessPayload = {
  payment_method: {
    brand: string;      // e.g., 'visa', 'mastercard', 'amex'
    last4: string;      // Last 4 digits of card number
    exp_month: number;  // Expiration month (1-12)
    exp_year: number;   // Expiration year (e.g., 2030)
  };
};

Example:

checkout.on('success', (payload) => {
  // payload:
  // {
  //   payment_method: {
  //     brand: 'visa',
  //     last4: '4242',
  //     exp_month: 12,
  //     exp_year: 2030
  //   }
  // }

  console.log(`Paid with ${payload.payment_method.brand} ending in ${payload.payment_method.last4}`);
  // Output: "Paid with visa ending in 4242"
});

Error Handling

checkout.on('error', (err) => {
  switch (err.code) {
    case 'card_validation_error':
      // err.errors[] has field-level issues. Messages are localized.
      break;
    case 'card_declined':
      // Optional err.decline_code and user-friendly err.message
      break;
    case 'threeds_error':
    case 'threeds_authentication_failed':
    case 'threeds_canceled':
      // 3DS issue. Some servers use status_code 424 with a detailed message.
      break;
    default:
      // Other terminal errors (e.g., authentication_failed, session_expired)
      break;
  }
});

Notes

  • error: the SDK forwards a normalized ApiErrorPayload when provided by Hosted Checkout.
  • processing: non-terminal event. Use it to disable close buttons or show "do not close" messaging, then clear that UI on success, error, cancel, or expired.
  • threeds_error: forwards payload.error when available; otherwise the raw message data.
  • three![img.png](img.png)ds_* non-error events: payload is forwarded as-is from Hosted Checkout (shape may evolve).

Form Validation for Wallet Payments

How It Works

  1. User clicks Apple Pay or Google Pay button in the checkout
  2. SDK emits form_validation event to your code
  3. You validate your form and call either:
    • completeFormValidation(wallet) - allows wallet to proceed
    • failFormValidation(wallet, errors) - blocks wallet, you show your own errors
  4. If validation passes, the wallet payment sheet opens

Event: form_validation

Property Type Description
checkoutId string The checkout session ID
wallet 'apple_pay' | 'google_pay' Which wallet button was clicked

Methods

completeFormValidation(wallet)

Call this when your form is valid. The wallet payment sheet will open.

checkout.completeFormValidation('google_pay');

failFormValidation(wallet, errors?)

Call this when your form has errors. The wallet will not open, and you should display your own error messages.

checkout.failFormValidation('apple_pay', [
  { code: 'invalid_email', message: 'Please enter a valid email address' },
  { code: 'missing_address', message: 'Shipping address is required' }
]);

Basic Example

import { PaypercutCheckout, SdkEvent } from '@paypercut/checkout-js';

const checkout = PaypercutCheckout({
  id: '01KB23M9EC960C50G3AH14FTTT',
  containerId: '#checkout',
  wallet_options: ['apple_pay', 'google_pay']
});

// Handle form validation for wallet payments
checkout.on(SdkEvent.FormValidation, ({ wallet, checkoutId }) => {
  // Validate your own form fields
  const email = document.getElementById('email').value;
  const isEmailValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);

  if (isEmailValid) {
    // Form is valid - allow wallet to proceed
    checkout.completeFormValidation(wallet);
  } else {
    // Form has errors - block wallet and show your errors
    document.getElementById('email-error').textContent = 'Please enter a valid email';
    checkout.failFormValidation(wallet, [
      { code: 'invalid_email', message: 'Please enter a valid email' }
    ]);
  }
});

checkout.render();

React Example

import { useEffect, useRef, useState } from 'react';
import { PaypercutCheckout, CheckoutInstance, SdkEvent } from '@paypercut/checkout-js';

export function CheckoutWithForm({ checkoutId }: { checkoutId: string }) {
  const containerRef = useRef<HTMLDivElement>(null);
  const checkoutRef = useRef<CheckoutInstance | null>(null);
  const [email, setEmail] = useState('');
  const [emailError, setEmailError] = useState('');

  useEffect(() => {
    if (!containerRef.current) return;

    const checkout = PaypercutCheckout({
      id: checkoutId,
      containerId: containerRef.current,
      ui_mode: 'embedded',
      wallet_options: ['apple_pay', 'google_pay'],
      validate_form: true
    });

    // Handle wallet form validation
    checkout.on(SdkEvent.FormValidation, ({ wallet }) => {
      const isValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);

      if (isValid) {
        setEmailError('');
        checkout.completeFormValidation(wallet);
      } else {
        setEmailError('Please enter a valid email address');
        checkout.failFormValidation(wallet, [
          { code: 'invalid_email', message: 'Invalid email' }
        ]);
      }
    });

    checkout.on('success', (payload) => {
      console.log('Payment successful:', payload.payment_method);
    });

    checkout.render();
    checkoutRef.current = checkout;

    return () => checkout.destroy();
  }, [checkoutId, email]);

  return (
    <div>
      {/* Your custom form fields */}
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          placeholder="your@email.com"
        />
        {emailError && <span style={{ color: 'red' }}>{emailError}</span>}
      </div>

      {/* Checkout iframe */}
      <div ref={containerRef} />
    </div>
  );
}

Timeout Behavior

If you don't respond to the form_validation event within 10 seconds, the SDK will:

  1. Emit an error event with code form_validation_timeout
  2. Block the wallet payment from starting

This ensures the checkout doesn't hang indefinitely if the handler is not implemented.

When to Use

Use form validation when you have:

  • Custom email/phone fields outside the checkout iframe
  • Shipping address forms that must be filled before payment
  • Terms & conditions checkboxes that must be accepted
  • Any other merchant-side validation requirements

If you don't need to validate anything, you can simply auto-approve:

checkout.on('form_validation', ({ wallet }) => {
  // No validation needed - always allow wallet to proceed
  checkout.completeFormValidation(wallet);
});

Types

ApiErrorPayload

The SDK forwards the error payload provided by Hosted Checkout. Common shapes include:

// Card validation error with per-field details (messages are localized)
type CardField = 'card_number' | 'expiration_date' | 'cvc' | 'cardholder_name';

type CardValidationErrorCode =
  | 'invalid_number' | 'incomplete_number'
  | 'invalid_expiry' | 'incomplete_expiry'
  | 'invalid_cvc' | 'incomplete_cvc'
  | 'required';

type VendorValidationType = 'invalid' | 'incomplete' | 'required';

type CardValidationErrorItem = {
  field: CardField;
  code: CardValidationErrorCode;
  message: string; // localized per current checkout locale
  vendor_type: VendorValidationType;
};

type ApiErrorPayload =
  | {
      checkoutId: string;
      code: 'card_validation_error';
      message: string; // e.g., 'Card details are incomplete or invalid'
      status_code: number; // typically 400
      timestamp: string; // ISO8601
      errors: CardValidationErrorItem[];
    }
  | {
      code:
        | 'threeds_error'
        | 'threeds_authentication_failed'
        | 'threeds_canceled'
        | 'card_declined'
        | 'authentication_failed'
        | 'session_expired'
        | string; // other server codes may appear, e.g. 'permission_denied'
      type?: 'card_error' | string;
      message?: string;
      status_code?: number; // some sources use `status`
      status?: number;      // compatibility with some payloads
      decline_code?: string; // Optional decline reason
      trace_id?: string;
      timestamp?: string; // ISO8601
      [key: string]: any; // passthrough
    };

Error payload examples

{
  "checkoutId": "01KB23M9EC960C50G3AH14FTTT",
  "code": "card_validation_error",
  "message": "Card details are incomplete or invalid",
  "status_code": 400,
  "timestamp": "2025-01-01T12:00:00.000Z",
  "errors": [
    { "field": "card_number", "code": "invalid_number", "message": "Card number is invalid", "vendor_type": "invalid" },
    { "field": "expiration_date", "code": "incomplete_expiry", "message": "Incomplete date", "vendor_type": "incomplete" }
  ]
}
{
  "checkoutId": "01KA8EG3KA2A61YXX4XVD4FYPT",
  "code": "permission_denied",
  "message": "forbidden",
  "status_code": 403,
  "trace_id": "00761104aff14f33adb84d7437d7e320",
  "timestamp": "2025-12-02T09:31:22.779Z"
}
{
  "checkoutId": "01KA8EG3KA2A61YXX4XVD4FYPT",
  "code": "threeds_error",
  "type": "card_error",
  "message": "Card authentication failed.",
  "status": 400,
  "timestamp": "2025-12-02T09:31:44.250Z"
}

Note: Some payloads provide status_code while others provide status. Treat them interchangeably.

Error codes explained

  • authentication_failed — General authentication failure during confirm or 3DS. Usually recoverable by retrying or using another card.
  • card_declined — Issuer declined the card. Optional decline_code may be present (e.g., insufficient_funds).
  • wallet_authentication_canceled — User canceled Apple/Google Pay authentication sheet.
  • wallet_not_available — Apple/Google Pay not available on the device/browser.
  • card_validation_error — Client-side validation of card fields failed. errors[] contains localized, per-field issues.
  • threeds_error — 3DS flow encountered an error (e.g., ACS unavailable). Often accompanied by a server message.
  • threeds_authentication_failed — 3DS authentication failed (e.g., challenged and consumer failed/denied).
  • threeds_canceled — Customer canceled the 3DS challenge.
  • session_expired — Checkout session is no longer valid; create a new session.
  • permission_denied — Server responded with permission error (e.g., session not allowed). Not enumerated above; SDK forwards unknown codes unchanged.

Notes:

  • For 3DS issues, you may receive threeds_error with status_code 424 and a server-provided message.
  • For declines, expect code: 'card_declined', optional decline_code, and a user-friendly message.
  • The validation error message fields are already localized based on the checkout’s locale.

3DS payloads (representative samples)

The SDK forwards whatever Hosted Checkout sends for 3DS events. Shapes may evolve.

// threeds_started
{
  type: 'THREEDS_START_FLOW',
  checkoutId: '01KB23M9EC960C50G3AH14FTTT',
  // additional context as provided by Hosted Checkout (e.g., method, acsUrl)
}

// threeds_complete
{
  type: 'THREEDS_COMPLETE',
  checkoutId: '01KB23M9EC960C50G3AH14FTTT',
  // authentication result context (e.g., status: 'Y' | 'A' | 'C' | 'D')
}

// threeds_canceled
{
  type: 'THREEDS_CANCELED',
  checkoutId: '01KB23M9EC960C50G3AH14FTTT',
}

// threeds_error (non-terminal)
// Note: terminal failures will also be emitted on `error` with ApiErrorPayload
{
  type: 'THREEDS_ERROR',
  checkoutId: '01KB23M9EC960C50G3AH14FTTT',
  error?: ApiErrorPayload,
}

Tip: Prefer subscribing with the SdkEvent enum for stronger typing.

Usage Examples

Vanilla JavaScript (CDN)

<!DOCTYPE html>
<html>
<head>
  <title>Paypercut Checkout</title>
</head>
<body>
  <div id="checkout"></div>

  <script src="https://cdn.jsdelivr.net/npm/@paypercut/checkout-js@1.0.16/dist/paypercut-checkout.iife.min.js"></script>
  <script>
    const checkout = PaypercutCheckout({
      id: '01KB23M9EC960C50G3AH14FTTT',
      containerId: '#checkout',
      locale: 'en',
      ui_mode: 'embedded',
      wallet_options: ['apple_pay', 'google_pay']
    });

    checkout.on('success', function(payload) {
      // payload.payment_method: { brand, last4, exp_month, exp_year }
      alert('Payment successful with ' + payload.payment_method.brand + ' ending in ' + payload.payment_method.last4);
    });

    checkout.on('error', function(error) {
      alert('Payment failed: ' + error.message);
    });

    checkout.render();
  </script>
</body>
</html>

TypeScript / ESM

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

const checkout = PaypercutCheckout({
  id: '01KB23M9EC960C50G3AH14FTTT',
  containerId: '#checkout',
  locale: 'auto',
  ui_mode: 'embedded',
  wallet_options: ['apple_pay', 'google_pay'],
  form_only: false
});

checkout.on('success', (payload) => {
  // payload.payment_method: { brand, last4, exp_month, exp_year }
  console.log('Payment successful');
  console.log(`Paid with ${payload.payment_method.brand} **** ${payload.payment_method.last4}`);
  // Redirect to success page
  window.location.href = '/success';
});

checkout.on('error', (error) => {
  console.error('Payment error:', error);
  // Show error message to user
  alert(`Payment failed: ${error.message}`);
});

checkout.render();

React

import { useEffect, useRef, useState } from 'react';
import { PaypercutCheckout, CheckoutInstance } from '@paypercut/checkout-js';

interface PaymentMethod {
  brand: string;
  last4: string;
  exp_month: number;
  exp_year: number;
}

export function CheckoutComponent({ checkoutId }: { checkoutId: string }) {
  const containerRef = useRef<HTMLDivElement>(null);
  const checkoutRef = useRef<CheckoutInstance | null>(null);
  const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
  const [paymentMethod, setPaymentMethod] = useState<PaymentMethod | null>(null);

  useEffect(() => {
    if (!containerRef.current) return;

    const checkout = PaypercutCheckout({
      id: checkoutId,
      containerId: containerRef.current,
      locale: 'auto',
      ui_mode: 'embedded',
      wallet_options: ['apple_pay', 'google_pay'],
      form_only: false
    });

    checkout.on('loaded', () => {
      setStatus('idle');
      console.log('Checkout loaded');
    });

    checkout.on('success', (payload) => {
      setStatus('success');
      setPaymentMethod(payload.payment_method);
      console.log('Payment successful:', payload.payment_method);
    });

    checkout.on('error', (error) => {
      setStatus('error');
      console.error('Payment error:', error);
    });

    checkout.render();
    checkoutRef.current = checkout;

    return () => {
      checkout.destroy();
    };
  }, [checkoutId]);

  return (
    <div>
      <div ref={containerRef} style={{ width: '100%', height: '600px' }} />
      {status === 'loading' && <p>Processing payment...</p>}
      {status === 'success' && paymentMethod && (
        <p>✅ Payment successful with {paymentMethod.brand} ending in {paymentMethod.last4}!</p>
      )}
      {status === 'error' && <p>❌ Payment failed. Please try again.</p>}
    </div>
  );
}

If you want to create a modal-like experience, you can use the loaded event to show/hide a loading overlay:

import { useEffect, useRef, useState } from 'react';
import { PaypercutCheckout, CheckoutInstance } from '@paypercut/checkout-js';

export function ModalCheckout({ checkoutId, onClose }: { checkoutId: string; onClose: () => void }) {
  const containerRef = useRef<HTMLDivElement>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    if (!containerRef.current) return;

    const checkout = PaypercutCheckout({
      id: checkoutId,
      containerId: containerRef.current
    });

    checkout.on('loaded', () => {
      setIsLoading(false);
    });

    checkout.on('success', () => {
      console.log('Payment successful');
      onClose();
    });

    checkout.on('error', (error) => {
      console.error('Payment error:', error);
    });

    checkout.render();

    return () => {
      checkout.destroy();
    };
  }, [checkoutId, onClose]);

  return (
    <div style={{
      position: 'fixed',
      top: 0,
      left: 0,
      right: 0,
      bottom: 0,
      backgroundColor: 'rgba(0, 0, 0, 0.5)',
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center',
      zIndex: 9999
    }}>
      <div style={{
        position: 'relative',
        width: '90%',
        maxWidth: '500px',
        height: '90%',
        maxHeight: '700px',
        backgroundColor: '#fff',
        borderRadius: '12px',
        overflow: 'hidden'
      }}>
        {isLoading && (
          <div style={{
            position: 'absolute',
            top: 0,
            left: 0,
            right: 0,
            bottom: 0,
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
            backgroundColor: '#fff',
            zIndex: 1
          }}>
            <p>Loading checkout...</p>
          </div>
        )}
        <div ref={containerRef} style={{ width: '100%', height: '100%' }} />
        <button
          onClick={onClose}
          style={{
            position: 'absolute',
            top: '10px',
            right: '10px',
            zIndex: 2
          }}
        ></button>
      </div>
    </div>
  );
}

Container Styling

The SDK automatically resizes the iframe to match the checkout content height. To ensure proper display, style your container with max-width and max-height constraints rather than fixed dimensions.

.checkout-container {
  /* Use max-width/max-height to constrain the container */
  max-width: 450px;
  max-height: 600px;
  overflow: hidden;
  overflow-y: auto;
  scroll-behavior: smooth;
}

/* The mount point should be full width with no fixed height */
#checkout-mount {
  width: 100%;
  /* Height is set dynamically by SDK via resize messages */
  overflow: hidden;
  position: relative;
}

Key Points

  1. Avoid fixed height or min-height on the container - the SDK will resize the iframe to fit the checkout content
  2. Use max-height if you want to limit the container size and enable scrolling for longer content
  3. Use max-width to constrain the container width (recommended: 375px–450px for optimal checkout display)
  4. Set overflow-y: auto on the container if using max-height to enable scrolling

Example HTML

<div class="checkout-container">
  <div id="checkout-mount"></div>
</div>
const checkout = PaypercutCheckout({
  id: '01KB23M9EC960C50G3AH14FTTT',
  containerId: '#checkout-mount'
});
checkout.render();

Security

Origin Validation

The SDK automatically validates all postMessage communications against the configured checkoutHost origin. This prevents unauthorized messages from being processed.

Content Security Policy

For enhanced security, configure your Content Security Policy headers:

Content-Security-Policy:
  frame-src https://buy.paypercut.io OR something else;
  script-src 'self' https://cdn.jsdelivr.net https://unpkg.com;
  connect-src https://buy.paypercut.io  OR something else;

HTTPS Only

Always use HTTPS in production. The SDK is designed for secure communication over HTTPS.

Troubleshooting

Checkout not displaying

Problem: The iframe doesn't appear after calling render().

Solutions:

  • Ensure the container element exists in the DOM before calling render()
  • Check that the id is a valid checkout session ID
  • Verify there are no CSP errors in the browser console
  • Enable debug mode: debug: true to see detailed logs

Events not firing

Problem: Event handlers are not being called.

Solutions:

  • Ensure you subscribe to events before calling render()
  • Check that the checkout session is active and not expired
  • Enable debug mode to see message logs

TypeScript errors

Problem: TypeScript shows type errors when using the SDK.

Solutions:

  • Ensure you're importing types: import { PaypercutCheckout, CheckoutInstance } from '@paypercut/checkout-js'
  • Update to the latest version of the SDK
  • Check that your tsconfig.json includes the SDK's type definitions

Performance Optimization

Preconnect to Checkout Host

Add DNS prefetch and preconnect hints to your HTML for faster loading:

<link rel="preconnect" href="https://buy.paypercut.io ">
<link rel="dns-prefetch" href="https://buy.paypercut.io ">

Lazy Loading

Load the SDK only when needed to reduce initial bundle size:

async function loadCheckout() {
  const { PaypercutCheckout } = await import('@paypercut/checkout-js');
  return PaypercutCheckout;
}

// Load when user clicks pay button
payButton.addEventListener('click', async () => {
  const PaypercutCheckout = await loadCheckout();
  const checkout = PaypercutCheckout({ id: '01KB23M9EC960C50G3AH14FTTT' });
  checkout.render();
});

Best Practices

1. Always Verify Payments on Your Backend

Never rely solely on frontend events for payment confirmation. Always verify payments using webhooks on your backend:

// ✅ Good: Use frontend events for UI updates only
checkout.on('success', () => {
  // Show success message
  showSuccessMessage();
  // Redirect to order confirmation page
  window.location.href = '/orders/confirmation';
});

// ❌ Bad: Don't grant access based on frontend events alone
checkout.on('success', () => {
  // This can be manipulated by users!
  grantPremiumAccess(userId); // Don't do this!
});

2. Handle All Event Types

Always handle both success and error events:

checkout.on('success', () => {
  // Handle success
  showSuccessMessage();
});

checkout.on('error', () => {
  // Show user-friendly error message
  alert('Payment failed. Please try again.');
});

3. Clean Up on Component Unmount

Always call destroy() when your component unmounts to prevent memory leaks:

// React example
useEffect(() => {
  const checkout = PaypercutCheckout({ id: checkoutId });
  checkout.render();

  return () => {
    checkout.destroy(); // Clean up!
  };
}, []);

Express Checkout (Apple Pay / Google Pay)

Express Checkout lets you embed standalone Apple Pay and Google Pay buttons anywhere on your page — product pages, cart pages, or a dedicated checkout step — without a full checkout form. The SDK handles wallet sheet presentation, shipping address/option collection, tokenization, and payment method creation automatically.

How It Works

  1. Merchant calls PaypercutExpressCheckout(options) and mounts it into a container
  2. SDK renders Apple Pay and/or Google Pay buttons inside a secure hosted iframe
  3. User clicks a button → wallet payment sheet opens, collecting billing address, shipping address, and shipping option
  4. User changes shipping address or option → SDK fires shipping_address_change / shipping_option_change event → merchant calls updateWith() with updated totals → wallet sheet updates
  5. User confirms payment → SDK tokenizes the wallet token via Basis Theory, creates a Paypercut payment method, and auto-closes the wallet sheet
  6. SDK fires payment_method_created with paymentMethodId — use this server-side to create and confirm a payment intent

Shipping is always required. Express Checkout always collects both shipping address and shipping option. You must listen for shipping_address_change and shipping_option_change and call updateWith() to unblock the wallet sheet.


Step-by-step flow (annotated example)

The following shows the exact call/receive sequence for a complete Express Checkout payment, from initialization to payment confirmation.

// ─── Step 1: Initialize ───────────────────────────────────────────────────────
// You call PaypercutExpressCheckout() with your config.
// Nothing is shown yet — buttons render after mount().

const express = PaypercutExpressCheckout({
  publishableKey: 'pk_test_...',
  amount: 2500,        // €25.00 — the initial total (before shipping is chosen)
  currency: 'EUR',
  countryCode: 'DE',
  shippingOptions: [
    { id: 'standard', label: 'Standard Shipping', detail: '3–5 days', amount: 499 },
    { id: 'express',  label: 'Express Shipping',  detail: '1–2 days', amount: 999 },
  ],
});


// ─── Step 2: Subscribe to events (before mount) ───────────────────────────────
// Always subscribe before calling mount().

// You RECEIVE: 'ready' — buttons are rendered and visible
express.on('ready', () => {
  console.log('Wallet buttons ready');
});

// You RECEIVE: 'click' — user tapped Apple Pay or Google Pay button
express.on('click', () => {
  console.log('Wallet sheet opening...');
});


// ─── Step 3: Wallet sheet opens — provide initial shipping options ─────────────
// You RECEIVE: 'session_start' when the wallet sheet opens, BEFORE any address
// or option is shown. The wallet sheet is PAUSED — you must call updateWith()
// to provide the initial shipping options and unblock the sheet.
//
// This is your chance to dynamically load options from your backend.
// event.wallet tells you which wallet opened: 'apple_pay' | 'google_pay'

express.on('session_start', async ({ wallet, updateWith }) => {
  console.log('Wallet sheet opened:', wallet);

  // Optionally fetch shipping options from your backend
  // const options = await fetch('/api/shipping-options').then(r => r.json());

  // You CALL: updateWith() — this unblocks the wallet sheet with initial options
  updateWith({
    amount: 2500 + 499,    // subtotal + default shipping cost
    shippingOptions: [
      { id: 'standard', label: 'Standard Shipping', detail: '3–5 days', amount: 499 },
      { id: 'express',  label: 'Express Shipping',  detail: '1–2 days', amount: 999 },
    ],
    lineItems: [   // Apple Pay only — line item breakdown
      { label: 'Subtotal',          amount: 2500 },
      { label: 'Standard Shipping', amount: 499 },
    ],
  });
  // → Wallet sheet now renders with these options
});


// ─── Step 4: User changes shipping address ────────────────────────────────────
// You RECEIVE: 'shipping_address_change' with a partial address
// (country, state, city, postalCode — no street at this stage)
// The wallet sheet is PAUSED — you must call updateWith() to unblock it.

express.on('shipping_address_change', ({ address, updateWith }) => {
  console.log('User shipping to:', address.country, address.postalCode);
  // address = { country: 'DE', state: 'BY', city: 'Munich', postalCode: '80331' }

  // Calculate shipping cost for this address on your end
  const shippingCost = address.country === 'DE' ? 499 : 999;
  const newTotal = 2500 + shippingCost;

  // You CALL: updateWith() — this unblocks the wallet sheet and updates the displayed total
  updateWith({
    amount: newTotal,                         // new total including shipping
    shippingOptions: [                        // updated options for this region
      { id: 'standard', label: 'Standard Shipping', amount: shippingCost },
      { id: 'express',  label: 'Express Shipping',  amount: shippingCost + 500 },
    ],
    lineItems: [                      // Apple Pay only — line item breakdown
      { label: 'Subtotal',          amount: 2500 },
      { label: 'Standard Shipping', amount: shippingCost },
    ],
    // To reject an unsupported address instead:
    // status: 'invalid_shipping_address',
  });
  // → Wallet sheet now shows the updated total and shipping options
});


// ─── Step 5: User selects a shipping option ───────────────────────────────────
// You RECEIVE: 'shipping_option_change' when the user switches e.g. Standard → Express
// The wallet sheet is PAUSED again — you must call updateWith() again.

express.on('shipping_option_change', ({ shippingOption, updateWith }) => {
  console.log('User picked:', shippingOption.label, shippingOption.amount);
  // shippingOption = { id: 'express', label: 'Express Shipping', amount: 999 }

  const newTotal = 2500 + shippingOption.amount;

  // You CALL: updateWith() — unblocks the sheet with the new total
  updateWith({
    amount: newTotal,
    lineItems: [                      // Apple Pay only
      { label: 'Subtotal',            amount: 2500 },
      { label: shippingOption.label,  amount: shippingOption.amount },
    ],
  });
  // → Wallet sheet updates to show the new total
});


// ─── Step 6: User confirms payment ───────────────────────────────────────────
// User taps "Pay" in the wallet sheet.
// SDK tokenizes the card via Basis Theory, creates a Paypercut payment method,
// and auto-closes the wallet sheet. No action needed from you to close the sheet.

// You RECEIVE: 'payment_method_created' with the payment method ID
express.on('payment_method_created', async ({ paymentMethodId, idempotencyKey, billingDetails, shippingDetails, selectedShippingOption }) => {
  console.log('Payment method created:', paymentMethodId);
  // paymentMethodId = 'pm_01J...'
  // billingDetails  = { name: 'John Doe', address: { line1: '...', country: 'DE', ... } }
  // shippingDetails = { name: 'John Doe', address: { line1: '48 Example St', ... } }
  // selectedShippingOption = { id: 'express', label: 'Express Shipping', amount: 999 }

  // You CALL: your backend to create and confirm the payment intent
  const res = await fetch('/api/confirm-payment', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ paymentMethodId, idempotencyKey }),
  });

  if (res.ok) {
    window.location.href = '/success';
  }
});


// ─── Step 7: Handle failure or cancellation ───────────────────────────────────
// You RECEIVE: 'payment_method_failed' if tokenization or PM creation fails
express.on('payment_method_failed', ({ error }) => {
  console.error('Payment failed:', error);
  showErrorMessage('Payment could not be processed. Please try again.');
});

// You RECEIVE: 'cancel' if the user dismisses the wallet sheet
express.on('cancel', () => {
  console.log('User closed the wallet sheet');
});


// ─── Step 8: Mount ────────────────────────────────────────────────────────────
// You CALL: mount() — renders the wallet buttons into the container
express.mount('#express-checkout');
// → 'ready' fires once buttons are visible


// ─── Step 9: Cleanup ─────────────────────────────────────────────────────────
// You CALL: destroy() when the component unmounts to prevent memory leaks
// express.destroy();

Initialization

import { PaypercutExpressCheckout, ExpressSdkEvent } from '@paypercut/checkout-js';

const express = PaypercutExpressCheckout({
  publishableKey: 'pk_test_...',
  amount: 2999,               // in minor units (€29.99)
  currency: 'EUR',
  countryCode: 'DE',
  shippingOptions: [
    { id: 'standard', label: 'Standard Shipping', detail: '3–5 business days', amount: 499 },
    { id: 'express',  label: 'Express Shipping',  detail: '1–2 business days', amount: 999 },
  ],
});

express.mount('#express-checkout');

Express Checkout Options

Option Type Required Description
publishableKey string Yes Your Paypercut publishable API key (pk_test_… or pk_live_…)
amount number Yes Total amount in minor units (e.g. 1200 = €12.00)
currency string Yes ISO 4217 currency code (e.g. 'EUR', 'USD')
countryCode string Yes ISO 3166-1 alpha-2 merchant country code (e.g. 'DE'). Required for Google Pay in production.
shippingOptions ExpressCheckoutShippingOption[] Yes Initial shipping options shown in the wallet sheet. Must include at least one.
lineItems LineItem[] No Line item breakdown shown in the Apple Pay sheet (subtotal, tax, etc.). Ignored by Google Pay.
walletOptions ('apple_pay' | 'google_pay')[] No Which wallets to show. Defaults to both.
layout 'column' | 'row' No 'row' (default) places buttons side by side. 'column' stacks them vertically.
locale string No ISO 639-1 two-letter language code (e.g. 'en', 'bg', 'pl'). Controls the language of the wallet button labels. Each wallet applies this independently — not all languages are supported by every wallet; unsupported values fall back to 'en'.
buttonOptions ExpressButtonOptions No Customize the label type and color/style of the wallet buttons. See Button Options below.

Button Options

Control the label and appearance of the Apple Pay and Google Pay buttons via the buttonOptions object.

PaypercutExpressCheckout({
  // ...
  buttonOptions: {
    type: 'pay',               // label shown on both buttons — defaults to 'buy'
    applePayStyle: 'black',    // Apple Pay button color — defaults to 'black'
    googlePayColor: 'default', // Google Pay button color — defaults to 'default'
    borderRadius: '0.5rem',    // border radius for both buttons — defaults to '0.5rem'
  },
});

type — applies to both Apple Pay and Google Pay

Value Button label
'plain' (default) Logo only (no text)
'pay' Pay with Apple Pay / Pay with Google Pay
'buy' Buy with Apple Pay / Buy with Google Pay
'donate' Donate with …
'checkout' Checkout with …
'subscribe' Subscribe with …
'book' Book with …
'order' Order with …
'tip' Tip with …
'contribute' Contribute with …

applePayStyle — Apple Pay only

Value Appearance
'black' (default) Black background, white logo
'white' White background, black logo
'white-outline' White background with a black border, black logo

borderRadius — applies to both

Any valid CSS value (e.g. '0.5rem', '8px', '50%'). Defaults to '0.5rem'.

googlePayColor — Google Pay only

Value Appearance
'default' (default) White background on light themes, dark on dark themes
'black' Black background
'white' White background
interface ExpressCheckoutShippingOption {
  id: string;       // unique identifier, e.g. 'standard'
  label: string;    // display label, e.g. 'Standard Shipping'
  detail?: string;  // subtitle, e.g. '3–5 business days'
  amount: number;   // cost in minor units
}

interface LineItem {
  label: string;
  amount: number;             // minor units
  type?: 'final' | 'pending';
}

Express Checkout Methods

mount(container)

Mounts the wallet buttons into the given container.

express.mount('#express-checkout');
// or pass an HTMLElement
express.mount(document.getElementById('express-checkout')!);

on(event, handler)

Subscribes to an event. Returns an unsubscribe function.

const unsub = express.on('payment_method_created', (e) => { ... });
unsub(); // unsubscribe

update(options)

Updates the wallet sheet with new totals or shipping options. Call this from shipping_address_change / shipping_option_change handlers via the provided updateWith() helper.

express.update({
  amount: 3998,
  shippingOptions: [
    { id: 'standard', label: 'Standard Shipping', amount: 499 },
    { id: 'express',  label: 'Express Shipping',  amount: 999 },
  ],
});

destroy()

Removes iframes and all event listeners.

express.destroy();

Express Checkout Events

Event Enum Payload Description
ready ExpressSdkEvent.Ready Wallet buttons are rendered and ready
click ExpressSdkEvent.Click User tapped a wallet button
session_start ExpressSdkEvent.SessionStart ExpressSessionStartEvent Wallet sheet opened — must call updateWith() with initial options to unblock
shipping_address_change ExpressSdkEvent.ShippingAddressChange ShippingAddressChangeEvent User changed shipping address — must call updateWith()
shipping_option_change ExpressSdkEvent.ShippingOptionChange ShippingOptionChangeEvent User selected a different shipping option — must call updateWith()
payment_method_created ExpressSdkEvent.PaymentMethodCreated ExpressPaymentMethodCreatedEvent Payment method created — use paymentMethodId to confirm on backend
payment_method_failed ExpressSdkEvent.PaymentMethodFailed ExpressPaymentMethodFailedEvent Tokenization or payment method creation failed
cancel ExpressSdkEvent.Cancel User dismissed the wallet sheet
error ExpressSdkEvent.Error Error Unrecoverable SDK error

Shipping Address & Option Changes

Shipping is always collected in Express Checkout. When the user selects or changes their address or shipping option inside the wallet sheet, the sheet is paused and waits for you to respond with updated totals. You must call updateWith() within 30 seconds or the wallet sheet will show an error.

shipping_address_change

Fires when the user picks or changes their shipping address. The address is partially anonymized at this stage (no street — only country, state, city, postal code).

express.on('shipping_address_change', ({ address, updateWith }) => {
  // address: { country, state?, city?, postalCode? }
  console.log('Shipping to:', address.country, address.postalCode);

  // Calculate shipping cost for this address
  const shippingCost = address.country === 'DE' ? 299 : 599;
  const newTotal = 2500 + shippingCost;

  // REQUIRED: call updateWith() to unblock the wallet sheet
  updateWith({
    amount: newTotal,
    shippingOptions: [
      { id: 'standard', label: 'Standard Shipping', amount: shippingCost },
      { id: 'express',  label: 'Express Shipping',  amount: shippingCost + 500 },
    ],
    // Use 'invalid_shipping_address' if you don't ship to this address:
    // status: 'invalid_shipping_address',
  });
});

shipping_option_change

Fires when the user picks a different shipping option (e.g. switches from Standard to Express).

express.on('shipping_option_change', ({ shippingOption, updateWith }) => {
  // shippingOption: { id, label, detail?, amount }
  console.log('Shipping option changed to:', shippingOption.label, shippingOption.amount);

  const newTotal = 2500 + shippingOption.amount;

  // REQUIRED: call updateWith() to unblock the wallet sheet
  updateWith({
    amount: newTotal,
    // Optionally update Apple Pay line items to reflect the new shipping row:
    lineItems: [
      { label: 'Subtotal', amount: 2500 },
      { label: shippingOption.label, amount: shippingOption.amount },
    ],
  });
});

updateWith() options

Field Type Description
amount number New total in minor units
shippingOptions ExpressCheckoutShippingOption[] Updated shipping options list
lineItems LineItem[] Updated Apple Pay line items (ignored by Google Pay)
status 'success' | 'invalid_shipping_address' | 'invalid_shipping_option' Use an invalid status to show an error inside the sheet without closing it

Restricting Shipping to Specific Countries / Addresses

Wallets don't accept a pre-defined list of allowed addresses — but you have two tools to control where you ship.


Option 1 — Country restriction (Google Pay only)

Google Pay lets you restrict which countries are selectable in the address picker. Users from other countries simply cannot choose an address. This is the cleanest UX — the restriction is enforced before the user even types anything.

This is controlled by passing allowedCountries in the SDK options:

const express = PaypercutExpressCheckout({
  publishableKey: 'pk_test_...',
  amount: 2999,
  currency: 'EUR',
  countryCode: 'DE',
  allowedCountries: ['DE', 'AT', 'CH'],  // Google Pay only — user cannot select other countries
  shippingOptions: [ ... ],
});

Apple Pay does not support pre-filtering by country. Users always see their saved addresses regardless of origin country.


Option 2 — Reject unsupported addresses at runtime (both wallets)

This works for both Apple Pay and Google Pay. When shipping_address_change fires, check the address against your supported regions. If you don't ship there, call updateWith() with status: 'invalid_shipping_address' — the sheet stays open and shows an error to the user without closing.

const SUPPORTED_COUNTRIES = ['DE', 'AT', 'CH', 'NL'];

express.on('shipping_address_change', ({ address, updateWith }) => {
  if (!SUPPORTED_COUNTRIES.includes(address.country)) {
    // Reject — sheet shows an error, stays open
    updateWith({ status: 'invalid_shipping_address' });
    return;
  }

  // Country is supported — calculate shipping and respond
  const shippingCost = address.country === 'DE' ? 299 : 599;
  updateWith({
    amount: subtotal + shippingCost,
    shippingOptions: [
      { id: 'standard', label: 'Standard Shipping', amount: shippingCost },
      { id: 'express',  label: 'Express Shipping',  amount: shippingCost + 500 },
    ],
  });
});

Use allowedCountries to prevent unsupported countries from being selectable upfront, and validate in shipping_address_change as a safety net:

const SUPPORTED_COUNTRIES = ['DE', 'AT', 'CH'];

const express = PaypercutExpressCheckout({
  publishableKey: 'pk_test_...',
  amount: 2999,
  currency: 'EUR',
  countryCode: 'DE',
  allowedCountries: SUPPORTED_COUNTRIES,  // Google Pay: restricts picker
  shippingOptions: [ ... ],
});

express.on('shipping_address_change', ({ address, updateWith }) => {
  // Apple Pay safety net — also catches any edge cases for Google Pay
  if (!SUPPORTED_COUNTRIES.includes(address.country)) {
    updateWith({ status: 'invalid_shipping_address' });
    return;
  }

  const shippingCost = address.country === 'DE' ? 299 : 599;
  updateWith({
    amount: 2999 + shippingCost,
    shippingOptions: [
      { id: 'standard', label: 'Standard Shipping', amount: shippingCost },
    ],
  });
});

Summary

Approach Apple Pay Google Pay UX
allowedCountries in options ✗ not supported ✅ restricts picker Best — error before user interacts
status: 'invalid_shipping_address' in shipping_address_change Sheet shows inline error, stays open
Both combined ✅ (runtime only) ✅ (picker + runtime) Recommended

Express Checkout — Integration Examples

Three real-world scenarios showing the complete integration from init to payment confirmation.


Example 1 — Simple: static shipping options, EU-only (Google Pay + Apple Pay)

Best for merchants with fixed shipping rates that don't change by address. Google Pay restricts the picker to EU countries. Apple Pay rejects other countries at runtime. Both wallets share the same event handlers.

const SUPPORTED_COUNTRIES = ['DE', 'AT', 'CH', 'NL', 'FR'];
const SUBTOTAL = 4900; // €49.00

const express = PaypercutExpressCheckout({
  publishableKey: 'pk_live_...',
  amount: SUBTOTAL + 499,       // subtotal + default shipping
  currency: 'EUR',
  countryCode: 'DE',
  allowedCountries: SUPPORTED_COUNTRIES,  // Google Pay: restricts picker to these countries
});

// Step 1 — Provide initial shipping options when wallet sheet opens
express.on('session_start', ({ wallet, updateWith }) => {
  updateWith({
    amount: SUBTOTAL + 499,
    shippingOptions: [
      { id: 'standard', label: 'Standard Shipping', detail: '3–5 days', amount: 499 },
      { id: 'express',  label: 'Express Shipping',  detail: '1–2 days', amount: 999 },
    ],
    lineItems: [
      { label: 'Subtotal',          amount: SUBTOTAL },
      { label: 'Standard Shipping', amount: 499 },
    ],
  });
});

// Step 2 — Validate address (Apple Pay: only way to restrict countries)
express.on('shipping_address_change', ({ address, updateWith }) => {
  if (!SUPPORTED_COUNTRIES.includes(address.country)) {
    updateWith({ status: 'invalid_shipping_address' });
    return;
  }

  updateWith({
    amount: SUBTOTAL + 499,
    shippingOptions: [
      { id: 'standard', label: 'Standard Shipping', detail: '3–5 days', amount: 499 },
      { id: 'express',  label: 'Express Shipping',  detail: '1–2 days', amount: 999 },
    ],
    lineItems: [
      { label: 'Subtotal',          amount: SUBTOTAL },
      { label: 'Standard Shipping', amount: 499 },
    ],
  });
});

// Step 3 — Update total when user picks a different shipping option
express.on('shipping_option_change', ({ shippingOption, updateWith }) => {
  updateWith({
    amount: SUBTOTAL + shippingOption.amount,
    lineItems: [
      { label: 'Subtotal',           amount: SUBTOTAL },
      { label: shippingOption.label, amount: shippingOption.amount },
    ],
  });
});

// Step 4 — Payment method created, confirm on your backend
express.on('payment_method_created', async ({ paymentMethodId, idempotencyKey, shippingDetails, selectedShippingOption }) => {
  await fetch('/api/orders', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      paymentMethodId,
      idempotencyKey,
      shippingAddress: shippingDetails?.address,
      shippingOption: selectedShippingOption?.id,
    }),
  });
  window.location.href = '/order/confirmation';
});

express.on('shipping_options_required', ({ wallet }) => {
  console.error(`${wallet}: session_start timed out — shipping options were not provided`);
});

express.on('payment_method_failed', ({ error }) => console.error('Payment failed:', error));
express.on('cancel', () => console.log('User cancelled'));

express.mount('#express-checkout');

Example 2 — Dynamic: shipping cost calculated from address via backend (Google Pay + Apple Pay)

Best for merchants with zone-based pricing loaded from their own API. Shipping cost is unknown until the user's country/postal code is available.

const SUBTOTAL = 7500; // €75.00

const express = PaypercutExpressCheckout({
  publishableKey: 'pk_live_...',
  amount: SUBTOTAL,     // no shipping added yet — will be set in session_start
  currency: 'EUR',
  countryCode: 'DE',
  allowedCountries: ['DE', 'AT', 'CH', 'NL', 'BE', 'FR', 'IT', 'ES'],
});

// Step 1 — Fetch initial options from your API before the sheet opens
express.on('session_start', async ({ wallet, updateWith }) => {
  // Call your backend — you have 3 seconds
  const { options, defaultAmount } = await fetch('/api/shipping/default').then(r => r.json());
  // e.g. { options: [{ id: 'standard', label: 'Standard', amount: 599 }], defaultAmount: 8099 }

  updateWith({
    amount: defaultAmount,
    shippingOptions: options,
    lineItems: [
      { label: 'Subtotal',           amount: SUBTOTAL },
      { label: options[0].label,     amount: options[0].amount },
    ],
  });
});

// Step 2 — Recalculate when user changes address
express.on('shipping_address_change', async ({ address, updateWith }) => {
  const res = await fetch('/api/shipping/calculate', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ country: address.country, postalCode: address.postalCode }),
  });

  if (!res.ok) {
    // Country not supported or no shipping available
    updateWith({ status: 'invalid_shipping_address' });
    return;
  }

  const { options, total } = await res.json();
  // e.g. { options: [{ id: 'standard', amount: 699 }, { id: 'express', amount: 1199 }], total: 8199 }

  updateWith({
    amount: total,
    shippingOptions: options,
    lineItems: [
      { label: 'Subtotal',       amount: SUBTOTAL },
      { label: options[0].label, amount: options[0].amount },
    ],
  });
});

// Step 3 — User switches shipping option
express.on('shipping_option_change', ({ shippingOption, updateWith }) => {
  updateWith({
    amount: SUBTOTAL + shippingOption.amount,
    lineItems: [
      { label: 'Subtotal',           amount: SUBTOTAL },
      { label: shippingOption.label, amount: shippingOption.amount },
    ],
  });
});

// Step 4 — Create order on backend
express.on('payment_method_created', async ({ paymentMethodId, idempotencyKey, billingDetails, shippingDetails, selectedShippingOption }) => {
  const res = await fetch('/api/orders', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      paymentMethodId,
      idempotencyKey,
      billing: billingDetails,
      shipping: shippingDetails,
      shippingOptionId: selectedShippingOption?.id,
    }),
  });
  const { orderId } = await res.json();
  window.location.href = `/orders/${orderId}/confirmation`;
});

express.on('shipping_options_required', ({ wallet }) => {
  alert(`Could not load shipping options for ${wallet}. Please try again.`);
});

express.on('payment_method_failed', ({ error }) => alert('Payment failed: ' + error));
express.on('cancel', () => console.log('User cancelled'));

express.mount('#express-checkout');

Example 3 — React: Express Checkout as a component with dynamic options

import { useEffect, useRef } from 'react';
import {
  PaypercutExpressCheckout,
  ExpressSdkEvent,
  type ExpressCheckoutInstance,
} from '@paypercut/checkout-js';

interface Props {
  subtotal: number;        // minor units
  onSuccess: (orderId: string) => void;
  onCancel: () => void;
}

const SUPPORTED_COUNTRIES = ['DE', 'AT', 'CH', 'NL'];

export function ExpressCheckout({ subtotal, onSuccess, onCancel }: Props) {
  const containerRef = useRef<HTMLDivElement>(null);
  const instanceRef = useRef<ExpressCheckoutInstance | null>(null);

  useEffect(() => {
    if (!containerRef.current) return;

    const express = PaypercutExpressCheckout({
      publishableKey: process.env.NEXT_PUBLIC_PAYPERCUT_KEY!,
      amount: subtotal,
      currency: 'EUR',
      countryCode: 'DE',
      allowedCountries: SUPPORTED_COUNTRIES,
    });

    // Provide initial options before sheet opens
    express.on(ExpressSdkEvent.SessionStart, async ({ wallet, updateWith }) => {
      const { options } = await fetch('/api/shipping/default').then(r => r.json());
      updateWith({
        amount: subtotal + options[0].amount,
        shippingOptions: options,
        lineItems: [
          { label: 'Subtotal',       amount: subtotal },
          { label: options[0].label, amount: options[0].amount },
        ],
      });
    });

    // Recalculate when address changes
    express.on(ExpressSdkEvent.ShippingAddressChange, async ({ address, updateWith }) => {
      if (!SUPPORTED_COUNTRIES.includes(address.country)) {
        updateWith({ status: 'invalid_shipping_address' });
        return;
      }
      const { options, total } = await fetch('/api/shipping/calculate', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ country: address.country, postalCode: address.postalCode }),
      }).then(r => r.json());

      updateWith({
        amount: total,
        shippingOptions: options,
        lineItems: [
          { label: 'Subtotal',       amount: subtotal },
          { label: options[0].label, amount: options[0].amount },
        ],
      });
    });

    // Update total when shipping option changes
    express.on(ExpressSdkEvent.ShippingOptionChange, ({ shippingOption, updateWith }) => {
      updateWith({
        amount: subtotal + shippingOption.amount,
        lineItems: [
          { label: 'Subtotal',           amount: subtotal },
          { label: shippingOption.label, amount: shippingOption.amount },
        ],
      });
    });

    // Payment method ready — confirm on backend
    express.on(ExpressSdkEvent.PaymentMethodCreated, async ({ paymentMethodId, idempotencyKey, shippingDetails, selectedShippingOption }) => {
      const { orderId } = await fetch('/api/orders', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          paymentMethodId,
          idempotencyKey,
          shipping: shippingDetails,
          shippingOptionId: selectedShippingOption?.id,
        }),
      }).then(r => r.json());

      onSuccess(orderId);
    });

    express.on(ExpressSdkEvent.ShippingOptionsRequired, ({ wallet }) => {
      console.error(`${wallet}: session_start timed out — implement the session_start handler`);
    });

    express.on(ExpressSdkEvent.PaymentMethodFailed, ({ error }) => console.error('Payment failed:', error));
    express.on(ExpressSdkEvent.Cancel, onCancel);

    express.mount(containerRef.current);
    instanceRef.current = express;

    return () => {
      instanceRef.current?.destroy();
      instanceRef.current = null;
    };
  }, [subtotal, onSuccess, onCancel]);

  return <div ref={containerRef} style={{ width: '100%' }} />;
}

payment_method_created Event

Fires after the user authorizes payment and the SDK has successfully created a Paypercut payment method. Use the paymentMethodId to create and confirm a payment intent on your backend.

express.on('payment_method_created', (event) => {
  // event.paymentMethodId  — Paypercut PM ID (pm_xxx), use this to confirm payment on your backend
  // event.token            — { id: string, type: 'apple_pay' | 'google_pay' }
  // event.idempotencyKey   — unique key for this payment attempt (pass to your backend)
  // event.billingDetails   — { name?, email?, address? }
  // event.shippingDetails  — { name?, address? }
  // event.selectedShippingOption — { id, label, amount }

  console.log('Payment method created:', event.paymentMethodId);
  console.log('Shipping to:', event.shippingDetails?.address);
  console.log('Selected option:', event.selectedShippingOption?.label);

  // Call your backend to create + confirm the payment intent
  await fetch('/api/confirm-payment', {
    method: 'POST',
    body: JSON.stringify({
      paymentMethodId: event.paymentMethodId,
      idempotencyKey: event.idempotencyKey,
    }),
  });
});
interface ExpressPaymentMethodCreatedEvent {
  paymentMethodId: string;                      // Paypercut PM ID (pm_xxx)
  token: { id: string; type: 'apple_pay' | 'google_pay' };
  idempotencyKey: string;
  billingDetails?: {
    name?: string;
    email?: string;
    phone?: string;
    address?: { line1?: string; line2?: string; city?: string; state?: string; postalCode?: string; country?: string };
  };
  shippingDetails?: {
    name?: string;
    address?: { line1?: string; line2?: string; city?: string; state?: string; postalCode?: string; country?: string };
  };
  selectedShippingOption?: { id: string; label: string; detail?: string; amount: number };
}

Express Checkout Full Example

<!DOCTYPE html>
<html>
<head><title>Express Checkout</title></head>
<body>
  <div id="express-checkout" style="max-width: 400px;"></div>

  <script src="https://cdn.jsdelivr.net/npm/@paypercut/checkout-js/dist/paypercut-checkout.iife.min.js"></script>
  <script>
    const express = PaypercutExpressCheckout({
      publishableKey: 'pk_test_...',
      amount: 2500,
      currency: 'EUR',
      countryCode: 'DE',
      shippingOptions: [
        { id: 'standard', label: 'Standard Shipping', detail: '3–5 days', amount: 499 },
        { id: 'express',  label: 'Express Shipping',  detail: '1–2 days', amount: 999 },
      ],
      lineItems: [
        { label: 'Subtotal', amount: 2500 },
        { label: 'Standard Shipping', amount: 499 },
      ],
    });

    // Wallet buttons ready
    express.on('ready', () => console.log('Express Checkout ready'));

    // Shipping address changed — recalculate and respond
    express.on('shipping_address_change', ({ address, updateWith }) => {
      const shippingCost = address.country === 'DE' ? 499 : 799;
      updateWith({
        amount: 2500 + shippingCost,
        shippingOptions: [
          { id: 'standard', label: 'Standard Shipping', amount: shippingCost },
          { id: 'express',  label: 'Express Shipping',  amount: shippingCost + 500 },
        ],
        lineItems: [
          { label: 'Subtotal', amount: 2500 },
          { label: 'Standard Shipping', amount: shippingCost },
        ],
      });
    });

    // Shipping option changed — update total
    express.on('shipping_option_change', ({ shippingOption, updateWith }) => {
      updateWith({
        amount: 2500 + shippingOption.amount,
        lineItems: [
          { label: 'Subtotal', amount: 2500 },
          { label: shippingOption.label, amount: shippingOption.amount },
        ],
      });
    });

    // Payment method created — confirm on backend
    express.on('payment_method_created', async (event) => {
      console.log('Payment method:', event.paymentMethodId);
      console.log('Shipping option:', event.selectedShippingOption?.label);
      await fetch('/api/confirm-payment', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ paymentMethodId: event.paymentMethodId }),
      });
      window.location.href = '/success';
    });

    express.on('payment_method_failed', ({ error }) => {
      console.error('Payment failed:', error);
    });

    express.on('cancel', () => {
      console.log('User dismissed the wallet sheet');
    });

    express.mount('#express-checkout');
  </script>
</body>
</html>

Express Checkout React Example

import { useEffect, useRef } from 'react';
import { PaypercutExpressCheckout, ExpressSdkEvent, ExpressCheckoutInstance } from '@paypercut/checkout-js';

interface Props {
  subtotal: number;   // minor units
  onSuccess: (paymentMethodId: string) => void;
}

export function ExpressCheckoutButton({ subtotal, onSuccess }: Props) {
  const containerRef = useRef<HTMLDivElement>(null);
  const instanceRef = useRef<ExpressCheckoutInstance | null>(null);

  useEffect(() => {
    if (!containerRef.current) return;

    const express = PaypercutExpressCheckout({
      publishableKey: process.env.NEXT_PUBLIC_PAYPERCUT_KEY!,
      amount: subtotal + 499,  // subtotal + default shipping
      currency: 'EUR',
      countryCode: 'DE',
      shippingOptions: [
        { id: 'standard', label: 'Standard Shipping', detail: '3–5 days', amount: 499 },
        { id: 'express',  label: 'Express Shipping',  detail: '1–2 days', amount: 999 },
      ],
    });

    express.on(ExpressSdkEvent.ShippingAddressChange, ({ address, updateWith }) => {
      const shipping = address.country === 'DE' ? 499 : 799;
      updateWith({
        amount: subtotal + shipping,
        shippingOptions: [
          { id: 'standard', label: 'Standard Shipping', amount: shipping },
          { id: 'express',  label: 'Express Shipping',  amount: shipping + 500 },
        ],
      });
    });

    express.on(ExpressSdkEvent.ShippingOptionChange, ({ shippingOption, updateWith }) => {
      updateWith({ amount: subtotal + shippingOption.amount });
    });

    express.on(ExpressSdkEvent.PaymentMethodCreated, (event) => {
      onSuccess(event.paymentMethodId);
    });

    express.on(ExpressSdkEvent.PaymentMethodFailed, ({ error }) => {
      console.error('Express payment failed:', error);
    });

    express.mount(containerRef.current);
    instanceRef.current = express;

    return () => {
      instanceRef.current?.destroy();
      instanceRef.current = null;
    };
  }, [subtotal, onSuccess]);

  return <div ref={containerRef} style={{ width: '100%' }} />;
}

FAQ For Internal Validation

Q: How do I handle successful payments? Listen to the success event and redirect users or update your UI accordingly. Always verify the payment on your backend using webhooks.

Q: Can I use this with server-side rendering (SSR)? Yes, but ensure the SDK is only initialized on the client side. For Next.js, use dynamic imports with ssr: false.

Q: What happens if the user closes the browser during payment?

Q: Can I have multiple checkouts on one page? Maybe Yes, but only one should be active at a time to avoid user confusion. If no, we destroy the previous one and create a new one.

Q: How do I handle errors? Listen to the error event and display user-friendly error messages. Log errors for debugging.