React / Next.js
Use Payment SDK 0.7.0 in a client component with explicit payment entry and cleanup.
1. Install the SDK types
npm install @highwirepayments/payment-sdk@0.7.0The package supplies TypeScript declarations. This example loads the self-contained CDN build at runtime and uses only type imports from npm; no additional runtime dependency is needed.
In Next.js, put the checkout in a client component. Initialize and mount from browser code after the payment container exists. This example starts payment from a shopper action, so React's Strict Mode effect replay does not create payments.
Your backend reconciles existing payments before setting canStartPayment, supplies the
authoritative amount, and implements the atomic binding and same-origin JSON contract from the
Plain HTML guide. Follow the
server binding and verification requirements.
2. Add the payment component
Flow: initialize on the shopper action → mount with the server amount → bind atomically → enable the inert controls → verify and reconcile on the server. This is a complete safe component, including async teardown and retry boundaries. Replace the test publishable key.
Complete PaymentStep component
'use client';
import { useEffect, useRef, useState } from 'react';
import type {
BillingAddress,
HighWire,
HighWireError,
Checkout,
HighWireInstance,
} from '@highwirepayments/payment-sdk';
type SDKNamespace = {
default: typeof HighWire;
HighWireError: typeof HighWireError;
SDK_VERSION: string;
};
declare global {
interface Window {
HighWire?: SDKNamespace;
}
}
let sdkPromise: Promise<SDKNamespace> | null = null;
const retryableRequestCodes = new Set([
'HW_NETWORK',
'HW_TIMEOUT',
'HW_SERVICE_UNAVAILABLE',
]);
function loadSDK(): Promise<SDKNamespace> {
if (window.HighWire?.SDK_VERSION === '0.7.0') {
return Promise.resolve(window.HighWire);
}
sdkPromise ??= new Promise<SDKNamespace>((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdn.highwirepayments.com/sdk/0.7.0/highwire.js';
script.integrity = 'sha384-NZVoZbDdfN3Ix90/7E+1ojIBysBz1Cwhizp9zPhjUxaOVkMdHBk9RSSxMSAO44LU';
script.crossOrigin = 'anonymous';
script.onload = () => {
if (window.HighWire?.SDK_VERSION === '0.7.0') resolve(window.HighWire);
else {
script.remove();
reject(new Error('Payment SDK unavailable'));
}
};
script.onerror = () => {
script.remove();
reject(new Error('Payment SDK could not load'));
};
document.head.appendChild(script);
}).catch((error) => {
sdkPromise = null;
throw error;
});
return sdkPromise;
}
function sdkError(error: unknown) {
const ErrorClass = window.HighWire?.HighWireError;
return ErrorClass && error instanceof ErrorClass ? error : null;
}
type Order = {
id: string;
amount: number;
currency: 'usd';
customerEmail: string;
canStartPayment: boolean;
};
export function PaymentStep({ order }: { order: Order }) {
const containerRef = useRef<HTMLDivElement>(null);
const billingRef = useRef<HTMLFormElement>(null);
const instanceRef = useRef<Promise<HighWireInstance> | null>(null);
const checkoutRef = useRef<Checkout | null>(null);
const aliveRef = useRef(false);
const startingRef = useRef(false);
const busyRef = useRef(false);
const mountingRef = useRef(false);
const formFailedRef = useRef(false);
const lastErrorRef = useRef<unknown>(null);
const [starting, setStarting] = useState(false);
const [bound, setBound] = useState(false);
const [formReady, setFormReady] = useState(false);
const [busy, setBusy] = useState(false);
const [confirming, setConfirming] = useState(false);
const [closed, setClosed] = useState(false);
const [requiresSubmit, setRequiresSubmit] = useState(false);
const [message, setMessage] = useState('');
useEffect(() => {
aliveRef.current = true;
return () => {
aliveRef.current = false;
checkoutRef.current?.unmount();
};
}, []);
function reportError(error: unknown) {
if (!aliveRef.current || error === lastErrorRef.current) return;
lastErrorRef.current = error;
const failure = sdkError(error);
const code = failure?.code ?? null;
const recovery = failure?.recovery ?? (code === 'HW_INCOMPLETE' ? 'correct' : undefined);
if (recovery === 'wait') return;
if (recovery === 'correct' || recovery === 'retry' || recovery === 'recheck') {
setConfirming(recovery === 'recheck');
if (recovery !== 'recheck') {
busyRef.current = false;
setBusy(false);
}
setMessage(recovery === 'correct'
? 'Check your email, payment details and billing address, then try again.'
: recovery === 'retry'
? 'Payment could not start. Try again.'
: 'We are confirming your payment. Check your order status before paying again.');
return;
}
const retryableSubmit = busyRef.current && code !== null
&& retryableRequestCodes.has(code);
const formFailed = code === 'HW_IFRAME_UNRECOVERABLE'
|| code === 'HW_PROTOCOL_MISMATCH'
|| (code === 'HW_TIMEOUT' && !busyRef.current && !mountingRef.current);
if (formFailed) {
formFailedRef.current = true;
setFormReady(false);
setClosed(true);
checkoutRef.current?.unmount();
} else if (!retryableSubmit) {
setFormReady(false);
setClosed(true);
checkoutRef.current?.unmount();
}
busyRef.current = false;
setBusy(false);
setMessage(formFailed
? 'Payment form unavailable. Check your order status before reloading.'
: code === 'HW_RATE_LIMITED'
? 'Too many attempts. Wait, then check your order status before trying again.'
: !retryableSubmit
? 'Payment has stopped. Check your order before trying again.'
: 'Payment could not complete. Try again.');
}
async function startPayment() {
if (startingRef.current || !containerRef.current) return;
startingRef.current = true;
setStarting(true);
if (!order.canStartPayment) {
setClosed(true);
setMessage('Check the existing order payment before starting again.');
return;
}
setMessage('Preparing payment…');
try {
const sdk = await loadSDK();
if (!aliveRef.current || !containerRef.current) return;
instanceRef.current ??= sdk.default.init({
publishableKey: 'REPLACE_WITH_YOUR_TEST_PUBLISHABLE_KEY',
apiBase: 'https://api-payments-staging.highwirepayments.com',
}).catch((error) => {
instanceRef.current = null;
throw error;
});
const hw = await instanceRef.current;
if (!aliveRef.current || !containerRef.current) return;
mountingRef.current = true;
const checkout = await hw.mount({
container: containerRef.current,
amount: order.amount,
currency: order.currency,
onReady: () => {
if (aliveRef.current && !formFailedRef.current) {
setMessage('Enter your payment details.');
}
},
onAvailabilityChange: (available) => {
if (aliveRef.current) setFormReady(available);
},
onChange: (card) => {
if (aliveRef.current) setFormReady(card.complete);
},
onProcessing: () => {
busyRef.current = true;
if (!aliveRef.current) return;
setBusy(true);
setMessage('Processing your payment…');
},
onResult: (result) => {
if (!aliveRef.current) return;
busyRef.current = false;
setBusy(false);
if (result.status === 'declined' && result.retryable) {
setMessage('Declined. Try another card or method.');
return;
}
setClosed(true);
checkoutRef.current?.unmount();
setMessage(result.status === 'pending'
? 'We are confirming your payment. Check your order status.'
: result.status === 'succeeded'
? 'Payment submitted. Your server will confirm the order.'
: 'This checkout has ended. Check your order before starting again.');
},
onError: reportError,
});
mountingRef.current = false;
checkoutRef.current = checkout;
if (!aliveRef.current) {
checkout.unmount();
return;
}
if (formFailedRef.current) checkout.unmount();
const response = await fetch(`/api/orders/${encodeURIComponent(order.id)}/payment`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ paymentId: checkout.paymentId }),
});
if (!response.ok) throw new Error('Payment could not be bound to the order');
if (!aliveRef.current) return;
setRequiresSubmit(checkout.requiresSubmit);
setBound(true);
} catch (error) {
mountingRef.current = false;
if (checkoutRef.current) {
checkoutRef.current.unmount();
if (aliveRef.current) {
setClosed(true);
setMessage('Payment unavailable. Check your order before trying again.');
}
} else if (aliveRef.current) {
const code = sdkError(error)?.code ?? null;
if (!formFailedRef.current && code !== null
&& retryableRequestCodes.has(code)) {
startingRef.current = false;
setStarting(false);
formFailedRef.current = false;
setClosed(false);
setFormReady(false);
setMessage('Payment unavailable. Try entering payment again.');
} else {
setFormReady(false);
setClosed(true);
setMessage(formFailedRef.current
? 'Payment form unavailable. Check your order status before reloading.'
: code === 'HW_RATE_LIMITED'
? 'Too many attempts. Wait, then reload to check your order before trying again.'
: 'Payment could not start. Check your order status before trying again.');
}
}
}
}
async function pay() {
const checkout = checkoutRef.current;
const billing = billingRef.current;
if (!checkout || !billing || !bound || !formReady || busyRef.current || confirming
|| closed || !billing.reportValidity()) return;
const { customerEmail, ...billingAddress } = Object.fromEntries(new FormData(billing)) as
BillingAddress & { customerEmail?: string };
busyRef.current = true;
setBusy(true);
try {
await checkout.submit({ customerEmail, billingAddress });
} catch (error) {
reportError(error);
} finally {
busyRef.current = false;
if (aliveRef.current) setBusy(false);
}
}
return (
<section aria-label="Payment">
{!bound && !closed && (
<button type="button" disabled={starting} onClick={startPayment}>
Continue to payment
</button>
)}
<div inert={!bound || closed}>
<form ref={billingRef} aria-label="Contact and billing details">
<input name="customerEmail" type="email" autoComplete="email" aria-label="Email"
defaultValue={order.customerEmail} required />
<input name="name" autoComplete="billing name" aria-label="Name" required />
<input name="country" autoComplete="billing country" aria-label="Country code"
pattern="[A-Za-z]{2}" required />
<input name="line1" autoComplete="billing address-line1" aria-label="Address" />
<input name="line2" autoComplete="billing address-line2" aria-label="Apartment, suite" />
<input name="city" autoComplete="billing address-level2" aria-label="City" />
<input name="state" autoComplete="billing address-level1" aria-label="State" />
<input name="postalCode" autoComplete="billing postal-code" aria-label="Postal code"
required />
</form>
<div ref={containerRef} />
{requiresSubmit && !closed && (
<button type="button" disabled={!formReady || busy || confirming} onClick={pay}>
Pay
</button>
)}
</div>
<p role="status">{message}</p>
</section>
);
}The loader fetches the integrity-checked CDN file once; npm imports remain type-only. The
inert wrapper blocks payment until binding without hiding the SDK container. requiresSubmit
controls the Pay button, and callbacks handle completeness, processing, and duplicate errors.
The component covers every layout. onChange (card field) and onAvailabilityChange (a
drop-in form that uses your Pay button)
both set formReady; a checkout reports only one of them. The email and billing fields are read
each time the shopper pays, so a refused email or address can be corrected in place. Every card
payment needs the email; the drop-in form needs the full address, at least the name, a two-letter
country and the postalCode, and the card field sends line1, city, state and postalCode. The component prefills
the email from the order; prefill the address from your earlier checkout steps if you have it.
Errors from that drop-in form carry
recovery: correct and retry let the
shopper fix the email, card or billing fields and pay again, wait changes nothing, and unavailable
stops the controls. recheck disables Pay and sends the shopper to their order status, as
pending does; the form stays mounted so a result already on its way still arrives. To let the
shopper check from the page instead, add a button that calls
checkout.recheck() once the running
submit() has settled. The component suits the default billingAddressForm: 'none'; with
'full', call checkout.submit({ customerEmail }) without a billingAddress and keep only the
email field.
3. Keep order and payment lifecycle aligned
Keep the component mounted while the shopper pays and disable order editing during confirmation.
If the order or totals change, reconcile first, then render a fresh component with a new React
key. Cleanup unmounts late async work, but cannot cancel a charge; reconcile every bound payment.
After HW_NETWORK, request-level HW_TIMEOUT, or HW_SERVICE_UNAVAILABLE without a
recovery, the component permits an immediate retry of the same operation. A form-loading
timeout, rate limit, configuration or authorization error, non-retryable decline, pending
result, abandoned verification, ended checkout, or reload stops the controls. Show order status
and let the backend decide whether a new payment is allowed; see
order changes and cleanup.
Configure wallet domains and CSP before production.