npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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

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.

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:

  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:

| 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.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.

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

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