Skip to content

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 callcreates the PSP intent objectmay create nothing, see "stateless sessions"creates the PSP object + hosted URL
Client confirm()finalizes, returns terminal statusreturns requires_confirmation + clientTokennavigates to the hosted flow
completePaymentomit the method entirelyrequired, finalizes with the clientTokenomit (the PSP fulfils on its side)
Capability flagrequiresServerCompletion: falserequiresServerCompletion: truefalse, 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. Expose requestTimeoutMs, maxNetworkRetries, and a sleep test seam like every shipped REST adapter.
  • Amounts: integer minor units at every boundary, both directions. Use getCurrencyExponent / assertMinorUnitAmount from core. PSP-specific quirks (e.g. Stripe's three-decimal multiples-of-10) stay inside your adapter and reject with invalid_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's firstCurrencyCode(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 with invalid_request before 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 with NO_CURRENCY's default exponent under a record that reports NO_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 in supportedCurrencies while 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 with supportedCurrencies, and PaymentService refuses such a session with a non-retryable unsupported_operation before 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 whose currencies all 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 as supportedCurrencies reads, 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 override paymentMethods wholesale. Derive both from one constant so they cannot drift. A rail gated to currencies your supportedCurrencies excludes is unroutable and validateAdapterCapabilities rejects 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 states customerCountry; 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 PayFanoutError with a taxonomy code, a user-safe message (use core's getUserMessage(code) catalog — never a third English variant), an honest retryable, pspName, and the untouched PSP error on raw. Even locally-generated failures set raw to 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's classifyHttpFallback is that tail). Retryable semantics are contract, not taste: rate_limited/psp_unavailable are always retryable, authentication_required NEVER is (the customer comes back on-session) — the conformance suite asserts both. psp_unavailable, rate_limited and unknown already mean the call may have taken effect: callers retry them only under the same idempotencyKey, 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 (a processing_error for an answer you could not read back, a refusal you cannot resolve), set outcomeUnknown: true, which callers treat the same way. A PSP's refusal of a reused key (Stripe's idempotency_error, a duplicate merchantRefNum), 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: idempotencyKey is REQUIRED on every mutating call — session creation, completion, refunds, capture, cancel, and verification included. Forward it through your PSP's mechanism (Stripe: Idempotency-Key request option; GoCardless: Idempotency-Key header; PayPal: PayPal-Request-Id). Check whether that mechanism replays a duplicate or rejects it. Paysafe's merchantRefNum under dupCheck rejects the repeat (409, error 5031) 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.id is present, stamp it into PSP metadata (payfanout_id) if your PSP supports metadata, and prefer it for PaymentInfo.id. Echo the stored metadata on PaymentInfo.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 via money.expectations — never fake it.
  • Money truth on PaymentInfo: report amountRefunded faithfully (callers derive refund state via getRefundState — never invent a "refunded" status), and populate amountCaptured/amountCapturable wherever your PSP reports settlement state — partial and multi-capture flows are invisible without them. If you declare supportsRefundRetrieval, you MUST implement retrieveRefund(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 requires supportsRefunds), and modificationOutcome says 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 flags false and modificationOutcome: "asynchronous"; its cancelPayment and capturePayment resolve "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 a PayFanoutError when one arrives — never fold it into a "pending" acknowledgement. Declaring supportsRefundRetrieval: false while 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, and mandateReference for debit rails (SEPA/ACH/BACS), hosts must never have to dig into raw for "Visa •••• 4242".
  • Checkout fields: map statementDescriptor / receiptEmail / shippingDetails / sca to 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 true only if implemented, PaymentService enforces coherence at registration via core's validateAdapterCapabilities): retrievePayment ↔ supportsPaymentRetrieval, retrieveRefund ↔ supportsRefundRetrieval (which also requires supportsRefunds), 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, and supportsMultiCapture (requires supportsManualCapture; 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 flag false is 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 nativeSubscriptions block 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 into NativeSubscriptionRecord: integer minor-unit amounts, the unified status union (unmappable provider states → "unknown", never dropped), cadence as interval/intervalCount or the source RRULE on schedule — reject cadences your PSP cannot express (invalid_request) instead of approximating. cancelNativeSubscription must 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 the nativeSubscriptions conformance fixtures (createInput for create-capable adapters; seedSubscriptions when create is 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 declare false — 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-file occurrence mapping), plus savePaymentMethod if you are tokenize-first. Cards live at the PSP ONLY. Save-during-checkout: honor session customer + savePaymentMethod and surface the stored token on PaymentInfo.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. Never JSON.parse + re-serialize before verifying, the conformance suite feeds you a re-serialized body (same JSON value, different bytes) and requires false — 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's constantTimeEqual), 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's kr-answer is — 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 supply webhook.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).
  • parseWebhookEvent returns a UnifiedWebhookEvent with a stable id (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 sharing eventCode and pspReference, and the Adyen adapter's id is that pair; Worldline documents payment.id and type, 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 become type: "unknown" (conformance proves this on a correctly signed body), only unparseable payloads throw (invalid_request). Timestamps come from the payload, never from Date.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 parseWebhookEvent THROW on batched payloads and export a PSP-specific fan-out helper (parseGoCardlessWebhookEvents is 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) and refundId on 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 misleading payment.refunded); async rails emit payment.processing before their terminal event; disputes resolve into payment.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:

ts
// 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/core only, scripts/check-boundaries.mjs fails 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 take loadScript/get<Psp>Global test seams in config.
  • loadSdk(): inject the PSP script lazily and idempotently (core's injectScript / assertBrowser helpers); guard SSR with a clear error. <PayFanoutProvider> never calls you eagerly. Return before calling injectScript once the SDK global exists, as every shipped client adapter does, and confirm the global after it resolves. A call that finds a tag an earlier injectScript call 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 tag injectScript added 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 }) sets integrity (and crossorigin="anonymous" unless you pass crossOrigin), 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's sri) is fine. With a hash, a conflicting <script> for the same URL, one without the same integrity or without a crossorigin attribute, makes the call reject with a non-retryable invalid_request, as does a value holding no well-formed sha256-, sha384- or sha512- 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 optional cspNonce in the config: refuse a malformed one in the constructor with core's isValidCspNonce, as you refuse a bad environment, never read one from the page, and pass it as injectScript's nonce, which sets the tag's nonce attribute before src and insertion, where the browser reads it. attributes sets any other attribute the SDK reads from its own tag the same way (PayZen's kr-public-key, PayPal's data-csp-nonce), refusing names the helper manages and on… handlers, and async: false injects a non-async script. Load a stylesheet with injectStylesheet(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 fire error on 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 fires load only 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's stylesheetUrl does. 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. Forward options.appearance to the PSP's styling hooks. Return a branded handle via brandMountedFieldsHandle, 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 through options.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.fieldOptions to your SDK's field-creation call untouched (host wins), and options.locale to 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's DATA_PAYFANOUT_FIELD attribute 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's redirect: "if_required"); a full-page navigation is a contract violation for card flows — but IS the flow for redirect-shaped PSPs, where confirm() navigates to the hosted page. Return your completion shape from §0. Failures resolve (not reject) with { status: "failed", error }, raw preserved.
  • 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 breaks mount.
  • handleRedirectReturn(location): required whenever you report a supported flow: "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. Return null when the URL carries no params for your PSP, useRedirectReturn probes every registered adapter.
  • listPaymentMethodCapabilities(): be honest about flows, a bank redirect is flow: "redirect", a cash voucher is voucher_code. If enablement varies per merchant account, default conservatively and accept a config override.

3. Wire up the conformance suite ​

ts
// 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 check green (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 AdapterOnboardingDescriptor wired into the conformance onboarding fixture; 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 supportedCurrencies or unsupportedCurrencies)
  • [ ] 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