# Hosted checkout

Hosted checkout is the **simplest, lowest-friction** way to accept a payment with Fynex. Card details are entered on a Fynex-hosted page, so your servers and frontend never touch raw PAN/CVV — your PCI obligations stay at the SAQ A level.

## When to use it

- You want to ship fast.
- You don't have an existing card-collection UI.
- You're OK redirecting customers to a Fynex domain to complete the payment.

If you need to keep the customer on your own domain or already manage card data securely, use [server-to-server](https://docs.fynex.ai/payments-api/v2/docs#tag/server-to-server) instead.

## Lifecycle

```flow
lanes: Your backend | Fynex | Customer browser
node create 0 0 | Create session | POST /checkout | | end
node session 1 1 | Session created | sessionId, checkoutUrl
node redirect 0 2 | Redirect customer | to checkoutUrl
node pay 2 3 | Hosted payment page | Card form and 3DS
node process 1 4 | Process payment | Authorize and capture
node return 2 5 | Return to your site | returnUrls.success or .failure
node verify 0 6 | Verify and fulfil | Webhook or poll status | | end ok
edge create session | POST
edge session redirect | response
edge redirect pay | redirect
edge pay process | submit
edge process return | redirect
edge return verify | poll status
```

> [!NOTE]
> Fynex delivers a `PaymentCompleted` webhook to the webhook URL(s) configured on your seller account (your receiver must return HTTP 200). To verify the final payment state — or as a backstop — poll `GET /payments-api/v1/payments/{payment_id}` (bearer token; the path value is your `externalOrderRef`) from your server, or subscribe to SSE events in the browser. See the [Polling & SSE](https://docs.fynex.ai/payments-api/v2/docs#tag/polling-sse) guide.

## Step-by-step

1. **Create a checkout session from your backend**

   Send `POST /payments-api/v1/checkout` with a Bearer token and a unique `Idempotency-Key` header.

```bash
curl -sS -X POST "$FYNEX_API/payments-api/v1/checkout" \
  -H "Authorization: Bearer $FYNEX_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "externalOrderRef": "ORDER-1042",
    "amount": 49.00,
    "currencyCode": "GBP",
    "countryCode": "GB",
    "returnUrls": {
      "success": "https://example.com/orders/1042/success",
      "failure": "https://example.com/orders/1042/failure"
    },
    "description": "Order #1042"
  }'
```

   You receive a `201 Created` response:

   ```json
   {
     "sessionId": "6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b",
     "checkoutUrl": "https://pay.fynex.ai/checkout/6f9b84e1-3b83-4fb9-9f42-a8ac27d11d6b",
     "expiresAt": "2026-04-29T13:30:00Z"
   }
   ```

   The TTL is set server-side; `expiresAt` tells you when the session will become invalid.

2. **Redirect the customer**

   Send a `302` to `checkoutUrl`, or render it as a button. Do not embed it in an iframe — browsers may block cross-origin iframe navigation.

   ```js
   res.redirect(302, session.checkoutUrl);
   ```

3. **Customer completes the payment on the hosted page**

   3DS challenges, if required, are handled internally by the hosted page. Your integration does not need to manage any 3DS redirect.

4. **Customer is redirected back**

   On completion, Fynex redirects the customer to `returnUrls.success` (payment completed) or `returnUrls.failure` (payment failed or abandoned). The redirect itself carries no authoritative payment state — do not grant fulfilment based on it.

5. **Verify the payment status**

   Poll `GET /payments-api/v1/payments/{payment_id}` (bearer token; the path value is your `externalOrderRef`) or use SSE to confirm the final state before fulfilling the order.

   See [Polling & SSE](https://docs.fynex.ai/payments-api/v2/docs#tag/polling-sse) for code samples.

## Request fields reference

### Required fields

| Field | Type | Description |
|-------|------|-------------|
| `externalOrderRef` | string | Your order reference. Stored on both the checkout session and the underlying payment. |
| `amount` | float | Amount in major units (e.g., `49.00` for £49.00). |
| `currencyCode` | string (3-letter) | ISO 4217 code, e.g. `GBP`. Normalized to upper-case server-side. Note: `/checkout` does not enforce a currency allowlist — an unsupported currency will be rejected at payment time, not at session creation. |
| `countryCode` | string (2-letter) | ISO 3166-1 alpha-2. Required despite the `omitempty` JSON tag. |
| `returnUrls.success` | string | URL the customer is redirected to on successful payment. |
| `returnUrls.failure` | string | URL the customer is redirected to on failure. Note the field name is `failure`, not `cancel`. |

### Optional fields

| Field | Type | Description |
|-------|------|-------------|
| `autoSettlement` | bool | When `true`, the payment auto-settles immediately; `false` leaves it for manual `POST /capture`. Omitted means the seller account's configured default. |
| `orderData.payeeDistribution` | array | Split funds across payees. Each element is `{ "payeeId": <int64>, "amountMinor": <int64> }`, the share in integer minor units (`8500` for £85.00). The deprecated `{ "payeeId": <int64>, "amount": <float> }` in major units is still accepted; every element must use the same one. The shares must add up to the payment `amount`. See [Split payments](#split-payments). |
| `sellerMerchantName` | string | Business name displayed on the hosted checkout page. |
| `logoUrl` | string | Logo URL displayed on the hosted checkout page. |
| `locale` | string | Language code (e.g., `en`). Falls back to the seller account locale, then `en`. |
| `description` | string | Order description shown to the customer (e.g., `"Order #1042"`). |
| `allowPromotions` | bool | When `true`, the customer may apply a coupon or Fynex wallet credit on the hosted page, and the amount charged is then lower than `amount` by the promotion. Defaults to `false`: the customer pays exactly `amount`. Promotions are offered only once Fynex has switched coupon checkout on. |

> [!CAUTION]
> **Fields that do NOT exist on this DTO:** `successUrl`, `cancelUrl`, `expiresInMinutes`, `metadata`, `customerEmail`, `payeeId` (top-level), `splitRules` (top-level). Do not send these — the server will ignore them silently or reject the request.

## Split payments

To distribute the captured amount across multiple payees, include `orderData.payeeDistribution`. Retrieve payee IDs from `GET /payments-api/v1/payees`.

> [!NOTE]
> `payeeDistribution` only takes effect alongside an active [Split Rule](https://docs.fynex.ai/payments-api/v2/docs#tag/splits) on your
> seller account — with no active rule, it's silently ignored and nothing is distributed. See
> [Split Rules § Precedence when both are present](https://docs.fynex.ai/payments-api/v2/docs#tag/splits) for exactly how the two interact.

```json
{
  "externalOrderRef": "ORDER-1042",
  "amount": 100.00,
  "currencyCode": "GBP",
  "countryCode": "GB",
  "returnUrls": {
    "success": "https://example.com/success",
    "failure": "https://example.com/failure"
  },
  "orderData": {
    "totalAmount": 100.00,
    "payeeDistribution": [
      { "payeeId": 101, "amountMinor": 8500 },
      { "payeeId": 102, "amountMinor": 1500 }
    ]
  }
}
```

`amount` and `orderData.totalAmount` are in major units. Each share's `amountMinor` is in integer minor units, so `8500` is £85.00. The shares must add up to `amount` in minor units, which is `10000` for the £100.00 above (or to `orderData.totalAmount` if you provide it). A distribution sent in the wrong unit cannot add up, so it is refused with `400`. The deprecated major-units `amount` on a share is still accepted, but every share in one distribution must use the same unit — see [Split Rules](https://docs.fynex.ai/payments-api/v2/docs#tag/splits). What happens to the money afterwards (fees, rounding, holds, refunds) is described in [Split Rules](https://docs.fynex.ai/payments-api/v2/docs#tag/splits), section *How Fynex splits money*.

## 3DS handling

The hosted checkout page manages the entire 3DS flow internally. If the issuer requires a challenge, the customer completes it on the hosted page without ever leaving the Fynex-controlled flow. Your backend only sees the final outcome via polling/SSE — there is no `requiresAction` or `redirectUrl` on this path.

If you need direct control over the 3DS redirect (e.g., your own checkout UI), use [server-to-server](https://docs.fynex.ai/payments-api/v2/docs#tag/server-to-server) instead.

## Common pitfalls

> [!WARNING]
> **Do not fulfil the order based on the redirect alone.** A user can navigate directly to `returnUrls.success` without paying. Always verify the payment state server-side before dispatching goods or services.

> [!CAUTION]
> **`401 seller auth is required`**
>
> `/checkout` answers this when the request carries no authenticated seller — a missing, malformed or revoked Bearer token (`/initialize-payment` phrases the same condition as `merchantId is missing in auth context`). It is not an `Idempotency-Key` problem: a missing or non-UUID key answers `400` with `Idempotency-Key header is required` / `Idempotency-Key must be a valid UUID`, and that check runs only after the token has passed. Fix the token for a 401, the header for those 400s.

- **`Idempotency-Key` reuse with a different body returns `409 Conflict`** — `allowPromotions` included. Use a fresh UUID per order.
- **`returnUrls.failure` — not `cancelUrl`.** The field is named `failure`. Sending `cancelUrl` has no effect.
- **`currencyCode` is not validated at session creation.** An unrecognized currency may be silently accepted at `/checkout` and only rejected when the payment is processed. Test end-to-end in staging.
- **Amount is locked at session creation.** To change the amount, create a new session.

## Test cards

The hosted checkout page manages 3DS internally and **always runs it** — unlike the
server-to-server flow, the hosted page does not expose the `skip3DS` flag. Because 3DS always
runs, the PAN you use decides the authentication outcome, and a handle that does not
authenticate (`threeDResult` other than `Y`/`A`) **cannot be settled**.

For a happy-path success in staging use one of the frictionless-`Y` PANs, with any future
expiry date and any 3-digit CVV:

| Card number | Brand |
|-------------|-------|
| `4000 0000 0000 2701` | Visa |
| `5200 0000 0000 2235` | Mastercard |

> [!IMPORTANT]
> Do **not** use `4111 1111 1111 1111` here. It is not one of the authenticating PANs, so on
> the hosted checkout it always ends in a decline. It only works on flows that skip 3DS.

To exercise a decline or a failed 3DS challenge, use one of the challenge/failure PANs from the
[Test cards & sandbox](https://docs.fynex.ai/payments-api/v2/docs#tag/test-cards) guide, which lists the full set.

With no buyer at hand — a test suite, a CI job, an AI agent — a Demo account can pay its own
session with `POST /test/checkout/{sessionId}/complete`: Fynex submits this page with the
frictionless Visa above, so no card details pass through your code. See
[Test cards & sandbox](https://docs.fynex.ai/payments-api/v2/docs#tag/test-cards).

## See also

- **[Checkout widget](https://docs.fynex.ai/payments-api/v2/docs#tag/checkout-widget)** — Want to embed Fynex
- **[Polling & SSE](https://docs.fynex.ai/payments-api/v2/docs#tag/polling-sse)** — Verify payment outcomes without webhooks.
- **[Captures & refunds](https://docs.fynex.ai/payments-api/v2/docs#tag/captures-refunds)** — Capture, partially capture, or refund a payment after the fact.
- **[Server-to-server payments](https://docs.fynex.ai/payments-api/v2/docs#tag/server-to-server)** — Take full control of the payment flow and 3DS redirect.
