Skip to main content

Payments

Some accounts require online payment for some or all services. This guide walks through how payment fits into the booking flow and what your client does for each payment provider. The full request and response reference is in Payment endpoints.

The payment providers are configured by the account in Cliento. Your client needs nothing from Cliento that the API does not already return: the Stripe publishable key comes from settings, the Stripe account and the list of providers from confirmation-options, and the Klarna client token from /klarna/session. Cliento's own widget runs exactly the flow described here.

When payment is required

Payment shows up in the response from confirmation-options:

{
"cbUuid": "4674c65b-a64f-43a5-94c7-af3b59053cc6",
"cbRef": "mK0k22O3",
"reservationExpiry": "2026-07-23T09:46:24Z",
"confirmationMethod": "Payment",
"paymentAmountIncVat": 500.0,
"paymentProviders": ["None", "Stripe", "Swish", "PayOnSite"],
"allowDiscountVoucher": true,
"saleItems": [
{ "itemType": "Service", "quantity": 1, "amount": 500.0, "vatPct": 25.0, "vatAmount": 100.0, "description": "Dry Haircut" }
],
"stripeDestAccountId": "acct_1ABCdefGHIjklMNO",
"attributes": { "pbk_allowMarketing": false },
"prefs": { "...": "same preference object as in settings, shortened here" }
}
FieldDescription
confirmationMethodPayment means the booking must be completed with POST /booking/pay instead of /booking/confirm
paymentAmountIncVatAmount to pay now, including VAT, in the account currency
paymentProvidersThe providers the customer may choose from, see below
saleItemsLine items behind the amount. itemType is Service, Product, Discount or VoucherSale
allowDiscountVoucherThe customer may enter a discount or gift card code
stripeDestAccountIdThe Stripe account to charge, pass it to Stripe.js. null when the account does not use Stripe

The server returns Payment when a booked service requires payment (web_paymentRequired on the service), the price is above zero and the account has online payments enabled. Payment takes precedence over Pin: no PIN SMS is sent by confirmation-options for a payment booking, the SMS verification comes back only if the customer chooses to pay on site, see Pay on site. If a service requires payment but the selected resource cannot take online payment and pay on site is not allowed either, confirmation-options returns 400 with code 3004.

paymentProviders can contain:

ProviderMeaning
StripeCard payment through Stripe, completed in one or two /booking/pay calls
SwishSwish payment, the customer approves in the Swish app while you poll for the result
KlarnaKlarna Payments, authorized with the Klarna SDK
PayOnSiteNo online payment, the customer pays at the visit. Listed when the account allows it for every paid service in the booking
NoneNothing to pay. Always listed, but only accepted when the amount is 0, for example after a gift card covered the full amount

A reservation is held until reservationExpiry. Calling /booking/pay or /klarna/session extends the hold by five minutes so that a slow card or Swish approval does not lose the slot. The other calls on this page do not extend it.

Choosing a provider

Show the customer the providers from paymentProviders (hide None, it is implied when the amount is zero). What happens next depends on the choice:

ProviderBefore /booking/pay/booking/pay result
PayOnSite, NoneCall /booking/payment-provider once. Pin means a PIN SMS was just sent, ask the customer for the code201, booking confirmed
StripeCreate a payment method with Stripe.js201, or 402 with requiresAction (3D Secure, then call again), or 402 declined
SwishNothingAlways 402 with a Swish payment request. Poll /booking/{cbUuid}/status until it is paid
KlarnaCall /klarna/session and authorize with the Klarna SDK201, booking confirmed

Every /booking/pay call takes the same three base fields plus the provider specific ones:

{
"cbUuid": "4674c65b-a64f-43a5-94c7-af3b59053cc6",
"paymentProvider": "Stripe",
"captchaResponse": null
}

A successful /booking/pay returns 201 with the same body as confirm, and the booking exists in the calendar. Cliento sends the confirmation messages and, for paid bookings, a receipt by email.

Pay on site and free bookings

PayOnSite and None confirm the booking without taking money, so they go through the account's SMS verification when it is enabled. Call /booking/payment-provider first to find out:

curl -X POST "{baseUrl}/booking/payment-provider" \
-H "Content-Type: application/json" \
-d '{"cbUuid": "4674c65b-a64f-43a5-94c7-af3b59053cc6", "paymentProvider": "PayOnSite"}'
{ "confirmationMethod": "Pin" }
ValueMeaning
PinA PIN SMS was just sent to the customer's phone. Ask for the code and pass it as pin
NoPinNo verification, call /booking/pay with pin set to null
PaymentReturned for Stripe, Swish and Klarna, the call is not needed for them

Call it once per choice. Every call with PayOnSite or None sends a new PIN and invalidates the previous one. Then complete the booking:

