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

# Customize session minting

> Add authentication, customize error handling, or mint sessions from a host that does not use the Fetch API standard, such as Express or Fastify.

The `createSessionRouteHandler` shipped with the App Kit SDK is a drop-in
session route for any host that uses the Fetch API standard (Next.js, Hono,
Cloudflare Workers, Bun, Deno, modern Node). The default handler is
unauthenticated. Most apps need to customize it in these areas:

* Authenticate so only signed-in users can mint sessions.
* Log or inspect session minting failures.
* Mint sessions from a host that does not use the Fetch API.

## Add authentication

On Fetch-compatible hosts, create the handler once with
`createSessionRouteHandler` and register it as `POST /api/onramp/sessions` in
your router. See the
[embed widget quickstart](/app-kit/quickstarts/onramp-embed-widget) for a full
example.

```typescript theme={null}
import {
  createAppServerKit,
  createSessionRouteHandler,
} from "@circle-fin/app-kit/server";

const server = createAppServerKit({
  onramp: { apiKey: process.env.CIRCLE_API_KEY! },
});

const handleOnrampSession = createSessionRouteHandler(server.onramp);
```

Add an `authorize` callback. The callback runs before the body is validated and
can return `true` (allow), `false` (reject with `401`), or throw a `KitError`
(rejected with the corresponding status):

```typescript theme={null}
import { auth } from "@/lib/auth";

const handleOnrampSession = createSessionRouteHandler(server.onramp, {
  authorize: async (request) => {
    const session = await auth(request);
    return session?.user != null;
  },
});
```

You can also validate that the request body matches the signed-in user:

```typescript theme={null}
const handleOnrampSession = createSessionRouteHandler(server.onramp, {
  authorize: async (request) => {
    const session = await auth(request);
    if (!session?.user) return false;

    const body = await request.clone().json();
    return body.appUserId === session.user.id;
  },
});
```

The handler's body validation runs after `authorize` returns, so an
unauthenticated request is rejected with `401` before any payload is parsed.

## Customize error handling

`createSessionRouteHandler` maps thrown errors to HTTP status codes
automatically. Using the same `server` and `handleOnrampSession` from above,
pass an `onError` callback:

```typescript theme={null}
const handleOnrampSession = createSessionRouteHandler(server.onramp, {
  onError: (error, request) => {
    logger.error("onramp session mint failed", {
      url: request.url,
      error,
    });
  },
});
```

`onError` runs after the error is mapped to a response, so the response is still
returned. The callback is for observability. It does not change the status code
or body.

See the [error handling reference](/app-kit/references/onramp-error-handling)
for the full error to status mapping.

## Use a non-Fetch host

`createSessionRouteHandler` only works on runtimes that pass standard `Request`
objects. Express and Fastify pass their own request and response objects, so
call `server.onramp.createSession()` directly instead. You're now responsible
for the error mapping the Fetch handler did automatically.

