@payfanout/adapter-paysafe-server
v3.0.2
Published
Server-side Paysafe adapter for PayFanout (Payments REST API: Payment Handles, Payments, Settlements, Refunds, Webhooks). Holds secrets — never bundle client-side.
Readme
@payfanout/adapter-paysafe-server
Server-side Paysafe adapter for PayFanout: Payment Handles, Payments, Settlements, Refunds, Webhooks, and Payment Scheduler subscriptions, over the Paysafe REST APIs.
Holds secrets. This package uses your Paysafe REST credentials. Never bundle it client-side.
It implements the ServerPaymentAdapter contract from @payfanout/core, so
@payfanout/server drives it through the same unified API as every other PSP. It talks to
the REST API directly and is edge-runtime compatible (WebCrypto only, no Node builtins),
so it runs on Cloudflare Workers and Next.js edge routes.
📖 Documentation: https://donapulse.github.io/payfanout/ · Set up Paysafe · Server usage
Installation
pnpm add @payfanout/server @payfanout/adapter-paysafe-server@payfanout/core comes in transitively.
Usage
import { PaymentService } from "@payfanout/server";
import { PaysafeServerAdapter } from "@payfanout/adapter-paysafe-server";
const paysafe = new PaysafeServerAdapter({
username: process.env.PAYSAFE_USERNAME!,
password: process.env.PAYSAFE_PASSWORD!,
environment: "sandbox", // never inferred from credentials
merchantAccountResolver: (currency, country) => lookupAccount(currency, country), // required
sessionSigningKey: process.env.PAYSAFE_SESSION_KEY!, // signs the stateless session context
webhookHmacKey: process.env.PAYSAFE_WEBHOOK_HMAC_KEY!, // string or array for rotation
});
const payments = new PaymentService({ adapters: [paysafe] });Pair it on the browser with @payfanout/adapter-paysafe. This is a
tokenize-first PSP: the client tokenizes first, then your server finalizes the payment
via completePayment (wire a server-completion route for it).
The signed, stateless session
Because PayFanout persists nothing, this adapter's session is a signed, self-contained
context: amount, currency, and merchant account are HMAC-signed into pspSessionId at
creation and verified at completePayment. The browser round-trips the token but cannot
tamper with the amount, and every context carries an expiry (sessionTtlSeconds,
default 1h) enforced at completion. encodeSessionContext / decodeSessionContext are
exported for advanced use.
What's inside
PaysafeServerAdapter, the full server contract (create/update/complete/retrieve, captures, refunds, settlements, verification via the Verifications API, saved-card charging, native subscriptions).- Webhook helpers,
verifyPaysafeWebhookSignatureandparsePaysafeWebhookEvent, checking theSignatureheader against the raw request bytes and emitting a normalizedUnifiedWebhookEvent. Paysafe sends no event id, soevent.idis derived from the event name, resource id, status and status time, the same for every redelivery;pspPaymentIdnames the returned payment on a bank return and is unset on refund events, which carryrefundId. Paysafe counts only a 200 or 202 as received and makes at most three attempts, so reconcile withretrievePaymentfor anything missed, except bank-debit returns, which no read is documented to reflect: reconcile those against the Merchant Back Office return reports. mapPaysafeError, unifies Paysafe errors intoPayFanoutError(business errors like declines or3406are never replayed), andPAYSAFE_PSP_NAME.
PSP-native subscriptions (Payment Scheduler)
The adapter declares nativeSubscriptions: { list, retrieve, create, cancel } all true,
backed by Paysafe's Payment Scheduler (subscriptionsplans/v1, same hosts and the same
Basic API key as the Payments API — the docs' "Back Office" is where that key is
retrieved, not a separate credential).
- Create bills an already-vaulted MULTI_USE token (
savePaymentMethod's output; the scheduler rejects single-use Paysafe.js tokens). Subscriptions attach to a plan: passplanIdto bill from a plan you manage (the input amount/currency/cadence must match it — mismatches reject instead of silently billing different terms), or omit it and the adapter creates a dedicated open-ended plan from the input inline. - Cadence is
day/month/year(+intervalCount1-365). The scheduler has no weekly frequency and no RRULE input —interval: "week"and anyschedulereject withinvalid_requestrather than approximating. - Idempotency rides
merchantRefNum("unique for this accountId"):input.merchantRefNumwins when supplied,idempotencyKeyfills it otherwise, and a replayed create recovers the existing subscription by that refNum. A creation retried after a lost answer can leave an orphan inline plan (plans carry no refNum); the subscription itself stays exactly-once, so nothing double-bills. - Cancel PATCHes the final
CANCELLEDstatus (never the reversibleSUSPENDED) and is verified-idempotent: on a rejection the adapter re-reads the subscription and treatsCANCELLED/COMPLETEDas success. - Status mapping:
ACTIVE→active,CANCELLED→canceled,SUSPENDED→paused,COMPLETED→completed; anything else on the wire →unknown. pspCustomerIdandmetadatahave no scheduler channel and are withheld; the customer profile derives from the vaulted token.
Notes
- Reads are retried on timeouts/5xx/429 with backoff (
maxNetworkRetries, default 2); writes are never re-sent blindly. Paysafe rejects a repeatedmerchantRefNum(409, error5031, underdupCheck) instead of answering with the original, so a write whose answer was lost is looked up by itsmerchantRefNumand returned when Paysafe has it. A payment, capture or refund is re-sent only after a 429; a payment handle, verification or void also once the lookup shows nothing. Card and Interac completions senddupCheck: false, so another card can follow a decline under the same key. A bank-debit completion sendsdupCheck: true, so a second attempt is refused instead of debiting again while no failed attempt shows under the key, andfalseonce one does, so corrected bank details can follow. Once a failed attempt shows, the check is off: two attempts sent together, or one resubmitted before the lookup shows the other's payment or handle, can both be debited. §10 of the setup guide covers these timings, and when a bank-debit key needs replacing. A key reused for a different amount or currency, or a different saved card or verification card, rejects withinvalid_request, unless every earlier attempt under a card or Interac completion key failed; while the key's payment, capture or refund may have moved money (it has not failed, been voided, cancelled or expired), or a bank-debit key holds a spent handle whose payment the lookup does not show, the rejection carriesoutcomeUnknown. An original that cannot be read back rejects with a non-retryableprocessing_error; retry it later with the same key, or, for a full capture, whose reference does not come from the key, capture in full again later. The defaultrequestTimeoutMsis 60000, the response timeout of Paysafe's own SDKs, and bounds each exchange rather than a whole call. - A capture of the whole authorization (no amount, or the authorized amount) settles under
a reference derived from the payment, not under its idempotency key:
payfanout-capture-<pspPaymentId>, then-a2to-a10once a full capture there has moved no money (failed, cancelled or expired).retrievePaymentandrefundPaymentlook those references up, and a capture that finds the payment already captured in full there answers with that settlement, whatever its key. After partial captures, a capture of the authorized amount is refused before it is sent: capture the rest with no amount, while some is left. With nothing left and no full capture showing, a capture is refused as nothing left to capture; a full capture made moments ago can trail in Paysafe's lookup, so checkretrievePaymentfirst. A partial capture settles under its idempotency key, which may not start withpayfanout-capture-, and no read can find it from the payment: its amount counts inamountCaptured, butrefundPaymentcannot refund it. Every capture's answer carries its settlement onraw.captureSettlement: keep a partial capture's settlementidand refund it in the Paysafe portal, as you would a capture an earlier release made, which settled under the capture's key. Settlement lookups start the day before the payment instead of covering Paysafe's default 30 days, and are sent again over that window only when Paysafe refuses the range; any other lookup failure fails the call instead of reading as no settlement. See captures and refunds in the setup guide. - Paysafe refunds neither SEPA nor Bacs direct debits, so
refundPaymentrejects a payment on either rail with a non-retryableunsupported_operationonce it has read the payment, before any refund request. - Currencies whose Paysafe exponent may not be PayFanout's are refused, as Paysafe reads
amountin the minor units of its currency table and the adapter converts nothing: CLP and BYR, which the table prices with another exponent (CLP 2, where ISO 4217 gives 0), and currencies the table lacks that are not priced in hundredths (ISK, UYI and others). Sessions in them reject with a non-retryableinvalid_requestbefore any request. Saved-method charges, native subscription creates and completions reject the same way once their key is looked up, markedoutcomeUnknownwhen Paysafe already holds something under it that an earlier release may have made, or when the lookup fails. Captures, voids and refunds of a payment Paysafe holds in one read the payment, then reject before any settlement, void or refund request: withinvalid_requestwhen they state an amount, withunsupported_operationotherwise. Reads of such a payment or subscription, or of a refund Paysafe reports in one, subscription cancels (which read the subscription first) and subscription list pages holding one reject withunsupported_operation, and webhook events Paysafe reports in one carry noamount. The adapter declares these currencies inunsupportedCurrencies, so the router skips Paysafe for a session in one andPaymentServicerefuses it withunsupported_operation, from the@payfanout/serverrelease that reads the field (see currencies the adapter refuses). - Paysafe has no public events API (
supportsEventPolling: false), so missed-webhook recovery falls back toretrievePaymentper order. - Scheduler availability is per merchant account, like every Paysafe product option — the capability flags state what the adapter implements; an account without the scheduler answers with its own rejection.
Documentation
License
MIT