curl -X POST "{baseUrl}/booking/pay" \
-H "Content-Type: application/json" \
-d '{
"cbUuid": "4674c65b-a64f-43a5-94c7-af3b59053cc6",
"paymentProvider": "PayOnSite",
"pin": "1234",
"captchaResponse": null
}'
{
"cbUuid": "4674c65b-a64f-43a5-94c7-af3b59053cc6",
"cbRef": "mK0k22O3",
"smsReminderEnabled": true,
"emailConfirmSent": true,
"smsConfirmSent": true,
"paymentStatus": "Unpaid",
"paymentProvider": "None"
}

Note that the response for a PayOnSite booking reports paymentStatus Unpaid and paymentProvider None: no online payment was made, the chosen provider is not echoed back. A None booking (amount zero) reports Paid and None.

StatusWhen
403The PIN was wrong. Let the customer try again, the reservation is still held
400, code 3000PayOnSite is not allowed for this booking, or None was used with an amount above zero

Stripe

Load Stripe.js (https://js.stripe.com/v3/) with the stripePublicKey from settings and the stripeDestAccountId from confirmation-options as the connected account:

const stripe = Stripe(settings.stripePublicKey, {
stripeAccount: options.stripeDestAccountId || undefined
});

Collect the card with a Stripe Element and create a payment method. The widget uses the Card Element; any Stripe.js UI that produces a pm_… payment method id works the same way:

const { paymentMethod, error } = await stripe.createPaymentMethod({ type: 'card', card: cardElement });

Send the payment method id to /booking/pay:

curl -X POST "{baseUrl}/booking/pay" \
-H "Content-Type: application/json" \
-d '{
"cbUuid": "4674c65b-a64f-43a5-94c7-af3b59053cc6",
"paymentProvider": "Stripe",
"stripePaymentMethodId": "pm_1ABCdefGHIjklMNOpqrsTUVW",
"captchaResponse": null
}'

The response is one of three:

201, paid. The booking is confirmed, the body is the confirm response with paymentStatus Paid and paymentProvider Stripe.

402, action required. The card needs 3D Secure authentication:

{
"providerStatus": "requires_action",
"clientSecret": "pi_3ABCdefGHIjklMNO_secret_pqrsTUVWxyz",
"requiresAction": true,
"paymentSucceeded": false,
"chargeId": null,
"source": "Visa **** 4242"
}

Run the authentication in the browser and call /booking/pay again with the payment intent id instead of the payment method id:

const { paymentIntent, error } = await stripe.handleCardAction(body.clientSecret);
curl -X POST "{baseUrl}/booking/pay" \
-H "Content-Type: application/json" \
-d '{
"cbUuid": "4674c65b-a64f-43a5-94c7-af3b59053cc6",
"paymentProvider": "Stripe",
"stripePaymentIntentId": "pi_3ABCdefGHIjklMNO",
"captchaResponse": null
}'

This second call returns 201 when the payment went through. If handleCardAction returns an error (the customer failed or cancelled the authentication), treat it like a declined card: collect a new card and start over with a new payment method id.

402, declined. The card was declined or Stripe rejected the payment:

{
"paymentStatus": "Unpaid",
"paymentProvider": "Stripe",
"failureCode": "card_declined",
"failureMessage": "Kortet nekades"
}

failureCode is the Stripe decline code. failureMessage is a Swedish message meant for display. The reservation is still held, so the customer can try another card or another provider.

The PIN is never checked for Stripe, Swish or Klarna, even when the account uses SMS verification. Leave pin out for these providers.

Swish

Start the payment with the customer's Swish number as swishPayerAlias, in E.164 format without the leading +:

curl -X POST "{baseUrl}/booking/pay" \
-H "Content-Type: application/json" \
-d '{
"cbUuid": "4674c65b-a64f-43a5-94c7-af3b59053cc6",
"paymentProvider": "Swish",
"swishPayerAlias": "46701234567",
"captchaResponse": null
}'

The response is always 402. It means the Swish payment request was created and is waiting for the customer:

{
"paymentRequestToken": "c28a4061470440d8a27a5c9a7b1f4c7e",
"bookingRef": "mK0k22O3",
"instructionUuid": "B9A4C7D5E2F44A1B8C3D6E7F8A9B0C1D"
}

Swish pushes the payment request to the number you passed. When swishPayerAlias is null the customer instead enters the number in the Swish app, and paymentRequestToken lets you open the app directly. On a mobile device, offer a button that opens swish://paymentrequest?token={paymentRequestToken}&callbackurl=. On desktop, tell the customer to open the Swish app and approve. The token is an empty string when Swish did not return one. bookingRef is the same value as cbRef.

Then poll the booking status every few seconds with the instructionUuid from the response:

