Skip to content

Worldline

kessai_worldline integrates Worldline Direct as a gateway (plugin id worldline). The payer is taken through a Worldline hosted checkout page; later holds and charges run as card-on-file subsequent merchant-initiated transactions against that checkout's own authorized payment id, so no stored card has to be reused.

It needs the Worldline Direct PHP SDK:

composer require wl-online-payments-direct/sdk-php

Without it the gateway reports itself unavailable: it starts no checkout, and a payment on this gateway simply never leaves pending.

Routes

Path
/admin/config/services/kessai/worldline The settings form below.
/kessai/worldline/return/{kessai_payment} Where the payer lands after paying. Public, guarded by a per-payment token.
/kessai/worldline/webhook Where Worldline POSTs notifications. Public, guarded by an HMAC over the raw body.

Credentials

Setting
API endpoint URL The pre-production endpoint while testing, production once live.
PSPID Your merchant id.
API key id, API secret key The Direct API credentials.
Webhook key id, Webhook secret key Used to verify notifications. See Settling a payment.

Secrets belong in settings.php

The two secrets are read from settings.php in preference to configuration, so a real secret never has to live in a config export:

$settings['kessai_worldline.api_secret'] = 'the-api-secret';
$settings['kessai_worldline.webhook_secret'] = 'the-webhook-secret';

When an override is present the matching form field says so and stores nothing. With no override, a value typed into the field is written to configuration, and will appear in an export.

Hosted checkout

Setting Default
Allowed number of payment attempts 3 How many times the payer may try inside one session. A declined card is retried on the same page rather than needing a new one.
Session timeout (minutes) 0 How long the payer has, and the payment's own deadline. See below.
Default hosted page locale fr_FR The fallback language of the hosted page.
Locale per language empty The language of the hosted page, per site language. See below.

The session timeout decides two things

It sets the provider session window, and it is what WorldlineGateway::defaultDeadline() reports to the engine as the payment's deadline. Both come from one value deliberately, so a checkout cannot outlive the payment it settles.

Leaving it at 0 does not mean "no limit". The payment falls back to kessai.settings:default_payment_deadline (see Architecture), and the session is clamped so it cannot outrun that deadline. With the shipped defaults that is thirty minutes, not Worldline's own three hours.

A session is never allowed to outlive its payment

The session is clamped when it is opened, to whichever is smaller: the configured window, or the time left on the payment. A fixed setting cannot hold that on its own, because the remaining window shrinks as the payment ages, so a payer returning near the deadline would otherwise be handed a fresh session that outlives it. If under a minute of the window is left, no session is opened and the payer is sent onward.

Without the clamp the shipped defaults invert: a three hour provider session against a thirty minute payment deadline means the payment is reaped while the checkout is still live, and the payer can still pay into it.

The hosted page follows the payer

Every payment records the language it was created in, and that is the language its hosted page opens in. Before this the gateway sent one configured locale to everyone, so an English payer got a French payment page.

A Drupal language code is not a provider locale, and the gap cannot be derived: the site knows en, Worldline wants en_GB or en_US, and only the site knows which. So the mapping is configured rather than guessed, one row per site language on the settings form.

Default hosted page locale is what everything else falls back to: a language with no row, a row left empty, and a payment with no usable language. A site that configures no mapping behaves exactly as it did before, and no combination of settings leaves the gateway without a locale to send.

Where the rows come from

The form keeps no list of its own. It renders one row per language the site has, read from the language manager, so the rows are whatever is configured at Administration > Configuration > Regional and language > Languages. Add a language there and a row appears; remove one and its row goes.

There is deliberately nothing to add or remove here. A payment's language is always one of the site's languages, or none at all, so a row for anything else could never be reached, and a hand-kept list would drift out of step with the languages that exist. Clearing a row removes a mapping: empty rows are dropped on save, so the stored map never holds a blank.

Multi-domain sites

The rows are identical on every domain. Core builds the language list by listing config names, and an override changes values rather than which config objects exist, so no per-domain override can add or remove a language.

Usually there is nothing to change either: one set of settings serves every domain, and what varies is the payment's own language, which is already domain-correct on a site whose domains differ by language.

