Skip to content

Payments on another host

A site can take payments without holding any. kessai_client answers the same PaymentClientInterface the engine does, over HTTP, against a Kessai somewhere else; kessai_server is what puts that engine on a network. A module written against the contract runs on either kind of site without a line changing, which is the whole point of the split.

Why a site would want that: payment records are the most sensitive rows a site holds and the most regulated, and a shop, a booking site and an intranet form have no reason each to keep their own. One host holds the contracts with the providers, the payment table, the gateways and the cron; the rest ask it.

Four modules

Module Install it on Depends on
kessai every site, as a dependency of whatever uses it nothing
kessai_engine the host that settles payments kessai
kessai_server the host that settles payments for other sites kessai_engine, consumers, simple_oauth
kessai_client a site whose payments are settled elsewhere kessai, key

A site installs kessai_engine or kessai_client, not both. Both compile side by side and the client wins - a site part-way through a move settles through the client it installed rather than the engine it is leaving - but that is a migration state, not a configuration.

One consumer, one account

The unit of trust is a consumer on the engine host, and it is the consumers and simple_oauth modules doing the authenticating rather than anything here.

A consumer targets exactly one payment account, and that is read from the consumer rather than from the request. A payment account is a contract at a provider, so which one a caller's money lands in is the engine site owner's decision; a caller naming its own would be choosing it. Nothing a client sends names an account, and takePayment would not believe it if it did.

Fail closed. A consumer with no account set can take no payment at all. There is no account meaning "any", because picking one would move money into a contract nobody chose.

Retiring one. A consumer is retired by being disabled, not by being deleted, and the same is true of the account it is pointed at. A disabled consumer takes no new payment; it goes on reading the payments it already took and goes on being told when they move, because those are still its payments and a notice about money that moved is not something to withhold. That is the rule an account already follows.

And its client id is what a payment names it by, so once a payment has been taken the id is fixed: changing it is refused exactly as deleting the consumer is, and for the same reason. The rows that name the old id cannot tell a consumer that was deleted from one that now answers to something else. Rotating a consuming site's credentials means its client secret, which is a different field and untouched by this; a genuinely new identity is a second consumer registered beside the first, which can then be disabled.

The id is compared exactly as stored, the way simple_oauth authenticates it: shop and shop are two consumers, each answering for its own payments. One ending in whitespace is refused when the consumer is saved: MySQL and MariaDB ignore trailing spaces when they compare, so shop would be taken for shop. The same rule holds for every value kessai matches on: a payment's subject, and its metadata names and values.

Deleting either is refused while the other depends on it, and while payments name the consumer. The refusal is not tidiness. A payment records the consumer's client id and the account's uuid as plain values rather than references, so nothing at all breaks at the moment of deletion and everything breaks later: deleting a consumer leaves its payments readable by nobody, since every read is scoped to the consumer asking, and deleting the account leaves the consumer unable to take a payment while saying nothing until one is tried.

The keys are refused on the same terms - the one a remote's client secret is held in, and the two a Worldline account signs its calls and verifies its webhooks with. Refused, rather than declared as a config dependency, because a dependency would delete the remote or the account along with the key, which is worse than what it prevents.

Every one of those refusals is raised wherever the deletion is asked for, and not only on the confirmation form: drush, a bulk operation and another module's own code all reach storage without passing a form, and a rule that only shaped a form would be a rule only an operator had to follow. It is raised before anything is written, so what was refused is still there afterwards, along with everything that named it. A caller that catches it reads the reason from DeletionRefusedException::findInChain(), which finds the refusal whether storage handed it over as it was or rewrapped it on the way out.

Several consumers may share one account — a preprod site and a staging site pointed at the same test contract, say — and still never see each other's payments. Every payment records the consumer that took it, and a payment belonging to another is answered 404 rather than 403: a refusal would confirm the payment exists, and a consumer walking ids would learn how much business another one is doing.

Which consumer took a payment is a column on the payment, and nothing a request can influence: it is read from the identity the request authenticated as. What a consumer asks to be remembered is a separate thing, its own to name and never read by kessai — except that a name beginning kessai_ is refused, so nothing a caller records can be mistaken for something kessai put there.

