# 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://api.fynex.ai/payments-api/v2/docs#tag/server-to-server) instead.

## Lifecycle

```
Your backend                    Fynex                       Customer browser
─────────────                    ─────                       ────────────────
POST /checkout    ─────────►    creates session
                                returns sessionId,
                                checkoutUrl, expiresAt
                  ◄──── response
redirect customer ────────────────────────────────────────►  fills card form
                                                             on hosted page
                                                             (3DS handled here)
                                Fynex processes payment ◄──── submit
                                                             redirect →
                                                             returnUrls.success
                                                             or .failure
poll for status   ─────────►   (see Polling & SSE guide)
verify, fulfil
```

> [!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 the GraphQL `genericPayment` query or subscribe to SSE events. See the [Polling & SSE](https://api.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.

#### curl

```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"
  }'
```

#### JavaScript

```js
import { randomUUID } from 'node:crypto';

export async function createCheckoutSession(order) {
  const res = await fetch(
    `${process.env.FYNEX_API}/payments-api/v1/checkout`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.FYNEX_TOKEN}`,
        'Content-Type': 'application/json',
        'Idempotency-Key': randomUUID(),
      },
      body: JSON.stringify({
        externalOrderRef: order.id,
        amount: order.amount,
        currencyCode: order.currency,
        countryCode: order.countryCode,
        returnUrls: {
          success: `https://example.com/orders/${order.id}/success`,
          failure: `https://example.com/orders/${order.id}/failure`,
        },
        description: `Order #${order.id}`,
      }),
    }
  );
  if (!res.ok) throw new Error(await res.text());
  return res.json();
}
```

#### Python

```python
import os
import uuid
import requests

def create_checkout_session(order: dict) -> dict:
    res = requests.post(
        f"{os.environ['FYNEX_API']}/payments-api/v1/checkout",
        headers={
            "Authorization": f"Bearer {os.environ['FYNEX_TOKEN']}",
            "Content-Type": "application/json",
            "Idempotency-Key": str(uuid.uuid4()),
        },
        json={
            "externalOrderRef": order["id"],
            "amount": order["amount"],
            "currencyCode": order["currency"],
            "countryCode": order["country_code"],
            "returnUrls": {
                "success": f"https://example.com/orders/{order['id']}/success",
                "failure": f"https://example.com/orders/{order['id']}/failure",
            },
            "description": f"Order #{order['id']}",
        },
        timeout=10,
    )
    res.raise_for_status()
    return res.json()
```

   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 the GraphQL `genericPayment(id)` query or use SSE to confirm the final state before fulfilling the order.

   See [Polling & SSE](https://api.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. Default: `false`. |
| `orderData.payeeDistribution` | array | Split funds across payees. Each element is `{ "payeeId": <int64>, "amount": <float> }`. The amounts must sum to `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"`). |

> [!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`.

```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, "amount": 85.00 },
      { "payeeId": 102, "amount": 15.00 }
    ]
  }
}
```

All amounts are in major units. The distribution sum must equal `amount` (or `orderData.totalAmount` if provided).

## 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://api.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]
> **Misleading 401 error: "sellerAccountId is missing in auth context"**
>
> This error sounds like an authentication problem, but it is almost always caused by a **missing or malformed `Idempotency-Key` header**. The checkout handler reads the seller context via the same helper that validates the header — a missing or non-UUID key surfaces as this misleading 401 before the Bearer token is even checked. Verify your header first before investigating auth issues.

- **`Idempotency-Key` reuse with a different body returns `409 Conflict`.** 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://api.fynex.ai/payments-api/v2/docs#tag/test-cards) guide, which lists the full set.

## See also

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