Plain HTML
Add Payment SDK 0.7.0 to a browser checkout using the CDN build.
1. Add the checkout markup
Load the SDK once, before your same-origin integration file. The URL and SRI below are the matching 0.7.0 pair; preserve the served file unchanged.
<button id="start-payment" type="button">Continue to payment</button>
<section id="checkout-controls" inert aria-label="Payment">
<form id="billing" aria-label="Contact and billing details">
<input name="customerEmail" type="email" autocomplete="email" aria-label="Email" 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 id="payment"></div>
<button id="pay" type="button" hidden disabled>Pay</button>
</section>
<p id="payment-status" role="status"></p>
<script src="https://cdn.highwirepayments.com/sdk/0.7.0/highwire.js"
integrity="sha384-NZVoZbDdfN3Ix90/7E+1ojIBysBz1Cwhizp9zPhjUxaOVkMdHBk9RSSxMSAO44LU"
crossorigin="anonymous"></script>
<script src="/checkout.js" defer></script>The outer section starts inert, so card, wallet, and drop-in controls cannot be used until
your backend binds the payment to the order. Keep the container laid out; hiding it can prevent
components from measuring themselves. The CDN bundles the wallet loader.
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. A
drop-in form that uses your Pay button
needs the full address, at least the name, a two-letter country and the postalCode; the card
field sends line1, city, state and postalCode. Use your own labels and styling, and prefill the fields from your
earlier checkout steps if you have them; the example prefills the email from the order.
2. Provide merchant endpoints
The following /api/orders/current and /api/orders/{id}/payment routes are your merchant
backend, not SDK endpoints:
GET /api/orders/currentreconciles any existing payment and returns{ id, amount, currency, customerEmail, canStartPayment }. Use integer cents and USD currency. SetcanStartPayment: trueonly when the server authorizes a new attempt; otherwise send the shopper to order status before mounting.POST /api/orders/{id}/paymentaccepts{ paymentId }and binds it atomically. Allow an idempotent repeat of the same pair; reject conflicts.
Follow the server binding and verification requirements for authorization, same-origin JSON and cookie-based CSRF protection, merchant ownership, authoritative amount and currency checks, eligibility, unique constraints, and reconciliation. Never overwrite a paid or unresolved payment; fulfill only from verified capture.
3. Create /checkout.js
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 example, including teardown and retry boundaries; replace the publishable key with your sandbox key.
Complete checkout.js example
(() => {
const startButton = document.getElementById('start-payment');
const controls = document.getElementById('checkout-controls');
const billingForm = document.getElementById('billing');
const payButton = document.getElementById('pay');
const message = document.getElementById('payment-status');
const sdk = window.HighWire;
if (sdk?.SDK_VERSION !== '0.7.0') {
startButton.disabled = true;
message.textContent = 'Payment SDK unavailable. Reload to try again.';
return;
}
const HighWire = sdk.default;
const { HighWireError } = sdk;
let instancePromise;
let checkout;
let formReady = false;
let busy = false;
let confirming = false;
let mounting = false;
let bound = false;
let closed = false;
let lastError;
let disposed = false;
let formFailed = false;
const retryableRequestCodes = new Set([
'HW_NETWORK',
'HW_TIMEOUT',
'HW_SERVICE_UNAVAILABLE',
]);
function updateControls() {
controls.inert = !bound || closed;
payButton.hidden = !checkout?.requiresSubmit || closed;
payButton.disabled = !bound || !formReady || busy || confirming || closed;
}
function reportError(error) {
if (disposed || error === lastError) return;
lastError = error;
const code = error instanceof HighWireError ? error.code : null;
const recovery = error instanceof HighWireError
? error.recovery ?? (code === 'HW_INCOMPLETE' ? 'correct' : undefined)
: undefined;
if (recovery === 'wait') return;
if (recovery === 'correct' || recovery === 'retry' || recovery === 'recheck') {
confirming = recovery === 'recheck';
if (!confirming) busy = false;
message.textContent = 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.';
updateControls();
return;
}
const retryableSubmit = busy && retryableRequestCodes.has(code);
formFailed = ['HW_IFRAME_UNRECOVERABLE', 'HW_PROTOCOL_MISMATCH'].includes(code)
|| (code === 'HW_TIMEOUT' && !busy && !mounting);
closed = formFailed || !retryableSubmit;
if (closed) {
formReady = false;
checkout?.unmount();
}
busy = false;
message.textContent = 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.'
: closed
? 'Payment has stopped. Check your order before trying again.'
: 'Payment could not complete. Try again.';
updateControls();
}
startButton.addEventListener('click', async () => {
if (disposed || startButton.disabled) return;
startButton.disabled = true;
message.textContent = 'Preparing payment…';
try {
const orderResponse = await fetch('/api/orders/current');
if (disposed) return;
if (!orderResponse.ok) throw new Error('Order unavailable');
const order = await orderResponse.json();
if (disposed) return;
if (order.canStartPayment !== true) {
closed = true;
startButton.hidden = true;
throw new Error('Check the existing order payment before starting again');
}
instancePromise ??= HighWire.init({
publishableKey: 'REPLACE_WITH_YOUR_TEST_PUBLISHABLE_KEY',
apiBase: 'https://api-payments-staging.highwirepayments.com',
}).catch((error) => {
instancePromise = undefined;
throw error;
});
const hw = await instancePromise;
if (disposed) return;
billingForm.elements.customerEmail.value ||= order.customerEmail ?? '';
mounting = true;
const mounted = await hw.mount({
container: '#payment',
amount: order.amount,
currency: order.currency,
onReady: () => {
if (!disposed && !closed) message.textContent = 'Enter your payment details.';
},
onAvailabilityChange: (available) => {
if (disposed) return;
formReady = available;
updateControls();
},
onChange: (card) => {
if (disposed) return;
formReady = card.complete;
updateControls();
},
onProcessing: () => {
if (disposed) return;
busy = true;
message.textContent = 'Processing your payment…';
updateControls();
},
onResult: (result) => {
if (disposed) return;
busy = false;
if (result.status === 'declined' && result.retryable) {
message.textContent = 'Declined. Try another card or method.';
} else {
closed = true;
checkout?.unmount();
message.textContent = 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.';
}
updateControls();
},
onError: reportError,
});
mounting = false;
if (disposed) {
mounted.unmount();
return;
}
checkout = mounted;
if (closed) checkout.unmount();
const binding = 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 (disposed) return;
if (!binding.ok) throw new Error('Payment could not be bound to the order');
bound = true;
startButton.hidden = true;
updateControls();
} catch (error) {
mounting = false;
if (disposed) return;
if (checkout) {
checkout.unmount();
closed = true;
} else {
const code = error instanceof HighWireError ? error.code : null;
if (!formFailed && retryableRequestCodes.has(code)) {
startButton.disabled = false;
closed = false;
formReady = false;
message.textContent = 'Payment unavailable. Try entering payment again.';
updateControls();
return;
}
closed = true;
formReady = false;
message.textContent = formFailed
? '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.';
updateControls();
return;
}
reportError(error);
}
});
payButton.addEventListener('click', async () => {
if (disposed || !checkout || payButton.disabled || !billingForm.reportValidity()) return;
const { customerEmail, ...billingAddress } = Object.fromEntries(new FormData(billingForm));
busy = true;
updateControls();
try {
await checkout.submit({ customerEmail, billingAddress });
} catch (error) {
reportError(error);
} finally {
busy = false;
updateControls();
}
});
window.addEventListener('pagehide', () => {
disposed = true;
bound = false;
closed = true;
startButton.disabled = true;
checkout?.unmount();
updateControls();
});
window.addEventListener('pageshow', (event) => {
if (event.persisted) {
message.textContent = 'Checkout paused. Reload to check your order before paying.';
}
});
})();If the CDN script is blocked or fails integrity verification, the example keeps payment inactive and shows a reload message.
onError and rejection handling receive the same SDK error; this example displays it once.
Do not process the resolved submit result again: onResult already handles it.
The same code covers every layout. A checkout reports either onChange (card field) or
onAvailabilityChange (a drop-in form that uses your Pay button),
and both set formReady. The card field ignores the billing name, country and line2. 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.
This example sends the billing fields from your page, so it suits a drop-in form mounted with the
default billingAddressForm: 'none'. With 'full', call checkout.submit({ customerEmail })
without a billingAddress and keep only the email field.
4. Handle leaving and restarting
For in-page navigation, call checkout.unmount() when the payment step leaves the DOM and remove
your event listeners. Keep the instance if returning to the same unresolved order. Disable order
editing while payment is being confirmed.
After HW_NETWORK, request-level HW_TIMEOUT, or HW_SERVICE_UNAVAILABLE without a
recovery, the example permits an immediate retry of the same operation. A form-loading
timeout, rate limit, configuration or authorization error, non-retryable decline, ended
checkout, reload, or abandoned verification stops the controls. Reconcile on your backend
before offering another payment; see
the lifecycle rules.
The disposed flag blocks continuations after pagehide, including a checkout that finishes
mounting after teardown. A page restored from the back/forward cache stays inactive; reload to
rerun the backend eligibility check before mounting. A binding request already sent may finish
on the server after leaving, so keep reconciling it there.
Use the wallet and CSP guide to configure enabled methods.