@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
- @circle-fin/onramp-kit
Overview
onramp-kit is split into two complementary surfaces that ship from the
same npm package:
| Surface | Import path | Where it runs | Purpose |
|---|---|---|---|
| Server kit | @circle-fin/onramp-kit/server | Node | Exchange your apiKey for a short-lived sessionToken. |
| Client kit | @circle-fin/onramp-kit | Browser | Mount the onramp widget (iframe / popup) using a session token. |
| Protocol primitives | @circle-fin/onramp-kit/protocol | Both | Shared constants, event envelope, session shape. |
| Mock helpers | @circle-fin/onramp-kit/mocks | Tests | In-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:*).
| Setting | Set on | Sandbox | Production (default) |
|---|---|---|---|
apiKey | server | Testnet API key | Mainnet API key |
baseUrl | server | https://api-test.circle.com | https://api.circle.com |
widgetBaseUrl | server and client | https://onramp-sandbox.arc.io | https://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)
apiKeyformat: 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(returns405otherwise), - validates the body against the shared session-request schema
(
400on 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-storeeverywhere — 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.
mountIframedoes 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 anawait, inside a ReactuseEffect, etc.openWindowmust 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_COMPLETEDevent with codeCANCELED_BY_CUSTOMER, so youronDepositNotCompletedhandler 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 youronAnyEvent/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
mountIframeoropenWindow, 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_SUBMITTEDmeans the customer finished the widget flow. It does not carry atransactionHash.- Card, Apple Pay, and Google Pay:
amountis 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.
amountis the amount they entered, which they may never send.
- Card, Apple Pay, and Google Pay:
DEPOSIT_SETTLEDis best-effort and fires only for card, Apple Pay, and Google Pay purchases. It usually adds the on-chaintransactionHash, 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 ofDEPOSIT_SETTLEDas "no deposit."
When your app needs a reliable record that a purchase completed, check on-chain from your server:
- Check that the token arrived at the
destinationAddressyou set on the session. UsetransactionHashfromDEPOSIT_SETTLEDwhen you have it. - Take the amount from the chain. The delivered amount is net of fees,
so it does not equal the fiat
amountin the event payload. - 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.
orderIdis 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:
| Field | Branch on it to… |
|---|---|
type | Classify the failure — 'INPUT', 'NETWORK', 'SERVICE', 'RATE_LIMIT', 'RPC', 'UNKNOWN'. |
recoverability | Decide whether to retry — 'RETRYABLE', 'RESUMABLE', or 'FATAL'. |
name / code | Stable 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:
-
The container must already be attached to the document when you call
mountIframe. The kit appends the iframe synchronously and installs aMutationObserverto auto-dispose if the container later detaches. In React, mount from auseEffect(not during render) so the ref is populated; in vanilla code, wait forDOMContentLoadedor insert the container yourself before calling. -
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:
reason | Cause | Recommended fallback |
|---|---|---|
popup_blocked | Blocker, or an await ran before the call | Re-attempt from a fresh synchronous click |
in_app_browser | Instagram / Facebook / TikTok / LinkedIn WebView, etc. | mountIframe, or open in the system browser |
pwa_standalone | Installed PWA in standalone display mode | mountIframe |
Additional notes:
- On mobile browsers,
window.openopens a tab, not a sized popup — thewidth/heightfeatures are ignored. Design for both. - A custom
targetof'_blank'disables the popup features; pass a named target (the default'circle-onramp') for a true popup. - The popup intentionally keeps
window.opener(we omitnoopener/noreferrer) so it canpostMessageresults 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
- 💬 Discord: Join our community
License
This project is licensed under the Apache 2.0 License. Contact support for details.