> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ionicfi.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Accept a payment with Hosted Checkout

> One API call, a redirect, and Ionic handles the rest. The fastest way to get paid.

Hosted Checkout is the fastest way to take a payment with Ionic: one API call
from your server, a redirect, and you're done. Ionic hosts the payment page
(the card form, Apple Pay, declines and retries) so you never build payment
UI or touch card data.

## When to use it

* You want to get paid this week, not after building a checkout page.
* You're selling one-time products or services and want a page that just
  works.
* You're launching subscriptions and want signup and the first charge handled
  for you.
* You want card data and PCI scope nowhere near your servers.

|                                                | Best for                              | Server code                |
| ---------------------------------------------- | ------------------------------------- | -------------------------- |
| **Hosted Checkout** (this guide)               | The fastest path to a working payment | One API call               |
| [Embedded Checkout](/guides/embedded-checkout) | The payment form on your own domain   | One API call + browser SDK |
| [Payment links](/guides/payment-links)         | Sharing a URL, no code                | None                       |

## How it works

```text theme={null}
Your server creates a Checkout Session
      ↓
The browser redirects to session.url
      ↓
Ionic collects and processes payment
      ↓
Ionic sends checkout.session.completed
      ↓
Your server fulfills the order once
```

## Before you start

* Create a test-mode secret key (`sk_v1_test_...`) in the Dashboard.
* Have a success page and a cancel page on your site.

<Warning>
  Create Checkout Sessions from your server. Never expose an Ionic secret key
  in browser code, a mobile app, a public repository, or a client-visible error.
</Warning>

On Node, install the [server SDK](/sdks/server): typed requests and
responses, an idempotency key generated for each call, and automatic retries on
transient failures.

```bash theme={null}
npm install @ionicfi/sdk
```

Every step below also shows raw curl.

