Using Payment Links, Payment Page, payment extensions, Payouts, POS, or the Reconciliation API? View the classic product documentation
Web SDK integration
Complete guide to integrating Peach Orchestration payments in web applications
Prerequisites
Before you start integrating, make sure you have a Vue 3 development environment, Peach Orchestration credentials, and a backend server that can create Payment Intents.
Development environment
- Vue 3.x, using the Composition API.
- Node.js 16+.
- Vite, Vue CLI, or Nuxt 3.
Peach Payments account
Don't have an account yet? Sign up first.
Already have an account? Collect your credentials from the Dashboard:
Log in to the Dashboard
You can log in to the Dashboard using one of the following methods:
- Using a magic link; this is the default login method. After entering your email address, Dashboard sends you a magic link that you click to log in.
- Using a password.
Info
Log in using a magic link
To access the Dashboard:
- Visit https://dashboard.peachpayments.com.
- Enter your company email address and click Submit.
- In your email inbox, click the Login to Dashboard link that you received from Peach Payments (if you've got multiple login emails, click the latest link). If the email isn't in your inbox, check your spam folder. The magic link expires after five minutes, but you can redo this procedure to receive a new link.
- When the Dashboard prompts you for two-factor authentication:
- On your phone, open your authenticator app and note the 6-digit authentication code for Peach Payments.

Example Google Authenticator authentication code. - In your browser, in the Verify Your Identity window, enter the authentication code.
- Select Remember this device for 30 days if you don't want the Dashboard to ask for an authentication code for the next 30 days.
- Click Continue.
- On your phone, open your authenticator app and note the 6-digit authentication code for Peach Payments.
Warning
Log in using a password
When you first gain access to the Dashboard, the email contains a link to either log in using a magic link, or to complete your account by setting a password. Use the password that you set there for all future logins, unless you reset it. If you can't find the initial email, you can set your first password using the reset your password functionality.
To access the Dashboard:
- Visit https://dashboard.peachpayments.com.
- Click Username & Password beneath the Submit button.
- Enter your company email address and password, and click Log in.
- When the Dashboard prompts you for two-factor authentication:
- On your phone, open your authenticator app and note the 6-digit authentication code for Peach Payments.
- In your browser, in the Verify Your Identity window, enter the authentication code.
- Select Remember this device for 30 days if you don't want the Dashboard to ask for an authentication code for the next 30 days.
- Click Continue.
- In the left sidebar, click Peach Orchestration.
- Under Peach Orchestration credentials, copy your profile ID (
pro_xxx) and publishable key (pk_snd_xxxfor sandbox,pk_prd_xxxfor live). The publishable key is safe to use in client-side code. - Under API keys, click New API key + to create a merchant API key (
snd_xxxfor sandbox,prd_xxxfor live).
Never ship your merchant API key
The merchant API key belongs on your server only. Never expose it in client-side code — a web page, a mobile app bundle, or anything else a shopper can read.
Can't find these credentials, or don't see the Peach Orchestration section? Contact support to request access.
Backend server
You need a server to create Payment Intents. Keep your secret key on the server only.
Recommended setup
- TypeScript, for better type support.
- Pinia or Vuex for state management.
Wallet prerequisites
Only relevant if you are accepting Apple Pay, Google Pay, or Samsung Pay.
Apple Pay (web only)
You can use the Peach Payments certificates, so you do not need your own merchant ID and payment processing certificate. You do still have to verify your domain:
- Download the Peach merchant domain verification file for the environment you are integrating against:
- Host that file at
{yourDomain}/.well-known/apple-developer-merchantid-domain-association.
Domain verification requirements
Do not add a .txt extension. The file must return HTTP 200 with no redirect,
and cannot sit behind a proxy, redirect, or password — Peach Payments needs
access to the well-known address to onboard the domain with Apple.
The same domain verification restrictions that Apple documents still apply. See Register and verify your domain.
Allow requests without a user-agent, or allowlist Apple's IP addresses as described in Setting up your server.
Google Pay (web only)
You can use the Peach Payments Google Pay ID.
Samsung Pay (web only)
You can use the Peach Payments Samsung Pay certificates.
Mobile apps need their own credentials
For Mobile SDK V2 wallet certificates and merchant IDs, see the Mobile SDK guides — app integrations cannot use the Peach certificates.
Load the Web SDK from the CDN with a script tag, then wrap it in a composable so the rest of your app can reach it.
No npm package yet
npm packages are not published yet — Peach Payments will publish its own. Until then, the CDN script tag is the only distribution channel.
Add the script tag
Add the loader to index.html:
<script src="https://sdk.sandbox-next.peachpayments.com/sandbox/web/0.133.4/v1/HyperLoader.js"></script>Composable setup
Create a composable for the Hyper integration:
// composables/useHyperSDK.ts
import { ref, onMounted } from 'vue';
declare const Hyper: (
publishableKey: string,
options?: { customBackendUrl?: string },
) => any;
const hyperInstance = ref<any | null>(null);
const isLoading = ref(true);
export function useHyperSDK() {
onMounted(() => {
if (!hyperInstance.value && typeof Hyper !== 'undefined') {
hyperInstance.value = Hyper('pk_test_your_publishable_key', {
customBackendUrl: 'https://app.sandbox-next.peachpayments.com/api',
});
isLoading.value = false;
}
});
return {
hyper: hyperInstance,
isLoading,
};
}Hold the SDK instance in shared composable state, initialise it once when the app mounts, and read the keys from environment variables.
Shared composable
// composables/useHyperSDK.ts
import { ref, shallowRef } from 'vue';
const publishableKey = import.meta.env.VITE_HYPER_PUBLISHABLE_KEY;
// Shared state
const hyper = shallowRef<any>(null);
const isReady = ref(false);
export async function initHyper() {
if (hyper.value) return hyper.value;
hyper.value = Hyper(publishableKey, {
customBackendUrl: import.meta.env.VITE_HYPER_API_URL,
});
isReady.value = true;
return hyper.value;
}
export function useHyperSDK() {
return {
hyper,
isReady,
initHyper,
};
}Initialise on app mount
In App.vue:
<script setup lang="ts">
import { onMounted } from 'vue';
import { initHyper } from './composables/useHyperSDK';
onMounted(async () => {
await initHyper();
});
</script>Environment variables
In .env.development:
VITE_HYPER_PUBLISHABLE_KEY=pk_test_xxxIn .env.production:
VITE_HYPER_PUBLISHABLE_KEY=pk_live_xxxA single-file component can create the Payment Intent, mount the Payment Element into a template ref, and confirm the payment on submit.
Checkout form component
CheckoutForm.vue:
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { useHyperSDK } from '@/composables/useHyperSDK';
const { hyper, isReady } = useHyperSDK();
const elements = ref<any>(null);
const paymentElement = ref<any>(null);
const error = ref<string | null>(null);
const processing = ref(false);
const paymentElementContainer = ref<HTMLDivElement | null>(null);
// Create payment intent and mount element
onMounted(async () => {
// Wait for Hyper to be ready
while (!isReady.value) {
await new Promise((r) => setTimeout(r, 100));
}
// Create payment intent
const response = await fetch('/api/create-payment-intent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: 6500, currency: 'zar' }),
});
const { clientSecret } = await response.json();
// Create Elements
elements.value = hyper.value!.elements({
clientSecret,
appearance: {
theme: 'stripe',
variables: { colorPrimary: '#0066FF' },
},
});
// Create and mount Payment Element
paymentElement.value = elements.value.create('payment');
paymentElement.value.mount(paymentElementContainer.value);
// Listen for changes
paymentElement.value.on('change', (event: any) => {
error.value = event.error?.message || null;
});
});
onUnmounted(() => {
paymentElement.value?.unmount();
});
async function handleSubmit() {
if (!hyper.value || !elements.value) return;
processing.value = true;
error.value = null;
const { error: submitError, paymentIntent } =
await hyper.value.confirmPayment({
elements: elements.value,
confirmParams: {
return_url: `${window.location.origin}/payment-complete`,
},
redirect: 'if_required',
});
if (submitError) {
error.value = submitError.message ?? 'Payment failed';
processing.value = false;
} else if (paymentIntent?.status === 'succeeded') {
window.location.href = '/payment-success';
}
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<div ref="paymentElementContainer" class="mb-4" />
<div v-if="error" class="text-red-600 mb-4">
{{ error }}
</div>
<button
type="submit"
:disabled="processing || !isReady"
class="w-full py-3 bg-blue-600 text-white rounded-lg disabled:opacity-50"
>
{{ processing ? 'Processing...' : 'Pay Now' }}
</button>
</form>
</template>The Payment Element decides which methods to show from your business profile and the Payment Intent. You can still control the display order, enable or disable wallets, and choose how redirect methods are presented.
Payment method ordering
The first method in the list has the highest priority.
<script setup lang="ts">
const createPaymentElement = () => {
paymentElement.value = elements.value.create('payment', {
// Control display order (first = highest priority)
paymentMethodOrder: [
'card',
'apple_pay',
'google_pay',
'samsung_pay',
'payjustnow',
],
});
paymentElement.value.mount(container.value);
};
</script>Enabling and disabling methods
Server-side control is recommended: pass payment_method_types when creating the Payment Intent.
async function createPaymentWithMethods(enabledMethods: string[]) {
const response = await fetch('/api/create-payment-intent', {
method: 'POST',
body: JSON.stringify({
amount: 6500,
currency: 'zar',
payment_method_types: enabledMethods,
}),
});
return response.json();
}For client-side wallet control:
<script setup lang="ts">
const createPaymentElement = () => {
paymentElement.value = elements.value.create('payment', {
wallets: {
applePay: 'auto', // 'auto' | 'never' | 'always'
googlePay: 'never', // Disable Google Pay
},
});
};
</script>All supported payment methods
Enable methods on your business profile.
| Category | Methods |
|---|---|
| Cards | card (Visa, Mastercard, American Express, Diners Club) |
| Wallets | apple_pay, google_pay, samsung_pay, scan_to_pay, mpesa (KE), maucas, blink_by_emtel, mcb_juice (MU) |
| Bank transfer | peach_eft, capitec_pay, pay_shap |
| Buy now pay later | payjustnow, payflex, float, mobicred, happy_pay, rcs, zero_pay |
| Voucher and crypto | one_for_you, money_badger |
Inline iframe redirection
By default a redirect payment method opens a full-screen overlay popout. Set inline_iframe_redirection_enabled to render the redirect inside your checkout form, in an embedded iframe. It goes on the same options object as layout. Pair it with sdkHandleConfirmPayment: the SDK confirms from inside the element, so it needs the return_url here.
const paymentElement = elements.create('payment', {
// ...your existing options (layout, paymentMethodOrder, wallets, ...)
inline_iframe_redirection_enabled: true,
sdkHandleConfirmPayment: {
confirmParams: { return_url: 'https://your-site.com/checkout/complete' },
},
});Cards always pop out
Non-card redirects go inline. Cards (3DS) and express or one-click flows always pop out regardless of this flag — 3DS and ACS pages generally refuse to be framed.
Prerequisites
- This only takes effect when the connector returns a
redirect_inside_popupnext action, which requiresis_iframe_redirection_enabledon the Peach connector or merchant. Without that backend flag the option does nothing. - Your redirect targets must allow framing.
X-Frame-Options: DENYor a restrictiveframe-ancestorsrenders blank inside the iframe. Only enable it for alternative payment method pages you control or have verified.
Completion is detected by polling the Payment Intent (the embedded page advances it server-side), so it works cross-origin. There is a 15-minute backstop, and it settles cleanly if the shopper abandons the flow.
Wallet buttons
WalletButton.vue:
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useHyperSDK } from '@/composables/useHyperSDK';
const { hyper, isReady } = useHyperSDK();
const buttonContainer = ref<HTMLDivElement | null>(null);
const canPay = ref(false);
const walletType = ref<'apple' | 'google' | null>(null);
onMounted(async () => {
while (!isReady.value) {
await new Promise((r) => setTimeout(r, 100));
}
const paymentRequest = hyper.value!.paymentRequest({
country: 'ZA',
currency: 'zar',
total: { label: 'Your Store', amount: 6500 },
requestPayerName: true,
requestPayerEmail: true,
});
const result = await paymentRequest.canMakePayment();
if (result?.applePay) {
walletType.value = 'apple';
canPay.value = true;
} else if (result?.googlePay) {
walletType.value = 'google';
canPay.value = true;
}
if (canPay.value) {
const elements = hyper.value!.elements();
const prButton = elements.create('paymentRequestButton', {
paymentRequest,
style: {
paymentRequestButton: {
height: '48px',
},
},
});
prButton.mount(buttonContainer.value);
}
});
</script>
<template>
<div v-if="canPay" ref="buttonContainer" class="mb-4" />
</template>Pass a customer session client secret alongside the Payment Intent client secret to show a returning shopper their saved cards.
Checkout with saved cards
SavedCardsCheckout.vue:
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useHyperSDK } from '@/composables/useHyperSDK';
interface CheckoutData {
clientSecret: string;
customerSessionClientSecret?: string;
}
const { hyper, isReady } = useHyperSDK();
const elements = ref<any>(null);
const paymentElement = ref<any>(null);
const container = ref<HTMLDivElement | null>(null);
onMounted(async () => {
while (!isReady.value) {
await new Promise((r) => setTimeout(r, 100));
}
// Fetch checkout data with customer session
const response = await fetch('/api/checkout-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customerId: 'cus_xxx' }),
});
const data: CheckoutData = await response.json();
// Initialise with customer session for saved cards
elements.value = hyper.value!.elements({
clientSecret: data.clientSecret,
customerSessionClientSecret: data.customerSessionClientSecret,
});
paymentElement.value = elements.value.create('payment');
paymentElement.value.mount(container.value);
});
</script>Setup Intent: save a card without a payment
<script setup lang="ts">
async function saveCard() {
if (!hyper.value || !elements.value) return;
const { error, setupIntent } = await hyper.value.confirmSetup({
elements: elements.value,
confirmParams: {
return_url: `${window.location.origin}/card-saved`,
},
});
if (error) {
console.error(error);
} else if (setupIntent?.status === 'succeeded') {
console.log('Card saved!');
}
}
</script>Wrap the confirmation call in a try block, translate error codes into messages a shopper can act on, and branch on the returned Payment Intent status.
<script setup lang="ts">
import { ref } from 'vue';
const error = ref<string | null>(null);
const processing = ref(false);
async function handleSubmit() {
if (!hyper.value || !elements.value) return;
processing.value = true;
error.value = null;
try {
const { error: submitError, paymentIntent } =
await hyper.value.confirmPayment({
elements: elements.value,
confirmParams: {
return_url: `${window.location.origin}/complete`,
},
redirect: 'if_required',
});
if (submitError) {
handleError(submitError);
} else if (paymentIntent) {
handlePaymentIntent(paymentIntent);
}
} catch (e) {
error.value = 'An unexpected error occurred';
console.error(e);
} finally {
processing.value = false;
}
}
function handleError(err: HyperError) {
const messages: Record<string, string> = {
card_declined: 'Your card was declined.',
expired_card: 'Your card has expired.',
incorrect_cvc: 'Incorrect security code.',
insufficient_funds: 'Insufficient funds.',
};
if (err.type === 'card_error' && err.code) {
error.value = messages[err.code] || err.message || 'Card error';
} else if (err.type === 'validation_error') {
error.value = 'Please check your payment details.';
} else {
error.value = err.message || 'Payment failed.';
}
}
function handlePaymentIntent(paymentIntent: any) {
switch (paymentIntent.status) {
case 'succeeded':
window.location.href = '/success';
break;
case 'processing':
error.value = 'Payment is processing...';
break;
case 'requires_payment_method':
error.value = 'Payment failed. Try another method.';
break;
}
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<div ref="paymentElementContainer" />
<Transition name="fade">
<div v-if="error" class="bg-red-50 text-red-600 p-3 rounded mt-4">
{{ error }}
</div>
</Transition>
<button type="submit" :disabled="processing">
{{ processing ? 'Processing...' : 'Pay Now' }}
</button>
</form>
</template>Build the appearance object as a computed value so it follows your app's theme, and reinitialise the Elements when the theme changes.
Reactive theming
<script setup lang="ts">
import { ref, computed } from 'vue';
// Reactive theme
const isDark = ref(window.matchMedia('(prefers-color-scheme: dark)').matches);
const appearance = computed<Appearance>(() => ({
theme: isDark.value ? 'night' : 'stripe',
variables: {
colorPrimary: '#0066FF',
colorBackground: isDark.value ? '#1a1a1a' : '#ffffff',
colorText: isDark.value ? '#ffffff' : '#1a1a1a',
fontFamily: 'Inter, system-ui, sans-serif',
borderRadius: '8px',
},
rules: {
'.Input': {
border: '1px solid',
borderColor: isDark.value ? '#374151' : '#e5e7eb',
padding: '12px',
},
'.Input:focus': {
borderColor: '#0066FF',
boxShadow: '0 0 0 2px rgba(0, 102, 255, 0.2)',
},
'.Tab--selected': {
backgroundColor: '#0066FF',
color: '#ffffff',
},
},
}));
// Watch for system theme changes
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', (e) => {
isDark.value = e.matches;
// Reinitialise elements with new appearance
reinitializeElements();
});
function reinitializeElements() {
if (paymentElement.value) {
paymentElement.value.unmount();
}
elements.value = hyper.value!.elements({
clientSecret: clientSecret.value,
appearance: appearance.value,
});
paymentElement.value = elements.value.create('payment');
paymentElement.value.mount(container.value);
}
</script>Tailwind integration
<template>
<div
class="max-w-md mx-auto p-6 bg-white dark:bg-gray-900 rounded-xl shadow-lg"
>
<h2 class="text-xl font-bold mb-4 text-gray-900 dark:text-white">
Payment details
</h2>
<div
ref="paymentElementContainer"
class="mb-4 p-4 border border-gray-200 dark:border-gray-700 rounded-lg"
/>
<button
type="submit"
class="w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 text-white font-medium
rounded-lg transition-colors disabled:opacity-50"
:disabled="processing"
>
{{ processing ? 'Processing...' : 'Pay Now' }}
</button>
</div>
</template>Use the sandbox test cards to exercise each outcome, then cover the checkout component with Vitest and the full flow with Playwright.
Test cards
| Scenario | Card number |
|---|---|
| Success | 4242 4242 4242 4242 |
| 3DS | 4000 0025 0000 3155 |
| Declined | 4000 0000 0000 0002 |
Component testing with Vitest
CheckoutForm.spec.ts:
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import CheckoutForm from './CheckoutForm.vue';
// Mock the composable
vi.mock('@/composables/useHyperSDK', () => ({
useHyperSDK: () => ({
hyper: { value: mockHyper },
isReady: { value: true },
}),
}));
const mockHyper = {
elements: vi.fn(() => ({
create: vi.fn(() => ({
mount: vi.fn(),
unmount: vi.fn(),
on: vi.fn(),
})),
})),
confirmPayment: vi.fn(),
};
describe('CheckoutForm', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders payment button', () => {
const wrapper = mount(CheckoutForm);
expect(wrapper.find('button').text()).toContain('Pay');
});
it('shows error on payment failure', async () => {
mockHyper.confirmPayment.mockResolvedValue({
error: { message: 'Card declined' },
});
const wrapper = mount(CheckoutForm);
await wrapper.find('form').trigger('submit');
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain('Card declined');
});
});End-to-end with Playwright
e2e/checkout.spec.ts:
import { test, expect } from '@playwright/test';
test('checkout flow', async ({ page }) => {
await page.goto('/checkout');
const frame = page.frameLocator('iframe[name^="__privateHyper"]');
await frame.locator('[name="cardnumber"]').fill('4242424242424242');
await frame.locator('[name="exp-date"]').fill('1234');
await frame.locator('[name="cvc"]').fill('123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL(/\/success/);
});Most Vue-specific problems come down to when the SDK touches the DOM and how its instances are stored in reactive state.
Element not mounting
Ensure the ref is available before mounting.
onMounted(async () => {
await nextTick(); // Wait for DOM
if (!container.value) {
console.error('Container not found');
return;
}
paymentElement.value.mount(container.value);
});Reactivity not working
Use shallowRef for SDK instances.
import { shallowRef } from 'vue';
const hyper = shallowRef<Hyper | null>(null);
const elements = shallowRef<any>(null);Memory leaks
Always unmount elements.
onUnmounted(() => {
if (paymentElement.value) {
paymentElement.value.unmount();
paymentElement.value = null;
}
});Nuxt SSR errors
Use a client-only wrapper.
<template>
<ClientOnly>
<CheckoutForm />
</ClientOnly>
</template>Or use onMounted:
onMounted(async () => {
if (typeof window === 'undefined') return;
// Initialise SDK
});Hot reload breaks elements
Handle HMR in development.
if (import.meta.hot) {
import.meta.hot.dispose(() => {
paymentElement.value?.unmount();
});
}Getting help
Contact support for help.