Where there is a need for more customization and control over the checkout experience, a headless version of Primer’s Universal Checkout is available.

You can use Headless Universal Checkout with your UI, giving you more flexibility and allowing you to move faster when making design changes, while still having Universal Checkout capture sensitive PCI card data or other form data.

If you’re looking for a simpler way of integrating Universal Checkout, consider integrating our Drop-In checkout.

Not all payment methods are currently compatible with Headless Checkout.

Please refer to this table to learn more about the payment methods available for Headless Checkout.

Before you start

Before you start, make sure:

Create a client session

A client session is the starting point for integrating payments at Primer. You can attach any data associated with the order to your client session.

Creating a client session provides you with a client token, a temporary key used to initialize the Universal Checkout.

The information you include in the client session is used in the Dashboard:

  • to conditionally route payments with Workflows
  • to activate payment methods and other features in Universal Checkout

So pass as much information as you can!

Generate an API key

Requests to our API are authenticated using an API key in the X-Api-Key header. Create an API key by visiting the developer page of the Primer Dashboard.

Make sure to set the following scopes for your API Key:

  • client_tokens:write
  • transactions:authorize

Make a client session request

On your server, create a client session with

.

Make sure to pass at least the following data:

FieldDescription
orderIdYour reference for the payment.

_Make sure to keep track of orderId - you will later receive updates to the payment via Webhooks. The payment will contain the orderId specified in the client session. _
currencyCodeThe three-letter currency code in ISO 4217 format.
e.g. use USD for US dollars.
order
↳ lineItems
The details of the line items of the order.

The body of a successful response contains a clientToken that you will use to initialize the Universal Checkout.

Here is how the client session request to the Primer API should look like:

BASH
curl --location --request \
 POST 'https://api.sandbox.primer.io/client-session' \
 --header 'X-Api-Key: <YOUR_API_KEY>' \
 --header 'X-Api-Version: 2.2' \
 --header 'Content-Type: application/json' \
 --data '{
    "orderId": "<YOUR_ORDER_ID>",
    "currencyCode": "GBP",
    "amount": 5000,
    "order": {
      "lineItems": [{
        "itemId": "shoes-123",
        "amount": 2500,
        "quantity": 2
      }],
      "countryCode": "GB"
    }
 }'

# Here is a (heavily truncated) example response

{
  "clientToken": "THE_CHECKOUT_SESSION_TOKEN",
  "clientExpirationDate": "2022-03-08T14:00:00Z",
  "orderId": "<YOUR_ORDER_ID>",
  "currencyCode": "GBP",
  "amount": 5000,
	"order": {
      "lineItems": [{
        "itemId": "shoes-123",
        "amount": 2500,
        "quantity": 2
      }],
      "countryCode": "GB",
    }
}

Get Started

Primer Headless Universal Checkout works in a simple way:

  1. Get a clientToken from your server
  2. Start Primer Headless Universal Checkout with the client token
  3. Primer Headless Universal Checkout will then return the available payment methods for the session initiated. Those payment methods that have been configured in the Dashboard and whose conditions match the current client session will be returned.
  4. You show the user the list of available payment methods.
  5. When the user selects a payment method, show its UI to enable the user to enter their credentials. Depending on the payment method, you will have to either ask the SDK to render it, or build the UI yourself.
  6. Primer’s Headless Universal Checkout will then create a payment for you and manage its lifecycle. You will receive a confirmation of payment with a callback to indicate the checkout flow has completed.
This documentation is only relevant for v2.21.0 and upward.

Step 1. Install the SDK

Please review the Content Security Policy (CSP) recommendations before installing.

With npm

Our Web SDK is available on npm under the name @primer-io/checkout-web.

This package includes TypeScript definitions.

BASH
# With yarn
yarn add @primer-io/checkout-web

# With npm
npm install --save @primer-io/checkout-web
Typescript
import { Primer } from '@primer-io/checkout-web'

Primer.createHeadless(clientToken, options)
  • The npm package only works if used alongside a bundler such as Webpack or Parcel. If you’re directly writing JavaScript using script tag, please use our CDN instead.
  • As of today, the npm package does not work in a server environment. If you are using Next.js, Gatsby, or a similar framework, make sure the Primer functions are called on the client side, or use our CDN instead.

With our CDN