Every door that reads a body refuses a field it does not read, with a 400 naming it. Dropped, a misspelled field would mean its default, and here the defaults move money: a capture sent amounts instead of amount would take the whole hold and close the payment, and a refund would return the whole balance. On the door that finds payments, a misspelled filter would widen the answer instead.

A door that takes a list of ids takes at most 100 of them, and refuses a longer list with a 400. It loads every payment it is sent, and the request limits count requests rather than ids. The client sends a longer list in several requests, so a caller of getPayments(), deletePayments() or forgetStoredPaymentMethods() passes whatever list it holds.

The same value on either side

On a site with an engine, a consumer asking for a payment names a kessai_payment_account. On a site with a client it names a kessai_remote, which is a config entity holding a host, an identity to authenticate as, and nothing about money.

It names both of them the same way: by the uuid of the account the money lands in. On the engine that is the account's own uuid; on the client it is the uuid the remote recorded when it shook hands, which is that same account over there. So a workflow configured on one site goes on meaning the account it meant when its payments move to another host, and getAvailableAccounts() answers the same shape - uuid to label - from either side. A site holds one remote per account it can pay into, and choosing between them is choosing an account, exactly as it is with an engine.

Asked for a payment that stores the payer's payment method, getAvailableAccounts() leaves out the accounts that cannot charge one later, as the handshake said: that is a gateway's capability and does not change from one checkout to the next, so no call is made for it. Asked with a maximum deadline, it asks each host whether its account can still take a payment by then, since whether the minimum time to pay fits is decided by the gateway over there. A host that cannot be asked is left out of the list, and the warning says so.

A name would not survive the trip. Each site calls its accounts whatever it likes, so worldline on one host and worldline on another are two different contracts as often as they are one, and nothing would say which.

Which account a remote's money lands in is not configured on the client. It is whatever the credentials land in over there. That is what makes moving a consumer from a preprod contract to a production one one field on one host, with nothing to redeploy on the other and nothing for a workflow to rename.

Whose payment is whose

A payment records which consumer asked for it, in a column of its own, written when it is created and never after. The engine never reads it — it compares it and no more — and it answers two questions rather than one.

Reading. A payment belonging to another consumer is answered as though it did not exist.

Creating. The engine answers a second ask with the payment already standing, so that a payer who submits a checkout twice is not charged twice, and it decides "the same payment" from the subject, the kind, the account and the amount. Two consuming sites number their own content independently — node 42 on one is node 42 on the other — and where they share an account, nothing in that list differs. The consumer is what differs, which is why it is part of the question, and why it is in the lock name too: without it, every site creating a payment for node 42 serializes against every other.

Stored payment methods. A payment method id is the payment method itself at the provider, and an account can be shared, so a consumer never names one. It holds or charges the subject's stored payment method, and the engine finds it among that consumer's own payments at its account, whose payer still agrees. Forgetting a payment method clears it on that consumer's payments only.

A caller on a site with its own engine sets nothing. The column is empty, which means "this site itself", and on a site that answers nobody else that is every payment.

The account uuid, and why a payment carries it

Each site names its accounts whatever it likes, so the name is no use across the wire - and two hosts can both hold a payment numbered 42. Every payment therefore records the uuid of the account it was taken at, in its account_uuid column, and carries it across the wire under the same name.

That is what routes a call back to the right host. A capture goes to the remote whose recorded account uuid matches the payment's, so a payment that arrived on its own - from a notice, carrying nothing this site put on it - routes as well as one this site created.

The uuid is read from the remote during the handshake and recorded on it, in state rather than in its configuration, as the notice secret is: which account the money lands in is decided on that side. An export would carry one environment's accounts to another, whose remote may be pointed at another host through settings.php, and the remote there would answer for payments it never took. So each environment learns its own. The accounts, and the access token, are kept with the address and the client id they were learned from. A remote that calls another host, another path on the same host, or authenticates as another consumer, from its form or from settings.php, reaches none of them until it shakes hands there: save the remote once on a site whose remote points somewhere new, which runs the handshake.

A copy of the site holding the payments is a copy of its consumers too. Their notice addresses and signing secrets come with the database, so the copy's cron tells the production consumer sites about the copy's payments, signed as production would sign them. On such a copy, clear each consumer's Kessai consumer site and allowed origins before cron runs.

