@circle-fin/onramp-kit

中文文档
1.0.8发布于 2026/11/06TypeScriptESM + CJS近 30 天下载 2.5万

Official Circle SDK for embedding the Arc Onramp widget — server-side session creation and client-side widget mounting.

原始使用文档
文档目录21 节
  1. Table of Contents
  2. Overview
  3. Installation
  4. Sandbox vs production
  5. Quick start
  6. 1. Expose a session-creation route on your server
  7. 2. Launch the widget in the browser
  8. Popup mode
  9. Alternative: bring your own transport
  10. Listening to lifecycle events
  11. Confirming a purchase
  12. Handling session expiry / idle-out
  13. Always tear down on unmount
  14. Handling errors
  15. Browser support & hosting requirements
  16. Content Security Policy
  17. Container requirements (iframe mode)
  18. Popup mode constraints
  19. iOS Safari & third-party storage
  20. Community & Support
  21. License

@circle-fin/onramp-kit

Official Circle SDK for embedding the Arc Onramp widget — server-side session creation and client-side widget mounting.

Table of Contents

Overview

onramp-kit is split into two complementary surfaces that ship from the same npm package:

SurfaceImport pathWhere it runsPurpose
Server kit@circle-fin/onramp-kit/serverNodeExchange your apiKey for a short-lived sessionToken.
Client kit@circle-fin/onramp-kitBrowserMount the onramp widget (iframe / popup) using a session token.
Protocol primitives@circle-fin/onramp-kit/protocolBothShared constants, event envelope, session shape.
Mock helpers@circle-fin/onramp-kit/mocksTestsIn-memory mocks for the server and client kits.

The split exists because the apiKey is a long-lived credential and must never reach a browser. The server kit creates a per-user sessionToken that is safe to hand to the client.

Installation

npm install @circle-fin/onramp-kit
# or
yarn add @circle-fin/onramp-kit

Sandbox vs production

The kit targets production by default. Use sandbox for local development and testing — it moves no real money and delivers testnet tokens. The production widget only accepts https: embedding pages, so a local dev server on http://localhost must use sandbox (which also allows http://localhost:*).

SettingSet onSandboxProduction (default)
apiKeyserverTestnet API keyMainnet API key
baseUrlserverhttps://api-test.circle.comhttps://api.circle.com
widgetBaseUrlserver and clienthttps://onramp-sandbox.arc.iohttps://onramp.arc.io

Keep the API key and both URLs in the same environment: use a testnet key with sandbox and a mainnet key with production. The server and client widgetBaseUrl values must match.

// server
const server = createOnrampServerKit({
  apiKey: process.env.ONRAMP_API_KEY!,
  baseUrl: process.env.ONRAMP_BASE_URL, // https://api-test.circle.com
  widgetBaseUrl: process.env.ONRAMP_WIDGET_BASE_URL, // https://onramp-sandbox.arc.io
})

// browser
const onramp = createOnrampKit({
  widgetBaseUrl: import.meta.env.VITE_ONRAMP_WIDGET_BASE_URL, // https://onramp-sandbox.arc.io
})

Quick start

The recommended flow is two short pieces of code: one thin route on your server that calls the SDK, and one call in the browser that points the client at that route.

1. Expose a session-creation route on your server

// app/api/onramp/sessions/route.ts (Next.js App Router)
import {
  createOnrampServerKit,
  createSessionRouteHandler,
} from '@circle-fin/onramp-kit/server'

const server = createOnrampServerKit({
  apiKey: process.env.ONRAMP_API_KEY!,
})

export const POST = createSessionRouteHandler(server)

apiKey format: use the value exactly as issued by the Circle console. Keys look like <ENV>_API_KEY:<keyId>:<keySecret>. The kit sends it verbatim as a bearer token (Authorization: Bearer <value>); do not strip or re-add the prefix.

