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/payments/accounts |
The accounts this site holds. A Worldline account is configured here, not on a form of its own. |
/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¶
Every setting below belongs to one account, not to the module. A site holding a test account beside its production one configures each separately, and a payment records which of them took the money.
| Setting | |
|---|---|
| API endpoint URL | The pre-production endpoint while testing, production once live. |
| PSPID | The merchant id this account pays into. |
| API key id, API secret key | The Direct API credentials. |
| Webhook key id, Webhook secret key | Used to verify notifications. See Settling a payment. |
| Accepted currencies | The currencies the contract behind this PSPID accepts, for example EUR. A payment in any other is refused before Worldline is asked. See Accounts. |
Secrets are keys¶
The two secrets are not typed into the account. Each names a key, so the credential can be held in a file or an environment variable and never reaches a config export.
There is no settings.php override, because there is nothing for one to
override: a site may hold several accounts and an override has one slot. Create
a key per account per secret, and select it on the account.
Timeouts¶
Each account bounds how long a call to Worldline may take.
| Setting | Default | Allowed | |
|---|---|---|---|
| Connect timeout (seconds) | 5 | 1 to 30 | How long a call waits to reach Worldline. |
| Timeout (seconds) | 30 | 1 to 55 | How long a whole call may take, connecting included. |
For example, an account on a slow network can wait longer to connect:
settings:
api:
connect_timeout: 10
timeout: 30
The timeout stops at 55 seconds because of the engine's 120-second operation lock. The most calls one operation makes under it is two: giving back money taken after a payment ended reads the payment, then refunds or releases it. Two calls of 55 seconds end ten seconds before the lock does. A longer call could outlast the lock, and a second caller could then ask Worldline about the same money.
An account saved without these settings uses the defaults.
A call that runs out of time fails as a cURL error, which the gateway treats as retryable. See Capture, release and refund carry an idempotency key.
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::getDefaultDeadline() 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_engine.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 expired while the checkout is still live, and the payer can still pay into it.
A Worldline account needs at least a minute before the deadline, its shortest session: when a site bounds a checkout, the account is not offered with less left. Because each session is sized per payment and a lapsed one is replaced at the next handoff, a Worldline account can also let a site extend its deadline. See Bounding a checkout.
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. One configured locale for everyone would hand an English payer 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 account's own 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 sends the fallback for every payment, 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.
A site with one language does not install the language module, and then there is no such page. The section is still there, with the one row, because a payment that carries no language falls back to the default locale and a row naming the language is therefore not the same answer as leaving it empty. Only the link is dropped; the sentence above the rows says the same thing either way.
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 account 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 credentials should have its own account, and the consumer that takes payments on that domain should name it. That is what accounts are for, and it is better than overriding one account per domain in two ways: the payment records which account took the money, so a capture or a refund years later still reaches the right merchant; and the two sets of credentials are visible side by side rather than hidden in an override layer.
Capture, release and refund carry an idempotency key¶
Each of the three sends CallContext::setIdempotenceKey() with the key kessai
derives for that 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.
None of the three derives its key from the payment alone, because a payment can
have several of each: a hold claimed in parts, a capture given back in parts, a
hold released in parts. Keyed on the payment, two of any of them would collapse
into one at Worldline and the second would silently move nothing. Each keys on
its own row (PaymentOperationKey::getCaptureKey(), ::getRefundKey() and
::getReleaseKey()), written before Worldline is asked, so a retry of an
unanswered one asks under the key it was first sent with.
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.
A call Worldline never answered is retryable too: the SDK raises a cURL
failure, a timeout among them, as \ErrorException, and a
platform error (HTTP 5xx) as PlatformException. Either way the request may
have been carried out and the answer lost, which is the case the key exists
for. The engine keeps the claim, refund or release row pending, or the
card-on-file payment, and asks again under the same key; Worldline then answers
the first request instead of moving the money a second time. That holds while
Worldline remembers the key: one asked again 23 hours or more after it was first
asked is not sent again (see Failures and retries).
Giving back money moved on a payment that ended without money, a rejected
checkout's among it, uses a key naming the Worldline transaction
(money-after-end-<transaction>-refund or -cancel) rather than the plain
refund and release ones. The same money is then one operation however it is
asked for: an undo whose answer was lost and the give-back that retries it
collapse at Worldline, while it remembers the key, instead of the second being
refused as a refund of more than was taken. Both give back the whole
transaction, so a give-back asked later still moves nothing twice: Worldline
refuses it, and the payment stays owed although the money went back. Record that
with recordMoneyAfterEndGivenBack() once the merchant console shows it. An
operator's refund is keyed on its own row, so it never collapses into this.
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; nothing is asked again past 23 hours, so the window covers every retry
this module sends. See
What was measured.
An unreachable provider raises rather than returning nothing¶
Opening a checkout can fail three ways, and NULL cannot express any of them: NULL is what a gateway that never redirects returns, so a payer would be bounced back with no payment page and no message.
Each raises GatewayRetryableException instead: the gateway not being
configured (as deleteStoredPaymentMethod() does, 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. A session is only
ever replaced after it lapsed, and the hosted checkout is asked what became of
it first: a payment made on it as it lapsed is settled then, and no other
session is opened. 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.
Amounts are in the currency's smallest unit¶
Worldline counts an amount in the smallest unit of its currency, following the
ISO 4217 exponent: a hundredth of a euro, a whole yen, a thousandth of a
Kuwaiti dinar. MinorUnits converts a Money by that exponent both ways, so a
1000 JPY payment is sent as 1000 and a figure stated in fils comes back as a
dinar amount of three decimals. The exponent is the one Money holds (see
Money).
An amount with more decimals than its currency has, such as 10.50 JPY, never
reaches the gateway: it is not a Money at all. The one amount the gateway
declines is one whose minor units are more than an integer holds, which only a
CLF or UYW amount near the largest a payment holds can be.
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.
A site holding two accounts has a second thing to know about this pair: one URL answers for both, so verification holds both keys at once and a held signature says only that one of them signed. Which one is in the key id the notification carries, and the payment it names has to have been taken at that account. A pre-production account beside a live one is the ordinary way a site gets to production, and without that check either could settle, fail or cancel the other's payments - a payment id is one sequence for the whole site.
Two sites on one contract, a test site beside another or a site and its
copy, both hear every notification the contract sends, and each numbers its
payments from one. So the merchant reference names more than the id:
kessai-payment-<id>-<first eight characters of the payment's uuid>. A
notification whose reference was built for another site's payment of the same
id names no payment here, and is logged and left alone.
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 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 payment handler so they cannot disagree:
- 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.
- Server-to-server webhook — HMAC-verified over the raw body.
- 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.
They also arrive together, so the handler settles one channel at a time: it takes a lock named for the payment and re-reads the payment's state inside it, exactly as the engine does for its own money operations. Without that, two channels that had each loaded the payment before either of them wrote would both read it as pending, and both would settle it. The undo of a rejected checkout would reach Worldline once, since it carries an idempotency key, but the movements recording it would be written twice, and the loser saving its own stale copy would put the winner's totals back to zero.
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 payment handler only ever acts on a still-pending payment: a checkout payment, carrying the checkout session, or a hold or charge on a stored card whose own answer never came back. A payment already resolved by another channel is left alone.
| Provider says | kessai records |
|---|---|
Status category COMPLETED (a direct-sale account captured it, or a stored-card charge went through) |
captured |
Status category PENDING_MERCHANT (an authorize-only account held it, or a stored-card hold went through) |
authorized, and the workflow's settle step captures it later |
Payment status CANCELLED (the payer canceled on the page) |
canceled, a routine non-payment |
Status category UNSUCCESSFUL (Worldline refused it) |
failed, except from a webhook about a checkout, below |
| Any other status category (created, pending the payer or a third party) | nothing; the payment stays pending for the next report |
| No payment created at all | nothing; the payment stays pending and lapses at its own deadline |
A webhook reports each attempt, and the hosted page lets the payer try again after a refused one, so a webhook about a checkout settles only an accepted payment or the payer's cancellation. One about an earlier session of the same payment, which lapsed before the payer finished, settles only an accepted payment: the payment holds a newer session, which the payer may still be on. How a checkout ended is read whole, from the hosted checkout, by the payer's return and the expiry sweep. A stored-card hold or charge has one attempt, so its webhook is taken as it comes.
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 canceled or expired here. Worldline reports a hosted
checkout the payer canceled 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.
One event, one answer. An abandoned checkout is expired, whether or not a run had the reconciliation budget to ask about it first, so which word a site sees never depends on how busy cron was.
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 payment handler checks two things. If either fails it releases what was taken, voiding a hold or 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
payment_method_valid_untiland 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 canceled 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:
- In the Worldline back office, create a webhook key and point its endpoint at
https://your-site.example/kessai/worldline/webhook. - Enter the Webhook key id on the account, under Payments > Accounts.
- Select the key holding this account's webhook secret, on the same form.
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 payment handler deletes it, best effort, so no unneeded card is left on file: this gateway never reuses a saved card, and a failure to delete must not stop the payment settling.
A hold or a charge with no such checkout to be made against is refused. A payment method id a payment happens to carry is never charged instead.
Only with the payer's agreement. A checkout is the source payment of a later
charge only when it was created with store_payment_method, which is the payer
agreeing at checkout that their card may be charged again with them gone. A
subsequent payment needs no stored card here, so that agreement is what stands
for one. forgetStoredPaymentMethods() withdraws it: once a checkout's card is
forgotten, no new hold or charge is made against it. One already asked whose
answer never came is still asked again, as the next paragraph explains.
The source payment is chosen once. The first ask of a hold or charge records
on it the checkout it is made against (source_payment), before Worldline is
asked. A payment asked again after an answer that never came is the same request
under the same key, so it is made against that checkout again rather than
looking for one: by then the payer may have withdrawn the agreement, or a newer
checkout taken its place, and looking again would decline the payment, or send
another request, with the card already charged.
The source payment belongs to one account. A provider payment id is a transaction at the merchant that created it, so a site holding a test account beside its production one never charges one against the other's checkouts. The source payment is looked for among the checkouts taken against the account the hold or charge itself names, which is the same rule the engine applies to its own stored payment method lookup.
And to one consumer. Where a site answers consumers on other hosts, several of them may be pointed at one account, and a subject is each consumer's own word for what it is charging for: two consuming sites that number their own content collide on it. So the source payment is looked for among the checkouts taken for the same consumer as well, and a charge that finds none is refused rather than sent against a card this consumer was never given. Worse than the account case rather than the same, which is why it is said separately: across two accounts the source is a transaction the merchant has never seen and the platform refuses it, while here it is a live transaction at the right merchant and the other site's payer is charged.
Failures and retries¶
Every operation raises rather than returning a boolean, and the gateway classifies why:
- Retryable (
GatewayRetryableException) when a claim, refund or release meets a transaction that has not settled into a state that allows it yet. Worldline reports this as error code50001127, or a message containingACTION_NOT_ALLOWED_ON_TRANSACTION. A release racing the authorization it voids gets that answer and clears on a retry, so it is logged as a notice, not an error. - Declined (
GatewayDeclinedException) for anything else, and for that same code once the claim, refund or release was first asked more than 15 minutes ago. The code also answers an operation the transaction will never allow: a capture on a closed authorization, or on a hold that lapsed (the 17-day one below). Retried for ever, that claim would stay unanswered, and the hold could never be released. An operation with no claim, refund or release of its own is declined on that code straight away: a hold or a charge made with the payer gone, giving back money after a payment ended, and deleting a stored token. - Found carried out when a claim, refund or release whose answer was lost
cannot simply be asked again. Each capture, refund and release carries its
movement's own key as its merchant reference, and the gateway reads the
transaction's operations (
getPaymentDetails) instead of trusting the answer: one carrying that key, carried out (captured, refunded, or a hold cancelled), completes the movement; one still on its way is retryable; none, or any other status, is the decline. That is done in two cases. - Refused as not allowed, past the 15 minutes. A transaction refuses an operation it will never allow, and also one that already happened.
- Asked again 23 hours or more after it was first asked, before it is sent. Worldline remembers a key for at least 24 hours. Past that, a final capture or a full refund asked again would be refused, but a partial one would be carried out a second time, moving the money twice while one row records it. So such a movement is never sent again: found carried out, it is completed; otherwise it is failed, with an error naming the transaction to check in the merchant console.
A hold or a charge made with the payer gone and asked again 23 hours or more after it was first asked is not sent again either. No lookup finds a subsequent payment by its key, so it is failed, with an error naming the merchant reference to look for in the merchant console. If the first ask did take the money, its webhook reports it on the failed payment as money taken after it ended, which is given back from there.
Whether Worldline returns that reference in the operations, and accepts a
40-character key with - and _ in it as one, has not been measured against
a pre-production account yet. If it does not return it, nothing is found: a
movement refused as not allowed is declined as without the lookup, and one
asked again after a day is failed rather than sent.
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 can be revisited, 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.
The partial money operations behave as the engine assumes¶
Measured 2026-08-10 on an authorize-only account, against two fresh 150,00 authorizations taken through the hosted checkout, plus one transaction from 2026-07-23 kept to test age. Every operation went through the gateway itself except where noted, so what was exercised is the request this module builds and not a hand-written one.
| Asked | Answer |
|---|---|
Claim 30,00 of 150,00 with isFinal: false |
Authorization stays open |
| A second claim of 20,00 against the same authorization | Accepted |
| Each capture | Carries its own provider id, kept on the claim row |
Release 60,00 of the 100,00 remainder (amountOfMoney + isFinal: false) |
Accepted, authorization stays open |
| Claim the last 40,00 after that release | Accepted |
| That release's own reference | None issued. CancelPaymentResponse carries only the updated payment |
Claim 40,00 of 150,00 with isFinal: true |
Accepted, and the authorization closes (see below) |
| A further 10,00 capture on that closed authorization, sent straight to the platform | Refused, 50001127 ACTION_NOT_ALLOWED_ON_TRANSACTION |
| Refund 20,00 of a 90,00 balance | Accepted, with its own provider id |
| A second refund exhausting the balance | Accepted, payment reaches refunded |
The final-capture row is the one worth having. isFinal: true really does close
the authorization and release what was not taken, which is what lets a caller
claim less than was held and trust the rest to go back to the cardholder. It was
tested by bypassing the engine's own isHoldOpen() guard and asking the platform
directly, because the guard would otherwise have answered for it: a local refusal
proves nothing about the provider.
Refunds were accepted against captures that had not yet settled, so a refund does not have to wait for settlement.
Two clocks: a hold expires, a source payment does not¶
The most consequential result, and it is one transaction answering two questions at the same moment, 17 days after it was authorized:
| Asked of the same 17-day-old payment | Answer |
|---|---|
capturePayment |
Refused, 50001127 ACTION_NOT_ALLOWED_ON_TRANSACTION |
subsequentPayment, a new authorization made against it |
Accepted |
The new authorization was then read back from the platform to confirm it was real
rather than merely a 200: PENDING_CAPTURE, isAuthorized true, 5,00.
So the money and the mandate age differently. A hold left standing for weeks can no longer be claimed, while the cardholder-initiated transaction underneath it stays a good source payment for later charges. That is what makes a long lead time work. A booking taken months ahead must not sit on one hold and hope to capture it at the end; it places a fresh authorization against the original checkout when the money is actually wanted. Nothing observed suggests a limit on how much later that can happen: 17 days was simply the oldest transaction available, not a ceiling that was found.
Nothing in the answer says a hold has died
getPayment reported PENDING_CAPTURE with isAuthorized true for that
payment moments before it refused the capture. Neither the status nor the
authorized flag can be used to decide whether a hold is still good. The only
way to find out is to try to take the money, so a flow that depends on
capturing an old hold has no way to check first, which is the real argument
for authorizing late rather than early.
Do not read a status as a settlement either
The parent payment reported PENDING_CAPTURE throughout the capture run
above: after each partial capture, after the final capture, and after the
platform itself refused to add anything more to that authorization. Captures
and releases settle asynchronously and the parent status lags behind them,
so what is still held is the engine's arithmetic (getHoldAmount()), never
the provider's status field.
A source payment works exactly while the platform still shows it live¶
findSourcePayment() accepts a checkout whatever state the payment
ended in, reasoning that releasing a hold does not undo the authentication that
placed it. Measured, that reasoning is right, and the rule is sharper than the
state of the kessai record:
| Source, as the platform sees it | kessai state | Age | Charge against it |
|---|---|---|---|
PENDING_CAPTURE, authorized |
released | 21 days | Accepted |
PENDING_CAPTURE, authorized |
captured | 21 days | Accepted |
PENDING_CAPTURE, authorized |
authorized | 17 days | Accepted |
PENDING_CAPTURE, authorized |
released hours earlier | same day | Accepted |
| Captured / refunded | captured / refunded | same day | Accepted |
CANCELLED / UNSUCCESSFUL, not authorized |
canceled | 10 days | Refused, 20001101 |
A source payment is accepted if and only if the platform still shows the transaction authorized. Age is not a factor: the refused one is younger than three accepted ones, and 21 days is simply the oldest tried.
The kessai state is not the test either, and the first row is the proof: a
payment this engine records as released was charged against perfectly well at
21 days,
because nothing had ever voided it upstream. The two ways a payment ends with
nothing taken are two quite different histories:
- canceled: the payer abandoned or canceled at the hosted page, so the platform voided the transaction. Nothing can be charged against it. This is what the refused row is.
- released: this engine released a hold. The transaction may well still be
live, and it can still be charged against. A release settles on the platform's
own schedule, so even a genuine one does not show up straight away: one sent
through this gateway was accepted, and hours later the payment still read
PENDING_CAPTUREwithisAuthorizedtrue and could still be charged against.
So there is no local signal that reliably says whether a source payment will work, and the lookup does not pretend otherwise: it ranks candidates and excludes none. A checkout this site has released is passed over while a live one is available, since it is the likelier of the two to have been voided, and is still used when it is all there is. So a subject whose newest checkout was voided keeps its stored payment method through an older live one, and a source payment that works is never ruled out for the state kessai happens to record.
Only the request itself can answer
A caller that must know whether a source payment is good has to ask the
platform for the transaction and look at isAuthorized, or simply attempt
the subsequent payment. Neither the payment's state here nor its age
predicts it.
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 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 canceled 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 canceling 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 what an operation the transaction no
longer allows gets back, which is why the gateway retries it only while the
movement is fresh: see Failures and retries.
The rules that matter when reading the code:
- The key is at most 40 characters, ASCII. This bites. A raw
Crypt::hmacBase64()is 43, soPaymentOperationKeytrims 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 release ones, and why each refund of a payment is keyed on itself rather than on the payment they share.
- A key is remembered for at least 24 hours. A claim, refund or release asked again later is not sent again but looked up among the transaction's operations: see Failures and retries.
- A duplicate still in flight answers HTTP 409, surfaced by the SDK as
IdempotenceExceptionand treated here as retryable rather than declined.
Do not measure a key limit against a non-idempotent endpoint
CreateHostedCheckout accepts key lengths from 32 to 256 without complaint,
which proves nothing: it never processes the header, so nothing there
validates a key at all. Measure the limit against an endpoint that is
idempotent, such as CancelPayment. 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 applies a timeout only when its connection is given one.
DefaultConnectionreads its connect and read timeouts from theCommunicatorConfigurationpassed to its constructor, and waits for ever without one. The module passes it, with the account's timeouts. - 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. - A capture sends the claim's amount and its
isFinal.truecloses the authorization and releases anything unclaimed;falsekeeps it open for a second claim.CaptureResponsecarries an identifier of its own, which the engine keeps on the claim row, so one claim of several can be matched against the provider again. - A partial release sends
isFinal: falseand its own amount. CancelPayment takes anamountOfMoney, so part of a hold can be released while the rest stays claimable;isFinalis computed from whether the release exhausts the hold.CancelPaymentResponsecarries only the updated payment, so Worldline issues no reference per release the way it does per refund, and the release row's reference stays empty here. - A partial refund sends
isFinal: false. RefundPayment takes the refund's own amount, andisFinalsays whether the merchant expects more refunds against the payment, so the gateway computes it from whether this refund exhausts the payment's balance. Worldline gives each refund its own identifier and status, which the engine keeps on the refund row.