Skip to main content

Payment service

The payment service provides an implementation for kiosk payments, by card terminal, by cash or deferred to the counter. When registered, the platform uses your plugin to process payments of that type.

Registration​

export default async context => {
context.service.addService({
type: 'paymentService',
paymentType: 'terminal',
startPayment: async params => {
// params contains: paymentSessionId, orderId, amount, currencyIsoCode, uiCultureCode
// ... handle the payment
return { status: 'ReservationStarted', requestData: params };
},
});
};

Payment types​

The paymentType property tells the platform which payment method your service handles:

Payment typePayment method
terminalCard payment at a card terminal
cashCash payment at a cash drawer or note recycler
deferredThe customer orders at the kiosk and pays later, at the counter

It decides two things:

  • Which payment method button the checkout offers, and which service drives the payment the customer picks. This is always the case.
  • Which waiting screen the customer sees while the payment is in progress: a cash payment replaces the "insert your card" screen with a cash equivalent, so the customer is not asked to insert a card while paying with cash, and a deferred payment shows a "Preparing payment" spinner, since there is nothing for the customer to do at the kiosk. This applies only when your service leaves renderOngoingPayment out. See Control the waiting screen.

When the checkout offers a payment type​

The checkout offers a payment type once a plugin has registered a service for it. Some payment types can also be paid without a plugin, and are offered whenever that is possible:

Payment typeWithout a plugin
terminalThe platform drives the card terminal itself, so card payment is always offered.
cashNot possible. Cash is only offered when a plugin registers a cash service.
deferredOffered when deferred payment is enabled in the platform's payment configuration.

A registered service always takes precedence, and the checkout shows one button per payment type either way. The checkout picks its payment methods when it opens, so a service registered after that is not offered until the customer next reaches the checkout.

Deferred payment​

info

The deferred payment type is available from the October/November 2026 release.

Deferred payment enabled in the platform's payment configuration is available to anyone running the kiosk interface, including outside a real kiosk, so anyone could place an unpaid order. A deferred payment service only exists on kiosks whose plugin registers it, which makes it the way to offer deferred payment only on real kiosks: leave it disabled in the payment configuration and register the service instead.

The service's startPayment registers the deferred payment with the platform's payment side, the same as any other payment type, and returns ReservationStarted. The payment flow that follows is the same as well. See Payment flow after startPayment.

export default async context => {
context.service.addService({
type: 'paymentService',
paymentType: 'deferred',
startPayment: async params => {
// ... register the deferred payment for params.orderId
return { status: 'ReservationStarted', requestData: params };
},
});
};

Register a receipt service as well. It is the only way a customer who pays later gets a slip to take to the counter, because the platform does not print its own order confirmation once a payment service is registered. See Receipts.

Registering more than one payment type​

To handle both card and cash payments, register a separate service for each type:

export default async context => {
context.service.addService({
type: 'paymentService',
paymentType: 'terminal',
startPayment: async params => {
// ... drive the card terminal
return { status: 'ReservationStarted', requestData: params };
},
});

context.service.addService({
type: 'paymentService',
paymentType: 'cash',
startPayment: async params => {
// ... drive the cash drawer
return { status: 'ReservationStarted', requestData: params };
},
});
};

Receipts​

Registering a payment service of any type changes how the kiosk handles receipts for every payment, not only the payments your service drives:

  • The customer is no longer asked whether they want a receipt before paying.
  • After the order is placed, the customer picks from the receipts your receipt services print.

Register at least one receipt service alongside your payment services. Without one, the customer is never asked about a receipt at all.

warning

This also applies to card payments the platform drives itself. On a kiosk without a terminal service, registering only a cash or deferred service stops card payments from asking about a receipt, and the platform prints its default receipt instead. Register a receipt service to give the customer the choice back.

Request parameters​

The startPayment method receives an object with the following properties:

PropertyTypeDescription
paymentSessionIdstringIdentifier for the payment session
orderIdstringThe order being paid for
amountnumberThe total amount to charge in the smallest currency unit (e.g. 100 for $1.00)
currencyIsoCodestringCurrency code (e.g. "SEK", "NOK")
uiCultureCodestringCulture code for localization (e.g. "en-GB")

