@circle-fin/onramp-kit
v1.0.3
Published
Official Circle SDK for embedding the Arc Onramp widget — server-side session creation and client-side widget mounting.
Readme
@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-kitQuick 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.
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. A few host-side prerequisites apply.
Content Security Policy
If your site sends a CSP, it must allow the widget origin. Without these directives the iframe silently fails to render — there is no error event, because the browser blocks the load before our code runs.
frame-src https://onramp.arc.io;
connect-src https://onramp.arc.io https://api.circle.com;(Adjust to include any additional origins the embedded app requires — confirm the current list with Circle support.)
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.
Server-side webhooks are the source of truth
Browser lifecycle events (DEPOSIT_SUBMITTED, DEPOSIT_SETTLED) are
best-effort UX signals. A customer can close the tab between
submitting and settlement, and the browser will never deliver the
settle event even though the deposit completes server-side. Do not
treat the absence of DEPOSIT_SETTLED as "no deposit." Reconcile
final state from Circle's server-side webhooks; use the in-browser
events only to drive UI.
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.
Ready to start accepting onramp deposits?
Built with ❤️ by Circle
