# Hosted Payment Page session reference

The /hpp/v1 endpoints — create, read, distribute and disable a hosted session — plus every branding option, session status, callback body and distribution error code.

The Hosted Payment Page API lives at `/hpp/v1` and has four operations — create a session,
read it, distribute it, disable it — plus one callback Kustom makes to you. Every
customization of the hosted page is a parameter of the create call, so they are documented
here rather than separately. For the flow these calls sit in, read
[Integrating the Hosted Payment Page](/checkout/surfaces/hosted-payment-page/).

## Endpoints at a glance

| Operation | Call | Auth |
|---|---|---|
| Create a session | `POST /hpp/v1/sessions` | Basic |
| Read a session | `GET /hpp/v1/sessions/{session_id}` | Basic |
| Distribute a session | `POST` to the `distribution_url` from the create response | Basic |
| Disable a session | `DELETE /hpp/v1/sessions/{session_id}` | Basic |
| Status callback | `POST` from Kustom to your `merchant_urls.status_update` | Yours to secure |

Base URLs are `https://api.playground.kustom.co` and `https://api.kustom.co`.

## Creating a session

```bash
curl -X POST https://api.kustom.co/hpp/v1/sessions \
  --header "Authorization: Basic <token>" \
  --header "Content-Type: application/json" \
  --data @session.json
```

### The order the session wraps

`payment_session_url` points at the order the session renders. Build it from the
`order_id` the Checkout API returned:

```
https://<base url>/checkout/v3/orders/<order_id>
```

Use the same environment in this URL as the credentials you are calling with.

### Merchant URLs

Where the customer goes after acting on the page, and where Kustom notifies you. All are
optional. Leave `success`, `failure`, `back`, `cancel` and `error` empty and Kustom shows
its own generic pages instead.

| Key | Purpose |
|---|---|
| `success` | Redirect after a successful authorization |
| `failure` | Redirect after a refusal |
| `back` | Redirect from the **Back** button — the session stays reachable |
| `cancel` | Redirect from the **Cancel** button — the session becomes `CANCELLED`. Set without `back` on an order that has no `options.acquiring_channel`, it drives a **Back** button instead |
| `error` | Redirect after an error. Without it, errors are indistinguishable from refusals |
| `status_update` | Where Kustom `POST`s status callbacks. Must be HTTPS |
| `distribution_update` | Where Kustom `POST`s once a link has been sent by e-mail or SMS. Must be HTTPS |

Those seven are the whole object. Anything else you send is accepted and discarded.

A `distribution_update` callback reports the send, not the customer — it carries
`session_id`, `distribution_id`, `method` (`email` or `sms`) and the contact information the
link went to, and it fires once per successful distribution. It follows the same retry
policy as `status_update`, below.

Two placeholders are substituted into these URLs before the customer is sent to them, and
which one resolves depends on the URL:

| Placeholder | Value | Where it resolves |
|---|---|---|
| `{{session_id}}` | The hosted session's identifier | Every URL in the object |
| `{{order_id}}` | The order identifier | `success` only |

On a checkout session `order_id` is always populated, so `success` can carry it
unconditionally. A placeholder that doesn't resolve for a given URL is **removed** rather than
left in place — put `{{order_id}}` in `failure` and the customer lands on a URL with an empty
parameter, not a literal `{{order_id}}` you could notice in a log.

### Session options

Everything under `options`. All optional, all defaulting to something sensible.

#### `background_images`

A list of images the page may use as its background. Kustom picks the one that best fits
the customer's viewport, using the `width` you declare. **Images must be served over
HTTPS** — the create call rejects any other `url` with a `400`.

| Field | Type | Meaning |
|---|---|---|
| `url` | String | The image, over HTTPS |
| `width` | Int | The screen width this image works best at |

```json
{
  "options": {
    "background_images": [
      { "url": "https://example.com/background.jpg", "width": 1280 },
      { "url": "https://example.com/background_small.jpg", "width": 480 }
    ]
  }
}
```

#### `page_title`

The heading on the page and the browser tab's title. Because you supply it, **you** are
responsible for translating it to the session's locale — Kustom does not.

Type `String`. Defaults to `Complete your purchase`.