Responses​

Success​

Return a ReservationStarted response when payment has been initiated successfully:

return {
status: 'ReservationStarted',
requestData: {
paymentSessionId: params.paymentSessionId,
orderId: params.orderId,
amount: params.amount,
currencyIsoCode: params.currencyIsoCode,
uiCultureCode: params.uiCultureCode,
},
};

Error​

Return an Error response when payment fails:

return {
status: 'Error',
error: {
code: 'kiosk.payment.error.amountdeclined',
message: 'A human-readable error message',
},
};

Payment flow after startPayment​

A ReservationStarted response means the payment is under way but the customer is not finished - they are still tapping a card or feeding in cash. The platform waits that out on a dedicated page, which shows the customer what to do and hands over to order placement once the payment settles.

By default that page shows the screen matching your paymentType: an insert card prompt for terminal, a cash prompt for cash, a "Preparing payment" spinner for deferred. The page stays there until one of the following events is received:

EventSourceOutcome
PaymentSessionAbortedOrderingThe payment session was aborted. The UI displays an error and the customer can retry.
PaymentReservationCompletedPaymentThe payment reservation completed successfully. The UI proceeds to the next step.
PaymentReservationFailedPaymentThe payment reservation failed. The UI displays an error and the customer can retry.

If nothing happens at all for 3 minutes and 30 seconds, the page gives up waiting and tells the customer the payment was cancelled.

Control the waiting screen​

info

Controlling the waiting screen is available from the October/November 2026 release.

Your payment service is the only party that knows what its hardware is asking of the customer, so it can decide what the waiting page shows by implementing the optional renderOngoingPayment method:

context.service.addService({
type: 'paymentService',
paymentType: 'terminal',
startPayment: async params => {
// ... start the payment
return { status: 'ReservationStarted', requestData: params };
},
renderOngoingPayment: async ongoingPayment => {
ongoingPayment.ui.renderPage('terminal-payment');
},
});

The method is called once, when the waiting page opens. Leave it out and the platform keeps showing the screen matching your paymentType, so existing services are unaffected.

note

When you implement renderOngoingPayment, paymentType no longer affects what the customer sees on the waiting page: your service renders every screen, including the insert card or cash prompt if you want one. paymentType is still required, because the checkout uses it to decide which payment method button to offer and which service drives the payment the customer picks.

renderOngoingPayment can return a { dispose } handle for anything the call itself set up, such as a hardware session, a timer or a subscription of your own. The platform runs dispose once, when the page closes.

The method receives a context object with the following properties:

PropertyTypeDescription
uiobjectDecides what the customer sees. See Render a screen
paymentobjectThe payment the page is waiting for: orderId, paymentSessionId and uiCultureCode
addEventListenerfunctionSubscribes to payment events. See Events
paymentCompletefunctionReleases a held handover to order placement. See Hold the handover to order placement

The amount and currency are not repeated on payment, because your service already has them from the startPayment call that began this payment.

Render a screen​

ui.renderPage(page, data) shows one of the screens the platform provides, already themed and translated. Call it again at any point to move the customer on to another screen, which arrives with the page's own transition. Calling it for the screen already showing updates that screen in place without the transition, so a progress screen can be ticked up as often as you like.

PageShowsData
terminal-paymentAn insert card promptnone
cash-paymentA pay with cash promptnone
preparing-paymentA spinner and "Preparing payment"{ progress }, 0-100, fills the loader circle
performing-paymentA spinner and "Performing payment"{ progress }, 0-100, fills the loader circle
template-pageYour own texts and image in the platform's payment message layout{ title, subtitle, imageUrl?, text? }

The template-page is the middle ground between reusing a screen and building one: the layout, theme and fonts come from the platform, the words and the picture are yours.