createSessionRouteHandler is a drop-in Request → Response handler that:

  • accepts only POST (returns 405 otherwise),
  • validates the body against the shared session-request schema (400 on shape mismatch),
  • calls server.createSession(body) and returns the session as JSON,
  • maps thrown KitErrors to sensible HTTP status codes (INPUT → 400, RATE_LIMIT → 429, NETWORK → 504, SERVICE/RPC → 502, otherwise 500),
  • sets Cache-Control: no-store everywhere — session tokens are short-lived and must never be cached.

It works in any runtime that speaks the Fetch API standard — Next.js App Router, Hono, Cloudflare Workers, Bun, Deno, modern Node. Hosts on Express / Fastify can keep calling server.createSession() directly.

Plugging in your own authn / authz

import { auth } from '@/lib/auth'

export const POST = createSessionRouteHandler(server, {
  authorize: async (request) => {
    const session = await auth(request)
    return session?.user != null
  },
})

Return false → 401. Throw a KitError → the corresponding status. Return true or undefined → proceed.

2. Launch the widget in the browser

The client kit exposes two synchronous mount helpers — mountIframe (inline) and openWindow (popup) — and one optional REST fetcher (fetchOnrampSession). They are deliberately separated so popup mode can run inside a synchronous user gesture frame (browsers block popups otherwise).

Gesture rules differ between the two modes. mountIframe does not require a user gesture — it just inserts an <iframe> into a container you own, so it is safe to call on page load, after an await, inside a React useEffect, etc. openWindow must be called synchronously from a user gesture (a click handler frame) or the browser blocks the popup — see Popup mode below.

import { createOnrampKit, fetchOnrampSession } from '@circle-fin/onramp-kit'

const onramp = createOnrampKit()

async function startOnramp() {
  const session = await fetchOnrampSession({
    url: '/api/onramp/sessions',
    body: {
      appUserId: 'user-123',
      destinationAddress: '0xabc...',
    },
  })

  const widget = onramp.mountIframe({
    session,
    container: document.getElementById('onramp-root')!,
    onInitializationSuccess: () => console.log('ready'),
    onDepositSettled: ({ payload }) => {
      console.log('settled', payload.amount, payload.tokenSymbol)
    },
  })

  return widget
}

Scoping which tokens/chains the widget offers

Pass an optional assets selection on the session request to limit the token/chain pairs shown in the widget. Every field is optional and they combine with AND semantics; omit assets for the full supported catalog:

await fetchOnrampSession({
  url: '/api/onramp/sessions',
  body: {
    appUserId: 'user-123',
    destinationAddress: '0xabc...',
    assets: { chains: ['arc'] }, // Arc only, all tokens
    // assets: { tokens: ['USDC'] },                          // USDC on every chain
    // assets: { pairs: [{ token: 'USDC', chain: 'arc' }] },  // exact pairs
  },
})

chains accepts a chain id or display label; tokens a symbol. Scoping is display-only — the widget's catalog and eligibility stay the source of truth, so it is safe to set from the browser.

Authorizing your embedding domain (referrerDomain)

When you embed the widget in an iframe on your own site, set referrerDomain so your page is authorized as a frame ancestor of the payment provider's nested iframe. Omit it when the widget runs as a top-level page or in popup mode.

referrerDomain is a construction option on the server kit, not a per-session parameter — by design. It configures the frame-ancestor allowlist (a security boundary), so it must come from your own trusted config and can never be set by a client. Setting it on the kit (rather than on createSession) means a client-submitted session body can't influence it, even through the drop-in createSessionRouteHandler:

// server entry — configured once from trusted config
const server = createOnrampServerKit({
  apiKey: process.env.ONRAMP_API_KEY!,
  referrerDomain: process.env.ONRAMP_REFERRER_DOMAIN, // e.g. 'portal.arc.io'
})

// the drop-in route stays a safe one-liner — `referrerDomain` is applied
// server-side to every mint, and nothing in the request body can override it
export const POST = createSessionRouteHandler(server)

