# Payments

Some accounts require online payment for some or all services. This guide
walks through how payment fits into the [booking flow](https://developers.cliento.com/docs/widget-api/booking-flow.md)
and what your client does for each payment provider. The full request and
response reference is in [Payment endpoints](https://developers.cliento.com/docs/widget-api/payment-endpoints.md).

The payment providers are configured by the account in Cliento. Everything
your client needs comes from the API:

- the Stripe publishable key, from [settings](https://developers.cliento.com/docs/widget-api/reference-data.md#fetch-settings)
- the Stripe account and the list of providers, from
  [confirmation-options](https://developers.cliento.com/docs/widget-api/booking.md#get-confirmation-options)
- the Klarna client token, from
  [`/klarna/session`](https://developers.cliento.com/docs/widget-api/payment-endpoints.md#create-a-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](https://developers.cliento.com/docs/widget-api/booking.md#get-confirmation-options):

```json
{
  "cbUuid": "4674c65b-a64f-43a5-94c7-af3b59053cc6",
  "cbRef": "mK0k22O3",
  "reservationExpiry": "2026-10-23T09:46:24Z",
  "confirmationMethod": "Payment",
  "paymentAmountIncVat": 595.0,
  "paymentProviders": ["None", "Stripe", "Swish", "PayOnSite"],
  "allowDiscountVoucher": true,
  "saleItems": [
    { "itemType": "Service", "quantity": 1, "amount": 595.0, "vatPct": 25.0, "vatAmount": 119.0, "description": "Women's haircut" }
  ],
  "stripeDestAccountId": "acct_1ABCdefGHIjklMNO",
  "demoPin": null,
  "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](#discount-vouchers-and-gift-cards) |
| `stripeDestAccountId` | The Stripe account to charge, pass it to Stripe.js. `null` when the account does not use Stripe |

The server returns `Payment` when all three are true:

- a booked service requires payment (`web_paymentRequired` on the service)
- the price is above zero
- the account has online payments enabled

`Payment` takes precedence over `Pin`:
confirmation-options sends no PIN SMS for a booking that requires payment. SMS
verification applies again only if the customer chooses to pay on site, see
[Pay on site](#pay-on-site-and-free-bookings).

Sometimes a service requires payment but cannot be paid for: the selected
resource cannot take online payment, and pay on site is not allowed either.
Then 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`](https://developers.cliento.com/docs/widget-api/payment-endpoints.md#choose-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`](https://developers.cliento.com/docs/widget-api/payment-endpoints.md#booking-status) until it is paid |
| `Klarna` | Call [`/klarna/session`](https://developers.cliento.com/docs/widget-api/payment-endpoints.md#create-a-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:

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

A successful `/booking/pay` returns `201` with the same body as
[confirm](https://developers.cliento.com/docs/widget-api/booking.md#confirm-the-booking), 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 whether a PIN is needed:

```bash
curl -X POST "{baseUrl}/booking/payment-provider" \
  -H "Content-Type: application/json" \
  -d '{"cbUuid": "4674c65b-a64f-43a5-94c7-af3b59053cc6", "paymentProvider": "PayOnSite"}'
```

```json
{ "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:

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

```json
{
  "cbUuid": "4674c65b-a64f-43a5-94c7-af3b59053cc6",
  "cbRef": "mK0k22O3",
  "smsReminderEnabled": true,
  "emailConfirmSent": true,
  "smsConfirmSent": true,
  "paymentStatus": "Unpaid",
  "paymentProvider": "None"
}
```

The response does not echo the provider you chose. For a `PayOnSite` booking,
`paymentStatus` is `Unpaid` and `paymentProvider` is `None`, because no online
payment was made. For a `None` booking (amount zero), `paymentStatus` is `Paid`
and `paymentProvider` is `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](https://developers.cliento.com/docs/widget-api/reference-data.md#fetch-settings) and the `stripeDestAccountId`
from confirmation-options as the connected account:

```javascript
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:

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

Send the payment method id to `/booking/pay`:

```bash
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:

```json
{
  "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:

```javascript
const { paymentIntent, error } = await stripe.handleCardAction(body.clientSecret);
```

```bash
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:

```json
{
  "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.

Leave `pin` out for `Stripe`, `Swish` and `Klarna`.

## Swish

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

```bash
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:

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

Swish pushes the payment request to the number you passed, and the customer
approves it in the Swish app. On a mobile device, also offer a button that opens
`swish://paymentrequest?token={paymentRequestToken}&callbackurl=`, which takes
the customer straight to the request. On desktop, tell the customer to open the
Swish app and approve. When Swish did not return a token, `paymentRequestToken`
is an empty string. Open `swish://` in that case. Cliento's booking widget always
sends a number. `bookingRef` is the same value as `cbRef`.

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

```bash
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:

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

`status` is the Swish status:

- `DECLINED`: the customer rejected the payment
- `CANCELLED`: the payment was cancelled
- `ERROR`: Swish reported an error

`errorCode` has the Swish error code 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:

```bash
curl -X POST "{baseUrl}/klarna/session" \
  -H "Content-Type: application/json" \
  -d '{"cbUuid": "4674c65b-a64f-43a5-94c7-af3b59053cc6"}'
```

```json
{
  "clientToken": "eyJhbGciOiJSUzI1NiIs...",
  "sessionId": "0b1d9815-165e-42e2-8867-35bc03789e00",
  "paymentMethodCategories": ["pay_later", "pay_over_time"],
  "saleItems": [
    { "itemType": "Service", "quantity": 1, "amount": 595.0, "vatPct": 25.0, "vatAmount": 119.0, "description": "Women's haircut" }
  ]
}
```

Only offer Klarna when `paymentMethodCategories` is not empty. Create one
session per reservation and reuse it. Then:

1. Load the Klarna Payments SDK (`https://x.klarnacdn.net/kp/lib/v1/api.js`).
2. Initialize it with the client token.
3. Load Klarna's payment form with an order built from the sale items.
4. Authorize, as in this example:

```javascript
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
  }
});
```

Klarna takes amounts in minor units (öre), which is why the example multiplies
by 100. `saleItems` here are the items from
confirmation-options, or from the [voucher](#discount-vouchers-and-gift-cards)
response when a voucher is applied. A `Discount` item has `amount` `0`, with
the (negative) discount in `discountAmount`. Send the authorization
token to `/booking/pay`:

```bash
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. Cliento's booking widget offers Klarna only
for bookings less than 28 days ahead, since the authorization must be captured
within Klarna's time limit. The API does not check this: apply the same limit
in your client.

## Discount vouchers and gift cards

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

```bash
curl -X POST "{baseUrl}/booking/validate-voucher" \
  -H "Content-Type: application/json" \
  -d '{"cbUuid": "4674c65b-a64f-43a5-94c7-af3b59053cc6", "voucherCode": "GIFT123"}'
```

```json
{ "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](https://developers.cliento.com/docs/widget-api/payment-endpoints.md#validate-a-voucher)
for the error codes. When the code is valid, apply it:

```bash
curl -X POST "{baseUrl}/booking/voucher" \
  -H "Content-Type: application/json" \
  -d '{"cbUuid": "4674c65b-a64f-43a5-94c7-af3b59053cc6", "voucherCode": "GIFT123"}'
```

```json
{
  "amount": 0.0,
  "saleItems": [
    { "itemType": "Service", "quantity": 1, "amount": 595.0, "vatPct": 25.0, "discountAmount": 0, "description": "Women's haircut", "...": "..." },
    { "itemType": "Discount", "quantity": 1, "amount": 0, "vatPct": 0, "discountAmount": -595.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: the response of
confirmation-options does not include an 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](#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](https://developers.cliento.com/docs/widget-api/payment-endpoints.md#apply-or-remove-a-voucher).

## 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.

`/booking/pay` returns `403` with an empty body when the reservation is gone:
the `cbUuid` never existed, was released, or has expired and been removed. A
wrong PIN gets the same response. Use the status call to tell them apart.

These calls are safe to repeat:

- `/booking/confirmation-options`
- `/booking/{cbUuid}/status`
- `/booking/validate-voucher`
- `/booking/voucher` with the same code
- `/booking/payment-provider` for `Stripe`, `Swish` and `Klarna`

These are not:

- `/booking/payment-provider` for `PayOnSite` or `None` sends a new PIN SMS each
  time.
- `/klarna/session` creates a new session each time. 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.

These endpoints are rate limited like the rest of the API, see
[Rate limits](https://developers.cliento.com/docs/widget-api.md#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: it is already confirmed |
| 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` has 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](https://developers.cliento.com/docs/widget-api/payment-endpoints.md#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 refused and the reservation is gone. Reserve a new slot |

## 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 |
| --- | --- |
| Widget 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](https://developers.cliento.com/docs/widget-api.md#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](https://developers.cliento.com/docs/widget/tracking.md) does.