Include the Primer.min.js script and the Checkout.css stylesheet on the page where you want to render the Checkout. Make sure to pass the proper version in the URL.

HTML
<link rel="stylesheet" href="https://sdk.primer.io/web/v2.0.0/Checkout.css" />

<script src="https://sdk.primer.io/web/v2.0.0/Primer.min.js" integrity="sha384-yUCj6Q8h0Q6zFc35iT7v7pFoqlgpBD/xVr5SQxOgAnu2Cq286mf7IAyrFBJ8OsIa" crossorigin="anonymous"></script>

The Primer.min.js will add the Primer object to the global scope:

Typescript
const { Primer } = window

Primer.createHeadless(clientToken, options)

Step 2: Initialize Primer’s Headless Universal Checkout

Generate a client token

Request a client token from your backend by creating a client session.

Check our guide on how to create a client session here.

Remember that based on your client token different payment methods will be available for display.

Configure Headless Universal Checkout

Once you have a client token, initialize Primer’s headless checkout with Primer.createHeadless(clientToken, options).

Typescript
const headless = await Primer.createHeadless(clientToken, options)

Make sure to implement at least the following callbacks:

  • onAvailablePaymentMethodsLoad(paymentMethodTypes)
    returns the available payment methods for the client session. Use it to render a list of payment methods.
  • onCheckoutComplete(data)
    is called when the payment has been successfully completed. It returns a reference to the payment.
  • onCheckoutFail(error, data, handler)
    is called if the payment fails to be created or processed.

Payment methods are added and configured through your Primer Dashboard. onAvailablePaymentMethodsLoad will return the payment methods whose conditions match the current client session.

Finally, call headless.start() to retrieve the list of payment methods, and start the checkout flow.

Typescript
await headless.start()

Here is a full example to configure and start Headless Universal Checkout:

Typescript
window.addEventListener('load', onLoaded)

async function onLoaded() {
	// Create a client session via your backend
	const clientSession = await fetch('/client-session', {
		method: 'post',
		headers: { 'Content-Type': 'application/json' },
	}).then(data => data.json())

	const { clientToken } = clientSession
	const { Primer } = window

	// Create an instance of the headless checkout
	const headless = await Primer.createHeadless(clientToken, {
		onAvailablePaymentMethodsLoad(paymentMethods) {
			// Called when the available payment methods are retrieved

			for (const paymentMethod of paymentMethods) {
				// `type` is a unique ID representing the payment method
				const { type, managerType } = paymentMethod

				switch (managerType) {
					case 'CARD': {
						// Configure your card form (see Step 4.a)
						// await configureCardForm(paymentMethod);
						break
					}
					case 'NATIVE': {
						// Render the native payment method button (see Step 4.b)
						// Relevant for PayPal, Apple Pay and Google Pay
						// configureNativeButton(paymentMethod);
						break
					}
					case 'REDIRECT': {
						// Handle redirect payment methods (see Step 4.c)
						// configureRedirectPaymentMethod(paymentMethod);
						break
					}
					case 'KLARNA': {
						// Handle Klarna payment methods (see Step 4.d)
						// configureklarnaPaymentMethod(paymentMethod);
						break
					}
					case 'ACH': {
						// Handle ACH payment method
						// Only `STRIPE_ACH` is supported (see Step 4.e)
						// configureStripeAchPaymentMethod(paymentMethod);
						break
					}

					// More payment methods to follow
				}
			}
		},

		onCheckoutComplete({ payment }) {
			// Notifies you that a payment was created
			// Move on to next step in your checkout flow:
			// e.g. Show a success message, ...
			console.log('onCheckoutComplete', payment)
		},

		onCheckoutFail(error, { payment }, handler) {
			// Notifies you that the checkout flow has failed and a payment could not be created
			// This callback can also be used to display an error state within your own UI.

			// ⚠️ `handler` is undefined if the SDK does not expect anything from you
			if (!handler) {
				return
			}

			// ⚠️ If `handler` exists, you MUST call one of the functions of the handler

			// Show a default error message
			return handler.showErrorMessage()
		},
	})

	// Start the headless checkout
	await headless.start()

	console.log('Headless Universal Checkout is loaded!')
}

See more options and events in the SDK API Reference

Step 3: Show available payment methods

