Set up Paysafe
Paysafe is a tokenize-first PSP: the browser tokenizes the card into a single-use handle, then your server finalizes the charge with that handle. This inverts Stripe's flow, and PayFanout models both as first-class, the React code is identical, but Paysafe needs one extra thing Stripe doesn't: a server-completion route (step 7).
Two packages: @payfanout/adapter-paysafe-server (holds your API credentials; edge-runtime compatible, WebCrypto only, runs on Cloudflare Workers / Next.js edge) and @payfanout/adapter-paysafe (browser-safe, holds only the public tokenization key).
Paysafe API details evolve
Endpoint hosts, portal menu names, webhook header names, and test-card lists change over time and vary per merchant account. The field names and behavior below are exact (read from the adapter source), but re-verify credential locations and test cards against your own Paysafe developer portal before going live.
1. Get your Paysafe credentials
From the Paysafe Merchant / Business Portal (its API-credentials and account sections):
| Credential | What it is | Used by |
|---|---|---|
| API username + password | Payments REST API Basic-auth credentials (server-only) | server adapter (username, password) |
| Public single-use-token key | Base64 API key that can only mint single-use card tokens, browser-safe | client adapter (apiKey) |
| Merchant account id(s) | One per currency/country your account is provisioned for | server adapter (merchantAccountResolver) |
| Webhook HMAC key | Signs webhook payloads; configured with your notification endpoint | server adapter (webhookHmacKey) |
Sandbox and live are separate credential sets and separate hosts, the adapter derives the host from environment (sandbox → api.test.paysafe.com, live → api.paysafe.com).
One secret you generate yourself
sessionSigningKey is not issued by Paysafe. Because PayFanout is stateless, the Paysafe "session" is a signed, self-contained token, amount, currency, and account id are HMAC-signed into it so the browser can round-trip it but cannot tamper with the amount. That HMAC key is yours. Generate a strong random secret once and keep it stable:
openssl rand -hex 32 # → PAYSAFE_SESSION_KEYIf it changes (or differs between server instances), previously issued sessions fail signature verification at completion. The client adapter only reads the payload half and never needs this key.
2. Install
# server
pnpm add @payfanout/server @payfanout/adapter-paysafe-server
# client (React)
pnpm add @payfanout/react @payfanout/adapter-paysafe react react-domPaysafe.js is not an npm dependency; the client adapter injects it lazily from Paysafe's CDN on first mount.
3. Environment variables
# .env (server), never committed
PAYSAFE_USERNAME=…
PAYSAFE_PASSWORD=…
PAYSAFE_ACCOUNT_ID=… # a merchant account id (omit if your key is single-account)
PAYSAFE_SESSION_KEY=… # YOUR secret, openssl rand -hex 32 (not from Paysafe)
PAYSAFE_WEBHOOK_HMAC_KEY=…
# client bundle, must be VITE_-prefixed to reach the browser
VITE_PAYSAFE_PUBLIC_KEY=… # the public single-use-token Base64 key
VITE_PAYSAFE_CURRENCY=CAD # match your sandbox account's currency (see §11)Env-var names deliberately differ from config field names, e.g. PAYSAFE_SESSION_KEY feeds sessionSigningKey, PAYSAFE_WEBHOOK_HMAC_KEY feeds webhookHmacKey.
4. Wire the server adapter
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", // → api.test.paysafe.com
// Paysafe accounts are per currency/country. Return undefined for a single-account
// key and Paysafe routes by key + currency:
merchantAccountResolver: (currency, country) => process.env.PAYSAFE_ACCOUNT_ID,
sessionSigningKey: process.env.PAYSAFE_SESSION_KEY!, // YOUR HMAC secret, not a Paysafe credential
webhookHmacKey: process.env.PAYSAFE_WEBHOOK_HMAC_KEY!, // string, or string[] while rotating
});
const payments = new PaymentService({ adapters: [paysafe] });| Field | Required | Default | Notes |
|---|---|---|---|
username / password | ✅ | - | Basic-auth REST API credentials. Server-only. |
environment | ✅ | - | Exactly "sandbox" or "live"; selects the API host. Never inferred. |
merchantAccountResolver | ✅ | - | (currency, country?) => accountId | undefined. Must be a function. Return undefined for single-account keys. |
sessionSigningKey | ✅ | - | HMAC key for the stateless signed session. You generate this. Keep it stable across restarts/instances. |
webhookHmacKey | ✅ | - | Paysafe's webhook signing key. Pass a string[] to rotate with no cutover. |
sessionTtlSeconds | - | 3600 | How long a signed session stays completable (1h). Enforced at completion. |
requestTimeoutMs | - | 30000 | Abort a hung Paysafe connection; surfaces as a retryable psp_unavailable. |
maxNetworkRetries | - | 2 | Retries transport trouble (network/timeout/5xx/429) only, never business errors like declines. |
createPaymentSession makes no network call — for cards
For a card session it just mints and signs the self-contained session token locally, the first real API call is completePayment (step 7). That is why the session must carry everything completion needs, signed. Interac e-Transfer is the exception: Paysafe.js cannot tokenize it, so the handle is minted server-side at session creation (§8).
5. Wire the client adapter
import { PayFanoutProvider, PaymentFields, PayButton } from "@payfanout/react";
import { PaysafeClientAdapter } from "@payfanout/adapter-paysafe";
const paysafe = new PaysafeClientAdapter({
apiKey: import.meta.env.VITE_PAYSAFE_PUBLIC_KEY, // public single-use-token key, browser-safe
environment: "sandbox", // → Paysafe.js "TEST"
});
<PayFanoutProvider adapters={[paysafe]} initialPsp="paysafe" completionEndpoint="/api/complete">
<PaymentFields clientSecret={session.clientSecret} onChange={({ complete }) => setPayEnabled(complete)} />
{/* completionEndpoint finishes the tokenize-first flow automatically — no onServerCompletion. See §7. */}
<PayButton onResult={(result) => showOutcome(result)}>Pay</PayButton>
</PayFanoutProvider>apiKeymust be the public Base64 tokenization key, never the server username/password. It can only mint single-use tokens and holds no secret authority.- Currency comes from the signed session, not from client config. It must be a currency your Paysafe account supports, or Paysafe.js fails to set up (error
9055). See §11. - Split card fields let you own the layout via slots (
data-payfanout-field="cardNumber|expiryDate|cvv"), see React usage.
Content-Security-Policy
A CSP-enforcing page must allow every host Paysafe.js touches, or the fields fail quietly and each missing host looks like a different problem:
script-src—https://hosted.paysafe.comloads Paysafe.js. Blocking it surfaces a retryablepsp_unavailable("Failed to load … paysafe.min.js").frame-src— the card-field iframes. In sandbox they are served fromhttps://hosted.test.paysafe.com(LIVE useshttps://hosted.paysafe.com), so allowing only the LIVE host still breaks mounting underenvironment: "sandbox".connect-src— Paysafe.js issues XHRs from the parent page: client telemetry to thehostedhosts, plus payment-method / merchant-configuration / BIN lookups tohttps://api.paysafe.com/https://api.test.paysafe.com. Blocking them degrades the mount with only console CSP violations to show for it.
script-src https://hosted.paysafe.com
frame-src https://hosted.paysafe.com https://hosted.test.paysafe.com
connect-src https://hosted.paysafe.com https://hosted.test.paysafe.com
https://api.paysafe.com https://api.test.paysafe.comThe .test hosts are exercised only by environment: "sandbox" and are harmless to allow in a production CSP (or gate them per environment). Override the script URL with the sdkUrl config field to pin a version or self-host.
6. Billing postal code is required
Browser-tokenized handles carry no AVS data, so Paysafe rejects card charges without a billing postal/ZIP code (error 3004). Supply billingDetails.address on createPaymentSession (the demo always does) — or, when the postal code is collected on the payment step, pass billingDetails to completePayment (step 7): it merges over the session's billing, so AVS-enforcing accounts complete without recreating the session.
7. The server-completion route (Paysafe-only)
This is the step Stripe doesn't have. When the client tokenizes, the library POSTs the resulting clientToken (with the session reference and any completion-time billingDetails) to your completionEndpoint, where you mount createCompletionHandler:
import { createCompletionHandler } from "@payfanout/server";
// POST /api/complete
const complete = createCompletionHandler({
resolveSession: async (sessionRef) => {
const order = await db.orderByClientSecret(sessionRef); // your storage
return { service: payments, pspName: "paysafe", pspSessionId: order.pspSessionId, idempotencyKey: `complete-${order.id}` };
},
});Under the hood it calls completePayment, which verifies the session signature and expiry, merges any completion-time billingDetails over the session's (§6), then charges. Calling it on a confirm-on-client PSP (Stripe) throws — it exists only for tokenize-first PSPs (requiresServerCompletion: true). Prefer to hand-write the route? Call completePayment directly; both forms are in Server usage, and the client side is React usage.
8. Interac e-Transfer (Canada)
Paysafe.js cannot tokenize Interac e-Transfer — it is a Payments-API rail — so PayFanout mints the payment handle server-side, inside createPaymentSession, and the customer authenticates at their bank. It is the one Paysafe session that calls Paysafe before the client mounts.
Like every non-card rail, it is off by default — enablement is per-account and this one is Canada-only, so opt in on both adapters:
paymentMethods: [
{ type: "card", flow: "embedded", supported: true },
// Keep the CAD gate: this list replaces the defaults wholesale, and without
// it the router cannot skip Paysafe for a non-CAD session — it would offer
// the rail, then fail on the adapter's own check instead of failing over.
{ type: "interac_etransfer", flow: "redirect", supported: true, currencies: ["CAD"] },
],Give the session its own paymentMethodTypes (a handle is minted for exactly one payment type, so it cannot share a session with cards), plus a returnUrl and the customer's email — Paysafe collects from that alias, so it is the instrument, not a receipt nicety:
const session = await payments.createPaymentSession({
amount: 5_44, // CAD only
currency: "CAD",
country: "CA",
paymentMethodTypes: ["interac_etransfer"],
returnUrl: "https://shop.example/return",
receiptEmail: "payer@example.com", // or billingDetails.email
idempotencyKey: `interac-${order.id}`,
});The session comes back requires_action: <PaymentFields> renders a plain panel instead of hosted card fields (override the copy with fieldOptions.description), and <PayButton> navigates to Interac. When the customer lands back on your returnUrl, <RedirectReturn> resolves requires_confirmation with a placeholder clientToken — pass onServerCompletion (same contract as <PayButton>, reusing the session reference you stored before navigating) and the §7 server-completion route finishes the payment unchanged. The placeholder is deliberate: the real handle token rides the signed session context, and the server adapter ignores the wire value for a session whose handle is already minted.
The session cannot be amended once its handle exists (updatePaymentSession throws) — the customer authorizes that handle at their bank, so a changed cart needs a new session.
Lower sessionTtlSeconds for this rail
Paysafe expires a redirect handle after ~15 minutes, and the value is response-only, so the adapter cannot align to it. The default sessionTtlSeconds is 3600, so a signed session can outlive its handle by ~45 minutes: a slow customer returns to a session that still verifies but whose handle is gone. Set sessionTtlSeconds near the handle window if you run Interac.
The return trip is a hint — webhooks are the outcome
Paysafe signals results by which return link it uses, PayFanout points them all at your one returnUrl, and Paysafe's Interac integration notes are explicit that Interac does not redirect the customer back at all after a completed payment — the links fire on the failed/cancelled paths. So tell the customer to come back (the panel copy is a good place) and never gate the order on the return trip. The handle flips to PAYABLE as soon as the customer is redirected, announced by a PAYMENT_HANDLE_PAYABLE webhook (delivered as unknown; its payload merchantRefNum is your session idempotencyKey) — that event is Paysafe's documented cue to complete. If you never complete, Paysafe completes on your behalf once the ~15-minute handle window closes (when the customer paid) or fails the handle. Either way the terminal state arrives on the mapped webhooks (PAYMENT_COMPLETED / PAYMENT_FAILED), so a completion attempt that rejects because the handle already left PAYABLE means "reconcile by webhook", not "the customer failed". Bank debits settle later: completePayment usually returns processing (succeeded once Interac has already confirmed the transfer to Paysafe).
9. Bank debits — SEPA, ACH, Bacs, EFT (Canada)
Paysafe's direct-debit rails are Payments-API-only like Interac, but with no redirect: the customer's bank details are the instrument, so the client adapter renders its own plain inputs (Paysafe.js is never loaded for these sessions) and the details ride the completion request. The unified types are sepa_debit, ach, bacs_debit, and pad (Pre-Authorized Debit — Paysafe's word for it is EFT).
All four are off by default (per-account enablement). Opt in on both adapters, keeping the declared gates — the list replaces the defaults wholesale:
paymentMethods: [
{ type: "card", flow: "embedded", supported: true },
{ type: "sepa_debit", flow: "embedded", supported: true, currencies: ["EUR"] },
{ type: "bacs_debit", flow: "embedded", supported: true, currencies: ["GBP"], countries: ["GB"] },
{ type: "pad", flow: "embedded", supported: true, countries: ["CA"] },
],(Paysafe documents no currency for ACH or EFT, so those rails carry no currencies gate — your merchant-account currencies decide, see the sandbox-currency section below.)
A bank-debit session is restricted to exactly one rail (the client mounts one collection UI per session, same rule as Interac): request paymentMethodTypes: ["sepa_debit"] with currency: "EUR", ["bacs_debit"] with GBP, ["ach"] or ["pad"] with what your account settles. Manual capture is rejected — debits settle with authorization.
<PaymentFields> renders the rail's fields (account holder + IBAN for SEPA, routing + account for ACH, sort code + account for Bacs, institution + transit + account for EFT/PAD), with labels and placeholders overridable via fieldOptions.fields.<name>. SEPA and Bacs additionally render a mandate-consent checkbox — the scheme's authorization requirement, not a nicety; override the wording with fieldOptions.mandateText to match your terms. <PayButton> stays disabled until the required fields (and consent, where required) are filled. On confirm, the details travel as the session's clientToken through the §7 server-completion route unchanged: the server adapter mints the payment handle and charges it with settleWithAuth: true in one step, and the mandate reference (SEPA/Bacs) surfaces on PaymentInfo.mandateReference.
Bank debits settle in days, not seconds
completePayment normally returns processing. The money truth arrives by webhook: PAYMENT_COMPLETED when the request is accepted into the banking network, and — days later — PAYMENT_RETURNED_COMPLETED (also delivered by Paysafe as PAYMENT_RETURN_COMPLETED; both map to payment.failed) when the bank bounces the debit. Bacs runs a ~10-business-day cycle. Never ship the order on processing. Settlement-lifecycle events (SETTLEMENT_*) carry settlement ids, not payment ids, and are delivered as unknown — correlate by payload merchantRefNum (your idempotencyKey) if you consume them. Paysafe documents no refunds for Bacs; refund support on the other rails follows your account, and an in-flight settlement reports availableToRefund: 0 ("not refundable yet"), so refunds only open up once settlement completes.
Sandbox test values (from Paysafe's pages): SEPA IBAN NL77ABNA0492122466 (BIC ABNANL2A); Bacs sort code 086081, account 51120177; EFT institution 001, transit 22446, account 897543213. ACH publishes no test values.
Validate SEPA/Bacs/ACH against your own provisioned account first
These rails are per-account provisioning like everything non-card at Paysafe. A CAD sandbox account answers EFT end-to-end, PAYMENTHUB-1 for ACH, and — for SEPA/Bacs — error 5005 "Creation of sepa/bacs single use payment handle is not supported": the request parses, the operation is refused. On an unprovisioned account that is indistinguishable from a provisioning gap, so before enabling sepa_debit, bacs_debit, or ach in production, run one sandbox payment against your provisioned account and confirm the handle mints. ACH deserves the same first run because Paysafe publishes no ACH field list at all — the adapter sends the scheme's canonical fields (routing + account + holder), and your account is where that is proven. If a provisioned account still answers 5005, contact Paysafe support about the required handle setup before going live.
10. Register the webhook endpoint
Configured in the portal, not via the API
Paysafe's POST /payments rejects webhook/return-link fields (error 5023), so you register your notification endpoint URL and its HMAC key in the Paysafe portal, not in code. PayFanout only verifies what Paysafe sends.
Point the portal's notification endpoint at https://your-api.example/webhooks/paysafe, copy the HMAC key into PAYSAFE_WEBHOOK_HMAC_KEY, and mount the handler with the raw body (signature verification hashes the exact bytes):
import { createAdapterWebhookHandler } from "@payfanout/server";
const paysafeHook = createAdapterWebhookHandler(paysafe, {
onEvent: (event) => enqueue(event), // ack-fast: enqueue, dedupe by event.id; never process inline
});
app.post("/webhooks/paysafe", express.raw({ type: "application/json" }), async (req, res) => {
const r = await paysafeHook({ rawBody: req.body.toString("utf8"), headers: req.headers });
res.status(r.status).end();
});
app.use(express.json()); // AFTER the webhook routePaysafe retries effectively forever until it sees a 2xx, so your onEvent must enqueue and return fast. Paysafe has no public events-polling API (supportsEventPolling: false), for missed-webhook recovery, reconcile with retrievePayment per order. See Webhooks.
11. Test cards & the sandbox-currency trap
Match your account's currency
Paysafe sandbox accounts are usually provisioned for a single currency (the reference test account is CAD-only). If your session currency doesn't match, Paysafe.js fails at mount with error 9055. Set VITE_PAYSAFE_CURRENCY (client), and PAYSAFE_CURRENCY for saved-token/subscription charges (server), to your account's currency.
Use your Paysafe test account's documented sandbox cards, the exact set (and, for many accounts, an amount-based response simulator that triggers declines/3DS by transaction amount) depends on your account configuration. A commonly available test Visa is 4111 1111 1111 1111; confirm the current list, decline triggers, and 3DS test cards in your Paysafe portal rather than assuming.
12. Go live
- [ ] Swap in the live API username/password and the live public tokenization key.
- [ ] Set
environment: "live"on both adapters (host flips toapi.paysafe.com). - [ ] Confirm your live merchant account ids per currency/country and that
merchantAccountResolverreturns them. - [ ] Register the live notification endpoint in the portal and use its live HMAC key.
- [ ] Keep
PAYSAFE_SESSION_KEYstable and secret in production, rotate it deliberately (it invalidates in-flight sessions), and store it like any other secret. - [ ] Verify card fields are still the Paysafe.js hosted iframes (SAQ-A), no raw card input.
- [ ] Re-check endpoint paths, webhook header names, and error codes against the current Paysafe developer portal.
Then continue with Server usage, React usage, and Webhooks.