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" }
}
| Field | Description |
|---|---|
confirmationMethod | Payment means the booking must be completed with POST /booking/pay instead of /booking/confirm |
paymentAmountIncVat | Amount to pay now, including VAT, in the account currency |
paymentProviders | The providers the customer may choose from, see below |
saleItems | Line items behind the amount. itemType is Service, Product, Discount or VoucherSale |
allowDiscountVoucher | The customer may enter a discount or gift card code |
stripeDestAccountId | The 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:
| Provider | Meaning |
|---|---|
Stripe | Card payment through Stripe, completed in one or two /booking/pay calls |
Swish | Swish payment, the customer approves in the Swish app while you poll for the result |
Klarna | Klarna Payments, authorized with the Klarna SDK |
PayOnSite | No online payment, the customer pays at the visit. Listed when the account allows it for every paid service in the booking |
None | Nothing 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:
| Provider | Before /booking/pay | /booking/pay result |
|---|---|---|
PayOnSite, None | Call /booking/payment-provider once. Pin means a PIN SMS was just sent, ask the customer for the code | 201, booking confirmed |
Stripe | Create a payment method with Stripe.js | 201, or 402 with requiresAction (3D Secure, then call again), or 402 declined |
Swish | Nothing | Always 402 with a Swish payment request. Poll /booking/{cbUuid}/status until it is paid |
Klarna | Call /klarna/session and authorize with the Klarna SDK | 201, 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" }
| Value | Meaning |
|---|---|
Pin | A PIN SMS was just sent to the customer's phone. Ask for the code and pass it as pin |
NoPin | No verification, call /booking/pay with pin set to null |
Payment | Returned 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.
| Status | When |
|---|---|
| 403 | The PIN was wrong. Let the customer try again, the reservation is still held |
400, code 3000 | PayOnSite 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"}'
| Response | Meaning |
|---|---|
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 |
410 | The 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:
- Call
/booking/{cbUuid}/statuswith{"paymentProvider": null, "instructionUuid": null}. Confirmedmeans the first call went through. Show thebodyas the confirmation.PendingConfirmmeans the reservation is still open and nothing was charged, call/booking/payagain. For Stripe, create a new payment method first.410means 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:
| Status | When |
|---|---|
400, code 3 | The reservation is not pending anymore: already confirmed, released or replaced by a new reserve call |
400, code 3000 | The provider is not available for this account or booking, PayOnSite is not allowed, or None was used with an amount above zero |
400, code 3002 | Swish rejected the payment request (message holds the Swish error codes), or could not find the payment request for the instructionUuid |
400, code 3004 | Payment is required but not possible for the selected resource, and pay on site is not allowed |
400, codes 1110, 1117-1121, 1126, 1127 | The voucher code is not valid, see Apply or remove a voucher |
| 402 | A payment result, see the Stripe and Swish sections. From /booking/confirm it means the booking requires payment |
| 403 | Wrong PIN, or unknown cbUuid on /booking/pay |
| 410 | From /booking/{cbUuid}/status, the reservation is gone |
| 418 | The 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:
| Purpose | Domains |
|---|---|
| Cliento API | apibk.cliento.com (connect-src) |
| Stripe | js.stripe.com (script-src, frame-src), api.stripe.com (connect-src), hooks.stripe.com (frame-src) |
| Klarna | x.klarnacdn.net (script-src), *.klarna.com (frame-src, connect-src) |
| Captcha | www.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.