It is also what makes an account being pointed elsewhere over there visible: a handshake answering a different uuid is somebody having moved the consumer to another contract, which may be exactly what they meant and is worth seeing either way.

Saving the remote after such a handshake keeps the account it replaces. New payments go to the account it is pointed at now; the ones taken at the earlier account stay there, and this site still routes their captures, releases and refunds, and accepts their notices, through the same remote.

The two questions that name no payment - reading payments back from ids, and finding them by what a consumer remembered - take their host from withAccount(), or from the account the query names. A caller always knows which account it took a payment through, so asking every host in turn would be a request per host on every lookup for an answer only one of them has.

Disabling a remote is how a client site retires a host. A disabled remote answers nothing. So a payment taken through a retired host names an account this site can no longer reach. That is ordinary, not broken.

Every call that would settle such a payment is refused. The caller is never told a capture happened on a host nobody asked.

forgetStoredPaymentMethods() is the one exception. It skips that payment and logs it, naming the payment and the account. Then it goes on with the rest of the list. The contract promises that one payment cannot stop the batch. Giving up would leave every other payer's card on file.

A site serving several domains

A consumer site serving several domains, by host or by path, with the Domain module or anything like it, gives each domain a remote of its own, each authenticating as a consumer of its own over there, and so each with its own account. A module taking a payment on a domain names that domain's account, as it names an account on every request.

Overriding one remote per domain does not work, and fails closed rather than mixing the domains' payments. A remote keeps one account, one token and one notice secret, those of where it last shook hands, so saving it on one domain leaves every other domain reaching no account. Cron and drush run with no domain, on the remote as saved, and could not settle a payment another domain's values took. A notice posted under a path-based domain without its prefix is read on the default domain. With a remote per domain none of that matters: a notice names its account in the route, which names the remote, whichever domain answers it. The status report warns about a remote that reaches no account where it calls.

The handshake

A remote is added under Configuration › Web services › Kessai client, one for each account this site pays into, and the notice limits are the tab beside the listing. Until there is an enabled one the site reaches nowhere and every request for a payment is refused, so the status report says so rather than leaving the first payer to find out.

Saving a remote asks that Kessai what it is, and refuses the save if it does not like the answer. One call settles everything an operator has just guessed at: whether the address is a Kessai at all, whether the credentials are accepted, which account the money will land in and whether that account can still take a payment, and whether the two sides can read each other's payments.

The answer also lists the currencies that account accepts, and the remote records them, so getAccountCurrencies() answers on this side without a call. A currency added over there later is seen once the remote is saved again. Until then, the host that holds the account is still the one that refuses a currency it does not accept.

That last one is PaymentWire::VERSION. Within a version an answer only grows: a key may be added and a reader that has never heard of it ignores it, but no key is removed, renamed or retyped. A request is held tighter: every endpoint refuses a field it does not read, so a field added to a request moves the version. Two sites on different versions are refused at the handshake, before a payment can be misread.

Failing here is the point. The alternative is finding out during a checkout, with a payer waiting at a provider and money possibly already moving.

Where the payer comes home

A payer taken for another site belongs back on that site when the provider is done, and the engine's own rule is "nowhere but here".

The consumer says where on the payment request. That is the only place it can: the link taking the payer to the provider is minted on the engine host, from that site's own hash salt, so what it carries has to be known when the payment is created. A site with an engine records the same field from the same request, so one piece of consumer code means the same thing either side of the wire — and there, handing an address to getHandoffUrl() still works and still wins, for a checkout that only learns where the payer belongs at the last moment. A remote payment refuses that, rather than quietly dropping it: its link was minted already.

$payment_client->create(new PaymentRequest(
  amount: new Money('42.00', 'EUR'),
  account_uuid: $account_uuid,
  kind: 'booking',
  return_url: 'https://shop.example/thanks',
));

The address is then checked where it enters the engine rather than when the payer is being redirected. By then the payment has been taken and the money may have moved, and the only choices left are following an address nobody vouched for or stranding the payer. The engine's own creation door holds the same rule, so the refusal is not this API's alone: a consumer on the engine's own host, naming an address no route would follow, is refused there rather than handed a payment whose payer can only ever land on a front page.