A domain that genuinely needs different values can have them, since these are ordinary settings read through the config factory and domain_config overrides them like any other configuration; edit them through the domain switcher on the settings form, which otherwise edits the shared values. One thing to weigh first: the API credentials live in the same config object, so overriding per domain also lets a domain carry its own PSPID. That is either exactly what is wanted or a way to point a domain at the wrong merchant account. The secrets still come from settings.php either way.

Capture, cancel and refund carry an idempotency key

Each of the three sends CallContext::setIdempotenceKey() with the key kessai derives for that payment and operation, so Worldline collapses a repeat instead of moving the money twice. This covers the retry the engine's own lock cannot see, one raised outside Drupal entirely.

Worldline answers a key that is still in flight with HTTP 409, carrying error code 1409, which the SDK surfaces as IdempotenceException. The gateway treats it as retryable, not as a decline: the duplicate was refused rather than performed, and the first request is still settling.

Rejecting a checkout uses its own keys (undo-checkout-refund and undo-checkout-cancel) rather than the plain refund and cancel ones. Releasing money the engine is about to reject is a different operation from a refund an operator asks for later on the same payment, and the two must not collapse into each other if both happen.

The key is capped at 40 characters, which is Worldline's documented maximum. A raw Crypt::hmacBase64() is 43, so PaymentOperationKey trims it; sending a longer one risks the request's idempotency simply not applying. How long Worldline remembers a key is documented as at least 24 hours and is not measured here, which is fine, since the window only has to cover a retry. See What was measured.

An unreachable provider raises rather than returning nothing

Opening a checkout can fail three ways, and all three used to return NULL, which is also what a gateway that never redirects returns. The payer was bounced back with no payment page and no message.

Each now raises GatewayRetryableException: the gateway not being configured (the same reading deleteToken() already had, since configuring it makes the call work), the SDK call failing, and Worldline answering without a usable redirect URL or session id.

Two cases still return NULL, because for them there is genuinely nothing to open: a zero-amount payment, which Worldline rejects outright, and a payment with less than a minute of its deadline left, which would produce a session outliving the record it pays for.

One session per payment

A payer who reaches the handoff route more than once gets the session they already have, not a new one, for as long as it is open.

This matters because the provider returns the redirect URL exactly once. The hosted checkout GET returns a status and the created payment, with no URL, so a URL not kept at creation cannot be recovered. Minting a session per click would orphan the previous one, and the return resolves against the stored session, so a payer could come back against a session the site no longer tracks. Reuse also preserves the attempt allowance granted to that session.

The RETURNMAC the provider issues at creation is stored with the session and checked when the payer returns. One that does not match belongs to a session this site no longer tracks, so nothing is settled from it and the webhook resolves the payment instead. It is issued once too, alongside the URL.

Zero-amount payments start no checkout

Worldline rejects a zero-amount hosted checkout, and kessai never invents an amount. A caller that needs a card on file for a free booking should authorize a real amount, a guarantee say, through the payment amount.

Webhooks are separate credentials, and silent without them

The API key id and secret let this site call Worldline. The webhook key id and webhook secret are a different pair, and they let Worldline call back. Setting the first pair does not set the second, and a gateway that takes payments perfectly well can have no working notification channel.

With no webhook credentials the endpoint cannot verify a signature, so it accepts every notification and discards it. Answering 200 is deliberate, since the alternative is Worldline retrying forever against an endpoint that will never verify anything, but it means nothing bounces to tell you.

That matters because the webhook is the reliable half of settlement. The browser return is best-effort by nature: a payer can close the tab or lose signal after paying. Without webhooks such a payment settles only when the expiry sweep reconciles it with the provider, which is up to an hour later and bounded by the reconciliation budget.

The status report now says so, at warning severity, whenever the gateway is otherwise configured and the webhook credentials are missing, and the first discarded notification is logged on the kessai channel. Running without webhooks is supported; it should just be a decision rather than a surprise.

Settling a payment

A hosted-checkout payment settles through one of three channels, whichever arrives first; the others are idempotent no-ops, and all three go through the same finalizer so they cannot disagree:

  1. Browser return — the payer comes back to the return route. The query is untrusted, so nothing in it settles anything: the outcome is fetched from Worldline server-side.
  2. Server-to-server webhook — HMAC-verified over the raw body.
  3. The expiry sweep — before writing a payment off as expired, cron asks Worldline what became of it. See Architecture.