Map `KitError.type` to HTTP status the same way `createSessionRouteHandler`
does. See the
[HTTP status code mapping](/app-kit/references/onramp-error-handling#http-status-code-mapping)
for the full mapping. Shape error response bodies to match your API.

<Tabs>
  <Tab title="Express">
    ```typescript theme={null}
    import express from "express";
    import { createAppServerKit, KitError } from "@circle-fin/app-kit/server";

    const server = createAppServerKit({
      onramp: { apiKey: process.env.CIRCLE_API_KEY! },
    });

    const app = express();
    app.use(express.json());

    app.post("/api/onramp/sessions", async (req, res) => {
      try {
        const session = await server.onramp.createSession({
          appUserId: req.body.appUserId,
          destinationAddress: req.body.destinationAddress,
        });
        res.setHeader("Cache-Control", "no-store");
        res.json(session);
      } catch (error) {
        if (error instanceof KitError) {
          let status = 500;
          switch (error.type) {
            case "INPUT":
              status = 400;
              break;
            case "RATE_LIMIT":
              status = 429;
              break;
            case "NETWORK":
              status = 504;
              break;
            case "SERVICE":
            case "RPC":
              status = 502;
              break;
          }
          return res.status(status).json({ message: error.message });
        }
        return res.sendStatus(500);
      }
    });
    ```
  </Tab>

  <Tab title="Fastify">
    ```typescript theme={null}
    import Fastify from "fastify";
    import { createAppServerKit, KitError } from "@circle-fin/app-kit/server";

    const server = createAppServerKit({
      onramp: { apiKey: process.env.CIRCLE_API_KEY! },
    });

    const app = Fastify();

    app.post("/api/onramp/sessions", async (request, reply) => {
      try {
        const body = request.body as {
          appUserId: string;
          destinationAddress: string;
        };
        const session = await server.onramp.createSession({
          appUserId: body.appUserId,
          destinationAddress: body.destinationAddress,
        });
        reply.header("Cache-Control", "no-store");
        return session;
      } catch (error) {
        if (error instanceof KitError) {
          let status = 500;
          switch (error.type) {
            case "INPUT":
              status = 400;
              break;
            case "RATE_LIMIT":
              status = 429;
              break;
            case "NETWORK":
              status = 504;
              break;
            case "SERVICE":
            case "RPC":
              status = 502;
              break;
          }
          return reply.status(status).send({ message: error.message });
        }
        return reply.status(500).send();
      }
    });
    ```
  </Tab>
</Tabs>

Add authentication in front of this route, like any other endpoint in your app.

## Limit the tokens and blockchains the widget supports

By default, the Onramp widget shows its full catalog of supported tokens and
blockchains. If your app only handles a subset, pass an `assets` object on the
session request to narrow what the widget's selector displays.

```typescript theme={null}
const session = await kit.onramp.fetchSession({
  url: "/api/onramp/sessions",
  body: {
    appUserId: "user-123",
    destinationAddress: "USER_WALLET_ADDRESS",
    assets: {
      tokens: ["USDC"],
      chains: ["arc"],
    },
  },
});
```

You can set any of three fields:

* `tokens`: an array of token symbols such as `USDC`, `EURC`, or `ETH`. The
  widget shows those tokens on every blockchain that supports them.
* `chains`: an array of blockchains, matched by either the network id (`arc`,
  `base`, `ethereum`) or the display label (`Arc`, `Base`, `Ethereum`). Matching
  is case-insensitive. The widget shows every supported token, restricted to
  those blockchains.
* `pairs`: an array of exact token and blockchain combinations. Use this when
  you need finer control than `tokens` and `chains` allow.

  ```typescript theme={null}
  assets: {
    pairs: [
      { token: "USDC", chain: "arc" },
      { token: "EURC", chain: "base" },
    ],
  }
  ```

If you set more than one field, the widget only shows options that match every
field you set. Omit `assets` to show the full set of supported tokens and
blockchains. See
[Supported blockchains and tokens](/app-kit/references/supported-blockchains)
for the current list.

<Note>
  `assets` scopes what the selector displays. It doesn't override the widget's
  eligibility, geo, or quote logic. A token or blockchain listed in `assets` can
  still be unavailable for a specific user if they're geo-blocked or otherwise
  ineligible.
</Note>

## Allow your page to embed the widget

The widget page enforces a `frame-ancestors` Content Security Policy so only
approved parent sites can embed it. The allowlist is built from a
`referrerDomain` you set when constructing the server kit.

Pass it as a bare hostname on `createAppServerKit`:

```typescript theme={null}
const server = createAppServerKit({
  onramp: {
    apiKey: process.env.CIRCLE_API_KEY!,
    referrerDomain: "your.domain.com",
  },
});
```

Keep the following in mind when setting `referrerDomain`:

* **Format:** Use a single bare hostname such as `app.example.com` or
  `localhost`. Don't include a scheme, port, or path.
* **Source:** Derive `referrerDomain` server-side from your app's
  per-environment config. A browser-supplied `Origin` or `Referer` header would
  let attackers widen the allowlist.
* **Sandbox:** `referrerDomain` isn't enforced. Set it anyway to verify your
  wiring before promoting to production.
* **Production:** Debit card, Apple Pay, and Google Pay flows fail with a 403 if
  the domain isn't registered in your KYB's `web_url` entries.