```json
{
  "options": {
    "page_title": "Complete your purchase"
  }
}
```

#### Logo and feature image

Configured on your merchant account in the Portal, not per session. Where they appear
depends on the customer's viewport, since the page is responsive. Changes take a few
minutes to show up, in Playground as well as production.

#### `merchant_urls.back`

Supplying a `back` URL is itself a customization: it gives the page a **Go back** button,
which leaves the session alive. Set `back` alone and that's the only exit. Set `cancel`
too and the page draws a **Cancel** button beside it, which ends the session for good. With
neither, the customer gets a **Cancel** button leading to Kustom's own page.

```json
{
  "merchant_urls": {
    "back": "https://example.com/back?token=<random_uuid>&sid={{session_id}}"
  }
}
```

### The create response

| Field | Type | Meaning |
|---|---|---|
| `session_id` | String | The session's identifier. The one field worth storing |
| `session_url` | URL | Read the session here. Needs your credentials |
| `distribution_url` | URL | Distribute the session here. Needs your credentials |
| `redirect_url` | Public URL | Send the customer here |
| `qr_code_url` | Public URL | A QR code image leading to the same page |
| `expires_at` | Date | When the session dies — never after the order, at most 48 hours from creation, less a stagger of up to 59 minutes |
| `distribution_module` | Object | Token and URLs for the standalone Distribution Module |

Use these URLs as returned rather than reconstructing them. Their shape isn't a stable
contract.

## Reading a session

```bash
curl https://api.kustom.co/hpp/v1/sessions/<session_id> \
  --header "Authorization: Basic <token>" \
  --header "Content-Type: application/json"
```

Just created, waiting for the customer:

```json
{
  "session_id": "39a1c773-bafd-754d-af1f-b30c592f1267",
  "status": "WAITING",
  "updated_at": "2019-05-13T14:51:46.288Z",
  "expires_at": "2019-05-15T13:51:43.507Z"
}
```

Completed — you get the order identifier. A session on a Checkout order never carries an
`authorization_token`, whatever `place_order_mode` says; that field belongs to sessions
on other payment providers:

```json
{
  "session_id": "39a1c773-bafd-754d-af1f-b30c592f1267",
  "status": "COMPLETED",
  "order_id": "a1a8f727-2756-6058-bd3c-40069be0994b",
  "klarna_reference": "X438HG0Q",
  "updated_at": "2019-05-13T14:54:04.675Z",
  "expires_at": "2019-05-15T13:51:43.507Z"
}
```

`klarna_reference` is the short human-readable reference on the order, the one that
appears on statements and in support conversations. The field name is what the API
actually returns.

## Session statuses

`updated_at` always carries the moment the status last changed.

| Status | Final | What happened |
|---|---|---|
| `WAITING` | No | The session exists, and the customer hasn't opened it |
| `IN_PROGRESS` | No | The customer opened the page |
| `COMPLETED` | Yes | The payment was authorized. Carries `order_id` |
| `FAILED` | No | The payment was refused. The customer may retry |
| `CANCELLED` | Yes | The customer confirmed a cancellation. Irreversible |
| `BACK` | No | The customer pressed **Back**. They may return |
| `ERROR` | No | Something failed during payment. The customer may retry |
| `DISABLED` | Yes | You disabled it with a `DELETE`. Irreversible |

Only three statuses are final. `FAILED`, `BACK` and `ERROR` all leave the session usable,
so don't treat them as the end of the purchase.

## Status callbacks

Set `merchant_urls.status_update` when creating the session and Kustom `POST`s to it on
every status change. This is cheaper and faster than polling, and is the mechanism to
build on.

### Callback body

| Field | Type | Meaning |
|---|---|---|
| `event_id` | String | Event identifier. De-duplicate on it — you may see the same event twice |
| `session` | Object | The session, in exactly the shape the read endpoint returns |

```json
{
  "event_id": "27ba32b0-644b-4b22-94a9-dac503bcae18",
  "session": {
    "session_id": "39a1c773-bafd-754d-af1f-b30c592f1267",
    "status": "COMPLETED",
    "order_id": "a1a8f727-2756-6058-bd3c-40069be0994b",
    "klarna_reference": "X438HG0Q",
    "updated_at": "2019-05-13T14:54:04.675Z",
    "expires_at": "2019-05-15T13:51:43.507Z"
  }
}
```