The webhook is what makes settlement reliable: a payer who closes the browser before returning is still settled, because Worldline notifies the server regardless. The third channel is the backstop for when neither of the first two arrived at all.

In an Orchestra workflow (through orchestra_payment), settling the payment resumes the parked payment step from its pinned token, so a booking is confirmed server-side even when the payer never came back.

What the provider's answer becomes

The finalizer only ever acts on a still-pending checkout payment, the one carrying the checkout session, so a later hold, a later charge, or a payment already resolved by another channel is left alone.

Provider says kessai records
Status category COMPLETED (a direct-sale account captured it) captured
Status category PENDING_MERCHANT (an authorize-only account held it) authorized, and the workflow's settle step captures it later
Payment status CANCELLED (the payer cancelled on the page) cancelled, a routine non-payment
Anything else failed
No payment created at all nothing; the payment stays pending and lapses at its own deadline

A checkout that created no payment is never recorded failed: nothing was charged, and a refusal arrives as a created payment in the REJECTED status. Nor is it recorded cancelled or expired here. Worldline reports a hosted checkout the payer cancelled and one that simply lapsed with the same CANCELLED_BY_CONSUMER status (see the measurements above), so it cannot tell the two apart, and a session can lapse while the payment still has time left, so an ended session is not evidence the payer is finished. The payment's own expires deadline decides, through the expiry sweep, exactly as it does for a gateway with no provider to ask.

That is the point of the change: one event, one answer. Before it, an abandoned checkout was recorded failed when reconciliation reached it and expired when the sweep got there first, so which word a site saw depended on the reconciliation budget rather than on anything the payer did.

An authorize-only account is settled off the request path deliberately, so the capture can retry the transient race described below rather than failing inline.

Two refusals that undo themselves

Before recording a payment paid, the finalizer checks two things, and if either fails it releases what was taken (voiding a hold, refunding a capture) and records the payment failed:

  • The amount and currency must match the local payment exactly. A tampered or mismatched return cannot settle a payment for the wrong sum.
  • A card needed on file must outlive its deadline. If the payment carries a card_valid_until and the card expires before it, a later hold or charge could not succeed, so it is rejected now rather than confirmed and broken later.

The gateway reference is stored as soon as there is one, whatever the outcome, so a cancelled or declined payment keeps its link to the provider-side record.

Turning the webhook on

Until both a webhook key id and secret are set, the endpoint answers Worldline with a 200 so it does not retry forever, but cannot verify the call, so nothing settles by webhook. To enable it:

  1. In the Worldline back office, create a webhook key and point its endpoint at https://your-site.example/kessai/worldline/webhook.
  2. Enter the Webhook key id on the settings form.
  3. Set the webhook secret in settings.php as above, or in the form field.

While it is off, early in testing for example, the browser return and the expiry sweep are what settle payments.

Card on file

A deferred hold or a later charge is made as an unscheduled merchant-initiated subsequent payment against the subject's own authorized checkout payment id. That is the canonical card-on-file path and needs no stored card, so the hosted checkout requests no tokenization and the payer sees no "save my card" prompt.

If the payer used the hosted page's own account-level save-my-card offer, Worldline mints an alias anyway; the finalizer deletes it, best effort, so no unneeded card is left on file.

A payment with no such checkout to anchor on falls back to charging a stored token, if it has one. verify() is a zero-amount account verification and never overwrites the payment's real gateway reference.

Failures and retries

Every operation raises rather than returning a boolean, and the gateway classifies why:

  • Retryable (GatewayRetryableException) when the transaction has not settled into a state that allows the operation yet. Worldline reports this as error code 50001127, or a message containing ACTION_NOT_ALLOWED_ON_TRANSACTION. The same call once it has settled is expected to succeed, so this is logged as a notice, not an error.
  • Declined (GatewayDeclinedException) for anything else.

Everything is logged to the kessai channel. A failed API call logs the HTTP status, the provider's error id, and each field-level error with its code and property, because the bare SDK message ("incorrect request") does not name the offending field.

What was measured against a pre-production account

Verified on 2026-07-31 against a Worldline pre-production PSPID, by creating real hosted checkouts and observing what came back. Recorded with the evidence so nobody has to run it again. Provider references: hosted checkout and idempotent requests.