curl -X POST "{baseUrl}/booking/4674c65b-a64f-43a5-94c7-af3b59053cc6/status" \
-H "Content-Type: application/json" \
-d '{"paymentProvider": "Swish", "instructionUuid": "B9A4C7D5E2F44A1B8C3D6E7F8A9B0C1D"}'
ResponseMeaning
200 {"status": "PendingConfirm", ...}The customer has not approved yet. Keep polling
200 {"status": "Confirmed", "body": {...}}Paid. body is the confirm response with paymentStatus Paid and paymentProvider Swish. Stop polling, the booking is done
200 {"status": "PaymentError", "body": {...}}The payment was declined, cancelled or failed. Stop polling. The reservation is still held, let the customer try again or pick another provider
410The reservation has expired or is gone. Reserve a new slot

A PaymentError body looks like this:

{
"status": "DECLINED",
"errorCode": "",
"message": "",
"additionalInformation": ""
}

status is the Swish status: DECLINED when the customer rejected the payment, CANCELLED when it was cancelled, ERROR when Swish reported an error, with the Swish error code in errorCode when there is one.

Swish can also reject the payment request right away, for example for a number that is not enrolled in Swish. Then /booking/pay returns 400 with code 3002 and the Swish error codes, comma separated, in message. The widget shows specific messages for RP06 (the customer already has an ongoing payment request) and ACMT03 (the number is not enrolled in Swish), and a generic "payment failed" message for everything else.

The booking is confirmed on the server when Swish reports the payment, so the customer receives the confirmation even if your page is closed. The poll also completes the booking when Swish's callback to Cliento was lost, so polling alone is sufficient. Stop polling at reservationExpiry and show the customer that the time ran out. If a payment arrives after the reservation expired, Cliento refunds it automatically.

To retry Swish, call /booking/pay again with Swish. The previous Swish payment request is cancelled before the new one is created, so the customer never has two requests open for the same booking.

Klarna

Create a Klarna session for the reservation:

curl -X POST "{baseUrl}/klarna/session" \
-H "Content-Type: application/json" \
-d '{"cbUuid": "4674c65b-a64f-43a5-94c7-af3b59053cc6"}'
{
"clientToken": "eyJhbGciOiJSUzI1NiIs...",
"sessionId": "0b1d9815-165e-42e2-8867-35bc03789e00",
"paymentMethodCategories": ["pay_later", "pay_over_time"],
"saleItems": [
{ "itemType": "Service", "quantity": 1, "amount": 500.0, "vatPct": 25.0, "vatAmount": 100.0, "description": "Dry Haircut" }
]
}

Only offer Klarna when paymentMethodCategories is not empty. Create one session per reservation and reuse it. Then load the Klarna Payments SDK (https://x.klarnacdn.net/kp/lib/v1/api.js), initialize it with the client token, load the widget with an order built from the sale items, and authorize:

Klarna.Payments.init({ client_token: session.clientToken });

const order = {
purchase_country: 'SE',
purchase_currency: 'SEK',
locale: 'sv-SE',
order_amount: Math.round(options.paymentAmountIncVat * 100),
order_lines: saleItems.map((item) => {
const isDiscount = item.itemType === 'Discount';
const unitAmount = isDiscount ? item.discountAmount : item.amount;
return {
name: item.description,
quantity: item.quantity,
unit_price: Math.round(unitAmount * 100),
total_amount: Math.round(unitAmount * item.quantity * 100),
type: isDiscount ? 'discount' : 'physical'
};
})
};

Klarna.Payments.load({ container: '#klarna-container', payment_method_category: 'pay_later' }, order, () => {});
Klarna.Payments.authorize({ payment_method_category: 'pay_later' }, order, (result) => {
if (result.approved) {
// POST /booking/pay with result.authorization_token
}
});

Amounts are in minor units. saleItems here are the items from confirmation-options, or from the voucher response when a voucher is applied. A Discount item has amount 0 and carries the (negative) discount in discountAmount. Send the authorization token to /booking/pay:

curl -X POST "{baseUrl}/booking/pay" \
-H "Content-Type: application/json" \
-d '{
"cbUuid": "4674c65b-a64f-43a5-94c7-af3b59053cc6",
"paymentProvider": "Klarna",
"klarnaAuthToken": "b4bd3423-24e3-4d33-8a45-3dc94b4d7aee",
"captchaResponse": null
}'

The response is 201 with paymentStatus Paid and paymentProvider Klarna. The Klarna order is captured by Cliento after the appointment, your client is not involved in that. Klarna is only offered by the widget for bookings less than 28 days ahead, since the authorization must be captured within Klarna's time limit.

Discount vouchers and gift cards

When allowDiscountVoucher is true, the customer may enter a code before paying. Validate it first, then apply it:

curl -X POST "{baseUrl}/booking/validate-voucher" \
-H "Content-Type: application/json" \
-d '{"cbUuid": "4674c65b-a64f-43a5-94c7-af3b59053cc6", "voucherCode": "GIFT123"}'
{ "valid": false, "errors": [ { "errorCode": "CodeNotFound", "message": "Voucher code not found", "data": {} } ] }

An invalid code is still a 200, with valid set to false and the reasons in errors. See Validate a voucher for the error codes. When the code is valid, apply it:

curl -X POST "{baseUrl}/booking/voucher" \
-H "Content-Type: application/json" \
-d '{"cbUuid": "4674c65b-a64f-43a5-94c7-af3b59053cc6", "voucherCode": "GIFT123"}'
{
"amount": 0.0,
"saleItems": [
{ "itemType": "Service", "quantity": 1, "amount": 500.0, "vatPct": 25.0, "discountAmount": 0, "description": "Dry Haircut", "...": "..." },
{ "itemType": "Discount", "quantity": 1, "amount": 0, "vatPct": 0, "discountAmount": -500.0, "description": "GIFT123", "...": "..." }
],
"discountVoucher": { "code": "GIFT123" }
}

amount is the new amount to pay, replacing paymentAmountIncVat. The saleItems now include a Discount item, with the discount as a negative discountAmount. Keep these values in your client: calling confirmation-options again does not reflect the applied voucher. Send voucherCode as null to remove the voucher again. When amount becomes 0, complete the booking with paymentProvider None as described in Pay on site and free bookings. The voucher is checked again at /booking/pay. If it became invalid in the meantime the call returns 400 with one of the voucher error codes listed in Payment endpoints.

Errors, retries and recovery

/booking/pay is not idempotent. Do not retry it automatically after a timeout or network error, and do not send the same request twice. Instead:

  1. Call /booking/{cbUuid}/status with {"paymentProvider": null, "instructionUuid": null}.
  2. Confirmed means the first call went through. Show the body as the confirmation.
  3. PendingConfirm means the reservation is still open and nothing was charged, call /booking/pay again. For Stripe, create a new payment method first.
  4. 410 means the reservation is gone, reserve a new slot.

A second /booking/pay after a successful one returns 400 with code 3 ("Reservation not found"), since the reservation is no longer pending. It does not charge again. A /booking/pay with a cbUuid that never existed or was already cleaned up returns 403 with an empty body, the same response as a wrong PIN, so use the status call to tell them apart.

Safe to repeat: /booking/confirmation-options, /booking/{cbUuid}/status, /booking/validate-voucher, /booking/voucher with the same code, and /booking/payment-provider for Stripe, Swish and Klarna. Repeating /booking/payment-provider for PayOnSite or None sends a new PIN SMS. Repeating /klarna/session creates a new session, reuse the first one.

Switching provider needs no cleanup: call /booking/pay again with the new provider on the same cbUuid. After a declined card or a Swish PaymentError the reservation is still held.

The confirmation endpoints are throttled per reservation and per IP, see Rate limits. A 429 carries a Retry-After header, wait that long and repeat the same call. Poll the status endpoint no more often than every few seconds.

Status codes specific to the payment flow:

StatusWhen
400, code 3The reservation is not pending anymore: already confirmed, released or replaced by a new reserve call
400, code 3000The provider is not available for this account or booking, PayOnSite is not allowed, or None was used with an amount above zero
400, code 3002Swish rejected the payment request (message holds the Swish error codes), or could not find the payment request for the instructionUuid
400, code 3004Payment is required but not possible for the selected resource, and pay on site is not allowed
400, codes 1110, 1117-1121, 1126, 1127The voucher code is not valid, see Apply or remove a voucher
402A payment result, see the Stripe and Swish sections. From /booking/confirm it means the booking requires payment
403Wrong PIN, or unknown cbUuid on /booking/pay
410From /booking/{cbUuid}/status, the reservation is gone
418The request was blocked by the abuse checks and the reservation was deleted. Treat like 403

Domains to allow

All API calls go from the customer's browser directly to https://apibk.cliento.com, CORS is open for any origin. If your site uses a Content Security Policy, allow:

PurposeDomains
Cliento APIapibk.cliento.com (connect-src)
Stripejs.stripe.com (script-src, frame-src), api.stripe.com (connect-src), hooks.stripe.com (frame-src)
Klarnax.klarnacdn.net (script-src), *.klarna.com (frame-src, connect-src)
Captchawww.google.com and www.gstatic.com (script-src, frame-src), only when the account uses captcha

Swish needs no domains, the Swish app is opened through the swish:// link.

Tracking

cbRef in the confirm response (and in body of a Confirmed status response) is the booking reference shown to the customer. Use it as the transaction id when you send purchase events to your analytics, so a retried or polled confirmation is not counted twice. This is what the widget's automatic tracking does.