# Integrating the Hosted Payment Page

Create an order, wrap it in a hosted session, get the customer to the page Kustom hosts and read the outcome — the integration model for integrations that can't run any client-side code.

The Hosted Payment Page (HPP) puts Kustom Checkout on a page Kustom hosts, so your
integration is server calls only — no snippet, no iframe, no JavaScript of yours anywhere.
You create the order exactly as you would for the embedded checkout, create a hosted
session on top of it and then get the customer to that session by redirect, e-mail or QR
code. This guide covers the whole flow. The field-by-field contract lives in
[the session reference](/checkout/surfaces/hosted-payment-page/session-reference/).

## Before you start

You need a working [Checkout integration](/checkout/integrate/create-order/) — HPP doesn't replace
it, it distributes it. Three Kustom services are involved in a full purchase:

| Service | What it does here | Base path |
|---|---|---|
| Checkout | Creates the order — the cart, the totals, the callback URLs | `/checkout/v3` |
| Hosted Payment Page | Wraps that order in a session the customer can reach | `/hpp/v1` |
| Order Management | Captures and refunds afterwards | `/ordermanagement/v1` |

All three take the same HTTP Basic credentials. Playground is
`https://api.playground.kustom.co`, production is `https://api.kustom.co`. Credentials
don't cross between them.

Decide up front which of the three shapes you're building, because it changes how you
distribute the session in step 3:

- **E-commerce.** The customer is already in a browser on a site you own, so you can redirect them.
- **Telesales or e-mail.** The customer is on the phone or in their inbox. You have to send them a link.
- **Shared device.** A kiosk or an in-store screen. Privacy rules mean you must not leave a session open on a shared browser, so you distribute rather than redirect.

## Step by step

### 1. Create the order

This is the ordinary Checkout call. Nothing about it is HPP-specific:

```bash
curl -X POST https://api.playground.kustom.co/checkout/v3/orders \
  --header "Authorization: Basic <token>" \
  --header "Content-Type: application/json" \
  --data @order.json
```

See [Create and update an order](/checkout/integrate/create-order/) for what belongs in the body.
The response carries the full order, but for HPP you need only one field from it:
`order_id`. The `html_snippet` in the response is for the embedded checkout — with HPP you
never render it yourself, and you should not try to.

### 2. Create the hosted session on top of it

`POST /hpp/v1/sessions`, pointing `payment_session_url` at the order you just created:

```json
{
  "payment_session_url": "https://api.playground.kustom.co/checkout/v3/orders/8cf27b55-53e8-6aba-9fb4-7c692e56ddee",
  "merchant_urls": {
    "success": "https://example.com/done?order_id={{order_id}}",
    "back": "https://example.com/cart",
    "failure": "https://example.com/declined",
    "error": "https://example.com/error",
    "status_update": "https://example.com/hpp-callback?session={{session_id}}&token=<your secret>"
  }
}
```

The response gives you a `session_id` and a set of URLs — `redirect_url` for the customer,
`session_url` for you to poll, `distribution_url` to send the link by e-mail or SMS and
`qr_code_url` for a screen. [The reference](/checkout/surfaces/hosted-payment-page/session-reference/)
lists all of them, and every option you can set on the session, including branding.

**Use the URLs as returned.** Their structure isn't part of the contract and Kustom
changes it. Only `session_id` is stable enough to store.

### 3. Get the customer to the page

**Redirect** if the customer is already in a browser you control. Send them to
`redirect_url`, and set `merchant_urls` so they come back to you afterwards:

| URL | When the customer lands there |
|---|---|
| `success` | Payment was authorized. You must show your own confirmation — Kustom's isn't enough on its own. |
| `failure` | Payment was refused. The customer has already seen why. |
| `back` | The customer pressed **Back**. The session stays open, so they can return to it. Disabling it is your call. |
| `cancel` | The customer confirmed a cancellation, which moves the session to `CANCELLED` permanently. |
| `error` | Something went wrong. Without this URL, errors go to `failure` and you can't tell a decline from a fault. |

Which of the two exit controls the page draws follows from which of the two URLs you set. A
**Back** control leaves the session reachable. A **Cancel** control prompts for confirmation
and then moves the session to `CANCELLED` permanently. Set both and the page draws both. Set
neither and the customer gets a Cancel leading to Kustom's own page.

Set only one and the order decides. With just `back`, you get Back. With just `cancel`, you get
Cancel when the order declares an `options.acquiring_channel` and Back when it doesn't — the
discriminator is whether the field is set at all, not which channel it names.

Both URLs also stand in for each other. `back` falls back to `cancel` when it's missing,
and `error` falls back to `failure` — which is why a missing `error` URL leaves you unable
to tell a decline from a fault rather than losing the customer entirely.