Every route that then sends the payer somewhere asks which origins that payment allows, and there are three: the gateway's own return route, the handoff route for the visits that never reach a provider - a payment already settled, one past its deadline, a gateway that could not start, and one that starts no round-trip at all - and a hosted checkout kessai serves itself, which is the simulator. A payer who opens their handoff link a second time after paying is the ordinary way the second of those is met.

All three read the address the payment recorded. Two of them can also be handed one on the link - the handoff route and the simulator's page - because a checkout may only learn where its payer belongs at the last moment. Both prefer the one on the link and fall back to the record when it is not somewhere this payer may be sent, which is the right way round: nothing signs the address on a link, and the link travels in browser history and in mail, while the recorded one was named in code and checked when the payment was created. A link carrying no address at all is what getHandoffUrl() mints when it is handed none, and so is the simulator's own checkout link, which carries a token and nothing else. The record is therefore what sends home every payer of a consumer that said the address on the request.

Sending a payer to another site is also said in the response, not only decided: core replaces a redirect that leaves this site with an error page unless the response vouches for the address. OnwardUrl builds that response, so a gateway whose return route uses it needs to know nothing about the rule.

An origin is scheme, host and port, all three, compared exactly and never as a prefix. https://shop.example does not admit https://shop.example.attacker.test, https://pay.shop.example, http://shop.example or https://shop.example:8443. Anything not registered is refused, and refused at the moment the payment is asked for rather than after it has been taken.

Being told when a payment moves

A consumer site cannot see a provider call back, a deadline pass, or an operator refund something on the engine host. Those arrive as notices.

Every event a payment announces is posted to the consumer that took it, and announced on the consumer's site under the same \Drupal\kessai\Event\PaymentEvents constant, carrying the same PaymentInterface. A subscriber written against a Kessai on its own site needs no change. PaymentEvent::isHandled() goes back in the answer, so a consumer that drove the outcome itself stands down whatever the engine site ships as its default reaction - the same return channel a local subscriber has.

The address is worked out, not configured. It is the consumer's site, then PaymentNotice::PATH, then the uuid of the account the payment was taken at:

https://shop.example/kessai-client/notice/<account uuid>

The engine already holds both ends of that. The uuid is its own account's, and the site is where that consumer's payer is sent home to - the origin registered against the consumer, which a site taking payments has already given. So an engine answering a thousand consumer sites stores one address each, and nothing at all about how any of them is built. Rename a remote on a consumer site and notices keep arriving, because the address never named it.

The consumer carries a Kessai consumer site field for the two cases the origins do not answer: a consumer that registered several of them, where guessing would mean posting a payment's details to whichever was listed first, and a consumer whose payer never leaves the engine and registered none. Where nothing says which site a consumer is, it is told nothing, which is the right answer for one that reads its payments when it needs them.

The field is also the one place to say a consumer site is installed under a path. Its routes answer below that path only, so a notice posted at the root of its host would reach nothing. Give the path with the site:

https://example.com/shop  ->  https://example.com/shop/kessai-client/notice/<account uuid>

The allowed origins stay origins, https://example.com: a payer's way home is matched on the origin alone.

Saving the remote on the consumer site runs the handshake, and the two sides agree a signing secret between themselves. The handshake answers with the address the engine worked out, so an operator can see where it will post and that it is the site they meant.

What a notice has to prove

The route is open, because the engine host is not a user of the consumer site and has nothing to log in with. The signature is the whole of what stands between a stranger and telling a site an unpaid order was captured, so:

  • The body is signed with the agreed secret, over the bytes that were sent. A receiver that re-encodes before checking is signing its own guess at what arrived.
  • The timestamp is inside the digest rather than beside it, so a notice recorded off the wire cannot be freshened and sent again. Anything more than PaymentNotice::TOLERANCE seconds out is refused.
  • A notice is checked before any of it is read as a payment.
  • The address picks the secret, so a host holding one remote's secret cannot speak for another account: a notice signed for one and posted to another's address does not verify. A notice for an account the site reaches through no enabled remote is answered as though there were nothing there, because for that site there is not.
  • The payment inside has to be that account's too. A held signature says which host sent the notice and nothing about which payment it may speak for, and the account on a payment is what every later capture, refund and release is routed by. A body naming another account is refused before the event is announced.
  • Before a secret has been agreed there is no secret, and a notice is refused rather than trusted. "Nothing to check against" must never read as "no check".