When the checkout is done initializing, the callback onAvailablePaymentMethodsLoad is invoked. Use this event to show the list of payment methods to the user:

  • Some payment methods such as Google Pay, Apple Pay, and PayPal require Primer to manage their payment method button.

  • For the others, you have full control over how the payment method button should be presented. To assist you, Primer exposes the AssetsManager that enables you to retrieve the logo and main colors attached to each payment method.

Typescript
// getAssetsManager() is only available once Headless Checkout has been fully initialized
const assetsManager = headless.getAssetsManager()

const { iconUrl, paymentMethodName, backgroundColor } = await assetsManager.getPaymentMethodAsset('ADYEN_IDEAL') // ADYEN_IDEAL comes from `paymentMethod.type`

The assets manager is only available once onAvailablePaymentMethodsLoad has been called.

Step 4: Handle payment method selection

Headless Universal Checkout enables you to create any UI that suits your needs, using the components and data we provide.

Step 4.a: Handle cards

When PAYMENT_CARD is available as a payment method and provided via onAvailablePaymentMethodsLoad, build your card form using Primer input elements.

Get started by creating a payment method manager for cards.

Typescript
const cardManager = await headless.createPaymentMethodManager('PAYMENT_CARD')

Show card fields

Headless Universal Checkout securely captures payment method data while fully embedded in your app. By communicating directly with Primer’s PCI-L1 tokenization service, Universal Checkout transforms sensitive customer data into a secure uniform string called a payment method token.

First, prepare containers in the DOM for the Primer hosted inputs. You would need three containers for the card number, the expiry date, and the CVV.

Typescript
const container = document.getElementById('my-container')

const cardNumberInputId = 'checkout-card-number-input'
const cardNumberInputEl = document.createElement('div')
cardNumberInputEl.setAttribute('id', cardNumberInputId)

const cardExpiryInputId = 'checkout-card-expiry-input'
const cardExpiryInputEl = document.createElement('div')
cardExpiryInputEl.setAttribute('id', cardExpiryInputId)

const cardCvvInputId = 'checkout-card-cvv-input'
const cardCvvInputEl = document.createElement('div')
cardCvvInputEl.setAttribute('id', cardCvvInputId)

container.append(cardNumberInputEl, cardExpiryInputEl, cardCvvInputEl)

Then, create the hosted card inputs:

Typescript
const { cardNumberInput, expiryInput, cvvInput } = cardManager.createHostedInputs()

Finally, render your inputs into the relevant containers:

Typescript
await Promise.all([
	cardNumberInput.render(cardNumberInputId, {
		placeholder: '1234 1234 1234 1234',
		ariaLabel: 'Card number',
	}),
	expiryInput.render(cardExpiryInputId, {
		placeholder: 'MM/YY',
		ariaLabel: 'Expiry date',
	}),
	cvvInput.render(cardCvvInputId, {
		placeholder: '123',
		ariaLabel: 'CVV',
	}),
])
Customize card fields

Card fields are rendered with individual iframes in order to remain PCI-L1 compliant. One key consequence is that the CSS of your page will not be propagated to the card fields. This includes color, and font.

Pass a style object to the render function to configure colors and font options.

Typescript
const style = {
	input: {
		base: {
			height: 'auto',
			border: '1px solid rgb(0 0 0 / 10%)',
			borderRadius: '2px',
			padding: '12px',
			boxShadow: '0 4px 8px 0 rgba(0,0,0,0.2)',
		},
	},
}

cardNumberInput.render(cardNumberInputId, {
	placeholder: '1234 1234 1234 1234',
	ariaLabel: 'Card number',
	style,
})

Check the “Styling Inputs” section of the Customize Universal Checkout guide to learn how to adapt the style to your requirements.

Reset card form

If needed, the card form can be cleared by calling reset():

Typescript
cardManager.reset()
Remove card elements

Call removeHostedInputs() to remove the hosted card input fields from the DOM:

Typescript
cardManager.removeHostedInputs()

Detect card type

When the user enters the card credentials, Headless Universal Checkout automatically detects the possible types of the card.

Listen to the callback onCardNetworksChange on the card manager to receive the type of card.

Typescript
const cardManager = await headless.createPaymentMethodManager('PAYMENT_CARD', {
	onCardNetworksChange(data) {
		// ...
	},
})

Learn more about onCardNetworksChange in the API reference.