### Retries

Kustom doesn't wait on your response before carrying on with the customer. It expects a
`2xx` within **three seconds**. If none arrives it retries twice more, three seconds apart
— **three attempts in total**, each with its own three-second deadline — and then gives up
on that callback.

It gives up on the callback, not on the session. Every status change is dispatched and
retried independently, so a missed `IN_PROGRESS` doesn't cost you the `COMPLETED` that
follows. Still, one status change getting three attempts inside ten seconds is a thin
margin, which is why a polling backstop is worth having.

### Securing the endpoint

The URL must be HTTPS. Kustom sends no signature or authorization header, so put a secret
of your own in the URL and check it on arrival — and treat the callback as a trigger to
read the session, not as trusted state:

```json
{
  "merchant_urls": {
    "status_update": "https://example.com/hpp-callback?hppSessionId={{session_id}}&secretToken=7d1cbc3b-b30c-4be2-a8c4-dc76482d7bf6"
  }
}
```

## Distributing a session

`POST` to the `distribution_url` from the create response. Three methods.

**By e-mail.** Kustom sends the customer a message, localized to the session's language,
containing the link. If the address matches a customer who has used Kustom before and
consented to pre-filling, their details are pre-filled, which measurably shortens the
payment.

```json
{
  "method": "email",
  "contact_information": { "email": "user@example.com" }
}
```

Kustom validates the address and returns `4xx` for a bad one, so put validation in your
own UI too. Delivery is asynchronous and can take minutes. **Playground is capped at 100
e-mails per day.**

**By SMS.** Kustom texts the link to a phone number. It validates the number against
`phone_country` (ISO 3166-1 alpha-2) and answers an invalid one with `CONSTRAINT_VIOLATION`:

```json
{
  "method": "sms",
  "contact_information": { "phone": "07000212345", "phone_country": "SE" }
}
```

SMS is sent only when the order's purchase country is one of AT, DE, DK, FI, FR, GB, NL,
NO or SE. Anywhere else Kustom sends an e-mail instead. A repeat SMS for the same session to
the same number within 10 minutes (1 minute in Playground) is answered as a success but
not sent.

**By token.** For a customer who has scanned the QR code on a shared screen. Pass the
`access_id` the code yielded, unmodified and correctly JSON-escaped:

```json
{
  "method": "token",
  "contact_information": { "access_id": "5a5e8e70-1a96-4651-b3b6-b774012a5bbb" }
}
```

### Distribution error codes

| Status | Code | Meaning |
|---|---|---|
| 400 | `UNKNOWN` | Undefined error. Contact Kustom support |
| 400 | `MISSING_CONTACT_DATA` | The contact block is incomplete |
| 400 | `CONSTRAINT_VIOLATION` | The request is malformed |
| 400 | `UNSUPPORTED_COUNTRY` | The phone number's country is not valid in this region |
| 400 | `BLACKLIST_REFUSED` | The recipient is blocked |
| 429 | `EMAIL_LIMIT_EXCEEDED` | The Playground daily e-mail cap was hit |
| 404 | — | The session no longer exists. No body, so there is no code to branch on |
| 502 | — | Distribution failed. No body either; retry, and escalate if it persists |

Only the `400` and `429` responses carry a body with an `error_code`. A `404` and a `502` are
bodiless, so for those the status is the whole signal.

## Disabling a session

```bash
curl -X DELETE https://api.kustom.co/hpp/v1/sessions/<session_id> \
  --header "Authorization: Basic <token>"
```

No request body, no response body. The session's status becomes `DISABLED`, permanently.
A customer who follows the old link gets an explicit error. A customer who was already on
the page gets a "Page Not Found".

## Related

- [Integrating the Hosted Payment Page](/checkout/surfaces/hosted-payment-page/) — the flow these calls belong to
- [Create and update an order](/checkout/integrate/create-order/) — the order a session wraps
- [Checkout API reference](/api/checkout/) — the order's full schema
- [Capture an order](/order-management/capture/) — what happens after `COMPLETED`