ongoingPayment.ui.renderPage('template-page', {
title: 'Tap your card',
subtitle: 'Hold it against the reader\nuntil the light turns green',
imageUrl: 'https://<YOUR_ASSET_HOST>/tap.png',
text: 'Remove your card when you are told to',
});

title and subtitle become the heading and sub-heading, imageUrl is shown below them, and text is a paragraph under the image. Line breaks are preserved in all three texts. If the image cannot be loaded, the platform falls back to its standard placeholder image.

note

Nothing on the template-page is translated by the platform. Pass text in the culture the page is waiting on, available as context.payment.uiCultureCode.

Render your own component​

ui.renderComponent(render) hands you a shadow root to fill, so your own styles neither leak into the page nor inherit from it. Everything your markup needs must be declared inside the shadow root. Calling renderComponent or renderPage again replaces whatever is currently shown.

renderOngoingPayment: async ongoingPayment => {
ongoingPayment.ui.renderComponent(({ shadow }) => {
shadow.innerHTML =
'<h1>Pay at the terminal</h1><div>Left to pay: <span id="leftToPay"></span></div>';
const span = shadow.getElementById('leftToPay');
const handle = setInterval(async () => {
const response = await fetch('https://<YOUR_TERMINAL_HOST>/leftToPay');
span.innerText = (await response.json()).leftToPay;
}, 1000);

return { dispose: () => clearInterval(handle) };
});
},

The render function can return a { dispose } handle, which the platform runs when the component is replaced or the page closes. Clear timers and subscriptions there. This handle belongs to one rendered component and is separate from the handle renderOngoingPayment itself returns.

Events​

addEventListener(type, listener) subscribes to what the page learns about the payment. It returns a function that unsubscribes. The page unsubscribes everything when it closes, so call it only to stop listening earlier.

The page starts waiting for the payment as soon as it opens, alongside renderOngoingPayment, but holds every event until your service first calls addEventListener, and then delivers them in order. If your service never subscribes, the held events are released when renderOngoingPayment settles. You can therefore await your own setup before subscribing, or await an event inside renderOngoingPayment, without missing anything.

warning

Subscribe to every event you need at once, one call after the other with no await in between. The held events go out as soon as the first listener is added, so a listener added after an await only receives what happens from then on.

renderOngoingPayment: async ongoingPayment => {
await myTerminalApi.connect();

// Subscribe to everything together, before awaiting anything else
ongoingPayment.addEventListener('manualaction', onManualAction);
ongoingPayment.addEventListener('paymentcomplete', onPaymentComplete);
ongoingPayment.addEventListener('paymentaborted', onPaymentAborted);
},
EventFired whenPayloadWhat happens next
manualactionThe platform asks your service to carry something out{ transactionId, actionCode, action, orderId, deviceId, ... }. See Manual actionsThe page keeps waiting for the payment. Your screen stays up and the same payment can raise the event again
paymentcompleteThe reservation reached a final, successful state{ transactionId, preventDefault }The page hands over to order placement, unless your service holds the handover
paymentabortedThe payment session was abandoned, so the payment will not completenoneThe page hands over to order placement, which reports the outcome to the customer
paymenttimeoutNothing happened for long enough that the page gave up waitingnoneThe page replaces your screen with a "payment cancelled" message and a way back to the checkout
note

The event set is provisional and expected to grow.

After manualaction​

Fulfilling the action is all your service has to do. The page carries on waiting for the payment, so there is no need to tell the platform you are done, and a payment may ask for several actions in a row. See Manual actions.

After paymentcomplete​

The page hands over to order placement, which confirms the payment, places the order and takes the customer on to the order status page. That takes your screen down and runs the dispose handles for the rendered component and for renderOngoingPayment.

Rendering one last screen in response to this event does work, unlike the two events below. Your own markup or one of the predefined pages, performing-payment being the natural one, is left standing for about a second before the handover, so the customer sees it rather than a flicker. To keep it up for longer, or to finish work of your own first, hold the handover with preventDefault(). See Hold the handover to order placement.

After paymentaborted​