Value rules:

  • Hostname only — no scheme, port, path, or wildcard.
    • ✅ portal.arc.io
    • ❌ https://portal.arc.io · portal.arc.io:443 · portal.arc.io/buy · *.arc.io
  • For a subdomain, pass the exact host the page is served from (buy.arc.io), not the apex domain.

This is separate from your site's CSP: the CSP lets your page frame onramp.arc.io; referrerDomain lets the provider's nested iframe accept your page as an ancestor. A correctly embedded iframe needs both.

Popup mode

openWindow must be invoked synchronously from the user gesture (e.g. directly inside a click handler) or the browser will block the popup. The return value discriminates between 'opened' and 'blocked' outcomes — 'blocked' is a normal flow, not an exceptional one.

// Hint: create the session beforehand (e.g. when the form is rendered),
// then call openWindow synchronously on click.
const session = await fetchOnrampSession({ url: '/api/onramp/sessions', body })

button.addEventListener('click', () => {
  const result = onramp.openWindow({
    session,
    onDepositSettled: ({ payload }) => …,
  })

  if (result.status === 'blocked') {
    // Branch on `reason` to tailor recovery:
    //  - 'popup_blocked'  → ask the user to retry (another sync click)
    //  - 'in_app_browser' → fall back to mountIframe(), or prompt the
    //                       user to open the page in the system browser
    //  - 'pwa_standalone' → fall back to mountIframe()
    if (result.reason === 'popup_blocked') {
      showPopupRetryDialog(result.errorMessage)
    } else {
      onramp.mountIframe({ session, container })
    }
    return
  }

  result.widget.on('DEPOSIT_NOT_COMPLETED', ({ code }) => …)
})

Popup-closed signal. If the customer closes the popup before submitting a deposit, the kit detects it and fires a DEPOSIT_NOT_COMPLETED event with code CANCELED_BY_CUSTOMER, so your onDepositNotCompleted handler runs without any extra polling on your side.

Alternative: bring your own transport

If your app already has its own data layer (tRPC, GraphQL, server components, react-query) you can skip fetchOnrampSession and hand the resolved session to mountIframe / openWindow directly. The session shape is the same object your server kit returned.

const session = await trpc.onramp.createSession.mutate({
  appUserId,
  destinationAddress,
})
const widget = onramp.mountIframe({ session, container })

Listening to lifecycle events

Every dispatched envelope ships as { event, code, payload? }. The event describes the host-visible effect; the code narrows the cause. See ONRAMP_EVENT_TYPES and ONRAMP_EVENT_CODES in @circle-fin/onramp-kit/protocol.

widget.on('INITIALIZATION_ERROR', ({ code, payload }) => {
  if (code === 'INVALID_SESSION_TOKEN') return refreshSession()
  if (code === 'PAGE_NOT_LOADED') return showLoadFailureToast()
})

widget.on('*', (envelope) => analytics.track('onramp', envelope))

Forward-compatible by design. The kit does not validate the event { event, code, payload } against a closed schema. A widget release that adds a new event, code, payment method, or payload field is delivered to your onAnyEvent / on('*') handlers verbatim instead of being dropped — so the SDK never lags the hosted app. Known events still get fully-typed callbacks for autocomplete.

Events reach only the page that launched the widget. The widget posts events to the page that embeds it (iframe mode) or opened it (popup mode). If you open the widget URL as a top-level page — for example, from a native app in the system browser (Safari View Controller, Chrome Custom Tabs) — nothing receives the events. To get them, host the widget on your own page with mountIframe or openWindow, and open that page instead.

Confirming a purchase

Onramp Kit does not send webhooks to your server, and there is no server-side order-status API. Lifecycle events are browser postMessage signals: use them to drive your UI, not as proof that a purchase completed.

  • DEPOSIT_SUBMITTED means the customer finished the widget flow. It does not carry a transactionHash.
    • Card, Apple Pay, and Google Pay: amount is the fiat amount paid, not the token amount delivered.
    • Bank transfer: it fires when the customer closes the bank-details screen, before any money has moved. amount is the amount they entered, which they may never send.
  • DEPOSIT_SETTLED is best-effort and fires only for card, Apple Pay, and Google Pay purchases. It usually adds the on-chain transactionHash, but only arrives if the widget is still open when the purchase settles. A customer who closes the tab first never triggers it, even though the purchase completes. Do not treat the absence of DEPOSIT_SETTLED as "no deposit."

