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 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 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.
  • 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 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.
  • 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; Paysafe: merchantRefNum; GoCardless: Idempotency-Key header; 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.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 supportsRefunds, you MUST implement retrieveRefund(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, 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): updatePaymentSessionsupportsSessionUpdate (in-place amend or re-issued signed token, callers always continue with the returned session), fetchEventssupportsEventPolling (missed-webhook recovery), listPayments/listRefundssupportsListing, and supportsMultiCapture (requires supportsManualCapture; 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 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. 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's constantTimeEqual), 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).
  • parseWebhookEvent returns a UnifiedWebhookEvent with a stable id (the PSP's event id; if absent, hash the raw bytes). 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 (failedpayment.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.

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.
  • 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.
  • 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 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 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)
  • [ ] 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