The page hands over to order placement as well, but there the payment is found to have failed and the customer is shown an error and offered a retry. The customer is therefore no longer looking at your screen, so rendering in response to this event changes nothing. Use it to stand your hardware down. Your dispose handles still run as the page closes.

After paymenttimeout​

The page stays where it is and covers your screen with the platform's own "payment cancelled" message and a button back to the checkout, where the customer can pick a payment method again. The dispose handle for your rendered component runs as it is replaced. Rendering in response to this event changes nothing, so use it to stand your hardware down.

Hold the handover to order placement​

paymentcomplete is followed by the handover to order placement, which takes your screen down. A service with something left to do first, such as a receipt to print, a screen the customer has to read, or hardware to stand down, calls preventDefault() on the event. Nothing then happens until your service calls context.paymentComplete().

ongoingPayment.addEventListener('paymentcomplete', ({ preventDefault }) => {
preventDefault();

ongoingPayment.ui.renderPage('performing-payment', { progress: 60 });

printReceipt().finally(() => ongoingPayment.paymentComplete());
});

preventDefault() only counts while the event is being handled, so calling it from a later callback has no effect. Call paymentComplete() whichever way the work turns out: the page waits at most one minute and then hands over regardless, because the payment has already been taken and the order still has to be placed.

warning

The one minute ceiling is a backstop for a service that crashed or whose hardware never answered, not a budget to spend. Hold the handover for the one screen or receipt you need, then release it.

The minute counts from when the payment completes, not from when your service receives the event. Any time your service spends getting ready before it subscribes comes out of the same minute. If your service has still not subscribed when the minute runs out, it never receives paymentcomplete and the page hands over to order placement.

Manual actions​

Some payments need a step only your hardware can take, such as driving the card entry on your terminal. The platform asks for it as a manual action, and the ones addressed to a payment service arrive on the manualaction event:

ongoingPayment.addEventListener('manualaction', ({ actionCode, action }) => {
if (action.actionType !== 'Payment.ManualAction.PaymentService') return;

// action.data is your provider's payload - narrow it on its dataType
myTerminalApi.fulfil(actionCode, action.data);
});
PropertyTypeDescription
transactionIdstringThe payment transaction the action belongs to
orderIdstringThe order being paid
tenantIdstringThe tenant the payment belongs to
merchantIdstringThe merchant the payment belongs to
correlationIdstringTraces the payment request across the platform's payment side
deviceIdstring | nullThe device the payment was started on, or null when it was not started from one
customerReferencestringOptional. The reference the payment was started with, when there is one
actionCodestringWhat is being asked for, for example CardEntry
action.actionTypestringPayment.ManualAction.PaymentService
action.dataunknownThe provider-specific payload
note

Payment.ManualAction.PaymentService is the only action type delivered to a payment service today, but more types could be added later. action.actionType is the discriminator of that union, so narrow on it before reading anything else off action. A listener that assumes the current shape would break if a new type arrives, while one that checks action.actionType first keeps working and can ignore the types it does not handle.

The platform passes action.data straight through without reading it, because your service is the only party that knows how. The payloads seen so far carry a dataType, such as Payment.ManualAction.PaymentService.Data.AdyenPaymentsApp, alongside an encrypted request, an expiry and the URLs to fulfil it against.

Manual actions addressed to anything else, such as an iframe, a redirect or a payment component, are not delivered to services. The platform handles those itself.

Error codes​

Error codes apply to every payment type unless the description says otherwise. Return kiosk.payment.error.uncategorized when no other code matches the failure.

CodeDescription
kiosk.payment.error.cancelledbycustomerThe customer cancelled the payment
kiosk.payment.error.amountdeclinedThe amount was declined
kiosk.payment.error.declinedThe card was blocked or declined
kiosk.payment.error.terminalissuesTerminal hardware or connection issues
kiosk.payment.error.uncategorizedAn uncategorized error
kiosk.payment.error.invalidpinInvalid PIN entered. terminal only
kiosk.payment.error.cardnotallowedCard type not accepted
kiosk.payment.error.invalidcardInvalid or unreadable card. terminal only