# Checkout sessions (/developers/checkout-sessions)

A checkout session is one payment link for one of your orders. Your server creates it with
`POST /v1/checkout-sessions`, the payer pays on Fianto's hosted checkout page, and Fianto tells your
server when the payment is final. This guide covers one-time payments (`mode: payment`); for
recurring prices see [Subscriptions](/developers/subscriptions).

**The steps of a one-time checkout**

1. From your server, create a session with `POST /v1/checkout-sessions`. The first create returns its `url`; creating again for the same open order returns `url: null`, so reissue the link instead.
2. Send the payer to that `url`: Fianto's hosted checkout page.
3. The payer's wallet signs the transaction. The wallet only signs; it does not send.
4. The checkout page hands the signed transaction to Fianto.
5. Fianto broadcasts it to Solana, and rebroadcasts it when needed.
6. When the transaction is confirmed, Fianto tells the checkout page, which shows the payer success. The order is not `PAID` yet.
7. When it is finalized, the order becomes `PAID`, the payment `SUCCEEDED`, and Fianto sends `order.paid` and `checkout.session.completed` to your webhook endpoint — only at finalized.

## Before you start [#before-you-start]

* Your merchant account is approved.
* You have an application's app id and secret. See [Authentication](/developers/authentication).
* Your receiving wallet has a USDC token account. If it has none, send it any amount of USDC once,
  or creating a session fails with `merchant_token_account_missing`.
* You have a webhook route for `order.paid`. See [Webhooks](/developers/webhooks/overview).

## Steps [#steps]

**Step 1.**

### Decide what the payer pays [#decide-what-the-payer-pays]

Send exactly one of these:

* **`price_id`**: an active one-time price on an active product, created in the dashboard. The
  price sets the amount.
* **`amount` with `description`**: a decimal USDC string from `0.01` to `1000000`, with at most
  6 decimals, such as `"12.50"`. `description` is required with `amount` (up to 1,000 characters).

`line_items` (up to 50, each with a name, a quantity and an amount) are shown to the payer only.
Fianto never adds them up or checks them against the amount, so they do not set what the payer pays.

> **The service fee is added on top:**
>
> When the platform's service fee is above zero, it is added on top of your amount and paid by the
> payer, in the same transaction, to Fianto's treasury. The fee is rounded down in the payer's favour
> and fixed on the session when you create it.

**Step 2.**

### Create the session [#create-the-session]

`mode`, `order_id` (your own id, up to 128 characters), `success_url` and `cancel_url` are
required. The URLs must be `https`, up to 2,048 characters. Unknown fields are refused with 400
`validation_failed`.

#### curl

```bash
curl https://api.fianto.xyz/v1/checkout-sessions \
  --user "$FIANTO_APP_ID:$FIANTO_APP_SECRET" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: checkout-order_1001-1" \
  -d '{
    "mode": "payment",
    "order_id": "order_1001",
    "amount": "12.50",
    "description": "Coffee beans, 1 kg",
    "success_url": "https://shop.example/thank-you",
    "cancel_url": "https://shop.example/cart"
  }'
```

#### SDK

```ts
import { Fianto } from '@fianto/sdk';

const fianto = new Fianto(); // reads FIANTO_APP_ID and FIANTO_APP_SECRET

const session = await fianto.checkoutSessions.create(
  {
    mode: 'payment',
    order_id: 'order_1001',
    amount: '12.50',
    description: 'Coffee beans, 1 kg',
    success_url: 'https://shop.example/thank-you',
    cancel_url: 'https://shop.example/cart',
  },
  { idempotencyKey: 'checkout:order_1001:1' },
);

// Store session.id with your order. session.url is the payment link.
console.log(session.id, session.url, session.expires_at);
```

Optional fields:

| Field                                  | Rules                                                    |
| -------------------------------------- | -------------------------------------------------------- |
| `customer_email`, `customer_reference` | Your own details about the payer                         |
| `ui_mode`                              | `redirect` (the default) or `popup`                      |
| `expires_at`                           | From 5 minutes to 24 hours from now. Default: 30 minutes |
| `metadata`                             | Up to 20 keys; key up to 40 characters, value up to 500  |
| `line_items`                           | Display only, see the previous step                      |

> **The url comes back only once:**
>
> The response carries `url` only when you create a session or reissue its link. Every other read
> returns `url: null`. Creating again with the same `order_id` while its session is still open
> returns that same session with `url: null`. To get a working link for it, reissue the link (step 5).

**Step 3.**

### Send the payer to checkout [#send-the-payer-to-checkout]

* **Redirect** (`ui_mode: redirect`): send the browser to `url`. After a success, checkout moves the
  payer to `success_url` after about 3 seconds. A cancel goes to `cancel_url`. An expired session
  stays on the checkout page. Fianto adds no query parameters to either URL.
* **Popup** (`ui_mode: popup`): open `url` in a popup with `@fianto/js` or `@fianto/react`. When the
  payer succeeds, cancels or the session expires, the popup posts
  `{ type: 'fianto.checkout', session_id, status }` to the **origin of your `success_url`**, then
  closes. Serve the page that opens checkout from that origin.

The SDK's checkout route helpers always create popup sessions. See the
[quickstart](/get-started/quickstart).

**Step 4.**

### Fulfil the order from Fianto, not from the browser [#fulfil-the-order-from-fianto-not-from-the-browser]

The checkout page shows the payer a success message once the payment is **confirmed**. Fianto marks
the order `PAID` and the payment `SUCCEEDED`, and sends `order.paid` and
`checkout.session.completed`, only once it is **finalized**.