Capture cardholder name

You are free to render the cardholder name input however you want.

As the user enters their cardholder name, call setCardholderName(cardholderName) to pass the cardholder name to the cardManager:

Typescript
cardholderNameInput.addEventListener('change', e => {
	cardManager.setCardholderName(e.target.value)
})

If the cardholder name is required, its content will be validated by Headless Universal Checkout.

You can specify whether the cardholder name is required by setting an option when initializing Headless Universal Checkout:

Typescript
const headless = await Primer.createHeadless(clientToken, {
	card: {
		cardholderName: {
			required: true,
		},
	},
})

Handle input errors

The event change, available on each input, reacts to input changes. This returns if the input is valid or not, and the error type.

Typescript
cardNumberInput.addEventListener('change', (...args) => {
	console.log('cardNumberInput changed', ...args)
})

Validate and Submit

When the user submits the card information, follow the following flow:

  • First, validate all the inputs using the validate() function that does basic validations on the hosted inputs.
  • Then, submit the validated data using the submit() function. This triggers the payment creation.

For example, if the user clicks submit, you can handle it as follows:

Typescript
submitButton.addEventListener('click', async () => {
	// Validate your card input data
	const { valid } = await cardManager.validate()
	if (valid) {
		// Submit the card input data to Primer for tokenization
		await cardManager.submit()
  	}	}
})

Calling submit() triggers the creation and handling of the payment.

  • If onCheckoutComplete is called, show a success message and reset the inputs.
  • If onCheckoutFail is called, show a failure message and allow the customer to try again with the same details.

Prepare 3DS

When the user pays by card, the Workflow will decide whether a 3DS challenge is required or not. If so, Headless Universal Checkout will automatically render the 3DS challenge in context.

To improve 3DS success rates, it is recommended to pass the following elements in the Client Session:

  • customer.emailAddress
  • customer.billingAddress

Integration example snippet

Below is an example code snippet of how it all fits together.

Typescript
const container = document.getElementById('my-container')

// Create containers for your hosted inputs
const cardNumberInputId = 'checkout-card-number-input'
const cardNumberInputEl = document.createElement('div')
cardNumberInputEl.setAttribute('id', cardNumberInputId)

const cardExpiryInputId = 'checkout-card-expiry-input'
const cardExpiryInputEl = document.createElement('div')
cardExpiryInputEl.setAttribute('id', cardExpiryInputId)

const cardCvvInputId = 'checkout-card-cvv-input'
const cardCvvInputEl = document.createElement('div')
cardCvvInputEl.setAttribute('id', cardCvvInputId)

const cardHolderInputId = 'checkout-card-holder-input'
const cardHolderInputEl = document.createElement('input')
cardHolderInputEl.setAttribute('id', cardHolderInputId)
cardHolderInputEl.setAttribute('placeholder', 'Cardholder Name')

const submitButton = document.createElement('input')
const buttonId = 'submit-button'
submitButton.setAttribute('type', 'button')
submitButton.setAttribute('id', buttonId)
submitButton.value = 'Submit'

// Add them to your container
container.append(cardNumberInputEl, cardExpiryInputEl, cardCvvInputEl, cardHolderInputEl, submitButton)
async function configureCardForm() {
	const baseStyles = {
		height: 'auto',
		border: '1px solid rgb(0 0 0 / 10%)',
		borderRadius: '2px',
		padding: '12px',
		boxShadow: '0 4px 8px 0 rgba(0,0,0,0.2)',
	}

	// Create the payment method manager
	const cardManager = await headless.createPaymentMethodManager('PAYMENT_CARD')

	// Create the hosted inputs
	const { cardNumberInput, expiryInput, cvvInput } = cardManager.createHostedInputs()

	await Promise.all([
		cardNumberInput.render(cardNumberInputId, {
			placeholder: '1234 1234 1234 1234',
			ariaLabel: 'Card number',
			style: baseStyles,
		}),
		expiryInput.render(cardExpiryInputId, {
			placeholder: 'MM/YY',
			ariaLabel: 'Expiry date',
			style: baseStyles,
		}),
		cvvInput.render(cardCvvInputId, {
			placeholder: '123',
			ariaLabel: 'CVV',
			style: baseStyles,
		}),
	])

	// Set the cardholder name if it changes
	document.getElementById(cardHolderInputId).addEventListener('change', e => {
		cardManager.setCardholderName(e.target.value)
	})

	// Configure event listeners for supported events
	cardNumberInput.addEventListener('change', (...args) => {
		console.log('cardNumberInput changed', ...args)
	})

	cardNumberInput.focus()

	submitButton.addEventListener('click', async () => {
		// Validate your card input data
		const { valid } = await cardManager.validate()
		if (valid) {
			// Submit the card input data to Primer for tokenization
			await cardManager.submit()
		}
	})
}

