# Card to Crypto

Your users pay by card and receive crypto in their wallet. Aurea's card onramp runs the payment page, the KYC, the card payment and the delivery on chain, with a payment provider as merchant of record; you choose where the crypto goes and hear what happened.

## How it works

1. Your app reads [the configuration](https://docs.aureahub.com/docs/card-onramp-config.md): whether the onramp is on for your tenant, the publishable key and the pairs you offer.
2. Optionally it shows [the price](https://docs.aureahub.com/docs/card-onramp-quotes.md).
3. It [opens a session](https://docs.aureahub.com/docs/card-onramp-create.md) for one of the user's wallets. Aurea reads the address from that wallet and opens a session **locked to that address and that network**: the user cannot change either in the payment widget.
4. Your page mounts the payment widget with the `clientSecret`, or sends the user to Aurea's [hosted page](https://docs.aureahub.com/docs/guide-card-onramp.md). The user signs in, completes the KYC and pays.
5. Aurea follows the session until it is final. Your app [reads the session](https://docs.aureahub.com/docs/card-onramp-get.md) for the result — and the on-chain hash once delivered — or your server hears it as an [event](https://docs.aureahub.com/docs/guide-events.md).

If your users' wallets are yours to manage — you hold them, and your server knows each user's address — use [For Your Own Wallets](https://docs.aureahub.com/docs/guide-card-onramp-wallets.md) instead: your server opens the purchase, and your users need no Aurea account.

## Setup

Aurea sets the card onramp up for your tenant. Ask your Aurea contact to:

1. **Switch it on** for your tenant, in the sandbox first, then in production, with the pairs you offer and the fiat currency the widget opens with.
2. **Allow users' own wallets**, if your app lets users receive at an address of their own (see *Users with their own wallet* below), or **tenant wallets**, if your server holds the wallets (see [For Your Own Wallets](https://docs.aureahub.com/docs/guide-card-onramp-wallets.md)).
3. **Register your return URLs** — a deep link such as `brandbank://onramp/done`, or an https origin — if you use the hosted page.
4. **Register your web domains**, if your own web pages embed the payment widget: the widget runs only on the domains Aurea registers for it.
5. **Set up your webhook**, if your server should hear about each purchase ([Events to Your Server](https://docs.aureahub.com/docs/guide-events.md)).

Until the onramp is on, [the configuration](https://docs.aureahub.com/docs/card-onramp-config.md) answers `switchedOn: false` and opening a session answers `403` `CARD_ONRAMP_OFF`.

## Where the crypto goes

- A session names a `walletId`, never an address. Aurea reads the address from the user's own wallet; another user's wallet answers `404`.
- The wallet must be one whose key Aurea holds or has seen proven — custodial, key shares, MPC, or client-side registered with its ownership challenge. A watch-only import is refused (`CARD_ONRAMP_DESTINATION_NOT_ALLOWED`).
- An EVM key controls its address on every EVM network, so an EVM wallet receives any EVM pair; a Solana wallet receives Solana pairs only.
- Aurea checks the payment provider's answer before handing it to you: a session that is not locked to the wallet's address, network and currency is refused with `502` and no client secret.

### Users with their own wallet

Where your tenant allows it (`provenAddresses` in [the configuration](https://docs.aureahub.com/docs/card-onramp-config.md)), a user can also receive at an address of a wallet of their own — once they proved they control it:

1. [Ask for a challenge](https://docs.aureahub.com/docs/card-onramp-address-challenge.md) for the address: Aurea answers the exact message to sign. It names your tenant, never Aurea, and says the proof is to receive the crypto the user buys by card.
2. Have the user sign it with the address's key — `personal_sign` (EIP-191) in an EVM wallet, `signMessage` in a Solana wallet — and [send the signature](https://docs.aureahub.com/docs/card-onramp-address-verify.md).
3. Open a session with `addressId` instead of `walletId`. It is locked to that address exactly as to a wallet's.

A user lists and revokes what they proved with [Proven Addresses](https://docs.aureahub.com/docs/card-onramp-addresses.md) and [Revoke an Address](https://docs.aureahub.com/docs/card-onramp-address-revoke.md). These proofs are the onramp's own: an address proven for the bank ramp is proven again here.

## Pairs and regions

Aurea can offer `usdc`, `usdt` and `eth` on Ethereum; `usdc` and `eth` on Base; `usdc` and `matic` on Polygon; `usdc` and `avax` on Avalanche; `usdc` on Celo; `usdc` and `sol` on Solana.

Each pair is sold only in some fiat currencies, and not the same ones in production and in the sandbox. Every pair in [Configuration](https://docs.aureahub.com/docs/card-onramp-config.md) carries `sourceCurrencies`, the currencies it is sold in for that environment: open the session in one of them. A session in another currency — the one you send in `sourceCurrency`, or your tenant's `defaultSourceCurrency` when you send none — is refused before it opens with `400` `CARD_ONRAMP_PAIR_NOT_SOLD_IN_CURRENCY` (`details.sourceCurrencies` names the right ones), and [Quotes](https://docs.aureahub.com/docs/card-onramp-quotes.md) list only the pairs sold in the currency asked. This is what Aurea measured on the payment provider's widget, from the EU:

| Pair | Production EUR | Production USD | Sandbox EUR | Sandbox USD |
| --- | --- | --- | --- | --- |
| `eth/ethereum` | ✓ | ✓ | ✓ | ✓ |
| `sol/solana` | ✓ | ✓ | — | ✓ |
| `usdc/ethereum` | — | ✓ | ✓ | ✓ |
| `usdc/base`, `eth/base` | — | ✓ | — | ✓ |
| `usdc/polygon`, `matic/polygon` | — | ✓ | — | ✓ |
| `usdc/avalanche`, `avax/avalanche` | — | ✓ | — | ✓ |
| `usdc/solana` | — | ✓ | — | ✓ |
| `usdt/ethereum`, `usdc/celo` | — | — | — | — |

So in production EUR buys ETH on Ethereum and SOL, and USD buys every other pair but USDT and USDC on Celo, which are not sold now. The user can still switch currency inside the widget: a pair switched to a currency it is not sold in only shows the widget's error, so tell users which currency to pay in.

The card onramp serves users in the EU and the US (not Hawaii). The payment provider decides from the user's IP address whether it can serve them: a session opened with a user token passes the IP address of that request, so open it from the user's device, not from your server (a server opening purchases for its own wallets passes `customerIp` instead). A user who cannot be served gets `403` `CARD_ONRAMP_CUSTOMER_UNSUPPORTED`: hide the onramp for them.

## Amounts

`sourceAmount` (fiat) or `destinationAmount` (crypto) only suggests an amount: the user can change it, and the fiat currency, in the widget. What was really paid and delivered is in the session's `amounts` once the payment provider reports it, in `amounts.sourceCurrency` — the currency the payment provider charged in, which is not `requested.sourceCurrency` when the user switched currency in the widget. In the EU, the payment provider may ask the user to prove they control the destination wallet for purchases from 1,000 EUR (the Travel Rule).

## Opening the widget

Load the payment provider's scripts from the payment provider's domains — never bundle or host a copy — and mount the widget with the session's secret. The `clientSecret` exposes the wallet address: never log it and never put it in a URL.

```html
<head>
  <script src="https://js.stripe.com/dahlia/stripe.js"></script>
  <script src="https://crypto-js.stripe.com/crypto-onramp-outer.js"></script>
</head>
<body>
  <div id="onramp"></div>
</body>
```

```javascript
const api = 'https://api.aureahub.com';
const auth = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };

// 1. What the tenant offers here
const config = await (await fetch(`${api}/v1/ramp/card/config?isTestnet=true`, { headers: auth })).json();
if (!config.switchedOn) return hideOnramp();

// 2. A session for one of the user's wallets (the key makes a retry safe)
const res = await fetch(`${api}/v1/ramp/card/sessions`, {
  method: 'POST',
  headers: { ...auth, 'Idempotency-Key': crypto.randomUUID() },
  body: JSON.stringify({ isTestnet: true, walletId, pair: 'usdc/ethereum', sourceAmount: '50.00' })
});
const opened = await res.json();
if (!res.ok) return showError(opened.details?.code);

// 3. The payment widget
const stripeOnramp = StripeOnramp(opened.publishableKey);
stripeOnramp
  .createSession({ clientSecret: opened.clientSecret, appearance: { theme: 'light' } })
  .addEventListener('onramp_session_updated', async (event) => {
    const status = event.payload.session.status;
    if (status === 'fulfillment_processing' || status === 'fulfillment_complete' || status === 'rejected') {
      // 4. The widget's word is a hint: read the result from Aurea
      const { session } = await (await fetch(`${api}/v1/ramp/card/sessions/${opened.session.id}`, { headers: auth })).json();
      showResult(session);
    }
  })
  .mount('#onramp');
```

An app without a web page of its own — a mobile app — can open Aurea's hosted page instead: see **Hosted page** below.

## Hosted page

For an app with no web page to mount the widget in, Aurea serves one: it shows your tenant's name, logo and colour around the payment widget (branded by Aurea), and sends the user back to your app when the session is paid, delivered or refused.

Around the widget the page shows what the user is buying — the crypto amount, the token and its network, the price in the currency charged, and the wallet or proven address it goes to — and three steps, *Verify*, *Pay*, *Receive*, that follow the session. After the payment it shows the result: *Payment received* while the crypto is delivered (the page keeps reading the session until the crypto has arrived), *Crypto delivered* with what was received, what was paid, the address and the transaction — linked to the network's explorer for a live purchase — or *Purchase not completed*.

1. Open the session as usual ([Open a Session](https://docs.aureahub.com/docs/card-onramp-create.md)), then ask for a link: [Hosted Page Link](https://docs.aureahub.com/docs/card-onramp-hosted-link.md), with the `returnUrl` your app answers to.
2. Open the answered `url` in the system browser or an in-app browser (`SFSafariViewController`, Chrome Custom Tabs). It works for 30 minutes; ask for a new one to open the session again.
3. When the session is `fulfillment_processing`, `fulfillment_complete`, `rejected` or `failed`, the page sends the browser to your `returnUrl` with `onrampSession` (the session's `id`) and `status` added to its query — for example `brandbank://onramp/done?onrampSession=444417bc-…&status=fulfillment_complete`. Treat that as a hint and [read the session](https://docs.aureahub.com/docs/card-onramp-get.md) for the result. A button takes the user back at any time.

- **Your brand:** the page's buttons take your tenant's primary colour (a `#rrggbb` value the Aurea operator sets on your tenant) and fall back to Aurea's indigo. The payment provider is named once, in a small line under the widget.
- **Setup:** Aurea registers its own domain for the page. Ask the Aurea operator to add your app's return URL — a deep link such as `brandbank://onramp/done`, or an https origin — to your tenant's return URLs. Until your tenant has one, a link with a `returnUrl` is refused; without one, the page tells the user to close it.
- The link's token is in the URL's fragment (after `#`), which browsers never send to a server, and the page removes it from the address bar at once. The client secret never appears in a URL.
- The page speaks English or Italian (`locale`) and is light or dark, with the widget (`theme`). A new link opened in the same tab — only the fragment changes — opens its own session.

## Statuses

| status | Meaning |
| --- | --- |
| `creating` | Aurea asked the payment provider and has no answer yet. Sending the same `Idempotency-Key` again asks the payment provider again for the same session. |
| `failed` | The payment provider refused to create it; `failureCode` says why. No answer within an hour ends here too, with `CARD_ONRAMP_NO_ANSWER`. |
| `initialized` | The widget can open. |
| `requires_payment` | The user passed the KYC and reached the payment. |
| `fulfillment_processing` | Paid; the crypto is on its way. |
| `fulfillment_complete` | Delivered: `transactionId` is the on-chain hash. |
| `rejected` | The user was turned away (KYC, sanctions or fraud). |

A status only moves forward: an older answer never undoes a newer one. `providerStatus` is the payment provider's own word, and can name a status added later.

## Updates to your server

Your server can hear about every session without asking. Aurea posts `ramp.card_session.updated` to your tenant's webhook of that environment — the same webhook, secret, signature and retries as the bank ramp's events ([Events to Your Server](https://docs.aureahub.com/docs/guide-events.md)) — each time a session's status changes, from `initialized` on. `data` is the session as [Get a Session](https://docs.aureahub.com/docs/card-onramp-get.md) answers it, never the client secret.

```json
{
  "id": "evt_4be0c7a93f1d52e86a0b9c4d7e21f358",
  "type": "ramp.card_session.updated",
  "environment": "sandbox",
  "occurredAt": "2026-09-24T15:12:40.000Z",
  "createdAt": "2026-09-24T15:12:41.206Z",
  "data": {
    "id": "444417bc-0675-4d3a-8831-bd1cbedc9738",
    "environment": "sandbox",
    "status": "fulfillment_complete",
    "providerStatus": "fulfillment_complete",
    "providerSessionId": "cos_1QAbCdEfGhIjKlMn",
    "pair": { "name": "usdc/ethereum", "currency": "usdc", "network": "ethereum", "aureaChain": "ethereum", "family": "evm", "sourceCurrencies": ["eur", "usd"] },
    "destination": { "kind": "aurea_wallet", "walletId": "990b620f-a1f5-4317-9c77-974270328a92", "addressId": null, "address": "0xC0207704CaEB9342cf491Ad4a177F43592df65a7" },
    "customer": null,
    "requested": { "sourceCurrency": "eur", "sourceAmount": "50.00", "destinationAmount": null },
    "amounts": { "sourceCurrency": "eur", "sourceAmount": "50.00", "destinationAmount": "49.100000", "networkFee": "0.40", "transactionFee": "0.50" },
    "transactionId": "0x5f2c…",
    "failureCode": null,
    "createdAt": "2026-09-24T15:10:02.118Z",
    "updatedAt": "2026-09-24T15:12:41.199Z"
  }
}
```

- Each status is posted once, whether the payment provider's webhook, a read or Aurea's own check brought it; `id` is the same on every retry, so drop an `id` you have already handled.
- `failed` is posted too: the payment provider refused the session (`failureCode` says why), or never answered its creation within an hour (`CARD_ONRAMP_NO_ANSWER`).
- No order is promised between deliveries: a status only moves forward, so keep the furthest one you have seen.
- Nothing is posted while your tenant has no webhook in that environment, while it is switched off, or when it asks only for other types.

## In the transactions feed

A session the user paid — `fulfillment_processing` or `fulfillment_complete` — appears in the user's [Unified Transaction Feed](https://docs.aureahub.com/docs/agg-tx-list.md) as `card_onramp_purchase`, with what the payment provider charged and delivered and, once delivered, the on-chain hash. Its `id` is the session's. Ask for it by name in `types` if your app sends a list of types.

## Testing

In the sandbox: the one-time code `000000`, SSN `000000000`, address line 1 `address_full_match`, and the card `4242 4242 4242 4242`. In the sandbox, amounts are replaced with fixed test limits.

---

Web version: https://docs.aureahub.com/#guide-card-onramp
