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

# Virtual bank accounts

Give every user their own bank account details — a **US routing and account number** or a **European IBAN** — and have the fiat that lands there converted to USDC and delivered straight to their Openfort embedded wallet. [Noah](https://noah.com) issues the account and runs the on-ramp; Openfort holds the keys.

With Openfort and Noah together, your app can:

* Issue a **USD** virtual account (ACH and domestic wire) or a **EUR** virtual account (SEPA IBAN) per user, or both
* Auto-convert every incoming deposit to USDC and settle it on-chain to the user's non-custodial wallet
* Run identity verification through Noah's hosted KYC flow, keyed by the Openfort user ID
* Receive webhooks for verification, fiat deposits, and the resulting on-chain transactions

:::info
This is a **fiat on-ramp with no card and no manual quoting**. The user sends a normal bank transfer to an account in their own name; Noah converts and forwards. Your app never touches the money — it reads the account details and the resulting balance.
:::

## How it works

The Openfort user ID doubles as the Noah `CustomerID`, so there is no user table to keep in sync. Once that customer is verified, one call binds a bank account to the wallet address: every deposit that arrives is converted and sent on-chain to that address.

```text
 User                     Your backend                    Noah                      Chain
 ───────────────────────────────────────────────────────────────────────────────────────────

  sign in ───────────────▶ Openfort embedded wallet
                           (usr_… + 0xabc…)

  verify identity ───────▶ POST /v1/onboarding/{customerId} ─────▶ hosted KYC page
         ◀──────────────── HostedURL                                     │
                                  ◀──────── Customer webhook ────────────┘  Approved

  get bank details ──────▶ POST /v1/workflows/bank-deposit-to-onchain-address
                           { CustomerID, FiatCurrency, CryptoCurrency,
                             Network, DestinationAddress: { Address } }
         ◀──────────────── USD → BankSwift + ACH and Fedwire alongside it
         ◀──────────────── EUR → BankSepa  · BIC + IBAN

  bank transfer ─────────────────────────────▶ fiat received
                                               convert to USDC ───────────▶ wallet
                                  ◀──── FiatDeposit + Transaction webhooks
```

Only one field changes between the two currencies: `FiatCurrency`. What comes back is **not** symmetrical, and this is the part worth reading twice — a USD account is payable three ways (SWIFT, ACH, Fedwire), a EUR account one way (SEPA). Each way carries its own `PaymentMethodType`, and that type is the only thing that says what `BankCode` means: a routing number on ACH and Fedwire, a BIC on SWIFT and SEPA. Label a BIC as a routing number and the payer's transfer goes nowhere.

## Getting started

:::note
* Node.js 22+
* An [Openfort account](https://dashboard.openfort.io) with a publishable key, secret key, and Shield publishable key
* A Noah **sandbox** account — self-serve and free at [business.sandbox.noah.com](https://business.sandbox.noah.com)
* An HTTPS URL for your app (a tunnel such as ngrok in local dev) — Noah's hosted KYC redirects back to it
:::

::::steps
### Set up the two halves

Two pieces: a frontend that signs the user in and shows their account details, and a backend that talks to Noah. The backend holds both secrets — the Openfort secret key and the Noah API key — and the browser only ever talks to it. Nothing below is framework-specific; the examples use React and Express.

### Get your Noah sandbox credentials

Sandbox registration is self-serve. In the [Noah Business Dashboard](https://business.sandbox.noah.com), go to **Configuration → API → API Keys → Create New**, give the key a label, and copy it — you only see it once.

Sandbox calls go to `https://api.sandbox.noah.com` with the key in the `X-Api-Key` header. Request signing is optional there as long as you create the key **without** a signing public key; production is different, and [Going to production](#going-to-production) covers it.

:::tip
Sandbox cryptocurrencies carry a `_TEST` suffix. Use `USDC_TEST` on `PolygonTestAmoy` in sandbox, and `USDC` on `Polygon` in production — derive both from a single app-mode variable so one switch moves the whole app.
:::

### Configure your environment

:::code-group
```bash [backend/.env.local]
# Openfort — dashboard.openfort.io. Validates the user's session token.
OPENFORT_SECRET_KEY=sk_test_...
OPENFORT_PUBLISHABLE_KEY=pk_test_...

# Noah — server-only
NOAH_ENVIRONMENT=sandbox
NOAH_API_KEY=apikey_sandbox_...
NOAH_WEBHOOK_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----..."
# Currencies hosted KYC onboards for. EU customers: EUR alone.
NOAH_FIAT_OPTIONS=USD,EUR

# HTTPS URL Noah's hosted KYC redirects back to (a tunnel in local dev)
PUBLIC_APP_URL=https://your-tunnel.ngrok.app
```

```bash [frontend/.env]
VITE_OPENFORT_PUBLISHABLE_KEY=pk_test_...
VITE_OPENFORT_SHIELD_KEY=your-shield-publishable-key
VITE_API_BASE_URL=http://localhost:3021
VITE_NOAH_ENVIRONMENT=sandbox
```
:::

The Noah key is server-side only. Every Noah call runs in a backend route behind your own authentication — never from the browser.

### Verify the user with hosted KYC

A virtual account can only be issued to a customer Noah has verified. Start onboarding with the Openfort user ID as the customer ID and redirect the user to the hosted page:

```ts
// backend/src/routes.ts — the Openfort user id is the Noah customer id
const { user } = await openfort.iam.getSession({ accessToken })   // usr_...

const session = await noah.request<{ HostedURL: string }>(
  `/v1/onboarding/${encodeURIComponent(user.id)}`,
  {
    method: 'POST',
    body: {
      ReturnURL: `${config.appUrl}/?kyc=complete`,
      // Each currency runs its entity's agreements — see the warning below.
      FiatOptions: config.fiatOptions.map((code) => ({ FiatCurrencyCode: code })),
    },
  },
)
```

Read the status back with `GET /v1/customers/{customerId}` — `Verifications.Status` is `Pending`, `Approved`, or `Declined`. Gate the "Get bank details" button on `Approved`, and treat a `404` as "not onboarded yet" rather than an error.

:::warning
`ReturnURL` must be HTTPS. Noah rejects an `http://localhost` callback, so run a tunnel in local development.
:::

:::warning
**Ask only for the currencies you can serve that customer.** Every entry in `FiatOptions` adds its Noah entity's agreements to the hosted flow — `USD` brings the US banking partner's Fund Transfer Agreement and terms pages. Onboarding a customer whose country that entity cannot serve fails on those pages, with either "Accounts unavailable … due to regulatory restrictions" or a `500`, and **no customer record is created at all** (`GET /v1/customers/{customerId}` stays `404`).

Onboarding EU customers, send `[{ FiatCurrencyCode: 'EUR' }]` alone. Make it configuration rather than a constant, so the same code onboards US customers to the US entity.
:::

One more thing that bites in sandbox: Noah's verification provider treats a repeat application from an identity that already completed KYC under a different `CustomerID` as a fraud signal and declines it. Use a fresh identity per test run, and read an existing customer's status rather than re-onboarding someone you already verified.

### Issue the virtual account

One endpoint issues both rails. Pass the wallet address you want the converted USDC delivered to:

```ts
// backend/src/noah.ts
async function createVirtualAccount({ customerId, walletAddress, fiatCurrency }) {
  return request<NoahVirtualAccount>('/v1/workflows/bank-deposit-to-onchain-address', {
    method: 'POST',
    body: {
      CustomerID: customerId,
      FiatCurrency: fiatCurrency,   // 'USD' → ACH/wire · 'EUR' → SEPA IBAN
      CryptoCurrency: cryptoCurrency, // USDC_TEST (sandbox) | USDC
      Network: network,               // PolygonTestAmoy (sandbox) | Polygon
      DestinationAddress: { Address: walletAddress },
    },
  })
}
```

Here is what each currency actually returns, from the sandbox:

:::code-group
```json [USD]
{
  "AccountHolderName": "John Mock-Doe",
  "AccountNumber": "239531098956",
  "BankCode": "SSBAUS32",
  "BankName": "SSB Bank",
  "BankAddress": { "City": "Pittsburgh", "Country": "US", "State": "PA" },
  "PaymentMethodID": "Bank/Swift/USD/SSBAUS32/239531098956/usr_...",
  "PaymentMethodType": "BankSwift",
  "Fee": { "FiatCurrencyCode": "USD", "TotalFeeBase": "25", "TotalFeePct": "0.15" },
  "RelatedPaymentMethods": [
    {
      "Details": { "AccountNumber": "239531098956", "BankCode": "043087080" },
      "Fee": { "FiatCurrencyCode": "USD", "TotalFeeBase": "2.19", "TotalFeePct": "0.15" },
      "PaymentMethodID": "Bank/Ach/USD/043087080/239531098956/usr_...",
      "PaymentMethodType": "BankAch"
    },
    {
      "Details": { "AccountNumber": "239531098956", "BankCode": "043087080" },
      "Fee": { "FiatCurrencyCode": "USD", "TotalFeeBase": "20", "TotalFeePct": "0.15" },
      "PaymentMethodID": "Bank/Fedwire/USD/043087080/239531098956/usr_...",
      "PaymentMethodType": "BankFedwire"
    }
  ],
  "VirtualAccountID": "Wnvg6aZHlTnhDUbf1Rxzk1AFtn8vJL96bcgYqtbL0VM",
  "DestinationAddress": { "Address": "0x370206496048f4eDbe60e3AcBD4CFEC50B2433bd" }
}
```

```json [EUR]
{
  "AccountHolderName": "John Mock-Doe",
  "AccountNumber": "MT62CFTE19870000000090010305349",
  "BankCode": "CFTEMTM1XXX",
  "BankName": "OPENPAYD FINANCIAL SERVICES MALTA LTD",
  "BankAddress": { "City": "St. Julian's", "Country": "MT" },
  "PaymentMethodID": "Bank/Sepa/EUR/CFTEMTM1XXX/MT62CFTE1987.../usr_...",
  "PaymentMethodType": "BankSepa",
  "Fee": { "FiatCurrencyCode": "EUR", "TotalFeeBase": "0", "TotalFeePct": "1" },
  "VirtualAccountID": "VSusrAOL3edbolXAATQYwNTWn0LeNa7e6IP5nvWCmRK",
  "DestinationAddress": { "Address": "0x370206496048f4eDbe60e3AcBD4CFEC50B2433bd" }
}
```
:::

:::warning
The USD account arrives as **SWIFT**, with ACH and Fedwire in `RelatedPaymentMethods` — same account number, different `BankCode`, very different fee ($2.19 for ACH against $25 for the wire). Read the type; do not assume `FiatCurrency: "USD"` means ACH. Ignoring `RelatedPaymentMethods` both hides the routing number and offers the SWIFT BIC in its place.
:::

Flatten the response into one list of ways to pay, each carrying its own rail, so the rest of the app never branches on Noah's vocabulary:

```ts
type Rail = 'ach' | 'fedwire' | 'swift' | 'sepa'

function mapRail(paymentMethodType: string): Rail {
  switch (paymentMethodType) {
    case 'BankSepa': return 'sepa'
    case 'BankAch': return 'ach'
    case 'BankFedwire': return 'fedwire'
    default: return 'swift'
  }
}

function mapVirtualAccount(va: NoahVirtualAccount, currency: 'USD' | 'EUR') {
  return {
    currency,
    accountHolderName: va.AccountHolderName,
    bankName: va.BankName,
    paymentMethodId: va.PaymentMethodID,   // the primary — what deposit simulation takes
    methods: [
      {
        rail: mapRail(va.PaymentMethodType),
        accountNumber: va.AccountNumber,   // IBAN on sepa
        bankCode: va.BankCode,             // routing on ach/fedwire, BIC on swift/sepa
        feeBase: va.Fee?.TotalFeeBase,
      },
      ...(va.RelatedPaymentMethods ?? []).map((m) => ({
        rail: mapRail(m.PaymentMethodType),
        accountNumber: m.Details.AccountNumber,
        bankCode: m.Details.BankCode,
        feeBase: m.Fee?.TotalFeeBase,
      })),
    ],
  }
}
```

### Show the details to the user

The account is the user's deposit destination, so the only UI that matters is a copyable list of fields — one block per way to pay, each labeled for its own rail. Show the fee next to each: it is what tells a US payer to use ACH instead of a $25 wire.

```tsx
const RAIL_LABELS = {
  ach: { title: 'ACH', number: 'Account number', code: 'Routing number' },
  fedwire: { title: 'Domestic wire (Fedwire)', number: 'Account number', code: 'Routing number' },
  swift: { title: 'International wire (SWIFT)', number: 'Account number', code: 'SWIFT / BIC' },
  sepa: { title: 'SEPA credit transfer', number: 'IBAN', code: 'BIC' },
}

return account.methods.map((method) => {
  const labels = RAIL_LABELS[method.rail]
  return (
    <section key={method.rail}>
      <h3>{labels.title}</h3>
      {method.feeBase && <span>fee {method.feeBase}</span>}
      <CopyField label={labels.code} value={method.bankCode} />
      <CopyField label={labels.number} value={method.accountNumber} />
    </section>
  )
})
```

:::tip
Read the account details from the API when you display them rather than caching them indefinitely. The assignment is stable, but bank partners and account holder names change, and stale details send a user's money to the wrong place.
:::

### Simulate a deposit in sandbox

No real bank transfer is needed to test the whole path. Post the `PaymentMethodID` you got back:

```ts
await request('/v1/sandbox/fiat-deposit/simulate', {
  method: 'POST',
  body: {
    PaymentMethodID: account.paymentMethodId,
    FiatAmount: '100',
    FiatCurrency: 'USD',
  },
})
```

Noah then runs the real conversion path in sandbox: the deposit is credited, converted to `USDC_TEST`, and sent to the wallet on Polygon Amoy, firing the same webhooks production would.

### Handle the webhooks

Deposits arrive asynchronously, so webhooks are how your app learns that money moved. Noah signs the raw request body with ECDSA SHA-384 and sends the base64 signature in the `Webhook-Signature` header — verify it against Noah's public key before trusting anything:

```ts
// backend/src/server.ts — raw body first, so the signature still matches
app.post('/api/banking/webhooks', express.raw({ type: '*/*' }), (req, res) => {
  const rawBody = req.body.toString('utf8')
  const signature = req.header('Webhook-Signature') ?? ''

  const verifier = createVerify('SHA384')
  verifier.update(rawBody)
  if (!verifier.verify(noahPublicKeyPem, Buffer.from(signature, 'base64'))) {
    return res.status(401).json({ error: 'Invalid signature' })
  }

  const { EventType, Data } = JSON.parse(rawBody)
  switch (EventType) {
    case 'Customer':    /* KYC status changed */          break
    case 'FiatDeposit': /* money landed in the account */ break
    case 'Transaction': /* USDC sent on-chain */          break
  }
  res.json({ received: true })
})
```

Mount the raw body parser before any JSON parser — the signature covers the exact bytes Noah sent, so parsing first breaks verification. Parse only after verifying, and keep a short-lived set of recently seen signatures so a replayed delivery is a no-op.
::::

## USD and EUR side by side

| | USD virtual account | EUR virtual account |
| --- | --- | --- |
| Ways to pay it | SWIFT (primary), ACH and Fedwire in `RelatedPaymentMethods` | SEPA credit transfer |
| `FiatCurrency` | `USD` | `EUR` |
| `PaymentMethodType` | `BankSwift`, `BankAch`, `BankFedwire` | `BankSepa` |
| `AccountNumber` | Account number (same on all three) | IBAN |
| `BankCode` | BIC on SWIFT, routing number on ACH and Fedwire | BIC |
| Fee (sandbox) | $25 SWIFT · $20 Fedwire · $2.19 ACH, each + 0.15% | 1%, €1 minimum |
| Coverage | United States | 27 European countries |
| Extra checks | Ownership verification may send microdeposits under $1 — each one fires two `FiatDeposit` and two `Transaction` events | Deposits above €15,000 per transaction or €30,000 per month trigger enhanced due diligence |

USD virtual accounts require Noah's **Standard Model** KYC, where the user verifies directly with Noah — the hosted flow in this recipe. If you are a licensed entity onboarding customers through the Reliance Model, confirm USD availability with Noah before you build against it.

You can issue both accounts for the same user: call the workflow twice with a different `FiatCurrency`. Both can point at the same wallet address, and both settle in USDC.

## Going to production

Sandbox keys are self-serve; production keys are issued by Noah after commercial and compliance onboarding. Plan for this to run in parallel with the build.

:::steps
### Contact Noah and complete KYB

Email [business@noah.com](mailto\:business@noah.com) or use the contact form on [noah.com](https://noah.com) to open the account. Noah runs Know Your Business checks on your company and agrees the compliance model (Standard or Reliance) with you.

### Set up request signing

Signing is **mandatory in production**, and Noah wants to see it working before they hand over live credentials. Generate an ES384 keypair:

```bash
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-384 -out noah_private.pem
openssl pkey -in noah_private.pem -pubout -out noah_public.pem
```

Register `noah_public.pem` with the API key in the Business Dashboard, keep the private key in `NOAH_SIGNING_PRIVATE_KEY`, and sign each request with a short-lived JWT sent in the `Api-Signature` header:

```ts
const jwt = await new SignJWT({
  method: 'POST',
  path: '/v1/workflows/bank-deposit-to-onchain-address',
  bodyHash,                                  // SHA-256 of the raw body, hex; omit if no body
})
  .setProtectedHeader({ alg: 'ES384' })
  .setAudience('https://api.noah.com')
  .setIssuedAt()
  .setExpirationTime('5m')
  .sign(privateKey)
```

Create a signed key in sandbox first and run your integration against it — that proves the signature is correct before live money is involved.

### Get the production key and switch environments

Once onboarding completes, your Noah contact issues the production API key from [business.noah.com](https://business.noah.com). Then switch:

| | Sandbox | Production |
| --- | --- | --- |
| Base URL | `https://api.sandbox.noah.com` | `https://api.noah.com` |
| API key | `apikey_sandbox_...` | Production key from your Noah contact |
| Request signing | Optional | Required |
| `CryptoCurrency` | `USDC_TEST` | `USDC` |
| `Network` | `PolygonTestAmoy` | `Polygon` |
| Openfort keys | `pk_test_` / `sk_test_` | `pk_live_` / `sk_live_` |

The same signing keypair works in both environments. The webhook verification key does not — set the production public key and re-register your webhook endpoint (`https://yourdomain.com/api/banking/webhooks`) on the production dashboard.

### Validate on mainnet

Run one real deposit and one real payout end to end before opening the flow to users. Confirm the USDC lands at the wallet address you passed, and that the `FiatDeposit` and `Transaction` webhooks reach your production endpoint and verify against the production key.
:::

## Next steps

* [Noah virtual account documentation](https://docs.noah.com/products/bank-onramp/)
* [Noah API authentication and request signing](https://docs.noah.com/api-concepts/authentication/api/)
* [Embedded Wallet Guide](https://www.openfort.io/docs/products/embedded-wallet)
* [Gas Sponsorship](https://www.openfort.io/docs/configuration/gas-sponsorship)
