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.
Table of Contents
- Installation
- Quick Start — Embedded Checkout
- API Reference — Embedded Checkout
- Events — Embedded Checkout
- Form Validation for Wallet Payments
- Express Checkout (Apple Pay / Google Pay)
- Types
- Usage Examples
- Security
- Troubleshooting
- Performance Optimization
- Best Practices
- FAQ
Installation
Via NPM
npm install @paypercut/checkout-jsVia Yarn
yarn add @paypercut/checkout-jsVia PNPM
pnpm add @paypercut/checkout-jsVia 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 normalizedApiErrorPayloadwhen provided by Hosted Checkout.processing: non-terminal event. Use it to disable close buttons or show "do not close" messaging, then clear that UI onsuccess,error,cancel, orexpired.threeds_error: forwardspayload.errorwhen available; otherwise the raw message data.threeds_*non-error events: payload is forwarded as-is from Hosted Checkout (shape may evolve).
Form Validation for Wallet Payments
How It Works
- User clicks Apple Pay or Google Pay button in the checkout
- SDK emits
form_validationevent to your code - You validate your form and call either:
completeFormValidation(wallet)- allows wallet to proceedfailFormValidation(wallet, errors)- blocks wallet, you show your own errors
- 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:
- Emit an
errorevent with codeform_validation_timeout - 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_codewhile others providestatus. 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_codemay 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_errorwithstatus_code424 and a server-provided message. - For declines, expect
code: 'card_declined', optionaldecline_code, and a user-friendlymessage. - The validation error
messagefields 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>
);
}Modal-like Implementation
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.
Recommended Container Styles
.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
- Avoid fixed
heightormin-heighton the container - the SDK will resize the iframe to fit the checkout content - Use
max-heightif you want to limit the container size and enable scrolling for longer content - Use
max-widthto constrain the container width (recommended: 375px–450px for optimal checkout display) - Set
overflow-y: autoon the container if usingmax-heightto 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
idis a valid checkout session ID - Verify there are no CSP errors in the browser console
- Enable debug mode:
debug: trueto 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.jsonincludes 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
- Merchant calls
PaypercutExpressCheckout(options)and mounts it into a container - SDK renders Apple Pay and/or Google Pay buttons inside a secure hosted iframe
- User clicks a button → wallet payment sheet opens, collecting billing address, shipping address, and shipping option
- User changes shipping address or option → SDK fires
shipping_address_change/shipping_option_changeevent → merchant callsupdateWith()with updated totals → wallet sheet updates - User confirms payment → SDK tokenizes the wallet token via Basis Theory, creates a Paypercut payment method, and auto-closes the wallet sheet
- SDK fires
payment_method_createdwithpaymentMethodId— 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_changeandshipping_option_changeand callupdateWith()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(); // unsubscribeupdate(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 },
],
});
});Option 3 — Combine both (recommended for Google Pay)
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.