@payfanout/adapter-adyen
v1.1.3
Published
Client-side Adyen adapter for PayFanout (Adyen Web Card component, hosted iframe fields, tokenize-first). No secrets, no server code.
Readme
@payfanout/adapter-adyen
Client-side Adyen adapter for PayFanout: the Adyen Web Card component (card data captured inside Adyen's hosted iframes, SAQ-A eligible), tokenize-first.
No secrets. This package ships to the browser and holds only the public
clientKey, whose origins you allowlist in the Adyen Customer Area.
It implements the ClientPaymentAdapter contract from @payfanout/core, so
@payfanout/react renders it through the same <PaymentFields> / <PayButton> as every
other PSP.
📖 Documentation: https://donapulse.github.io/payfanout/ · Set up Adyen · React usage
Installation
pnpm add @payfanout/react @payfanout/adapter-adyen react react-domAdyen Web is not an npm dependency; the adapter injects the pinned build lazily from Adyen's CDN on first mount.
Usage
import { PayFanoutProvider, PaymentFields, PayButton } from "@payfanout/react";
import { AdyenClientAdapter } from "@payfanout/adapter-adyen";
const adyen = new AdyenClientAdapter({
clientKey: import.meta.env.VITE_ADYEN_CLIENT_KEY,
environment: "sandbox",
countryCode: "NL",
});
<PayFanoutProvider adapters={[adyen]} initialPsp="adyen" completionEndpoint="/api/complete">
<PaymentFields clientSecret={session.clientSecret} />
{/* completionEndpoint finishes the tokenize-first flow automatically — no onServerCompletion. */}
<PayButton onResult={(result) => showOutcome(result)}>Pay</PayButton>
</PayFanoutProvider>environmentselects the value handed to Adyen Web (sandbox → test,live → live), the CDN host Adyen Web loads from, and the client key it takes:test_…on sandbox,live_…on live. A live account served from another region setsadyenEnvironmenttolive-us,live-au,live-nea,live-inorlive-apse(compared case-insensitively), and Adyen Web then loads from that region's host,checkoutshopper-{value}.cdn.adyen.com, as Adyen requires.live-apseis accepted because Adyen Web types it and maps it to its own hosts, which serve the pinned build, though Adyen's v6 integration guides do not list it; check it with Adyen before going live on it. Nothing is inferred: the constructor throwsinvalid_requestfor a client key that does not start withtest_on sandbox orlive_on live (a legacy origin key included), for anadyenEnvironmentthat contradictsenvironment, and for one Adyen Web does not support.countryCodeis required — Adyen Web takes it on the checkout instance.- The session's
clientSecretis the server adapter's signed session token; the adapter reads its payload half (amount and currency) so Adyen Web shows the right figures. It never needs the signing key. confirm()resolves{ status: "requires_confirmation", clientToken }whereclientTokenis a JSON envelope taken from Adyen Web's state: the encrypted card blob (paymentMethod) and the browser data 3-D Secure 2 uses (browserInfo,origin,billingAddress,riskData), nothing else. The host passes it to the server'scompletePayment—<PayButton>/completionEndpointwire this automatically. Only the matching major release of@payfanout/adapter-adyen-serverdecodes the envelope, so upgrade the server adapter first.
Customization
options.appearance becomes Adyen's styles object (base, error, placeholder,
validated) and options.fieldOptions passes through to the Card component untouched — the
host wins on conflicts, except the two keys the adapter must own: showPayButton (forced to
false, since your <PayButton> drives submission) and onChange (where the encrypted blob
and the validity stream arrive). options.locale sets the SDK's locale for that mount.
sdkVersion loads another Adyen Web build from Adyen's CDN, and sdkUrl / stylesheetUrl
load the script / stylesheet from your own host. The adapter carries Adyen's integrity hashes
for its pinned build only, so an overridden file loads without an integrity check:
sdkVersion turns it off for both files, sdkUrl and stylesheetUrl for their own.
cspNonce puts your Content-Security-Policy nonce on both tags the adapter injects, the
script and the stylesheet <link>, for a page whose script-src or style-src allows them
by nonce; the adapter never reads a nonce from the page. See the
Adyen guide for what it covers.
3-D Secure
A challenge comes back from the server: completePayment reports requires_action with
Adyen's action object on PaymentInfo.raw, and an empty pspPaymentId when Adyen answers
without a pspReference (its native 3-D Secure 2 example does). Hand it to
handleAction(handle, action): Adyen Web replaces the card fields with the threeDS2
fingerprint or challenge and runs it inline, and the result is a fresh clientToken —
the onAdditionalDetails data, { details: { threeDSResult } } — which the host completes
with exactly as it did the first one. That answer can carry another action; handle it the
same way. handleAction is Adyen-specific — the unified contract has no action step, because
most PSPs resolve challenges inside confirm().
Adyen can still choose its redirect flow. handleAction then navigates the page to Adyen,
which sends the shopper back to the session's returnUrl with a redirectResult query
parameter; adyenRedirectResultToken(redirectResult) turns it into the clientToken the
return page completes the payment with, within the signed session's expiry.
One challenge runs at a time per mounted field set: calling handleAction again while one
is outstanding resolves { status: "failed" } with invalid_request rather than replacing
the pending resolver, which would strand the first caller's promise. The promise settles
when Adyen reports the shopper's details, or as failed when Adyen Web reports an error
through onError first — authentication_required, or a retryable psp_unavailable when
Adyen Web names it a network or script error, or its message reads as one — or when the
fields are unmounted first. Race it against your own timer if you need a deadline on an
abandoned challenge. Once handleAction has run, the card fields are gone: confirm() on
that handle resolves failed with invalid_request, and the fields must be remounted to pay
again.
Notes
- Card data is captured only inside Adyen's hosted iframes; there is no raw card input, and no PAN/CVV ever touches your DOM.
onChangefires once with{ complete: false, empty: true }on mount, then with{ complete }on every SDK validity change, so "disable Pay until complete" works out of the box.- The adapter loads Adyen Web 6.45.2 (
ADYEN_WEB_VERSION) with the Subresource Integrity hashes Adyen publishes for it andcrossorigin="anonymous", so the browser refuses a modified script or stylesheet. A<script>your page adds itself for the default URL needs the sameintegrity, exported asADYEN_WEB_SCRIPT_INTEGRITY, and acrossoriginattribute: whilewindow.AdyenWebis not defined yet, a conflicting tag makesloadSdk()reject withinvalid_request. A stylesheet<link>your page adds for the default URL is used as it is, so give itADYEN_WEB_STYLESHEET_INTEGRITYand acrossoriginattribute to keep the check. - If the script fails to load, the next mount fetches it again; if it loaded without
defining
window.AdyenWeb, the next mount checks again instead of failing from a cached result. A stylesheet that failed stays on the page and is reused, and it never blocks the fields from mounting. The mount does wait for the stylesheet to load or fail, and for the sheets it@imports: Adyen'sadyen.cssimports none, but astylesheetUrlof your own that imports from a slow host delays the first mount on each page load.
Documentation
License
MIT
