> **Can't find what you're looking for?** Use `search_docs` on the docs MCP server at `https://www.openfort.io/api/mcp` to find what you need.
>
> **Have feedback?** Use `submit_feedback` on the same MCP server.

# Bank rails with Lightspark Grid

Let a user add dollars to their Openfort embedded wallet from a bank account, and cash USDC back out to that bank. [Lightspark Grid](https://docs.lightspark.com) prices each conversion as a **quote**: a rate locked for a few minutes plus the payment instructions to fund it. Money in comes with bank details (ACH, wire, RTP or FedNow). Money out comes with a USDC deposit address on Base, which the embedded wallet pays with an ordinary ERC-20 transfer.

With Openfort and Grid together, your app can:

* Create a Grid customer for each Openfort user and register their embedded wallet as the USDC destination
* Quote USD → USDC and show the user where to send the bank transfer
* Link a US bank account and quote USDC → USD
* Send the quoted USDC from the embedded wallet with sponsored gas, and track the payout to `COMPLETED`

The sample renders as a phone: a home screen with the balance, a **Deposit** flow for money in and a **Cash out** flow for money out.

[View Sample Code](https://github.com/openfort-xyz/recipes-hub/tree/main/lightspark-grid) — GitHub Repository. Complete source code for the Lightspark Grid bank rails recipe.

:::warning
Grid's sandbox has no testnet. Money in settles only in Grid's records: no USDC reaches the wallet on any chain. Money out sends real Base Sepolia USDC to the deposit address, but Grid only watches Base mainnet, so the recipe then calls `POST /sandbox/send` to mark the deposit as received. In production Grid sees both payments itself.
:::

## Build it with your agent

Adding this to an app you already have? Set up your coding agent with the [Openfort docs MCP server and skill](https://www.openfort.io/docs/overview/building-with-ai), then give it this prompt:

```text
Add bank deposits and cash-outs through Lightspark Grid to this app's Openfort embedded wallet. Follow the "Add this to your app" section of
https://github.com/openfort-xyz/recipes-hub/blob/main/lightspark-grid/AGENTS.md
```

The same file lists every Openfort primitive the recipe uses and the errors you're likely to hit, with their fixes.

## Run the sample

```bash
pnpx gitpick openfort-xyz/recipes-hub/tree/main/lightspark-grid openfort-lightspark-grid
cd openfort-lightspark-grid
pnpm install
cp .env.example .env.local
pnpm dev
```

Grid sandbox keys are self-serve at [app.lightspark.com](https://app.lightspark.com) → Settings → API Keys. To try **Cash out**, fund the wallet with Base Sepolia USDC from [faucet.circle.com](https://faucet.circle.com): sandbox deposits never reach the wallet on-chain.

## How it works

| Step | Grid call | Notes |
| --- | --- | --- |
| Customer | `GET /customers?platformCustomerId=`, `POST /customers` | `platformCustomerId` is the Openfort user ID, so Grid holds the mapping |
| Register the wallet | `POST /customers/external-accounts` | `BASE_WALLET` with the embedded wallet address |
| Money in | `POST /quotes` | USD `REALTIME_FUNDING` source, the wallet as destination |
| Link a bank | `POST /customers/external-accounts` | `USD_ACCOUNT` with a full beneficiary |
| Money out | `POST /quotes`, then ERC-20 `transfer` | USDC-on-Base source; the wallet pays the deposit address |
| Track | `GET /transactions/{id}` | Polled until `COMPLETED` or `FAILED` |

Openfort handles the user and the wallet: sign-in, the embedded wallet, the sponsored transfer, and the server-side check that a request comes from the user who owns the wallet. Grid handles the customer, the bank account and the conversion.

### Trust nothing from the client

Every route handler verifies the Openfort session with `openfort.iam.getSession()` and looks the Grid customer up from the user, never from the request body. A quote can be read or simulated only by the customer it was created for:

```ts
// src/features/grid/customer.ts
export async function ownedQuote(quoteId: string, customerId: string) {
  const quote = await getQuote(quoteId)
  if (!quote.source.customerId || bareId(quote.source.customerId) !== bareId(customerId)) {
    throw new GridError('Quote not found', 404)
  }
  return quote
}
```

`POST /quotes` returns `source.customerId` as `Customer:<uuid>`, but `GET /quotes/{id}` returns the bare UUID, so compare them with the prefix stripped.

### Money in

The quote locks the receiving side, so the wallet gets exactly the amount the user asked for:

```ts
// src/features/grid/client.ts
return request<Quote>('/quotes', {
  method: 'POST',
  body: {
    source: { sourceType: 'REALTIME_FUNDING', customerId: input.customerId, currency: 'USD' },
    destination: { destinationType: 'ACCOUNT', accountId: input.walletAccountId },
    lockedCurrencySide: 'RECEIVING',
    lockedCurrencyAmount: Number(input.usdcAmount),
    description: 'Add money to wallet',
  },
})
```

The response's `paymentInstructions` hold a `USD_ACCOUNT` entry with the routing number, account number and a reference the user must include in the transfer. The quote expires after 3 minutes.

### Money out

The quote locks the sending side and returns a `BASE_WALLET` deposit address. The embedded wallet sends exactly `totalSendingAmount` to it before `expiresAt`:

```ts
// src/features/grid/use-grid.ts
const hash = await writeContractAsync({
  abi: ERC20_ABI,
  address: usdc,
  functionName: 'transfer',
  args: [to, BigInt(quote.totalSendingAmount)],
})
```

With a gas sponsorship configured on the Openfort provider, the user needs no ETH. Grid then pays the bank and the transaction moves to `COMPLETED`; in sandbox a 3 USDC cash-out pays $2.95 by ACH.

## Configuration

```bash
NEXT_PUBLIC_OPENFORT_PUBLISHABLE_KEY=pk_test_...
NEXT_PUBLIC_OPENFORT_SHIELD_PUBLISHABLE_KEY=
OPENFORT_SECRET_KEY=sk_test_...                # Server-side: verifies the session before any Grid call
NEXT_PUBLIC_OPENFORT_FEE_SPONSORSHIP_ID=       # pol_... on the chain below
NEXT_PUBLIC_OPENFORT_DEFAULT_CHAIN_ID=84532    # 84532 Base Sepolia, 8453 Base

GRID_CLIENT_ID=                                # The API key's ID, not the platform ID
GRID_CLIENT_SECRET=                            # Server-side only
GRID_ENVIRONMENT=sandbox                       # sandbox or production
```

All Openfort keys and the gas sponsorship must come from the same project, and the sponsorship must cover the chain you run.

## Things to know

* **One base URL.** Sandbox and production both use `https://api.lightspark.com/grid/2025-10-13`; the API key decides the environment. A wrong key ID or secret comes back as an HTML 401 page, not JSON.
* **Customers need a deliverable email and a full name.** Placeholder domains such as `example.com` are rejected, and so is a `fullName` without both a first and a last name. Email sign-in leaves the Openfort user with no display name, so the recipe asks for the name once, before it creates the customer.
* **Approval.** A sandbox platform configured as a regulated institution approves customers at creation. Otherwise a customer needs KYC through `GET /customers/{id}/kyc-link` before quoting.
* **Bank accounts need the holder.** `USD_ACCOUNT` requires `bankAccountType` and a `beneficiary` with name, birth date, nationality and address.
* **Currencies.** A new sandbox platform has USD, USDC, USDT and USDB. Other currencies and local rails (SEPA Instant, PIX and others) are enabled per platform.

## Next steps

* [Virtual bank accounts](https://www.openfort.io/docs/recipes/virtual-bank-accounts): per-user bank details with Noah
* [Cash out with Bridge](https://www.openfort.io/docs/recipes/bridge-offramp): a permanent cash-out address with Bridge
* [Gas sponsorship](https://www.openfort.io/docs/configuration/gas-sponsorship): sponsor the cash-out transfer
* [Grid ramps guide](https://docs.lightspark.com/ramps/conversion-flows/fiat-crypto-conversion)
