High Wire Payments
Guides

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:

  1. GET /api/orders/current reconciles any existing payment and returns { id, amount, currency, customerEmail, canStartPayment }. Use integer cents and USD currency. Set canStartPayment: true only when the server authorizes a new attempt; otherwise send the shopper to order status before mounting.
  2. POST /api/orders/{id}/payment accepts { 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.

On this page