# Hosted checkout flow

The hosted checkout is the standard way to accept a card payment. Paymee hosts the
payment page and handles 3-D Secure, so card data never reaches your servers.

## Lifecycle

1. **Create a session** — `POST /gateway/sessions/` with the amount, the buyer, and
   your URLs. The response includes a `checkout_url`.
2. **Redirect the buyer** to `checkout_url`.
3. **The buyer pays** on the Paymee-hosted page (with 3-D Secure). After a decline they
   can try again on the same page, up to `max_attempts` times. See
   [The payment page](#the-payment-page).
4. **Paymee sends a webhook** to your `webhook_url` with the authoritative result.
   Fulfil the order here, not on the browser redirect.
5. **After a successful payment, the buyer is redirected** to your `return_url`.

```mermaid
sequenceDiagram
    participant S as Your server
    participant P as Paymee
    participant B as Buyer
    S->>P: POST /gateway/sessions/
    P-->>S: { checkout_url }
    S->>B: redirect to checkout_url
    B->>P: pays on hosted page (3-D Secure)
    P->>S: webhook session.succeeded (authoritative) → fulfil
    P->>B: redirect to return_url
```

## Create a session

`POST https://api.paymee.online/api/v1/gateway/sessions/`

### Request fields

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `amount` | string | ✅ | Decimal string with the currency's exact number of decimals: **3 for TND**, e.g. `"10.500"`. See [Amounts & currency](/guides/amounts-and-currency/). |
| `currency` | string | – | ISO 4217 code. Defaults to your account's currency (`TND`). |
| `merchant_reference` | string (≤ 200) | ✅ | Your unique reference for this payment, e.g. your order id. See [Idempotency](/guides/idempotency/). |
| `first_name` | string (≤ 100) | ✅ | Buyer's given name. |
| `last_name` | string (≤ 100) | ✅ | Buyer's surname. |
| `email` | string (≤ 255) | ✅ | Buyer's email. |
| `phone` | string (≤ 20) | – | Buyer's phone. |
| `description` | string (≤ 500) | – | What the buyer is paying for. |
| `order_id` | string (≤ 100) | – | Your display-friendly order id. |
| `items` | array of objects | – | Line items, e.g. `[{ "name": "T-shirt", "quantity": 2, "unit_price": "25.000" }]`. Returned as you sent them. |
| `metadata` | object | – | Your own key/values, returned on the session and every webhook. Up to 50 keys; keys up to 40 characters, without `[` or `]`; values up to 500 characters. Numbers and booleans are stored as text (`42` → `"42"`); objects and lists are refused. |
| `return_url` | string (≤ 2048) | – | Where the buyer is sent after a successful payment. See [Returning the buyer](#returning-the-buyer). |
| `cancel_url` | string (≤ 2048) | – | Where the buyer goes back to your store without paying. |
| `webhook_url` | string (≤ 2048) | – | Where Paymee sends this session's events. Defaults to your account's endpoint. See [Webhooks](/guides/webhooks/). |
| `max_attempts` | integer | – | Payment attempts allowed on the session, 1–10 (default 5). |
| `locale` | string | – | Language of the payment page: `auto` (default), `fr` or `en`. See [Language](#language). |

All three URLs must use `https://` on a domain name; `webhook_url` must not be an IP
address. An optional text field sent as `""` counts as not given.

See the full schema in the
[API Reference → Create a payment session](/reference/operations/createpaymentsession/).

### Response

`201 Created`, with the session:

```json
{
  "id": "ps_0b7c3a1e-5d2f-4e8a-9c6b-1f2e3d4c5b6a",
  "merchant_reference": "order-0001",
  "order_number": "PM-014A2C",
  "order_id": "",
  "amount": "10.500",
  "currency": "TND",
  "original": { "amount": "10.500", "currency": "TND", "rate": "1.000000" },
  "description": "",
  "items": [],
  "status": "CREATED",
  "livemode": true,
  "buyer": {
    "first_name": "Aymen",
    "last_name": "Ben Salah",
    "email": "aymen@example.com",
    "phone": ""
  },
  "metadata": { "cart_id": "8812" },
  "return_url": "https://your-site.tn/orders/order-0001/paid",
  "cancel_url": "https://your-site.tn/cart",
  "webhook_url": "https://your-site.tn/webhooks/paymee",
  "max_attempts": 5,
  "checkout_url": "https://pay.paymee.online/checkout/kq2V8Zr3xN0bT6mW1yPdF4sHjL9cA7uE",
  "locale": "auto",
  "expires_at": "2026-11-21T10:24:00.000000Z",
  "completed_at": null,
  "created_at": "2026-11-20T10:24:00.000000Z",
  "attempts": [],
  "refunds": []
}
```

`buyer` is the same object as in webhooks. Each entry of `attempts` has
`failure_code` and `failure_message` — the acquirer's decline code and reason, as in
webhooks — when that attempt was declined.

Redirect the buyer to `checkout_url` exactly as returned. Sending the same
`merchant_reference` again returns this session with `200 OK` instead of creating a
second one — see [Idempotency](/guides/idempotency/).

## The payment page

`checkout_url` opens Paymee's payment page, where the buyer pays by card (Visa or
Mastercard).

- **Two pages, one flow.** Paymee's page shows the amount, a language switcher, the result
  and the buttons back to your store. The card is entered on the page of ClicToPay, the
  acquiring bank, so card details never reach your servers.
- **3-D Secure on every payment.** The buyer's bank checks that the card is theirs: it may
  ask for a code by SMS or an approval in its app, or approve without asking anything.
- **A decline is not the end.** The buyer stays on the page and can try again, with the
  same card or another, up to `max_attempts` times (default 5). See
  [Session status](#session-status).

### Language

The payment page is in French or English. Choose its language with `locale`:

| `locale` | The page opens in |
| --- | --- |
| `auto` (default) | The buyer's browser language: French for a French browser, English for any other. |
| `fr` | French. |
| `en` | English. |

The buyer can switch language on the page; their choice wins over `locale` and is kept on
that browser for later payments. The card-entry page opens in the language the buyer is
reading when they click **Pay**.

### Websites and mobile apps

On a website, send the buyer to `checkout_url` in the same tab or a new one. The payment
page can't be shown in an iframe: browsers refuse to frame it.

In a mobile app, open `checkout_url` in an **in-app browser tab**:
`SFSafariViewController` on iOS, Custom Tabs on Android. The buyer stays in your app, the
page shows its real address, and banks' 3-D Secure steps work as in a browser, including
a hand-off to the bank's own app.

To bring the buyer back to your app, make `return_url` and `cancel_url` links your app
opens: a universal link on iOS or an App Link on Android, on your own domain (for example
`https://your-site.tn/app/orders/order-0001/paid`). When the app opens, read the order's
state from your server, which the [webhook](/guides/webhooks/) keeps up to date.

:::caution[Avoid embedded WebViews]
An embedded WebView (`WKWebView`, Android `WebView`) lets your app read what the buyer
types on the page, card number included, and some banks' 3-D Secure steps fail inside
one. If you have to use one:

- enable JavaScript and DOM storage, and accept cookies (on Android, third-party cookies
  too, with `CookieManager.setAcceptThirdPartyCookies`);
- open links the WebView can't load itself (a bank's app, `intent://` or other custom
  schemes) outside it;
- watch navigation for your `return_url` and `cancel_url`, then close the WebView.
:::

## Returning the buyer

Paymee doesn't add anything to your URLs: the buyer lands on exactly the `return_url` or
`cancel_url` you gave. Put what you need to show the right page in the URL itself, for
example `https://your-site.tn/orders/order-0001/paid`.

- **`return_url`** — after a **successful** payment, the hosted page shows a short
  confirmation, then redirects the buyer here. Without one, the buyer stays on the
  confirmation.
- **`cancel_url`** — the hosted page shows a **Return to store** button linking here:
  while paying, after a decline, and when the link or the session has expired. Without
  one, the page tells the buyer they can close the window.

A declined buyer is not redirected: they stay on the hosted page and can try again while
attempts remain.

:::tip[Don't fulfil on redirect]
The buyer landing on `return_url` is **not** a reliable signal — they can close the tab
before the redirect. Fulfil orders from the [webhook](/guides/webhooks/), and treat
`return_url` purely as UX. On that page, read the order's state from your own database
(updated by the webhook), or fetch the session.
:::

## Session status

A session moves through these statuses (`status` field):

| Status | Meaning |
| --- | --- |
| `CREATED` | Created; no payment attempt yet. |
| `PROCESSING` | A payment attempt is in progress (the buyer is on the card or 3-D Secure page). |
| `PENDING` | An attempt was declined; the buyer can try again. |
| `SUCCEEDED` | Paid. **Final.** |
| `FAILED` | Every allowed attempt was declined. **Final.** |
| `EXPIRED` | Not paid within the session's lifetime (about 24 hours). **Final.** |
| `CANCELED` | Canceled by your `cancel` call. **Final.** |

```mermaid
stateDiagram-v2
    direction LR
    [*] --> CREATED
    CREATED --> PROCESSING: payment attempt
    PROCESSING --> SUCCEEDED: approved
    PROCESSING --> PENDING: declined, attempts left
    PENDING --> PROCESSING: buyer tries again
    PROCESSING --> FAILED: declined, no attempts left
    PENDING --> FAILED: no attempts left
    CREATED --> CANCELED: your cancel call
    PENDING --> CANCELED: your cancel call
    CREATED --> EXPIRED: lifetime over
    PENDING --> EXPIRED: lifetime over
    PROCESSING --> EXPIRED: lifetime over
    SUCCEEDED --> [*]
    FAILED --> [*]
    CANCELED --> [*]
    EXPIRED --> [*]
```

The buyer can't cancel a session: one they abandon stays open until it expires. Each
final status has its webhook (`session.succeeded`, `session.failed`, `session.expired`)
except `CANCELED`, which only your own call can cause.

:::note[Session lifetime vs. checkout-link lifetime]
The 24 hours above are the **payment session's** lifetime. The `checkout_url` you
redirect the buyer to is a separate, shorter-lived link (**about 30 minutes**). If a
buyer lets a link go stale it expires on its own, emitting
[`checkout.session.expired`](/guides/webhooks/#events), **while the session stays
open** and payable until its 24 hours elapse. To give the buyer a fresh link, call
`POST /gateway/sessions/` again with the same `merchant_reference`.
:::

## Retrieving sessions

Fetch a session at any time with
[`GET /gateway/sessions/{id}/`](/reference/operations/getpaymentsession/). It includes its
payment `attempts` and its `refunds`. **Webhooks are the primary mechanism**; fetching
is the backup and the source of truth for reconciliation.

List your sessions, newest first, with
[`GET /gateway/sessions/list/`](/reference/operations/listpaymentsessions/):

```bash
curl "https://api.paymee.online/api/v1/gateway/sessions/list/?limit=50" \
  -H "Authorization: Bearer sk_live_…"
```

The response is `{ "data": [ …sessions… ], "has_more": true }`. `limit` is 1–100
(default 20). To get the next page, pass the last session's `id` as `starting_after`;
`ending_before` pages backwards.

## Canceling

Cancel a session that hasn't been paid with
[`POST /gateway/sessions/{id}/cancel/`](/reference/operations/cancelpaymentsession/). The
response is the session, now `CANCELED`, and its checkout link stops working.

- Only `CREATED` and `PENDING` sessions can be canceled. While a payment attempt is
  `PROCESSING`, the call is refused with `422` `invalid-state`: wait for the attempt to
  finish.
- A session that is already final answers `409` `session-terminal`.

Source: https://docs.paymee.online/guides/hosted-checkout/