Those five are every redirect `merchant_urls` carries. The object takes two more, both
callbacks rather than destinations — `status_update`, in step 4 below, and
`distribution_update`, which fires once when Kustom has successfully sent a link by e-mail
or SMS and carries `session_id`, `distribution_id`, `method` and the contact information it
was sent to.

**Distribute** if you can't redirect. `POST` to the `distribution_url` with an e-mail
address or a phone number and Kustom sends a localized message containing the link, or display `qr_code_url`
on a screen for the customer to scan. Both are covered in
[the reference](/checkout/surfaces/hosted-payment-page/session-reference/). If an operator — a clerk or a
telesales agent — is driving the flow by hand, use the Distribution Module rather than
building your own interface on the API.

### 4. Read the outcome

Don't rely on the redirect. The customer can close the window before it happens, and then
you have taken a payment you don't know about. Track the session instead, by one of two
mechanisms, ideally both:

- **Callbacks.** Set `merchant_urls.status_update` at session creation and Kustom `POST`s the session to you on every status change. This is the mechanism to build on.
- **Polling.** `GET` the `session_url` from the create response. Use it as the backstop for callbacks your endpoint missed, not as the primary path.

A callback is attempted three times in total — the first send plus two retries, three
seconds apart — and each attempt is given three seconds to answer. That's the whole budget,
about nine seconds from the first attempt to the last, which is precisely why the polling
backstop is worth having.

The statuses and the callback body are in
[the reference](/checkout/surfaces/hosted-payment-page/session-reference/). The one you're waiting for is
`COMPLETED`.

### 5. Capture, or disable the session

Unless the order was created with `auto_capture`, no money moves until you capture it
through Order Management — see [Capture an order](/order-management/capture/).

If the purchase is off — the offer expired, the customer paid another way — `DELETE` the
session. Its status becomes `DISABLED`, permanently, and the link stops working.

## Why the hosted session expires before the order

The session and the order have tied lifetimes. A session never outlives its order: it
takes the order's expiry, capped at 48 hours after the session is created, and then
expires **up to an hour earlier** than that. The early part is a stagger of between 0 and
59 minutes that Kustom applies to spread expiries out, so two sessions created a minute
apart on identical orders can expire at different times.

The practical consequence is that `expires_at` on the session is the deadline that matters
to you, and the order's own expiry isn't something to distribute a link against.

You may attach several sessions to one order — for instance to try two brandings — but
it's a bad practice and Kustom treats it as one. Prefer one session per order.

## Check that it works

Run the whole flow in Playground before you build the production path:

1. Create an order, then a session on it. You get a `session_id` and a `redirect_url`.
2. Open `redirect_url` in a browser. Your `status_update` endpoint receives `IN_PROGRESS`.
3. Pay with [Playground test data](/basics/test-data/). Your endpoint receives `COMPLETED`, carrying the `order_id`.
4. `GET` the `session_url` and confirm it agrees with the callback.
5. `DELETE` a second, unused session and confirm its status is `DISABLED` and its link is dead.

## Variations

**Auto-capture.** Create the order with `auto_capture` and money moves at authorization,
with no Order Management call from you. Use it when goods are handed over immediately.

**Place-order mode.** A session on a Checkout order always completes with an `order_id`,
never an `authorization_token`, so `{{order_id}}` is usable in `success` whatever you set.
The one `options.place_order_mode` value that changes anything here is `CAPTURE_ORDER`,
which sets `options.auto_capture` on the order for you — the auto-capture variation above,
from the session side. `PLACE_ORDER` is a no-op on a Checkout order.

**Generic confirmation pages.** Leave `merchant_urls` empty and Kustom shows its own
success, failure and cancellation pages. Acceptable for a pilot. For anything real, send
the customer somewhere you control.

## Troubleshooting

**The customer paid but never came back.** Expected, and the reason step 4 exists. The
redirect is best-effort. The callback and the read endpoint are the record.

**A callback never arrived.** Kustom gives up after three attempts against an endpoint that
doesn't answer within three seconds each time. Each callback is retried on its own, so
losing one doesn't stop the next: a session whose `IN_PROGRESS` never landed can still
deliver `COMPLETED`. Poll `session_url` to recover the state, then fix the endpoint's
latency — three seconds is the whole budget per attempt, including TLS.

**The customer sees "Page Not Found".** The session was disabled while they were on it.
That's what `DISABLED` looks like from their side.

**You can't tell declines from faults.** You didn't set `error`, so both went to
`failure`. Set both URLs.

## Related

- [Session reference](/checkout/surfaces/hosted-payment-page/session-reference/) — every endpoint, option, status and error code
- [Capture an order](/order-management/capture/) — moving the money after `COMPLETED`
- [Integration models](/checkout/#integration-models) — whether HPP is the right integration model at all
- [Create and update an order](/checkout/integrate/create-order/) — the call step 1 makes
- [In-person payments](/in-person/) — the other way to take payment away from your website
- [Checkout API reference](/api/checkout/) — the order's full schema
