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

# Backend setup

> The widget needs a proxy holding your Rhinestone API key. Write one, or deploy Rhinestone's.

The widget runs in the browser, so it can't hold your Rhinestone API key — the key authorizes writes against your project, from registering accounts to spending sponsorship. Every request the modal makes goes to a proxy **you** run, which attaches the key and forwards to the deposit processor.

Both modals take it as a required `backendUrl`. There is no default — point it at a proxy you run, on your own key.

Two ways to get one:

* **Deploy Rhinestone's.** Open source and configurable — see [deploy the Rhinestone proxy](#deploy-the-rhinestone-proxy).
* **Write your own.** A route table and a header — see [minimal proxy](#minimal-proxy).

<Warning>
  If you have a deployed integration that never set `backendUrl`, it is running on
  Rhinestone's API key rather than yours. Point it at your own proxy.
</Warning>

## Deploy the Rhinestone proxy

[`rhinestonewtf/deposit-widget-proxy`](https://github.com/rhinestonewtf/deposit-widget-proxy) is the proxy Rhinestone runs, packaged so you can deploy it as-is. It covers every route in the [table below](#required-routes), and adds [regional payment methods](#regional-payment-methods) — which a hand-written proxy can't do, since resolving the user's country needs the edge that actually sees them.

It needs one variable — your API key:

```bash theme={null}
git clone https://github.com/rhinestonewtf/deposit-widget-proxy
cd deposit-widget-proxy
docker build -t deposit-widget-proxy .
docker run -p 4000:4000 -e RHINESTONE_API_KEY=your-key deposit-widget-proxy
```

Point `backendUrl` at it and check `GET /health`. Everything else is optional and documented in the repository's [README](https://github.com/rhinestonewtf/deposit-widget-proxy#configuration):

| Variable | Purpose |
| - | - |
| `RHINESTONE_API_KEY` | Attached as `x-api-key` upstream. The process exits if it's unset |
| `DEPOSIT_SERVICE_URL` | The upstream processor. Defaults to production |
| `TRUSTED_COUNTRY_HEADER`, `TRUSTED_PROXY_HOPS`, `TRUSTED_PROXY_CIDRS` | [Regional payment methods](#regional-payment-methods) |

## Minimal proxy

A proxy is an explicit route table plus a header. The allowlist is the security boundary — see the warning below.

For modal versions that use `/assets`, your proxy must forward both `GET /assets` and `QUERY /assets`. Allow `QUERY` in CORS preflight and forward its JSON body unchanged.

```ts theme={null}
import { Hono } from "hono";
import { cors } from "hono/cors";

const UPSTREAM = "https://v1.orchestrator.rhinestone.dev/deposit-processor";
const MODAL_VERSION_HEADER = "x-deposit-modal-version";

// Every route the modal calls, and nothing else. `:param` segments are matched
// by Hono, so a request can only reach a shape you listed here.
const ROUTES: [method: "get" | "post" | "query", path: string][] = [
  ["get", "/chains"],
  ["post", "/register-managed"],
  ["post", "/quotes/preview"],
  ["get", "/check/:address"],
  ["get", "/portfolio/:address"],
  ["get", "/portfolio/solana/:address"],
  ["get", "/deposits"],
  ["get", "/liquidity"],
  ["get", "/prices"],
  ["get", "/assets"],
  ["query", "/assets"],
  ["get", "/setup"],
  ["get", "/qr/tokens"],
  ["post", "/polymarket/withdraw"],
  // Only needed if you enable `assetMigrations` for a DeFi protocol (e.g. Aave).
  // Both are served by a read-scoped key: `unwind` writes nothing and returns an
  // unsigned transaction only the position holder's wallet can execute.
  ["get", "/positions/:address"],
  ["post", "/positions/:address/unwind"],
  // Mints the short-lived token that attributes widget analytics to your
  // project. Omitting it costs attribution, never deposits — and its response
  // must not be cached, see below.
  ["post", "/analytics/ingest-token"],
  // Card and exchange on-ramp. `/onramp/orders/:id` only: never forward the
  // order list `GET /onramp/orders` with your key, it lists every user's orders.
  ["get", "/onramp/providers"],
  ["post", "/onramp/orders"],
  ["get", "/onramp/options"],
  ["get", "/onramp/orders/:id"],
  // Safe to forward: the user's signature in the body is the authorization, so
  // your API key alone can't move funds through it. See below.
  ["post", "/deposits/recover"],
  // Gasless permit deposits, safe for the same reason: the user's token
  // authorization signature in the body is what moves the funds.
  ["post", "/deposits/permit/prepare"],
  ["post", "/deposits/permit"],
  // Add ["post", "/safe/withdraw"] only if your onSendTransaction relays through
  // it. Do NOT add /deposits/refund here — see below.
];

const app = new Hono();
// Reflects whatever origin asks. Narrow this and you must list our hosted
// embed origin too — see CORS, origins and the version header below.
app.use("*", cors({ origin: (origin) => origin, allowMethods: ["GET", "POST", "QUERY", "OPTIONS"] }));

for (const [method, path] of ROUTES) {
  app.on(method.toUpperCase(), path, async (c) => {
    const url = new URL(c.req.url);

    // A fresh header set, never the incoming one: the browser must not be able
    // to set `x-api-key` itself.
    const headers: Record<string, string> = {
      "Content-Type": "application/json",
      "x-api-key": process.env.RHINESTONE_API_KEY!,
    };
    const modalVersion = c.req.header(MODAL_VERSION_HEADER);
    if (modalVersion) headers[MODAL_VERSION_HEADER] = modalVersion;

    const res = await fetch(`${UPSTREAM}${url.pathname}${url.search}`, {
      method: c.req.method,
      headers,
      body: method === "get" ? undefined : await c.req.text(),
    });
    const noStore = path === "/analytics/ingest-token";
    return new Response(res.body, {
      status: res.status,
      headers: noStore ? { "Cache-Control": "no-store" } : undefined,
    });
  });
}

export default app;
```

<Warning>
  Do **not** replace that loop with a wildcard passthrough (`app.all("/*", …)`). The
  proxy attaches your API key to whatever reaches it, so a wildcard hands the browser
  every write on the upstream — including `POST /setup`, which rotates your webhook
  secret and sponsorship config. The route list is what stops that.
</Warning>

### Bank transfers need provisioning first

The modal can offer bank transfers: the user gets a virtual bank account in
their name, wires fiat to it, and the deposit lands on chain like any other.
It needs `@rhinestone/deposit-modal` 0.21.0 or later, and is off by default. Enabling it takes two steps: the bank transfer provider
passes your business through its KYB review, then Rhinestone turns virtual
accounts on for your organization. [Contact us](https://www.rhinestone.dev/contact)
to start. Until then the modal offers card and exchange funding only, even with
`virtualAccountOnramp` set.

Once enabled, add one authenticated endpoint to your application's backend.
It identifies the signed-in customer and checks that they may use the requested
deposit account. The modal calls it through the `getCustomerSession` prop.

This exchange needs a stable customer ID from your application and the registered
deposit account address, not the customer's name, bank details or KYC documents.
The customer completes identity verification through the provider's flow in the modal.

#### Backend: authorize the customer and account

Use `createCustomerSessionHandler` from `@rhinestone/deposit-modal/server`.
The following example uses a Next.js route handler; the helper itself accepts
and returns standard Web `Request` and `Response` objects.

`requireUser` and `findDepositAccountForUser` below represent **your application's**
authentication and ownership lookup, not Rhinestone SDK functions. The lookup
must establish that this user is authorized for this deposit account; checking
only that the account belongs to your project is insufficient.

```ts title="app/api/customer-session/route.ts" theme={null}
import { createCustomerSessionHandler } from "@rhinestone/deposit-modal/server";
import { requireUser } from "@/server/auth";
import { findDepositAccountForUser } from "@/server/deposit-accounts";

export const POST = createCustomerSessionHandler({
  apiKey: process.env.RHINESTONE_API_KEY!,
  authorize: async (request) => {
    // Verify the existing login and enforce your application's CSRF policy.
    const user = await requireUser(request);
    if (!user) return null;

    const body = await request.json().catch(() => null);
    if (typeof body?.smartAccount !== "string") return null;

    // Treat the browser's address as a selection, never proof of ownership.
    const account = await findDepositAccountForUser({
      userId: user.id,
      smartAccount: body.smartAccount,
    });
    if (!account) return null;

    return {
      externalCustomerId: user.id,
      smartAccount: account.address,
    };
  },
});
```

The helper calls `POST /compliance/sessions` server-side with your API key and
the identifiers returned by `authorize`. Rhinestone creates or resumes that
customer, binds the deposit account to it, and issues a 15-minute token scoped
to the customer, project and bound account. A customer has one bound account,
because its bank details pay into one address; a session for another of the
user's deposit accounts is refused with `ACCOUNT_CHANGE_REQUIRES_MIGRATION`. The helper returns only `{ token, expiresAt }` to the browser, with
`Cache-Control: no-store`; returning `null` from `authorize` refuses the request.
Keep `externalCustomerId` stable across visits and derive it from the authenticated
user, never from a browser-supplied customer ID.

#### Frontend: supply the session callback

For a same-origin, cookie-authenticated backend:

```tsx theme={null}
virtualAccountOnramp={{}}
getCustomerSession={async (smartAccount, options) => {
  const response = await fetch("/api/customer-session", {
    method: "POST",
    credentials: "same-origin",
    headers: { "Content-Type": "application/json" },
    // Forward `options`: History's `resumeOnly` must reach the helper.
    body: JSON.stringify({ smartAccount, ...options }),
    cache: "no-store",
  });
  if (!response.ok) {
    // Keep the refusal's code (e.g. ACCOUNT_CHANGE_REQUIRES_MIGRATION),
    // so the modal can explain it instead of offering a retry.
    const { code } = await response.json().catch(() => ({}));
    throw Object.assign(new Error("Customer session unavailable"), { code });
  }
  return response.json();
}}
```

History also uses the session: it lists the deposit account's bank, card and
exchange orders, including payments that never became a deposit. It asks with
`{ resumeOnly: true }`, which the helper forwards so Rhinestone only resumes an
existing customer and never creates one or binds the account. Until the user has
set up bank transfer for this account, History shows deposits only. If your
endpoint drops `options`, opening History binds the account like the bank step.

Use your existing authenticated HTTP client instead if your application uses
bearer authentication or requires a CSRF header. The modal sends the returned
customer token through the proxy for bank-related requests; your project API key
stays on the server. Do not persist the customer token in browser storage.

Rhinestone chooses the account currency from the residence the user declares
during verification: EUR for residents of the SEPA area, USD elsewhere. It is
not configurable.

<Note>
  Some residences can't open a bank account on every rail; see the provider's
  [restricted jurisdictions](https://noah.com/restricted-geos).
</Note>

Rhinestone's proxy forwards these session-authenticated routes as-is. If you
wrote your own, it needs them too — we share the list when we enable the
feature for you. Responses carry the user's bank details: never log or cache them.

### Recovery can be forwarded; refunds cannot

These two look alike and differ in exactly one way: where the authorization comes
from.

`POST /deposits/recover` carries a signature from the deposit's `recipient`, covering
which deposit and which destination. The service verifies it before moving anything,
so your API key on its own achieves nothing here — which is what makes it safe to
forward like any other route. See [history and recovery](/deposits/overview/widget/history-and-recovery#signing-contract).

`POST /deposits/refund` carries no such proof. Every route in that loop passes the
browser's body through with your API key attached, so forwarding this one would let
anyone return any of your recoverable deposits to an address they chose. A proxy
authenticates nobody, so it cannot be the thing that decides.

Most apps need only the recover route. If some of your recipients genuinely cannot
sign, authorize a refund in your own backend with
[`createRefundHandler`](/deposits/overview/widget/history-and-recovery#when-the-recipient-cant-sign), which
checks the deposit belongs to the caller before spending the key, and call the
processor directly.

## Required routes

Missing a route doesn't degrade the flow — the request 404s and that part of the modal stops working.

Two routes are load-bearing rather than partial: without `/chains` the modal has no chain set at all, and without `/register-managed` there is no deposit account to send to. Everything else costs you the feature in its row.

| Method | Route | Used for |
| - | - | - |
| `GET` | `/chains` | The chain set, and each chain's tokens, explorer and gas asset. **Required** |
| `POST` | `/register-managed` | Registering the deposit account. **Required** |
| `POST` | `/quotes/preview` | Indicative fee/time quote on the review screen |
| `GET` | `/check/:address` | Registration + target lookup |
| `GET` | `/portfolio/:address` | EVM balances |
| `GET` | `/portfolio/solana/:address` | Solana balances |
| `GET` | `/deposits` | Deposit status, account-scoped history, and recovery lookup |
| `GET` | `/liquidity` | Route liquidity check |
| `GET` | `/prices` | USD pricing |
| `GET`, `QUERY` | `/assets` | Token metadata, USD prices and logos |
| `GET` | `/setup` | Your client config (source-token allowlist, minimums) |
| `GET` | `/qr/tokens` | Suggested source tokens for the QR flow, filtered to what your config accepts |
| `GET` | `/onramp/options` | Fiat methods for the user's region and the exchange list. See [regional payment methods](#regional-payment-methods) |
| `POST` | `/deposits/recover` | [History recovery](/deposits/overview/widget/history-and-recovery), authorized by the recipient's signature |
| `POST` | `/deposits/permit/prepare` | Offers a gasless deposit and returns the payload to sign. Omitting it costs the gasless path, not the deposit |
| `POST` | `/deposits/permit` | Submits the signed token authorization that funds the deposit |
| `GET` | `/positions/:address` | DeFi positions the user can migrate. Only for [asset migrations](/deposits/crypto/asset-migrations) |
| `POST` | `/positions/:address/unwind` | Builds the unsigned exit transaction for one position. Only for asset migrations |
| `POST` | `/polymarket/withdraw` | Polymarket withdrawals |
| `GET` | `/onramp/providers` | Which onramps your project has, so the widget offers only those. Without it every enabled row is offered |
| `POST` | `/onramp/orders` | Starts a card or exchange checkout |
| `GET` | `/onramp/orders/:id` | That checkout's order status. Never forward the list `GET /onramp/orders` with your key |
| `POST` | `/analytics/ingest-token` | Attribution token for widget analytics. Omitting it leaves sessions unattributed; deposits and event collection are unaffected |
| `POST` | `/safe/withdraw` | Relaying a signed Safe transfer with sponsored gas. **Not called by the modal** — proxy it only if your own [`onSendTransaction`](/deposits/crypto/withdraw-funds#executing-the-transfer) relays through it |

Card and exchange checkout need `@rhinestone/deposit-modal` 0.20.0 or later. Older versions call the `/onramp/swapped/*` routes, which the deposit service has removed.

<Warning>
  Proxy `GET /setup` only, never `POST /setup`. The POST is an admin write — it rotates
  your webhook secret and sponsorship config — and must not be reachable from a browser.
</Warning>

### The analytics token route

Analytics events themselves do not pass through your proxy — the modal posts them
straight to Rhinestone, which is why the ingest origin belongs in your app's
[content security policy](/deposits/overview/widget/widget-configuration#content-security-policy)
rather than in your proxy. The token is the proxy's only analytics involvement.

Serve it like every other route, on the server-held key, and mark the response
`Cache-Control: no-store` — the token is per-session and short-lived, so a shared
cache must not hand one session's token to another. It needs no new browser
request header, no CORS allowance and no configuration, so a proxy with a fixed
`allowHeaders` list needs no change.

A proxy without the route leaves sessions unattributed. Collection continues and
deposits are untouched; the modal reports
[`token_unavailable`](/deposits/overview/widget/analytics#ingest-failures) once and
stops asking.

## CORS, origins and the version header

### Mobile integrations call from our origin, not yours

On the web the modal runs inside your page, so its requests carry your own domain as the `Origin`. A mobile integration does not: the native wrapper loads the modal from a page we host, and every backend call it makes comes from

```
https://deposit.rhinestone.dev
```

(`https://dev.deposit.rhinestone.dev` if you are pointed at a dev integration). Scheme and host only — a CORS origin never carries a port or a path.

If your proxy sets an explicit `origin: [...]` allow-list, add it there:

```ts theme={null}
cors({ origin: ["https://yourapp.com", "https://deposit.rhinestone.dev"], allowMethods: ["GET", "POST", "QUERY", "OPTIONS"] })
```

<Warning>
  Miss this and the modal is dead on mobile with nothing naming the cause — every
  call fails at preflight, so there is no request in your logs and no error that
  mentions CORS. Add it **before** your first mobile build, not while debugging one.
</Warning>

Bare `cors()` reflects whatever origin asks, so a proxy left on the default — including [Rhinestone's](#deploy-the-rhinestone-proxy) — needs no change.

### The version header

Both modals send `x-deposit-modal-version` on every request. Browsers reject a request carrying a header the server didn't allow on the preflight, so an explicit allow-list must include it:

```ts theme={null}
allowHeaders: ["Content-Type", "x-deposit-modal-version"]
```

Bare `cors()` in Hono is fine — with no `allowHeaders` it reflects whatever the preflight asks for.

<Warning>
  This bites on upgrade, not on first deploy. A modal version that starts sending a new
  header fails the **whole request** at preflight against a proxy with a fixed
  allow-list, not just the header. Deploy proxy changes before the modal that needs them.
</Warning>

Forwarding it upstream is optional, but it lets a support request be matched to the exact build you're running. To read the value in your own app, for a bug report:

```ts theme={null}
import { MODAL_VERSION } from "@rhinestone/deposit-modal/constants";
```

## Regional payment methods

Fiat on-ramp methods vary by country, and your proxy is the only component that can see the end user: the processor sits behind it and only ever observes your proxy's address. See [payment methods and availability](/deposits/onramps/payment-methods-and-availability) for the available methods. So `GET /onramp/options?provider=swapped&kind=fiat` returns the generic method set unless your proxy names the user's region.

You do **not** need a GeoIP database — the processor owns the lookup. The proxy only names what it observed, which takes one of two variables:

* `TRUSTED_COUNTRY_HEADER` — you're behind a CDN that already resolves country, so forward its header (`cf-ipcountry`, `x-vercel-ip-country`, `cloudfront-viewer-country`).
* `TRUSTED_PROXY_HOPS` — nothing resolves it for you, so relay the client IP and let the processor resolve it.

Either one also requires `TRUSTED_PROXY_CIDRS`, an allowlist of the peers permitted to set forwarding headers. Without it any browser could send `x-forwarded-for` or `cf-ipcountry` and choose its own region, so [Rhinestone's proxy](#deploy-the-rhinestone-proxy) refuses to start when you set one without the other. See its [README](https://github.com/rhinestonewtf/deposit-widget-proxy#regional-payment-methods) for the details, including why hops are counted from the right.

<Note>
  Every path here fails closed. A wrong setting costs you localization, not correctness: you get the generic method set rather than a region that isn't the user's. A hand-written proxy that relays nothing behaves exactly as it does today.
</Note>

## Webhooks

The widget's [lifecycle callbacks](/deposits/overview/widget/callbacks-and-error-handling) are browser UI signals and fire only while the modal is open, so a user who closes it mid-bridge leaves your app unaware the deposit completed. Anything that must happen regardless — server-side settlement such as crediting a balance or sending a receipt — belongs on a [webhook](/deposits/headless/processing-and-tracking/webhooks) handler. Configure it once with `POST /setup`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.