The secret is minted per consumer on the engine and handed over during the handshake, over the connection the client is already authenticated on, so an operator types it nowhere and the two sides cannot drift. It is not the OAuth client secret: consumers stores that hashed, so the engine cannot read it back, and it is the consumer's credential rather than a shared one. Holding the notice secret lets a consumer believe a notice; it takes no payment.

Delivery is best effort

A notice that does not arrive is logged and not retried. The money has already moved, the engine's record of it is right, and a consumer that missed a notice still has the payment to read - nothing about money depends on one arriving, which is what makes best effort an acceptable answer here rather than a corner cut. A retry queue is not built.

A consumer site that lets a notice run out of time is posted to once per request or cron run: one down behind a firewall that drops the connection, and one that takes the notice and answers slower than the timeout. Each notice waits up to eight seconds, and cron's expiry sweep announces one per payment it expires, so asking again for every payment would hold cron for minutes on a batch of one consumer's payments. The line logged for the first says the rest are not sent; the consumer can still read those payments back. A consumer that refused the connection or answered with an error status is still told about every payment: that costs nothing to hear.

Both halves write to the kessai channel, as every module here does: a notice that would not deliver and the one a consumer refused are two ends of one payment, and the rest of that payment's story is on the same channel. So one filter reads all of it.

Secrets, and where they live

Secret Kept Why there
OAuth client secret a key entity on the client out of config exports and out of the repository
Access token state, one per remote agreed with another host, not this site's configuration
Notice signing secret state on the client, key-value on the engine the same

Deleting a remote takes its token, its notice secret and the accounts it learned with it, so an operator removing a host they no longer trust does not leave its credentials behind.

Every call is https in both directions, and either end refuses anything else: the client secret, every access token and every payment travel that way, and so does every notice. A host on a trusted private network can be allowed explicitly, by naming it in the kessai.allow_http_hosts array in settings.php — which lives there and not in configuration on purpose, because it is an operator's statement about their own network and a config import must not be able to introduce one.

No call between the two follows a redirect: not the token request, not the handshake, not a payment call, and not a notice. A redirect could point at plain http, or somewhere inside the network, and carry the secret or the payment there. On the consumer site a redirect is refused, and the refusal names where it pointed: set the remote's address to where the other Kessai really answers. On the engine a notice answered with a redirect is logged like any other answer that is not a success, and the consumer is not told.

What an origin has to be

The consumer's allowed origins and its site are the engine's whole basis for deciding where a payer may be sent and where a payment may be posted, so a value that is not an origin is refused where it is typed rather than failing later. An origin is a scheme, a host and a port if it is not the default, and nothing else:

  • https://shop.example and https://shop.example:8443 are different sites. An address naming no port is not the same as one naming the default.
  • shop.example is refused. Without a scheme it can never equal anything.
  • https://shop.example/thanks is refused as an allowed origin. Vouching for a site by one of its pages would mean vouching for whatever else is under that host. The Kessai consumer site field takes a path, because it says where a site is installed rather than vouching for one: a site under https://example.com/shop is told there. A query, a fragment, an empty segment or a .. is refused there too, written plainly or encoded.
  • A user or a password before the host is refused, here and in every address a payer is sent to. These values end up in logs, and that part is where PHP and a browser disagree about the host: https://evil.example\@shop.example/ is shop.example to parse_url() and evil.example to the browser.

Each of those is refused three times over, because each refusal catches something the others cannot. A constraint on the field answers an operator typing into the form, on the field they typed into - and that is the only place core runs it: validate() is called by the entity form, by JSON:API and by REST, and never by save(). So hook_consumer_presave() runs the same constraint on the paths that save without validating, which is an update hook, a drush script, a migration through the entity API, or another module; it raises inside the storage's transaction, so the row is not written. What neither sees is a write straight to the table, and that is why the stored values are read through a check as well: ConsumerScope::readOrigins() is what every route that sends a payer home reads the origins through, and ConsumerSite reads the site the same way before posting to it. What is at stake is where a payer is redirected, and where a whole payment is posted.