When your app needs a reliable record that a purchase completed, check on-chain from your server:

  1. Check that the token arrived at the destinationAddress you set on the session. Use transactionHash from DEPOSIT_SETTLED when you have it.
  2. Take the amount from the chain. The delivered amount is net of fees, so it does not equal the fiat amount in the event payload.
  3. Count each transfer once, keyed by its transaction hash.

Bank transfers need extra care:

  • A returning customer can choose a bank-transfer account they set up in an earlier session. The deposit then lands on that account's wallet, which may differ from the session's destinationAddress.
  • Bank-transfer accounts stay open, so later transfers into them arrive on-chain with no widget event at all.
  • orderId is the customer's bank-transfer reference. It is the same across all of their accounts and deposits, so it cannot tell deposits or accounts apart.

Do not identify deliveries by sender address. Deliveries come from the payment provider's wallets, which differ by environment and payment method and are not guaranteed to stay the same.

Handling session expiry / idle-out

When a session idles out while the widget is open, the hosted app emits DEPOSIT_NOT_COMPLETED with code SESSION_TIMEOUT. The kit also surfaces this (and an INVALID_SESSION_TOKEN init error) through a dedicated onSessionExpired callback so you do not have to branch on codes by hand — it is your "create a new session and re-launch" signal.

onramp.mountIframe({
  session,
  container,
  onSessionExpired: async () => {
    // The current session is dead — create a fresh one and re-mount.
    const fresh = await fetchOnrampSession({
      url: '/api/onramp/sessions',
      body,
    })
    onramp.mountIframe({ session: fresh, container })
  },
})

The matching onDepositNotCompleted / onInitializationError callback still fires too, so existing handlers keep working.

Always tear down on unmount

The widget controller attaches a message listener to window and keeps an init-timeout timer. Call widget.close() when your view unmounts so these are released. For iframe mode the kit also installs a MutationObserver that auto-disposes if the iframe is detached from the DOM without a close() call — but you should not rely on it as your primary cleanup path.

// React
useEffect(() => {
  const widget = onramp.mountIframe({ session, container: ref.current! })
  return () => widget.close()
}, [session])

Handling errors

Everything the kit throws is a KitError (re-exported from @circle-fin/onramp-kit), so you can instanceof-check it and branch on its structured, stable fields instead of parsing message strings:

FieldBranch on it to…
typeClassify the failure — 'INPUT', 'NETWORK', 'SERVICE', 'RATE_LIMIT', 'RPC', 'UNKNOWN'.
recoverabilityDecide whether to retry — 'RETRYABLE', 'RESUMABLE', or 'FATAL'.
name / codeStable identifiers for exact matching, logging, and telemetry.
import { KitError, fetchOnrampSession } from '@circle-fin/onramp-kit'

try {
  const session = await fetchOnrampSession({ url, body })
  onramp.mountIframe({ session, container })
} catch (err) {
  if (err instanceof KitError) {
    if (err.recoverability === 'RETRYABLE') return scheduleRetry()
    if (err.type === 'INPUT') return showValidationError(err.message)
  }
  throw err
}

Prefer type + recoverability for control flow — they are the stable, forward-compatible contract. Use name / code only for exact matching, logging, or telemetry. The server route handler (createSessionRouteHandler) maps these same types to HTTP status codes automatically (see step 1).

Browser support & hosting requirements

The onramp renders in a cross-origin iframe (or popup) served from https://onramp.arc.io (production) or https://onramp-sandbox.arc.io (sandbox). A few host-side prerequisites apply.

Content Security Policy

