Writing a PayFanout adapter for a new PSP
Adding a PSP means shipping one or two new packages, no changes to @payfanout/core, @payfanout/server, @payfanout/react, or any consuming app. You are done when your adapter passes @payfanout/conformance, the same suite every shipped adapter passes (they are listed in Payment providers). This guide assumes you have never seen the core internals; the contracts in @payfanout/core plus this document are the whole interface.
0. Decide your PSP's completion shape first
This is the one architectural decision, everything else is mapping. Ask: how does a payment reach its terminal state? The shipped adapters cover three shapes:
| Confirm-on-client (Stripe, PayZen) | Tokenize-first (Paysafe, PayPal, Worldline, Adyen) | Redirect / hosted (GoCardless) | |
|---|---|---|---|
| Server session call | creates the PSP intent object | may create nothing, see "stateless sessions" | creates the PSP object + hosted URL |
Client confirm() | finalizes, returns terminal status | returns requires_confirmation + clientToken | navigates to the hosted flow |
completePayment | omit the method entirely | required, finalizes with the clientToken | omit (the PSP fulfils on its side) |
| Capability flag | requiresServerCompletion: false | requiresServerCompletion: true | false, every method flow: "redirect" |
PayPal is tokenize-first with a popup approval instead of hosted fields (the clientToken is the approved order id); GoCardless proves a PSP with no embeddable fields at all still fits — the client adapter's confirm() hands off to the hosted authorisation page and handleRedirectReturn finishes the trip. Declare what you are; PaymentService and <PayButton> already handle every shape.
Stateless sessions: PayFanout persists nothing. If your PSP needs data at completion time that only existed at session time (amount, currency, account id), encode it into pspSessionId as a signed self-contained token — the pattern lives in packages/adapter-paysafe-server/src/session-context.ts (HMAC prevents the browser tampering with the amount; the client adapter reads the payload half without the key). Embed an expiry and enforce it on every decode: expired tokens reject with code: "session_expired" (hosts recover by creating a fresh session), a signed token must never stay completable forever. Build on core's WebCrypto helpers (hmacSha256, constantTimeEqual, the base64url family) instead of node:crypto/ Buffer so your server adapter also runs on edge runtimes — every REST adapter has a test guarding against Node builtins sneaking back in (the Stripe adapter is the one Node-only exception, its SDK requires Node).
1. Server adapter (@payfanout/adapter-<psp>-server)
Implement ServerPaymentAdapter from @payfanout/core. Rules that the conformance suite and PaymentService will hold you to:
- Config: require explicit
environment: "sandbox" | "live"(never inferred from key prefixes), explicit API version if your PSP has one, and an injectable transport (client/fetch) so tests run against an in-memory fake. If merchant accounts vary by currency/country, take a resolver function, not a single id. - Transport: compose core's transport primitives —
requestWithTimeout(the timer covers the whole exchange INCLUDING the response body read; a stalled body must time out, not hang the host),withTransportRetries(never retry business rejections),isTransportRetryable,safeJson. ExposerequestTimeoutMs,maxNetworkRetries, and asleeptest seam like every shipped REST adapter. - Amounts: integer minor units at every boundary, both directions. Use
getCurrencyExponent/assertMinorUnitAmountfrom core. PSP-specific quirks (e.g. Stripe's three-decimal multiples-of-10) stay inside your adapter and reject withinvalid_request, never leak them to callers. - Currencies you report come from the PSP record, else from a source your adapter trusts (the currency it sent, or one another record of the same payment states), else core's
NO_CURRENCY("XXX", ISO 4217's "no currency"): never a plausible guess such as USD. Read them with core'sfirstCurrencyCode(own, fallback, …), which takes the first candidate that is three letters once trimmed and uppercased, so an empty or malformed code falls through to the next source; scale the amount by that same reading. Never send an amount in a guessed currency either: when no record states the currency, refuse the call withinvalid_requestbefore sending, unless the amount is one the PSP itself reported and its API makes the currency optional on that call (a full refund of the PSP's own remaining amount), which then goes without one. An amount the PSP reports with no currency anywhere reads withNO_CURRENCY's default exponent under a record that reportsNO_CURRENCY, so a call that went through never fails on its answer; a record with no currency field to flag it (a refund read) refuses it instead. - Hard currency constraints go in
capabilities.supportedCurrencies(uppercase ISO 4217; omit when unrestricted). The router pre-screens candidates with it — a declared constraint means a mismatched payment skips your PSP instead of aborting the failover cascade on your local rejection. Keep the local validation as defense. - Currencies you refuse to send go in
capabilities.unsupportedCurrencies(uppercase ISO 4217; omit when there are none), for a PSP that takes too many currencies to list insupportedCurrencieswhile your adapter refuses a few, such as a currency the PSP prices with another exponent than ISO 4217; you may declare both lists. The router pre-screens session creation with it exactly as withsupportedCurrencies, andPaymentServicerefuses such a session with a non-retryableunsupported_operationbefore calling you; every other call (updates, saved-method charges, native subscriptions) still meets your own refusal, so keep it. Declare a currency when you refuse every amount you would send in it. That is a trade-off: screening also refuses zero-amount sessions in the currency, even ones your adapter still serves because they send no amount, and your changeset must say so. A refusal of only some non-zero amounts (Stripe's MGA amounts that are not whole ariary) stays a local check. Derive the list from the constant or rule your local refusal reads so the two cannot drift. Registration rejects an entry that can never match (not a string, or not three letters once trimmed and uppercased), a currency in both lists, and a supported rail whosecurrenciesall sit on this one; the conformance suite also fails an entry that works but is not written as its bare uppercase code, as it does for the other currency lists. - Per-rail currency constraints go in the same shape one level down, on the method:
paymentMethods: [{ type: "sepa_debit", flow: "embedded", supported: true, currencies: ["EUR"] }]. Absent or empty means unrestricted, exactly assupportedCurrenciesreads, and the PSP-wide list still applies on top. Declare it for any rail that settles in fixed currencies (SEPA/EUR, Bacs/GBP, PAD/CAD) — a guard you keep private instead makes a CAD-only rail look available for a USD payment, so the router cannot fail over to a PSP that could have served it. Same rule as above: declare it and keep the local check, since a host can drive the adapter without the router and can overridepaymentMethodswholesale. Derive both from one constant so they cannot drift. A rail gated to currencies yoursupportedCurrenciesexcludes is unroutable andvalidateAdapterCapabilitiesrejects it. - Per-rail country constraints are the customer-side sibling:
countries: ["GB"](uppercase ISO 3166-1 alpha-2) on a rail only customers in those countries can pay with — Bacs needs a UK bank account, Interac a Canadian one. Screening consults it only when the session statescustomerCountry; absent input screens nothing, so declaring it never hides your PSP from a host that doesn't know the customer's country. Declare only what the provider documents as a country or a short closed list; a zone rail (SEPA) stays undeclared — zone membership drifts, and a stale list would screen out valid payments. - Errors: every rejection is a
PayFanoutErrorwith a taxonomycode, a user-safemessage(use core'sgetUserMessage(code)catalog — never a third English variant), an honestretryable,pspName, and the untouched PSP error onraw. Even locally-generated failures setrawto something diagnostic. Map at least: declines (+insufficient_funds/expired_card/invalid_card_data/authentication_required/fraud_suspected), 429 →rate_limited, 5xx/network →psp_unavailable, 4xx →invalid_request(core'sclassifyHttpFallbackis that tail). Retryable semantics are contract, not taste:rate_limited/psp_unavailableare always retryable,authentication_requiredNEVER is (the customer comes back on-session) — the conformance suite asserts both.psp_unavailable,rate_limitedandunknownalready mean the call may have taken effect: callers retry them only under the sameidempotencyKey, and the subscription engine never moves such a renewal to a new key. When a money-moving call may have taken effect although you report it with a code that would otherwise read as definitive (aprocessing_errorfor an answer you could not read back, a refusal you cannot resolve), setoutcomeUnknown: true, which callers treat the same way. A PSP's refusal of a reused key (Stripe'sidempotency_error, a duplicatemerchantRefNum), or a replay of its first answer that the adapter refuses (Adyen answering a reused key with the first request's stored response), proves the key's first request ran, so it carries the flag too. Reading that request back lifts the flag only when it shows the call's own request, which then answers the call, or one that finally moved no money (failed, voided, cancelled, expired). Anything else read back, a payment that went through, one still in progress, a modification the PSP has only acknowledged, may be the money the call was meant to move, and a caller told to use a new key would move it twice. - Idempotency:
idempotencyKeyis REQUIRED on every mutating call — session creation, completion, refunds, capture, cancel, and verification included. Forward it through your PSP's mechanism (Stripe:Idempotency-Keyrequest option; GoCardless:Idempotency-Keyheader; PayPal:PayPal-Request-Id). Check whether that mechanism replays a duplicate or rejects it. Paysafe'smerchantRefNumunderdupCheckrejects the repeat (409, error5031) instead of answering with the original, so the Paysafe adapter never re-sends a write blindly: after a timeout, a 5xx or a duplicate rejection it reads the original back through Paysafe's?merchantRefNum=lookups, and a payment, capture or refund that cannot be read back fails as "retry later with the same key" instead of going out again. Unless your PSP documents that its channel absorbs a duplicate still in flight, do the same. Where the instrument is single-use (a Paysafe payment handle refuses a second charge), that refusal already guards the replay, and a duplicate check on top would block a new card after a decline under a stable per-order key. Where the adapter mints the instrument itself on each attempt (Paysafe's bank debits), a replay carries a new one, so the duplicate check stays on until a failed attempt shows under the key. If your PSP has no idempotency channel (PayZen), document how its state machine makes replays safe instead. - Host id round-trip: when
input.idis present, stamp it into PSP metadata (payfanout_id) if your PSP supports metadata, and prefer it forPaymentInfo.id. Echo the stored metadata onPaymentInfo.metadata. If your PSP genuinely cannot (Paysafe strict-rejects extra fields; the id rides the signed token only), declare it honestly in the conformance fixtures viamoney.expectations— never fake it. - Money truth on
PaymentInfo: reportamountRefundedfaithfully (callers derive refund state viagetRefundState— never invent a "refunded" status), and populateamountCaptured/amountCapturablewherever your PSP reports settlement state — partial and multi-capture flows are invisible without them. If you declaresupportsRefundRetrieval, you MUST implementretrieveRefund(refundId); async refunds return"pending"and hosts poll them to a terminal state. - Readback and modification shape — declare what your PSP actually gives you.
supportsPaymentRetrieval↔retrievePayment,supportsRefundRetrieval↔retrieveRefund(which also requiressupportsRefunds), andmodificationOutcomesays whether capture/cancel/refund answer with the real result ("synchronous") or only acknowledge the request ("asynchronous"). A push-only provider — one that accepts its payment reference as a write target, exposes no read for a payment or a refund, and reports every outcome by webhook — declares both retrieval flagsfalseandmodificationOutcome: "asynchronous"; itscancelPaymentandcapturePaymentresolve"processing", never a terminal state the PSP has not confirmed. The conformance suite gates its readback assertions on these flags, so declaring them honestly is what makes the suite provable rather than aspirational — the shipped adapters all read their PSP back and are"synchronous". An"asynchronous"adapter is excused from rejecting an over-refund locally (with no read it cannot know the remaining balance), but it MUST still surface the provider's own rejection as aPayFanoutErrorwhen one arrives — never fold it into a"pending"acknowledgement. DeclaringsupportsRefundRetrieval: falsewhile returning"pending"refunds leaves the refund lifecycle with nothing the suite can settle, so take that combination deliberately: it says the refund outcome reaches the host over webhooks and nowhere else. - Receipt-grade facts: populate
PaymentInfo.paymentMethodDetails({ brand, last4, wallet?, expMonth?, expYear? }, lowercase brand) once your PSP reports the instrument, andmandateReferencefor debit rails (SEPA/ACH/BACS), hosts must never have to dig intorawfor "Visa •••• 4242". - Checkout fields: map
statementDescriptor/receiptEmail/shippingDetails/scato whatever your PSP accepts; validate locally what you can (lengths, charsets) and withhold fields your PSP's endpoint rejects rather than failing the payment. - Optional surfaces, gated by capability flags (declare
trueonly if implemented,PaymentServiceenforces coherence at registration via core'svalidateAdapterCapabilities):retrievePayment↔supportsPaymentRetrieval,retrieveRefund↔supportsRefundRetrieval(which also requiressupportsRefunds),updatePaymentSession↔supportsSessionUpdate(in-place amend or re-issued signed token, callers always continue with the returned session),fetchEvents↔supportsEventPolling(missed-webhook recovery),listPayments/listRefunds↔supportsListing, andsupportsMultiCapture(requiressupportsManualCapture; every partial capture is its own charge with its own idempotency key). The two retrieval pairs are enforced in BOTH directions — implementing a read while declaring its flagfalseis a violation too, because that flag switches off conformance assertions rather than merely describing the provider. If a modification needs to re-read the payment internally, keep that helper private: the flag is checked against the contract METHOD name, not against your ability to fetch. - PSP-native subscriptions — the
nativeSubscriptionsblock is REQUIRED and per-operation:{ list, retrieve, create, cancel }, each flag pairing with its method (listNativeSubscriptions↔list,retrieveNativeSubscription↔retrieve,createNativeSubscription↔create,cancelNativeSubscription↔cancel). All-false is the explicit "this PSP has no native billing product" declaration — never omit the block. Declare only what the provider's own product supports (no list API →list: false; approval-gated creation →create: false). Records normalize intoNativeSubscriptionRecord: integer minor-unit amounts, the unified status union (unmappable provider states →"unknown", never dropped), cadence asinterval/intervalCountor the source RRULE onschedule— reject cadences your PSP cannot express (invalid_request) instead of approximating.cancelNativeSubscriptionmust be VERIFIED-IDEMPOTENT: on a cancel rejection, re-fetch and resolve an already-terminal subscription as success, rethrowing the original error otherwise — the conformance suite replays every cancel. Any flag true ⇒ supply thenativeSubscriptionsconformance fixtures (createInputfor create-capable adapters;seedSubscriptionswhencreateis false). - Vaulting (
supportsSavedPaymentMethods) is optional and must be honest. Card rails that can vault should (Stripe, Paysafe, PayZen candidates); rails that cannot meet the contract's instantly-succeeded off-session charge declarefalse— as GoCardless (async bank debits) and PayPal (v3 vault is future work) do today. Declaring it demands the full surface:createCustomer,listSavedPaymentMethods,deleteSavedPaymentMethod(keyed by the TOKEN, resolve PSP-internal ids inside the adapter),chargeSavedPaymentMethod(honest credential-on-fileoccurrencemapping), plussavePaymentMethodif you are tokenize-first. Cards live at the PSP ONLY. Save-during-checkout: honor sessioncustomer+savePaymentMethodand surface the stored token onPaymentInfo.savedPaymentMethodToken. The conformance suite runs a customer→save→list→charge×2→delete round-trip against your fake. - Verification without vaulting: if zero-amount verification on your PSP creates a stored instrument (Stripe's SetupIntent does), you must guarantee cleanup on every path, success, failed verification, and error, and fail loudly if cleanup fails. Otherwise set
supportsPaymentMethodVerification: false.
Webhooks
verifyWebhookSignature(rawBody, headers)must operate on the exact raw body string. NeverJSON.parse+ re-serialize before verifying, the conformance suite feeds you a re-serialized body (same JSON value, different bytes) and requiresfalse— of every"raw-bytes"adapter, which is the default expectation; the next bullet covers the one scope where a re-encoded body legitimately still verifies. Two verification patterns are shipped precedent: local HMAC over the raw bytes with constant-time comparison and timestamp tolerance (Stripe, Paysafe, GoCardless, PayZen — use core'sconstantTimeEqual), and postback verification where the PSP's API confirms the signature (PayPal — require the raw body to be exactly one JSON object, splice it into the postback by string concatenation, fail closed on any transport trouble).webhookSignatureScope— declare what the signature actually covers."raw-bytes"(the default, and what most providers do) means the signature is computed over bytes as delivered — the whole body, or a string lifted out of an envelope as PayZen'skr-answeris — so any re-encoding of that signed range invalidates it, and the re-serialized-body assertion above applies."field-values"means the provider signs a set of values extracted from the payload — a colon-joined field list, say — so a re-encoded body still verifies and the suite inverts that assertion rather than making you invent a byte-level heuristic that would reject legitimate deliveries. Where verification happens is irrelevant to the flag: PayPal's postback is"raw-bytes"because PayPal verifies the exact delivered body. A"field-values"adapter owes its hosts two things — authenticate the delivery channel by another means (endpoint credentials, mutual TLS, an allowlist: the signature alone proves nothing about who called you), and never present an unsigned field as trusted, because every field outside the signed set arrives unauthenticated even on a delivery that verifies. Tamper coverage does not lapse either way: the suite still requires a forged payload and a credential-less delivery to fail, and the waiver is two-sided — declaring"field-values"obliges you to VERIFY a re-encoded body (a byte-signer cannot borrow the exemption) and to supplywebhook.tamperedSignedValueBody, a delivery carrying one altered signed value with its signature untouched, which you must reject.- Accept an array of signing secrets/HMAC keys so rotation needs no cutover, any active key verifying wins (core's
normalizeSecrets). parseWebhookEventreturns aUnifiedWebhookEventwith a stableid(the PSP's event id; if absent, hash the raw bytes). When the provider documents which fields identify a duplicate delivery, build the id from those fields alone: an envelope id, or any other field the provider does not promise to repeat, can let a redelivery past the host's dedupe store. Adyen documents duplicates as sharingeventCodeandpspReference, and the Adyen adapter's id is that pair; Worldline documentspayment.idandtype, and the Worldline adapter keys on that pair. When that identity can merge distinct events, the adapter's docs must give hosts an idempotent re-read fallback that runs regardless of dedupe. Map known event types onto the unified vocabulary; unknown-but-valid types becometype: "unknown"(conformance proves this on a correctly signed body), only unparseable payloads throw (invalid_request). Timestamps come from the payload, never fromDate.now().- Batched deliveries: the unified contract is one event per delivery. If your PSP batches (GoCardless ships up to 250 events under one signature), make
parseWebhookEventTHROW on batched payloads and export a PSP-specific fan-out helper (parseGoCardlessWebhookEventsis the pattern: verify once, fan out per event) — never silently drop trailing events. - Money facts ride the event where the payload carries them: populate
amount/currency(integer minor units) andrefundIdon refund-shaped events so hosts don't need a retrieve round-trip. Never fabricate them from other fields. - Map outcomes honestly: a refund-object event maps by the refund's own status (
failed→payment.refund_failed, never a misleadingpayment.refunded); async rails emitpayment.processingbefore their terminal event; disputes resolve intopayment.chargeback_won/payment.chargeback_lost.
Onboarding descriptor & verifyCredentials
Export a declarative AdapterOnboardingDescriptor (from @payfanout/core) so a host can build its provider-settings screen — credential fields, "events to subscribe", CSP hosts — as generic loops instead of per-PSP forms. Ship it from the server package (it carries the webhook event list and pairs with the server-only probe below), even though it also describes the client credential fields:
// packages/adapter-acme-server/src/onboarding.ts
import type { AdapterOnboardingDescriptor } from "@payfanout/core";
export const acmeOnboarding: AdapterOnboardingDescriptor = {
pspName: "acme",
credentialFields: [
{ key: "secretKey", kind: "secret", scope: "server", format: { pattern: "^sk_", hint: "Acme secret key" } },
{ key: "publishableKey", kind: "public", scope: "client", format: { pattern: "^pk_" } },
{ key: "webhookSecret", kind: "secret", scope: "server" },
],
// `events` = the exact provider strings your parser recognizes; OMIT it if the PSP has
// no discrete subscribable event types (PayZen sends order-state snapshots).
webhook: { signature: "hmac-sha256-hex", events: ["payment.succeeded", "payment.failed"] },
csp: { script: ["https://sdk.acme.test"], frame: [], connect: ["https://api.acme.test"] },
};Pass it as onboarding in the conformance fixtures and the suite asserts it via validateOnboardingDescriptor: pspName matches the adapter, credential fields are well-formed and unique with at least one scope: "server" field, each format.pattern compiles, and webhook.events/CSP hosts carry no blanks. Keep it honest and co-located with the config and webhook parser so it can't drift — secret fields are never redisplayed by a host, perCurrency marks per-currency accounts (Paysafe merchant accounts), and the signature is hmac-sha256-hex (Stripe/PayZen/GoCardless), hmac-sha256-base64 (Paysafe), or provider-postback (PayPal).
Optionally implement verifyCredentials() — a side-effect-free probe behind a host "Test connection" button. Make ONE read-only call (a vault/list read, an OAuth mint, a liveness endpoint) and classify: { ok: true }, or { ok: false, category } with auth (401/403 — wrong key), network (timeout/5xx/429 — transient), or internal. Never mutate PSP state, never retry an auth rejection, never log secrets. A message may quote a value from the answer's body, such as an error code, only when it matches the provider's documented code shape (a strict pattern with a length cap) and core's repeatsSecret(value, credentials) returns false: a request sent to a wrong baseUrl carried the credentials to whatever answered, and that server wrote the body.
2. Client adapter (@payfanout/adapter-<psp>)
Implement ClientPaymentAdapter:
- Boundary: this package ships to browsers. It may depend on
@payfanout/coreonly,scripts/check-boundaries.mjsfails the build if it references the server adapter, a Node SDK, or anything holding secrets — and fails if the package is missing from its allowlist entirely. Type your PSP's browser SDK structurally and takeloadScript/get<Psp>Globaltest seams in config. loadSdk(): inject the PSP script lazily and idempotently (core'sinjectScript/assertBrowserhelpers); guard SSR with a clear error.<PayFanoutProvider>never calls you eagerly. Return before callinginjectScriptonce the SDK global exists, as every shipped client adapter does, and confirm the global after it resolves. A call that finds a tag an earlierinjectScriptcall added, still loading, waits for it: it resolves when that tag loads and rejects when it fails. Any other script on the page for the URL is reused at once, though one the page added itself may still be loading or may have failed. A taginjectScriptadded is removed when its load fails; if you cache the load promise, clear it when it rejects, or one network error breaks every later mount until the page reloads. If the PSP publishes Subresource Integrity hashes for a version-pinned SDK file, pass the hash:injectScript(url, pspName, { integrity })setsintegrity(andcrossorigin="anonymous"unless you passcrossOrigin), and a file that fails the check rejects like any other load failure. The PSP's CDN must answer CORS (Access-Control-Allow-Origin) for the check to run; otherwise the file is blocked. Pass a pinned hash only for the adapter's default pinned URL, never after a host overrides the SDK URL or version, and never pin a hash for a URL whose content the PSP updates in place: its next release would fail the check. A hash the PSP returns at runtime for the current file (such as Worldline'ssri) is fine. With a hash, a conflicting<script>for the same URL, one without the sameintegrityor without acrossoriginattribute, makes the call reject with a non-retryableinvalid_request, as does a value holding no well-formedsha256-,sha384-orsha512-token (lowercase algorithm, base64 digest: at least one major engine skips any other token and would run the file unchecked). That detects a conflicting tag; it is not a trust boundary, since returning early on the SDK global means a copy the host page already loaded is used without any check. For pages with a nonce-based Content-Security-Policy, take an optionalcspNoncein the config: refuse a malformed one in the constructor with core'sisValidCspNonce, as you refuse a badenvironment, never read one from the page, and pass it asinjectScript'snonce, which sets the tag'snonceattribute beforesrcand insertion, where the browser reads it.attributessets any other attribute the SDK reads from its own tag the same way (PayZen'skr-public-key, PayPal'sdata-csp-nonce), refusing names the helper manages andon…handlers, andasync: falseinjects a non-async script. Load a stylesheet withinjectStylesheet(url, pspName, { nonce, integrity }): one<link rel="stylesheet">per URL, resolving when it loads and also when it fails, since styling is cosmetic. The link stays on the page either way, because a browser can also fireerroron a link whose own rules applied when one of its@imports fails, and a later call reuses it. A call that finds a link an earlier call injected, still loading, waits for it, and a link firesloadonly once its@imports have loaded, so don't make mounting wait on a sheet you know imports from another host (PayZen's theme imports Google Fonts, and its adapter injects the theme without awaiting it); if a host can name its own sheet and mounting waits for it, say so in the option's JSDoc, as Adyen'sstylesheetUrldoes. Say in the option's JSDoc what the nonce covers for your PSP and what its SDK still loads or inlines without one.mount(container, options): render hosted/iframe fields only (SAQ-A), never a raw card input. Forwardoptions.appearanceto the PSP's styling hooks. Return a branded handle viabrandMountedFieldsHandle, and validate handles you receive back. A redirect-only PSP (GoCardless) may mount a lightweight explainer instead of fields. A failed mount rejects; if you also report it throughoptions.onError, pass the same error instance to both, which consumers such as<PaymentFields>dedupe by identity.- Customization is a passthrough, not an enumeration: forward
options.fieldOptionsto your SDK's field-creation call untouched (host wins), andoptions.localeto its locale option, protect ONLY the keys your adapter must own to function (mount selectors, environment, session-derived currency/account), and document them. Split-field PSPs must honor the slot convention: elements inside the container carrying core'sDATA_PAYFANOUT_FIELDattribute are the host's mount points (never remove them on unmount); fall back to your own stacked containers without slots. confirm(handle): resolve 3DS/next-action inline (iframe/modal, e.g. Stripe'sredirect: "if_required"); a full-page navigation is a contract violation for card flows — but IS the flow for redirect-shaped PSPs, whereconfirm()navigates to the hosted page. Return your completion shape from §0. Failures resolve (not reject) with{ status: "failed", error },rawpreserved.- Field-state events: fire
options.onChange({ complete: false })once on mount, then on every SDK validity change, this drives "disable Pay until complete" UX. Registration must be defensive: an SDK build without the event surface degrades (onChange stays initialized), it never breaksmount. handleRedirectReturn(location): required whenever you report a supportedflow: "redirect"method (the client conformance suite enforces it — a redirect flow without a return-trip handler strands the customer). Inspect the landing URL and resolve the outcome from the PSP object itself, not from a status hint in the query string. Returnnullwhen the URL carries no params for your PSP,useRedirectReturnprobes every registered adapter.listPaymentMethodCapabilities(): be honest about flows, a bank redirect isflow: "redirect", a cash voucher isvoucher_code. If enablement varies per merchant account, default conservatively and accept a config override.
3. Wire up the conformance suite
// packages/adapter-acme-server/test/acme-server.test.ts
import { runServerAdapterConformanceTests } from "@payfanout/conformance";
import { AcmeServerAdapter } from "../src/index.js";
import { FakeAcmeApi } from "./fake-acme-api.js"; // in-memory API that dedupes like the real one
runServerAdapterConformanceTests("acme", () => { /* fresh adapter + fake */ }, {
createSessionInput: () => ({ amount: 1099, currency: "USD", idempotencyKey: `k-${Math.random()}` }),
zeroDecimalSessionInput: () => ({ amount: 500, currency: "JPY", idempotencyKey: `k-${Math.random()}` }),
threeDecimalSessionInput: () => ({ amount: 1234, currency: "BHD", idempotencyKey: `k-${Math.random()}` }),
webhook: {
validRawBody, validHeaders,
expectedType: "payment.succeeded", expectedEventId: "evt_1",
expectedAmount: 1099, // when the payload carries an amount
unknownEvent: { rawBody, headers }, // SIGNED body of a type you don't map
},
// The money paths are proven, not trusted:
money: {
completedPayment: async (adapter, { amount, id, metadata }) => { /* drive the fake to a completed payment, return pspPaymentId */ },
authorizedPayment: async (adapter, { amount }) => { /* manual-capture PSPs: authorized, uncaptured */ },
cancelablePayment: async (adapter) => { /* a pre-completion payment; cancel yields "canceled", or "processing" when modificationOutcome is "asynchronous" */ },
// Declare documented PSP limitations honestly (defaults are true):
// expectations: { idRoundTrip: false, metadataEcho: false },
},
failingCalls: [ /* missing ids, declines, expired tokens … with expected taxonomy codes */ ],
idempotency: {
run: async (adapter, key) => [/* same mutating call twice with `key` */],
sideEffectCount: () => lastFake.uniqueCreations, // must be exactly 1
},
completePayment: { input: (session) => ({ /* only for tokenize-first PSPs */ }) },
});Add runClientAdapterConformanceTests for the client package the same way. Build your fake against the PSP's documented behavior (including idempotency dedupe, over-refund rejection, and capture-state bookkeeping — the money cases exercise all of it) so the suite proves plumbing, then validate against the PSP sandbox manually before going live.
4. Checklist before you call it done
- [ ]
pnpm run checkgreen (typecheck + boundary check + all tests) - [ ] Server + client conformance suites pass — including every money-path case
- [ ] Both webhook ingress patterns work with your adapter (per-adapter and unified)
- [ ] Exports an
AdapterOnboardingDescriptorwired into the conformanceonboardingfixture;verifyCredentials()implemented if the PSP has a safe read-only probe - [ ] Full + partial refund, over-refund rejection, cancel-before-capture, manual capture / multi-capture (if supported) exercised against the PSP sandbox
- [ ] JPY and BHD amounts round-trip correctly end-to-end (or the constraint is declared via
supportedCurrenciesorunsupportedCurrencies) - [ ] Registered in the demo app (
examples/demo) and payable behind the unchanged<PayButton>, if the demo needed edits beyond adding your adapter to the two registries, something leaked - [ ] Added to
scripts/check-boundaries.mjs(the check fails unclassified packages), the README packages table,typedoc.json, and the integration workflow's env