A stored site that does not survive that check is answered as though the column were empty, which means the consumer's single registered origin answers instead. That is the same address a consumer that stored nothing is told at. A junk row is not a reason to tell a consumer nothing: it registered one origin, that is where its payer is sent home, and so that is where it is. Where it registered several there is still nothing to answer, because several origins are several places and a payment's details are not posted to whichever was listed first. The status report names every consumer holding such a value, under Kessai consumer addresses.

An amount on the wire

An amount crosses as one object, its figure and its currency together, in a request and in an answer alike:

{
  "amount": {"value": "1.234", "currency": "KWD"},
  "kind": "booking"
}

The figure is text, never a number: a JSON number is a float to most readers, and money does not go through a float. A door refuses an amount that is not such an object, that carries any other key, or that is not money in its currency, such as {"value": "10.50", "currency": "JPY"}, with a 400 naming amount. A payment answers every figure it carries the same way: captured_amount, released_amount, refunded_amount, hold_amount and balance are each an object of a value and a currency.

PaymentWire::writeMoney() and PaymentWire::readMoney() are the two halves, on whichever side is writing or reading.

Versions

The API paths carry no version, which is what core's own JSON:API does. The two sites agree on PaymentWire::VERSION at the handshake, and a remote whose version differs is refused when it is saved. Upgrade the host that settles the payments, then the sites that ask it.

Finding payments a page at a time

The find endpoint answers at most 100 payments per request, newest first, and refuses a larger limit. The next page is asked for with older_than, the id of the last payment of the page before. findPayments() on the client does that for the caller, so a query without a limit still returns every match, one request per hundred. A PaymentQuery can name a limit and an older_than of its own, which a caller reading a long history in pages passes the same way on either side of the wire.

A refusal across the wire

The host answers a refused request with one status per kind of refusal, and the client throws on its side what the engine would have thrown, so a consumer's own catch blocks work the same either side of the wire.

What the host refused Status What the client throws
A malformed request: a bad amount, an unknown field, a subject too long, a return address that is not one of the consumer's origins, a reserved metadata name, a payment reported paid or authorized when only its provider says so 400 \InvalidArgumentException
A payment that is not this consumer's, or does not exist 404 \InvalidArgumentException
The consumer itself: it is disabled, pointed at no account, or its role lacks the permission the door asks for. The exception names the remote and the host's reason 403 GatewayDeclinedException
The provider declined, for good 422 GatewayDeclinedException
The consumer asked more often than the host allows, so nothing was done: try again later 429 GatewayRetryableException
The provider could not answer yet: try again 503 GatewayRetryableException
Nothing: it answered with a redirect, which is not followed. The exception names where it pointed 3xx RemoteKessaiException
Anything else 500 RemoteKessaiException

A refused consumer is a decline because that is what an engine raises for an account it has disabled: nothing a retry changes, and nothing the caller did wrong in the request.

The movement that refund(), release() and giveBackMoneyAfterEnd() return, and each one getClaims(), getRefunds() and getReleases() read back, carries the payment as the host answered it, so getPayment() on it counts the money that has moved, as it does on a site with an engine.

A 500 says only that the operation failed. The exception behind it is logged on the host, in the kessai channel, and never sent to the consumer.

Asking again after an answer that never came

A host that cannot be reached, or does not answer before the remote's timeout, is a RemoteKessaiException, and says nothing about what the host did: it may have refused, or carried the request out and lost the answer on the way back. For a claim, a refund or a release that matters, because the host does not know a second request is the first one asked again. Name each one with a request key:

$payment_client->refund($payment, new Money('10.00', 'EUR'), 'goodwill', 'order-42-refund-1');

The key travels with the request, and the host keeps it on the movement it makes. Asked again under the same key, the host answers with that movement, or with the provider's refusal of it, rather than asking the provider to move the money a second time; a key already naming another amount is refused. A key is the consumer's own, unique among the payment's claims, refunds or releases, and up to 128 letters, digits, -, _, . or :. Without one, a repeat after the first went through is a second movement.

The remote's default timeout, 130 seconds, is longer than the host takes for one movement of money, which its operation lock bounds, so a consumer stops waiting only once the host is done one way or the other.