The session timeout is in minutes, and the minimum is one

A checkout created with sessionTimeout: 1 was IN_PROGRESS immediately and CANCELLED_BY_CONSUMER seventy-five seconds later, while a sessionTimeout: 5 control created in the same batch was still IN_PROGRESS. The unit is minutes.

sessionTimeout: 0 is refused outright, with error 50001111 naming hostedCheckoutSpecificInput.sessionTimeout. One is accepted. So the minimum is one minute, and the gateway's rule of refusing to open a session with less than a minute of the payment's deadline left lines up with it exactly.

There is no maximum at 180. Both 181 and 1440 were accepted, so the documented three-hour default is a default and not a ceiling.

The redirect URL survives repeat visits

Fetched three times in a row, the same URL returned HTTP 200 with a byte-identical payment form each time. The page is revisitable, which is what makes reusing one session per payment safe rather than a gamble: a payer who goes back, refreshes, or follows a re-sent link lands on the page they left.

This was the assumption the whole one-session-per-payment design rested on, and it holds.

An expired session reports CANCELLED_BY_CONSUMER

There is no distinct "expired" hosted-checkout status. A session nobody touched, which lapsed purely on its timeout, reports exactly what a session the payer walked away from reports. Anything reading the hosted-checkout status cannot tell the two apart.

Idempotency covers every operation this gateway keys, but not checkouts

Worldline applies idempotency to a fixed list of methods: CreatePayment, CancelPayment, RefundPayment, CapturePayment, CompletePayment, CreatePayout and SubsequentPayment (reference).

Every operation this gateway attaches a key to is on it: capture is CapturePayment, cancel is CancelPayment, refund is RefundPayment, and the card-on-file path is CreatePayment or SubsequentPayment.

CreateHostedCheckout is not on it, which was confirmed here: the same key sent twice with different amounts produced two different checkouts. That is not a defect, the endpoint simply does not take part, and no key is sent to it.

This was confirmed end to end against pre-production, not taken from the page. An authorization was cancelled three times: once with a 40-character key, again with the same key, and once with a different one.

Call Key Result X-GCS-Idempotence-Request-Timestamp
1 K CANCELLED absent, so performed
2 K CANCELLED present, pointing at call 1
3 fresh refused, 50001127 absent, so performed

Call 2 is the proof: Worldline returned the first outcome and told us so through the timestamp header, rather than cancelling twice. Call 3 is the control, and shows what a duplicate does without a matching key: it reaches the provider and is refused, which is the second operation the key exists to prevent.

Two things came out of that incidentally. A 40-character key is accepted, so the trim is right rather than merely compliant. And 50001127 ACTION_NOT_ALLOWED_ON_TRANSACTION is the code this gateway already maps to retryable, now confirmed against the live provider.

The rules that matter when reading the code:

  • The key is at most 40 characters, ASCII. This bites. A raw Crypt::hmacBase64() is 43, so PaymentOperationKey trims to 40; 240 bits is far more than enough to keep operations apart.
  • A repeat is answered with the original outcome, even if the body differs. So a key must name one logical operation and nothing else, which is why rejecting a checkout uses keys of its own rather than reusing the plain refund and cancel ones.
  • A key is remembered for at least 24 hours, which comfortably covers any retry this module makes.
  • A duplicate still in flight answers HTTP 409, surfaced by the SDK as IdempotenceException and treated here as retryable rather than declined.

Do not measure a key limit against a non-idempotent endpoint

Key lengths of 32 through 256 were all "accepted" by CreateHostedCheckout during this exercise, and the conclusion drawn was that length did not matter. It could not fail: that endpoint never processes the header, so nothing validated the key. The documented 40-character limit was found afterwards, and the key had been three characters over it the whole time. A check can only tell you something if it is capable of failing.

Known limits

Established by reading the SDK and the provider documentation:

  • The SDK exposes no HTTP timeout. No connect timeout, no read timeout; the only timeout in the whole package is the hosted checkout session. A hung request cannot be bounded from this side, which is why the expiry sweep caps how many payments it asks about per cron run rather than relying on calls returning promptly.
  • The redirect URL and the RETURNMAC are returned exactly once, by createHostedCheckout(). Neither can be fetched again, which is why the engine persists both the moment they arrive.