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 the Stripe, Paysafe, GoCardless, PayPal, and PayZen adapters pass. 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? Five shipped adapters cover three shapes:
| Confirm-on-client (Stripe, PayZen) | Tokenize-first (Paysafe, PayPal) | 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. - 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. - 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. - 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; Paysafe:merchantRefNum; GoCardless:Idempotency-Keyheader; PayPal:PayPal-Request-Id). 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 declaresupportsRefunds, you MUST implementretrieveRefund(refundId); async refunds return"pending"and hosts poll them to a terminal state. - 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):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). - 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. Three 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 — splice the raw body into the postback by string concatenation, fail closed on any transport trouble).- 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). 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.
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.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.- 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 must yield "canceled" */ },
// 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
supportedCurrencies) - [ ] 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