Adyen integration
This guide explains how Adyen Express Checkout (Google Pay and Apple Pay) is implemented in a Checkout API storefront.
What this integration provides
The reference implementation mounts both Google Pay and Apple Pay through Adyen Web Components and runs the payment flow through Checkout API payment endpoints.
In your own project, create an express checkout component that follows the same flow described in this guide: widget configuration loading, wallet initialization, payment submission, confirmation handling, and failure routing.
Page responsibilities
Your storefront should define pages for the final payment states:
- Success page: payment is complete, no further action is needed, and the page should load and render the order data.
- Confirmation page: used for the 3DS/additional details flow, where you call
paymentResultto resolve the final payment outcome. In the flow shown in this legacy example, the shopper is also redirected here after a standard successful payment. - Failure page: payment was not processed correctly, and the page should display error handling and a clear way for the shopper to retry the payment flow.
Prerequisites
Before integrating:
- Adyen Drop-in must be available in checkout payment methods.
- Your storefront must implement confirmation and failure pages, and optionally a dedicated success page if your checkout uses one.
Install package:
npm install @adyen/adyen-web
Integration steps
1) Load express payment configuration
The implementation loads Adyen config with useAdyenPaymentConfiguration(...) and passes:
type(express_checkout_adyen)returnUrl(your confirmation page URL in case the 3ds uses redirection flow)amountin minor unitslineItemsfor wallet sheet display
The backend call uses Checkout API widgets configuration mode (configurationOnly: true), with shared secret and session token.
When parsing the response, match the requested plugin type to the response item name to get the correct widget/configuration entry.
const requestedType = 'express_checkout_adyen';
const pluginResponseByName = Object.values(response)
.flat()
.find(plugin => plugin.name === requestedType);
const config = pluginResponseByName?.contents ? JSON.parse(pluginResponseByName.contents) : undefined;
For PDP usage, startup amount includes product price when that product is not yet in selection.
2) Create shared AdyenCheckout instance
Both wallets use one shared AdyenCheckout instance:
const checkout = await AdyenCheckout({
amount: { currency: data.paymentAmount.currency, value: data.paymentAmount.amount },
clientKey: data.clientKey,
countryCode: data.country,
environment: data.context,
locale: data.languageCode,
paymentMethodsResponse: data.paymentMethodsResponse,
onPaymentCompleted: () => window.location.assign(returnUrl),
onError: handleOnError,
});
Shared event behavior:
onPaymentCompleted- redirect to your confirmation page
onError- optional Ingrid workaround reset (
requestPaymentFieldsUpdate({ additionalFields: { expressCheckout: false } })) - cancellation cleanup: remove auto-added PDP line with
requestSetQuantity({ quantity: 0 })
- optional Ingrid workaround reset (
3) Set up Google Pay from shared checkout
Create GooglePay(checkout, config) and mount it.
const googlePay = new GooglePay(checkout, {
isExpress: true,
callbackIntents: ['SHIPPING_ADDRESS', 'SHIPPING_OPTION'],
paymentDataCallbacks: {
onPaymentDataChanged: async ({ callbackTrigger, shippingAddress, shippingOptionData }) => {
if (callbackTrigger === 'INITIALIZE') {
await prepareSelectionForExpressCheckout();
return {};
}
if (callbackTrigger === 'SHIPPING_ADDRESS') {
return await recalculateForShippingAddress(shippingAddress!);
}
if (callbackTrigger === 'SHIPPING_OPTION') {
return await recalculateForShippingOption(shippingOptionData!.id);
}
return {};
},
},
onAuthorized: (payload, actions) => {
const ok = mapAndValidateGoogleAddresses(payload.authorizedEvent);
if (!ok) return actions.reject();
actions.resolve();
},
onSubmit: (state, _component, actions) => {
void submitPayment(state, actions, currentBillingAddress, currentShippingAddress);
},
});
Google Pay event to API mapping:
onPaymentDataChanged(INITIALIZE)- triggered when Google Pay initializes the payment sheet
- use this step to prepare the selection for Express Checkout before the shopper continues
- call
GET /selectionto get the current selection state, orPUT /payment-fieldswhen the Ingrid workaround is enabled - if you are handling PDP Express Checkout and the item is not yet in the selection, call
POST /items/{item}to add it on the fly - if needed, set the initial shipping method with
PUT /shipping-methods/{shippingMethod}
onPaymentDataChanged(SHIPPING_ADDRESS)- triggered when the shopper provides or changes the shipping address in the Google Pay sheet
- use this callback to recalculate totals and available shipping methods for the selected address
- call
POST /paymentwithpaymentInitiateOnly: true - refresh totals and shipping methods from
GET /selection
onPaymentDataChanged(SHIPPING_OPTION)- triggered when the shopper selects a shipping option inside the Google Pay sheet
- use it to persist the selected shipping method and refresh totals
- call
PUT /shipping-methods/{shippingMethod}
onAuthorized- triggered after the shopper authorizes the payment in Google Pay
- no Checkout API call is needed here
- validate email, billing, and shipping addresses, map Google Pay address payloads into the structure your payment submission expects, and save those addresses for the later
onSubmitpayload
onSubmit- triggered when Adyen submits the final Google Pay payment data
- use this as the final payment step
- call
POST /paymentwith the final wallet payload
4) Set up Apple Pay from shared checkout
Create ApplePay(checkout, config), then mount only if isAvailable() resolves.
const applePay = new ApplePay(checkout, {
isExpress: true,
onClick: async (resolve, reject) => {
try {
await prepareSelectionForExpressCheckout();
resolve();
} catch {
reject(new Error('Unable to prepare Apple Pay checkout'));
}
},
onShippingContactSelected: async (resolve, reject, event) => {
try {
resolve(await recalculateForShippingContact(event.shippingContact));
} catch {
reject();
}
},
onShippingMethodSelected: async (resolve, reject, event) => {
try {
resolve(await recalculateForShippingMethod(event.shippingMethod.identifier));
} catch {
reject();
}
},
onAuthorized: (payload, actions) => {
const ok = mapAndValidateAppleAddresses(payload.authorizedEvent.payment);
if (!ok) return actions.reject();
actions.resolve();
},
onSubmit: (state, _component, actions) => {
void submitPayment(state, actions, currentBillingAddress, currentShippingAddress);
},
});
Apple Pay event to API mapping:
onClick- triggered after the shopper clicks the Apple Pay button, before the payment sheet is shown
- use this step to prepare the selection for Express Checkout
- call
GET /selectionto get the current selection state, orPUT /payment-fieldswhen the Ingrid workaround is enabled - if you are handling PDP Express Checkout and the item is not yet in the selection, call
POST /items/{item}to add it on the fly
onShippingContactSelected- triggered when the shopper provides or changes the shipping address in the Apple Pay sheet
- use this callback to recalculate totals and available shipping methods for the selected address
- if country is unsupported, return
ApplePayError('shippingContactInvalid', ...) - otherwise call
POST /paymentwithpaymentInitiateOnly: true - refresh totals and shipping methods from
GET /selection
onShippingMethodSelected- triggered when the shopper selects a shipping method inside the Apple Pay sheet
- use it to persist the selected shipping method and refresh totals
- call
PUT /shipping-methods/{shippingMethod}
onAuthorized- triggered after the shopper authorizes the payment in Apple Pay
- no Checkout API call is needed here
- validate the billing and shipping data, map Apple Pay address payloads into the structure your payment submission expects, and save those addresses for the later
onSubmitpayload
onSubmit- triggered when Adyen submits the final Apple Pay payment data
- use this as the final payment step
- call
POST /paymentwith the final wallet payload
5) Handling payment
Handle the standard payment flow first.
Happy path:
onSubmitcallsPOST /payment.- The payment payload includes the resolved billing and shipping addresses, the selected
paymentMethod,paymentMethodSpecificFields: { ...state.data },paymentReturnPagepointing to your confirmation page, andpaymentFailedPagepointing to your failure page. - If no additional action is required, Adyen triggers
onPaymentCompleted. onPaymentCompletednavigates the user to your confirmation page.- On your confirmation page, call
POST /payment-resultand render the placed order.
Example payment-result request used on the confirmation page:
await requestPaymentResult({
...resolveBrackets(paymentData),
});
Event payload to REST mapping samples
Google Pay SHIPPING_ADDRESS -> POST /payment (init-only update):
const address = {
city: shippingAddress.locality,
country: shippingAddress.countryCode,
state: shippingAddress.administrativeArea,
zipCode: shippingAddress.postalCode,
};
await requestPayment({
address,
shippingAddress: address,
paymentMethod: paymentConfigData.paymentMethod,
paymentInitiateOnly: true,
paymentReturnPage: createAbsoluteURL('confirmation'),
paymentFailedPage: createAbsoluteURL('failure'),
termsAndConditions: true,
});
Google Pay SHIPPING_OPTION -> PUT /shipping-methods/{shippingMethod}:
await requestSetShippingMethod({
shippingMethod: shippingOptionData.id,
});
Apple Pay onShippingContactSelected -> POST /payment (init-only update):
const address = {
city: shippingContact.locality,
country: shippingContact.countryCode,
state: shippingContact.administrativeArea,
zipCode: shippingContact.postalCode,
};
await requestPayment({
address,
shippingAddress: address,
paymentMethod: paymentConfigData.paymentMethod,
paymentInitiateOnly: true,
paymentReturnPage: createAbsoluteURL('confirmation', { express: 'true' }),
paymentFailedPage: createAbsoluteURL('failure'),
termsAndConditions: true,
});
Wallet onSubmit (final payment) -> POST /payment:
await requestPayment({
address: billingAddress,
shippingAddress,
paymentMethod: paymentConfigData.paymentMethod,
paymentMethodSpecificFields: { ...state.data },
paymentReturnPage: createAbsoluteURL('confirmation'),
paymentFailedPage: createAbsoluteURL('failure'),
termsAndConditions: true,
});
Handling 3D Secure flow
Use a dedicated callback flow for 3DS/additional details when Adyen requires an extra shopper step after POST /payment.
- Wallet submission starts with
POST /payment. - If Adyen requires 3DS,
onAdditionalDetailsis triggered. - In
onSubmit, detect whether the payment response requires an additional shopper step:
const response = await requestPayment({
address: adyenBillingAddress,
paymentFailedPage: createAbsoluteURL('failure'),
paymentMethod: paymentConfigData.paymentMethod,
paymentMethodSpecificFields: state.data as Record<string, unknown>,
paymentReturnPage: createAbsoluteURL('confirmation'),
shippingAddress: adyenShippingAddress,
termsAndConditions: true,
});
const formFields: Record<string, string> = response.formFields ?? {};
if (response.action === 'javascript') {
actions.resolve({
action: formFields.action as unknown as PaymentAction,
resultCode: formFields.resultCode as ResultCode,
});
} else {
actions.resolve({
resultCode: formFields.resultCode as ResultCode,
});
}
A javascript action means that Adyen requires an additional step such as 3DS. After that step is completed, onAdditionalDetails is called.
4. Post the callback payload from onAdditionalDetails to your confirmation page:
const handleOnAdditionalDetails = (state: AdditionalDetailsData) => {
function flattenForPost(dataToFlatten: unknown, prefix: string): Record<string, string> {
if (!dataToFlatten || typeof dataToFlatten !== 'object' || Array.isArray(dataToFlatten)) {
return {};
}
const result: Record<string, string> = {};
Object.entries(dataToFlatten as Record<string, unknown>).forEach(([key, value]) => {
const flatKey = prefix ? `${prefix}[${key}]` : key;
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
Object.assign(result, flattenForPost(value, flatKey));
} else if (value !== undefined) {
result[flatKey] = String(value);
}
});
return result;
}
const form = document.createElement('form');
form.method = 'post';
form.action = returnUrl;
const flattenedItems = flattenForPost(state.data, '');
Object.entries(flattenedItems).forEach(([name, value]) => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = name;
input.value = value;
form.appendChild(input);
});
form.setAttribute('style', 'position:absolute;left:-100px;top:-100px;');
document.body.appendChild(form);
form.submit();
};
- Navigate the user to your confirmation page, where the callback payload is parsed and
POST /payment-resultis called. - After
payment-resultresolves:- render the placed order on success
- redirect to your failure page on error
Validation checklist
- Google Pay and Apple Pay are rendered only when configuration is available.
- PDP flow adds missing item once and tracks added line id for rollback.
- Shipping address and shipping option callbacks update totals in wallet sheet.
onAuthorizedrejects invalid/missing address data.- Cancellation removes auto-added PDP item line.
- your confirmation page is reachable and correctly handles both standard completion and 3DS/additional details callbacks.
- your failure page is reachable and handles retry/recovery correctly.
Ingrid compatibility
Unfortunately, in its current state the Ingrid plugin is not usable with Express Checkout. Ingrid has its own frontend widget that the customer needs to interact with in order to get the shipping options. There is no way to make it compatible with the Express Checkout widget. This means that orders placed via the Express Checkout widget can only utilize the standard Centra shipping options. Moreover, if the plugin is enabled, it will interfere with the Express Checkout orders. It will communicate with Ingrid and also update the selection with default shipping option and other relevant attributes. There are three places where such interference happens:
- When initializing the selection with items
- Submitting address submission
- Finalizing the payment
Because we cannot disable this behaviour by default, a workaround was required. These are the necessary steps you will need to perform in order for Express Checkout to not be negatively impacted by an enabled Ingrid v2 plugin:
-
Configure shipping options in Centra.
-
When initializing Express Checkout, fetch selection/checkout data through the Ingrid-aware path:
If the Ingrid workaround is disabled, use the standard selection endpoint:
const selection = await requestSelection();If the Ingrid workaround is enabled, use the payment fields endpoint instead:
const selection = await requestPaymentFieldsUpdate({
additionalFields: {
expressCheckout: true,
},
});After initialization, fetch the first shipping method from the selection response and perform the
setShippingMethodmutation with{ id: $shippingMethodId }. -
After initialization, keep the Express Checkout flow outside of Ingrid and use only the standard Centra shipping methods for the order.
With these steps in place the Express Checkout order will be placed outside of Ingrid. Clients that have a hard dependency on third-party Delivery Experience systems for order fulfilment can consider building custom logic that receives the order data from Centra and in Ingrid case uses the Ingrid API to book the shipping.