If your site sends a CSP, it must allow the widget origin for the environment you target. Without this directive the iframe silently fails to render — there is no error event, because the browser blocks the load before our code runs.

Production:

frame-src https://onramp.arc.io;

Sandbox:

frame-src https://onramp-sandbox.arc.io;

Requests made inside the iframe or popup follow the widget's own CSP, not your page's, so no connect-src entry is needed for them. The kit's only request from your page goes to your own session route.

Container requirements (iframe mode)

Two host-side preconditions apply when calling mountIframe:

  1. The container must already be attached to the document when you call mountIframe. The kit appends the iframe synchronously and installs a MutationObserver to auto-dispose if the container later detaches. In React, mount from a useEffect (not during render) so the ref is populated; in vanilla code, wait for DOMContentLoaded or insert the container yourself before calling.

  2. The container needs an explicit, non-zero height. The iframe is rendered at width: 100%; height: 100%. A cross-origin iframe cannot size itself to its content, so without a resolved height the iframe collapses to 0px and the user sees nothing.

    #onramp-root {
      height: 720px;
    } /* or min-height, or a flex child with a sized parent */
    

Popup mode constraints

openWindow is best-effort and returns a 'blocked' result (never throws) when a usable popup cannot be opened:

reasonCauseRecommended fallback
popup_blockedBlocker, or an await ran before the callRe-attempt from a fresh synchronous click
in_app_browserInstagram / Facebook / TikTok / LinkedIn WebView, etc.mountIframe, or open in the system browser
pwa_standaloneInstalled PWA in standalone display modemountIframe

Additional notes:

  • On mobile browsers, window.open opens a tab, not a sized popup — the width/height features are ignored. Design for both.
  • A custom target of '_blank' disables the popup features; pass a named target (the default 'circle-onramp') for a true popup.
  • The popup intentionally keeps window.opener (we omit noopener/noreferrer) so it can postMessage results back. The trust boundary is Circle's own widget origin.

iOS Safari & third-party storage

iOS Safari's ITP can restrict the embedded app's access to its own storage inside an iframe. If you see KYC/session issues isolated to iOS Safari, prefer openWindow mode on that platform.

Community & Support

License

This project is licensed under the Apache 2.0 License. Contact support for details.


Ready to start accepting onramp deposits?

Join Discord

Built with ❤️ by Circle

版本与发布记录

@circle-fin/onramp-kit 共发布 9 个版本,首次发布于 2026/09/16;过去 12 个月发布了 9 个版本。

版本发布日期距今
1.0.8latest2026/11/063 天前
1.0.72026/10/3110 天前
1.0.62026/10/2317 天前
1.0.52026/10/2119 天前
1.0.42026/10/1624 天前
1.0.32026/09/301 个月前

关于 @circle-fin/onramp-kit 的常见问题

@circle-fin/onramp-kit 是什么?

Official Circle SDK for embedding the Arc Onramp widget — server-side session creation and client-side widget mounting.

如何安装 @circle-fin/onramp-kit?

在项目目录运行 npm install @circle-fin/onramp-kit,或使用 pnpm add / yarn add / bun add。

@circle-fin/onramp-kit 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@circle-fin/onramp-kit 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@circle-fin/onramp-kit 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=20.0.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@circle-fin/onramp-kit 可以商用吗?

@circle-fin/onramp-kit 使用 未声明 许可。package.json 没有声明许可证。没有许可证的代码默认保留全部权利,商用前请联系作者或查看仓库中的 LICENSE 文件。以上为概括,不构成法律意见,请以许可原文为准。

@circle-fin/onramp-kit 的下载量有多少?

根据 npm 官方下载统计,@circle-fin/onramp-kit 在 2026/10/10 – 2026/11/08 这 30 天内被下载 24,664 次,日均约 822 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@circle-fin/onramp-kit 还在维护吗?

最新版本 1.0.8 发布于 2026/11/06(3 天前);至今共发布 9 个版本,其中过去 12 个月发布了 9 个;首次发布于 2026/09/16。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。