@payfanout/adapter-worldline-server
v3.0.3
Published
Server-side Worldline Direct adapter for PayFanout (Online Payments REST v2: Hosted Tokenization, Payments, Captures, Refunds, Webhooks). Holds secrets — never bundle client-side.
Readme
@payfanout/adapter-worldline-server
Server-side Worldline Direct adapter for PayFanout: Hosted Tokenization, Payments, Captures, Refunds, and Webhooks, over the Worldline Direct Online Payments REST API (v2).
Holds secrets. This package uses your Worldline Direct API key and secret. 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 Worldline · Server usage
Installation
pnpm add @payfanout/server @payfanout/adapter-worldline-server@payfanout/core comes in transitively.
Usage
import { PaymentService } from "@payfanout/server";
import { WorldlineServerAdapter } from "@payfanout/adapter-worldline-server";
const worldline = new WorldlineServerAdapter({
apiKeyId: process.env.WORLDLINE_API_KEY_ID!,
secretApiKey: process.env.WORLDLINE_SECRET_API_KEY!,
merchantId: process.env.WORLDLINE_MERCHANT_ID!,
environment: "sandbox", // never inferred from credentials
sessionSigningKey: process.env.WORLDLINE_SESSION_KEY!, // signs the stateless session context
webhookKeys: [
{ keyId: process.env.WORLDLINE_WEBHOOKS_KEY_ID!, secretKey: process.env.WORLDLINE_WEBHOOKS_SECRET_KEY! },
], // one key, or several during rotation — any active key verifying wins
// Worldline requires a 3-D Secure return URL on every card payment; sessions may override it.
defaultReturnUrl: "https://your-shop.example/checkout/return",
});
const payments = new PaymentService({ adapters: [worldline] });Pair it on the browser with @payfanout/adapter-worldline. This is a
tokenize-first PSP: the browser tokenizes the card into a hostedTokenizationId with the
Hosted Tokenization Page, 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, capture method, and the hostedTokenizationId are HMAC-signed
into pspSessionId at creation and verified at completePayment. Whoever holds the token
cannot tamper with the amount, and every context carries an expiry (sessionTtlSeconds,
default 1h) enforced at completion. encodeSessionContext / decodeSessionContext are
exported for advanced use.
The context is signed, not encrypted: whoever holds pspSessionId can read it, metadata
included, so keep secrets as well as personal data out of it and keep pspSessionId on your
server (createCompletionHandler never needs it in the browser). That keeps the token out of
the browser, not the metadata: the completion route answers the browser with the whole
PaymentInfo, metadata (and after a read-back, Worldline's echo in raw) included, so treat
Worldline metadata as visible to the customer. Metadata at Worldline's 1000-character limit
(below) adds up to about 4 KB to pspSessionId: 1,350 characters when it is ASCII, and 3,995
when every character takes three UTF-8 bytes, as € does.
The host id round-trips via order.references.merchantReference (PaymentInfo.id), and the
session's metadata via order.references.merchantParameters, sent JSON-encoded and echoed
by Worldline on reads and webhooks: retrievePayment reports it as PaymentInfo.metadata, and
readWorldlineWebhookMetadata(event) reads it from a webhook's payment. Worldline caps the
field at 1000 characters, so a session whose metadata, as the JSON sent, is longer or is not an
object of string values is refused with invalid_request before any call to Worldline.
Worldline also says the field "must not contain any personal data": keep personal data out of
a Worldline session's metadata. The adapter cannot tell and sends what it is given. Before
upgrading from 2.x, which ignored session metadata, remove personal data and anything the
customer should not see from it, and keep it within that limit
(Set up Worldline, step 7).
Authentication
Requests are signed with Worldline's v1HMAC scheme (Authorization: GCS v1HMAC:{apiKeyId}:{signature})
using WebCrypto — no node:crypto, so the adapter stays edge-compatible. The Date header is
sent and signed (RFC-1123 GMT); Worldline rejects timestamps older than five minutes, so the
clock is an injectable now() seam. Every mutating call carries a signed, deterministic
X-GCS-Idempotence-Key derived from the caller's idempotencyKey.
3-D Secure
Worldline lists a set of 3-D Secure properties as mandatory on every card payment, and the Hosted Tokenization Page requires at least those. Every CreatePayment the adapter sends carries the ones it can supply:
- The return URL, in both documented forms —
cardPaymentMethodSpecificInput.returnUrlandcardPaymentMethodSpecificInput.threeDSecure.redirectionData.returnUrl. It is required: passreturnUrlper session or setdefaultReturnUrlonce, absolute, with a scheme such ashttps://or an app scheme, at most 200 characters; a session with neither, or with a URL that breaks those rules, is refused withinvalid_requestbefore any call to Worldline. An emptyreturnUrlcounts as none. SetdefaultReturnUrlbefore upgrading from a release that did not require a return URL: sessions that release created without their ownreturnUrlcarry none, and completing them is otherwise refused. threeDSecure.skipAuthentication: false, never the deprecated flatcardPaymentMethodSpecificInput.skipAuthentication.- The browser's device data as
order.customer.device. The client adapter'sconfirm()sends it with thehostedTokenizationIdas a JSONclientToken,{"hostedTokenizationId":"…","device":{…}}, whichdecodeWorldlineClientTokenreads back. Only the fields a browser can read are kept —locale,timezoneOffsetUtcMinutes,userAgentandbrowserData— and each is checked against Worldline's documented types and lengths; one that fails is dropped rather than failing the payment.acceptHeaderandipAddress(request headers your server observes) anddeviceFingerprint(bound to a device-fingerprinting session) are defined by Worldline but refused from the browser, as is any key Worldline does not define. A barehostedTokenizationIdis still accepted and sends no device data. challengeIndicator: "challenge-required"when the session passessca: { challenge: "force" }.- A contact detail for Visa, which Worldline also requires: the adapter sends
order.customer.contactDetails.emailAddressfrom the session'sreceiptEmailorbillingDetails.email, so pass one of them. - The use case Cartes Bancaires requires,
cardPaymentMethodSpecificInput.paymentProduct130SpecificInput.threeDSecure.usecase: "single-amount", on every payment whatever the card's brand, which the adapter does not learn before paying. Worldline's 3-D Secure guide writesuseCase; the API contract and Worldline's Node SDK spell itusecase. The adapter neither stores cards nor charges them again, so each payment is a single amount.
The rest of the list does not come from this adapter:
- The cardholder name (
cardPaymentMethodSpecificInput.card.cardholderName) is collected in the Hosted Tokenization iframe's name field, which Worldline hides unless theTokenizerreceiveshideCardholderName: false; keep that field visible (see@payfanout/adapter-worldline). order.customer.device.acceptHeaderand, for Visa and Cartes Bancaires,order.customer.device.ipAddresscome from the customer's HTTP request to your server, not from the browser, and neitherCompletePaymentInputnorcreateCompletionHandlercarries them to the adapter today, so the adapter cannot send them.
sca: { exemption: "moto" } sends cardPaymentMethodSpecificInput.transactionChannel: "MOTO",
Worldline's channel for mail order and telephone order payments; without it no channel is
sent, and Worldline applies its ECOMMERCE default. The 3-D Secure data above is sent
unchanged on a MOTO payment: Worldline says its platform detects MOTO as outside the scope of
SCA, but not what it does with that data, so a MOTO payment it does not treat as excluded
still goes through 3-D Secure rather than skipping it. A challenge then comes back as
requires_action, and on a telephone order it would open in the browser the card was typed
into, not the cardholder's, so such a payment stays unfinished instead of being charged
without authentication. MOTO needs an acquirer and a Worldline account that allow it; run one
MOTO payment in the sandbox before relying on it.
Session creation also refuses, with invalid_request and before any call to Worldline, an
id longer than 40 characters (it travels as order.references.merchantReference) and a
statementDescriptor longer than 256 characters. The descriptor is sent as
order.references.softDescriptor, not the deprecated descriptor.
What's inside
WorldlineServerAdapter, the full server contract (create session / complete / retrieve, manual capture, cancel, refunds, refund polling).- Webhook helpers,
verifyWorldlineWebhookSignatureandparseWorldlineWebhookEvent, operating on the raw request bytes and emitting a normalizedUnifiedWebhookEvent. One event per delivery; a single-event array wrapper is unwrapped, and a multi-event batch is rejected rather than partially processed. The eventidisworldline:<type>:<payment id>(the refund's id when there is no payment), built only from the pair Worldline documents as identical across duplicate deliveries, since any other field may differ on a redelivery and let a duplicate through. Payment-link events,payment.testmessages and deliveries without the pair keep the envelope id. Worldline also warns: "The payment.id can change after each maintenance operation following an incremental logic. However, as this is not the case in some specific scenarios, we strongly recommend not building your business operations around it." So two events of one type on one payment id share an id, and a store keyed on it drops the second. On every verified refund-type delivery (payment.refunded,payment.refund_failed, andunknownevents whose lower-casedraw.typestarts withrefund.), whether or not its id was already seen, re-read:retrievePaymentforamountRefunded,retrieveRefundfor your refunds stillpending(both reads are idempotent). Also pollretrieveRefunduntil your refunds leavepending, reconcile captured payments withretrievePaymenton a schedule, which catches operations made outside PayFanout (Worldline recommends a back-up GetPaymentDetails check), and never sumevent.amountacross refund events. Events after a maintenance operation (capture, refund, possibly cancellation) can report the operation's id aspspPaymentId. Correlation routes each cover part of them:merchantReference, sent only whencreatePaymentSessiongets anid, shown echoed on sale events only and unverified on maintenance events; the refund id, the suffix of the compositerefundId(below), for refunds made throughrefundPaymentonly; andretrievePaymentwith the originalpspPaymentId. Parse no other id. Details: Set up Worldline, step 8. readWorldlineWebhookMetadata, the session metadata a parsed webhook's payment echoes, read by the rulesPaymentInfo.metadatafollows, sinceUnifiedWebhookEventcarries no metadata.mapWorldlineError, unifies Worldline errors intoPayFanoutError(business rejections are never replayed), andWORLDLINE_PSP_NAME.buildV1HmacAuthorization, the request signer, exported for testing.
Notes
- The transport retries timeouts/5xx/429 with backoff (
maxNetworkRetries, default 2); every money-moving call is idempotent, so a replay can never double-charge. - Worldline answers a repeated idempotency key with its first outcome for at least 24 hours, so a completion repeated after a failed attempt goes out again under a key derived from yours and that attempt: a stable per-order key still lets the customer pay with another card, and a completion repeated after a success returns that payment. A key carries at most 20 attempts; Set up Worldline, step 7, lists the other limits.
- Worldline Direct has no refund-by-id endpoint, so
refundPaymentreturns a compositerefundId({paymentId}:{refundId}) thatretrieveRefundresolves through the payment's refund list. The part after the last:is Worldline's own refund id — the one webhooks report. - Worldline Direct exposes no public events-list API (
supportsEventPolling: false), so missed-webhook recovery falls back toretrievePaymentper order. PaymentInfo.createdAtis the payment'spaymentOutput.transactionDate, which Worldline describes as "the server-side processing date and time of the transaction", read only when it carries a time zone (Z,±HH:MMor±HH): Worldline names no zone for a value without one, and its own SDKs read such a value differently. Whether a later capture or refund moves it is undocumented and not yet sandbox-verified, so keep your own record of when an order was placed. A payment that reports none, one without a time zone, or one that is not a date, gets1970-01-01T00:00:00.000Z.- Card vaulting, zero-amount verification, session update, and listing are out of scope for
this version (declared
false). - A capture or cancellation the acquirer refuses leaves the payment authorised: it reads
requires_captureand the authorisation stays inamountCapturable, ready to capture or cancel again.capturePaymentreports the refusal only when Worldline answers it synchronously; Worldline documents a capture asCAPTURE_REQUESTED(status code 91) first, so the call usually resolvesprocessingand the refusal (93) surfaces later, throughretrievePaymentor thepayment.rejected_capturewebhook, which parses asunknown. - A refused capture can also leave an automatic-capture payment at
requires_capture.usePaymentStatusfrom@payfanout/reactdoes not treat that status as final and keeps polling, so stop it (enabled: false) when it arrives. cancelPaymentresolvesprocessingwhile the acquirer has not confirmed the cancellation (status codes 61/62); thepayment.cancelledwebhook, documented for status code 6, or a laterretrievePaymentsettles it. Cancelling a payment Worldline reports as closed (already captured, for example) rejects with a non-retryableinvalid_request, and repeating a cancellation that already took effect answerscanceled. A 409 that persists while the payment is still cancellable stays a retryableprocessing_error, since an original under the same key may still be in flight; retry with the sameidempotencyKey.- A 409 on any call, Worldline's answer while the original request under the same key is
still being processed, is retried, and one that outlives the retries rejects with a
retryable
processing_errormarkedoutcomeUnknown: the original may still go through, so retry only under the sameidempotencyKey. - A refund the acquirer refuses leaves the payment
succeeded:retrieveRefundreports the refundfailedand it stays out ofamountRefunded. Itspayment.rejectedwebhook, carrying status code 73 or 83, arrives aspayment.refund_failed; that reading follows Worldline's Statuses reference and is not yet sandbox-verified. - Worldline documents the CapturePayment amount in cents with two assumed decimals, so a
partial capture in a currency without two decimals (JPY, BHD, …) is refused with
invalid_requestand no capture request is sent. Capture the full authorised amount (it is sent without an amount) or cancel instead.
Documentation
License
MIT
