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::TOLERANCEseconds 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.exampleandhttps://shop.example:8443are different sites. An address naming no port is not the same as one naming the default.shop.exampleis refused. Without a scheme it can never equal anything.https://shop.example/thanksis 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 underhttps://example.com/shopis 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/isshop.exampletoparse_url()andevil.exampleto 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.