See more options and events in the SDK API Reference

Step 4.b: Handle payment methods with native buttons

This applies to PayPal, Apple Pay and Google Pay.

Some payment methods require Primer to manage the payment method’s button and implementation, and only require you to display the button to your customer.

Follow this approach when PAYPAL, APPLE_PAY or GOOGLE_PAY is available as a payment method and provided via onAvailablePaymentMethodsLoad.

Render the button

Get started by creating the payment method manager:

Typescript
const paymentMethodManager = await headless.createPaymentMethodManager('PAYPAL') // or APPLE_PAY / GOOGLE_PAY

Then, create an instance of a payment method button and render it:

Typescript
// Create the button container
const payPalButton = document.createElement('div')
const payPalButtonId = 'paypal-button'
payPalButton.setAttribute('type', 'button')
payPalButton.setAttribute('id', payPalButtonId)

// Create and render the button
const button = paymentMethodManager.createButton()
button.render(payPalButtonId, {
	style: {
		buttonColor: 'silver',
	},
})

See additional style options in the SDK API Reference.

Handle button clicks (optional)

When the payment method button is clicked, Headless Universal Checkout automatically handles the rendering of the payment method screen and the payment. Based on the result of the payment, you should handle different callbacks.

  • If onCheckoutComplete is called, show a success message and hide the button.
  • If onCheckoutFail is called, show a failure message and allow the customer to try again.

You can also listen to the click event for logging or analytics purposes:

Typescript
button.addEventListener('click', () => {
	// React to click
	// E.g. send off analytics
})

Other button methods

The button object also supports other methods:

Typescript
// Hide the button
button.clean()

// Set the button to disabled
button.setDisabled(true | false)

// Focus the button (not supported for PayPal)
button.focus()

// Unfocus the button (not supported for PayPal)
button.blur()

Integration example snippet

Below is an example code snippet of how it all fits together.

Typescript
// Create your button container
const payPalButton = document.createElement('div')
const payPalButtonId = 'paypal-button'
payPalButton.setAttribute('type', 'button')
payPalButton.setAttribute('id', payPalButtonId)

function configurePayPalButton() {
	// Create the payment method manager
	const button = paymentMethodManager.createButton()

	// Render the button
	button.render(paypalButtonId, {
		style: {
			buttonColor: 'silver',
		},
	})
}

Step 4.c: Handle payment methods with redirect

Some payment methods require redirecting to another web page in order to capture payment details.

Headless checkout automatically renders that web page in a popup window in order to maintain the current context.

Get started by creating a payment method manager:

Typescript
const manager = await headless.createPaymentMethodManager(paymentMethod.type)

When the user has selected the payment method, call the start function. This function automatically:

  • opens a popup to present a loading indicator, then the payment method web page
  • shows an overlay on top of your page that prompts the user to focus on the popup
Typescript
myButton.addEventListener('click', () => {
	manager.start()
})

Make sure start is called synchronously after a click

start opens a popup. However, most browsers have strict popup-blocking rules to protect the user. One such rule is to only allow popups that result from a direct user action.

Therefore, to ensure that the popup is properly rendered, make sure to call this function immediately after a user click.

If the browser cannot open popup windows, the current window will be redirected instead.

To ensure maximum compatibility, read the guide on how to handle redirects.

Step 4.d: Handle Klarna

The Klarna payment method requires selecting a payment category before redirecting to capture payment details and finalize the payment.

You can create the interface for selecting a payment category before the redirect. Using an api function exposed by the payment method manager you can start the checkout, after the payment category has been selected and confirmed, and it will automatically render the payment web page in a popup window.

Get started by creating a payment method manager. You need to provide a callback function, onPaymentMethodCategoriesChange, in the options parameter of the createPaymentMethodManager function:

Typescript
const manager = await headless.createPaymentMethodManager(paymentMethod.type, {
	onPaymentMethodCategoriesChange,
})

Use the data sent through the onPaymentMethodCategoriesChange callback and use it to render a custom user interface for the user to be able to select payment categories. More details about the callback can be found here

Typescript
function onPaymentMethodCategoriesChange(paymentMethodCategories) {
	// Use the paymentMethodCategories sent through the onPaymentMethodCategoriesChange function and render a custom user interface to the user
	// This example assumes that you implement displayCategory that will render a custom user interface that will display the DOM structure for payment categories for Klarna
	for (let paymentMethodCategory in paymentMethodCategories) {
		displayCategory(paymentMethodCategory, handlePaymentCategoryClick)
	}
}

Klarna will need to render more details about the payment method inside a container. You need to provide a containerId, that belongs to a container in the DOM, which will later be used to render payment details about the selected payment method category. When a payment method category has been selected call the renderCategory function from the payment method manager. The renderCategory function uses the id of the selected payment method category, a containerId and an onHeightChange callback in order to render more details about the category in the selected container.

The onHeightChange callback will return a new height based on the selected payment method category. Use the new height to change the height of the container in which Klarna will render the payment category details. More details about the callback can be found here

Typescript
// This example assumes that you implement the handleHeightChange function that will update
// the height of the container in which Klarna will display the payment category details
function handlePaymentCategoryClick(paymentMethodCategoryId) {
	manager.renderCategory({
		paymentMethodCategoryId,
		containerId,
		onHeightChange: newHeight => {
			handleHeightChange(paymentMethodCategoryId, newHeight)
		},
	})
}

When the user has selected a payment category and has confirmed the start of the payment, call the start function. This function automatically:

  • opens a popup to present a loading indicator, then the payment method web page
  • shows an overlay on top of your page that prompts the user to focus on the popup
Typescript
function handleCategorySelected(selectedCategory) {
	manager.start({
		paymentMethodCategoryId: selectedCategory.id,
	})
}

Step 4.e: Handle Stripe ACH

The Stripe ACH payment method requires filling in a form with the customer’s first name, last name, and email, and then going through the Stripe interface to collect the bank account details. Once the bank details are collected, the mandate should be displayed together with a button so the customer can click to confirm it.

Optionally, if it’s an existing customer, the existing customer details can be used to pre-populate the input fields.

You can grab the details from the onClientSessionUpdate callback and store them on a variable, example:

Typescript
let initialCustomerDetails = { firstName: '', lastName: '', emailAddress: '' };

await Primer.createHeadless(clientToken, {
	onClientSessionUpdate(clientSession) {
		initialCustomerDetails.firstName = clientSession.customer?.firstName ?? '';
		initialCustomerDetails.lastName = clientSession.customer?.lastName ?? '';
		initialCustomerDetails.emailAddress = clientSession.customer?.emailAddress ?? '';
	}
})

The customer client session property can be undefined.

The onClientSessionUpdate is always called once before the onAvailablePaymentMethodsLoad, so the payment method manager can be created as usual.

When creating the ACH payment method manager, the stripePublishableKey needs to be provided, and optionally, the onCollectBankAccountDetailsComplete callback.

Typescript
const manager = await headless.createPaymentMethodManager(paymentMethod.type, {
	stripePublishableKey: 'pk_...',
	onCollectBankAccountDetailsComplete,
})

// Display your own form with the first name, last name and email input fields

After creating the manager and displaying the form, the data can be submitted.

In case something is wrong, a validation result will be returned, and if there’s no validation error, the collectBankAccountDetails method can be called to start the Stripe flow.

Typescript
const validationError = await manager.start({ firstName, lastName, emailAddress })

if (validationError) {
	// Handle the validation errors updating the form accordingly
} else {
	await manager.collectBankAccountDetails()

	// Display the mandate text and the Confirm button
}

Alternatively, instead of awaiting for the collectBankAccountDetails promise to be resolved, the onCollectBankAccountDetailsComplete callback can be set up to display the mandate screen.

Typescript
function onCollectBankAccountDetailsComplete() {
	// Display the mandate text and the Confirm button
}

Once the customer clicks the confirm button, the confirmMandate method can be called.

Typescript
await manager.confirmMandate()

// Display a success screen