Finished the [Quickstart](/quickstart)? Skip to
[Fulfill from the webhook](#3-fulfill-from-the-webhook).

## 1. Create a Checkout Session

Call `POST /v1/checkout/sessions` from your server, calculating the amount
from prices you trust, never from the browser.

<CodeGroup>
  ```ts TypeScript theme={null}
  // app/api/checkout/route.ts
  import { Ionic } from "@ionicfi/sdk";

  const ionic = new Ionic({ token: process.env.IONIC_SECRET_KEY });

  export async function POST(): Promise<Response> {
    const orderId = "order_1001"; // your pending order's ID

    const session = await ionic.checkout.sessions.create({
      "Idempotency-Key": `${orderId}-checkout`,
      mode: "payment",
      line_items: [
        {
          name: "Pro onboarding",
          amount: 15000,
          currency: "usd",
          quantity: 1,
        },
      ],
      customer_email: "buyer@example.com",
      client_reference_id: orderId,
      success_url: "https://example.com/success?session_id={CHECKOUT_SESSION_ID}",
      cancel_url: "https://example.com/cart",
      metadata: {
        order_id: orderId,
      },
    });

    // Save session.id on your order, then hand the hosted URL to the browser.
    return Response.json({ url: session.url });
  }
  ```

  ```bash curl theme={null}
  curl https://api.ionicfi.com/v1/checkout/sessions \
    -H "Authorization: Bearer $IONIC_SECRET_KEY" \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: order_1001-checkout" \
    -d '{
      "mode": "payment",
      "line_items": [
        {
          "name": "Pro onboarding",
          "amount": 15000,
          "currency": "usd",
          "quantity": 1
        }
      ],
      "customer_email": "buyer@example.com",
      "client_reference_id": "order_1001",
      "success_url": "https://example.com/success?session_id={CHECKOUT_SESSION_ID}",
      "cancel_url": "https://example.com/cart",
      "metadata": {
        "order_id": "order_1001"
      }
    }'
  ```
</CodeGroup>

In this example:

* `amount` is in cents: `15000` is \$150.00. USD only for now.
* `order_1001` is your order's ID; it threads through `Idempotency-Key`,
  `client_reference_id`, and `metadata` so the webhook can find the order.
* `buyer@example.com` pre-fills the buyer's email (optional).
* `success_url` and `cancel_url` are pages on your site.
* `IONIC_SECRET_KEY` is an environment variable holding your test-mode secret
  key.

The line items here are inline: a name, an amount, a currency. If you sell
from your [Catalog](/guides/catalog), send a `price_id` instead and Ionic uses
the price you defined there:

```json theme={null}
{
  "line_items": [
    { "price_id": "price_Pro000000000000000000000", "quantity": 1 }
  ]
}
```

Inline items are the quickest way to start. Catalog prices keep amounts in one
place and are required for subscriptions.

The response includes the session and its hosted URL:

```json theme={null}
{
  "id": "cs_000000000000000000001001",
  "object": "checkout_session",
  "mode": "payment",
  "ui_mode": "hosted",
  "status": "open",
  "payment_status": "unpaid",
  "amount_total": 15000,
  "currency": "USD",
  "url": "https://checkout.ionicfi.com/c/hcs_..."
}
```

Save the `cs_...` ID on your order and use `url` unchanged; it carries its
own token (`hcs_...`), not the session ID.

If the call times out, retry it with the same `Idempotency-Key` and body to
get the original session back. See [Idempotency](/api-reference/idempotency).

### Selling a subscription?

Same call with `mode: "subscription"`, where every line item is a recurring
[Catalog](/guides/catalog) price sharing one billing cadence:

<CodeGroup>
  ```ts TypeScript theme={null}
  const session = await ionic.checkout.sessions.create({
    "Idempotency-Key": "signup_account_492_monthly",
    mode: "subscription",
    line_items: [
      { price_id: "price_Monthly00000000000000000", quantity: 1 },
    ],
    client_reference_id: "signup_492",
    success_url:
      "https://app.example.com/billing/success?session_id={CHECKOUT_SESSION_ID}",
    cancel_url: "https://app.example.com/plans",
    metadata: {
      account_id: "account_492",
    },
  });
  ```

  ```bash curl theme={null}
  curl https://api.ionicfi.com/v1/checkout/sessions \
    -H "Authorization: Bearer $IONIC_SECRET_KEY" \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: signup_account_492_monthly" \
    -d '{
      "mode": "subscription",
      "line_items": [
        {
          "price_id": "price_Monthly00000000000000000",
          "quantity": 1
        }
      ],
      "client_reference_id": "signup_492",
      "success_url": "https://app.example.com/billing/success?session_id={CHECKOUT_SESSION_ID}",
      "cancel_url": "https://app.example.com/plans",
      "metadata": {
        "account_id": "account_492"
      }
    }'
  ```
</CodeGroup>

`price_Monthly00000000000000000` stands in for one of your recurring prices.
Pass `customer` when the buyer already has an Ionic customer record;
otherwise the hosted page collects their details and creates one. Everything
downstream (redirect, webhook, fulfillment) is identical; see
[Subscriptions](/guides/subscriptions) for the recurring lifecycle:
renewals, dunning, cancellation.

## 2. Redirect the buyer

From your page, call your server route and follow the returned URL:

```ts theme={null}
const response = await fetch("/api/checkout", {
  method: "POST",
});

if (!response.ok) {
  throw new Error("Could not start checkout");
}

const { url } = await response.json();
window.location.assign(url);
```

When the buyer pays, Ionic replaces the `{CHECKOUT_SESSION_ID}` placeholder
in `success_url` with the real session ID and redirects. The back button
returns the buyer to `cancel_url`; that and a closed tab both leave the
session `open` and `unpaid`.

On your success page, verify before you show "paid": send the `session_id`
query value to your backend, retrieve the session with your secret key, and
check both status fields.

<CodeGroup>
  ```ts TypeScript theme={null}
  const session = await ionic.checkout.sessions.retrieve({ id: sessionId });

  const paid =
    session.status === "complete" && session.payment_status === "paid";
  ```

  ```bash curl theme={null}
  curl https://api.ionicfi.com/v1/checkout/sessions/cs_000000000000000000001001 \
    -H "Authorization: Bearer $IONIC_SECRET_KEY"
  ```
</CodeGroup>

If the session is still `open`, show a short "Confirming payment" state and
poll your backend.

<Note>
  Don't call `POST /v1/checkout/sessions/{id}/confirm` here; that endpoint
  is for [Embedded Checkout](/guides/embedded-checkout).
</Note>

## 3. Fulfill from the webhook

Subscribe an endpoint to `checkout.session.completed` and
`checkout.session.expired` (delivered when a session lapses unpaid, after 24
hours by default). The completed event carries the order references you set at
creation:

```json theme={null}
{
  "id": "evt_000000000000000000001001",
  "type": "checkout.session.completed",
  "data": {
    "object": {
      "id": "cs_000000000000000000001001",
      "status": "complete",
      "payment_status": "paid",
      "payment_intent_id": "pi_000000000000000000001001",
      "client_reference_id": "order_1001",
      "metadata": {
        "order_id": "order_1001"
      }
    }
  }
}
```

Verify the signature on the raw body before parsing. On Node,
`webhooks.unwrap()` from the [server SDK](/sdks/server) checks the signature
and replay window, then returns a typed event; this Next.js example uses it.
On another stack, [Verify signatures](/webhooks/signatures) has the manual
implementation in Node, Python, and Go.

```ts app/api/webhooks/ionic/route.ts theme={null}
import {
  WebhookParseError,
  WebhookVerificationError,
  webhooks,
} from "@ionicfi/sdk";
import { fulfillOrderOnce } from "@/lib/orders";

export async function POST(request: Request): Promise<Response> {
  const rawBody = await request.text();

  let event;
  try {
    event = webhooks.unwrap(
      rawBody,
      Object.fromEntries(request.headers.entries()),
      process.env.IONIC_WEBHOOK_SECRET!,
    );
  } catch (err) {
    if (
      err instanceof WebhookVerificationError ||
      err instanceof WebhookParseError
    ) {
      return new Response("Invalid signature", { status: 400 });
    }
    throw err;
  }

  if (event.type === "checkout.session.completed") {
    const session = event.data.object;

    if (
      session.status === "complete" &&
      session.payment_status === "paid"
    ) {
      const orderId =
        session.client_reference_id ?? session.metadata?.order_id;

      if (!orderId) {
        return new Response("Missing order reference", { status: 400 });
      }

      await fulfillOrderOnce({
        eventId: event.id,
        sessionId: session.id,
        orderId,
      });
    }
  }

  return new Response("OK", { status: 200 });
}
```

`fulfillOrderOnce` stores the event ID under a unique constraint so each order
fulfills once, and hands slow work (shipping, provisioning, email) to a
queue. Ionic delivers events at least once and in no guaranteed order; see
[Idempotency and retries](/webhooks/idempotency-and-retries). Ionic doesn't
email the buyer a receipt; send your order confirmation from this fulfillment
path.

<Note>
  **Developing locally?** Webhooks need a URL Ionic can reach. Expose your dev
  server with a tunnel (for example ngrok or cloudflared), register the tunnel
  URL as a test-mode endpoint, and pay again. The
  [webhooks quickstart](/webhooks/quickstart) covers endpoint setup.
</Note>

## See it work

Pay with `4111 1111 1111 1111`, any future expiration date, and any
three-digit CVC. Then check:

1. The buyer lands on your success page.
2. Your endpoint receives `checkout.session.completed`, and the retrieved
   session is `complete` and `paid`.
3. The payment appears in your Dashboard.

If all three check out, you have a working integration. When you're ready,
[go live](/guides/going-live).

To see a decline, create a session whose total is under \$1.00; the session
stays `open` and the buyer can retry. More scenarios in
[Test cards](/guides/test-cards).

## Advanced options

The [Checkout Sessions reference](/api-reference/checkout-sessions) has the
complete request and response contract. Commonly used:

* **Known customers:** pass `customer` to link the session. See
  [Customers](/guides/customers).
* **Optional items and quantities:** `optional_items` offers add-ons the buyer
  can select; `adjustable_quantity` on a line item lets the buyer change the
  count. Fulfill from the completed session's line items, not the original
  cart.
* **Field collection:** `collect_config` requires contact, billing, or
  shipping fields.
* **Button label:** `submit_type` accepts `auto`, `pay`, `book`, or `donate`.
* **Expiration:** `expires_at`, 30 minutes to 24 hours from creation; the
  default is 24 hours.
* **Capture and reuse:** `payment_intent_data` for supported capture and
  future-usage behavior.
* **Metadata:** up to 50 keys per session and 20 per line item; non-sensitive
  values only.
* **Apple Pay:** offered on the hosted page without merchant domain
  registration. See [Apple Pay](/guides/apple-pay).
* **Save a card without a purchase:** use a Setup Intent; Checkout Session
  creation does not accept `mode: "setup"`. See
  [Save cards](/guides/save-cards).

## Next steps

<CardGroup cols={2}>
  <Card title="Webhooks" icon="webhook" href="/webhooks/quickstart">
    Verify signatures and manage endpoints.
  </Card>

  <Card title="Going live" icon="rocket" href="/guides/going-live">
    Swap test keys for live keys and launch.
  </Card>

  <Card title="Refunds and captures" icon="rotate-left" href="/guides/refunds-captures">
    Refund, capture, or cancel a payment.
  </Card>

  <Card title="Embedded Checkout" icon="code" href="/guides/embedded-checkout">
    Keep buyers on your own domain.
  </Card>
</CardGroup>