> **success_url is not proof of payment:**
>
> Reaching `success_url`, or a popup reporting `succeeded`, only means the payer's browser believes
> checkout finished. Fulfil the order from the `order.paid` webhook, or from a server-side read that
> shows the order `PAID`.

**Step 5.**

### Reissue or cancel the session when you need to [#reissue-or-cancel-the-session-when-you-need-to]

**Reissue the link** with `POST /v1/checkout-sessions/{id}/link` (`reissueLink` in the SDK). It
returns a new `url` and the old link stops working. Fianto refuses when the session is not open or
has less than 120 seconds left (`session_not_reissuable`), when a payment is in progress or the
last transaction prepared for the payer could still land (`payment_in_progress`), or when Fianto
could not check Solana in time (`checkout_unavailable`, with `Retry-After`).

**Cancel** with `POST /v1/checkout-sessions/{id}/cancel`. Only an open session with no payment in
progress can be cancelled. Fianto sends `checkout.session.canceled`; there is no order event, and the
order stays `PENDING`. The payer can also cancel on the checkout page, with the same result.

```ts
import { Fianto } from '@fianto/sdk';

const fianto = new Fianto();

export async function newLink(sessionId: string) {
  const session = await fianto.checkoutSessions.reissueLink(sessionId);
  return session.url; // the old link no longer works
}

export async function cancelCheckout(sessionId: string) {
  await fianto.checkoutSessions.cancel(sessionId);
}
```

Both are `POST`s, so the curl form needs an `Idempotency-Key` too.

## Session statuses [#session-statuses]

| Status | Meaning | What you can do |
|---|---|---|
| `OPEN` | Waiting for the payer. The link works until expires_at. | Reissue the link, or cancel. |
| `COMPLETED` | The payment finalized. The order is PAID. | Fulfil the order (from order.paid). |
| `EXPIRED` | Nobody paid before expires_at. | Create a new session with the same order_id to try again. |
| `CANCELED` | You or the payer cancelled it. The order stays PENDING. | Create a new session with the same order_id to try again. |

Fianto checks for expired sessions every 30 seconds, and a payment in flight keeps a session from
expiring. When that check expires a session, Fianto sends `checkout.session.expired` and
`order.expired`. When a new create for the same `order_id` replaces a lapsed session, that session
still sends `checkout.session.expired`, but there is no `order.expired`: the order is reopened for the
new session.

`EXPIRED` and `CANCELED` are not final for the order. If a payment for that session still
finalizes, and you have not opened a newer session for the order, the order becomes `PAID` and
Fianto sends `order.paid` (without `checkout.session.completed`). Keep your webhook route listening.

## Check it worked [#check-it-worked]

> Read the session back from your server:
>
> ```bash
> curl https://api.fianto.xyz/v1/checkout-sessions/SESSION_ID \
>   --user "$FIANTO_APP_ID:$FIANTO_APP_SECRET"
> ```
>
> Right after creating it, `status` is `OPEN` and `url` is `null`. After a payer pays and the payment
> finalizes, `status` is `COMPLETED`, `order.status` is `PAID`, and your webhook route received
> `order.paid`.

## Troubleshooting [#troubleshooting]

| You see | Why | Fix |
|---|---|---|
| 400 `amount_or_price_required` | You sent neither `price_id` nor `amount`, both, or `amount` without `description`. | Send `price_id` alone, or `amount` with `description`. |
| 400 `amount_out_of_range` | The amount is below 0.01 or above 1,000,000 USDC. | Send an amount in range, as a decimal string. |
| 400 `validation_failed` with `field: "amount"` | `amount` is not a decimal string with at most 6 decimal places (for example a JSON number, or `"10.1234567"`). | Send the amount as a string such as `"10.5"`. |
| 400 `url_insecure` | `success_url` or `cancel_url` is `http`. | Use `https` URLs. |
| 400 `expires_at_out_of_range` | `expires_at` is less than 5 minutes or more than 24 hours away. | Pick a time in that window, or leave it out for 30 minutes. |
| `price_not_found`, `price_archived`, `product_archived` or `price_type_mismatch` | The `price_id` does not name an active one-time price on an active product of yours. | Copy the price id from the dashboard, and check it is one-time and not archived. |
| 409 `order_already_paid` | This `order_id` is already paid. | Use a new `order_id` to charge again. |
| 422 `merchant_token_account_missing` | Your receiving wallet has no USDC token account. | Send the wallet any amount of USDC once. |
| `url` is `null` | The order already has an open session, or you read the session instead of creating it. | Reissue the link with `POST /v1/checkout-sessions/{id}/link`. |
| 409 `session_not_reissuable` | The session is not open, or has less than 120 seconds left. | If it is still open, cancel it first. Then create a new session with the same `order_id`. |
| 409 `payment_in_progress` | A payment for this session is being processed, or (on a reissue) the last transaction prepared for the payer could still land. | Wait for it to finish; do not create a second payment for the order. |
| 409 `session_not_cancelable` | The session is no longer open. | Read the session to see its status. |
| The checkout page refuses with `build_limit_reached` or `too_close_to_expiry` | A session allows 10 successful builds and 30 build attempts, and no build in its last 120 seconds. | Cancel the session, then create a new one with the same `order_id`. |
| 409 `checkout_unavailable` | Fianto could not reach or check Solana in time (for example while checking your receiving wallet, or before a reissue). | Retry after `Retry-After` (5 seconds). The SDK retries it for you. |

## See also [#see-also]

- [Quickstart](/get-started/quickstart): A checkout route, pay button and webhook route with the SDK.

- [Webhook events](/developers/webhooks/events): What order.paid and the checkout events carry.

- [Idempotency](/developers/idempotency): Retry a create safely without losing the url.

- [How money moves](/get-started/how-money-moves): What the payer signs, and confirmed vs finalized.