# Aurea API Docs — complete documentation --- --- > Generated from https://docs.aureahub.com (165 pages). Index: https://docs.aureahub.com/llms.txt --- --- # Introduction Welcome to the **Aurea API** — a backend platform for authentication, cross-chain wallet management, and multi-chain transaction processing. Aurea supports both **custodial wallets** (for regulated entities) and **non-custodial wallets** (for everyone), and is compatible with any blockchain network and token in the market upon request. Build powerful Web3 applications with a simple, REST-based interface. - [Quick Start](https://docs.aureahub.com/docs/quickstart.md) — Register, get a token, and make your first API call in minutes. - [Authentication](https://docs.aureahub.com/docs/authentication.md) — Learn how JWT bearer tokens and API keys work together. - [Wallet Management](https://docs.aureahub.com/docs/wallets-create.md) — Create, import, and manage wallets across multiple blockchains. - [Transactions](https://docs.aureahub.com/docs/tx-create.md) — Send native coins and tokens from custodial and non-custodial wallets, and track their status. ## Overview The Aurea API (version `v0.1.0`) provides a comprehensive suite of endpoints organised into the following resource groups: - **Auth** — Registration, login, token refresh, and Google OAuth - **Users** — User profile management, search, and QR code generation - **Wallets** — Custodial and non-custodial wallets across any blockchain, with cross-chain balance aggregation - **Transactions** — Initiate and track cross-chain transactions across any supported network ## Base URL Base URL https://api.aureahub.com/v1 All API endpoints are relative to this base URL and are fully documented in this reference — see the **API Reference** and **Agentic Payments** sections in the sidebar. ## Machine-Readable Everything here exists in a form a program — or an agent writing your integration — can read directly: - **The OpenAPI document**, served by the API itself and always exactly what the running version does: [`https://api.aureahub.com/v1/docs/json`](https://api.aureahub.com/v1/docs/json) ([YAML](https://api.aureahub.com/v1/docs/yaml), and a browsable UI at [/v1/docs](https://api.aureahub.com/v1/docs)). It is the reference for request and response shapes; these pages are the reference for what to do with them and why. - **These pages as Markdown**, one file each, with an index in [`llms.txt`](https://docs.aureahub.com/llms.txt) and all of them in [`llms-full.txt`](https://docs.aureahub.com/llms-full.txt) ([llmstxt.org](https://llmstxt.org)). Any page's address with `.md` appended returns that page: `/docs/authentication.md`. If you are pointing an assistant at Aurea, give it `llms.txt` and the OpenAPI URL together: the first says what exists and why, the second says exactly what to send. ## Supported Blockchains The API is natively cross-chain. The networks listed below are available out of the box — any other blockchain or token in the market can be enabled upon request. ⟠ Ethereum ⬡ Polygon 🦉 Gnosis 🟡 BNB Chain 🔵 Arbitrum 🔴 Optimism 🔷 Base 🔺 Avalanche 🟢 Celo 🌊 Flow EVM ◎ Solana > 💡 Need a chain or token not listed here? Aurea supports **any blockchain network and any token** existing in the market. Contact us to enable additional networks for your tenant. ## Rate Limiting API requests are rate-limited. When you exceed the limit, the API returns a `429 Too Many Requests` response. Implement exponential back-off in your client for robust error handling. Web version: https://docs.aureahub.com/#introduction --- # Authentication The Aurea API uses two credentials: a **tenant API key and secret**, used to sign requests with HMAC-SHA256, and a **user access token** (JWT), sent as a bearer token. ## Overview Each tenant has an API key and an API secret. Requests made on behalf of the tenant — creating users, signing users in, reading the tenant's public branding — are **signed** with the secret. Signing a user in returns a short-lived **access token** and a **refresh token** for that user; the access token authorises user-level calls. 1. Sign a request to [register](https://docs.aureahub.com/docs/auth-register.md), [log in](https://docs.aureahub.com/docs/auth-login.md), or sign in with [Google](https://docs.aureahub.com/docs/auth-google.md) or [Apple](https://docs.aureahub.com/docs/auth-apple.md). 2. Call user-level routes with `Authorization: Bearer `. 3. When the access token expires, exchange the refresh token at [POST /v1/auth/refresh](https://docs.aureahub.com/docs/auth-refresh.md). > ⚠️ Anyone holding the API secret can sign requests as your tenant, so store it like a password. Secrets are managed with an admin bearer token through `GET /v1/admin/tenants/{id}/api-secret` and `POST /v1/admin/tenants/{id}/rotate-secret`. Rotating issues a new secret, and the old one stops working immediately. ## Credentials by Route Only the routes below check the tenant signature; no other route does. | Route | Tenant signature | Bearer token | | --- | --- | --- | | `POST /v1/auth/registerPOST /v1/auth/loginPOST /v1/auth/googlePOST /v1/auth/apple` | Required | — | | `POST /v1/auth/forgot-password` | Required | — | | `GET /v1/tenant/public` | Required | — | | `POST /v1/mpc/service/token` | Required | — | | `GET /v1/aave/positionsGET /v1/aave/rate-historyPOST /v1/aave/supplyPOST /v1/aave/withdrawPOST /v1/aave/prepare-supplyPOST /v1/aave/prepare-withdrawPOST /v1/aave/broadcast` | Required | Required | | `POST /v1/auth/refresh` | — | — (the refresh token goes in the body) | | `GET /v1/auth/reset-password/validatePOST /v1/auth/reset-password` | — | — (the reset token is the credential) | | `GET /health` | — | — | | `All other routes` | Not checked | Per endpoint — see its reference page | ## Bearer Tokens Send the access token returned by register, login, Google/Apple sign-in or refresh in the `Authorization` header: ```http Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... ``` Access tokens are JWTs signed by the API (HS256). Their payload contains: | Claim | Meaning | | --- | --- | | sub | User ID | | tenantId | The user's tenant; `null` for platform admins | | role | `user`, `tenantadmin` or `admin` | | iat | Issued-at time, Unix seconds | | exp | Expiry time, Unix seconds | Every problem with the bearer token produces the same `401`: a missing header, a malformed or badly signed token, an expired token, or a token without `tenantId` for a non-admin role. The body looks like this: ```json { "statusCode": 401, "error": "UnauthorizedError", "message": "Authentication required" } ``` Some endpoints return only `error` and `message`; see [Error Handling](https://docs.aureahub.com/docs/errors.md). ## Token Lifetimes - **Access token:** 15 minutes by default. Token responses report `"expiresIn": 900`. For the exact expiry, read the token's `exp` claim. - **Refresh token:** 7 days by default. - **Refresh-token rotation:** off by default. When it is on, each refresh returns a new refresh token and the old one can't be reused. See [Refresh Token → Rotation](https://docs.aureahub.com/docs/auth-refresh.md). - **Password changes:** refresh tokens issued before the user's password was last changed are rejected. > ℹ️ Lifetimes and rotation are server settings. The values above are the defaults; the values configured in a given deployment, including production, are deployment-specific. Build clients that read `exp` and handle a returned `refreshToken`, not clients that assume fixed values. ```javascript // Read an access token's expiry (Unix seconds). This does not verify the token. function tokenExpiry(accessToken) { const payload = JSON.parse(Buffer.from(accessToken.split('.')[1], 'base64url').toString('utf8')); return payload.exp; } ``` ## HMAC Request Signing A signed request carries three headers: | Header | Value | | --- | --- | | x-tenant-api-key | Your tenant API key. It must belong to an active tenant; the request runs in that tenant. | | x-timestamp | Current Unix time in **seconds**, as a string | | x-signature | Hex-encoded HMAC-SHA256 signature, with no prefix | The signature is computed with the tenant's **API secret**: ```text bodyHash = hex( SHA-256( raw request body ) ) // SHA-256 of an empty string when there is no body signingString = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + bodyHash x-signature = hex( HMAC-SHA256( key = API secret, message = signingString ) ) ``` - **METHOD**: the HTTP method in upper case, for example `POST`. - **PATH**: the request path *including the query string*, exactly as in the request line, with no scheme or host. Examples: `/v1/auth/login`, `/v1/tenant/public?lang=en`. - **TIMESTAMP**: the same string you send in `x-timestamp`. It must be within **300 seconds** of the server's clock, in either direction. - **Body**: hash the exact bytes you send. Serialise the JSON once and send that same string; re-serialising can change whitespace or key order and break the signature. - **Single use**: the API accepts each signature only once within the 300-second window. To retry, sign the request again. Two identical requests signed in the same second produce the same signature, so the second one is rejected. ```javascript import { createHash, createHmac } from 'node:crypto'; export function signedHeaders(method, pathWithQuery, body = '') { const timestamp = Math.floor(Date.now() / 1000).toString(); const bodyHash = createHash('sha256').update(body).digest('hex'); const signingString = `${method.toUpperCase()}\n${pathWithQuery}\n${timestamp}\n${bodyHash}`; const signature = createHmac('sha256', process.env.AUREA_TENANT_API_SECRET) .update(signingString) .digest('hex'); return { 'x-tenant-api-key': process.env.AUREA_TENANT_API_KEY, 'x-timestamp': timestamp, 'x-signature': signature, }; } // POST with a JSON body: sign and send the same string const path = '/v1/auth/login'; const body = JSON.stringify({ username: 'alice', password: 'SecurePass123' }); const login = await fetch('https://api.aureahub.com' + path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...signedHeaders('POST', path, body) }, body, }); // GET without a body: the body hash is the SHA-256 of an empty string. // If the URL has a query string, include it in the signed path. const branding = await fetch('https://api.aureahub.com/v1/tenant/public', { headers: signedHeaders('GET', '/v1/tenant/public'), }); ``` ## Signature Errors A request that fails the signature check gets one of these responses. On routes that validate the request body, validation runs *before* the signature check, so an invalid body returns `400` even if the signature is also wrong. | Status | message | Cause | | --- | --- | --- | | 401 | `Missing required HMAC headers: x-tenant-api-key, x-timestamp, x-signature` | One or more of the three headers is missing. | | 401 | `Invalid x-timestamp header` | x-timestamp is not an integer. | | 401 | `Request expired` | x-timestamp is more than 300 seconds away from the server time. | | 404 | `Invalid API key` | x-tenant-api-key does not match an active tenant. | | 401 | `Invalid signature` | The signature does not match. Check the method, the path and query string, the timestamp string, the exact body bytes and the secret. | | 401 | `Replay detected` | This exact signature was already accepted within the last 300 seconds. | ## Handling 401 During Long Flows An access token can expire in the middle of a flow. On a `401` from a bearer-protected route, refresh once and retry the original request once. If the refresh fails or the retry still returns `401`, send the user back to sign-in rather than looping. On routes that also need a tenant signature, sign the retried request again. If refresh-token rotation may be on, run only one refresh at a time; see [Refresh Token → Implementation](https://docs.aureahub.com/docs/auth-refresh.md). ```javascript export async function fetchWithAuth(url, init, store) { const withBearer = () => ({ ...init, headers: { ...(init.headers || {}), Authorization: `Bearer ${store.accessToken}` }, }); let res = await fetch(url, withBearer()); if (res.status !== 401) return res; // Refresh once const refresh = await fetch('https://api.aureahub.com/v1/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken: store.refreshToken }), }); if (!refresh.ok) throw new Error('RE_AUTH_REQUIRED'); const { accessToken, refreshToken } = await refresh.json(); store.accessToken = accessToken; if (refreshToken) store.refreshToken = refreshToken; // returned only when rotation is on res = await fetch(url, withBearer()); if (res.status === 401) throw new Error('RE_AUTH_REQUIRED'); return res; } ``` Web version: https://docs.aureahub.com/#authentication --- # Quick Start Register a user, create a wallet, then send a first transaction and follow it to a final status. ## Before You Start You need your tenant's **API key** and **API secret**. `POST /v1/auth/register` and `POST /v1/auth/login` must be signed with HMAC; they return an `accessToken` that every other call in this guide sends as `Authorization: Bearer `. The API secret produces the signatures, so keep it on your server. Details: [Authentication](https://docs.aureahub.com/docs/authentication.md). | Header | Value | | --- | --- | | x-tenant-api-key | Your tenant API key. | | x-timestamp | Current Unix time in seconds. Requests more than 300 seconds away from the server clock are rejected. | | x-signature | Hex-encoded HMAC-SHA256 of the signing string, keyed with your API secret. Each signature is accepted only once. | The signing string is the HTTP method, the request path as sent (for example `/v1/auth/login`), the timestamp and the hex SHA-256 of the raw request body, joined with newlines: ```javascript import { createHash, createHmac } from 'node:crypto'; function hmacHeaders(method, path, rawBody, apiKey, apiSecret) { const timestamp = Math.floor(Date.now() / 1000).toString(); const bodyHash = createHash('sha256').update(rawBody || '').digest('hex'); const signingString = [method, path, timestamp, bodyHash].join('\n'); const signature = createHmac('sha256', apiSecret).update(signingString).digest('hex'); return { 'x-tenant-api-key': apiKey, 'x-timestamp': timestamp, 'x-signature': signature, }; } ``` ## Step 1: Register `username` (3–100 characters: letters, digits, `_`, `-`), `email` and `password` (at least 8 characters, with upper-case, lower-case and a digit) are required. Add `wallet: { option: "create", chain }` to create a wallet in the same call; `chain` is required with `option: "create"`. ```javascript const API = 'https://api.aureahub.com'; const body = JSON.stringify({ username: 'alice', email: 'alice@example.com', password: 'SecurePass123', wallet: { option: 'create', chain: 'gnosis' }, // use a chain enabled for your tenant }); const res = await fetch(API + '/v1/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json', ...hmacHeaders('POST', '/v1/auth/register', body, API_KEY, API_SECRET), }, body, // send exactly the bytes you signed }); // 201 Created const { accessToken, refreshToken, expiresIn, user, wallet } = await res.json(); ``` The response includes a `wallet` only when one was created. On tenants that use MPC wallets, registration never creates one. Existing users sign in with `POST /v1/auth/login` (`username` and `password`, same three headers), which returns the same token fields. See [Register](https://docs.aureahub.com/docs/auth-register.md) and [Login](https://docs.aureahub.com/docs/auth-login.md). ## Step 2: Create a Wallet Skip this step if registration returned a wallet. List the chains your tenant can use, then create a wallet on one of them. ```javascript const auth = { Authorization: 'Bearer ' + accessToken }; // Chains with an enabled configuration and at least one active token for your tenant const { chains } = await fetch(API + '/v1/tokens/meta/chains', { headers: auth }) .then(r => r.json()); // chains: [{ id, name, chainId, nativeToken: { symbol, name, decimals } }, ...] const created = await fetch(API + '/v1/wallets/', { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ chain: chains[0].id, name: 'Main wallet' }), }); // 201 Created const myWallet = await created.json(); // { id, chain, address, walletType, isPrimary, isTestnet, label, isWatchOnly, keyManagementScheme, createdAt, updatedAt } ``` The custody model comes from your tenant's configuration, not from this request. Check `keyManagementScheme`: - `aes_single`, `sss_2of2_akv` — custodial: Aurea signs and submits sends. - `client_side`, `client_side_pending`, `mpc_tss` — non-custodial: sends must be signed by the client. Tenants that use MPC wallets get `400` from this endpoint and create wallets through the MPC key-generation flow instead. See [Create Wallet](https://docs.aureahub.com/docs/wallets-create.md). ## Step 3: Check Balance ```javascript const balance = await fetch(API + '/v1/wallets/' + myWallet.id + '/balance', { headers: auth }) .then(r => r.json()); // { walletId, address, chain, nativeBalance: { balance, symbol, decimals }, tokens: [...], lastUpdated } ``` > ⚠️ Balance values are formatted with the token's decimals, but transaction amounts must be integer strings in the smallest unit. Convert with `decimals` before sending — see [Amount Units](https://docs.aureahub.com/docs/amounts.md). ## Step 4: Send a Transaction ```javascript const tx = await fetch(API + '/v1/transactions/', { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ walletId: myWallet.id, chain: myWallet.chain, toAddress: '0x52908400098527886E0F7030069857D2E4169EE7', amount: '10000000000000000', // 0.01 of an 18-decimal coin, in the smallest unit }), }).then(r => r.json()); ``` - **Custodial wallet:** `201` with `status: "pending"` and the on-chain `txHash` (EVM chains). - **Non-custodial wallet:** `201` with `requiresClientSigning: true` and `txHash: null`. Sign the returned hash and broadcast it — follow [Sending a Transaction](https://docs.aureahub.com/docs/guide-send-transaction.md). A decimal amount such as `"0.01"` is rejected with `400`. Full reference: [Create Transaction](https://docs.aureahub.com/docs/tx-create.md). ## Step 5: Track Status ```javascript async function waitForFinalStatus(txId, { intervalMs = 5000, maxAttempts = 60 } = {}) { for (let attempt = 0; attempt < maxAttempts; attempt++) { const s = await fetch(API + '/v1/transactions/' + txId + '/status', { headers: auth }) .then(r => r.json()); if (s.status === 'confirmed' || s.status === 'failed') return s; await new Promise(resolve => setTimeout(resolve, intervalMs)); } throw new Error('Transaction still pending'); } const final = await waitForFinalStatus(tx.id); // { id, txHash, status, confirmations, blockNumber, blockTimestamp } ``` The status endpoint checks the chain and saves any change. See [Get Tx Status](https://docs.aureahub.com/docs/tx-status.md). ## Next Steps - [Sending a Transaction](https://docs.aureahub.com/docs/guide-send-transaction.md) — Custodial, non-custodial and gasless token sends. - [Supported Blockchains](https://docs.aureahub.com/docs/supported-blockchains.md) — How chains are enabled per tenant, and what each family supports. - [Amount Units](https://docs.aureahub.com/docs/amounts.md) — Smallest-unit amounts and conversion helpers. - [Unified Transaction Feed](https://docs.aureahub.com/docs/agg-tx-list.md) — Blockchain and the bank ramp activity in one list. - [User Onboarding](https://docs.aureahub.com/docs/guide-user-onboarding.md) — Guide to onboarding users. - [Non-Custodial Keys](https://docs.aureahub.com/docs/guide-non-custodial-keys.md) — Guide to client-held keys. - [Token Swaps](https://docs.aureahub.com/docs/guide-swap.md) — Guide to the swap endpoints. - [Fiat Pay-In](https://docs.aureahub.com/docs/guide-fiat-payin.md) — Guide to the bank ramp pay-ins. - [Fiat Pay-Out](https://docs.aureahub.com/docs/guide-fiat-payout.md) — Guide to the bank ramp payouts. - [DApps Browser](https://docs.aureahub.com/docs/guide-dapps.md) — Guide to the dApp endpoints. - [Sandbox Playbook](https://docs.aureahub.com/docs/guide-sandbox-playbook.md) — Test run before production. Web version: https://docs.aureahub.com/#quickstart --- # Error Handling Errors come back as JSON with an HTTP status code. Most use a common envelope; this page covers the envelope, the documented exceptions, and how to handle both. ## Error Format Errors raised by the API use this envelope: ```json { "statusCode": 401, "error": "UnauthorizedError", "message": "Authentication required" } ``` | Field | Type | Description | | --- | --- | --- | | statusCode | number | The HTTP status, repeated in the body. | | error | string | For errors raised by the API, the error class name: `BadRequestError`, `UnauthorizedError`, `ForbiddenError`, `NotFoundError`, `ConflictError`, `ValidationError`, `InternalServerError`, `ServiceUnavailableError`, `BlockchainError` or `NoahApiError`. Request-validation and database errors use a label instead, such as `Bad Request`, `Validation Error` or `Conflict`. Other failures carry the underlying error name, for example `Error` on `429` responses. | | message | string | Human-readable description. Branch on the status code, and on `code` where present, rather than on this text. | | details | any | Optional. Present on validation errors and on some application errors; the shape depends on the error. | | code | string | Optional. A machine-readable code, present only on some errors; see Error Codes below. | Unexpected server failures return `500` with `"message": "Internal server error"`; internal details are not included. ## Response Variations - **Endpoint response schemas filter error bodies.** When an endpoint declares a response body for an error status, only the declared fields are sent. The sign-in endpoints declare `{ error, message }` for several statuses, so a failed login returns just `{ "error": "UnauthorizedError", "message": "Invalid credentials" }`, with no `statusCode` and no `details`. Each auth endpoint page lists which statuses are affected. - **Some endpoints write their own body.** For example, the [password-reset](https://docs.aureahub.com/docs/auth-forgot-password.md) endpoints return `{ "error": "…" }`. - **Unknown routes** return the framework's not-found body: `{ "message": "Route GET:/v1/unknown not found", "error": "Not Found", "statusCode": 404 }`. So always read the HTTP status first, and take the text from `message`, falling back to `error`. ## HTTP Status Codes | Status | Meaning | | --- | --- | | 400 | The request failed schema validation (`Request validation failed`), the JSON body could not be parsed, the endpoint rejected a value (`BadRequestError`), or a referenced record does not exist (`Invalid reference`). | | 401 | Missing or invalid bearer token (`Authentication required`), a failed tenant signature, or rejected credentials or refresh token. See [Authentication](https://docs.aureahub.com/docs/authentication.md). | | 403 | Authenticated, but not allowed to perform this action (`ForbiddenError`). | | 404 | The resource was not found (`NotFoundError`), `x-tenant-api-key` does not match an active tenant (`Invalid API key`), or the route does not exist. | | 409 | Conflict with existing data (`ConflictError`), a duplicate record (`Resource already exists`), `PRIMARY_WALLET_EXISTS`, or an `Idempotency-Key` used for another request or still running (`IDEMPOTENCY_KEY_REUSED`, `IDEMPOTENCY_IN_PROGRESS`). | | 422 | The input passed schema validation but failed the endpoint's own checks: detailed validation (`Request validation failed` with `details`) or a `ValidationError` such as `Google account email is not verified`. | | 429 | A rate-limited route received too many requests. See Rate Limits below. | | 500 | Unexpected server failure (`Internal server error`). | | 502 | A service the API calls refused Aurea's connection for your tenant or failed unexpectedly, for example `NoahApiError` with `details.code` `NOAH_AUTHENTICATION_FAILED`. It is not a problem with the user's session: don't sign the user out. | | 503 | A service the API depends on is temporarily unavailable (`ServiceUnavailableError`), or the bank ramp is unavailable, slow or rate limiting (`NoahApiError` with `NOAH_UNAVAILABLE`, `NOAH_TIMEOUT`, `NOAH_UNREACHABLE` or `NOAH_RATE_LIMITED`). `NoahApiError` with `NOAH_NOT_CONFIGURED` means Aurea has not connected the bank ramp for your tenant in the environment the call selected, so the bank ramp was not called; retrying doesn't help until Aurea connects it (Bank Ramp Setup). | Some blockchain operations use other statuses, for example `501` when a required contract is not available on the network. ## Validation Errors Input is checked in two stages, and the two stages report problems differently. **Schema validation → `400`.** Runs before the endpoint's logic, and before the tenant signature check. `details` lists the validator's errors: ```json { "statusCode": 400, "error": "Bad Request", "message": "Request validation failed", "details": [ { "instancePath": "", "schemaPath": "#/required", "keyword": "required", "params": { "missingProperty": "refreshToken" }, "message": "must have required property 'refreshToken'" } ] } ``` **Endpoint checks → `422`.** Rules applied inside the endpoint, such as password strength at registration. `details` lists each failing field path with a message: ```json { "statusCode": 422, "error": "Validation Error", "message": "Request validation failed", "details": [ { "path": "password", "message": "Password must contain at least one uppercase letter" }, { "path": "password", "message": "Password must contain at least one digit" } ] } ``` > ℹ️ Where an endpoint declares `{ error, message }` for `400` (for example register, login, Google and Apple sign-in), `details` is not returned for schema-validation failures. ## Error Codes Most errors have **no** `code`. The API's error handler adds one in these cases: - `PRIMARY_WALLET_EXISTS` (`409`): a primary wallet already exists for this chain and network type. Set an existing wallet as primary, or create the new wallet as non-primary. - **Blockchain-operation errors** (`"error": "BlockchainError"`) carry a `code` and a boolean `retryable`, and may add `suggestedAction` and `fallbackAvailable`. Codes raised include `PREPARED_TX_NOT_FOUND`, `PREPARED_TX_ALREADY_USED`, `PREPARED_TX_EXPIRED`, `SIGNATURE_DECODE_FAILED`, `WRONG_SIGNER`, `NO_ROUTE_FOUND`, `NO_LIQUIDITY_POOL` and `CONTRACT_NOT_DEPLOYED`. A few application errors carry their code in `details.code` instead: - `CARD_TOPUP_UNAVAILABLE` (`403`): card top-up is switched off. Top up by bank transfer with [Initiate Deposit](https://docs.aureahub.com/docs/payin-initiate.md). - `PAYOUT_PRODUCTION_UNAVAILABLE` (`403`): a production payout was requested. Payouts are available only with `isTestnet: true`, and no funds are moved. - `NOAH_FUNCTION_OFF` (`403`): your tenant has not switched on that the bank ramp function (`details.function`: `kyc`, `payin` or `payout`) in that environment (`details.environment`). See [Tenant Settings](https://docs.aureahub.com/docs/bank-settings.md). - `NOAH_PAIR_UNAVAILABLE` (`400`): the currency, or the currency and network, is not one your tenant offers with the bank ramp in that environment; `details.available` lists what it offers. See [Currencies & Networks](https://docs.aureahub.com/docs/bank-currencies.md). - `NOAH_CUSTOMER_NOT_FOUND` (`404`, with `details.environment`): the user has no bank ramp profile in that environment yet. See [Onboarding Session](https://docs.aureahub.com/docs/payin-session.md). - `NOAH_KYC_NOT_APPROVED` (`422`, with `details.onboardingStatus` and `details.kycStatus`): a payout quote, and a payout paid from the user's wallet, need a completed the bank ramp's onboarding and an approved KYC. See [Onboarding Session](https://docs.aureahub.com/docs/payin-session.md). - `NOAH_CHANNEL_NOT_SUPPORTED` (`400`): a payout quote goes through a bank or identifier channel, never a card channel. `NOAH_QUOTE_NOT_FOUND` (`404`, with `details.environment`) and `NOAH_QUOTE_READY` (`409`): see [Answer a Form Step](https://docs.aureahub.com/docs/payout-quote-step.md). - `NOAH_QUOTE_NOT_LOCKED` (`409`, with `details.quoteId` and `details.reason`: `not_ready` or `expired`), `NOAH_QUOTE_USED` (`409`, with `details.quoteId` and `details.payoutId`) and `NOAH_PAYOUT_SOURCE_BUSY` (`409`, with `details.network` and `details.cryptoCurrency`): paying a quote from the user's own wallet — see [Pay from a Wallet](https://docs.aureahub.com/docs/payout-wallet-pay.md). - `NOAH_PAYOUT_AMOUNT_TOO_SMALL` (`400`, with `details.quoteId`): the payout quote leaves the beneficiary nothing — below the channel's `cryptoLimits.min` its fixed fee takes the whole payout, and the bank ramp prices it at zero rather than refusing it. Ask for a quote above that minimum. See [Pay from a Wallet](https://docs.aureahub.com/docs/payout-wallet-pay.md). - `NOAH_SOURCE_NOT_ALLOWED` (`400`, with `details.environment` and `details.reason`: `wallet_not_found`, `watch_only`, `wrong_network` or `address_not_proven`): the wallet or proven address named as the source of a payout cannot send it; the answer never repeats the address. `NOAH_PAYOUT_NOT_FOUND` (`404`, with `details.environment`): see [Get a Wallet Payout](https://docs.aureahub.com/docs/payout-wallet-get.md). - `details.outcome` `unknown`, beside `details.payoutId` on a `502` or `503` from [Pay from a Wallet](https://docs.aureahub.com/docs/payout-wallet-pay.md): Aurea cannot tell whether the bank ramp made the payout rule. Read the payout instead of sending the request again. - `NOAH_NOT_CONFIGURED` (`503`): Aurea has not connected the bank ramp for your tenant in that environment, and the bank ramp was not called. See [Bank Ramp Setup](https://docs.aureahub.com/docs/bank-setup.md). - `NOAH_MODE_OFF` (`403`): your tenant has not switched on that integration mode (`details.mode`) in that environment (`details.environment`) — the standalone mode, which proving an address needs and a payout from a proven address needs, or the Aurea wallets mode, which a payout from one of the user's wallets needs. See [Tenant Settings](https://docs.aureahub.com/docs/bank-settings.md). - `NOAH_DESTINATION_NOT_ALLOWED` (`400`): a pay-in is delivered only to one of the user's Aurea wallets, with the Aurea wallets mode, or to an address the user proved, with the standalone mode. See [Initiate Deposit](https://docs.aureahub.com/docs/payin-initiate.md). - `NOAH_ADDRESS_INVALID` (`400`), `NOAH_ADDRESS_CHALLENGE_LIMIT` (`409`), `NOAH_ADDRESS_SIGNATURE_INVALID` (`400`, with `details.reason` and `details.attemptsLeft`), `NOAH_ADDRESS_CHALLENGE_BURNT` and `NOAH_ADDRESS_CHALLENGE_EXPIRED` (`400`), `NOAH_ADDRESS_CHALLENGE_USED` (`409`): proving an address — see [Ask for a Challenge](https://docs.aureahub.com/docs/bank-address-challenge.md) and [Verify an Address](https://docs.aureahub.com/docs/bank-address-verify.md). - `NOAH_ENVIRONMENT_MISMATCH` (`400`): the `isTestnet` sent to Initiate Deposit disagrees with the network's environment. - `NOAH_WEBHOOK_INVALID` (`400`, with `details.field`, and `details.reason` for an address), `NOAH_WEBHOOK_CONFLICT` (`400`) and `NOAH_WEBHOOK_NOT_FOUND` (`404`): your tenant's webhooks — see [Save a Webhook](https://docs.aureahub.com/docs/tenant-webhook-save.md). - `RETURN_URL_NOT_ALLOWED` (`400`): the return URL is not one your tenant allows. Ask the Aurea operator to add it — see [Bank Ramp Setup](https://docs.aureahub.com/docs/bank-setup.md). - `RETURN_URL_NOT_HTTPS` (`400`): an onboarding session's return URL must be `https://`. `NOAH_CUSTOMER_TYPE_MISMATCH` (`409`, with `details.customerType`): The bank ramp already has the user with the other customer type. See [Onboarding Session](https://docs.aureahub.com/docs/payin-session.md). - `IDEMPOTENCY_KEY_INVALID` (`400`), `IDEMPOTENCY_KEY_REUSED` and `IDEMPOTENCY_IN_PROGRESS` (`409`): see [Idempotency](https://docs.aureahub.com/docs/idempotency.md). - `CARD_ONRAMP_OFF` (`403`, with `details.environment`), `CARD_ONRAMP_PAIR_UNAVAILABLE` (`400`, with `details.available`), `CARD_ONRAMP_PAIR_NOT_SOLD_IN_CURRENCY` (`400`, with `details.sourceCurrencies`), `CARD_ONRAMP_DESTINATION_NOT_ALLOWED` and `CARD_ONRAMP_AMOUNT_INVALID` (`400`), `CARD_ONRAMP_WALLET_NOT_FOUND` and `CARD_ONRAMP_SESSION_NOT_FOUND` (`404`), `CARD_ONRAMP_SESSION_FAILED` (`409`, with `details.failureCode`), `CARD_ONRAMP_SESSION_CLOSED` (`409`, with `details.status`), `CARD_ONRAMP_LINK_NOT_FOUND` (`404`) and `CARD_ONRAMP_LINK_EXPIRED` (`410`): the card-to-crypto onramp — see [Card to Crypto](https://docs.aureahub.com/docs/guide-card-onramp.md). - The payment provider's own refusals (`"error": "StripeOnrampError"`) carry `providerStatus`, `providerCode` and `providerRequestId` beside the code: `CARD_ONRAMP_CUSTOMER_UNSUPPORTED` (`403`), `CARD_ONRAMP_INVALID_REQUEST` (`400`), `CARD_ONRAMP_NOT_FOUND` (`404`), `CARD_ONRAMP_DISABLED`, `CARD_ONRAMP_MERCHANT_NOT_SET_UP`, `CARD_ONRAMP_AUTHENTICATION_FAILED`, `CARD_ONRAMP_RATE_LIMITED` and `CARD_ONRAMP_NOT_CONFIGURED` (`503`), `CARD_ONRAMP_UPSTREAM_ERROR`, `CARD_ONRAMP_UNREACHABLE` and `CARD_ONRAMP_UNEXPECTED_RESPONSE` (`502`). The payment provider's message is never passed on. If an endpoint declares an error body without `code`, response filtering removes it (see Response Variations). ## Rate Limits Rate limits are set per route and counted per client IP; not every route is rate-limited. The auth routes allow **20 requests per minute** each for register, login, Google and Apple sign-in, and **60 per minute** for refresh. See [Rate Limiting](https://docs.aureahub.com/docs/rate-limiting.md). Responses from a rate-limited route carry `x-ratelimit-limit`, `x-ratelimit-remaining` and `x-ratelimit-reset` (seconds until the window resets). Once the limit is exceeded, the route returns `429` with a `retry-after` header in seconds: ```json { "statusCode": 429, "error": "Error", "message": "Rate limit exceeded, retry in 1 minute" } ``` The wait named in the message depends on the route's window. Some endpoints apply their own throttling and body; for example, the password-reset endpoints return `429` with `{ "error": "Too many requests. Please try again later." }`. ## Handling Errors ```javascript export async function parseApiResponse(res) { const body = await res.json().catch(() => null); if (res.ok) return body; const err = new Error(body?.message ?? body?.error ?? `HTTP ${res.status}`); err.status = res.status; // always branch on this first err.code = body?.code; // present only on some errors err.details = body?.details; // validation errors, when returned if (res.status === 429) err.retryAfterSeconds = Number(res.headers.get('retry-after')); throw err; } ``` - **401**: on bearer-protected routes, refresh the access token once and retry (see [Authentication](https://docs.aureahub.com/docs/authentication.md)). On signed routes, check the signature inputs. - **400 / 422**: fix the request using `details` when it is returned. Resending the same input fails the same way. - **429**: wait for `retry-after` seconds before retrying. > 💡 You can send your own `x-request-id` header. The API uses it as the request ID in its logs, but does not echo it back in the response. Quote it when reporting a problem. Web version: https://docs.aureahub.com/#errors --- # Supported Blockchains Chains are configured per deployment and per tenant. Ask the API which ones your tenant can use instead of relying on a fixed list. ## Overview The API does not hard-code a list of networks: request schemas accept any chain identifier string, and what works for your tenant depends on three pieces of configuration — the chain registry, your tenant's blockchain configs and your tenant's token registry. The chains available to you are therefore deployment-specific; query them at runtime as shown below. The code distinguishes two families: EVM networks, and Solana (identifier `solana`). Their capabilities differ — see **Feature Support**. ## How Chains Are Enabled | Layer | What it holds | | --- | --- | | Chain registry | Platform-wide list of networks managed by platform admins (`/v1/admin/chains/`): slug `id` (lowercase letters, digits and hyphens), `name`, `isTestnet`, `rpcUrl`, `explorerUrl`, `nativeToken` and numeric `chainId`. Non-custodial sends and fee quotes read the numeric chain ID from here, falling back to your tenant's enabled blockchain config. | | Tenant blockchain configs | Per-tenant network settings created through the admin endpoint `/v1/admin/tenants/{id}/blockchain-configs`: `chain`, `chainId`, `rpcUrl`, `isTestnet`, `isEnabled`, gas settings, metadata and native token. Tenant users can read the enabled ones with `GET /v1/tenant/blockchain-configs`. | | Tenant token registry | Tokens registered for your tenant (`/v1/admin/tenants/{tenantId}/tokens`). A chain only appears in the runtime chain list when your tenant has at least one active token on it. | ## Listing Chains at Runtime ### `GET /v1/tokens/meta/chains` Lists chains that have an enabled blockchain config and at least one active token for the tenant. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `isTestnet` | string | no | `"true"` or `"false"`. Default `"false"`. | **Responses** `200` OK ```json { "chains": [ { "id": "gnosis", "name": "Gnosis", "chainId": 100, "nativeToken": { "symbol": "xDAI", "name": "xDAI", "decimals": 18 } } ] } ``` - A bearer token is optional. **Send it:** with a token the list is for your tenant; without one it is for the system tenant. - `name` is the `id` with its first letter capitalised. `nativeToken` comes from your tenant's token registry (`null` if none is registered). - `chainId` is the id of the requested network. For an EVM chain (ethereum, polygon, gnosis, bsc, arbitrum, optimism, base, avalanche, celo, flowevm) it comes from the Hub's EVM chain list: `celo` is `42220`, and `11142220` with `isTestnet=true`. Other chains take it from the chain registry (`null` if the chain is not registered). - `GET /v1/tenant/blockchain-configs` (bearer token required) returns your tenant's enabled configs, including `chainId`, `explorerUrl` and `nativeToken`. See also [Supported Chains (Token Meta)](https://docs.aureahub.com/docs/tokens-chains.md). ## Chain Identifiers Use a chain's `id` wherever an endpoint takes a `chain` parameter: ```json { "walletId": "3f1c2b9e-8a4d-4c6e-9b2a-5d7e1f0a6c3b", "chain": "gnosis", "toAddress": "0x52908400098527886E0F7030069857D2E4169EE7", "amount": "10000000000000000" } ``` - Mainnet and testnet are told apart by an `isTestnet` flag on wallets, registry entries and many requests. An EVM chain's wallets, blockchain configs and tokens are stored under the family chain with that flag: `polygon` with `isTestnet: true` is Polygon Amoy. - The chain registry lists each EVM testnet under its own id (`polygon-amoy`, `celo-sepolia`, `chiado`). Wallet, blockchain config and token endpoints accept that id and store the family chain on the testnet. - Addresses: EVM addresses are `0x` followed by 40 hex characters; Solana addresses are base58 strings of 32–44 characters. - An EVM wallet's address is valid on every EVM network, so [Create Transaction](https://docs.aureahub.com/docs/tx-create.md) accepts a `chain` other than the wallet's own. ## Feature Support What the API code supports for each chain family. Whether a capability is usable on your tenant still depends on its configuration. | Capability | EVM chains | Solana (`solana`) | | --- | --- | --- | | Custodial sends | Native coin and tokens. | SOL and SPL tokens (`tokenAddress` = mint). | | Non-custodial sends (`client_side`, `client_side_pending`, `mpc_tss`) | Yes, when a numeric chain ID is found for the chain. | No — `400` `Non-custodial Solana sends are not yet supported`. | | Status from the chain (Get Tx Status, background job) | Yes, from the transaction receipt. | No receipt lookup. Custodial Solana sends are recorded as `confirmed` when the send call returns. | | Fee quote for non-custodial wallets | Estimated from the network. | Zero quote (`isFree: true`). | | Gasless token sends (non-custodial) | Only on chains with a gasless transfer forwarder configured, for active tokens registered with permit support. | No. | > ℹ️ **Gasless swaps** are a separate feature: the gasless swap service works against Gnosis Chain (chain ID 100) and the EURe token, and a deployment can switch it off, in which case it returns `503` `Gasless swaps are currently disabled`. See [Token Swaps](https://docs.aureahub.com/docs/guide-swap.md). Web version: https://docs.aureahub.com/#supported-blockchains --- # Rate Limiting Some sensitive routes are rate-limited. There is no global limit, and there are no plans or tiers. ## Overview Limits are configured per route, and most routes have none. A limited route counts requests per client IP address within its time window; counters are kept in memory by each API instance. Agent Payments API keys have their own per-key limit, and the password-reset endpoints have their own throttling. ## Limits | Route | Limit | | --- | --- | | POST /v1/auth/register POST /v1/auth/login POST /v1/auth/google POST /v1/auth/apple | 20 per minute each | | POST /v1/auth/refresh | 60 per minute | | POST /v1/transactions/{id}/broadcast | 20 per minute | | POST /v1/transactions/quote | 60 per minute | | POST /v1/swap/broadcast POST /v1/swap/broadcast-approve POST /v1/swap/broadcast-gasless | 10 per 10 seconds each | | POST /v1/dapps/broadcast | 10 per 10 seconds | | GET /v1/dapps/nonce | 30 per minute | | POST /v1/dapps/sign-personal-message | 20 per minute | | POST /v1/aave/supply, /withdraw, /prepare-supply, /prepare-withdraw, /broadcast | 5 per minute each | | GET /v1/portfolio/performance | 20 per minute | | Requests authenticated with an Agent Payments API key | 120 per minute per key | | POST /v1/auth/forgot-password | Throttled per email address and per IP | ## Headers Responses from the routes in the table (except the API-key and password-reset limits) carry: | Header | Description | | --- | --- | | x-ratelimit-limit | Requests allowed in the window | | x-ratelimit-remaining | Requests left in the current window | | x-ratelimit-reset | Seconds until the window resets | | retry-after | On 429 only: seconds to wait before retrying | ## Exceeding a Limit A route-level limit answers `429`; the wait in the message depends on the route's window: ```json { "statusCode": 429, "error": "Error", "message": "Rate limit exceeded, retry in 1 minute" } ``` - The Agent Payments API-key limit answers `403` with the message `Rate limit exceeded`. - The password-reset endpoints answer `429` with `{ "error": "Too many requests. Please try again later." }`. ## Best Practices - On `429`, wait for `retry-after` seconds before retrying. - Sign tenant-signed requests again for each retry: a signature is accepted only once. - Don't retry sign-in in a loop; show the error to the user instead. ```javascript async function fetchWithRetry(url, options, maxRetries = 3) { for (let attempt = 0; ; attempt++) { const res = await fetch(url, options); if (res.status !== 429 || attempt === maxRetries) return res; const retryAfter = Number(res.headers.get('retry-after')) || 2 ** attempt; await new Promise(r => setTimeout(r, retryAfter * 1000)); } } ``` Web version: https://docs.aureahub.com/#rate-limiting --- # Amount Units How the API expects and returns token and fiat amounts. ## Overview Crypto amounts you send are **integer strings in the token's smallest unit**. Fiat amounts are decimals. Responses are not uniform: some return amounts as strings, others as JSON numbers — check each endpoint's response. ## Crypto Amounts Multiply the human amount by 10decimals. [List Tokens](https://docs.aureahub.com/docs/tokens-list.md) returns each token's `decimals`. - ETH and xDAI: 18 decimals — 1 = `"1000000000000000000"`. - EUR.e (Monerium, Gnosis): 18 decimals — 1 = `"1000000000000000000"`. - USDC: 6 decimals — 1 = `"1000000"`. - SOL: 9 decimals (lamports) — 1 = `"1000000000"`. SPL tokens use their mint's decimals. | Field | Format | | --- | --- | | POST /v1/transactions/ — amount | Integer string, smallest unit | | POST /v1/swap/approve — amount | Integer string, smallest unit | | POST /v1/swap/sign-permit, POST /v1/swap/execute-gasless — amounts | Integer string, smallest unit (wei) | | POST /v1/swap/quote — fromAmount | The pattern also accepts decimals, but Aurea passes the value to LI.FI unchanged, and LI.FI expects the smallest unit: send an integer string | | POST /v1/aave/* — amounts | Integer string in wei (18 decimals for EUR.e) | | POST /v1/ramp/bank/payout/initiate — cryptoAmount | Integer string, converted with 6 decimals for every currency | > ⚠️ Never send a decimal such as `"1.5"` where the smallest unit is expected, and send integer amounts as strings: JSON numbers lose precision above 253. ## Fiat Amounts | Field | Format | | --- | --- | | POST /v1/ramp/bank/payin/checkout — fiatAmount | Decimal string with at most 2 decimals, e.g. "50.00" | | POST /v1/ramp/bank/payout/initiate (response) — fiatAmount, exchangeRate | Strings; fiatAmount has 2 decimals | | GET /v1/ramp/bank/payin/deposits — fiatAmount | JSON number | | GET /v1/ramp/bank/payin/transactions — fiatAmount, cryptoAmount, exchangeRate | JSON numbers | | GET /v1/ramp/bank/payin/transactions — fiatFeeAmount, fiatRate, networkFee, breakdown[].amount | Exact decimal text without trailing zeros, e.g. "0.8840880389680685" | | GET /v1/ramp/bank/payin/deposits and /transactions — refunds[].amount | Exact decimal text without trailing zeros | | POST /v1/ramp/bank/payin/sandbox/simulate-deposit — amount | JSON number, at most 15000 | | GET /v1/portfolio/performance — changeEur, totalEur, totalUsd | JSON numbers | ## Conversion Helpers ```typescript // Human -> smallest unit (extra decimal digits are truncated) export function toSmallest(amount: string | number, decimals: number): string { const s = String(amount).trim(); const neg = s.startsWith('-'); const [i, f = ''] = (neg ? s.slice(1) : s).split('.'); const frac = (f + '0'.repeat(decimals)).slice(0, decimals); const raw = (BigInt(i || '0') * 10n ** BigInt(decimals)) + BigInt(frac || '0'); return (neg ? -raw : raw).toString(); } // Smallest unit -> human export function fromSmallest(value: string | bigint, decimals: number): string { const n = typeof value === 'bigint' ? value : BigInt(value); const neg = n < 0n; const abs = neg ? -n : n; const base = 10n ** BigInt(decimals); const whole = abs / base; const frac = (abs % base).toString().padStart(decimals, '0').replace(/0+$/, ''); const out = frac.length ? `${whole}.${frac}` : whole.toString(); return neg ? '-' + out : out; } toSmallest('1.5', 6); // "1500000" (USDC) toSmallest('10', 18); // "10000000000000000000" (EUR.e) fromSmallest('1500000', 6); // "1.5" ``` Web version: https://docs.aureahub.com/#amounts --- # Pagination List endpoints don't share a single pagination style — check the table below for the parameters and response shape of each one. ## Overview Aurea's list endpoints use one of three parameter styles — `page` + `limit`, `limit` + `offset`, or `limit` + `skip` — and put the results under different fields. None uses cursors. Read the endpoint's own page before writing a generic paginator. ## List Endpoints | Endpoint | Parameters | Response | | --- | --- | --- | | GET /v1/wallets/ | `limit` (default 50, max 100), `offset` (default 0) | `{ data, pagination: { total, limit, offset, hasMore } }` | | GET /v1/users/ GET /v1/users/advanced-search | `limit`, `offset` | `{ data, pagination: { total, limit, offset, hasMore } }` | | GET /v1/transactions/ GET /v1/transactions/aggregated/ | `page` (default 1), `limit` (default 20, max 100) | `{ data, pagination: { page, limit, total, totalPages } }` | | GET /v1/ramp/bank/payin/deposits | `limit` (default 20), `offset` (default 0) | `{ deposits, total, limit, offset }` | | GET /v1/ramp/bank/payin/transactions | `limit` (default 20), `offset` (default 0) | `{ transactions, total, limit, offset }` | | GET /v1/ramp/bank/payout/transactions | `page` (default 1), `limit` (default 20) | `{ payouts, total, page, limit, totalPages }` | | GET /v1/notifications/inbox | `limit` (default 20, max 100), `skip` (default 0) | `{ notifications, total }` | | GET /v1/agent-payments/agents | `limit` (max 200), `offset` | `{ data }` | ## Page and Limit For endpoints that return `pagination.totalPages` (on payouts, `totalPages` is at the top level): ```javascript async function* pagesByNumber(url, token, limit = 50) { for (let page = 1; ; page++) { const res = await fetch(`${url}?page=${page}&limit=${limit}`, { headers: { Authorization: `Bearer ${token}` } }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const { data, pagination } = await res.json(); yield data; if (page >= pagination.totalPages) return; } } // for await (const transactions of pagesByNumber('https://api.aureahub.com/v1/transactions/', token)) { ... } ``` ## Limit and Offset For endpoints that return `pagination.hasMore`. Where there is no `hasMore` (the bank ramp deposits and transactions), stop when `offset + limit` reaches `total`; the inbox uses `skip` instead of `offset`. ```javascript async function* pagesByOffset(url, token, limit = 50) { for (let offset = 0; ; offset += limit) { const res = await fetch(`${url}?limit=${limit}&offset=${offset}`, { headers: { Authorization: `Bearer ${token}` } }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const { data, pagination } = await res.json(); yield data; if (!pagination.hasMore) return; } } // for await (const wallets of pagesByOffset('https://api.aureahub.com/v1/wallets/', token)) { ... } ``` Web version: https://docs.aureahub.com/#pagination --- # Idempotency The bank ramp endpoints that create something take an `Idempotency-Key` header. Everywhere else, know what repeating a call does before you retry it. ## Overview Most endpoints don't detect duplicate requests: a repeated `POST` is processed again. Two mechanisms make a retry safe: the optional `Idempotency-Key` header of the bank ramp endpoints that create a KYC session, a virtual IBAN, a payout, a payout quote or a payout paid from the user's wallet, and the `idempotencyKey` body field of [agent payment executions](https://docs.aureahub.com/docs/agentic-api.md). ## Idempotency-Key on Bank Ramp Requests [Onboarding Session](https://docs.aureahub.com/docs/payin-session.md), [Initiate Deposit](https://docs.aureahub.com/docs/payin-initiate.md) (and its alias `/initiate-deposit`), [Initiate Payout](https://docs.aureahub.com/docs/payout-initiate.md), [Create a Payout Quote](https://docs.aureahub.com/docs/payout-quote.md) and [Pay Out a Quote from Your Wallet](https://docs.aureahub.com/docs/payout-wallet-pay.md) accept an optional `Idempotency-Key` header of up to 255 letters, digits, `-` or `_`. Make one key per operation — a UUID works — store it with the pending action, and send the same key with the same body on every retry. Every response of these endpoints carries `idempotency-replayed`: `true` when it is a stored answer, `false` otherwise. | Request | Answer | | --- | --- | | **No header** | Behaves as it always has: every request runs. | | **A new key** | Runs, and Aurea keeps the answer when the request succeeds. | | **The same key and the same body, after a success** | The stored answer — same status, same body — with `idempotency-replayed: true`. Nothing is created again and the bank ramp is not called. | | **The same key and a different body** | `409` with `details.code` `IDEMPOTENCY_KEY_REUSED`. Nothing runs. | | **The same key while the first request is still running** | `409` `IDEMPOTENCY_IN_PROGRESS`. Wait a moment and send it again. | | **The same key and the same body, after a refusal or a failure** | Runs again: only a successful answer is kept. A payout retried this way sends the bank ramp the request of the first attempt again, so the bank ramp does not see a second payout. | | **A key with other characters, or longer than 255** | `400` `IDEMPOTENCY_KEY_INVALID`. Nothing runs. | A key belongs to one user and one endpoint: the same key sent by another user, or to another endpoint, is a different key. Two bodies are the same when they have the same fields and values, in any order. ```javascript // Create the key once, keep it with the pending payout, and send it again on every retry const idempotencyKey = crypto.randomUUID(); async function createPayout() { const res = await fetch('https://api.aureahub.com/v1/ramp/bank/payout/initiate', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey }, body: JSON.stringify(payoutRequest) // the same body on every retry }); if (res.status === 409) return retryLater(); // IDEMPOTENCY_IN_PROGRESS, or the key was used for another body return res.json(); // the first answer, even when this is a retry } ``` ## What Happens on a Retry | Call | Repeating it | | --- | --- | | GET requests | Safe — they don't change anything. | | POST /v1/transactions/ (custodial wallet) | Sends a second transaction. | | POST /v1/swap/execute | Sends, or prepares, a second swap. | | POST /v1/ramp/bank/payout/initiate | Without `Idempotency-Key`, creates a second payout session. With the key of the first request, answers with the first payout. | | POST /v1/ramp/bank/payin/onboard-session | Without a key, returns the user's unexpired session or creates a new one. With the key of the first request, answers with the first answer. | | POST /v1/ramp/bank/payin/initiate | Without a key, assigns the virtual IBAN again; when the bank ramp returns the same payment method, Aurea updates the existing record. With the key of the first request, answers with the first answer. | | POST /v1/ramp/bank/payin/checkout | Card top-up is switched off: every request answers `403`. | | Broadcasting a prepared transaction | A prepared transaction can be broadcast once; another attempt is rejected with `409` `PREPARED_TX_ALREADY_USED`. | | Tenant-signed requests | Each signature is accepted once, so a retry has to be signed again — see [Authentication](https://docs.aureahub.com/docs/authentication.md). | ## Agent Payment Executions `POST /v1/agent-payments/executions` accepts an optional `idempotencyKey` (up to 255 characters). If your tenant already has an execution with that key, Aurea returns that execution unchanged instead of running the payment again. It doesn't compare the rest of the request, and it doesn't return an error. ```javascript // Create the key once, store it with the pending payment, and reuse it on every retry const idempotencyKey = crypto.randomUUID(); const res = await fetch('https://api.aureahub.com/v1/agent-payments/executions', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ ...executionRequest, idempotencyKey }) }); ``` ## Retrying Safely - If a request that moves money times out or the connection drops, don't resend it blindly. First check whether it went through — for example in [List Transactions](https://docs.aureahub.com/docs/tx-list.md), [List Payout Transactions](https://docs.aureahub.com/docs/payout-list.md) or the [Unified Feed](https://docs.aureahub.com/docs/agg-tx-list.md). - Prevent double submission in your UI (disable the button until the response arrives). - On `429`, wait for `retry-after` before retrying — see [Rate Limiting](https://docs.aureahub.com/docs/rate-limiting.md). Web version: https://docs.aureahub.com/#idempotency --- # Enums & State Machines The status values the API returns, and how a resource moves between them. ## Overview Statuses are strings, and different resources use different words for similar states — `confirmed` for transactions, `completed` for the bank ramp deposits and payouts. Treat a value you don't recognise as not final, and read the resource again before showing an outcome to the user. ## Transaction Values: `pending`, `confirmed`, `failed`, `cancelled`. A transaction starts as `pending` and is resolved from the chain to `confirmed` or `failed`; a custodial send that can't be submitted is stored as `failed`. `cancelled` is accepted as a filter value, but the transactions module never sets it. ```text pending ──► confirmed │ └──► failed ``` ## Swap Swaps are stored as transactions with `type: "swap"`. Values: `pending` → `confirmed` | `failed`. Solana swaps and completed gasless swaps are returned already `confirmed`. An expired quote is not a status: the call fails with `400` and a message starting with `QUOTE_EXPIRED`. ```text pending ──► confirmed │ └──► failed ``` ## Bank Ramp Onboarding (KYC) Returned by [Onboarding Status](https://docs.aureahub.com/docs/payin-status.md) and [Sync KYC Status](https://docs.aureahub.com/docs/payin-sync-status.md). - `kycStatus`: `not_started`, `pending`, `approved`, `rejected`. - `onboardingStatus`: `not_started`, `pending`, `in_progress`, `completed`, `failed`. - the bank ramp's verification result maps to Approved → `approved` / `completed`, Pending → `pending` / `pending`, Declined → `rejected` / `failed`. - A status never moves back: an approved user stays approved when the bank ramp later reports Pending (as it does for another currency), and `rejected` is final. - `canInitiateDeposit` is `true` only when `onboardingStatus` is `completed` and `kycStatus` is `approved`. - `verification.nextStep`: `start_onboarding`, `continue_onboarding`, `wait_for_review`, `approved`, `rejected` — see [Onboarding Status](https://docs.aureahub.com/docs/payin-status.md). ```text not_started ──► pending ──► approved ──► rejected (onboardingStatus: completed, then failed) │ └──► rejected (onboardingStatus: failed) ``` ## Bank Deposit Returned by [Get Deposits](https://docs.aureahub.com/docs/payin-deposits.md). Values: `pending`, `completed`, `failed`, `under_review`, `refunded`. A deposit becomes `completed` when the bank ramp's `FiatDeposit` webhook reports it as settled, and `failed` when the bank ramp reports it failed; until then it stays `pending`. ## Bank Pay-in Transaction Returned by [Get Transactions](https://docs.aureahub.com/docs/payin-transactions.md). `transactionType`: `purchase`, `withdrawal`, `refund`. `status`: `pending`, `completed`, `failed`, `cancelled`. ## Bank Payout Returned by [Get Payout Transaction](https://docs.aureahub.com/docs/payout-get.md). A payout is `pending` from creation until the bank ramp reports progress; the other statuses are set by the bank ramp's `Transaction` events for the payout, whose `ExternalID` names it. A status never moves back. `cancelled` stays in the list of values, but no the bank ramp event sets it. ```text pending ──► processing ──► completed (Transaction Pending → processing, Settled → completed) │ │ │ └──► failed (Transaction Failed) └──► completed or failed (a Settled or Failed that arrives first) ``` ## EURe Onboarding Gnosis Pay onboarding status, returned by `GET /v1/gnosis/local-status`: `pending` → `terms_accepted` → `kyc_completed` → `source_of_funds_completed` → `mobile_verified` → `active`. Web version: https://docs.aureahub.com/#enums-states --- # Webhooks Aurea sends webhooks for Agent Payments, the bank ramp and the card onramp. For wallets, transactions and swaps, read the state through the API. ## Overview Two different things are called webhooks in these docs: - **Outbound — Aurea calls your server.** For Agent Payments events, which this page describes, and for the changes to your users' bank ramp KYC, deposits, transactions and payouts and to their card onramp purchases, described in [Events to Your Server](https://docs.aureahub.com/docs/guide-events.md). The two are registered, signed and retried differently. - **Inbound — the bank ramp reports to Aurea.** Its events update KYC status, deposits and payouts inside Aurea. Aurea receives them once the bank ramp is connected for your tenant; there is nothing to subscribe on your side ([Bank Ramp Setup](https://docs.aureahub.com/docs/bank-setup.md)). Aurea doesn't push wallet, transaction, swap or EURe changes to your servers. ## Agent Payment Events | type | When | data | | --- | --- | --- | | agent.payment.settled | An agent payment was executed on its rail | `{ executionId, agentId, amount, currency, rail }` | | agent.payment.denied | The spend policy denied the payment | `{ executionId, agentId, reason }` | | agent.payment.rejected | A payment waiting for approval was rejected | `{ executionId, agentId, reason }` | ## Registering an Endpoint With a tenant admin access token, call `POST /v1/agent-payments/webhooks/endpoints` (201 with the created endpoint). `GET /v1/agent-payments/webhooks/endpoints` lists them under `data`. | Field | Description | | --- | --- | | url | Required. `http` or `https`; no credentials in the URL. `localhost` and hosts that resolve to private, loopback, link-local or cloud-metadata addresses are refused, also after redirects. | | events | Optional list of event types. Omitted or empty means all events. | | secret | Optional, 8–255 characters. Without a secret, deliveries are not signed. | | agentId | Optional agent ID. | ```javascript await fetch('https://api.aureahub.com/v1/agent-payments/webhooks/endpoints', { method: 'POST', headers: { Authorization: `Bearer ${tenantAdminToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ url: 'https://example.com/aurea/webhooks', events: ['agent.payment.settled', 'agent.payment.denied'], secret: process.env.AUREA_WEBHOOK_SECRET }) }); ``` ## Deliveries Each delivery is an HTTP `POST` with a JSON body: ```json { "id": ":", "type": "agent.payment.settled", "data": { "executionId": "…", "agentId": "…", "amount": "…", "currency": "…", "rail": "…" } } ``` | Header | Value | | --- | --- | | Content-Type | application/json | | X-Aurea-Event | The event type | | X-Aurea-Delivery | ID of this delivery | | X-Aurea-Signature | `sha256=` — only when the endpoint has a secret | - An event is queued at most once per endpoint, and its `id` is stable across retries: use it to skip events you have already processed. - There is no timestamp header, so deduplicating on `id` is also your replay protection. - Queued deliveries are sent when the delivery worker runs. A tenant admin can run it with `POST /v1/agent-payments/webhooks/run`, which returns `{ delivered, retried, dead }`; whether it also runs on a schedule depends on the deployment. ## Verifying the Signature `X-Aurea-Signature` is `sha256=` followed by the hex HMAC-SHA256 of the raw request body, keyed with the endpoint's secret. Verify it on the exact bytes you received, before parsing the JSON. ```typescript import { createHmac, timingSafeEqual } from 'node:crypto'; import express from 'express'; export function verifyAureaWebhook(rawBody: Buffer, signature: string | undefined, secret: string): boolean { if (!signature) return false; const expected = Buffer.from('sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex')); const received = Buffer.from(signature); return expected.length === received.length && timingSafeEqual(expected, received); } const app = express(); app.post('/aurea/webhooks', express.raw({ type: 'application/json' }), async (req, res) => { if (!verifyAureaWebhook(req.body, req.get('x-aurea-signature'), process.env.AUREA_WEBHOOK_SECRET!)) { return res.sendStatus(401); } const event = JSON.parse(req.body.toString('utf8')); if (!(await alreadyProcessed(event.id))) await handleEvent(event); res.sendStatus(200); }); ``` ## Retries Any `2xx` answer marks the delivery as delivered. Any other status, or a network error, schedules a retry after 1, 2, 4, 8 and 16 minutes. After the sixth failed attempt the delivery is marked dead and not retried. Retries are sent when the delivery worker runs after they become due. ## Everything Else - **Transactions and swaps:** poll [Get Tx Status](https://docs.aureahub.com/docs/tx-status.md) or the [Unified Feed](https://docs.aureahub.com/docs/agg-tx-list.md). - **The bank ramp:** [Events to Your Server](https://docs.aureahub.com/docs/guide-events.md), or read [Onboarding Status](https://docs.aureahub.com/docs/payin-status.md), [Get Deposits](https://docs.aureahub.com/docs/payin-deposits.md) and [Get Payout Transaction](https://docs.aureahub.com/docs/payout-get.md). - **Users' devices:** Aurea sends push notifications for some events, such as completed the bank ramp deposits and payouts, to devices registered with [Register Device](https://docs.aureahub.com/docs/notif-register.md). Web version: https://docs.aureahub.com/#webhooks --- # Signing Primitives Every cryptographic signature Aurea asks the client to produce, with a copy-pasteable TypeScript example. ## Overview Aurea never holds the private keys for non-custodial users, so the client is responsible for producing well-formed signatures. Each flow uses exactly one signing primitive — pick the right one. ## SIWE (EIP-4361) **When:** signing in to Gnosis Pay for EURe — `GET /v1/gnosis/auth/nonce?address=…`, then `POST /v1/gnosis/auth/challenge`, both with the user's bearer token. **Format:** an EIP-4361 (Sign-In With Ethereum) message containing the nonce, with the domain and URI Gnosis Pay expects. **Signer:** `personal_sign` over the message. Submit the exact message you signed, together with the signature and the address. ```typescript import { SiweMessage } from 'siwe'; const auth = { Authorization: `Bearer ${token}` }; // 1. Nonce for the signing address const nonceResponse = await fetch(`/v1/gnosis/auth/nonce?address=${wallet.address}`, { headers: auth }) .then(r => r.json()); // 2. Build and sign the EIP-4361 message const message = new SiweMessage({ domain: GNOSIS_PAY_SIWE_DOMAIN, // as required by Gnosis Pay uri: GNOSIS_PAY_SIWE_URI, // as required by Gnosis Pay address: wallet.address, version: '1', chainId: 100, // Gnosis nonce: NONCE, // from nonceResponse issuedAt: new Date().toISOString(), }).prepareMessage(); const signature = await wallet.signMessage(message); // 3. Submit exactly what was signed await fetch('/v1/gnosis/auth/challenge', { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ message, signature, address: wallet.address }) }); ``` ## EIP-712 Typed Data **When:** ERC-2612 permits. Note that for gasless swaps `POST /v1/swap/sign-permit` does not return typed data: Aurea signs the EUR.e permit server-side and returns `{ v, r, s, deadline, nonce, permitRequired }`, which is passed as `permitSignature` to `POST /v1/swap/execute-gasless`. **Format:** ERC-2612 `Permit` struct on the token contract. ```typescript const domain = { name: 'USD Coin', version: '2', chainId: 1, verifyingContract: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' }; const types = { Permit: [ { name: 'owner', type: 'address' }, { name: 'spender', type: 'address' }, { name: 'value', type: 'uint256' }, { name: 'nonce', type: 'uint256' }, { name: 'deadline', type: 'uint256' }, ] }; const value = { owner: wallet.address, spender: ROUTER_ADDRESS, value: amount, // smallest units, string nonce: permitNonce, // from the token contract deadline: Math.floor(Date.now() / 1000) + 3600, }; const signature = await wallet.signTypedData(domain, types, value); // Split with ethers' Signature.from(signature) for { v, r, s }. Note: POST /v1/swap/broadcast-gasless takes no permit — only { preparedTxId, v, r, s } for a prepared swap transaction ``` ## EIP-191 personal_sign **When:** DApp `sign-personal-message` (for wallets without a server-held key, sign the returned `messageHash` as a raw digest — it already includes this prefix), Monerium link-message, IBAN signing message. **Format:** `"\x19Ethereum Signed Message:\n" + len(message) + message` (`ethers` does this for you). ```typescript const { message } = await fetch('/v1/gnosis/monerium/link-message', { headers: { Authorization: `Bearer ${token}` } }).then(r => r.json()); const signature = await wallet.signMessage(message); // POST /v1/gnosis/monerium/link-safe { signature } ``` ## EIP-1559 Transactions **When:** non-custodial EVM sends. `POST /v1/transactions/` builds a type-2 (EIP-1559) transaction server-side and returns `txHashToSign` (keccak-256 of the unsigned transaction); the client signs that hash and submits only `v`, `r`, `s`. ```typescript import { SigningKey } from 'ethers'; // 1. Create: the API builds and stores the unsigned EIP-1559 transaction const tx = await fetch('/v1/transactions/', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ walletId, chain: 'gnosis', toAddress, amount: '10000000000000000' }) }).then(r => r.json()); // tx: { id, requiresClientSigning: true, gasless: false, preparedTxId, txHashToSign, nonce, maxFeePerGas, maxPriorityFeePerGas, chainId, expiresAt, ... } // 2. Client signs the 32-byte hash (no EIP-191 prefix) const sig = new SigningKey(privateKey).sign(tx.txHashToSign); // 3. Broadcast the signature (v = 0 or 1) before tx.expiresAt await fetch(`/v1/transactions/${tx.id}/broadcast`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ preparedTxId: tx.preparedTxId, v: sig.yParity, r: sig.r, s: sig.s }) }); ``` ## Solana VersionedTransaction **When:** not for swaps. Solana-source swaps are signed by Aurea: `POST /v1/swap/execute` (alias `POST /v1/swap/execute-solana`) receives the base64 `VersionedTransaction` from the quote's `transactionData.data`, signs it with the wallet's server-held keypair and returns the Solana signature as `txHash` with status `confirmed`. Non-custodial Solana wallets are rejected with `400`, and `POST /v1/swap/broadcast` does not accept Solana transactions. **Format:** base64-encoded `VersionedTransaction`. ```typescript // Solana-source swap (quote.sourceChainType === 'solana'): no client signature const res = await fetch('https://api.aureahub.com/v1/swap/execute', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ walletId, chain: 'solana', quoteId: quote.quoteId, transactionData: quote.transactionData // data = base64 VersionedTransaction from LI.FI }) }).then(r => r.json()); // res.status === 'confirmed'; res.txHash is the Solana signature ``` Web version: https://docs.aureahub.com/#signing --- # Versioning There is one API version, `v1`, and it is part of the path. ## Overview API endpoints live under `/v1/`; `GET /health` and a few web pages, such as the password-reset page, are outside it. The API doesn't read a version header and doesn't send `Deprecation` or `Sunset` headers. ## Building for Change - Ignore response fields you don't know, so that new fields don't break your client. - Treat status values you don't recognise as not final, and read the resource again before acting — see [Enums & State Machines](https://docs.aureahub.com/docs/enums-states.md). - Don't depend on the wording of error `message` fields; branch on the HTTP status and, where present, `code` — see [Error Handling](https://docs.aureahub.com/docs/errors.md). Web version: https://docs.aureahub.com/#versioning --- # User Onboarding Take a new user from sign-up to a first wallet. Which wallet flow you run is set by your tenant's wallet mode — it is not a per-user choice. ## Overview Every tenant has a `walletMode` in its configuration: `custodial` (the default when none is set), `non_custodial` or `mpc`. Registration has no custody parameter, so every user of a tenant follows that tenant's mode, and each mode creates wallets through different endpoints: - **custodial** — the server generates and stores the key: `POST /v1/wallets/`. - **non_custodial** — the key is generated on the device and only the address is registered: `POST /v1/wallets/client`. `POST /v1/wallets/` returns `400`. - **mpc** — a 2-of-3 threshold wallet created through the MPC key-generation ceremony. `POST /v1/wallets/` returns `400`. ## Step 1: Read the Wallet Mode Public Config (`GET /v1/tenant/public`) returns `walletMode` before any user is logged in. Like registration, it is authenticated with HMAC headers instead of a Bearer token: `x-tenant-api-key`, `x-timestamp` (Unix seconds, accepted within ±300 s of server time) and `x-signature` — a hex HMAC-SHA256, keyed with the tenant API secret, of `method`, request path (including any query string), timestamp and the hex SHA-256 of the raw body, joined by newlines. A given signature is accepted only once. ```typescript import { createHash, createHmac } from 'node:crypto'; const API = 'https://api.aureahub.com'; // HMAC headers for tenant-signed routes such as /v1/tenant/public and /v1/auth/register function tenantHeaders(method: string, pathWithQuery: string, rawBody = '') { const timestamp = Math.floor(Date.now() / 1000).toString(); const bodyHash = createHash('sha256').update(rawBody).digest('hex'); // SHA-256 of '' for no body const signature = createHmac('sha256', process.env.AUREA_TENANT_API_SECRET!) .update(`${method}\n${pathWithQuery}\n${timestamp}\n${bodyHash}`) .digest('hex'); return { 'x-tenant-api-key': process.env.AUREA_TENANT_API_KEY!, 'x-timestamp': timestamp, 'x-signature': signature, }; } const { walletMode } = await fetch(`${API}/v1/tenant/public`, { headers: tenantHeaders('GET', '/v1/tenant/public') }).then(r => r.json()); // 'custodial' | 'non_custodial' | 'mpc' ``` After login you can also use Available Features (`GET /v1/tenant/available-features`), whose `nonCustodialWallets` flag is `true` exactly when `walletMode` is `non_custodial`, and [MPC Config](https://docs.aureahub.com/docs/mpc-config.md) for MPC tenants. ## Step 2: Register `POST /v1/auth/register` takes `username` (3–100 letters, digits, `_` or `-`), `email`, `password` (at least 8 characters with upper-case, lower-case and a digit) and an optional `wallet` object whose `option` is `create`, `import` or `skip`. It requires the same HMAC headers, computed over the exact body you send. The response contains `accessToken`, `refreshToken`, `expiresIn`, `user` and `wallet` (`null` when no wallet was created). Full reference: [Register](https://docs.aureahub.com/docs/auth-register.md). ```typescript const body = JSON.stringify({ username: 'alice', email: 'alice@example.com', password: 'SecurePass123', // custodial tenants only — see below ...(walletMode === 'custodial' ? { wallet: { option: 'create', chain: 'gnosis' } } : {}) }); const res = await fetch(`${API}/v1/auth/register`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...tenantHeaders('POST', '/v1/auth/register', body) }, body }); const { accessToken, refreshToken, user, wallet } = await res.json(); const bearer = { Authorization: `Bearer ${accessToken}` }; ``` ## Custodial Tenants Create the first wallet at registration with `wallet: { option: "create", chain }`, or afterwards with [Create Wallet](https://docs.aureahub.com/docs/wallets-create.md). If the tenant is configured with a default registration chain, a wallet is also created at sign-up when `wallet` is omitted. The new wallet is made primary when the user has no primary wallet on that chain and network type. The server generates the key and stores it encrypted. When a key vault is configured, the key is split into two Shamir shares — one encrypted in the database, one in the key vault — and the wallet reports `keyManagementScheme: "sss_2of2_akv"`; otherwise it reports `aes_single`. ```typescript const wallet = await fetch(`${API}/v1/wallets/`, { method: 'POST', headers: { ...bearer, 'Content-Type': 'application/json' }, body: JSON.stringify({ chain: 'gnosis', name: 'Main' }) }).then(r => r.json()); ``` ## Non-Custodial Tenants Do not send `wallet.option: "create"` at registration and do not call `POST /v1/wallets/` — both return `400` on these tenants. Generate the key on the device, prove ownership with a challenge, and register the address. Aurea stores no key material; transfers and swaps are prepared by the server and signed on the device. ```typescript import { Wallet, getBytes } from 'ethers'; const device = Wallet.createRandom(); // keep the key in secure device storage const q = new URLSearchParams({ chain: 'evm', address: device.address }); const { challenge } = await fetch(`${API}/v1/wallets/client/challenge?${q}`, { headers: bearer }) .then(r => r.json()); const wallet = await fetch(`${API}/v1/wallets/client`, { method: 'POST', headers: { ...bearer, 'Content-Type': 'application/json' }, body: JSON.stringify({ chain: 'evm', // one wallet per supported EVM chain; returns the gnosis one address: device.address, signedChallenge: await device.signMessage(getBytes('0x' + challenge)) // sign the 32 challenge bytes }) }).then(r => r.json()); // wallet.keyManagementScheme === 'client_side' ``` Details: [Non-Custodial Challenge](https://docs.aureahub.com/docs/wallets-client-challenge.md), [Register Client Wallet](https://docs.aureahub.com/docs/wallets-client-register.md) and the [Non-Custodial Key Lifecycle](https://docs.aureahub.com/docs/guide-non-custodial-keys.md) guide. ## MPC Tenants On an MPC tenant, `wallet.option: "create"` at registration creates no wallet (the response's `wallet` is `null`), and `POST /v1/wallets/` returns `400`. Create the wallet with the MPC flow: [MPC Config](https://docs.aureahub.com/docs/mpc-config.md) → [Start DKG Ceremony](https://docs.aureahub.com/docs/mpc-dkg-start.md) → [Register MPC Wallet](https://docs.aureahub.com/docs/mpc-wallet-register.md). MPC wallets report `keyManagementScheme: "mpc_tss"`. ## Step 3: Primary Wallet and Balance The first wallet is usually primary already (see above). To choose another, use [Set Primary Wallet](https://docs.aureahub.com/docs/wallets-primary.md) — the body `{ "isPrimary": true }` is required — or [Set Primary (atomic)](https://docs.aureahub.com/docs/wallets-set-primary.md). Then read the balance with [Get Balance](https://docs.aureahub.com/docs/wallets-balance.md). ```typescript if (!wallet.isPrimary) { await fetch(`${API}/v1/wallets/${wallet.id}/primary`, { method: 'PATCH', headers: { ...bearer, 'Content-Type': 'application/json' }, body: JSON.stringify({ isPrimary: true }) }); } const balance = await fetch(`${API}/v1/wallets/${wallet.id}/balance`, { headers: bearer }) .then(r => r.json()); // { walletId, address, chain, nativeBalance: { balance, symbol, decimals }, tokens, lastUpdated } ``` ## Custody Modes at a Glance | | custodial | non_custodial | mpc | | --- | --- | --- | --- | | Wallet created with | `POST /v1/wallets/` or `wallet.option: "create"` at registration | `GET /v1/wallets/client/challenge` + `POST /v1/wallets/client` | MPC DKG ceremony + `POST /v1/mpc/wallets` | | Private key | Held by the server, encrypted (Shamir 2-of-2 across database and key vault when a key vault is configured) | Only on the user's device; the server stores the address | Threshold key shares — see MPC Wallets | | keyManagementScheme | `sss_2of2_akv` or `aes_single` | `client_side` | `mpc_tss` | | Transfers and swaps | Signed server-side | Prepared by the server, signed on the client | Prepared by the server, signed on the client | | dApp personal_sign | Server returns `signature` | Client signs the returned `messageHash` | Client signs the returned `messageHash` | | Key export | Through key migration, if enabled for the tenant | Not applicable — no server-held key | Rejected (`409`) — no server-held key | Web version: https://docs.aureahub.com/#guide-user-onboarding --- # Non-Custodial Key Lifecycle How a wallet key ends up only on the user's device — by registering a client-side wallet, or by migrating a server-custodial wallet — and what the server keeps at each stage. ## Overview A wallet's custody is recorded in its `keyManagementScheme`, returned by [Get Wallet](https://docs.aureahub.com/docs/wallets-get.md) and by the wallet creation and registration endpoints. There are two routes to a key held only on the device: 1. **Client-side from the start** — on `non_custodial` tenants the key is generated on the device and only the address is registered. The server never has the key. 2. **Migration** — an existing server-custodial wallet exports its key to the device, the device proves it holds the key, and the server removes its copy. The address does not change. Neither route places key shares on the device or uses a threshold scheme. For 2-of-3 threshold wallets, see [MPC Wallets](https://docs.aureahub.com/docs/mpc-config.md). ## Custody Schemes | | | | | --- | --- | --- | | `aes_single` | Server-held key, AES-256-GCM encrypted in the database (legacy). | Custodial wallets stored without a key-vault share. | | `sss_2of2_akv` | Server-held key split into two Shamir shares — one encrypted in the database, one in a key vault. Both are needed to rebuild the key. | Custodial wallets created while a key vault is configured. `aes_single` wallets are upgraded on first signing use when a key vault is available. | | `client_side_pending` | Migration in progress. Server key material is intact; export and cancellation are possible. Transfers and swaps are already prepared for client-side signing. | `POST /v1/wallets/request-export-token` | | `client_side` | Key only on the device; the server stores the address. | `POST /v1/wallets/client` or `POST /v1/wallets/confirm-client-custody` | | `mpc_tss` | MPC threshold wallet with no server-held key. Cannot be exported or migrated. | MPC key-generation ceremony | ## Client-Side Wallets On tenants with `walletMode: "non_custodial"`: 1. Generate the key on the device. 2. `GET /v1/wallets/client/challenge` with `chain` and `address` — a 32-byte challenge, valid for 10 minutes. 3. Sign the challenge bytes with the new key (see Signing Challenges below). 4. `POST /v1/wallets/client` with `{ chain, address, signedChallenge }` — creates a `client_side` wallet. Pass `chain: "evm"` to register the address on every supported EVM chain at once. ```typescript import { Wallet, getBytes } from 'ethers'; const API = 'https://api.aureahub.com'; const bearer = { Authorization: `Bearer ${accessToken}` }; // secp256k1 key generated on the device — persist it in secure storage const device = Wallet.createRandom(); const q = new URLSearchParams({ chain: 'gnosis', address: device.address }); const { challenge } = await fetch(`${API}/v1/wallets/client/challenge?${q}`, { headers: bearer }) .then(r => r.json()); const wallet = await fetch(`${API}/v1/wallets/client`, { method: 'POST', headers: { ...bearer, 'Content-Type': 'application/json' }, body: JSON.stringify({ chain: 'gnosis', address: device.address, signedChallenge: await device.signMessage(getBytes('0x' + challenge)) }) }).then(r => r.json()); // wallet.keyManagementScheme === 'client_side' ``` Reference: [Non-Custodial Challenge](https://docs.aureahub.com/docs/wallets-client-challenge.md), [Register Client Wallet](https://docs.aureahub.com/docs/wallets-client-register.md). ## Signing Challenges The registration challenge and the migration challenge are both 32 random bytes returned as 64 hex characters. Sign the **bytes**, not the hex text: - **EVM** — EIP-191 `personal_sign`. The server recovers the signer and compares it with the wallet address. - **Solana** — Ed25519 detached signature, sent hex-encoded. The server verifies it against the wallet's public key. ```typescript import { getBytes } from 'ethers'; import nacl from 'tweetnacl'; // EVM const evmSignature = await evmWallet.signMessage(getBytes('0x' + challenge)); // Solana (keypair from @solana/web3.js) const solanaSignature = Buffer.from( nacl.sign.detached(Buffer.from(challenge, 'hex'), keypair.secretKey) ).toString('hex'); ``` ## Migrating to Client Custody Moves a server-custodial wallet (`aes_single` or `sss_2of2_akv`) to `client_side` in four calls: | | | | | --- | --- | --- | | 1 | [POST /v1/wallets/request-export-token](https://docs.aureahub.com/docs/wallets-export-token.md) | Password check; one-time token valid 10 minutes; wallet becomes `client_side_pending`. | | 2 | [POST /v1/wallets/export-key](https://docs.aureahub.com/docs/wallets-export-key.md) | `X-Export-Token` header; the key is returned encrypted to a device X25519 key. Once per wallet; key export must be enabled for the tenant. | | 3 | [GET /v1/wallets/migration-challenge](https://docs.aureahub.com/docs/wallets-migration-challenge.md) | 32-byte challenge, valid 5 minutes. | | 4 | [POST /v1/wallets/confirm-client-custody](https://docs.aureahub.com/docs/wallets-confirm-custody.md) | Challenge signed with the exported key; server key material removed; wallet becomes `client_side`. | > ⚠️ - Step 1 does not check whether key export is enabled for the tenant; step 2 does. If it is disabled, the wallet stays `client_side_pending` until you cancel. > - From step 1, transfers and swaps for the wallet are prepared for client-side signing, but the device only has the key after step 2 — run the steps back to back. > - An expired export token cannot be replaced while the wallet is `client_side_pending`: cancel the migration, then start again. > - After step 2 the migration can no longer be cancelled. > - Step 4 clears the key material in the database immediately; a background job that runs every 5 minutes then disables the key-vault share. There is no API call to reverse it. Store the decrypted key securely before step 4. ```typescript import crypto from 'node:crypto'; import { Wallet, getBytes } from 'ethers'; const API = 'https://api.aureahub.com'; const bearer = { Authorization: `Bearer ${accessToken}` }; const json = { ...bearer, 'Content-Type': 'application/json' }; // 1. Export token — wallet becomes client_side_pending const { exportToken } = await fetch(`${API}/v1/wallets/request-export-token`, { method: 'POST', headers: json, body: JSON.stringify({ walletId, password }) }).then(r => r.json()); // 2. Export to a fresh X25519 key and decrypt (X25519 ECDH -> HKDF-SHA256 -> AES-256-GCM) const eph = crypto.generateKeyPairSync('x25519'); const clientEphemeralPublicKey = eph.publicKey .export({ type: 'spki', format: 'der' }).subarray(-32).toString('base64'); const out = await fetch(`${API}/v1/wallets/export-key`, { method: 'POST', headers: { ...json, 'X-Export-Token': exportToken }, body: JSON.stringify({ walletId, clientEphemeralPublicKey }) }).then(r => r.json()); const serverPublicKey = crypto.createPublicKey({ key: Buffer.concat([Buffer.from('302a300506032b656e032100', 'hex'), Buffer.from(out.ephemeralPublicKey, 'base64')]), format: 'der', type: 'spki' }); const shared = crypto.diffieHellman({ privateKey: eph.privateKey, publicKey: serverPublicKey }); const aesKey = Buffer.from(crypto.hkdfSync('sha256', shared, Buffer.alloc(0), Buffer.from('aurea-key-export-v1'), 32)); const decipher = crypto.createDecipheriv('aes-256-gcm', aesKey, Buffer.from(out.iv, 'base64')); decipher.setAuthTag(Buffer.from(out.tag, 'base64')); const { privateKey } = JSON.parse(Buffer.concat([ decipher.update(Buffer.from(out.encryptedPayload, 'base64')), decipher.final() ]).toString('utf8')); // EVM payload: { privateKey: "0x..." } const local = new Wallet(privateKey); // store securely before step 4 // 3. Migration challenge (valid 5 minutes) const { challenge } = await fetch(`${API}/v1/wallets/migration-challenge?walletId=${walletId}`, { headers: bearer }) .then(r => r.json()); // 4. Prove custody — the server then removes its key material const { migrated } = await fetch(`${API}/v1/wallets/confirm-client-custody`, { method: 'POST', headers: json, body: JSON.stringify({ walletId, signedChallenge: await local.signMessage(getBytes('0x' + challenge)) }) }).then(r => r.json()); ``` ## Cancelling a Migration [Cancel Migration](https://docs.aureahub.com/docs/wallets-cancel-migration.md) (`POST /v1/wallets/cancel-migration` with `walletId` and `password`) works only while the wallet is `client_side_pending` and before a successful export. It invalidates unused export tokens and restores `sss_2of2_akv` (or `aes_single` when the wallet has no database key share). Because server key material is untouched until step 4, the wallet is server-custodial again straight away. After an export the call returns `409`. ```typescript await fetch(`${API}/v1/wallets/cancel-migration`, { method: 'POST', headers: json, body: JSON.stringify({ walletId, password }) }); // { cancelled: true } ``` ## Device Key [Register Device Key](https://docs.aureahub.com/docs/wallets-register-device.md) stores one device public key on a wallet, authorised by a migration-challenge signature made with the wallet's own key. It does not create or store key shares, and it is not used to encrypt the exported key. Because the migration challenge is refused for `client_side` wallets, the call can only be completed before custody is confirmed; it consumes the challenge, so fetch a new one before step 4. ## Server-Side Key Storage Shamir's Secret Sharing is used only for **server-custodial** keys. The private key is split into two shares; neither share alone reveals anything and both are required to reconstruct it. The first share is encrypted and stored in the database, the second is stored in a key vault. The server rebuilds the key in memory when it needs it — to sign, or to encrypt it for the device during a migration. An Aurea administrator can rotate the shares. Wallets registered as `client_side` never have server key material. For a migrated wallet, step 4 clears the database key material immediately and the key-vault share is disabled by the background cleanup job shortly afterwards. Web version: https://docs.aureahub.com/#guide-non-custodial-keys --- # Sending a Transaction Custodial, non-custodial and gasless sends — one endpoint to start, and at most one more to finish. ## Overview Every send starts with `POST /v1/transactions/`. For a custodial wallet that single call signs and submits. For a non-custodial wallet it returns a hash to sign, and a second call submits the signature. Your code does not choose the path: branch on `requiresClientSigning` (and `gasless`) in the response. ```text Custodial POST /v1/transactions/ ──► poll GET /v1/transactions/{id}/status Non-custodial POST /v1/transactions/ ──► sign txHashToSign or permitHashToSign ──► POST /v1/transactions/{id}/broadcast ──► poll GET /v1/transactions/{id}/status ``` ## Before You Send - **Amounts** are integer strings in the token's smallest unit. Get token addresses and decimals from `GET /v1/tokens/chain/{chain}` and convert — see [Amount Units](https://docs.aureahub.com/docs/amounts.md). - **Custody** is given by the wallet's `keyManagementScheme`: `aes_single` and `sss_2of2_akv` are custodial; `client_side`, `client_side_pending` and `mpc_tss` are non-custodial. - **Fees:** [Transaction Quote](https://docs.aureahub.com/docs/tx-quote.md) gives an estimate before you send. ```typescript const API = 'https://api.aureahub.com'; const auth = { Authorization: 'Bearer ' + token }; const { tokens } = await fetch(API + '/v1/tokens/chain/gnosis', { headers: auth }).then(r => r.json()); const usdc = tokens.find(t => t.symbol === 'USDC'); // { symbol, name, address, decimals, isNative, ... } const amount = toSmallest('25', usdc.decimals); // toSmallest: see Amount Units ``` ## Custodial ```typescript const tx = await fetch(API + '/v1/transactions/', { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ walletId, chain: 'gnosis', toAddress, // or toUsername: 'bob' (exactly one of the two) amount, // smallest unit tokenAddress: usdc.address, }), }).then(r => r.json()); // EVM: tx.status === 'pending' and tx.txHash is set. Poll the status endpoint next. ``` If the submission fails, the record is saved as `failed` and the call returns `400` with `Failed to send transaction: …`. ## Non-Custodial (EVM) 1. `POST /v1/transactions/` stores a `pending` record, builds an unsigned EIP-1559 transaction and returns `requiresClientSigning: true`, `preparedTxId`, `txHashToSign` and `expiresAt`. 2. Sign `txHashToSign` — the 32-byte hash itself, without an EIP-191 prefix. 3. Within 5 minutes, send `preparedTxId`, `v` (0 or 1), `r` and `s` to `POST /v1/transactions/{id}/broadcast`. The API checks that the signature recovers to the wallet address and submits the transaction. ```typescript import { SigningKey } from 'ethers'; async function sendNonCustodial(walletId: string, toAddress: string, amount: string, privateKey: string) { // 1. Create: the API builds and stores the unsigned transaction const tx = await fetch(API + '/v1/transactions/', { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ walletId, chain: 'gnosis', toAddress, amount }), }).then(r => r.json()); if (!tx.requiresClientSigning) return tx; // custodial wallet: already submitted // 2. Sign on the client const key = new SigningKey(privateKey); const sig = key.sign(tx.gasless ? tx.permitHashToSign : tx.txHashToSign); const v = tx.gasless ? sig.v : sig.yParity; // 27|28 for gasless permits, 0|1 otherwise // 3. Broadcast before tx.expiresAt const res = await fetch(API + '/v1/transactions/' + tx.id + '/broadcast', { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ preparedTxId: tx.preparedTxId, v, r: sig.r, s: sig.s }), }); const sent = await res.json(); if (!res.ok) throw new Error((sent.code ?? res.status) + ': ' + sent.message); return sent; // status 'pending', txHash = on-chain hash } ``` - Fees are set by the server: `maxFeePerGas` and `maxPriorityFeePerGas` are the network's fee data × 1.5. Gas limit is your `gasLimit`, or 21000 for a native coin, or the network estimate + 30% for a token (150000 if estimation fails). - `mpc_tss` wallets produce the signature through the 2-of-3 MPC ceremony instead of a single local key; the broadcast body is the same. - Solana is not supported on this path (`400`). ## Gasless Token Sends For a non-custodial token send, the API switches to the gasless path by itself when the chain has a gasless transfer forwarder configured and the token is registered as active with permit (EIP-2612) support. The response then has `gasless: true` and `permitHashToSign` (an EIP-712 digest) instead of `txHashToSign`. - Sign `permitHashToSign` and broadcast with `v` = 27 or 28 to the same `POST /v1/transactions/{id}/broadcast` — the code above already handles both cases. - A relayer submits the permit-and-transfer transaction and waits for one confirmation; the response is the record with `status: "pending"` and the on-chain `txHash`. - If the relayer fails, the broadcast returns `500` with code `TX_SUBMISSION_FAILED` and `retryable: true`. - Which chains and tokens qualify depends on your deployment's configuration. ## Solana Solana sends (`chain: "solana"`) are supported for **custodial** wallets only. Send SOL by omitting `tokenAddress`, or an SPL token by setting `tokenAddress` to its mint. The API waits for confirmation before responding, so the returned record already has `status: "confirmed"`. ```typescript const solTx = await fetch(API + '/v1/transactions/', { method: 'POST', headers: { ...auth, 'Content-Type': 'application/json' }, body: JSON.stringify({ walletId: solanaWalletId, chain: 'solana', toAddress: solanaRecipient, // base58 address amount: '1000000', // smallest unit }), }).then(r => r.json()); // solTx.status === 'confirmed' ``` A non-custodial Solana wallet gets `400` `Non-custodial Solana sends are not yet supported`. See [Supported Blockchains](https://docs.aureahub.com/docs/supported-blockchains.md) for the full feature matrix. ## Tracking Status Poll [Get Tx Status](https://docs.aureahub.com/docs/tx-status.md) until `status` is `confirmed` or `failed`. It checks the EVM receipt and saves any change; a background job also advances pending EVM transactions every 60 seconds. For an activity screen that mixes blockchain and fiat items, use the [Unified Transaction Feed](https://docs.aureahub.com/docs/agg-tx-list.md). ## Retries and Expiry - The transaction endpoints do not support idempotency keys. Every `POST /v1/transactions/` that passes validation stores a new record, and for custodial wallets submits a new transaction. After a timeout, check [List Transactions](https://docs.aureahub.com/docs/tx-list.md) (newest first) before retrying. - A prepared send can be broadcast once — a second attempt returns `409` `PREPARED_TX_ALREADY_USED`. - After 5 minutes the broadcast returns `410` `PREPARED_TX_EXPIRED`. Create a new send; the expired record stays `pending` with no on-chain hash. - `422` `WRONG_SIGNER` means the signature does not recover to the wallet address — check the key and `v`. Web version: https://docs.aureahub.com/#guide-send-transaction --- # Token Swaps Every swap starts with a LI.FI quote. What happens next depends on the source chain, on who holds the wallet key, and on whether the pair qualifies for the gasless flow. ## Overview Swaps are routed through **LI.FI**. [Get Quote](https://docs.aureahub.com/docs/swap-quote.md) returns a `quoteId`, a `transactionData` object and approval information. Aurea does not store or re-fetch the quote: when you execute, it signs or prepares the `transactionData` you send, so pass it through unchanged. All swap endpoints take camelCase JSON bodies and require `Authorization: Bearer `. The exception is [List Swap Routes](https://docs.aureahub.com/docs/swap-routes-list.md), where the token is optional. ## Choose a Flow - **EVM, custodial wallet** — quote → approve if `needsApproval` (confirmed when the call returns) → execute → poll status. - **EVM, non-custodial wallet** (key management scheme `client_side`, `client_side_pending` or `mpc_tss`) — quote → approve and broadcast-approve if `needsApproval` → execute → sign → broadcast → poll status. - **Solana source, custodial wallet** — quote → execute (or its alias `execute-solana`); confirmed when the call returns. Non-custodial Solana wallets are not supported. - **EUR.e → xDAI on Gnosis**, when the quote has a `gasless` object — quote → sign-permit → execute-gasless → for non-custodial wallets that pay their own swap gas: sign → broadcast-gasless. Quotes with `isTestnet: true` are currently rejected. To exercise UI and history without LI.FI or a chain, [Simulate Swap](https://docs.aureahub.com/docs/swap-simulate.md) creates a confirmed swap record. ## Regular Swap (EVM) ```typescript const API = 'https://api.aureahub.com'; async function post(path: string, body: unknown): Promise { const res = await fetch(API + path, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const json = await res.json(); if (!res.ok) throw new Error(`${res.status} ${json.code ?? ''}: ${json.message}`); return json; } // 1. Quote const quote = await post('/v1/swap/quote', { walletId, fromChain: 'polygon', toChain: 'polygon', fromToken: FROM_TOKEN, // must be in the token registry toToken: '0x0000000000000000000000000000000000000000', // native coin fromAmount, // string, forwarded to LI.FI unchanged slippage: 0.5 // percent: 0.5% }); // 2. Approval (ERC-20 source token whose current allowance is zero) if (quote.needsApproval) { const approval = await post('/v1/swap/approve', { walletId, chain: 'polygon', tokenAddress: FROM_TOKEN, spenderAddress: quote.approvalAddress // amount omitted → unlimited allowance }); if (approval.requiresClientSigning) { await signAndBroadcast('/v1/swap/broadcast-approve', approval); // No transaction record for approvals: poll /v1/swap/check-allowance before executing } else if (approval.status !== 'confirmed') { throw new Error('Approval failed'); } } // 3. Execute — transactionData exactly as quoted const exec = await post('/v1/swap/execute', { walletId, chain: 'polygon', quoteId: quote.quoteId, transactionData: quote.transactionData, fromAmount: quote.fromAmount, // optional history metadata toAmount: quote.toAmount, fromTokenSymbol: quote.fromToken.symbol, toTokenSymbol: quote.toToken.symbol }); const swap = exec.requiresClientSigning ? await signAndBroadcast('/v1/swap/broadcast', exec, { transactionId: exec.transactionId }) : exec; // 4. Track: GET /v1/transactions/{transactionId}/status while status is 'pending' ``` ## Non-Custodial Signing For non-custodial wallets, `approve`, `execute` and `execute-gasless` can return `requiresClientSigning: true` with a `preparedTxId` and a `txHashToSign`. The hash is the keccak-256 of an unsigned EIP-1559 transaction that Aurea prepared. Sign the hash with the wallet key and send only `v` (0 or 1), `r` and `s`. Aurea rebuilds the transaction, checks that the signature recovers to the wallet's address, and broadcasts it. ```typescript import { SigningKey } from 'ethers'; async function signAndBroadcast(path: string, prepared: any, extra: object = {}) { const sig = new SigningKey(privateKey).sign(prepared.txHashToSign); // or your MPC / device signer return post(path, { ...extra, // /v1/swap/broadcast also needs transactionId preparedTxId: prepared.preparedTxId, v: sig.yParity, // 0 or 1 r: sig.r, s: sig.s }); } ``` > ⚠️ A prepared transaction expires after 5 minutes and can be submitted once — it is marked as used even if the signature is then rejected. A non-custodial swap broadcast is also refused with `QUOTE_EXPIRED` after the `quoteExpiry` you sent to `/execute`, or 60 seconds after `/execute` if you sent none. ## Solana Swap For a quote with `sourceChainType: "solana"`, LI.FI returns a base64 `VersionedTransaction` in `transactionData.data`. Send it to `/v1/swap/execute` or its alias `/v1/swap/execute-solana`. Aurea signs it with the wallet's server-held keypair, waits for confirmation, and returns the signature as `txHash`. There is no approval, client signing or broadcast step. Non-custodial Solana wallets get `400`. ```typescript const quote = await post('/v1/swap/quote', { walletId: solanaWalletId, fromChain: 'solana', toChain: 'solana', fromToken: 'So11111111111111111111111111111111111111112', // native SOL toToken: TO_MINT, fromAmount }); const { transactionId, txHash, status } = await post('/v1/swap/execute-solana', { walletId: solanaWalletId, chain: 'solana', quoteId: quote.quoteId, transactionData: quote.transactionData }); // status === 'confirmed'; txHash is the Solana signature ``` ## Gasless Swap (EUR.e → xDAI on Gnosis) This flow requires gasless swaps to be enabled on the deployment and a quote that contains a `gasless` object. The approval is an ERC-2612 permit. [Sign EIP-712 Permit](https://docs.aureahub.com/docs/swap-sign-permit.md) has Aurea sign it server-side and returns `{ v, r, s, deadline, nonce, permitRequired }`; nothing is signed as typed data on the client. See [Execute Gasless](https://docs.aureahub.com/docs/swap-gasless.md) for limits and response paths. ```typescript const EURE = '0x420CA0f9B9b604cE0fd9C18EF134C705e5Fa3430'; // EUR.e on Gnosis const quote = await post('/v1/swap/quote', { walletId, fromChain: 'gnosis', toChain: 'gnosis', fromToken: EURE, toToken: '0x0000000000000000000000000000000000000000', // xDAI fromAmount: amountWei // forwarded to LI.FI unchanged }); if (!quote.gasless) throw new Error('Gasless swap not available'); // Permit for spender = transactionData.to and value = amount (wei) const permitSignature = await post('/v1/swap/sign-permit', { walletId, quoteId: quote.quoteId, spenderAddress: quote.transactionData.to, amount: amountWei }); const result = await post('/v1/swap/execute-gasless', { walletId, quoteId: quote.quoteId, amount: amountWei, tokenAddress: EURE, transactionData: quote.transactionData, permitSignature }); if (result.requiresClientSigning) { // Non-custodial wallet that pays its own swap gas await signAndBroadcast('/v1/swap/broadcast-gasless', result); } // Track with result.transactionId ``` ## Slippage & Amounts - `slippage` is accepted only by `POST /v1/swap/quote`. It is a **percentage** from 0 to 50 — `0.5` = 0.5% — and defaults to `0.5`. Aurea divides it by 100 before sending it to LI.FI. There is no `slippage_bps` field, and sending `50` means 50%. - `fromAmount` on the quote must match `^\d+(\.\d+)?$` and is forwarded to LI.FI unchanged; Aurea does not scale it by token decimals. - `amount` on `/approve`, `/sign-permit` and `/execute-gasless` is digits only (`^\d+$`), in the token's smallest unit (wei for EUR.e). `fromAmount` on `/simulate` is digits only as well. - The optional amount and token fields on `/execute` (`fromAmount`, `toAmount`, symbols, decimals, addresses) are stored for transaction history. They do not change what is executed. ## Tracking & Retries - Swap statuses are `pending`, `confirmed` and `failed`. Custodial EVM swaps and non-custodial broadcasts return `pending`; poll [Get Tx Status](https://docs.aureahub.com/docs/tx-status.md) with `transactionId`. Solana swaps and completed gasless swaps return `confirmed`. - An expired quote is an error, not a status: `400` with a message starting `QUOTE_EXPIRED`. Request a new quote and execute again. - Swap endpoints do not implement idempotency keys and do not de-duplicate requests, so a repeated `/execute` sends or prepares another transaction. After a timeout, check the wallet's transactions before retrying. - The three broadcast endpoints are limited to 10 requests per 10 seconds. - Errors have the shape `{ statusCode, error, message }`, plus `details` when available; blockchain errors also carry `code` and `retryable`. Web version: https://docs.aureahub.com/#guide-swap --- # DApps Browser Integration Back an in-app dApp browser with Aurea: the tenant's dApp catalogue, EIP-191 message signing, nonce and gas lookups, and raw-transaction relay. ## Overview Five endpoints under `/v1/dapps` support a dApp browser. All require a Bearer token and work with EVM chains. The API has no per-dApp routes and no WalletConnect endpoints: the bridge between the dApp page and the wallet (for example handling `personal_sign` or `eth_sendTransaction` requests) lives in your app, which calls these endpoints. | | | | | --- | --- | --- | | [GET /v1/dapps/](https://docs.aureahub.com/docs/dapps-list.md) | — | Catalogue of the tenant's active dApps | | [POST /v1/dapps/sign-personal-message](https://docs.aureahub.com/docs/dapps-sign.md) | — | EIP-191 signature (custodial) or digest to sign (other wallets) | | [GET /v1/dapps/nonce](https://docs.aureahub.com/docs/dapps-nonce.md) | — | Pending nonce of an address | | [GET /v1/dapps/estimate-gas](https://docs.aureahub.com/docs/dapps-gas.md) | — | Gas limit with a 20% buffer | | [POST /v1/dapps/broadcast](https://docs.aureahub.com/docs/dapps-broadcast.md) | — | Relay a signed raw transaction | ## Enable and List The dApps browser is enabled per tenant. Available Features (`GET /v1/tenant/available-features`) reports it as `dappsEnabled`; when it is off, `GET /v1/dapps/` returns an empty list. ```typescript const API = 'https://api.aureahub.com'; const bearer = { Authorization: `Bearer ${accessToken}` }; const { dappsEnabled } = await fetch(`${API}/v1/tenant/available-features`, { headers: bearer }) .then(r => r.json()); if (dappsEnabled) { const { dapps } = await fetch(`${API}/v1/dapps/`, { headers: bearer }).then(r => r.json()); // [{ id, name, url, iconUrl, description, category, sortOrder, ... }] } ``` ## Message Signing When a dApp asks for `personal_sign`, forward the message and the selected address. Custodial wallets come back signed; `client_side` and MPC wallets come back with `requiresClientSigning: true` and a `messageHash` that already includes the EIP-191 prefix, which must be signed as a raw digest. ```typescript const result = await fetch(`${API}/v1/dapps/sign-personal-message`, { method: 'POST', headers: { ...bearer, 'Content-Type': 'application/json' }, body: JSON.stringify({ message, walletAddress }) // message: 0x-hex or UTF-8 text }).then(r => r.json()); const signature = result.signature // custodial: signed by the server ?? device.signingKey.sign(result.messageHash).serialized; // client_side: sign the digest locally // Return signature to the dApp ``` ## Sending a Transaction The dApps endpoints do not sign transactions. For a wallet whose key is on the device, build and sign the transaction locally, using the nonce and gas limit from Aurea, then relay it. The numeric `chainId` is available from [Supported Chains](https://docs.aureahub.com/docs/tokens-chains.md); fee fields are not returned by these endpoints. ```typescript const chainSlug = 'gnosis'; // tx comes from the dApp's eth_sendTransaction request: { to, data, value } const nonceQuery = new URLSearchParams({ chainSlug, address: device.address }); const { nonce } = await fetch(`${API}/v1/dapps/nonce?${nonceQuery}`, { headers: bearer }) .then(r => r.json()); const gasQuery = new URLSearchParams({ chainSlug, to: tx.to, data: tx.data ?? '0x', value: tx.value ?? '0x0' }); const { gasLimit } = await fetch(`${API}/v1/dapps/estimate-gas?${gasQuery}`, { headers: bearer }) .then(r => r.json()); const signedTransaction = await device.signTransaction({ to: tx.to, data: tx.data, value: tx.value, nonce, gasLimit, chainId, // e.g. from GET /v1/tokens/meta/chains maxFeePerGas, maxPriorityFeePerGas // from your own fee source }); const res = await fetch(`${API}/v1/dapps/broadcast`, { method: 'POST', headers: { ...bearer, 'Content-Type': 'application/json' }, body: JSON.stringify({ chainSlug, signedTransaction }) }); if (!res.ok) throw new Error((await res.json()).message); const { txHash } = await res.json(); ``` ## Limits and Errors | | | | | --- | --- | --- | | GET /v1/dapps/nonce | 30 / minute | 400 `NO_RPC_URL` · 404 `WALLET_NOT_FOUND` · 422 `RPC_ERROR` · 504 `RPC_TIMEOUT` | | GET /v1/dapps/estimate-gas | No route-specific limit | 400 `NO_RPC_URL` · 422 `RPC_ERROR` · 504 `RPC_TIMEOUT` | | POST /v1/dapps/sign-personal-message | 20 / minute | 404 `WALLET_NOT_FOUND` · 500 `KEY_DECRYPTION_FAILED` | | POST /v1/dapps/broadcast | 10 / 10 seconds | 400 `UNKNOWN_CHAIN` or `NO_RPC_URL` · 422 `RPC_ERROR` · 504 `RPC_TIMEOUT` | Error bodies on these routes have the shape `{ "error": "…", "message": "…" }`; RPC timeouts apply after 15 seconds. Web version: https://docs.aureahub.com/#guide-dapps --- # Bank Ramp Setup What has to be in place before your app offers the bank ramp — EUR bank transfers to stablecoins and back — and how your app checks it. ## Overview The bank ramp is an Aurea service. Your apps call Aurea's API with the user's access token; there is no other account, key or dashboard for you to manage. Aurea sets it up for your tenant environment by environment — the sandbox (`isTestnet: true`) and production — and each environment has its own settings, its own KYC for each user and its own webhook. ## What Aurea Sets Up Ask your Aurea contact, for the sandbox first and then for production, to: 1. **Connect the bank ramp** for your tenant. Until it is connected, the bank ramp endpoints answer `503` `NOAH_NOT_CONFIGURED` and nothing is sent anywhere. Once it is connected, every change to a user's KYC, deposits, transactions and payouts reaches Aurea with nothing to do on your side. 2. **Switch on the functions** you offer: KYC, pay-in, payout. A request for a function that is off answers `403` `NOAH_FUNCTION_OFF`. 3. **Choose where pay-ins go**: to the user's Aurea wallets (the *Aurea wallets* mode), to addresses outside Aurea that the user proved they own (the *standalone* mode — [Pay-In to Your Own Address](https://docs.aureahub.com/docs/guide-standalone-payin.md)), or both. Asking for a proof while the standalone mode is off answers `403` `NOAH_MODE_OFF`; a pay-in to an address the modes do not allow answers `400` `NOAH_DESTINATION_NOT_ALLOWED`. 4. **Choose the pairs** — currency and network — offered for pay-in and for payout. Another pair answers `400` `NOAH_PAIR_UNAVAILABLE` with the pairs offered. 5. **Set your fee**, if you charge one: a business fee per flow (pay-in, payout) and bank payment method type. The pay-in fee is taken from each deposit; the payout fee shows in each payout quote's breakdown. 6. **Register your return URLs** — where the hosted onboarding and payout pages send the user back. The onboarding page returns straight to your URL, so it must be an `https://` page (`400` `RETURN_URL_NOT_HTTPS` otherwise). A payout returns through Aurea, so an app deep link works there. Once your tenant has any return URL, any other answers `400` `RETURN_URL_NOT_ALLOWED`; a tenant with none yet is not checked. ## What You Set Up - **Your webhook**, if your server should hear about each change without asking: your tenant's administrator (role `tenantadmin`) saves one per environment — [Events to Your Server](https://docs.aureahub.com/docs/guide-events.md). - **Your screens**, from what your tenant offers: read it with [Tenant Settings](https://docs.aureahub.com/docs/bank-settings.md) and show only what will work. ## Check Your Settings `GET /v1/ramp/bank/settings?isTestnet=true` answers, for the sandbox, whether the bank ramp is on (`switchedOn`), which functions and modes are on, the pairs and the fees; without `isTestnet` it answers for production. It reads Aurea's records only, so call it whenever your app builds its menu ([Tenant Settings](https://docs.aureahub.com/docs/bank-settings.md)). ## Sandbox First The sandbox moves no real money: users complete a test KYC, and you can [simulate a deposit](https://docs.aureahub.com/docs/sandbox-deposit.md) and a payout. A user approved in the sandbox onboards again in production. The [Sandbox Playbook](https://docs.aureahub.com/docs/guide-sandbox-playbook.md) walks through every flow. Web version: https://docs.aureahub.com/#bank-setup --- # Bank Pay-In Take a user from zero to money in: bank ramp onboarding (KYC), then a virtual IBAN for bank transfers. ## Overview The bank ramp handles KYC and the banking rails; Aurea runs the bank ramp for your tenant. A user onboards once, then gets a **virtual IBAN** (Step 4): they send SEPA transfers whenever they like, and the bank ramp converts each transfer to crypto and sends it on-chain to the address you chose. The bank ramp takes bank transfers only (its card checkout, `POST /v1/ramp/bank/payin/checkout`, answers `403`). To let users pay by card, use the [card onramp](https://docs.aureahub.com/docs/guide-card-onramp.md). ## Before You Start - Aurea has connected the bank ramp for your tenant and switched on KYC and pay-in in the environment you use — [Bank Ramp Setup](https://docs.aureahub.com/docs/bank-setup.md). Read [Tenant Settings](https://docs.aureahub.com/docs/bank-settings.md) to know what to show. - Choose the environment on every request: `isTestnet: true` (or a sandbox network) uses the bank ramp's sandbox and the user's sandbox profile. A user has a separate profile, with its own KYC, IBANs and history, in each environment, so pass the same `isTestnet` on the reads too. The examples below are production. ## Step 1: Onboarding Status ```typescript const status = await fetch('https://api.aureahub.com/v1/ramp/bank/payin/onboarding-status', { headers: { Authorization: `Bearer ${token}` } }).then(r => r.json()); // onboardingStatus: not_started | pending | in_progress | completed | failed // kycStatus: not_started | pending | approved | rejected if (status.canInitiateDeposit) goToStep4(); ``` ## Step 2: Onboarding Session `returnUrl` is given to the bank ramp as-is, so it must be an `https://` URL, and one your tenant allows ([Bank Ramp Setup](https://docs.aureahub.com/docs/bank-setup.md)). ```typescript const session = await fetch('https://api.aureahub.com/v1/ramp/bank/payin/onboard-session', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ returnUrl: 'https://app.example.com/kyc/done', isTestnet: false }) }).then(r => r.json()); if (session.alreadyCompleted) goToStep4(); else await InAppBrowser.open(session.onboardingUrl); ``` ## Step 3: Sync After Return On your `returnUrl`, pull the result from the bank ramp instead of waiting for the webhook. Send the same `isTestnet` as the onboarding session. **A status that has not moved is a failure, not a reason to start again.** When both statuses still read `not_started` after the user came back, the bank ramp did not create the customer at all: the user was turned away on the hosted page, and nothing tells them apart from someone who never opened the link ([When the Bank Ramp Says No](https://docs.aureahub.com/docs/payin-session.md)). Count the attempts on your side and stop after the second — a third link leads to the same wall. ```typescript const synced = await fetch('https://api.aureahub.com/v1/ramp/bank/payin/sync-status', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ isTestnet: false }) }).then(r => r.json()); if (synced.canInitiateDeposit) goToStep4(); else if (synced.kycStatus === 'rejected') showKycRejected(); else if (synced.onboardingStatus === 'not_started' && synced.kycStatus === 'not_started') { // No customer yet: the hosted page produced nothing. Do not re-offer for ever. const attempts = countKycAttempt(userId); // your own storage, not Aurea's if (attempts >= 2) showKycUnavailable(); // offer a person, not the same button else offerKycOnceMore(); } else showKycInReview(); // Aurea is updated when the review ends ``` ## Step 4: Virtual IBAN No amount is involved. `network` selects sandbox or production, so set it explicitly; in production the pay-in pairs today are `EURC` on `Base` and `Solana`; `USDC` on `Base`, `Celo`, `Ethereum`, `PolygonPos` and `Solana`; `USDT` on `Celo` and `Ethereum`; and `PYUSD`, `USDG` and `USDPT` on `Solana` ([Currencies & Networks](https://docs.aureahub.com/docs/bank-currencies.md) has the current list, and your tenant may offer fewer). On an EVM network `destinationAddress` is required; on a Solana network it can be omitted, and the user's Solana wallet of that environment receives the funds. The funds can only go to one of the user's Aurea wallets — or, with the standalone mode, to an address the user proved they own ([Standalone Pay-In](https://docs.aureahub.com/docs/guide-standalone-payin.md)). ```typescript const iban = await fetch('https://api.aureahub.com/v1/ramp/bank/payin/initiate', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', 'Idempotency-Key': ibanRequestKey }, body: JSON.stringify({ cryptoCurrency: 'EURC', network: 'Solana' }) // the user's Solana wallet receives the funds }).then(r => r.json()); // Show iban.iban, iban.bic, iban.accountHolderName, iban.bankName. // Refresh by calling /initiate again after iban.expiresAt. ``` ## Step 5: Follow the Money Read the results, or let Aurea post each change to your server ([Events to Your Server](https://docs.aureahub.com/docs/guide-events.md)): - Bank transfers to a virtual IBAN: [Get Deposits](https://docs.aureahub.com/docs/payin-deposits.md) — `pending` until the bank ramp settles them, then `completed` (the user also gets a push notification). A deposit the bank ramp reviews shows why in `noahSubStatus` and `requestForInformation`; a rejected one is `failed` with its `refunds`. - The conversion and the on-chain delivery of each transfer, both with the deposit that paid for them: [List Customer Transactions](https://docs.aureahub.com/docs/payin-transactions.md). The bank ramp transactions also appear in the [Unified Feed](https://docs.aureahub.com/docs/agg-tx-list.md). ```typescript const { deposits } = await fetch('https://api.aureahub.com/v1/ramp/bank/payin/deposits?limit=20', { headers: { Authorization: `Bearer ${token}` } }).then(r => r.json()); ``` Web version: https://docs.aureahub.com/#guide-fiat-payin --- # Pay-In to Your Own Address Deliver a user's bank transfers to a wallet they already own — MetaMask, Phantom, a hardware wallet — without an Aurea wallet. ## Overview In the **standalone** mode the user brings their own address. Aurea never holds its key: the user proves once that they control the address by signing a message, and [Initiate Deposit](https://docs.aureahub.com/docs/payin-initiate.md) then accepts it as the destination of a virtual IBAN. The bank ramp converts each transfer to that IBAN and sends the crypto on-chain to the proven address. - The user still onboards with the bank ramp (KYC), but needs no Aurea wallet — not even a primary EVM wallet. - A proof belongs to one user, one environment and one kind of address. One EVM proof covers the address on every EVM network. - With both modes on, a pay-in may go to one of the user's Aurea wallets or to a proven address. - The mode decides where pay-ins go. Payouts work as in [Fiat Pay-Out](https://docs.aureahub.com/docs/guide-fiat-payout.md). ## Before You Start - Aurea has switched on KYC, pay-in and the standalone mode for your tenant in the environment you use — [Bank Ramp Setup](https://docs.aureahub.com/docs/bank-setup.md). [Tenant Settings](https://docs.aureahub.com/docs/bank-settings.md) shows `modes.standalone` and the pairs offered for pay-in. - The user has completed the bank ramp's onboarding, with KYC approved, in that environment — [Fiat Pay-In](https://docs.aureahub.com/docs/guide-fiat-payin.md), steps 1–3. - Pick the pair first: its `addressFormat` — `evm` or `solana` — is the kind of address to prove. In production the pay-in pair today is `EURC` on `Solana`; in the sandbox there are Solana and EVM pairs ([Currencies & Networks](https://docs.aureahub.com/docs/bank-currencies.md)). The examples below are production. ## Step 1: Sign a Challenge Ask for a challenge for the address, then have the wallet sign `message` exactly as returned. Signing moves no funds and costs no fee. The challenge can be verified for 10 minutes. Wallets that can't sign a message, and smart contract accounts, can't prove an address. ```typescript const API = 'https://api.aureahub.com'; const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }; async function post(path: string, body: object) { const res = await fetch(API + path, { method: 'POST', headers, body: JSON.stringify(body) }); const data = await res.json(); if (!res.ok) throw Object.assign(new Error(data.message), { status: res.status, details: data.details }); return data; } // Solana, with @solana/wallet-adapter-react (Phantom, Solflare, ...) import bs58 from 'bs58'; const { publicKey, signMessage } = useWallet(); const challenge = await post('/v1/ramp/bank/addresses/challenge', { family: 'solana', address: publicKey.toBase58(), isTestnet: false }); // ed25519 over the message's UTF-8 bytes, sent in base58 const signature = bs58.encode(await signMessage(new TextEncoder().encode(challenge.message))); ``` An EVM wallet signs with EIP-191 `personal_sign`; with ethers v6: ```typescript import { BrowserProvider } from 'ethers'; const signer = await new BrowserProvider(window.ethereum).getSigner(); const challenge = await post('/v1/ramp/bank/addresses/challenge', { family: 'evm', address: await signer.getAddress(), isTestnet: true // in the sandbox, e.g. for USDC_TEST on PolygonTestAmoy }); const signature = await signer.signMessage(challenge.message); ``` ## Step 2: Verify the Address `201` the first time, `200` when the user already proved the address in that environment. A wrong signature tells you how many attempts are left; after the fifth, or once the challenge expired or was used, ask for a new challenge. ```typescript const proven = await post('/v1/ramp/bank/addresses/verify', { challengeId: challenge.challengeId, signature, label: 'Phantom' // optional, up to 100 characters }); // proven.id, proven.address (in the form Aurea stores it), proven.environment ``` ## Step 3: Virtual IBAN Send the proven address as `destinationAddress`, on a network of its kind and its environment. Always send it: without one, a Solana route falls back to the user's Aurea Solana wallet, not to a proven address. Any address the user has not proved there, or has revoked, answers `400` `NOAH_DESTINATION_NOT_ALLOWED`, and nothing is sent to the bank ramp. ```typescript const iban = await fetch(API + '/v1/ramp/bank/payin/initiate', { method: 'POST', headers: { ...headers, 'Idempotency-Key': ibanRequestKey }, body: JSON.stringify({ cryptoCurrency: 'EURC', network: 'Solana', destinationAddress: proven.address }) }).then(r => r.json()); // Show iban.iban, iban.bic, iban.accountHolderName, iban.bankName. // Transfers to it arrive as EURC on the user's own Solana address. ``` Follow the transfers as in [Fiat Pay-In](https://docs.aureahub.com/docs/guide-fiat-payin.md), step 5. ## Step 4: Manage Addresses [List Addresses](https://docs.aureahub.com/docs/bank-addresses-list.md) returns what the user proved in one environment; [Revoke an Address](https://docs.aureahub.com/docs/bank-address-revoke.md) stops new virtual IBANs to one. Both answer whatever your tenant's settings. ```typescript const { addresses } = await fetch(API + '/v1/ramp/bank/addresses?isTestnet=false', { headers }).then(r => r.json()); await fetch(API + `/v1/ramp/bank/addresses/${addresses[0].id}`, { method: 'DELETE', headers }); // 204 ``` > ⚠️ Revoking an address changes nothing at the bank ramp: a virtual IBAN created earlier for it keeps delivering there. Stop showing that IBAN to the user. ## Errors - `403` `NOAH_MODE_OFF`: the standalone mode is off for your tenant in that environment — on the challenge, and on verifying a challenge of that environment. - `400` `NOAH_ADDRESS_INVALID`: not an address of that kind, or an EVM address in mixed case with a wrong checksum. - `409` `NOAH_ADDRESS_CHALLENGE_LIMIT`: the user has 5 open challenges; verify one or let it expire. - `400` `NOAH_ADDRESS_SIGNATURE_INVALID`: signed with another key or over another text (`details.reason`, `details.attemptsLeft`). - `400` `NOAH_ADDRESS_CHALLENGE_BURNT`, `400` `NOAH_ADDRESS_CHALLENGE_EXPIRED`, `409` `NOAH_ADDRESS_CHALLENGE_USED`: ask for a new challenge. - `400` `NOAH_DESTINATION_NOT_ALLOWED` on Initiate Deposit: the address is not one the user proved in that environment, or it was revoked. - `403` `NOAH_FUNCTION_OFF` and `400` `NOAH_PAIR_UNAVAILABLE`: as in [Initiate Deposit](https://docs.aureahub.com/docs/payin-initiate.md). Web version: https://docs.aureahub.com/#guide-standalone-payin --- # Bank Payout Let a user convert crypto into fiat on their bank account through the bank ramp's hosted payout page. Payouts currently run only in the bank ramp's sandbox (`isTestnet: true`); production requests are refused. **This is the hosted payout**, where the bank ramp's own page takes the crypto out of *Aurea's* balance. To have the user pay from a wallet they control — which is what most integrations want — read [Wallet Pay-Out](https://docs.aureahub.com/docs/guide-wallet-payout.md), or [Standalone Pay-Out](https://docs.aureahub.com/docs/guide-standalone-payout.md) when the wallet is one Aurea has no key for. ## Overview You create a payout for a crypto amount; Aurea gets the rate from the bank ramp and returns a hosted URL; the user chooses a bank account and confirms on the hosted page; the bank ramp's events update the payout in Aurea. The user never types an IBAN into your app. ## Before You Start - Aurea has connected the bank ramp for your tenant and switched on payouts in the sandbox — [Bank Ramp Setup](https://docs.aureahub.com/docs/bank-setup.md). [Tenant Settings](https://docs.aureahub.com/docs/bank-settings.md) lists the currencies it offers for payout. - Payouts run only in the bank ramp's sandbox for now: send `isTestnet: true`. This hosted payout is funded from the bank ramp balance Aurea keeps for your tenant, not from the user's wallet: when a payout is refused for balance, ask Aurea to top it up. A production request gets `403` `PAYOUT_PRODUCTION_UNAVAILABLE`. - The user has completed the bank ramp's onboarding — see [Fiat Pay-In](https://docs.aureahub.com/docs/guide-fiat-payin.md), steps 1–3. ## Step 1: Check KYC Payouts run in the sandbox, so read the user's **sandbox** profile: a user has a separate profile, with its own KYC, in each the bank ramp environment. ```typescript const status = await fetch('https://api.aureahub.com/v1/ramp/bank/payin/onboarding-status?isTestnet=true', { headers: { Authorization: `Bearer ${token}` } }).then(r => r.json()); if (status.kycStatus !== 'approved') { // Payouts require kycStatus "approved" in the sandbox — onboard with isTestnet: true first return startBankOnboarding(); } ``` ## Step 2: Create the Payout `cryptoAmount` is an integer string with 6 decimals (`"25000000"` = 25). `returnUrl` must be one your tenant allows ([Bank Ramp Setup](https://docs.aureahub.com/docs/bank-setup.md)). Send an `Idempotency-Key`, and the same key on every retry, so a retry never creates a second payout ([Idempotency](https://docs.aureahub.com/docs/idempotency.md)). ```typescript const res = await fetch('https://api.aureahub.com/v1/ramp/bank/payout/initiate', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', 'Idempotency-Key': payoutKey }, body: JSON.stringify({ cryptoCurrency: 'USDC_TEST', cryptoAmount: '25000000', fiatCurrency: 'EUR', returnUrl: 'myapp://payout/done', isTestnet: true }) }); const payout = await res.json(); if (!res.ok) throw new Error(payout.message); // { payoutId, checkoutUrl, fiatAmount, fiatCurrency, exchangeRate, expiresAt } savePayoutId(payout.payoutId); ``` ## Step 3: Open the Hosted Page Show the quote (`fiatAmount`, `exchangeRate`), then open `checkoutUrl`. When the user is done, the hosted page returns them to Aurea, which sends them to your `returnUrl` unchanged — the redirect carries no result. ```typescript await InAppBrowser.open(payout.checkoutUrl); // ...later, your deep-link handler for myapp://payout/done runs Step 4 ``` ## Step 4: Track the Status A payout starts as `pending` and moves to `processing`, then `completed` or `failed`, as the bank ramp reports the payout to Aurea. Read the payout — or receive `ramp.payout.updated` on your server ([Events to Your Server](https://docs.aureahub.com/docs/guide-events.md)) — and let the push notification Aurea sends on completion or failure prompt a refresh. ```typescript const current = await fetch( `https://api.aureahub.com/v1/ramp/bank/payout/transactions/${payoutId}?isTestnet=true`, // payouts run in the sandbox { headers: { Authorization: `Bearer ${token}` } } ).then(r => r.json()); switch (current.status) { case 'pending': /* user hasn't finished on the hosted page yet */ break; case 'processing': /* confirmed, on its way */ break; case 'completed': /* fiat sent to the bank account */ break; case 'failed': case 'cancelled': /* show the outcome and offer to try again */ break; } ``` ## Errors Initiate Payout answers `403` `PAYOUT_PRODUCTION_UNAVAILABLE` to a production request (`isTestnet` false or omitted), `403` `NOAH_FUNCTION_OFF` when your tenant has payouts switched off, and `400` with a descriptive `message` when: - the return URL is not one your tenant allows (`RETURN_URL_NOT_ALLOWED`); - your tenant doesn't offer the currency for payout (`NOAH_PAIR_UNAVAILABLE`); - the user never onboarded or `kycStatus` isn't `approved`; - the bank ramp has no channel for the currency pair; - the fiat amount is below the channel minimum or above its maximum (`details` contains the limit); - the bank ramp balance Aurea keeps for your tenant is too low. See [Initiate Payout](https://docs.aureahub.com/docs/payout-initiate.md) for the exact messages. Web version: https://docs.aureahub.com/#guide-fiat-payout --- # Payout from a Wallet Turn a user's crypto into money on a bank account, paid from a wallet **they** control — Aurea never holds it and never sends it for them. ## Overview This is the payout Fase 5 was built for, and it is not the [hosted one](https://docs.aureahub.com/docs/guide-fiat-payout.md). There, the bank ramp's own page takes the money out of Aurea's balance. Here the user's wallet sends the crypto, the bank ramp recognises the deposit **by the address it came from**, and pays the beneficiary. Nothing passes through Aurea. The shape of it, in five calls: 1. Ask where the money may go — country, channel, and whatever that channel asks about the beneficiary. 2. Ask for a **quote**: the rate, the fees and the deadline, locked. 3. Bind that quote to a **payout**, saying which wallet will pay. The answer is the deposit: an address, an exact amount, and a deadline. 4. The user's wallet **sends that deposit**. 5. Follow the payout until it completes. Two things decide everything that follows: **the deposit must come from the address you named**, and **it must arrive before its deadline**. ## Before You Start - Your tenant banks with the bank ramp, has **payouts** switched on in the environment you use, and offers the pair you are paying with — [Tenant Settings](https://docs.aureahub.com/docs/bank-settings.md) says what you may show, and [Currencies & Networks](https://docs.aureahub.com/docs/bank-currencies.md) what Aurea enables. - The **Aurea wallets** mode is on for your tenant. Paying from an address outside Aurea is the *standalone* mode and its own guide: [Standalone Pay-Out](https://docs.aureahub.com/docs/guide-standalone-payout.md). - The user has a bank ramp profile in that environment, with onboarding completed and KYC approved — [Fiat Pay-In](https://docs.aureahub.com/docs/guide-fiat-payin.md) takes them there. A user who is not approved is refused at step 3 with `422` `NOAH_KYC_NOT_APPROVED`, not at the end. - Choose the environment on **every** call with `isTestnet`: `true` is the bank ramp's sandbox, with its own profile, its own quotes and its own payouts. The examples below are the sandbox. ## Step 1: Where the Money Goes [Countries](https://docs.aureahub.com/docs/payout-countries.md) lists where payouts may land; [Search Channels](https://docs.aureahub.com/docs/payout-channels.md) lists the ways to get there — a SEPA transfer, a local rail, a wallet — each with its limits and its fees. Then [Channel Form](https://docs.aureahub.com/docs/payout-channel-form.md) says what that channel needs to know about the beneficiary, field by field, in the order to ask. A user who has been paid before has [saved beneficiaries](https://docs.aureahub.com/docs/payout-beneficiaries.md): offer those first, and ask for the form only for a new one. ## Step 2: The Quote [Create a Quote](https://docs.aureahub.com/docs/payout-quote.md) is where the numbers are decided: how much fiat the beneficiary receives, at which rate, with which fees — your tenant's payout fee included — and **until when**. Show the user those numbers, not your own arithmetic on them. - The bank ramp may ask for more before it can quote. The answer then carries a **form step**, and you send what it asks with [Answer a Form Step](https://docs.aureahub.com/docs/payout-quote-step.md) until the quote is `ready`. This is a conversation, not one call. - `quote.expiresAt` is the rate's deadline. After it the quote locks nothing and step 3 refuses it. - The signed quote the bank ramp returns stays inside Aurea. You never see it and never need it. ## Step 3: Pay It From the Wallet [Pay Out a Quote from Your Wallet](https://docs.aureahub.com/docs/payout-wallet-pay.md) binds the quote to a payout. You send three things: the `quoteId`, the `network` the deposit will travel on, and `source.walletId` — one of the user's Aurea wallets. **The key decides, not the wallet's label.** An EVM wallet is a valid source on every EVM network, because it is the same key: a wallet created on Ethereum can send the deposit on `PolygonTestAmoy`. A Solana wallet sends on Solana networks. What a wallet may *not* do is send a deposit it cannot sign: a watch-only wallet is refused (`400` `NOAH_SOURCE_NOT_ALLOWED`, `reason` `watch_only`), because Aurea has no proof the user controls its key. Send an `Idempotency-Key`. The bank ramp's rule carries no reference of its own, so asking twice would make two rules; with the key, a retry answers with the first payout and the bank ramp is not asked again. ## Step 4: Send the Deposit The answer *is* the instruction. Four fields matter to the wallet: - `deposit.address` — where to send. - `deposit.amountUnits` — **exactly** how much, in the token's smallest unit. It is the number a transfer takes. `deposit.amount` is the same figure written for a person. - `deposit.expiresAt` — when the deposit must have arrived. A deposit later than this is matched by no rule: the money leaves the user's wallet and no payout follows it. Show this deadline, and do not let a user start a transfer that cannot arrive in time. - `deposit.uri` — the same transfer written the way the chain family writes one: **EIP-681** on EVM, **Solana Pay** on Solana. Hand it to the wallet and nobody retypes an amount. Wallets honour these unevenly, so **check the amount before signing** and treat `amountUnits` as the truth. The deposit must come **from the source you named**. The bank ramp has nothing else to go on: there is no memo and no reference in the request, by design. ## Step 5: Follow It [Get a Wallet Payout](https://docs.aureahub.com/docs/payout-wallet-get.md) and [List Wallet Payouts](https://docs.aureahub.com/docs/payout-wallet-list.md) answer where it is. `pending` is waiting for the deposit; `processing`, the deposit arrived and the bank ramp is paying; `completed`, the beneficiary has the money; `failed`, the bank ramp refused the rule; `expired`, no deposit came in time. Do not poll for it: [Events to Your Server](https://docs.aureahub.com/docs/guide-events.md) posts your own record to your server whenever it changes, signed, and the user's device is notified when the payout completes or fails. ## When It Does Not Work | Answer | What happened | What to do | | --- | --- | --- | | `409` `NOAH_QUOTE_NOT_LOCKED` | The quote cannot be paid: The bank ramp still asks a form step (`not_ready`), or a locked rate has passed (`expired`). A quote that locks no rate is paid by a rule, not refused | Finish the form, or ask for a new quote. Nothing was stored | | `400` `NOAH_PAYOUT_AMOUNT_TOO_SMALL` | The quote pays the beneficiary nothing: the amount is below the channel's `cryptoLimits.min`, so its fixed fee takes it all, and the bank ramp prices it at zero instead of refusing it | Read `cryptoLimits.min` from [Search Channels](https://docs.aureahub.com/docs/payout-channels.md) and ask for a quote above it. Nothing was stored | | `409` `NOAH_QUOTE_USED` | That quote already pays a payout — whatever its status. A quote pays one payout | Use `details.payoutId` to show the one that exists; a new payout needs a new quote | | `409` `NOAH_PAYOUT_SOURCE_BUSY` | That address already has a payout waiting for a deposit of that currency on that network — **anywhere in Aurea**, not only in your tenant. The bank ramp matches by address, so two rules on one address would be ambiguous | Wait for the deposit or for the hold to end, or use another wallet. The same address on another network, or another currency, does not collide | | `400` `NOAH_PAIR_UNAVAILABLE` | The quote's currency on that network is not a payout pair Aurea enables and your tenant offers | `details.available` names what is offered; the currency comes from the quote, so only the network is yours to choose | | `403` `NOAH_MODE_OFF`, `mode` `aureaWallets` | Your tenant does not have the Aurea wallets mode | Ask the Aurea operator to switch it on, or pay from a proven address instead | | `422` `NOAH_KYC_NOT_APPROVED` | The user's KYC is not approved in that environment | Send them through onboarding; the check happens before anything is stored | | `502` `NOAH_UNEXPECTED_RESPONSE` with `outcome: "unknown"` | the bank ramp was asked once and its answer never arrived. A rule may or may not exist | **Do not retry.** Read the payout: Aurea repairs it on its own from what the bank ramp has, and the source stays held meanwhile | ## What Never Happens - **Aurea never holds the money and never sends the deposit.** The user's wallet signs the transfer; there is no path in which Aurea moves it for them. - **Aurea's own balance at the bank ramp is never what pays this.** A payout paid by a rule must move it by nothing at all, and an Aurea operator is told if it ever does. - **The bank ramp is asked for the rule once.** Its trigger carries no reference, so a second attempt would make a second rule; when Aurea cannot tell what happened it keeps the payout and repairs it from what the bank ramp has. - **Nothing of the user reaches a log.** Not the source address, not the deposit address, not the bank ramp's form session, not the signed quote. Web version: https://docs.aureahub.com/#guide-wallet-payout --- # Payout from Your Own Address Let a user turn crypto into money on a bank account from a wallet Aurea has never held a key for — MetaMask, Phantom, a hardware wallet. ## Overview The path is the one in [Wallet Pay-Out](https://docs.aureahub.com/docs/guide-wallet-payout.md), with one difference that changes what you must build: **Aurea has no key**. It cannot sign, cannot prompt, cannot retry on the user's behalf. What it can do is prove once that the user controls the address, and then hand back a deposit request precise enough that the user's own wallet can honour it without anyone retyping a number. So there are two phases. **Once**: the user signs a message and the address becomes theirs, in that environment, until they revoke it. **Each payout**: quote, bind, and then a deposit that must come from that address before a deadline. ## Before You Start - Your tenant banks with the bank ramp, has **payouts** switched on in that environment, and has the **standalone** mode — [Tenant Settings](https://docs.aureahub.com/docs/bank-settings.md). Without the mode, asking for a proof and paying from a proven address both answer `403` `NOAH_MODE_OFF` with `details.mode` `standalone`. - The user's KYC is approved in that environment. - A proof belongs to **one environment**: an address proven in the sandbox is not proven in production. ## Step 1: Prove the Address [Ask for a Challenge](https://docs.aureahub.com/docs/bank-address-challenge.md) gives a message; the user signs it with the address's own key; [Verify an Address](https://docs.aureahub.com/docs/bank-address-verify.md) checks the signature and keeps the address. [List Addresses](https://docs.aureahub.com/docs/bank-addresses-list.md) shows what a user has proven, and [Revoke an Address](https://docs.aureahub.com/docs/bank-address-revoke.md) takes one back. This is the same proof the [standalone pay-in](https://docs.aureahub.com/docs/guide-standalone-payin.md) uses: an address proven once serves both directions. ## Step 2: Quote and Pay The quote is the same as in [Wallet Pay-Out](https://docs.aureahub.com/docs/guide-wallet-payout.md): countries, channel, the channel's form, then [Create a Quote](https://docs.aureahub.com/docs/payout-quote.md) and its form steps until it is `ready`. What changes is one field of [Pay Out a Quote from Your Wallet](https://docs.aureahub.com/docs/payout-wallet-pay.md): instead of `source.walletId` you send `source.address` — the proven address. Exactly one of the two; both, or neither, answers `400`. An EVM address is matched in any letter case. An address that was never proven, or was revoked, answers `400` `NOAH_SOURCE_NOT_ALLOWED`. The proof is checked in the environment of the call. ## Step 3: The Deposit Request The answer carries everything the user's wallet needs, and this is where standalone earns its keep: - `deposit.address` and `deposit.amountUnits` — where, and **exactly** how much, in the token's smallest unit. - `deposit.expiresAt` — **the deposit's own deadline**: 30 minutes after the quote's, which is when the rule stops holding the address. A deposit that arrives later is matched by nothing. Show it as a deadline, not as a detail. - `deposit.uri` — the transfer written in the standard the chain family has, so the user opens it in their wallet instead of copying three values by hand: **EIP-681** on EVM (`ethereum:@/transfer?address=&uint256=`) and **Solana Pay** on Solana (`solana:?spl-token=&amount=`). A QR code of that string is the shortest path from your screen to their wallet. - `deposit.tokenAddress`, `decimals` and `chainId` when you build the transfer yourself. The request carries **no reference and no memo**: The bank ramp attributes the deposit by the address it came from. A deposit sent from another address of the same user is not this payout's deposit. `deposit.uri` is `null` when the request cannot be written exactly — no deposit address yet, or the registry no longer holds the token's details. The rest of the answer still says where and how much, so fall back to showing those rather than blocking the user. ## Step 4: While It Waits While a payout waits for its deposit, that address is **held**: no other payout may use it for the same currency on the same network, anywhere in Aurea, and the API says so with `409` `NOAH_PAYOUT_SOURCE_BUSY`. The address also cannot be **revoked** while it is holding a payout: [Revoke an Address](https://docs.aureahub.com/docs/bank-address-revoke.md) answers `409` `NOAH_ADDRESS_IN_USE`. That is deliberate — revoking would leave a deposit on its way to a payout the user no longer owns. Wait for the payout to finish, or let its deadline pass, and then revoke. ## Step 5: Follow It As in the Aurea-wallets mode: [Get a Wallet Payout](https://docs.aureahub.com/docs/payout-wallet-get.md), and your own record posted to your server by [Bank Ramp Events](https://docs.aureahub.com/docs/guide-events.md). `expired` means no deposit came in time — the user's crypto never left their wallet, and nothing is owed. ## When It Does Not Work | Answer | What happened | What to do | | --- | --- | --- | | `403` `NOAH_MODE_OFF`, `mode` `standalone` | Your tenant does not have the standalone mode in that environment | Ask the Aurea operator to switch it on | | `400` `NOAH_SOURCE_NOT_ALLOWED` | That address is not proven in this environment, or was revoked | Prove it again (step 1). A proof is per environment | | `404` `NOAH_RESOURCE_NOT_FOUND` on the channel reads | the bank ramp has nobody by that id yet: it creates the customer when the user *begins* the hosted onboarding, not when the session is made | Have the user start onboarding. [Sync KYC Status](https://docs.aureahub.com/docs/payin-sync-status.md) answering `synced: false` is the same fact seen from the other side | | `409` `NOAH_PAYOUT_SOURCE_BUSY` | The address already has a payout waiting for that currency on that network | Wait, or use another proven address. Another network or currency does not collide | | `409` `NOAH_ADDRESS_IN_USE` on revoke | A payout is waiting for a deposit from that address | Let the payout finish or expire, then revoke | | The deposit was sent late | It arrived after `deposit.expiresAt`, so no rule matched it | Nothing can attach it to that payout afterwards. This is why the deadline is shown, not hidden | | The deposit came from another address | the bank ramp attributes by address, and it was not the source named | The payout keeps waiting. Ask the user to send from the proven address | Web version: https://docs.aureahub.com/#guide-standalone-payout --- # 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
``` ```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 --- # Card Onramp for Your Own Wallets For a tenant that already holds its users' wallets and wants only the card onramp: your server names each end user by your own id, gives the wallet the crypto goes to, and opens the purchase. Aurea runs the payment page, the KYC, the card payment and the delivery to that wallet. ## Overview - **No Aurea account for your users.** You name each end user with your own id, `externalId`. The first call that names it makes it. - **Your wallets, attested by you.** The signed call from your server is your statement that the wallet serves that user. The user signs nothing. - **A default wallet per family** — `evm` (Ethereum, Base, Polygon, Avalanche, Celo) or `solana` — used when a purchase names no address. A purchase can also name its own wallet. - **One call per purchase**, which answers a hosted link to send the user to (a page on Aurea's domain, in your name and colours) and a client secret if you embed the payment widget yourself. - Apps where the user holds the key use the other flow: the user proves the address with a signature ([Card to Crypto](https://docs.aureahub.com/docs/guide-card-onramp.md)). ## Before You Start - Ask your Aurea contact to switch on the card onramp and **tenant wallets** for your tenant, in the sandbox first. Until then the calls answer `403` `CARD_ONRAMP_TENANT_WALLETS_OFF` or `CARD_ONRAMP_OFF`. - Give Aurea the addresses the hosted page may send users back to: only those are accepted as `returnUrl`. - These routes are called from your server only, signed with your API key and secret as described in [Authentication](https://docs.aureahub.com/docs/authentication.md). They take no user token. Each signature is accepted once, so sign every call, retries included. ## The Calls 1. [Attest a default wallet](https://docs.aureahub.com/docs/card-wallets-wallet-put.md) — `PUT /v1/ramp/card/customers/{externalId}/wallets/{family}`, once and whenever it changes. Sending the same address again changes nothing. 2. [Open a purchase](https://docs.aureahub.com/docs/card-wallets-session-create.md) — `POST /v1/ramp/card/customers/{externalId}/sessions` with the pair, the amount and `customerIp`, the user's public IP address as your server saw it: the payment provider decides from it whether it can serve the user. Send the user to `hostedLink.url` (valid 30 minutes) and keep `session.id`. 3. Follow it with the [events to your server](https://docs.aureahub.com/docs/guide-events.md) (`ramp.card_session.updated`, which carries `customer.externalId`), or [read it](https://docs.aureahub.com/docs/card-wallets-session-get.md). When the hosted page is done it sends the user to your `returnUrl` with `from=onramp`, `onrampSession` and `status` added. A session's `status` only moves forward: `initialized` → `requires_payment` → `fulfillment_processing` → `fulfillment_complete` (then `transactionId` is the on-chain hash), or `rejected`. The amounts are in `amounts.sourceCurrency`: the user can change the amount and the currency in the payment widget. ## Idempotency Send an `Idempotency-Key` (for example your order id) when you open a purchase. The same key with the same request answers `200` with the same session and a fresh hosted link. The user's IP address is not part of the comparison, so a retry from another address still matches. The same key with another request answers `409` `IDEMPOTENCY_KEY_REUSED`. ## Errors | Status | `details.code` | Meaning | | --- | --- | --- | | 401 | — | Signature missing, wrong, expired or used twice | | 403 | `CARD_ONRAMP_TENANT_WALLETS_OFF` | Tenant wallets are not switched on in that environment | | 403 | `CARD_ONRAMP_OFF` | The card onramp is not switched on in that environment | | 400 | `CARD_ONRAMP_CUSTOMER_ID_INVALID` | `externalId` is not 1 to 100 letters, digits, `.` `_` `:` `@` `-` | | 400 | `CARD_ONRAMP_CUSTOMER_IP_INVALID` | `customerIp` is not a public IPv4 or IPv6 address | | 400 | `CARD_ONRAMP_ADDRESS_INVALID` | The address is not one of the family's, or has a wrong checksum | | 400 | `CARD_ONRAMP_PAIR_UNAVAILABLE`, `CARD_ONRAMP_PAIR_NOT_SOLD_IN_CURRENCY`, `CARD_ONRAMP_AMOUNT_INVALID`, `RETURN_URL_NOT_ALLOWED` | The pair, its currency, the amount or the return URL | | 404 | `CARD_ONRAMP_CUSTOMER_NOT_FOUND`, `CARD_ONRAMP_SESSION_NOT_FOUND`, `CARD_ONRAMP_WALLET_NOT_FOUND` | No such end user, purchase of that end user, or default wallet | | 409 | `CARD_ONRAMP_WALLET_MISSING` | No address named and no default wallet of the pair's family | | 403 | `CARD_ONRAMP_CUSTOMER_UNSUPPORTED` | The payment provider cannot serve this user | Every refusal before the payment provider is called writes nothing: no end user, no wallet, no session. ## Checklist - Tenant wallets switched on in the sandbox, return URLs registered. - Calls signed on your server; the API secret never in an app or a browser. - `customerIp` is the user's address, not your server's. - A default wallet attested before a purchase that names no address. - An `Idempotency-Key` per purchase; your webhook verifies each event's signature. - A full purchase done in the sandbox before going live. Web version: https://docs.aureahub.com/#guide-card-onramp-wallets --- # Events to Your Server Aurea tells your server when the bank ramp changes a user's KYC, a deposit, a transaction or a payout, and when a card onramp session changes status: a signed `POST`, retried until your server takes it. ## Overview The bank ramp reports every change to Aurea ([Bank Ramp Setup](https://docs.aureahub.com/docs/bank-setup.md)). Aurea applies it to its own records and then, when your tenant has a webhook in that environment asking for that kind of change, posts the updated record to your server — normally a few seconds after the bank ramp's event reached Aurea. - **One webhook per environment.** Production and sandbox each have their own URL, secret, event types and status; a sandbox event never reaches the production webhook. - **Who sets it up:** an Aurea administrator, or your tenant's administrator (role `tenantadmin`), in the Aurea Admin console or with the endpoints under **Tenant Webhooks** in the API Reference. A tenant administrator can manage only its own tenant. - **What you receive is Aurea's record after the change**, with the ids Aurea's reads return — never the bank ramp's own payload. - **The card onramp uses the same webhook.** Each change of a card-to-crypto session's status is posted as `ramp.card_session.updated` ([Card to Crypto](https://docs.aureahub.com/docs/guide-card-onramp.md)); everything below about the delivery, the signature, retries and order applies to it too. - You can still read the same state at any time: [Onboarding Status](https://docs.aureahub.com/docs/payin-status.md), [Get Deposits](https://docs.aureahub.com/docs/payin-deposits.md), [Get Transactions](https://docs.aureahub.com/docs/payin-transactions.md) and [Get Payout Transaction](https://docs.aureahub.com/docs/payout-get.md). ## Before You Start - Aurea has connected the bank ramp for your tenant in that environment ([Bank Ramp Setup](https://docs.aureahub.com/docs/bank-setup.md)). Aurea can only tell you what the bank ramp told Aurea. - A server that answers on a public `https://` address, with the raw request body available to your code (to check the signature). - An access token of your tenant's administrator. ## Register Your Server Save the webhook of one environment with [Save a Webhook](https://docs.aureahub.com/docs/tenant-webhook-save.md). The first save makes it and answers `201` with its `secret` — **the only time the secret is shown**: store it with your server's other secrets before doing anything else. ```bash curl -X PUT https://api.aureahub.com/v1/admin/tenants/$TENANT_ID/webhooks/sandbox \ -H "Authorization: Bearer $TENANT_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "url": "https://hooks.example.com/aurea/events", "eventTypes": [] }' ``` In the Aurea Admin console, open the tenant's **Manage API Credentials** and use **Webhooks to the tenant's server**: **Add webhook** on the environment saves it and shows the secret once, with **Copy**. The same section changes the webhook, switches deliveries off, sends a test, replaces the secret and removes the webhook. The address must be one Aurea can safely call: - `https://` on the default port (443), with a full public DNS name such as `hooks.example.com`. An IP address, `localhost`, or a name ending in `.localhost`, `.local`, `.internal`, `.home.arpa` or `.localdomain` is refused. - No user or password, no fragment (`#`), no whitespace or backslash, and no `.` or `..` path segment, also encoded. At most 2048 characters. A query string is kept. - Aurea stores the address as the URL parser writes it: host in lowercase (punycode for international names), `:443` dropped. A refused address answers `400` with `details.code` `NOAH_WEBHOOK_INVALID`, `details.field` `url` and `details.reason`, and nothing is saved. > ℹ️ The host is resolved again on every delivery, and the delivery is refused — and retried later — when any of its addresses is not public: private, loopback, link-local, carrier-grade NAT, benchmarking, documentation, IETF, NAT64, 6to4, Teredo or multicast. Aurea connects to the address it checked, and follows no redirect. ## Event Types `eventTypes` lists the types the webhook receives; an empty list receives every type, including any published later. | type | Sent when | data | | --- | --- | --- | | ramp.kyc.updated | Aurea applied the bank ramp's `Customer` event for a user | `userId`, `kycStatus` and `onboardingStatus` (the values of [Onboarding Status](https://docs.aureahub.com/docs/payin-status.md)), `verificationStatus` (the bank ramp's `Verifications.Status` Aurea keeps, such as `Approved`), `customerType`, `actionsRequired`, `regions`, `agreements` and `nextStep` (as in Onboarding Status's `verification`), `updatedAt` | | ramp.deposit.updated | Aurea applied the bank ramp's `FiatDeposit` event for a bank transfer to a virtual IBAN | `depositId`, `noahDepositId`, `userId`, `paymentMethodId` (the `id` [Payment Methods](https://docs.aureahub.com/docs/payin-methods.md) returns), `status`, `noahStatus`, `noahSubStatus`, `requestForInformation`, `refunds`, `paymentMethodType`, `paymentSystemId`, `fiatAmount`, `fiatCurrency`, `depositDate`, `updatedAt` | | ramp.transaction.updated | Aurea applied the bank ramp's `Transaction` event to a transaction: a user's, the one a deposit pays for, a card top-up's or a payout's | `transactionId`, `noahTransactionId`, `userId`, `transactionType`, `status`, `noahStatus`, `noahSubStatus`, `cryptoAmount`, `cryptoCurrency`, `fiatAmount`, `fiatCurrency`, `network`, `destinationAddress`, `transactionHash`, `depositId`, `direction`, `noahDepositId`, `requestForInformation`, `refunds`, `ruleId`, `ruleExecutionId`, `reversesTransactionId`, `adjustment`, `breakdown`, `networkFee`, `fiatFeeAmount`, `fiatRate`, `paymentSystemId`, `updatedAt` | | ramp.payout.updated | the bank ramp's `Transaction` event changed a payout's status | `payoutId`, `userId`, `status`, `cryptoCurrency`, `cryptoAuthorizedAmount`, `fiatAmount`, `fiatCurrency`, `noahTransactionId`, `updatedAt` | | ramp.card_session.updated | A card onramp session's status changed — from `initialized` to `failed`, `rejected`, `fulfillment_processing` or `fulfillment_complete` ([Card to Crypto](https://docs.aureahub.com/docs/guide-card-onramp.md)) | The session as [Get a Session](https://docs.aureahub.com/docs/card-onramp-get.md) answers it: `id`, `environment`, `status`, `providerStatus`, `providerSessionId`, `pair`, `destination`, `customer` (your end user's `externalId` when your server opened it for one of [your own wallets](https://docs.aureahub.com/docs/guide-card-onramp-wallets.md), else `null`), `requested`, `amounts`, `transactionId`, `failureCode`, `createdAt`, `updatedAt`. Never the client secret. | - `noahSubStatus` is `AmlScreening`, `UnderReview`, `Submitted`, `Confirming` or `null`. A value the bank ramp does not document is sent as `null`, and the event is still applied. - `requestForInformation`, `refunds`, `breakdown` and `adjustment` have the shapes of [Get Deposits](https://docs.aureahub.com/docs/payin-deposits.md) and [List Customer Transactions](https://docs.aureahub.com/docs/payin-transactions.md); `refunds` and `breakdown` are `[]` when there are none. - A bank transfer's on-chain delivery names no deposit in the bank ramp's event; its `depositId` is the deposit of the conversion with the same `ruleExecutionId`. When the delivery's event arrives before the conversion's, that delivery is sent with `depositId` `null`, and its next event — and List Customer Transactions — carry it. - A field Aurea doesn't have yet is `null`. `noahDepositId` and `noahTransactionId` are the bank ramp's ids; every other id is Aurea's, the one its reads return. Times are ISO 8601 in UTC. - The deposit's and the transaction's amounts, fees and rate, and those in `refunds` and `breakdown`, are decimals written as text without trailing zeros (`"125.5"`). A payout's amounts are as [Get Payout Transaction](https://docs.aureahub.com/docs/payout-get.md) returns them: `cryptoAuthorizedAmount` in the token's smallest unit, `fiatAmount` as a decimal. - One the bank ramp event about a payout can send two deliveries: `ramp.transaction.updated` and, when the payout's status changed, `ramp.payout.updated`. - Nothing is sent for an event that is older than what Aurea already applied, for a delivery the bank ramp repeated, or while the webhook is switched off. An older `Customer` event that brings a region's verification Aurea didn't have, or a newer one, is still applied and sent: The bank ramp sends one event per currency. - Never sent: names, identity documents, bank account numbers, transfer references or the bank ramp's payment method ids. `paymentSystemId` is the transfer's id in the payment system (IMAD, UETR, trace number): it names the payment, not a person. ## The Delivery Each delivery is a `POST` to your address with a JSON body: ```json { "id": "evt_9f2c41d87a0b5e36c1d4f8a2b7e05c93", "type": "ramp.deposit.updated", "environment": "sandbox", "occurredAt": "2026-09-17T09:14:02.000Z", "createdAt": "2026-09-17T09:14:03.418Z", "data": { "depositId": "0b6c9f2e-3a1d-4e5f-8a7b-9c0d1e2f3a4b", "noahDepositId": "8d1f3b5a-7c9e-4f2a-b6d8-0e1a3c5b7d9f", "userId": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "paymentMethodId": "3f5b7d9a-1c3e-4a5b-8d7f-9b1d3f5a7c9e", "status": "completed", "noahStatus": "Settled", "noahSubStatus": null, "requestForInformation": null, "refunds": [], "paymentMethodType": "BankSepa", "paymentSystemId": "SEPA-20260917-000123", "fiatAmount": "125.5", "fiatCurrency": "EUR", "depositDate": "2026-09-17T09:13:58.000Z", "updatedAt": "2026-09-17T09:14:03.402Z" } } ``` | Field | Description | | --- | --- | | id | `evt_` and 32 hex characters. The same on every retry of this delivery; different for each type sent about one the bank ramp event, and for each status of a card onramp session. | | type | One of the types above, or `webhook.test` for a test. | | environment | `production` or `sandbox`: the webhook's environment. | | occurredAt | When the change happened, as the bank ramp reports it — or, for the card onramp, when the payment provider reported the new status. | | createdAt | When Aurea applied the change. | | data | The record after the change (Event Types). | | Header | Value | | Content-Type | `application/json` | | User-Agent | `Aurea-Webhooks/1.0` | | X-Webhook-Id | The body's `id` | | X-Webhook-Timestamp | When this attempt was signed, in Unix seconds | | X-Webhook-Event | The body's `type` | | X-Webhook-Signature | `v1=` and the base64 HMAC-SHA256 of `..` | ## Verify the Signature Check every delivery before trusting it. The key is the **bytes** of the base64 text after `whsec_` in your secret — not the text itself. Compute the HMAC-SHA256 of the `X-Webhook-Id` header, a dot, the `X-Webhook-Timestamp` header, a dot and the **raw body exactly as received**; base64-encode it, put `v1=` in front and compare it with `X-Webhook-Signature` in constant time. Refuse a timestamp that isn't plain digits or is more than five minutes away from your clock: it stops an old delivery from being replayed. Node.js with Express: ```typescript import { createHmac, timingSafeEqual } from 'node:crypto'; import express from 'express'; const TOLERANCE_SECONDS = 300; // True only for a delivery signed with your secret less than five minutes ago. export function verifyNoahDelivery( rawBody: Buffer, id: string | undefined, timestamp: string | undefined, signature: string | undefined, secret: string // whsec_… ): boolean { if (!id || !timestamp || !signature || !/^\d+$/.test(timestamp)) return false; if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false; const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); const mac = createHmac('sha256', key) .update(`${id}.${timestamp}.`) .update(rawBody) .digest('base64'); const expected = Buffer.from(`v1=${mac}`); const received = Buffer.from(signature); return expected.length === received.length && timingSafeEqual(expected, received); } export const app = express(); // The raw bytes are needed for the signature: don't let a JSON parser run first. app.post('/aurea/noah', express.raw({ type: 'application/json' }), async (req, res) => { const verified = verifyNoahDelivery( req.body, req.get('x-webhook-id'), req.get('x-webhook-timestamp'), req.get('x-webhook-signature'), process.env.AUREA_NOAH_WEBHOOK_SECRET! ); if (!verified) return res.sendStatus(401); const event = JSON.parse(req.body.toString('utf8')); if (await alreadyHandled(event.id)) return res.sendStatus(200); // a retry you already have await saveForLater(event); // answer within 10 seconds, work afterwards res.sendStatus(200); }); ``` Python (the `headers` of Flask, Django or FastAPI all work, since they look names up without regard to case): ```python import base64 import hashlib import hmac import time TOLERANCE_SECONDS = 300 def verify_noah_delivery(raw_body: bytes, headers, secret: str) -> bool: """True only for a delivery signed with your secret less than five minutes ago.""" msg_id = headers.get("x-webhook-id") timestamp = headers.get("x-webhook-timestamp") signature = headers.get("x-webhook-signature") if not msg_id or not timestamp or not signature: return False if not (timestamp.isascii() and timestamp.isdigit()): return False if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS: return False key = base64.b64decode(secret.removeprefix("whsec_")) mac = hmac.new(key, f"{msg_id}.{timestamp}.".encode() + raw_body, hashlib.sha256).digest() expected = "v1=" + base64.b64encode(mac).decode() return hmac.compare_digest(expected.encode(), signature.encode()) ``` Both were checked against deliveries signed by Aurea: they accept a genuine one — also with non-ASCII text in the body — and refuse a body changed by one bit, another id or timestamp, a delivery signed more than five minutes ago or ahead, another secret, and a timestamp that isn't plain digits. ## Answer and Retries - **Answer `2xx` within 10 seconds** and the delivery is done. Store the event and do the work afterwards. Aurea doesn't read your answer's body. - Anything else fails the attempt: another status (a redirect too), no answer in 10 seconds, an address that is refused or doesn't resolve, a connection error. - A failed delivery is sent again 30 seconds later, then after 1, 2, 4, 8, 16, 32, 64 and 128 minutes: **10 attempts over about four hours and a quarter**, then Aurea stops. Read the state through the API for anything your server missed; your Aurea contact can also put a delivery that stopped back in the queue, with the same `id` and a new set of attempts. - Every attempt carries the same body and `X-Webhook-Id`, with its own timestamp and signature. - The webhook is read again at each attempt: a replaced secret signs the retries, and a webhook removed, switched off or no longer asking for the type stops them. ## Order and Duplicates - **No order is promised.** Two deliveries — about one event or two — can arrive in either order, and a retry can arrive after a newer delivery. Compare `data.updatedAt` with what you stored, and don't let an older delivery overwrite a newer state. - The same delivery can arrive more than once, for example when your answer was lost. Keep the `id`s you handled and answer `2xx` to one you already have. ## Send a Test [Send a Test](https://docs.aureahub.com/docs/tenant-webhook-test.md) posts one `webhook.test` right away, signed like every delivery, and answers what happened. It also works while the webhook is switched off, is never retried, and is limited to 10 a minute. In the Aurea Admin console, **Send test** shows the outcome on the environment's card. ```json { "id": "evt_4b7e19c0d25a8f63e1b9d07c5a2f8e14", "type": "webhook.test", "environment": "sandbox", "occurredAt": "2026-09-17T09:20:11.052Z", "createdAt": "2026-09-17T09:20:11.052Z", "data": { "tenantId": "6f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", "webhookId": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d", "message": "A test delivery from Aurea: check its signature, then answer 2xx." } } ``` ## Replace the Secret [Replace the Secret](https://docs.aureahub.com/docs/tenant-webhook-rotate.md) answers with a new secret, shown only then, and **every delivery from that moment — retries included — is signed with it**. Deliveries your server refuses while it still has the old secret are retried, so put the new secret in place within a few minutes and nothing is lost. `secretHint`, the four characters before the secret's final `=`, tells you which secret the webhook uses. ## Switch Off or Remove - Saving the webhook with `"status": "disabled"` switches it off: changes that happen meanwhile are not sent later, and deliveries still waiting are dropped. `"active"` switches it on again with the same secret. - [Remove a Webhook](https://docs.aureahub.com/docs/tenant-webhook-remove.md) deletes it and its secret; deliveries still waiting are dropped. Saving one again makes a new secret. - Every change — made, changed, secret replaced, removed, tested — is written to your tenant's activity log with who did it, never with the secret. Web version: https://docs.aureahub.com/#guide-events --- # Sandbox Playbook Run the fiat flows end to end against the bank ramp's sandbox before going live. ## Overview There is **no separate sandbox API host**: every call goes to the base URL of the Aurea deployment you use (`https://api.aureahub.com`). Test mode is chosen per request, and the simulation endpoints act only on sandbox records. ## How Test Mode Works | What | How it is selected | | --- | --- | | bank ramp sandbox | isTestnet on onboarding sessions, sync-status, payouts and the pay-in and payout reads; a sandbox network (SolanaDevnet, PolygonTestAmoy) on Initiate Deposit; isTestnet on Currencies & Networks. A user has a separate profile in each environment. Card checkout is switched off in both environments. Details: Bank Ramp Setup. | | Blockchain testnets | isTestnet on wallet endpoints — for example POST /v1/wallets/import accepts isTestnet: true. | | Simulations | POST /v1/ramp/bank/payin/sandbox/simulate-deposit works only on the user's own sandbox payment method, and POST /v1/ramp/bank/payout/sandbox/simulate only on a sandbox payout; anything else answers 404. GET /v1/ramp/bank/payin/debug/customer-status works only on non-production deployments; production answers 400. | ## Before You Start 1. Have Aurea connect the bank ramp for your tenant in the **sandbox** and switch on the functions you test — [Bank Ramp Setup](https://docs.aureahub.com/docs/bank-setup.md). 2. Create a test user and log in ([Authentication](https://docs.aureahub.com/docs/authentication.md)). 3. With the Aurea wallets mode, a pay-in is delivered only to one of the user's Aurea wallets, and a watch-only import is not one. The sandbox onboarding session in step 1 creates the user's Solana Devnet wallet when they have none, so leave `destinationAddress` out on `SolanaDevnet`. With the standalone mode only, prove a Solana address for the sandbox first and send it ([Standalone Pay-In](https://docs.aureahub.com/docs/guide-standalone-payin.md)). ## Pay-in Dry Run ```typescript const API = 'https://api.aureahub.com'; const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }; const post = (path: string, body?: object) => fetch(API + path, { method: 'POST', headers, body: body ? JSON.stringify(body) : undefined }).then(r => r.json()); // 1. Hosted KYC in the sandbox const session = await post('/v1/ramp/bank/payin/onboard-session', { returnUrl: 'https://app.example.com/kyc/done', isTestnet: true }); // ...open session.onboardingUrl and complete the sandbox KYC... // 2. Pull the result const kyc = await post('/v1/ramp/bank/payin/sync-status', { isTestnet: true }); // expect canInitiateDeposit: true // 3. Sandbox virtual IBAN const pm = await post('/v1/ramp/bank/payin/initiate', { cryptoCurrency: 'EURC_TEST', network: 'SolanaDevnet' }); // delivered to the user's Solana Devnet wallet // 4. Simulated bank transfer (on the sandbox payment method from step 3) await post('/v1/ramp/bank/payin/sandbox/simulate-deposit', { paymentMethodId: pm.paymentMethodId, amount: 100 }); // 5. Once the deposit has reached Aurea, it is listed const { deposits } = await fetch(API + '/v1/ramp/bank/payin/deposits?isTestnet=true', { headers }).then(r => r.json()); ``` ## Payout Dry Run ```typescript // isTestnet: true selects the sandbox (production payouts are refused) const payout = await post('/v1/ramp/bank/payout/initiate', { cryptoCurrency: 'EURC_TEST', cryptoAmount: '20000000', // 20, 6 decimals fiatCurrency: 'EUR', returnUrl: 'myapp://payout/done', isTestnet: true }); // Skip the hosted page and force the result await post('/v1/ramp/bank/payout/sandbox/simulate', { payoutId: payout.payoutId, outcome: 'completed' }); const current = await fetch(API + `/v1/ramp/bank/payout/transactions/${payout.payoutId}?isTestnet=true`, { headers }).then(r => r.json()); // current.status === 'completed' ``` ## Limits - Simulations act only on sandbox records: a payment method created on a sandbox network, and a payout created with `isTestnet: true`. - Simulated deposits appear once the bank ramp's sandbox reports them to Aurea, a few seconds after the call: Aurea receives those reports as soon as it has connected the bank ramp for your tenant in the sandbox. - Payout creation checks the sandbox balance Aurea keeps for your tenant and the channel limits, just like production. - How KYC behaves in the bank ramp's sandbox is defined by the bank ramp — see the bank ramp's documentation. Web version: https://docs.aureahub.com/#guide-sandbox-playbook --- # Build with an AI Agent Everything an AI coding agent needs to integrate Aurea: where the machine-readable docs are, the rules every call follows, and the order of the calls for each service. ## Sources to Give the Agent - **OpenAPI 3.0:** `https://api.aureahub.com/v1/docs/json` — every public route with its parameters, bodies, answers and status codes. It is the reference when a page and the spec disagree. - **The docs as Markdown:** `https://docs.aureahub.com/llms.txt` (index), `https://docs.aureahub.com/llms-full.txt` (every page in one file), and `https://docs.aureahub.com/docs/.md` for one page. ## Rules Every Call Follows - **Base URL** `https://api.aureahub.com`; JSON in and out. - **Two ways to authenticate.** User calls send `Authorization: Bearer `. Calls from the tenant's server are signed with the tenant's API key and secret (three headers, see [Authentication](https://docs.aureahub.com/docs/authentication.md)). The API secret stays on the server. - **Environments.** `isTestnet: true` (in the body, or the query of a GET) selects the sandbox; omitted means production. - **Errors** answer `{ statusCode, error, message, details }`. Branch on `details.code`, never on `message` (see [Error Handling](https://docs.aureahub.com/docs/errors.md)). - **Idempotency.** Routes that create money movements accept `Idempotency-Key`; retry with the same key and the same body (see [Idempotency](https://docs.aureahub.com/docs/idempotency.md)). - **Amounts** are decimal strings, never floats (see [Amount Units](https://docs.aureahub.com/docs/amounts.md)). - **Statuses only move forward**; treat an unknown status as not final. - **Events** reach the tenant's server as signed `POST`s; verify the signature before trusting one (see [Events to Your Server](https://docs.aureahub.com/docs/guide-events.md)). ## Recipes - **Users and wallets:** [User Onboarding](https://docs.aureahub.com/docs/guide-user-onboarding.md), then [Sending a Transaction](https://docs.aureahub.com/docs/guide-send-transaction.md). - **Bank ramp** (a virtual account in the user's name, deposits to crypto, crypto to a bank account): [Bank Pay-In](https://docs.aureahub.com/docs/guide-fiat-payin.md) and [Bank Payout](https://docs.aureahub.com/docs/guide-fiat-payout.md). - **Card onramp for your app's users:** [Card to Crypto](https://docs.aureahub.com/docs/guide-card-onramp.md). - **Card onramp for wallets you already hold:** [For Your Own Wallets](https://docs.aureahub.com/docs/guide-card-onramp-wallets.md) — three server calls, no user account. ## A Prompt to Start From Give the agent the two sources above and a brief like this one, adapted to your stack: ``` Integrate the Aurea card onramp for wallets we already hold. Read https://docs.aureahub.com/llms-full.txt and the OpenAPI spec at https://api.aureahub.com/v1/docs/json. Sign server calls as the Authentication page describes, keep the API secret in server config, send the end user's IP as customerIp, use our order id as the Idempotency-Key, branch on details.code, verify event signatures, and test everything with isTestnet: true first. ``` Web version: https://docs.aureahub.com/#build-with-ai --- # Agentic Payments AI agents that transact within policy. Two products on one API: a shopper-facing commerce **widget** (Product A) you embed in a store, and an in-account **treasury agent** (Product B) that pays a merchant's own suppliers and payouts on instruction — every payment bounded by a spend policy and an approval threshold. ## Overview **Product A — Shopper widget.** An embeddable conversational commerce assistant. A shopper chats with the agent, builds a cart from your ingested catalogue, and pays — by stablecoin/on-chain rail or by card (a hosted card checkout, so shoppers without an Aurea account can pay too). **Product B — Treasury agent.** An in-account agent that moves a merchant's own money (supplier payments, payouts, subscriptions) on instruction. Every payment runs through the **PolicyGuard**: per-transaction, daily, weekly, monthly and velocity limits, recipient allow/deny lists, and an approval threshold above which a payment is parked for manual sign-off. Each agent is isolated — its own id, API keys, spend policy, mandates and executions. Policy checks run server-side; reservations are held on a spend ledger and only settled once the rail confirms. > ℹ️ The module is gated by a per-deployment feature flag and a per-tenant flag. When it is off for your deployment or tenant, every route returns `404`. Ask your Aurea contact to enable it before integrating. ## Base URL All Agent Payments endpoints live under a single prefix: ```bash https://api.aureahub.com/v1/agent-payments ``` ## Authentication The module uses two credential types depending on who is calling. ### Tenant-admin JWT (management) Almost every route — creating agents, policies, mandates, keys, ingesting catalogues, executions, analytics — requires a **tenant-admin** JWT bearer token. Access tokens are short-lived (~15 minutes); use your refresh token to renew them. See [Authentication](https://docs.aureahub.com/docs/authentication.md) for the token lifecycle. ```bash curl https://api.aureahub.com/v1/agent-payments/agents \ -H "Authorization: Bearer YOUR_TENANT_ADMIN_JWT" ``` A platform `admin` (super-admin, tenant-independent) may act on a specific tenant by adding `?tenantId=` to the request. A `tenantadmin` is always scoped to their own tenant and any `?tenantId=` they pass is ignored. ```bash # platform admin acting on a specific tenant curl "https://api.aureahub.com/v1/agent-payments/agents?tenantId=TENANT_UUID" \ -H "Authorization: Bearer YOUR_ADMIN_JWT" ``` ### Scoped merchant API keys (shopper-facing) The shopper-facing commerce routes — `/commerce/products`, `/commerce/checkout`, `/commerce/checkout/:id`, `/commerce/checkout/:id/card` and `/chat` — accept **either** a scoped merchant API key **or** a tenant-admin JWT. There are two key types: - **Publishable** — `apk_live_…`. Public-safe: ship it in the browser/widget. It is *origin-restricted* (only the allowed origins you configure may use it) and *rate-limited*. The key pins the tenant and agent, so the request body cannot target another merchant. - **Secret** — `ask_live_…`. Server-side only; never expose it in a browser. Returned exactly once at creation. Pass a key in any of these headers: `X-Aurea-Key`, `X-Merchant-Key`, or `Authorization: Bearer apk_live_…`. The only unauthenticated route in the module is the payment provider's callback that settles card checkouts. Aurea verifies its signature against the raw request body; you never call it. ## SDK Typed TypeScript SDK on npm — it ships both the embeddable widget and the management client: ```bash npm install @aureahub/agent-pay ``` ## Embed the widget (Product A) Drop the assistant into any storefront. The publishable key (`apk_live_…`) is public-safe — origin-restricted and rate-limited — so it is fine to ship in the browser. ```html
``` ## Ingest your catalogue The agent answers shoppers from your product catalogue. Ingestion is an **explicit** call — it is never automatic. Crawl your store once, and re-run whenever the catalogue changes. The crawler detects a JSON product feed, a Shopify products endpoint, or JSON-LD product markup. ```js const mgmt = AureaAgentManage.createManagementClient({ baseUrl: "https://api.aureahub.com", token // tenant-admin bearer token }); // Crawl + ingest a storefront URL (explicit, not automatic): await mgmt.ingestUrl(agentId, "https://your-store.example"); ``` You can also push a structured product feed directly instead of crawling — see `POST /commerce/feed` in the API reference. ## API Reference Every endpoint — agents, API keys, policies, mandates, executions, approvals, commerce, refunds & disputes, webhooks, analytics, KYA, MCP, scheduler and tenant settings — is documented field-by-field in the [Agentic Payments — API Reference](https://docs.aureahub.com/docs/agentic-api.md). Web version: https://docs.aureahub.com/#agentic-payments --- # Agentic Payments — API Reference All 54 endpoints under `https://api.aureahub.com/v1/agent-payments`. Unless noted, every route requires a tenant-admin JWT (a platform `admin` may add `?tenantId=` to act on a tenant). The shopper-facing commerce routes also accept a scoped merchant API key; the only unauthenticated route is the payment provider's own callback, which you never call. > ℹ️ Monetary amounts are decimal strings (e.g. `"49.90"`). The card refund route (`POST /commerce/checkout/:id/refund`) uses `amountMinor` (an integer in the smallest currency unit). All `:id` path parameters are UUIDs. ## Agents ### `POST /v1/agent-payments/agents` Authentication: bearer token required. Create an agent identity (merchant, spending, machine, or external) scoped to the caller's tenant. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | Display name (1–255 chars) | | `description` | string | no | Up to 2000 chars | | `agentType` | enum | no | internal \| merchant \| external \| machine | | `did` | string | no | Decentralised identifier (up to 255 chars) | | `publicKey` | string | no | Agent public key (for signed mandates) | | `userId` | uuid | no | Link the agent to a specific user | | `metadata` | object | no | Arbitrary JSON metadata | | `isTestnet` | boolean | no | Mark the agent as testnet-only | **Responses** `201` Created ```json { "id": "11111111-1111-1111-1111-111111111111", "tenantId": "22222222-2222-2222-2222-222222222222", "userId": null, "did": null, "name": "Acme Storefront Agent", "description": null, "agentType": "merchant", "status": "active", "publicKey": null, "verifiedAgentId": null, "metadata": {}, "isTestnet": false, "createdAt": "2026-06-24T10:00:00.000Z", "updatedAt": "2026-06-24T10:00:00.000Z" } ``` `422` Validation Error ```json { "detail": "Validation failed", "errors": [ { "path": ["name"], "message": "Required" } ] } ``` `404` Module Disabled ```json { "detail": "Agent payments module is not enabled for this deployment" } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/agent-payments/agents \ -H "Authorization: Bearer YOUR_TENANT_ADMIN_JWT" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Storefront Agent", "agentType": "merchant" }' ``` ### `GET /v1/agent-payments/agents` Authentication: bearer token required. List agents for the caller's tenant, optionally filtered by status with pagination. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | integer | no | 1–200 | | `offset` | integer | no | Zero-based offset | | `status` | enum | no | pending \| active \| suspended \| revoked | **Responses** `200` OK ```json { "data": [ { "id": "11111111-1111-1111-1111-111111111111", "name": "Acme Storefront Agent", "agentType": "merchant", "status": "active", "isTestnet": false, "createdAt": "2026-06-24T10:00:00.000Z" } ] } ``` **Example request** ```bash curl "https://api.aureahub.com/v1/agent-payments/agents?status=active&limit=50" \ -H "Authorization: Bearer YOUR_TENANT_ADMIN_JWT" ``` ### `GET /v1/agent-payments/agents/:id` Authentication: bearer token required. Fetch a single agent by id within the caller's tenant. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | uuid | yes | Agent id | **Responses** `200` OK ```json { "id": "11111111-1111-1111-1111-111111111111", "name": "Acme Storefront Agent", "agentType": "merchant", "status": "active", "metadata": {}, "createdAt": "2026-06-24T10:00:00.000Z" } ``` `404` Not Found ```json { "detail": "Agent not found" } ``` ### `DELETE /v1/agent-payments/agents/:id` Authentication: bearer token required. Soft-delete an agent: revoke its active API keys, then mark it revoked. Execution/audit history is preserved. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | uuid | yes | Agent id | **Responses** `200` OK ```json { "id": "11111111-1111-1111-1111-111111111111", "name": "Acme Storefront Agent", "status": "revoked", "updatedAt": "2026-06-24T11:00:00.000Z" } ``` `404` Not Found ```json { "detail": "Agent not found" } ``` ### `POST /v1/agent-payments/agents/provision-merchant` Authentication: bearer token required. One-call merchant provisioning (for integrations like Aurea Pay): creates a merchant agent plus a publishable key. The publishable key is returned in full exactly once. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | Merchant display name (1–255 chars) | | `externalRef` | string | no | Your own reference id, stored in agent metadata | | `allowedOrigins` | string[] | no | Origins permitted to use the publishable key (max 20) | | `label` | string | no | Label for the issued key (defaults to "widget") | **Responses** `201` Created ```json { "agent": { "id": "11111111-1111-1111-1111-111111111111", "name": "Acme", "agentType": "merchant", "status": "active" }, "publishableKey": { "id": "33333333-3333-3333-3333-333333333333", "agentId": "11111111-1111-1111-1111-111111111111", "keyType": "publishable", "key": "apk_live_9f8e7d6c5b4a...", "publicKey": "apk_live_9f8e7d6c5b4a...", "last4": "4a3b", "allowedOrigins": ["https://acme.example"], "label": "widget", "status": "active", "createdAt": "2026-06-24T10:00:00.000Z" } } ``` ## API Keys ### `POST /v1/agent-payments/agents/:id/api-keys` Authentication: bearer token required. Issue a scoped API key for an agent. The full key is returned exactly once; only a last4 is stored thereafter. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | uuid | yes | Agent id | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `keyType` | enum | yes | publishable \| secret | | `label` | string | no | Up to 120 chars | | `allowedOrigins` | string[] | no | Origins permitted (publishable keys; max 20) | **Responses** `201` Created ```json { "id": "33333333-3333-3333-3333-333333333333", "agentId": "11111111-1111-1111-1111-111111111111", "keyType": "secret", "key": "ask_live_a1b2c3d4e5f6...", "publicKey": null, "last4": "e5f6", "allowedOrigins": [], "label": "server", "status": "active", "createdAt": "2026-06-24T10:00:00.000Z" } ``` ### `GET /v1/agent-payments/agents/:id/api-keys` Authentication: bearer token required. List API keys for an agent. Secret keys never expose the full key — only last4. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | uuid | yes | Agent id | **Responses** `200` OK ```json { "data": [ { "id": "33333333-3333-3333-3333-333333333333", "agentId": "11111111-1111-1111-1111-111111111111", "keyType": "publishable", "publicKey": "apk_live_9f8e7d6c...", "last4": "4a3b", "allowedOrigins": ["https://acme.example"], "label": "widget", "status": "active", "createdAt": "2026-06-24T10:00:00.000Z" } ] } ``` ### `POST /v1/agent-payments/api-keys/:id/rotate` Authentication: bearer token required. Rotate an API key: issue a new secret with the same scope and retire the old one. The new full key is returned exactly once. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | uuid | yes | API key id | **Responses** `200` OK ```json { "id": "33333333-3333-3333-3333-333333333333", "agentId": "11111111-1111-1111-1111-111111111111", "keyType": "secret", "key": "ask_live_NEWKEY...", "publicKey": null, "last4": "9z8y", "status": "active", "createdAt": "2026-06-24T12:00:00.000Z" } ``` ### `POST /v1/agent-payments/api-keys/:id/revoke` Authentication: bearer token required. Revoke an API key immediately so it can no longer authenticate. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | uuid | yes | API key id | **Responses** `200` OK ```json { "id": "33333333-3333-3333-3333-333333333333", "status": "revoked" } ``` ### `PUT /v1/agent-payments/api-keys/:id/origins` Authentication: bearer token required. Replace the allowed-origins allow-list for a (publishable) API key. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | uuid | yes | API key id | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `allowedOrigins` | string[] | yes | Full replacement list of allowed origins (max 20) | **Responses** `200` OK ```json { "id": "33333333-3333-3333-3333-333333333333", "allowedOrigins": ["https://acme.example", "https://shop.acme.example"] } ``` ## Policies ### `POST /v1/agent-payments/policies` Authentication: bearer token required. Create a spending policy (limits, allow/deny lists, optional validity window) used by the PolicyGuard to authorize agent payments. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `agentId` | uuid | yes | Agent this policy governs | | `name` | string | yes | 1–255 chars | | `currency` | string | no | Limit currency (up to 20 chars) | | `perTxMax` | decimal | no | Max per-transaction amount (decimal string) | | `dailyMax` | decimal | no | Max per rolling day | | `weeklyMax` | decimal | no | Max per rolling week | | `monthlyMax` | decimal | no | Max per rolling month | | `velocityMaxCount` | integer | no | Max transactions per velocity window | | `velocityWindowSeconds` | integer | no | Velocity window length, seconds | | `allowedAssets` | string[] | no | Permitted assets | | `allowedChains` | string[] | no | Permitted chains | | `recipientAllowlist` | string[] | no | Only these recipients may be paid | | `recipientBlocklist` | string[] | no | These recipients are denied | | `stepUpThreshold` | decimal | no | Amount above which step-up is required | | `requiresApproval` | boolean | no | Force manual approval for every payment | | `notBefore` | datetime | no | ISO 8601; policy inactive before this | | `expiresAt` | datetime | no | ISO 8601; policy inactive after this | | `metadata` | object | no | Arbitrary JSON metadata | **Responses** `201` Created ```json { "id": "44444444-4444-4444-4444-444444444444", "agentId": "11111111-1111-1111-1111-111111111111", "name": "Default storefront policy", "status": "active", "currency": "EUR", "perTxMax": "250.00", "dailyMax": "1000.00", "weeklyMax": null, "monthlyMax": null, "velocityMaxCount": null, "velocityWindowSeconds": null, "allowedAssets": [], "allowedChains": [], "recipientAllowlist": [], "recipientBlocklist": [], "stepUpThreshold": "200.00", "requiresApproval": false, "notBefore": null, "expiresAt": null, "metadata": {}, "createdAt": "2026-06-24T10:00:00.000Z", "updatedAt": "2026-06-24T10:00:00.000Z" } ``` ## Mandates ### `POST /v1/agent-payments/mandates` Authentication: bearer token required. Create a signed agent mandate — the user-authorized spending grant an agent acts under (intent, cart, or recurring). **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `agentId` | uuid | yes | Agent the mandate is for | | `mandateType` | enum | yes | intent \| cart \| recurring | | `userId` | uuid | no | Authorizing user | | `walletId` | uuid | no | Source wallet | | `policyId` | uuid | no | Policy that bounds this mandate | | `parentMandateId` | uuid | no | Parent mandate (for sub-mandates) | | `description` | string | no | Up to 2000 chars | | `maxAmount` | decimal | no | Maximum spend under the mandate | | `currency` | string | no | Up to 20 chars | | `asset` | string | no | Up to 100 chars | | `chain` | string | no | Up to 50 chars | | `merchant` | string | no | Bound merchant (up to 255 chars) | | `cart` | object | no | Cart snapshot (cart mandates) | | `constraints` | object | no | Additional JSON constraints | | `recurrenceRule` | string | no | Recurrence rule (up to 255 chars) | | `recurrenceIntervalSeconds` | integer | no | Interval between runs, seconds | | `nextRunAt` | datetime | no | ISO 8601; first/next run time | | `maxRuns` | integer | no | Cap on total runs | | `notBefore` | datetime | no | ISO 8601 validity start | | `expiresAt` | datetime | no | ISO 8601 validity end | | `signature` | string | no | Detached signature over the mandate hash | | `signedBy` | enum | no | user \| agent | **Responses** `201` Created ```json { "id": "55555555-5555-5555-5555-555555555555", "agentId": "11111111-1111-1111-1111-111111111111", "mandateType": "recurring", "status": "active", "maxAmount": "100.00", "currency": "EUR", "recurrenceIntervalSeconds": 2592000, "nextRunAt": "2026-07-24T09:00:00.000Z", "runCount": 0, "maxRuns": 12, "signedBy": "user", "createdAt": "2026-06-24T10:00:00.000Z" } ``` ### `GET /v1/agent-payments/mandates/:id` Authentication: bearer token required. Fetch a single mandate by id within the caller's tenant. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | uuid | yes | Mandate id | **Responses** `200` OK ```json { "id": "55555555-5555-5555-5555-555555555555", "agentId": "11111111-1111-1111-1111-111111111111", "mandateType": "cart", "status": "active", "maxAmount": "49.90", "currency": "EUR", "createdAt": "2026-06-24T10:00:00.000Z" } ``` `404` Not Found ```json { "detail": "Mandate not found" } ``` ### `POST /v1/agent-payments/mandates/:id/revoke` Authentication: bearer token required. Revoke a mandate so the agent can no longer act under it. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | uuid | yes | Mandate id | **Responses** `200` OK ```json { "id": "55555555-5555-5555-5555-555555555555", "status": "revoked", "updatedAt": "2026-06-24T11:00:00.000Z" } ``` ### `POST /v1/agent-payments/mandates/:id/verify` Authentication: bearer token required. Verify a mandate's validity (signature / status / expiry). Read-only — moves no money. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | uuid | yes | Mandate id | **Responses** `200` Valid ```json { "valid": true } ``` `200` Invalid ```json { "valid": false, "reason": "Mandate expired" } ``` ## Authorize & Executions ### `POST /v1/agent-payments/authorize` Authentication: bearer token required. Preview a policy decision: evaluate a proposed payment against the PolicyGuard and return allow / deny / step-up / approval-required with its trace. Moves no money. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `agentId` | uuid | yes | Agent making the payment | | `mandateId` | uuid | no | Mandate the payment is under | | `policyId` | uuid | no | Policy to evaluate against | | `walletId` | uuid | no | Source wallet | | `amount` | decimal | no | Proposed amount | | `currency` | string | no | Up to 20 chars | | `asset` | string | no | Up to 100 chars | | `chain` | string | no | Up to 50 chars | | `recipient` | string | no | Proposed recipient | | `kind` | string | no | Payment kind (up to 50 chars) | | `idempotencyKey` | string | no | Up to 255 chars | **Responses** `200` Allow ```json { "allow": true, "reason": "within policy", "requiresStepUp": false, "requiresApproval": false, "riskScore": 12, "trace": { "reservationId": "66666666-6666-6666-6666-666666666666" } } ``` `200` Deny ```json { "allow": false, "reason": "perTxMax exceeded", "riskScore": 80 } ``` ### `POST /v1/agent-payments/executions` Authentication: bearer token required. Execute an agent payment through the policy gate: authorize (reserving on the spend ledger), then move money on the chosen rail. Idempotent by key. Decisions needing step-up/approval are parked as approval_required. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `agentId` | uuid | yes | Agent making the payment | | `mandateId` | uuid | no | Mandate the payment is under | | `policyId` | uuid | no | Policy to evaluate against | | `walletId` | uuid | no | Source wallet | | `amount` | decimal | yes | Amount (decimal string) | | `currency` | string | yes | 1–20 chars | | `asset` | string | no | Up to 100 chars | | `chain` | string | no | Up to 50 chars | | `recipient` | string | yes | Recipient (1–255 chars) | | `kind` | enum | no | payment \| payout \| swap \| redeem \| x402 \| refund | | `idempotencyKey` | string | no | Up to 255 chars | **Responses** `201` Settled ```json { "id": "77777777-7777-7777-7777-777777777777", "agentId": "11111111-1111-1111-1111-111111111111", "kind": "payment", "rail": "onchain", "status": "settled", "amount": "49.90", "currency": "EUR", "recipient": "0xRecipient...", "policyDecision": { "allow": true, "reason": "within policy" }, "riskScore": 12, "idempotencyKey": "order-1001", "createdAt": "2026-06-24T10:00:00.000Z" } ``` `201` Approval Required ```json { "id": "77777777-7777-7777-7777-777777777777", "status": "approval_required", "amount": "500.00", "currency": "EUR", "policyDecision": { "allow": false, "requiresApproval": true, "reason": "above approval threshold" } } ``` ### `POST /v1/agent-payments/executions/:id/approve` Authentication: bearer token required. Approve an execution parked as approval_required, releasing it to complete on the rail (the held reservation is settled). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | uuid | yes | Execution id | **Responses** `200` OK ```json { "id": "77777777-7777-7777-7777-777777777777", "status": "settled", "amount": "500.00", "currency": "EUR", "updatedAt": "2026-06-24T11:00:00.000Z" } ``` ## Approvals (agent-requests) ### `GET /v1/agent-payments/agent-requests` Authentication: bearer token required. List pending deferred-approval requests (executions awaiting a human decision) for the tenant. **Responses** `200` OK ```json { "data": [ { "id": "77777777-7777-7777-7777-777777777777", "status": "pending", "display": { "title": "Approve payment of EUR 500.00", "sections": [ { "label": "Recipient", "value": "0xRecipient..." } ] }, "result": null, "amount": "500.00", "currency": "EUR", "recipient": "0xRecipient...", "agentId": "11111111-1111-1111-1111-111111111111", "createdAt": "2026-06-24T10:00:00.000Z", "expiresAt": "2026-06-25T10:00:00.000Z" } ] } ``` ### `GET /v1/agent-payments/agent-requests/:id` Authentication: bearer token required. Fetch a single deferred-approval request by id. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | uuid | yes | Agent request (execution) id | **Responses** `200` OK ```json { "id": "77777777-7777-7777-7777-777777777777", "status": "pending", "display": { "title": "Approve payment of EUR 500.00", "sections": [] }, "amount": "500.00", "currency": "EUR", "agentId": "11111111-1111-1111-1111-111111111111", "createdAt": "2026-06-24T10:00:00.000Z" } ``` `404` Not Found ```json { "error": "not found" } ``` ### `POST /v1/agent-payments/agent-requests/:id/approve` Authentication: bearer token required. Approve a deferred-approval request, releasing the underlying execution to complete on the rail. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | uuid | yes | Agent request (execution) id | **Responses** `200` OK ```json { "id": "77777777-7777-7777-7777-777777777777", "status": "settled", "updatedAt": "2026-06-24T11:00:00.000Z" } ``` ### `POST /v1/agent-payments/agent-requests/:id/reject` Authentication: bearer token required. Reject a deferred-approval request with an optional reason; the underlying execution is declined and its held reservation released. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | uuid | yes | Agent request (execution) id | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `reason` | string | no | Optional rejection reason (up to 500 chars) | **Responses** `200` OK ```json { "id": "77777777-7777-7777-7777-777777777777", "status": "denied", "denialReason": "out of budget", "updatedAt": "2026-06-24T11:00:00.000Z" } ``` ## Commerce — Catalog & Ingest ### `POST /v1/agent-payments/commerce/feed` Authentication: bearer token required. Ingest a structured product feed for a merchant agent (1–1000 items). Returns the number of products ingested. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `agentId` | uuid | yes | Merchant agent | | `items` | object[] | yes | 1–1000 product items (see fields below) | | `items[].sku` | string | yes | 1–255 chars | | `items[].name` | string | yes | 1–255 chars | | `items[].price` | decimal | yes | Decimal string | | `items[].currency` | string | yes | 1–20 chars | | `items[].description` | string | no | Up to 2000 chars | | `items[].imageUrl` | url | no | Product image URL | | `items[].metadata` | object | no | Arbitrary JSON metadata | | `items[].active` | boolean | no | Whether the item is purchasable | **Responses** `200` OK ```json { "ingested": 42 } ``` ### `POST /v1/agent-payments/commerce/ingest-url` Authentication: bearer token required. Crawl a merchant storefront URL and ingest its catalogue (detects a JSON feed, Shopify products, or JSON-LD). Explicit, not automatic. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `agentId` | uuid | yes | Merchant agent | | `url` | url | yes | Storefront URL to crawl (up to 2048 chars) | **Responses** `200` OK ```json { "ingested": 42, "source": "shopify" } ``` ### `GET /v1/agent-payments/commerce/products` Authentication: bearer token required. List a merchant agent's ingested products. Accepts a scoped merchant API key (the key pins the agent) OR a tenant-admin JWT. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `agentId` | uuid | yes | Merchant agent (ignored when a publishable key pins the agent) | **Responses** `200` OK ```json { "data": [ { "id": "88888888-8888-8888-8888-888888888888", "agentId": "11111111-1111-1111-1111-111111111111", "sku": "TEE-001", "name": "Cotton Tee", "description": "100% organic cotton", "price": "24.90", "currency": "EUR", "imageUrl": "https://acme.example/img/tee.jpg", "active": true } ] } ``` **Example request** ```bash curl "https://api.aureahub.com/v1/agent-payments/commerce/products?agentId=AGENT_UUID" \ -H "X-Aurea-Key: apk_live_..." ``` ## Commerce — Checkout ### `POST /v1/agent-payments/commerce/checkout` Authentication: bearer token required. Create a checkout session (locked as a cart mandate) from cart items. Accepts a scoped merchant API key OR a tenant-admin JWT. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `agentId` | uuid | yes | Merchant agent (ignored when a publishable key pins the agent) | | `items` | object[] | yes | Cart line items (min 1) | | `items[].sku` | string | yes | 1–255 chars | | `items[].quantity` | integer | yes | Positive integer | | `currency` | string | no | Up to 20 chars | **Responses** `201` Created ```json { "checkoutId": "99999999-9999-9999-9999-999999999999", "status": "active", "currency": "EUR", "total": "49.80", "items": [ { "sku": "TEE-001", "name": "Cotton Tee", "quantity": 2, "unitPrice": "24.90", "lineTotal": "49.80" } ], "agentId": "11111111-1111-1111-1111-111111111111" } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/agent-payments/commerce/checkout \ -H "X-Aurea-Key: apk_live_..." \ -H "Content-Type: application/json" \ -d '{ "agentId": "AGENT_UUID", "items": [ { "sku": "TEE-001", "quantity": 2 } ] }' ``` ### `GET /v1/agent-payments/commerce/checkout/:id` Authentication: bearer token required. Fetch a checkout session by id. Accepts a scoped merchant API key OR a tenant-admin JWT. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | uuid | yes | Checkout id (== cart mandate id) | **Responses** `200` OK ```json { "checkoutId": "99999999-9999-9999-9999-999999999999", "status": "active", "currency": "EUR", "total": "49.80", "items": [ { "sku": "TEE-001", "name": "Cotton Tee", "quantity": 2, "unitPrice": "24.90", "lineTotal": "49.80" } ], "agentId": "11111111-1111-1111-1111-111111111111" } ``` ### `POST /v1/agent-payments/commerce/checkout/:id/complete` Authentication: bearer token required. Complete a checkout: execute the cart-mandate payment through the policy gate and record split settlement legs (merchant / platform / tax / fee). Tenant-admin JWT only. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | uuid | yes | Checkout id | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `recipient` | string | yes | Primary payout recipient (1–255 chars) | | `walletId` | uuid | no | Source wallet | | `splits` | object[] | no | Optional split settlement legs | | `splits[].partyType` | enum | no | merchant \| platform \| tax \| fee \| other | | `splits[].recipient` | string | no | Leg recipient (1–255 chars) | | `splits[].amount` | decimal | no | Leg amount (decimal string) | **Responses** `200` OK ```json { "checkoutId": "99999999-9999-9999-9999-999999999999", "execution": { "id": "77777777-7777-7777-7777-777777777777", "kind": "payment", "status": "settled", "amount": "49.80", "currency": "EUR" }, "legs": [ { "id": "leg-1", "executionId": "77777777-7777-7777-7777-777777777777", "legIndex": 0, "partyType": "merchant", "recipient": "0xMerchant...", "amount": "45.00", "currency": "EUR", "status": "settled" }, { "id": "leg-2", "executionId": "77777777-7777-7777-7777-777777777777", "legIndex": 1, "partyType": "platform", "recipient": "0xPlatform...", "amount": "4.80", "currency": "EUR", "status": "settled" } ] } ``` ### `POST /v1/agent-payments/commerce/checkout/:id/card` Authentication: bearer token required. Create a hosted card checkout for a cart mandate. The shopper (who need not have an Aurea account) is redirected to the returned url to pay by card. Accepts a scoped merchant API key OR a tenant-admin JWT. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | uuid | yes | Checkout id | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `successUrl` | url | yes | Redirect on success (up to 2048 chars) | | `cancelUrl` | url | yes | Redirect on cancel (up to 2048 chars) | **Responses** `201` Created ```json { "url": "https://…/c/pay/cs_live_...", "sessionId": "cs_live_a1b2c3..." } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/agent-payments/commerce/checkout/CHECKOUT_ID/card \ -H "X-Aurea-Key: apk_live_..." \ -H "Content-Type: application/json" \ -d '{ "successUrl": "https://acme.example/ok", "cancelUrl": "https://acme.example/cancel" }' ``` ## Commerce — Chat ### `POST /v1/agent-payments/chat` Authentication: bearer token required. Conversational shopping agent (Product A): send a shopper message and current cart, get a reply plus the resolved cart and an optional checkout handle. Accepts a scoped merchant API key (which pins the agent) OR a tenant-admin JWT. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `agentId` | uuid | no | Required unless a publishable key already pins the agent | | `message` | string | yes | Shopper message (1–2000 chars) | | `history` | object[] | no | Prior turns (max 20) | | `history[].role` | enum | no | user \| assistant | | `history[].content` | string | no | Up to 4000 chars | | `cart` | object[] | no | Current cart (max 50 items) | | `cart[].sku` | string | no | 1–255 chars | | `cart[].qty` | integer | no | Positive integer | **Responses** `200` OK ```json { "reply": "Added 1x Cotton Tee. Ready to checkout?", "cart": { "items": [ { "sku": "TEE-001", "name": "Cotton Tee", "qty": 1, "unitPrice": "24.90" } ], "total": "24.90", "currency": "EUR" }, "checkout": { "checkoutId": "99999999-9999-9999-9999-999999999999", "total": "24.90", "currency": "EUR" }, "llm": true } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/agent-payments/chat \ -H "X-Aurea-Key: apk_live_..." \ -H "Content-Type: application/json" \ -d '{ "message": "I want a cotton tee" }' ``` ## Refunds & Disputes ### `POST /v1/agent-payments/commerce/checkout/:id/refund` Authentication: bearer token required. Refund a settled card checkout (full or partial). Omit amountMinor for a full refund. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | uuid | yes | Checkout id | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `amountMinor` | integer | no | Refund amount in the smallest currency unit (e.g. cents). Omit for full refund. | **Responses** `200` OK ```json { "refundId": "re_a1b2c3...", "status": "succeeded", "executionId": "77777777-7777-7777-7777-777777777777", "amount": "24.90", "currency": "EUR" } ``` ### `GET /v1/agent-payments/commerce/disputes` Authentication: bearer token required. List recorded refunds and disputes/chargebacks for the tenant. **Responses** `200` OK ```json { "data": [ { "id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "tenantId": "22222222-2222-2222-2222-222222222222", "executionId": "77777777-7777-7777-7777-777777777777", "kind": "dispute", "provider": "stripe", "providerRef": "dp_a1b2c3...", "amount": "24.90", "currency": "EUR", "status": "needs_response", "reason": "fraudulent", "createdAt": "2026-06-24T10:00:00.000Z" } ] } ``` ## Authorization Webhook ### `GET /v1/agent-payments/authorization-webhook` Authentication: bearer token required. Get the tenant's real-time authorization webhook configuration (the external endpoint that can veto agent payments). **Responses** `200` OK ```json { "enabled": true, "url": "https://risk.acme.example/authorize", "fallback": "reject", "hasSecret": true } ``` ### `PUT /v1/agent-payments/authorization-webhook` Authentication: bearer token required. Create or update the tenant's real-time authorization webhook used as an external veto during payment authorization. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `enabled` | boolean | yes | Whether the external veto is active | | `url` | url | no | Webhook URL (nullable; up to 2048 chars) | | `secret` | string | no | Signing secret (nullable; up to 512 chars) | | `fallback` | enum | no | allow \| reject — decision when the webhook is unreachable | **Responses** `200` OK ```json { "enabled": true, "url": "https://risk.acme.example/authorize", "fallback": "reject", "hasSecret": true } ``` ## Outbound Webhooks ### `POST /v1/agent-payments/webhooks/endpoints` Authentication: bearer token required. Register an outbound webhook endpoint to receive agent-payments events. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `url` | url | yes | Destination URL | | `events` | string[] | no | Event types to subscribe to — agent.payment.settled \| agent.payment.denied \| agent.payment.rejected (each up to 100 chars). Omit for all events | | `agentId` | uuid | no | Restrict deliveries to one agent | | `secret` | string | no | Signing secret (8–255 chars) | **Responses** `201` Created ```json { "id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "tenantId": "22222222-2222-2222-2222-222222222222", "agentId": null, "url": "https://acme.example/hooks/aurea", "events": ["agent.payment.settled", "agent.payment.denied"], "status": "active", "createdAt": "2026-06-24T10:00:00.000Z", "updatedAt": "2026-06-24T10:00:00.000Z" } ``` ### `GET /v1/agent-payments/webhooks/endpoints` Authentication: bearer token required. List the tenant's registered outbound webhook endpoints. **Responses** `200` OK ```json { "data": [ { "id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", "agentId": null, "url": "https://acme.example/hooks/aurea", "events": ["agent.payment.settled"], "status": "active", "createdAt": "2026-06-24T10:00:00.000Z" } ] } ``` ### `POST /v1/agent-payments/webhooks/run` Authentication: bearer token required. Run one pass of the outbound webhook delivery worker (also wired as a cron). Returns delivery counters. **Responses** `200` OK ```json { "delivered": 3, "retried": 1, "dead": 0 } ``` ## Utilization & Analytics ### `GET /v1/agent-payments/agents/:id/utilization` Authentication: bearer token required. Get an agent's spend utilization against its policy limits (used vs remaining per window). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | uuid | yes | Agent id | **Responses** `200` OK ```json { "agentId": "11111111-1111-1111-1111-111111111111", "currency": "EUR", "hasPolicy": true, "windows": [ { "window": "daily", "limit": "1000.00", "used": "240.00", "remaining": "760.00" }, { "window": "velocity", "limit": 10, "used": 2, "remaining": 8 } ] } ``` ### `GET /v1/agent-payments/analytics/spend` Authentication: bearer token required. Aggregate settled and held spend per agent over an optional date range. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `agentId` | uuid | no | Restrict to one agent | | `from` | datetime | no | ISO 8601 range start | | `to` | datetime | no | ISO 8601 range end | **Responses** `200` OK ```json { "data": [ { "agentId": "11111111-1111-1111-1111-111111111111", "settled": "1240.00", "held": "0.00", "settledCount": 18 } ] } ``` ### `GET /v1/agent-payments/analytics/executions` Authentication: bearer token required. Execution counts and totals grouped by status for the tenant. **Responses** `200` OK ```json { "data": [ { "status": "settled", "count": 18, "amount": "1240.00" }, { "status": "approval_required", "count": 2, "amount": "1000.00" }, { "status": "denied", "count": 1, "amount": "0.00" } ] } ``` ## Delegated Credentials ### `POST /v1/agent-payments/credentials` Authentication: bearer token required. Issue a scoped, revocable delegated credential for an agent. The raw token is returned exactly once and cannot be retrieved again. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `agentId` | uuid | yes | Agent the credential is for | | `mandateId` | uuid | no | Mandate the credential is bound to | | `scope` | object | no | Arbitrary JSON scope restrictions | | `currency` | string | no | Up to 20 chars | | `maxAmount` | decimal | no | Total spend cap (decimal string) | | `maxUses` | integer | no | Max number of uses | | `expiresAt` | datetime | no | ISO 8601 expiry | **Responses** `201` Created ```json { "credential": { "id": "cccccccc-cccc-cccc-cccc-cccccccccccc", "agentId": "11111111-1111-1111-1111-111111111111", "tokenPrefix": "dcr_", "scope": {}, "currency": "EUR", "maxAmount": "100.00", "usedAmount": "0", "maxUses": 5, "useCount": 0, "status": "active", "expiresAt": "2026-12-31T23:59:59.000Z", "createdAt": "2026-06-24T10:00:00.000Z" }, "token": "dcr_live_RAWTOKEN_shown_once..." } ``` ### `GET /v1/agent-payments/credentials` Authentication: bearer token required. List delegated credentials, optionally filtered by agent. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `agentId` | uuid | no | Restrict to one agent | **Responses** `200` OK ```json { "data": [ { "id": "cccccccc-cccc-cccc-cccc-cccccccccccc", "agentId": "11111111-1111-1111-1111-111111111111", "tokenPrefix": "dcr_", "currency": "EUR", "maxAmount": "100.00", "usedAmount": "20.00", "maxUses": 5, "useCount": 1, "status": "active", "createdAt": "2026-06-24T10:00:00.000Z" } ] } ``` ### `POST /v1/agent-payments/credentials/:id/revoke` Authentication: bearer token required. Revoke a delegated credential immediately. Optionally record a reason. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | uuid | yes | Credential id | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `reason` | string | no | Optional revocation reason (up to 500 chars) | **Responses** `200` OK ```json { "id": "cccccccc-cccc-cccc-cccc-cccccccccccc", "status": "revoked", "revokedReason": "rotated", "revokedAt": "2026-06-24T11:00:00.000Z" } ``` ## Know-Your-Agent ### `POST /v1/agent-payments/agents/:id/kya/verify` Authentication: bearer token required. Submit a Know-Your-Agent verification for an agent (attestation method + trust level). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | uuid | yes | Agent id | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `method` | enum | yes | self_signed \| jwt_attestation \| fido \| manual | | `attestation` | object | no | Method-specific attestation payload | | `trustLevel` | enum | no | none \| basic \| attested \| certified | **Responses** `200` OK ```json { "id": "dddddddd-dddd-dddd-dddd-dddddddddddd", "agentId": "11111111-1111-1111-1111-111111111111", "method": "jwt_attestation", "status": "verified", "trustLevel": "attested", "attestation": {}, "reputationScore": 0, "verifiedAt": "2026-06-24T10:00:00.000Z", "createdAt": "2026-06-24T10:00:00.000Z" } ``` ### `GET /v1/agent-payments/agents/:id/kya` Authentication: bearer token required. Get the current Know-Your-Agent record for an agent. Returns { status: "none" } when no record exists. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | uuid | yes | Agent id | **Responses** `200` Record ```json { "id": "dddddddd-dddd-dddd-dddd-dddddddddddd", "agentId": "11111111-1111-1111-1111-111111111111", "method": "jwt_attestation", "status": "verified", "trustLevel": "attested", "reputationScore": 0, "createdAt": "2026-06-24T10:00:00.000Z" } ``` `200` None ```json { "status": "none" } ``` ## MCP ### `GET /v1/agent-payments/mcp/tools` Authentication: bearer token required. List the Aurea MCP tool surface — agent-payments capabilities exposed as discoverable, schema-described LLM tools. **Responses** `200` OK ```json { "tools": [ { "name": "create_checkout", "description": "Create a checkout session from cart items.", "inputSchema": { "type": "object", "properties": { "agentId": { "type": "string" } } } } ] } ``` ### `GET /v1/agent-payments/mcp/tools/:name` Authentication: bearer token required. Get a single MCP tool descriptor by name. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | MCP tool name | **Responses** `200` OK ```json { "name": "create_checkout", "description": "Create a checkout session from cart items.", "inputSchema": { "type": "object", "properties": { "agentId": { "type": "string" } } } } ``` `404` Not Found ```json { "detail": "Unknown MCP tool" } ``` ### `POST /v1/agent-payments/mcp/call` Authentication: bearer token required. Invoke an MCP tool by name with a validated input payload. Runs in the caller's tenant + user context. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `tool` | string | yes | MCP tool name (1–100 chars) | | `input` | object | no | Tool input arguments | | `rsaPublicKey` | string | no | Optional RSA public key for tools that encrypt their result | **Responses** `200` OK ```json { "result": { "checkoutId": "99999999-9999-9999-9999-999999999999", "total": "24.90", "currency": "EUR" } } ``` ## Scheduler ### `POST /v1/agent-payments/recurring/run` Authentication: bearer token required. Manually trigger the recurring-mandate scheduler for the tenant (also runs as a cron). Fires due recurring/standing mandates as agent payments. Returns run counters. **Responses** `200` OK ```json { "fired": 2, "completed": 2, "skipped": 0 } ``` ## Tenant Settings ### `GET /v1/agent-payments/tenant-settings` Authentication: bearer token required. Get per-tenant agent-payments enablement. A tenant is live only when both the global feature flag and this per-tenant flag are on. **Responses** `200` OK ```json { "agentPaymentsEnabled": true } ``` ### `PATCH /v1/agent-payments/tenant-settings` Authentication: bearer token required. Enable or disable agent payments for the tenant. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `enabled` | boolean | yes | Turn agent payments on/off for the tenant | **Responses** `200` OK ```json { "agentPaymentsEnabled": false } ``` ## Status & Diagnostics ### `GET /v1/agent-payments/status` Authentication: bearer token required. Module + LLM configuration status (phase, feature-flag state, configured LLM provider, whether the LLM is configured). Read-only. **Responses** `200` OK ```json { "module": "agent-payments", "phase": 1, "enabled": true, "llmProvider": "azure-openai", "llmConfigured": true } ``` ### `POST /v1/agent-payments/llm/ping` Authentication: bearer token required. Probe the agent LLM brain. Returns the provider, whether it answered, and the reply (or an error). **Responses** `200` OK ```json { "provider": "azure-openai", "ok": true, "reply": "pong" } ``` `200` Failed ```json { "provider": "azure-openai", "ok": false, "error": "request timed out" } ``` Web version: https://docs.aureahub.com/#agentic-api --- # Health Check Lightweight liveness probe for the Aurea API — use to verify the service is reachable before starting a flow. ## Overview This endpoint is public, unauthenticated, and intentionally cheap. It returns a small JSON payload with the current status and process uptime. Use it from client apps on startup, from load balancers, and from uptime-monitoring systems. Do not treat this as a deep health check — a `200 OK` here means the API process is up and the router is serving, not that every downstream provider (the bank ramp, Monerium, RPCs) is reachable. ### `GET /health` Authentication: none (public endpoint). Returns a 200 OK if the API process is running. **Responses** `200` OK ```json { "status": "ok", "uptime_seconds": 12345 } ``` ## Implementation ```typescript export async function isApiReachable(): Promise { try { const res = await fetch('https://api.aureahub.com/health', { method: 'GET' }); if (!res.ok) return false; const body = await res.json(); return body.status === 'ok'; } catch { return false; } } ``` Web version: https://docs.aureahub.com/#health-check --- # Register Create a user with username, email and password, and receive an access token, a refresh token and the user profile in the same response. A wallet can be created or imported in the same call. ## Overview A successful registration returns `201` with `accessToken`, `refreshToken`, `expiresIn` and `user`, plus `wallet` when one was created or imported. The user is already signed in, so no separate [Login](https://docs.aureahub.com/docs/auth-login.md) call is needed. Renew the access token with [Refresh Token](https://docs.aureahub.com/docs/auth-refresh.md). - **Tenant.** The user is created in the tenant identified by the `x-tenant-api-key` signing header. A `tenantApiKey` body field is accepted but does not change the tenant. - **Uniqueness.** Neither the username nor the email may already belong to an account in the same tenant, unless that account's status is `inactive`. Otherwise the call returns `409`. - **Password rules.** At least 8 characters, including a lowercase letter, an uppercase letter and a digit. A password shorter than 8 characters is rejected with `400`; a longer one missing a character type is rejected with `422`. > ⚠️ This endpoint requires **tenant request signing**: send `x-tenant-api-key`, `x-timestamp` and `x-signature` as described in [Authentication](https://docs.aureahub.com/docs/authentication.md). It does not take a bearer token, and it is rate-limited to **20 requests per minute per client IP**. ## Endpoint ### `POST /v1/auth/register` Creates a user in the signing tenant and returns an access token, a refresh token and the user (plus the wallet, if one was created or imported). Requires tenant HMAC signing headers. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `username` | string | yes | 3–100 characters: letters, digits, underscore or hyphen | | `email` | string | yes | A valid email address | | `password` | string | yes | At least 8 characters, including a lowercase letter, an uppercase letter and a digit | | `wallet` | object | no | Wallet to set up at registration. See Wallet Options. | | `wallet.option` | string | no | create, import or skip. Required when wallet is sent. | | `wallet.chain` | string | no | Chain identifier. Required for create and import. | | `wallet.privateKey` | string | no | 64 hexadecimal characters, with or without 0x. Required for import. | | `referralCode` | string | no | Referral code, up to 100 characters | | `termsAcceptedAt` | string | no | When the user accepted your terms: ISO 8601 date-time in UTC with a Z suffix, e.g. 2026-09-01T10:00:00Z | | `tenantApiKey` | string | no | Accepted (at least 32 characters) but not used: the tenant comes from the x-tenant-api-key header | **Responses** `201` Created ```json { "accessToken": "eyJhbGciOiJIUzI1NiIs...", "refreshToken": "eyJhbGciOiJIUzI1NiIs...", "expiresIn": 900, "user": { "id": "", "username": "alice", "email": "alice@example.com", "qrCode": "", "role": "user", "createdAt": "2026-09-01T10:00:00.000Z" } } ``` `400` Bad Request ```json { "error": "Bad Request", "message": "Request validation failed" } ``` `401` Unauthorized ```json { "statusCode": 401, "error": "UnauthorizedError", "message": "Invalid signature" } ``` `404` Unknown API Key ```json { "statusCode": 404, "error": "NotFoundError", "message": "Invalid API key" } ``` `409` Conflict ```json { "error": "ConflictError", "message": "Username already exists" } ``` `422` Validation Error ```json { "statusCode": 422, "error": "Validation Error", "message": "Request validation failed", "details": [ { "path": "password", "message": "Password must contain at least one uppercase letter" }, { "path": "password", "message": "Password must contain at least one digit" } ] } ``` `429` Too Many Requests ```json { "statusCode": 429, "error": "Error", "message": "Rate limit exceeded, retry in 1 minute" } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/auth/register \ -H "Content-Type: application/json" \ -H "x-tenant-api-key: " \ -H "x-timestamp: " \ -H "x-signature: " \ -d '{"username":"alice","email":"alice@example.com","password":"SecurePass123"}' ``` ## Wallet Options The optional `wallet` object controls whether a wallet is set up during registration: | wallet | Result | | --- | --- | | `(omitted)` | If the tenant's configuration sets a default registration chain, a wallet is created on that chain automatically, except on tenants that use MPC wallets. Otherwise no wallet is created. | | `{ "option": "create", "chain": "…" }` | Creates a wallet on that chain using the tenant's wallet mode (custodial unless the tenant is configured otherwise). On tenants that use MPC wallets, no wallet is created here: MPC wallets are created client-side through the MPC key-generation ceremony. | | `{ "option": "import", "chain": "…", "privateKey": "…" }` | Imports an existing private key as the user's wallet on that chain. The raw private key travels to the API in the request body. | | `{ "option": "skip" }` | No wallet is created, even if the tenant has a default registration chain. | When a wallet is created or imported, the response includes `wallet` with `id`, `chain`, `address`, `isPrimary` and `createdAt`. ## Errors On this endpoint, `400` and `409` bodies contain only `error` and `message`; `401`, `404`, `422` and `429` use the full envelope described in [Error Handling](https://docs.aureahub.com/docs/errors.md). | Status | message | When | | --- | --- | --- | | 400 | `Request validation failed` | Any of: a required field is missing; username is not 3–100 letters, digits, underscores or hyphens; email is not a valid address; password is shorter than 8 characters; wallet.option is not create, import or skip; wallet.privateKey is not 64 hex characters; referralCode is longer than 100 characters; termsAcceptedAt is not a date-time; tenantApiKey is shorter than 32 characters. No details are returned. | | 422 | `Request validation failed` | Any of: password is missing a lowercase letter, an uppercase letter or a digit; wallet is sent without option; option create without chain; option import without chain or privateKey; termsAcceptedAt has a UTC offset instead of Z. details lists each problem. | | 401 | `Missing required HMAC headers: x-tenant-api-key, x-timestamp, x-signature` | Signature problem. Other messages: Invalid x-timestamp header, Request expired, Invalid signature, Replay detected. See Authentication. | | 404 | `Invalid API key` | The x-tenant-api-key header does not match an active tenant. | | 409 | `Username already exists` | The username is taken in this tenant. | | 409 | `Email already exists` | The email is taken in this tenant. | | 429 | `Rate limit exceeded, retry in 1 minute` | More than 20 requests in one minute from the same client IP. See the retry-after header. | ## Implementation The tenant signature must cover the exact bytes you send, so build the body string once and use it for both the signature and the request: ```javascript import { createHash, createHmac } from 'node:crypto'; const API = 'https://api.aureahub.com'; // Tenant request signing: see Authentication → HMAC Request Signing function signedHeaders(method, pathWithQuery, body = '') { const timestamp = Math.floor(Date.now() / 1000).toString(); const bodyHash = createHash('sha256').update(body).digest('hex'); const signature = createHmac('sha256', process.env.AUREA_TENANT_API_SECRET) .update(`${method}\n${pathWithQuery}\n${timestamp}\n${bodyHash}`) .digest('hex'); return { 'x-tenant-api-key': process.env.AUREA_TENANT_API_KEY, 'x-timestamp': timestamp, 'x-signature': signature, }; } export async function register({ username, email, password }) { const path = '/v1/auth/register'; const body = JSON.stringify({ username, email, password }); const res = await fetch(API + path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...signedHeaders('POST', path, body) }, body, // exactly the string that was signed }); const data = await res.json(); if (res.status === 422 && Array.isArray(data.details)) { // e.g. "password: Password must contain at least one digit" throw new Error(data.details.map((d) => `${d.path}: ${d.message}`).join('; ')); } if (!res.ok) { // 409: "Username already exists" / "Email already exists" throw new Error(`${res.status}: ${data.message ?? data.error}`); } const { accessToken, refreshToken, expiresIn, user, wallet } = data; return { accessToken, refreshToken, expiresIn, user, wallet }; } ``` Web version: https://docs.aureahub.com/#auth-register --- # Login Sign in an existing user with username and password and receive an access token, a refresh token and the user profile. ## Overview The endpoint checks the username and password and returns `accessToken`, `refreshToken`, `expiresIn` and `user`. Send the access token as `Authorization: Bearer ` on protected routes, and exchange the refresh token at [Refresh Token](https://docs.aureahub.com/docs/auth-refresh.md) when the access token expires. Where the user is looked up: - If you send `tenantApiKey` in the body, only users of that tenant are searched. A key that does not match an active tenant returns `401` *Invalid tenant API key*. - Otherwise the username is matched against platform-admin accounts first, then against users of the tenant identified by the `x-tenant-api-key` signing header. The issued tokens are scoped to the user's own tenant. When you pass `createWallet`, the response can also contain `wallet` (`id`, `chain`, `address`, `isPrimary`, `createdAt`). Accounts created through [Google](https://docs.aureahub.com/docs/auth-google.md) or [Apple](https://docs.aureahub.com/docs/auth-apple.md) sign-in have no password and receive `401` *This account uses social sign-in*. > ⚠️ This endpoint requires **tenant request signing**: send `x-tenant-api-key`, `x-timestamp` and `x-signature` as described in [Authentication](https://docs.aureahub.com/docs/authentication.md). It does not take a bearer token, and it is rate-limited to **20 requests per minute per client IP**. ## Endpoint ### `POST /v1/auth/login` Checks username and password and returns an access token, a refresh token and the user. Requires tenant HMAC signing headers. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `username` | string | yes | 3–100 characters: letters, digits, underscore or hyphen | | `password` | string | yes | The account password | | `createWallet` | object | no | { chain }. If the user has no wallet on that chain, one is created (not on tenants that use MPC wallets). If they already have one, their primary wallet on that chain is returned in wallet. | | `tenantApiKey` | string | no | Search for the user in this tenant instead (at least 32 characters) | **Responses** `200` OK ```json { "accessToken": "eyJhbGciOiJIUzI1NiIs...", "refreshToken": "eyJhbGciOiJIUzI1NiIs...", "expiresIn": 900, "user": { "id": "", "username": "alice", "email": "alice@example.com", "qrCode": "", "role": "user", "createdAt": "2026-09-01T10:00:00.000Z" } } ``` `401` Unauthorized ```json { "error": "UnauthorizedError", "message": "Invalid credentials" } ``` `404` Unknown API Key ```json { "error": "NotFoundError", "message": "Invalid API key" } ``` `400` Bad Request ```json { "error": "Bad Request", "message": "Request validation failed" } ``` `422` Validation Error ```json { "statusCode": 422, "error": "Validation Error", "message": "Request validation failed", "details": [ { "path": "username", "message": "Username can only contain letters, numbers, underscores, and hyphens" } ] } ``` `429` Too Many Requests ```json { "statusCode": 429, "error": "Error", "message": "Rate limit exceeded, retry in 1 minute" } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/auth/login \ -H "Content-Type: application/json" \ -H "x-tenant-api-key: " \ -H "x-timestamp: " \ -H "x-signature: " \ -d '{"username":"alice","password":"SecurePass123"}' ``` ## Errors On this endpoint, `400`, `401` and `404` bodies contain only `error` and `message`; `422` and `429` use the full envelope described in [Error Handling](https://docs.aureahub.com/docs/errors.md). | Status | message | When | | --- | --- | --- | | 400 | `Request validation failed` | A required field is missing, username is shorter than 3 or longer than 100 characters, password is empty, or tenantApiKey is shorter than 32 characters. | | 401 | `Invalid credentials` | The username does not exist or the password is wrong. | | 401 | `This account uses social sign-in` | The account was created with Google or Apple and has no password. | | 401 | `Account is not active` | The password is correct but the account is not active. | | 401 | `Invalid tenant API key` | The tenantApiKey body field does not match an active tenant. | | 401 | `Missing required HMAC headers: x-tenant-api-key, x-timestamp, x-signature` | Signature problem. Other messages: Invalid x-timestamp header, Request expired, Invalid signature, Replay detected. See Authentication. | | 404 | `Invalid API key` | The x-tenant-api-key header does not match an active tenant. | | 422 | `Request validation failed` | username contains characters other than letters, digits, underscore or hyphen, or createWallet is sent without chain. details lists each problem. | | 429 | `Rate limit exceeded, retry in 1 minute` | More than 20 requests in one minute from the same client IP. See the retry-after header. | ## Implementation The tenant signature must cover the exact bytes you send, so build the body string once and use it for both the signature and the request: ```javascript import { createHash, createHmac } from 'node:crypto'; const API = 'https://api.aureahub.com'; // Tenant request signing: see Authentication → HMAC Request Signing function signedHeaders(method, pathWithQuery, body = '') { const timestamp = Math.floor(Date.now() / 1000).toString(); const bodyHash = createHash('sha256').update(body).digest('hex'); const signature = createHmac('sha256', process.env.AUREA_TENANT_API_SECRET) .update(`${method}\n${pathWithQuery}\n${timestamp}\n${bodyHash}`) .digest('hex'); return { 'x-tenant-api-key': process.env.AUREA_TENANT_API_KEY, 'x-timestamp': timestamp, 'x-signature': signature, }; } export async function login(username, password) { const path = '/v1/auth/login'; const body = JSON.stringify({ username, password }); const res = await fetch(API + path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...signedHeaders('POST', path, body) }, body, // exactly the string that was signed }); const data = await res.json(); if (!res.ok) { throw new Error(`${res.status}: ${data.message ?? data.error}`); } const { accessToken, refreshToken, expiresIn, user } = data; return { accessToken, refreshToken, expiresIn, user }; } ``` ## Staying Signed In `expiresIn` is returned as `900` seconds (15 minutes), which matches the default access-token lifetime. The lifetime itself is a server setting, so read the token's `exp` claim if you need the exact expiry. Refresh shortly before it passes, or refresh once when a protected call returns `401`. See [Refresh Token](https://docs.aureahub.com/docs/auth-refresh.md). ```javascript function scheduleRefresh(expiresInSeconds, refresh) { // refresh one minute before the access token expires const delayMs = Math.max(expiresInSeconds - 60, 0) * 1000; return setTimeout(refresh, delayMs); } ``` Web version: https://docs.aureahub.com/#auth-login --- # Refresh Token Exchange a refresh token for a new access token without asking the user to sign in again. ## Overview Send the `refreshToken` you received from [Login](https://docs.aureahub.com/docs/auth-login.md), [Register](https://docs.aureahub.com/docs/auth-register.md), or Google/Apple sign-in. The response contains a new `accessToken` and its `expiresIn` (`900` seconds). - **No signature, no bearer token.** This route checks neither the tenant HMAC headers nor an `Authorization` header. The refresh token in the body is the credential. - **Current claims.** The new access token's `role` and `tenantId` are read from the user's current record, so a role or tenant change takes effect at the next refresh. - **Lifetime.** Refresh tokens expire after 7 days by default. The lifetime is a server setting. - **Password changes.** A refresh token issued before the user's password was last changed is rejected. See [Forgot Password](https://docs.aureahub.com/docs/auth-forgot-password.md). - **Rate limit.** 60 requests per minute per client IP. ## Endpoint ### `POST /v1/auth/refresh` Returns a new access token for a valid refresh token. No tenant signature and no bearer token are needed. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `refreshToken` | string | yes | A refresh token previously issued by the API | **Responses** `200` OK ```json { "accessToken": "eyJhbGciOiJIUzI1NiIs...", "expiresIn": 900 } ``` `200` OK (rotation on) ```json { "accessToken": "eyJhbGciOiJIUzI1NiIs...", "expiresIn": 900, "refreshToken": "eyJhbGciOiJIUzI1NiIs..." } ``` `401` Unauthorized ```json { "error": "UnauthorizedError", "message": "Invalid or expired refresh token" } ``` `400` Bad Request ```json { "statusCode": 400, "error": "Bad Request", "message": "Request validation failed", "details": [ { "instancePath": "", "schemaPath": "#/required", "keyword": "required", "params": { "missingProperty": "refreshToken" }, "message": "must have required property 'refreshToken'" } ] } ``` `429` Too Many Requests ```json { "statusCode": 429, "error": "Error", "message": "Rate limit exceeded, retry in 1 minute" } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/auth/refresh \ -H "Content-Type: application/json" \ -d '{"refreshToken":""}' ``` ## Rotation Refresh-token rotation is a server setting and is **off by default**. Whether it is enabled in production is deployment-specific, so handle both cases: - **Rotation off:** the response has no `refreshToken`. Keep using the same refresh token until it expires. - **Rotation on:** the response includes a new `refreshToken`, and the token you sent is used up. Store the new one every time. Presenting a refresh token that was already used is treated as a replay: every refresh token issued from the same sign-in is revoked, and the call returns `401` *Refresh token reuse detected — please sign in again*. > ⚠️ With rotation on, never send the same refresh token twice. That includes two parallel requests or two browser tabs. Run one refresh at a time and share its result. ## Errors On this endpoint, `401` bodies contain only `error` and `message`; `400` and `429` use the full envelope described in [Error Handling](https://docs.aureahub.com/docs/errors.md). Treat any `401` as "sign the user in again". | Status | message | When | | --- | --- | --- | | 401 | `Invalid or expired refresh token` | The token is malformed, badly signed or expired, or its user can no longer be loaded. | | 401 | `Invalid token type` | The token is not a refresh token (for example, an access token). | | 401 | `Account is not active` | The user's account is not active. | | 401 | `Session expired after a password change — please sign in again` | The token was issued before the user's password was last changed. | | 401 | `Refresh token is no longer valid` | Rotation on: the token has been revoked or has expired. | | 401 | `Refresh token reuse detected — please sign in again` | Rotation on: the token was already used. All refresh tokens from that sign-in are now revoked. | | 400 | `Request validation failed` | The body has no refreshToken. details lists the problem. | | 429 | `Rate limit exceeded, retry in 1 minute` | More than 60 requests in one minute from the same client IP. See the retry-after header. | ## Implementation A refresh helper that runs at most one refresh at a time and stores a rotated refresh token when one is returned, plus a wrapper that refreshes once and retries once when a protected call returns `401`: ```javascript const API = 'https://api.aureahub.com'; let refreshInFlight = null; // store: { accessToken, refreshToken } export function refreshSession(store) { // One refresh at a time: with rotation on, a second call with the same token counts as reuse. refreshInFlight ??= (async () => { try { const res = await fetch(API + '/v1/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken: store.refreshToken }), }); if (res.status === 401) throw new Error('RE_AUTH_REQUIRED'); // sign the user in again if (!res.ok) throw new Error(`Refresh failed with HTTP ${res.status}`); const { accessToken, expiresIn, refreshToken } = await res.json(); store.accessToken = accessToken; if (refreshToken) store.refreshToken = refreshToken; // only returned when rotation is on return { accessToken, expiresIn }; } finally { refreshInFlight = null; } })(); return refreshInFlight; } // Call a bearer-protected route; on 401, refresh once and retry once. export async function apiFetch(store, path, init = {}) { const call = () => fetch(API + path, { ...init, headers: { ...init.headers, Authorization: `Bearer ${store.accessToken}` }, }); let res = await call(); if (res.status !== 401) return res; await refreshSession(store); res = await call(); if (res.status === 401) throw new Error('RE_AUTH_REQUIRED'); return res; } ``` Web version: https://docs.aureahub.com/#auth-refresh --- # Google Sign-In Exchange a Google ID token for Aurea tokens. The matching user is signed in, or a new user is created on first use. ## Overview Your app obtains a Google ID token and sends it here. Aurea verifies it with Google and returns the same token set as [Login](https://docs.aureahub.com/docs/auth-login.md): `accessToken`, `refreshToken`, `expiresIn` and `user`, plus `isNewUser: true` when this call created the account. - **Tenant.** The user belongs to the tenant identified by the `x-tenant-api-key` signing header. A `tenantApiKey` body field is accepted but does not change the tenant. - **Tenant configuration.** The tenant's configuration must contain a Google *web client ID*. Without one the call returns `400` *Google Sign-In is not enabled for this tenant*. - **Audience.** The ID token is verified with that web client ID as the expected audience, so it must be issued for it. - **Email.** Google must report the account's email as verified, and the token must carry an email address. - **No wallet** is created by this endpoint. > ⚠️ This endpoint requires **tenant request signing**: send `x-tenant-api-key`, `x-timestamp` and `x-signature` as described in [Authentication](https://docs.aureahub.com/docs/authentication.md). It is rate-limited to **20 requests per minute per client IP**. ## Endpoint ### `POST /v1/auth/google` Verifies a Google ID token, then signs in the matching user or creates a new one. Requires tenant HMAC signing headers. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `idToken` | string | yes | Google ID token issued for the tenant's configured Google web client ID | | `tenantApiKey` | string | no | Accepted (at least 32 characters) but not used: the tenant comes from the x-tenant-api-key header | **Responses** `200` OK ```json { "accessToken": "eyJhbGciOiJIUzI1NiIs...", "refreshToken": "eyJhbGciOiJIUzI1NiIs...", "expiresIn": 900, "user": { "id": "", "username": "alice", "email": "alice@gmail.com", "qrCode": "", "role": "user", "createdAt": "2026-09-01T10:00:00.000Z" }, "isNewUser": true } ``` `400` Not Enabled ```json { "error": "BadRequestError", "message": "Google Sign-In is not enabled for this tenant" } ``` `401` Invalid Token ```json { "error": "UnauthorizedError", "message": "Invalid Google ID token" } ``` `409` Conflict ```json { "error": "ConflictError", "message": "This email is already linked to a different Google account" } ``` `422` Email Not Verified ```json { "error": "ValidationError", "message": "Google account email is not verified" } ``` `429` Too Many Requests ```json { "statusCode": 429, "error": "Error", "message": "Rate limit exceeded, retry in 1 minute" } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/auth/google \ -H "Content-Type: application/json" \ -H "x-tenant-api-key: " \ -H "x-timestamp: " \ -H "x-signature: " \ -d '{"idToken":""}' ``` ## Account Matching After the token is verified, the user is resolved inside the tenant in this order: 1. **Linked account.** If the Google account is already linked to an active user, that user is signed in. 2. **Same email.** Otherwise, if a user with the same email exists and has no Google account linked, the Google account is linked to that user and they are signed in. If that user already has a *different* Google account linked, the call returns `409`. If that user is not active, it returns `401` *Account is not active*. 3. **New account.** Otherwise a user is created with no password. The username is the part of the email before `@`, with any character other than letters, digits, `_` and `-` replaced by `_`; if that username is taken, `_1`, `_2`, … is appended. The response includes `isNewUser: true`. `isNewUser` is omitted when an existing user was signed in. Users created here cannot use password [Login](https://docs.aureahub.com/docs/auth-login.md) (`401` *This account uses social sign-in*). ## Errors On this endpoint, `400`, `401`, `409` and `422` bodies contain only `error` and `message`; `404` and `429` use the full envelope described in [Error Handling](https://docs.aureahub.com/docs/errors.md). | Status | message | When | | --- | --- | --- | | 400 | `Request validation failed` | idToken is missing or empty. | | 400 | `Google Sign-In is not enabled for this tenant` | The tenant has no Google web client ID configured. | | 400 | `Google account has no email address` | The verified token carries no email. | | 401 | `Invalid Google ID token` | Verification failed: for example a bad signature, an expired token, or a token issued for another client ID. | | 401 | `Account is not active` | The existing user with the same email is not active. | | 401 | `Missing required HMAC headers: x-tenant-api-key, x-timestamp, x-signature` | Signature problem. Other messages: Invalid x-timestamp header, Request expired, Invalid signature, Replay detected. See Authentication. | | 404 | `Invalid API key` | The x-tenant-api-key header does not match an active tenant. | | 409 | `This email is already linked to a different Google account` | A user with this email is linked to another Google account. | | 422 | `Google account email is not verified` | The token reports the email as unverified. | | 429 | `Rate limit exceeded, retry in 1 minute` | More than 20 requests in one minute from the same client IP. See the retry-after header. | ## Implementation Forward the ID token with the tenant signature. Sign and send the same body string: ```javascript import { createHash, createHmac } from 'node:crypto'; const API = 'https://api.aureahub.com'; // Tenant request signing: see Authentication → HMAC Request Signing function signedHeaders(method, pathWithQuery, body = '') { const timestamp = Math.floor(Date.now() / 1000).toString(); const bodyHash = createHash('sha256').update(body).digest('hex'); const signature = createHmac('sha256', process.env.AUREA_TENANT_API_SECRET) .update(`${method}\n${pathWithQuery}\n${timestamp}\n${bodyHash}`) .digest('hex'); return { 'x-tenant-api-key': process.env.AUREA_TENANT_API_KEY, 'x-timestamp': timestamp, 'x-signature': signature, }; } // idToken: the Google ID token your app obtained for the tenant's web client ID export async function signInWithGoogle(idToken) { const path = '/v1/auth/google'; const body = JSON.stringify({ idToken }); const res = await fetch(API + path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...signedHeaders('POST', path, body) }, body, }); const data = await res.json(); if (!res.ok) { throw new Error(`${res.status}: ${data.message ?? data.error}`); } const { accessToken, refreshToken, expiresIn, user } = data; return { accessToken, refreshToken, expiresIn, user, isNewUser: data.isNewUser === true }; } ``` Web version: https://docs.aureahub.com/#auth-google --- # Apple Sign-In Exchange a Sign in with Apple identity token for Aurea tokens. The linked user is signed in, or a new user is created on first use. ## Overview Your app obtains an Apple identity token and sends it here. Aurea verifies it and returns the same token set as [Login](https://docs.aureahub.com/docs/auth-login.md): `accessToken`, `refreshToken`, `expiresIn` and `user`, plus `isNewUser: true` when this call created the account. - **Tenant.** The user belongs to the tenant identified by the `x-tenant-api-key` signing header. A `tenantApiKey` body field is accepted but does not change the tenant. - **Tenant configuration.** The tenant's configuration must contain an Apple *bundle ID*. Without one the call returns `400` *Apple Sign-In is not enabled for this tenant*. - **Verification.** The token's signature is checked against Apple's public keys (`https://appleid.apple.com/auth/keys`). The issuer must be `https://appleid.apple.com` and the audience must be the tenant's bundle ID. Expiry is checked with 30 seconds of clock tolerance. - **Email.** If the token carries an email with `email_verified` set to false, the call returns `422`. - **No wallet** is created by this endpoint. > ⚠️ This endpoint requires **tenant request signing**: send `x-tenant-api-key`, `x-timestamp` and `x-signature` as described in [Authentication](https://docs.aureahub.com/docs/authentication.md). It is rate-limited to **20 requests per minute per client IP**. ## Endpoint ### `POST /v1/auth/apple` Verifies an Apple identity token, then signs in the linked user or creates a new one. Requires tenant HMAC signing headers. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `idToken` | string | yes | Identity token from Sign in with Apple, issued for the tenant's configured bundle ID | | `tenantApiKey` | string | no | Accepted (at least 32 characters) but not used: the tenant comes from the x-tenant-api-key header | **Responses** `200` OK ```json { "accessToken": "eyJhbGciOiJIUzI1NiIs...", "refreshToken": "eyJhbGciOiJIUzI1NiIs...", "expiresIn": 900, "user": { "id": "", "username": "apple_001234ab", "email": "alice@example.com", "qrCode": "", "role": "user", "createdAt": "2026-09-01T10:00:00.000Z" }, "isNewUser": true } ``` `400` Not Enabled ```json { "error": "BadRequestError", "message": "Apple Sign-In is not enabled for this tenant" } ``` `401` Invalid Token ```json { "error": "UnauthorizedError", "message": "Invalid Apple identity token" } ``` `422` Email Not Verified ```json { "error": "ValidationError", "message": "Apple account email is not verified" } ``` `429` Too Many Requests ```json { "statusCode": 429, "error": "Error", "message": "Rate limit exceeded, retry in 1 minute" } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/auth/apple \ -H "Content-Type: application/json" \ -H "x-tenant-api-key: " \ -H "x-timestamp: " \ -H "x-signature: " \ -d '{"idToken":""}' ``` ## Account Matching 1. **Linked account.** If the Apple account (the token's `sub`) is already linked to an active user in the tenant, that user is signed in. 2. **New account.** Otherwise a user is created with no password. The username is `apple_` followed by the first 8 letters and digits of the Apple user ID; if that username is taken, `_1`, `_2`, … is appended. The token's email is stored when present. The response includes `isNewUser: true`. Apple sign-in does not look up existing users by email, so it never links to an account created with a password or with Google. `isNewUser` is omitted when an existing user was signed in. Users created here cannot use password [Login](https://docs.aureahub.com/docs/auth-login.md) (`401` *This account uses social sign-in*). ## Errors On this endpoint, `400`, `401` and `422` bodies contain only `error` and `message`; `404` and `429` use the full envelope described in [Error Handling](https://docs.aureahub.com/docs/errors.md). | Status | message | When | | --- | --- | --- | | 400 | `Request validation failed` | idToken is missing or empty. | | 400 | `Apple Sign-In is not enabled for this tenant` | The tenant has no Apple bundle ID configured. | | 401 | `Invalid Apple identity token` | Verification failed: for example a bad signature, the wrong issuer or audience, an expired token, or no subject. | | 401 | `Missing required HMAC headers: x-tenant-api-key, x-timestamp, x-signature` | Signature problem. Other messages: Invalid x-timestamp header, Request expired, Invalid signature, Replay detected. See Authentication. | | 404 | `Invalid API key` | The x-tenant-api-key header does not match an active tenant. | | 422 | `Apple account email is not verified` | The token carries an email with email_verified set to false. | | 429 | `Rate limit exceeded, retry in 1 minute` | More than 20 requests in one minute from the same client IP. See the retry-after header. | ## Implementation Forward the identity token with the tenant signature. Sign and send the same body string: ```javascript import { createHash, createHmac } from 'node:crypto'; const API = 'https://api.aureahub.com'; // Tenant request signing: see Authentication → HMAC Request Signing function signedHeaders(method, pathWithQuery, body = '') { const timestamp = Math.floor(Date.now() / 1000).toString(); const bodyHash = createHash('sha256').update(body).digest('hex'); const signature = createHmac('sha256', process.env.AUREA_TENANT_API_SECRET) .update(`${method}\n${pathWithQuery}\n${timestamp}\n${bodyHash}`) .digest('hex'); return { 'x-tenant-api-key': process.env.AUREA_TENANT_API_KEY, 'x-timestamp': timestamp, 'x-signature': signature, }; } // identityToken: the Sign in with Apple identity token your app obtained export async function signInWithApple(identityToken) { const path = '/v1/auth/apple'; const body = JSON.stringify({ idToken: identityToken }); const res = await fetch(API + path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...signedHeaders('POST', path, body) }, body, }); const data = await res.json(); if (!res.ok) { throw new Error(`${res.status}: ${data.message ?? data.error}`); } const { accessToken, refreshToken, expiresIn, user } = data; return { accessToken, refreshToken, expiresIn, user, isNewUser: data.isNewUser === true }; } ``` Web version: https://docs.aureahub.com/#auth-apple --- # Forgot Password Native, email-based password reset. A user requests a link, opens it, and sets a new password — with single-use tokens, anti-enumeration, and invalidation of earlier refresh tokens. ## Overview The flow has two halves. First the user **requests a reset** (`POST /v1/auth/forgot-password`) and, if an eligible account exists, receives an email containing a single-use link. Then they open that link and **complete the reset** (`POST /v1/auth/reset-password`), which sets the new password, invalidates the refresh tokens issued before the change, and signs them straight back in. The reset link opens the branded Aurea reset page at `https://api.aureahub.com/reset-password?token=…` on any device. It opens the mobile app directly only once Universal Links / App Links are configured for the app, which they currently are not. The completion endpoints are **token-authenticated** — the single-use token is the capability — so they work from a plain browser without a tenant signature. > ℹ️ Password reset is **enabled per tenant** and is off by default. When it is disabled, `forgot-password` returns `404`. Enable it from the Admin Dashboard (tenant → *Password Reset*) along with the token validity window, sender name, and reset link base URL. ## Request a reset Send the user's email. The response is **always identical** whether or not the account exists, so the endpoint never reveals which emails are registered. OAuth-only accounts (Google/Apple) receive a "use social sign-in" email instead of a reset link — again, the API response is unchanged. ### `POST /v1/auth/forgot-password` Authentication: none (public endpoint). Emails a reset link if an eligible account exists. Always returns the same response (anti-enumeration). Sent in the tenant context (tenant API key). **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `email` | string | yes | Email address of the account to reset | | `locale` | string | no | Email language: 'en' (default) or 'it' | **Responses** `200` OK ```json { "ok": true, "message": "If an account exists for that email, a password-reset link is on its way." } ``` `404` Not Enabled ```json { "error": "Password reset is not enabled for this tenant" } ``` `429` Too Many Requests ```json { "error": "Too many requests. Please try again later." } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/auth/forgot-password \ -H "Content-Type: application/json" \ -H "x-tenant-api-key: " \ -H "x-timestamp: " \ -H "x-signature: " \ -d '{"email": "alice@example.com", "locale": "en"}' ``` > ℹ️ This endpoint is signed with your tenant API key (the same HMAC headers used for other tenant-scoped calls). The official SDKs add these headers automatically. ## Validate a token Optionally check a token before showing the reset form — useful to display an "expired link" message early. This does **not** consume the token and needs no tenant signature. ### `GET /v1/auth/reset-password/validate` Authentication: none (public endpoint). Returns whether a reset token is still valid, without consuming it. Token-authenticated — no tenant signature required. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `token` | string | yes | The reset token from the email link | **Responses** `200` OK ```json { "valid": true } ``` `429` Too Many Requests ```json { "error": "Too many requests. Please try again later." } ``` **Example request** ```bash curl "https://api.aureahub.com/v1/auth/reset-password/validate?token=" ``` ## Complete the reset Submit the token and the new password. On success the user's password is rotated, refresh tokens issued before the change stop working, a security-notice email is sent, and a **fresh token pair is returned** so the user is immediately signed in. ### `POST /v1/auth/reset-password` Authentication: none (public endpoint). Sets the new password, invalidates earlier refresh tokens, and returns fresh tokens (auto-login). Token-authenticated. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `token` | string | yes | The reset token from the email link | | `newPassword` | string | yes | New password — min 8 chars, with an uppercase letter, a lowercase letter, and a digit | | `locale` | string | no | Confirmation email language: 'en' (default) or 'it' | **Responses** `200` OK ```json { "ok": true, "accessToken": "eyJhbGc...", "refreshToken": "eyJhbGc...", "expiresIn": 900, "userId": "uuid" } ``` `400` Invalid Token ```json { "error": "This reset link is invalid or has expired. Request a new one." } ``` `400` Weak Password ```json { "error": "That password appears in a known data breach. Please choose a different one." } ``` `429` Too Many Requests ```json { "error": "Too many requests. Please try again later." } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/auth/reset-password \ -H "Content-Type: application/json" \ -d '{"token": "", "newPassword": "NewSecurePass123"}' ``` ## Operator reset (support) Support staff can trigger a reset email for a specific user from a trusted backend, using an operator bearer token (role `admin` or `tenantadmin`). ### `POST /v1/auth/admin/send-reset` Authentication: bearer token required. Operator-triggered reset email for a specific user. Requires an admin or tenantadmin bearer token. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `userId` | string | yes | ID of the user to send a reset link to | | `tenantId` | string | no | Target tenant — required for platform admins; tenantadmins are scoped to their own tenant | **Responses** `200` OK ```json { "ok": true, "sent": true } ``` `403` Forbidden ```json { "error": "Forbidden" } ``` `404` Not Enabled ```json { "error": "Password reset is not enabled for this tenant" } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/auth/admin/send-reset \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{"userId": ""}' ``` ## Implementation The two completion endpoints are what your reset page calls. A minimal browser flow — validate the token on load, then submit the new password: ```javascript // 1) On the reset page, read the token from the URL and validate it const token = new URLSearchParams(location.search).get('token'); const { valid } = await fetch( `https://api.aureahub.com/v1/auth/reset-password/validate?token=${encodeURIComponent(token)}` ).then(r => r.json()); if (!valid) { // show "this link is invalid or has expired — request a new one" return; } // 2) Submit the new password const res = await fetch('https://api.aureahub.com/v1/auth/reset-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, newPassword }) }); if (!res.ok) { const err = await res.json(); throw new Error(err.error || 'Reset failed'); } // 3) Success — the response signs the user straight back in const { accessToken, refreshToken, userId } = await res.json(); sessionStorage.setItem('access_token', accessToken); localStorage.setItem('refresh_token', refreshToken); ``` To kick the flow off from your app's login screen, call `forgot-password` with the user's email and show a generic "check your email" confirmation regardless of the response: ```javascript async function requestPasswordReset(email) { await fetch('https://api.aureahub.com/v1/auth/forgot-password', { method: 'POST', headers: { 'Content-Type': 'application/json' /* + tenant HMAC headers (SDK adds these) */ }, body: JSON.stringify({ email, locale: 'en' }) }); // Always show the same message — never reveal whether the account exists. return 'If an account exists for that email, a reset link is on its way.'; } ``` ## Security - **Single-use, hashed tokens.** Only a SHA-256 hash of the token is stored; the raw token lives only in the email link. Each token works once and is consumed on a successful reset. - **Expiry.** Links expire after the tenant's configured window (60 minutes by default). - **Anti-enumeration.** `forgot-password` returns an identical response and timing whether or not the email exists; OAuth-only accounts get a "use social sign-in" email instead of a link. - **Breach check.** New passwords are checked against the Have I Been Pwned k-anonymity range API and rejected if they appear in a known breach. - **Rate limiting.** Requests are throttled per email and per IP. - **Session invalidation.** Completing a reset rejects refresh tokens issued before the change, so a stolen session can no longer be renewed. Access tokens already issued stay valid until they expire (15 minutes by default). > ℹ️ On a correct device, an existing non-custodial or MPC wallet keeps working transparently after a password reset — the login password never gates local wallet key material, so no seed or backup phrase is required to keep using the wallet. Web version: https://docs.aureahub.com/#auth-forgot-password --- # Get My Profile Read the signed-in user's own profile. ## Overview There is no `GET /v1/users/me`: `/v1/users/me` only supports [PATCH](https://docs.aureahub.com/docs/users-me-update.md). To read the current user: - use the `user` object returned by [Login](https://docs.aureahub.com/docs/auth-login.md), [Register](https://docs.aureahub.com/docs/auth-register.md) and the Google and Apple sign-in endpoints; or - call [Get User](https://docs.aureahub.com/docs/users-get.md) with your own user ID, which is the `sub` claim of the access token. ## Endpoint ### `GET /v1/users/{id}` Authentication: bearer token required. Returns a user of the caller's tenant by ID. Pass the sub claim of your access token to read your own profile. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | User UUID. For your own profile, the sub claim of the access token | **Responses** `200` OK ```json { "id": "0b7c1e52-4a9d-4f3e-8c21-6d5a9e3f7b10", "username": "alice", "email": "alice@example.com", "displayName": "Alice", "qrCode": "data:image/png;base64,…", "status": "active", "role": "user", "createdAt": "2026-09-01T10:00:00.000Z", "updatedAt": "2026-09-10T08:30:00.000Z" } ``` ## Implementation ```javascript // Reads the sub claim without verifying the token — fine for your own access token function userIdFromToken(accessToken) { const payload = accessToken.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); return JSON.parse(atob(payload)).sub; } async function getMyProfile(accessToken) { const res = await fetch(`https://api.aureahub.com/v1/users/${userIdFromToken(accessToken)}`, { headers: { Authorization: `Bearer ${accessToken}` } }); if (!res.ok) throw new Error(`Profile request failed: ${res.status}`); return res.json(); } ``` Web version: https://docs.aureahub.com/#users-me --- # Update My Profile Update the authenticated user's username and/or display name. ## Overview Allows the authenticated user to update their own `username` and/or `displayName`. At least one field must be provided. ### `PATCH /v1/users/me` Authentication: bearer token required. Update the current user's username and/or display name. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `username` | string | no | New username (3–100 chars). At least one field required. | | `displayName` | string | no | Display name (max 100 chars, nullable) | **Responses** `200` OK ```json { "id": "uuid", "username": "alice_new", "displayName": "Alice Smith" } ``` `400` Bad Request ```json { "detail": "At least one field must be provided" } ``` `409` Conflict ```json { "detail": "Username already taken" } ``` **Example request** ```bash curl -X PATCH https://api.aureahub.com/v1/users/me \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{"displayName": "Alice Smith"}' ``` Web version: https://docs.aureahub.com/#users-me-update --- # List Users Retrieve a paginated list of all users in the tenant. Primarily used in admin and back-office dashboards. ## Overview This endpoint returns all users belonging to the authenticated tenant, paginated for performance. It is intended for admin dashboards and back-office tools where you need a user directory. For looking up a specific user by username or wallet address, use the **Search Users** or **Advanced Search** endpoints instead, which are more efficient for single-user lookups. You can filter by `status` to show only active or suspended users, and by `isTestnet` to separate sandbox users from production ones. ### `GET /v1/users/` Authentication: bearer token required. Returns a paginated list of users in the tenant. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | integer | no | Results per page (default: 20, max: 100) | | `offset` | integer | no | Number of records to skip for pagination | | `status` | string | no | Filter by status: active, suspended, inactive | | `isTestnet` | boolean | no | Filter sandbox (true) or production (false) users | **Responses** `200` OK ```json { "items": [ { "id": "uuid", "username": "alice", "email": "alice@example.com", "status": "active", "role": "user", "created_at": "2024-01-15T10:00:00Z" } ], "total": 42, "limit": 20, "offset": 0 } ``` ## Implementation Fetch all pages of users using offset-based pagination: ```javascript async function getAllUsers(token) { const limit = 50; let offset = 0; let allUsers = []; while (true) { const res = await fetch( `https://api.aureahub.com/v1/users/?limit=${limit}&offset=${offset}`, { headers: { Authorization: `Bearer ${token}` } } ); const data = await res.json(); allUsers = allUsers.concat(data.items); if (allUsers.length >= data.total) break; offset += limit; } return allUsers; } // Or fetch a single page for a dashboard table: async function getUsersPage(token, page = 0, limit = 20) { const res = await fetch( `https://api.aureahub.com/v1/users/?limit=${limit}&offset=${page * limit}`, { headers: { Authorization: `Bearer ${token}` } } ); return res.json(); // { items, total, limit, offset } } ``` Web version: https://docs.aureahub.com/#users-list --- # Create User Programmatically create a new user from your server — distinct from the self-registration flow. ## Overview While `POST /v1/auth/register` is for end-user self-signup (no token required), this endpoint creates a user from your authenticated backend — for example, when provisioning accounts in bulk, migrating users from another system, or creating managed accounts on behalf of your customers. You can assign a `role` at creation time (`user` or `tenantadmin`). This endpoint returns the new user object including a QR code for payment receiving. > ℹ️ This endpoint requires a valid admin or tenant bearer token. It is intended for server-to-server calls, not for client-facing sign-up forms. ### `POST /v1/users/` Authentication: bearer token required. Programmatically creates a new user from an authenticated server context. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `username` | string | yes | Unique username (3–100 chars, alphanumeric / _ / -) | | `password` | string | yes | Minimum 8 characters | | `email` | string | no | Optional email address | | `role` | string | no | Role: user (default) or tenantadmin | **Responses** `201` Created ```json { "id": "uuid", "username": "bob", "email": "bob@example.com", "role": "user", "status": "active", "qrCode": "data:image/png;base64,..." } ``` `409` Conflict ```json { "detail": "Username already taken" } ``` ## Implementation ```javascript // Server-side: provision a user account programmatically async function provisionUser(adminToken, { username, email, password, role = 'user' }) { const res = await fetch('https://api.aureahub.com/v1/users/', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${adminToken}` }, body: JSON.stringify({ username, email, password, role }) }); if (res.status === 409) throw new Error('Username already taken'); if (!res.ok) throw new Error('Failed to create user'); return res.json(); // { id, username, email, role, qrCode, ... } } // Bulk provision example: async function importUsers(adminToken, userList) { const results = []; for (const user of userList) { try { const created = await provisionUser(adminToken, user); results.push({ success: true, id: created.id, username: user.username }); } catch (err) { results.push({ success: false, username: user.username, error: err.message }); } } return results; } ``` Web version: https://docs.aureahub.com/#users-create --- # Get User Retrieve the full profile of a specific user by their UUID. ## Overview Use this endpoint to fetch a single user's complete profile — including their status, role, and associated metadata. This is the go-to call for user profile pages, account settings screens, or when you need to verify a user's current status before performing an operation on their behalf. If you only know a username or wallet address, use **Search Users** or **Advanced Search** first to find the UUID. ### `GET /v1/users/{id}` Authentication: bearer token required. Returns full profile details for the specified user. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | User UUID | **Responses** `200` OK ```json { "id": "uuid", "username": "alice", "email": "alice@example.com", "created_at": "2024-01-15T10:00:00Z" } ``` `404` Not Found ```json { "detail": "User not found" } ``` Web version: https://docs.aureahub.com/#users-get --- # Update User Update a user's email address or password. At least one field must be provided. ## Overview Use this endpoint to allow users to change their email or reset their password from an account settings screen. The request body requires at least one of `email` or `password` — you only need to send the fields that should change. This endpoint operates on any user by UUID, making it suitable for both self-service (user updating their own account) and admin-driven updates. ### `PUT /v1/users/{id}` Authentication: bearer token required. Updates a user's email or password. At least one field required. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | User UUID | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `email` | string | no | New email address | | `password` | string | no | New password (min 8 characters) | **Responses** `200` OK ```json { "id": "uuid", "username": "alice", "email": "new@example.com" } ``` `400` Bad Request ```json { "detail": "At least one field (email or password) must be provided" } ``` `404` Not Found ```json { "detail": "User not found" } ``` ## Implementation ```javascript // Account settings — update email async function updateUserEmail(token, userId, newEmail) { const res = await fetch(`https://api.aureahub.com/v1/users/${userId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ email: newEmail }) }); if (!res.ok) throw new Error((await res.json()).detail); return res.json(); } // Change password async function changePassword(token, userId, newPassword) { const res = await fetch(`https://api.aureahub.com/v1/users/${userId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ password: newPassword }) }); if (!res.ok) throw new Error((await res.json()).detail); return res.json(); } ``` Web version: https://docs.aureahub.com/#users-update --- # Delete User Deactivate a user account. This is a soft-delete — data is retained but the user can no longer authenticate. ## Overview Deleting a user sets their account status to `inactive`. Their wallets, transactions, and history are preserved for audit and compliance purposes. The user will not be able to log in after deletion. A successful deletion returns `204 No Content` with no response body. This action should be treated as irreversible from the user's perspective — always confirm with the user before calling this endpoint. > ⚠️ This action immediately revokes the user's ability to authenticate. Any active sessions will be invalid on the next token verification. Ensure you have confirmation from the user or admin before calling this endpoint. ### `DELETE /v1/users/{id}` Authentication: bearer token required. Soft-deletes a user. Account is deactivated; data is retained. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | User UUID | **Responses** `204` No Content `404` Not Found ```json { "detail": "User not found" } ``` ## Implementation ```javascript async function deleteUser(token, userId) { const res = await fetch(`https://api.aureahub.com/v1/users/${userId}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } }); if (res.status === 404) throw new Error('User not found'); if (!res.ok) throw new Error('Failed to delete user'); // 204 No Content — no response body return true; } // Example: account deletion flow with confirmation async function requestAccountDeletion(token, userId) { const confirmed = confirm('Are you sure you want to delete your account? This cannot be undone.'); if (!confirmed) return; await deleteUser(token, userId); // Clear local tokens and redirect to homepage sessionStorage.removeItem('access_token'); localStorage.removeItem('refresh_token'); window.location.href = '/'; } ``` Web version: https://docs.aureahub.com/#users-delete --- # Search Users Find users by username — the primary lookup for peer-to-peer payment flows. ## Overview This endpoint performs a username-prefix search and returns matching users along with their wallets, making it ideal for a "Send to" screen where the sender types a recipient's username. The response includes wallet addresses directly, so you can pre-populate a payment form without a separate wallet lookup call. Results are ordered by match relevance. The search is case-insensitive and matches from the start of the username. > 💡 Debounce this call on the client side (300–500ms) to avoid firing on every keystroke. Only search when at least 2 characters have been entered. ### `GET /v1/users/search` Authentication: bearer token required. Returns users whose username matches the query string, with their wallet info. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `username` | string | yes | Username prefix to search (min 1 character) | **Responses** `200` OK ```json [ { "id": "uuid", "username": "alice", "qrCode": "data:image/png;base64,...", "wallets": [ { "id": "wallet-uuid", "chain": "ethereum", "address": "0x...", "isPrimary": true } ] } ] ``` ## Implementation ```javascript // Debounced username search for a "Send to" input field function debounce(fn, ms) { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); }; } async function searchUsers(token, query) { if (query.length < 2) return []; const res = await fetch( `https://api.aureahub.com/v1/users/search?username=${encodeURIComponent(query)}`, { headers: { Authorization: `Bearer ${token}` } } ); return res.json(); // Array of { id, username, qrCode, wallets } } // Attach to an input field with debouncing const searchInput = document.getElementById('recipient-search'); const debouncedSearch = debounce(async (e) => { const results = await searchUsers(accessToken, e.target.value); renderRecipientList(results); }, 350); searchInput.addEventListener('input', debouncedSearch); ``` Web version: https://docs.aureahub.com/#users-search --- # Advanced Search Look up users by wallet address, wallet UUID, or username — built for admin tools and dispute resolution flows. ## Overview While the basic search endpoint is optimised for P2P payment flows (username lookup), this endpoint is designed for back-office and admin scenarios where you need to reverse-lookup a user from a blockchain address, investigate a specific wallet, or search across multiple fields simultaneously. All query parameters are optional — provide one or more to filter results. The response is paginated. You can also filter by `isTestnet` to scope results to sandbox or production users. > ℹ️ Wallet address search supports both EVM (0x...) and Solana (base58) address formats. At least one query parameter must be provided. ### `GET /v1/users/advanced-search` Authentication: bearer token required. Multi-field user search by username, wallet UUID, or blockchain wallet address. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `username` | string | no | Username to search | | `wallet_id` | string | no | Wallet UUID | | `wallet_address` | string | no | Blockchain address (EVM 0x... or Solana base58) | | `isTestnet` | boolean | no | Scope to sandbox (true) or production (false) users | | `limit` | integer | no | Results per page | | `offset` | integer | no | Pagination offset | **Responses** `200` OK ```json { "items": [ { "id": "uuid", "username": "alice", "wallets": [ { "id": "wallet-uuid", "chain": "ethereum", "address": "0x...", "isPrimary": true } ] } ], "total": 1 } ``` ## Implementation ```javascript // Look up the user who owns a specific wallet address async function findUserByWalletAddress(token, address) { const res = await fetch( `https://api.aureahub.com/v1/users/advanced-search?wallet_address=${encodeURIComponent(address)}`, { headers: { Authorization: `Bearer ${token}` } } ); const data = await res.json(); return data.items[0] || null; // returns null if no match } // Look up by multiple fields simultaneously async function advancedUserSearch(token, { username, walletId, walletAddress }) { const params = new URLSearchParams(); if (username) params.set('username', username); if (walletId) params.set('wallet_id', walletId); if (walletAddress) params.set('wallet_address', walletAddress); const res = await fetch( `https://api.aureahub.com/v1/users/advanced-search?${params}`, { headers: { Authorization: `Bearer ${token}` } } ); return res.json(); } ``` Web version: https://docs.aureahub.com/#users-advsearch --- # Update FCM Token Register or update a Firebase Cloud Messaging (FCM) device token so the user receives push notifications for transactions and alerts. ## Overview Firebase Cloud Messaging (FCM) tokens identify a specific device for push notification delivery. Call this endpoint after the user logs in and after Firebase issues a token. The token can change (e.g. when the app is reinstalled), so you should call this endpoint every time Firebase provides a new token. Specify the `platform` so Aurea can send platform-specific payloads: `ios`, `android`, or `web`. > ℹ️ To stop receiving notifications (e.g. on logout), call `DELETE /v1/notifications/device-token` with the token instead of this endpoint. ### `PATCH /v1/users/me/fcm-token` Authentication: bearer token required. Registers or updates the authenticated user's FCM device token. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `fcmToken` | string | yes | Firebase Cloud Messaging device token | | `platform` | string | no | Device platform: ios, android, or web (defaults to ios) | **Responses** `200` OK ```json { "success": true, "message": "FCM token updated" } ``` ## Implementation ```javascript // After login: get FCM token from Firebase and register it with Aurea import { initializeApp } from 'firebase/app'; import { getMessaging, getToken, onMessage } from 'firebase/messaging'; const app = initializeApp({ /* your Firebase config */ }); const messaging = getMessaging(app); async function registerForPushNotifications(aureaToken) { try { // Request notification permission const permission = await Notification.requestPermission(); if (permission !== 'granted') return; // Get the FCM token from Firebase const fcmToken = await getToken(messaging, { vapidKey: 'YOUR_VAPID_KEY' // Web push certificate from Firebase console }); // Register the token with Aurea await fetch('https://api.aureahub.com/v1/users/me/fcm-token', { method: 'PATCH', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${aureaToken}` }, body: JSON.stringify({ fcmToken, platform: 'web' }) }); console.log('Push notifications registered'); // Listen for foreground messages onMessage(messaging, (payload) => { console.log('Push notification received:', payload); showNotificationToast(payload.notification); }); } catch (err) { console.warn('Push notification setup failed:', err); } } ``` Web version: https://docs.aureahub.com/#users-fcm --- # Generate QR Code Get a payment QR code for a user — encodes a deep link that pre-fills a send form with the recipient's username and tenant. ## Overview The QR code encodes a deep link in the format `bper://send?username={username}&tenant={tenantId}`. When scanned by another Aurea-powered app, it opens a pre-filled payment screen with the recipient already selected. The response is a base64-encoded PNG data URL ready to drop directly into an `` tag. This QR always reflects the user's **primary wallet**. If the user changes their primary wallet, generate a fresh QR code. > 💡 Cache the QR code image in your UI — it only changes when the user's primary wallet changes. Avoid regenerating on every page load. ### `GET /v1/users/{id}/qr-code` Authentication: bearer token required. Returns a base64 PNG QR code encoding a deep link for receiving payments. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | User UUID | **Responses** `200` OK ```json { "qrCode": "data:image/png;base64,iVBORw0KGgo..." } ``` `404` Not Found ```json { "detail": "User not found" } ``` ## Implementation ```javascript // Display a user's payment QR code async function showPaymentQR(token, userId) { const res = await fetch( `https://api.aureahub.com/v1/users/${userId}/qr-code`, { headers: { Authorization: `Bearer ${token}` } } ); if (!res.ok) throw new Error('Failed to load QR code'); const { qrCode } = await res.json(); // Drop directly into an tag — it's a data URL document.getElementById('payment-qr').src = qrCode; } // The QR encodes: bper://send?username=alice&tenant=tenant-uuid // When scanned by another Aurea app, it opens a pre-filled send screen ``` Web version: https://docs.aureahub.com/#users-qr --- # List User Wallets Retrieve all wallets owned by a specific user, across all supported blockchains. ## Overview A single user can hold multiple wallets — one per blockchain, or several on the same chain. This endpoint returns the complete list for a given user, including each wallet's chain, address, label, and whether it is the primary wallet (used for QR code payments and default transactions). This is different from `GET /v1/wallets/` which returns wallets for the *authenticated* user. This endpoint can be called for any user by UUID, making it suitable for admin dashboards and user profile views. ### `GET /v1/users/{id}/wallets` Authentication: bearer token required. Returns all wallets belonging to the specified user. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | User UUID | **Responses** `200` OK ```json [ { "id": "wallet-uuid", "chain": "ethereum", "address": "0xAbCd...", "label": "My ETH Wallet", "isPrimary": true }, { "id": "wallet-uuid-2", "chain": "polygon", "address": "0xEfGh...", "label": null, "isPrimary": false } ] ``` `404` Not Found ```json { "detail": "User not found" } ``` ## Implementation ```javascript // Get all wallets for a user async function getUserWallets(token, userId) { const res = await fetch( `https://api.aureahub.com/v1/users/${userId}/wallets`, { headers: { Authorization: `Bearer ${token}` } } ); if (!res.ok) throw new Error('Failed to load wallets'); return res.json(); // Array of wallet objects } // Find the primary wallet for a given user async function getPrimaryWallet(token, userId) { const wallets = await getUserWallets(token, userId); return wallets.find(w => w.isPrimary) || wallets[0] || null; } // Get all wallets grouped by chain async function getWalletsByChain(token, userId) { const wallets = await getUserWallets(token, userId); return wallets.reduce((acc, wallet) => { acc[wallet.chain] = acc[wallet.chain] || []; acc[wallet.chain].push(wallet); return acc; }, {}); } ``` Web version: https://docs.aureahub.com/#users-wallets --- # Create Wallet Generate a new blockchain wallet for the authenticated user — either custodial (key managed by Aurea) or non-custodial (user-controlled key). Wallets are cross-chain and any network or token can be supported upon request. ## Overview Aurea supports two wallet models to fit different regulatory and product requirements: - **Custodial wallets** — Aurea generates and securely stores the private key on behalf of the user. Designed for **regulated entities** (banks, EMIs, licensed custodians) that need full key custody and compliance controls. Your application never touches the private key. - **Non-custodial wallets** — Available to **everyone**. The private key is generated client-side and held by the user. Aurea manages wallet metadata and blockchain interactions without ever having access to the key. This endpoint provisions a new managed (custodial) wallet on the specified blockchain. For non-custodial wallet import, use **Import Wallet** instead. > ⚠️ Tenants configured for **MPC (2-of-3)** wallets (`walletMode: "mpc"`) or **non-custodial** wallets cannot mint a server-held key here — this endpoint returns `400`. Use **Start DKG Ceremony** (MPC) or **Register Client Wallet** (non-custodial) instead. This guarantees an MPC/non-custodial tenant never silently receives a custodial key. A user can have multiple wallets across different chains. Set `isPrimary: true` to make this the default wallet for QR code payments. Each user can only have one primary wallet at a time — setting a new one automatically demotes the previous primary. Use `isTestnet: true` to create wallets on test networks (Sepolia, Mumbai, etc.) for development and integration testing. > 💡 Aurea is natively **cross-chain**. The chains listed below are available by default. Any other blockchain network or token in the market can be enabled for your tenant upon request. ### `POST /v1/wallets/` Authentication: bearer token required. Creates a new custodial wallet on the specified blockchain. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `chain` | string | yes | Chain identifier: ethereum, polygon, bsc, arbitrum, optimism, gnosis, base, avalanche, celo, flowevm, solana — or any chain enabled for your tenant. A testnet's chain registry id (e.g. `polygon-amoy`) is also accepted: the wallet is stored under the family chain (`polygon`) with `isTestnet: true`. | | `name` | string | no | Friendly label for this wallet | | `isPrimary` | boolean | no | Mark as the user's primary wallet (default: false) | | `isTestnet` | boolean | no | Create on a test network (default: false) | **Responses** `201` Created ```json { "id": "wallet-uuid", "chain": "ethereum", "address": "0xAbCd...", "label": "My ETH Wallet", "walletType": "managed", "isPrimary": false, "isWatchOnly": false, "isTestnet": false, "created_at": "2024-01-15T10:00:00Z" } ``` `400` Bad Request ```json { "detail": "Unsupported chain: 'bitcoin'" } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/wallets/ \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"chain": "ethereum", "name": "My ETH Wallet", "isPrimary": true}' ``` ## Supported Chains The following chains are available by default. Any blockchain network or token in the market can be added upon request. ethereum polygon bsc arbitrum optimism base avalanche celo flowevm gnosis solana Use `GET /v1/tokens/meta/chains` to fetch the authoritative list of chains enabled for your tenant at runtime. ## Implementation ```javascript // Create a new wallet for the authenticated user async function createWallet(token, chain, label, isPrimary = false) { const res = await fetch('https://api.aureahub.com/v1/wallets/', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ chain, name: label, isPrimary }) }); if (!res.ok) { const err = await res.json(); throw new Error(err.detail || 'Failed to create wallet'); } return res.json(); // { id, chain, address, label, isPrimary, ... } } // Provision wallets on multiple chains at once async function setupMultiChainWallets(token) { const chains = ['ethereum', 'polygon', 'solana']; const wallets = await Promise.all( chains.map((chain, i) => createWallet(token, chain, `My ${chain} wallet`, i === 0) ) ); console.log('Created wallets:', wallets.map(w => w.address)); return wallets; } ``` Web version: https://docs.aureahub.com/#wallets-create --- # List Wallets Retrieve all wallets owned by the authenticated user, with optional chain filtering. ## Overview Returns the complete wallet portfolio for the current user. Use this endpoint to populate a wallet selector, display a portfolio overview, or find a specific chain's wallet before initiating a transaction. Filter by `chain` when you only need wallets for a specific blockchain. The `isPrimary` flag identifies the default wallet used for QR code payments. The `isTestnet` filter lets you separate sandbox wallets from production ones. ### `GET /v1/wallets/` Authentication: bearer token required. Returns all wallets owned by the authenticated user. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `chain` | string | no | Filter by chain identifier (e.g. ethereum). A testnet's chain registry id (e.g. `polygon-amoy`) filters by its family chain and also selects the testnet. | | `limit` | integer | no | Results per page (default: 50) | | `offset` | integer | no | Pagination offset | | `isTestnet` | boolean | no | Filter by testnet (true) or mainnet (false) | **Responses** `200` OK ```json { "items": [ { "id": "wallet-uuid", "chain": "ethereum", "address": "0xAbCd...", "label": "My ETH Wallet", "isPrimary": true, "isTestnet": false } ], "total": 3, "limit": 50, "offset": 0 } ``` ## Implementation ```javascript // Load all wallets for a wallet selector UI async function listWallets(token, chain = null) { const params = new URLSearchParams({ limit: '50' }); if (chain) params.set('chain', chain); const res = await fetch( `https://api.aureahub.com/v1/wallets/?${params}`, { headers: { Authorization: `Bearer ${token}` } } ); const data = await res.json(); return data.items; } // Find the right wallet for a given chain async function getWalletForChain(token, chain) { const wallets = await listWallets(token, chain); // Prefer primary, fall back to first available return wallets.find(w => w.isPrimary) || wallets[0] || null; } ``` Web version: https://docs.aureahub.com/#wallets-list --- # Import Wallet Bring an existing self-custodied wallet into Aurea's managed system using a private key or mnemonic phrase. ## Overview Use this endpoint when a user already has a blockchain wallet they want to manage through Aurea without creating a new one. Provide either a `privateKey` (64-character hex) or a `mnemonic` (BIP-39, 12 or 24 words). For HD wallets, optionally specify a `derivationPath` and `accountIndex`. You can also import a **watch-only** wallet by providing just the address (set `watchOnly: true`). Watch-only wallets can receive funds and have their balance queried, but cannot sign transactions. > ⚠️ **Security critical:** Private keys and mnemonics grant full control of a wallet. Only accept these from users directly in your UI — never log, cache, or proxy them through your own servers. Always enforce HTTPS. ### `POST /v1/wallets/import` Authentication: bearer token required. Imports an existing wallet into Aurea via private key, mnemonic, or watch-only address. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `chain` | string | yes | Blockchain identifier (e.g. ethereum). A testnet's chain registry id (e.g. `celo-sepolia`) is also accepted: the wallet is stored under the family chain (`celo`) with `isTestnet: true`. | | `privateKey` | string | no | 64-char hex private key (provide this OR mnemonic) | | `mnemonic` | string | no | BIP-39 mnemonic phrase, 12 or 24 words | | `derivationPath` | string | no | BIP-44 derivation path (e.g. m/44'/60'/0'/0/0) | | `accountIndex` | integer | no | HD wallet account index (default: 0) | | `label` | string | no | Friendly label for this wallet | | `watchOnly` | boolean | no | Import as watch-only (provide address instead of key) | | `address` | string | no | Wallet address (required when watchOnly: true) | | `isPrimary` | boolean | no | Set as primary wallet | | `isTestnet` | boolean | no | Import on testnet (default: false) | **Responses** `201` Created ```json { "id": "wallet-uuid", "chain": "ethereum", "address": "0xAbCd...", "label": "My Imported Wallet", "walletType": "imported", "isPrimary": false, "isWatchOnly": false } ``` `400` Bad Request ```json { "detail": "Invalid private key format" } ``` ## Implementation ```javascript // Import by private key async function importWalletByKey(token, chain, privateKey, label) { const res = await fetch('https://api.aureahub.com/v1/wallets/import', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ chain, privateKey, label }) }); if (!res.ok) throw new Error((await res.json()).detail); return res.json(); } // Import by mnemonic (HD wallet, first account) async function importWalletByMnemonic(token, chain, mnemonic, label) { const res = await fetch('https://api.aureahub.com/v1/wallets/import', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ chain, mnemonic, derivationPath: "m/44'/60'/0'/0/0", label }) }); if (!res.ok) throw new Error((await res.json()).detail); return res.json(); } // Import watch-only (read balance, can't send) async function importWatchOnlyWallet(token, chain, address) { const res = await fetch('https://api.aureahub.com/v1/wallets/import', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ chain, address, watchOnly: true }) }); if (!res.ok) throw new Error((await res.json()).detail); return res.json(); } ``` Web version: https://docs.aureahub.com/#wallets-import --- # Get Wallet Retrieve the details of a specific wallet by its UUID. ## Overview Use this endpoint to fetch full details for a single wallet — its chain, address, label, primary status, and whether it's a watch-only or testnet wallet. This is useful on wallet detail screens or before initiating a transaction when you need to confirm the wallet's chain and address. ### `GET /v1/wallets/{id}` Authentication: bearer token required. Returns full details for the specified wallet. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Wallet UUID | **Responses** `200` OK ```json { "id": "wallet-uuid", "chain": "polygon", "address": "0xAbCd...", "label": "My MATIC Wallet", "walletType": "managed", "isPrimary": false, "isWatchOnly": false, "isTestnet": false, "created_at": "2024-01-15T10:00:00Z" } ``` `404` Not Found ```json { "detail": "Wallet not found" } ``` ## Implementation ```javascript async function getWallet(token, walletId) { const res = await fetch( `https://api.aureahub.com/v1/wallets/${walletId}`, { headers: { Authorization: `Bearer ${token}` } } ); if (res.status === 404) throw new Error('Wallet not found'); if (!res.ok) throw new Error('Failed to fetch wallet'); return res.json(); } // Usage: confirm chain before sending const wallet = await getWallet(token, walletId); console.log(`Sending on ${wallet.chain} from ${wallet.address}`); ``` Web version: https://docs.aureahub.com/#wallets-get --- # Delete Wallet Remove a wallet from the user's account. The primary wallet cannot be deleted. ## Overview Deletes a wallet by UUID. The wallet is removed from the user's account and will no longer appear in wallet lists. If the wallet holds funds, those funds remain on-chain — they are not automatically transferred. Ensure the wallet balance is zero or transferred before deletion. The user's **primary wallet cannot be deleted**. To delete it, first assign a different primary wallet using `PATCH /v1/wallets/{id}/primary`, then delete the old one. > ⚠️ Deletion is permanent within Aurea's system. If the wallet has a non-zero balance, those funds will be inaccessible through the platform unless the wallet is re-imported. Always verify balance before deleting. ### `DELETE /v1/wallets/{id}` Authentication: bearer token required. Removes a wallet from the user's account. Cannot delete the primary wallet. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Wallet UUID | **Responses** `204` No Content `403` Forbidden ```json { "detail": "Cannot delete primary wallet" } ``` `404` Not Found ```json { "detail": "Wallet not found" } ``` ## Implementation ```javascript async function deleteWallet(token, walletId) { // Safety check: verify balance is zero first const balance = await fetch( `https://api.aureahub.com/v1/wallets/${walletId}/balance`, { headers: { Authorization: `Bearer ${token}` } } ).then(r => r.json()); const hasBalance = parseFloat(balance.nativeBalance?.amount || '0') > 0 || (balance.tokens || []).some(t => parseFloat(t.balance) > 0); if (hasBalance) { throw new Error('Wallet still has funds — transfer or withdraw before deleting'); } const res = await fetch(`https://api.aureahub.com/v1/wallets/${walletId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } }); if (res.status === 403) throw new Error('Cannot delete primary wallet — reassign primary first'); if (res.status === 404) throw new Error('Wallet not found'); if (!res.ok) throw new Error('Failed to delete wallet'); return true; // 204 No Content } ``` Web version: https://docs.aureahub.com/#wallets-delete --- # Get Wallet Balance Retrieve the native coin and all token balances for a specific wallet, with optional testnet support. ## Overview Returns the live on-chain balance for a wallet. The response includes the native balance (ETH, MATIC, SOL, etc.) and all registered token balances (USDC, USDT, etc.) with their contract addresses and decimal precision. Balances are fetched directly from the blockchain node, so they always reflect the current on-chain state. For an aggregated view across all wallets, use **Aggregate Balances** instead. ### `GET /v1/wallets/{id}/balance` Authentication: bearer token required. Returns live native and token balances for the specified wallet. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Wallet UUID | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `chain` | string | no | Query the wallet's address on another chain of the same network: a chain identifier, or a testnet's chain registry id (e.g. `polygon-amoy`). Default: the wallet's own chain. A chain on the other network returns `400`. | | `isTestnet` | boolean | no | Fetch testnet balances (default: false) | **Responses** `200` OK ```json { "walletId": "wallet-uuid", "address": "0xAbCd...", "chain": "ethereum", "nativeBalance": { "symbol": "ETH", "amount": "1.2345", "decimals": 18 }, "tokens": [ { "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "symbol": "USDC", "balance": "500.00", "decimals": 6 } ] } ``` `404` Not Found ```json { "detail": "Wallet not found" } ``` ## Implementation ```javascript // Get the balance of a specific wallet async function getWalletBalance(token, walletId) { const res = await fetch( `https://api.aureahub.com/v1/wallets/${walletId}/balance`, { headers: { Authorization: `Bearer ${token}` } } ); if (!res.ok) throw new Error('Failed to fetch balance'); return res.json(); } // Check if a wallet has enough ETH for a transaction async function hasEnoughBalance(token, walletId, requiredAmount) { const { nativeBalance } = await getWalletBalance(token, walletId); return parseFloat(nativeBalance.amount) >= parseFloat(requiredAmount); } // Get USDC balance specifically async function getTokenBalance(token, walletId, symbol) { const { tokens } = await getWalletBalance(token, walletId); const token_data = tokens.find(t => t.symbol === symbol); return token_data ? parseFloat(token_data.balance) : 0; } ``` Web version: https://docs.aureahub.com/#wallets-balance --- # Aggregate Balances Get a consolidated portfolio view — total value across all wallets and chains, broken down by asset and chain. ## Overview Instead of fetching each wallet's balance individually and summing them yourself, this endpoint does the aggregation server-side. It returns the total portfolio value in EUR and USD, a breakdown by chain, and a breakdown by token — making it ideal for a portfolio dashboard or total balance display. Use `refresh=true` to bypass the cache and force a fresh fetch from the blockchain. Without it, balances may be up to a few minutes old but will load faster. Use `isTestnet=true` to aggregate testnet wallets instead. ### `GET /v1/wallets/balances/aggregate` Authentication: bearer token required. Aggregates total balance across all user wallets, grouped by chain and token. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `refresh` | boolean | no | Force fresh blockchain fetch, bypassing cache (default: false) | | `isTestnet` | boolean | no | Aggregate testnet wallet balances (default: false) | **Responses** `200` OK ```json { "summary": { "totalInEur": "1250.40", "totalInUsd": "1367.30" }, "byChain": { "ethereum": { "totalInEur": "850.00", "totalInUsd": "928.50", "nativeBalance": "0.35" }, "polygon": { "totalInEur": "400.40", "totalInUsd": "438.80", "nativeBalance": "122.5" } }, "byToken": { "USDC": { "name": "USD Coin", "totalBalance": "800.00", "totalInEur": "732.80", "totalInUsd": "800.00" } } } ``` ## Implementation ```javascript // Load portfolio summary for dashboard async function getPortfolioSummary(token, forceRefresh = false) { const params = new URLSearchParams(); if (forceRefresh) params.set('refresh', 'true'); const res = await fetch( `https://api.aureahub.com/v1/wallets/balances/aggregate?${params}`, { headers: { Authorization: `Bearer ${token}` } } ); if (!res.ok) throw new Error('Failed to fetch portfolio'); const data = await res.json(); return { totalEur: data.summary.totalInEur, totalUsd: data.summary.totalInUsd, chains: data.byChain, tokens: data.byToken }; } // Example: display total portfolio value const portfolio = await getPortfolioSummary(token); console.log(`Total portfolio: €${portfolio.totalEur} / $${portfolio.totalUsd}`); ``` Web version: https://docs.aureahub.com/#wallets-aggregate --- # Set Primary Wallet Mark a wallet as the user's primary wallet for its chain and network type (mainnet or testnet). ## Overview A user has at most one primary wallet per chain and network type. The request body must be `{ "isPrimary": true }` — sending `false` returns `400`, because a primary wallet cannot be unset directly; promote another wallet instead. - If the wallet is already primary, it is returned unchanged. - Otherwise the wallet is set as primary, and a database trigger demotes the user's previous primary wallet on the same chain and network type. - An unknown id, or a wallet that belongs to another user, returns `404`. - New wallets can already be primary: `POST /v1/wallets/` makes a wallet primary when the user has no primary wallet on that chain and network type, and `POST /v1/wallets/client` does so for the user's first wallet there (unless `isPrimary` is passed). To make the wallet the user's *only* primary wallet across all chains of its network type, use [Set Primary (atomic)](https://docs.aureahub.com/docs/wallets-set-primary.md). ## Endpoint ### `PATCH /v1/wallets/{id}/primary` Authentication: bearer token required. Sets the wallet as primary for its chain and network type. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | Wallet to promote. | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `isPrimary` | boolean | yes | Must be `true`. | **Responses** `200` OK ```json { "id": "3f8b2a1e-5c4d-4e7f-9a0b-1c2d3e4f5a6b", "chain": "gnosis", "address": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F", "walletType": "eoa", "isPrimary": true, "isTestnet": false, "label": "Main", "isWatchOnly": false, "keyManagementScheme": "sss_2of2_akv", "createdAt": "2026-09-01T08:00:00.000Z", "updatedAt": "2026-09-11T10:00:00.000Z" } ``` `400` Bad Request ```json { "error": "BAD_REQUEST", "message": "isPrimary must be true. To unset primary, set another wallet as primary." } ``` `404` Not Found ```json { "error": "NotFoundError", "message": "Wallet not found" } ``` **Example request** ```bash curl -X PATCH https://api.aureahub.com/v1/wallets/WALLET_ID/primary \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"isPrimary": true}' ``` ## Implementation ```javascript // Promote a wallet from a wallet-settings screen async function setPrimaryWallet(token, walletId) { const res = await fetch( `https://api.aureahub.com/v1/wallets/${walletId}/primary`, { method: 'PATCH', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ isPrimary: true }) } ); if (!res.ok) { const err = await res.json(); throw new Error(err.message || 'Failed to update primary wallet'); } return res.json(); // wallet object with isPrimary: true } ``` Web version: https://docs.aureahub.com/#wallets-primary --- # Non-Custodial Challenge Get a one-time challenge that proves the user controls the private key of a wallet generated on their device — step 1 of registering a client-side (non-custodial) wallet. ## Overview On tenants whose `walletMode` is `non_custodial`, the private key is generated and kept on the user's device and Aurea stores only the address. Before the wallet is created, the device proves it holds the key: it fetches a challenge here, signs it with the new key, and submits the signature to [Register Client Wallet](https://docs.aureahub.com/docs/wallets-client-register.md). - The challenge is 32 random bytes, hex-encoded (64 characters), valid for **10 minutes**. - It is bound to the authenticated user and to the `address` you pass. Registration fails if a different address is submitted. - Each call replaces any unused wallet-registration challenge previously issued to the same user, so it is safe to call again after a failure or expiry. - For `chain=solana` the address is used as given (base58). For any other chain value the address is parsed as an EVM address, so it must be all-lowercase or correctly checksummed. > ⚠️ Validate EVM addresses before calling. A malformed or wrongly checksummed address is not reported as a validation error — the request fails with `500`. ## Endpoint ### `GET /v1/wallets/client/challenge` Authentication: bearer token required. Issues a 32-byte random challenge (TTL 10 min) to sign with the new wallet's private key before registering it. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `chain` | string | yes | Chain of the wallet being registered, e.g. `gnosis` or `solana`. Use `evm` to register one address on every supported EVM chain (see Register Client Wallet). A testnet's chain registry id (e.g. `polygon-amoy`) is also accepted. | | `address` | string | yes | Address of the new wallet — the same value you will send to `POST /v1/wallets/client`. | **Responses** `200` OK ```json { "challenge": "b54b30a6f3fbdd6cd84bfdd85aca1b2dc58c9c6b6d6c180e1c7a9bb7bd556f4c", "expiresAt": "2026-09-11T10:10:00.000Z" } ``` **Example request** ```bash curl "https://api.aureahub.com/v1/wallets/client/challenge?chain=gnosis&address=0x71C7656EC7ab88b098defB751B7401B5f6d8976F" \ -H "Authorization: Bearer YOUR_TOKEN" ``` ## Signing the Challenge Sign the **32 bytes** that the hex string decodes to — not the 64-character text. The same rule applies to the [Migration Challenge](https://docs.aureahub.com/docs/wallets-migration-challenge.md). - **EVM chains** — EIP-191 `personal_sign` over the challenge bytes, made with the new key. Send the 0x-prefixed signature as `signedChallenge`. - **Solana** — Ed25519 detached signature over the challenge bytes, sent **hex-encoded** (not base58). ```javascript import { getBytes } from 'ethers'; // EVM: EIP-191 personal_sign over the 32 challenge bytes const signedChallenge = await localWallet.signMessage(getBytes('0x' + challenge)); ``` ```javascript import nacl from 'tweetnacl'; // Solana: Ed25519 signature over the 32 challenge bytes, hex-encoded const signedChallenge = Buffer.from( nacl.sign.detached(Buffer.from(challenge, 'hex'), keypair.secretKey) ).toString('hex'); ``` ## Implementation ```javascript import { Wallet, getBytes } from 'ethers'; // 1. Generate the key on the device — it never leaves it const localWallet = Wallet.createRandom(); // 2. Ask for a challenge bound to this address const params = new URLSearchParams({ chain: 'gnosis', address: localWallet.address }); const res = await fetch(`https://api.aureahub.com/v1/wallets/client/challenge?${params}`, { headers: { Authorization: `Bearer ${accessToken}` } }); if (!res.ok) throw new Error(`Challenge error: ${res.status}`); const { challenge, expiresAt } = await res.json(); // 3. Sign the challenge bytes, then call POST /v1/wallets/client before expiresAt const signedChallenge = await localWallet.signMessage(getBytes('0x' + challenge)); ``` Web version: https://docs.aureahub.com/#wallets-client-challenge --- # Register Client Wallet Create a non-custodial wallet whose private key stays on the user's device. Aurea verifies the signed challenge and stores only the address. ## Overview This is step 2 of client-side wallet registration, used on tenants whose `walletMode` is `non_custodial` (on those tenants `POST /v1/wallets/` returns `400`). First fetch a challenge with [Non-Custodial Challenge](https://docs.aureahub.com/docs/wallets-client-challenge.md) and sign it with the new key. The server loads the most recent unused challenge issued to the user, checks that it has not expired and that it was issued for the same `address`, and verifies the signature against that address. It then consumes the challenge and creates the wallet with `keyManagementScheme: "client_side"`. No private key, encrypted key or key share is stored. - **Primary:** when `isPrimary` is omitted, the wallet becomes primary if it is the user's first wallet on that chain and network type. - **Address format:** EVM addresses are stored checksummed; Solana addresses are stored as given. - **Signing later:** transfers and swaps from a `client_side` wallet are prepared by the server and signed on the device — see [Sending a Transaction](https://docs.aureahub.com/docs/guide-send-transaction.md). - **Gnosis gas airdrop:** if enabled for the tenant, a small xDAI amount (set by the deployment) is sent to a newly registered Gnosis mainnet wallet. The transfer is not awaited and never affects the response. ## Endpoint ### `POST /v1/wallets/client` Authentication: bearer token required. Verifies the signed challenge and creates a wallet with keyManagementScheme client_side. No key material is stored server-side. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `chain` | string | yes | Chain identifier, e.g. `gnosis`, `ethereum`, `solana`. Use `evm` to register the address on every supported EVM chain at once (see below). A testnet's chain registry id (e.g. `polygon-amoy`) is also accepted: the wallet is stored under the family chain (`polygon`) on that testnet. | | `address` | string | yes | The address the challenge was issued for. | | `signedChallenge` | string | yes | Signature over the 32 challenge bytes: 0x-prefixed EIP-191 signature (EVM) or hex-encoded Ed25519 signature (Solana). | | `label` | string | no | Friendly label, returned as `label`. | | `isPrimary` | boolean | no | Mark as primary. Default: `true` if this is the user's first wallet on this chain and network type, otherwise `false`. For `evm`, see below. | | `isTestnet` | boolean | no | Register as a testnet wallet (default: `false`). | **Responses** `201` Created ```json { "id": "3f8b2a1e-5c4d-4e7f-9a0b-1c2d3e4f5a6b", "chain": "gnosis", "address": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F", "walletType": "eoa", "isPrimary": true, "isTestnet": false, "label": null, "isWatchOnly": false, "keyManagementScheme": "client_side", "createdAt": "2026-09-11T10:00:00.000Z", "updatedAt": "2026-09-11T10:00:00.000Z" } ``` `400` Bad Request ```json { "statusCode": 400, "error": "BadRequestError", "message": "Address mismatch — the signed challenge was issued for a different address" } ``` `422` Wrong Signer ```json { "statusCode": 422, "error": "BlockchainError", "message": "Challenge signature does not match the wallet address", "code": "WRONG_SIGNER", "retryable": false, "details": { "code": "WRONG_SIGNER", "retryable": false } } ``` ## Multi-Chain EVM Registration With `chain: "evm"` the same address is registered as one wallet per supported EVM chain — `gnosis`, `ethereum`, `polygon`, `bsc`, `arbitrum`, `optimism` and `base` — in a single database transaction, using a single challenge. - Chains where this address is already registered in the tenant on the same network (`isTestnet`) are skipped instead of failing. An address registered on mainnet gets its testnet wallets, and the reverse. - The `gnosis` wallet is primary unless you pass `isPrimary: false`; the other chains are created as non-primary. - `label` and `isTestnet` apply to every wallet created. - The response is the `gnosis` wallet. ## Errors Error responses | | | | | --- | --- | --- | | 400 | — | Request validation failed — a required field is missing or empty. | | 400 | — | `No active wallet claim challenge found. Call GET /wallets/client/challenge first.` | | 400 | — | `Challenge has expired. Request a new one.` | | 400 | — | `Address mismatch — the signed challenge was issued for a different address` | | 400 | — | `Invalid Solana wallet address` | | 409 | — | `A wallet with this address already exists in this tenant` — single-chain registration only, when the address is already registered on the same chain and network (`isTestnet`). | | 422 | WRONG_SIGNER | The signature does not verify for `address`. | | 422 | SIGNATURE_DECODE_FAILED | The EVM signature could not be decoded. | | 500 | — | A malformed EVM `address` is not caught by validation and fails with `500`. | ## Implementation ```javascript import { Wallet, getBytes } from 'ethers'; const API = 'https://api.aureahub.com'; async function registerClientWallet(accessToken, chain = 'gnosis') { const bearer = { Authorization: `Bearer ${accessToken}` }; const device = Wallet.createRandom(); // keep device.privateKey in secure storage // 1. Challenge bound to the new address const q = new URLSearchParams({ chain, address: device.address }); const { challenge } = await fetch(`${API}/v1/wallets/client/challenge?${q}`, { headers: bearer }) .then(r => r.json()); // 2. Register with the signature over the challenge bytes const res = await fetch(`${API}/v1/wallets/client`, { method: 'POST', headers: { ...bearer, 'Content-Type': 'application/json' }, body: JSON.stringify({ chain, address: device.address, signedChallenge: await device.signMessage(getBytes('0x' + challenge)), label: 'My wallet' }) }); if (!res.ok) { const err = await res.json(); throw new Error(err.message || `Registration failed: ${res.status}`); } return { wallet: await res.json(), device }; // wallet.keyManagementScheme === 'client_side' } ``` Web version: https://docs.aureahub.com/#wallets-client-register --- # Confirm Client Custody Final step of migrating a server-custodial wallet to client-side custody: prove the device holds the exported key, and the server removes its own copy. ## Overview Step 4 of the key migration flow — [Request Export Token](https://docs.aureahub.com/docs/wallets-export-token.md) → [Export Encrypted Key](https://docs.aureahub.com/docs/wallets-export-key.md) → [Migration Challenge](https://docs.aureahub.com/docs/wallets-migration-challenge.md) → Confirm Client Custody. See the [Non-Custodial Key Lifecycle](https://docs.aureahub.com/docs/guide-non-custodial-keys.md) guide for the whole sequence. The server loads the wallet's most recent unused migration challenge and verifies that `signedChallenge` was produced by the wallet's own private key (EIP-191 over the 32 challenge bytes for EVM wallets; hex-encoded Ed25519 for Solana). On success it performs, in one database transaction: - marks the challenge as used; - clears the server-held key material stored in the database and sets `keyManagementScheme` to `client_side`; - marks the wallet's key for deletion and removes the wallet's permit nonce reservations. A background job, which runs every 5 minutes, then disables the key share held in the key vault and marks the deletion as complete. After this call the server can no longer sign on behalf of the wallet. > ⚠️ This cannot be undone through the API: [Cancel Migration](https://docs.aureahub.com/docs/wallets-cancel-migration.md) only works while the wallet is `client_side_pending`. Decrypt the exported key and store it securely on the device **before** calling this endpoint — from then on, treat the device copy as the only copy. ## Endpoint ### `POST /v1/wallets/confirm-client-custody` Authentication: bearer token required. Validates the signed migration challenge, then removes server-side key material and sets keyManagementScheme to client_side. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `walletId` | string (uuid) | yes | Wallet being migrated. | | `signedChallenge` | string | yes | Signature over the 32 bytes of the migration challenge, made with the wallet's private key: 0x-prefixed EIP-191 signature (EVM) or hex-encoded Ed25519 signature (Solana). | **Responses** `200` OK ```json { "migrated": true } ``` `400` Bad Request ```json { "statusCode": 400, "error": "BadRequestError", "message": "No active migration challenge found. Call GET /migration-challenge first." } ``` `422` Wrong Signer ```json { "statusCode": 422, "error": "BlockchainError", "message": "Challenge signature does not match the wallet address", "code": "WRONG_SIGNER", "retryable": false, "details": { "code": "WRONG_SIGNER", "retryable": false } } ``` ## Errors Error responses | | | | | --- | --- | --- | | 400 | — | `No active migration challenge found. Call GET /migration-challenge first.` | | 400 | — | `Migration challenge has expired. Request a new one.` | | 404 | — | `Wallet not found` — unknown id, or the wallet belongs to another user. | | 409 | — | `Wallet is already fully migrated to client-side custody` | | 409 | — | `Key deletion is already pending. Contact support if stuck.` | | 422 | WRONG_SIGNER | The signature was not made by the wallet's key. | | 422 | SIGNATURE_DECODE_FAILED | The EVM signature could not be decoded. | ## Implementation ```javascript import { getBytes } from 'ethers'; // localWallet: ethers Wallet built from the key decrypted in "Export Encrypted Key" async function confirmClientCustody(accessToken, walletId, localWallet) { const API = 'https://api.aureahub.com'; const bearer = { Authorization: `Bearer ${accessToken}` }; const { challenge } = await fetch( `${API}/v1/wallets/migration-challenge?walletId=${walletId}`, { headers: bearer } ).then(r => r.json()); const res = await fetch(`${API}/v1/wallets/confirm-client-custody`, { method: 'POST', headers: { ...bearer, 'Content-Type': 'application/json' }, body: JSON.stringify({ walletId, signedChallenge: await localWallet.signMessage(getBytes('0x' + challenge)) }) }); if (!res.ok) throw new Error((await res.json()).message); return res.json(); // { migrated: true } } ``` Web version: https://docs.aureahub.com/#wallets-confirm-custody --- # Register Device Key Attach a device public key to a wallet, authorised by a signature from the wallet's own private key. ## Overview Stores `devicePublicKey` on the wallet record. A wallet holds one device key: a later successful call replaces the previous value. The OpenAPI description states the key is used for push-notification-based signing flows. In this API version the stored key is not part of the wallet object returned by the wallet endpoints, and no other part of the API uses it. It is not used to encrypt key material — key export uses the ephemeral key passed to [Export Encrypted Key](https://docs.aureahub.com/docs/wallets-export-key.md). The request must carry a signature over the wallet's most recent unused [Migration Challenge](https://docs.aureahub.com/docs/wallets-migration-challenge.md), made with the **wallet's** private key (EIP-191 over the 32 challenge bytes for EVM wallets; hex-encoded Ed25519 for Solana). A successful call consumes that challenge. > ⚠️ `GET /v1/wallets/migration-challenge` returns `409` for wallets that are already `client_side` — including every wallet created with `POST /v1/wallets/client`. In the current implementation this endpoint can therefore only be completed for a wallet that is not yet `client_side`, in practice during a key migration after the key has been exported. Because it consumes the challenge, request a new one before calling [Confirm Client Custody](https://docs.aureahub.com/docs/wallets-confirm-custody.md). ## Endpoint ### `POST /v1/wallets/register-device` Authentication: bearer token required. Associates a device public key with a wallet after verifying a migration-challenge signature made with the wallet's key. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `walletId` | string (uuid) | yes | Wallet to attach the device key to. | | `signedChallenge` | string | yes | Signature over the 32 bytes of the wallet's migration challenge, made with the wallet's private key. | | `devicePublicKey` | string | yes | Base64-encoded device public key. Stored as provided; its format is not validated. | **Responses** `200` OK ```json { "registered": true } ``` `400` Bad Request ```json { "statusCode": 400, "error": "BadRequestError", "message": "No active challenge found. Call GET /migration-challenge first." } ``` ## Errors Error responses | | | | | --- | --- | --- | | 400 | — | `No active challenge found. Call GET /migration-challenge first.` | | 400 | — | `Challenge has expired. Request a new one.` | | 404 | — | `Wallet not found` — unknown id, or the wallet belongs to another user. | | 422 | WRONG_SIGNER | The signature was not made by the wallet's key. | | 422 | SIGNATURE_DECODE_FAILED | The EVM signature could not be decoded. | ## Implementation ```javascript import { getBytes } from 'ethers'; // walletKey: ethers Wallet for the wallet's own private key (not the device key) async function registerDeviceKey(accessToken, walletId, walletKey, devicePublicKeyBase64) { const API = 'https://api.aureahub.com'; const bearer = { Authorization: `Bearer ${accessToken}` }; const { challenge } = await fetch( `${API}/v1/wallets/migration-challenge?walletId=${walletId}`, { headers: bearer } ).then(r => r.json()); const res = await fetch(`${API}/v1/wallets/register-device`, { method: 'POST', headers: { ...bearer, 'Content-Type': 'application/json' }, body: JSON.stringify({ walletId, signedChallenge: await walletKey.signMessage(getBytes('0x' + challenge)), devicePublicKey: devicePublicKeyBase64 }) }); if (!res.ok) throw new Error((await res.json()).message); return res.json(); // { registered: true } } ``` Web version: https://docs.aureahub.com/#wallets-register-device --- # Request Export Token Start migrating a server-custodial wallet to client-side custody: re-confirm the user's password and receive a one-time export token. ## Overview Step 1 of the key migration flow described in [Non-Custodial Key Lifecycle](https://docs.aureahub.com/docs/guide-non-custodial-keys.md). The server verifies the user's password and then, in one database transaction, invalidates any earlier unused export token for the wallet, stores a new token valid for **10 minutes**, and moves the wallet to `keyManagementScheme: "client_side_pending"`. - The token is returned once, as 64 hex characters (32 random bytes). The server stores only its SHA-256 hash. - Send it in the `X-Export-Token` header of [Export Encrypted Key](https://docs.aureahub.com/docs/wallets-export-key.md). - While the wallet is `client_side_pending`, its server-held key material is untouched and the migration can still be cancelled. Transfers and swaps for the wallet are, however, prepared for client-side signing, so finish the export promptly or cancel. - The call returns `409` if the wallet is already `client_side_pending` or `client_side`, or is an MPC wallet (`mpc_tss`) with no server-held key. If a token expires unused, call [Cancel Migration](https://docs.aureahub.com/docs/wallets-cancel-migration.md) first, then request a new token. > ⚠️ This call does not check whether key export is enabled for your tenant — `POST /v1/wallets/export-key` does, and returns `400` if it is not. In that case the wallet stays `client_side_pending` until you cancel the migration. ## Endpoint ### `POST /v1/wallets/request-export-token` Authentication: bearer token required. Verifies the password, issues a one-time export token (TTL 10 min) and moves the wallet to client_side_pending. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `walletId` | string (uuid) | yes | Server-custodial wallet to migrate. | | `password` | string | yes | The user's account password. | **Responses** `200` OK ```json { "exportToken": "37b6466d6bf9045a6800da16f3d821cb8b2485492a3c896cc15063c58ea7874d" } ``` `400` Bad Request ```json { "statusCode": 400, "error": "BadRequestError", "message": "Invalid password" } ``` ## Errors Error responses | | | | | --- | --- | --- | | 400 | — | `Invalid password` | | 404 | — | `Wallet not found` — unknown id, or the wallet belongs to another user. | | 409 | — | `Migration is already in progress or completed for this wallet` | | 409 | — | `This is a self-custodial MPC wallet — there is no server-held key to export.` | ## Implementation ```javascript async function requestExportToken(accessToken, walletId, password) { const res = await fetch('https://api.aureahub.com/v1/wallets/request-export-token', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ walletId, password }) }); if (!res.ok) throw new Error((await res.json()).message); const { exportToken } = await res.json(); return exportToken; // use as X-Export-Token within 10 minutes } ``` Web version: https://docs.aureahub.com/#wallets-export-token --- # Export Encrypted Key Step 2 of key migration: receive the wallet's private key encrypted to an ephemeral X25519 key generated on the device. ## Overview Call this after [Request Export Token](https://docs.aureahub.com/docs/wallets-export-token.md), while the wallet is `client_side_pending`. Send the raw token in the `X-Export-Token` header and the device's ephemeral X25519 public key in the body. The server decrypts the wallet key, encrypts it to that ephemeral key, and returns the ciphertext. - **Tenant setting:** key export must be enabled for the tenant (`keyExportEnabled` in the tenant configuration); otherwise the call returns `400`. - **Once per wallet:** after a successful export, every further call for the wallet returns `410`. The token is single-use and expires 10 minutes after it was issued. - **No way back:** once the key has been exported, [Cancel Migration](https://docs.aureahub.com/docs/wallets-cancel-migration.md) returns `409`. Continue with [Migration Challenge](https://docs.aureahub.com/docs/wallets-migration-challenge.md) and [Confirm Client Custody](https://docs.aureahub.com/docs/wallets-confirm-custody.md). - The server keeps its own key material until custody is confirmed. > ℹ️ Required header: `X-Export-Token: ` — the 64-character hex value returned by `POST /v1/wallets/request-export-token`. ## Endpoint ### `POST /v1/wallets/export-key` Authentication: bearer token required. Returns the wallet private key encrypted with X25519 ECDH + HKDF-SHA256 + AES-256-GCM. Requires the X-Export-Token header. One export per wallet. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `walletId` | string (uuid) | yes | Wallet in `client_side_pending` state. | | `clientEphemeralPublicKey` | string | yes | Base64 of the raw 32-byte X25519 public key generated on the device for this export. | **Responses** `200` OK ```json { "keyType": "evm", "encryptedPayload": "base64 AES-256-GCM ciphertext", "ephemeralPublicKey": "base64 raw 32-byte X25519 server public key", "iv": "base64 12-byte IV", "tag": "base64 16-byte GCM auth tag", "exportedAt": "2026-09-11T10:02:13.512Z" } ``` `400` Export Disabled ```json { "statusCode": 400, "error": "BadRequestError", "message": "Key export is not enabled for this tenant. Enable it in the admin settings." } ``` ## Encryption Scheme 1. The device generates an ephemeral X25519 key pair and sends the raw 32-byte public key, base64-encoded, as `clientEphemeralPublicKey`. 2. The server generates its own ephemeral X25519 key pair and computes the ECDH shared secret. 3. AES key = HKDF-SHA256(shared secret, salt: empty, info: `"aurea-key-export-v1"`, length: 32 bytes). 4. The plaintext is encrypted with AES-256-GCM under a random 12-byte IV. The response carries the ciphertext, the server's raw 32-byte ephemeral public key, the IV and the 16-byte auth tag, each base64-encoded. 5. The decrypted plaintext is UTF-8 JSON: `{"privateKey": "0x…"}` when `keyType` is `evm`, or `{"keypairHex": "…"}` (hex of the Solana secret key, 128 hex characters) when `keyType` is `solana`. `keyType` is `solana` for wallets on chain `solana` and `evm` for every other chain. ## Errors Error responses | | | | | --- | --- | --- | | 400 | — | Missing `X-Export-Token` header or invalid body. | | 400 | — | `Key export is not enabled for this tenant. Enable it in the admin settings.` | | 400 | — | `clientEphemeralPublicKey must be a 32-byte X25519 public key (base64)` — the token is not consumed. | | 404 | — | `Wallet not found` — unknown id, or the wallet belongs to another user. | | 409 | — | `Wallet is not in client_side_pending state. Call request-export-token first.` | | 410 | — | `Key has already been exported to a device for this wallet` | | 410 | — | `Export token not found or invalid`, `Export token has already been used` or `Export token has expired. Request a new one.` — while the wallet is `client_side_pending`, a new token can only be requested after cancelling the migration. | ## Implementation Node.js, using the built-in `crypto` module: ```javascript import crypto from 'node:crypto'; const X25519_SPKI_PREFIX = Buffer.from('302a300506032b656e032100', 'hex'); async function exportWalletKey(accessToken, walletId, exportToken) { // 1. Ephemeral X25519 key pair for this export only const eph = crypto.generateKeyPairSync('x25519'); const clientEphemeralPublicKey = eph.publicKey .export({ type: 'spki', format: 'der' }) .subarray(-32) // raw 32-byte key .toString('base64'); // 2. Request the encrypted key const res = await fetch('https://api.aureahub.com/v1/wallets/export-key', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'X-Export-Token': exportToken }, body: JSON.stringify({ walletId, clientEphemeralPublicKey }) }); if (!res.ok) throw new Error((await res.json()).message); const out = await res.json(); // 3. Decrypt: ECDH -> HKDF-SHA256 -> AES-256-GCM const serverPublicKey = crypto.createPublicKey({ key: Buffer.concat([X25519_SPKI_PREFIX, Buffer.from(out.ephemeralPublicKey, 'base64')]), format: 'der', type: 'spki' }); const shared = crypto.diffieHellman({ privateKey: eph.privateKey, publicKey: serverPublicKey }); const aesKey = Buffer.from( crypto.hkdfSync('sha256', shared, Buffer.alloc(0), Buffer.from('aurea-key-export-v1'), 32) ); const decipher = crypto.createDecipheriv('aes-256-gcm', aesKey, Buffer.from(out.iv, 'base64')); decipher.setAuthTag(Buffer.from(out.tag, 'base64')); const plaintext = Buffer.concat([ decipher.update(Buffer.from(out.encryptedPayload, 'base64')), decipher.final() ]); // EVM: { privateKey: "0x..." } Solana: { keypairHex: "..." } return { keyType: out.keyType, secret: JSON.parse(plaintext.toString('utf8')) }; } ``` Web version: https://docs.aureahub.com/#wallets-export-key --- # Migration Challenge Get a one-time challenge to sign with an existing wallet's private key — consumed by Confirm Client Custody or Register Device Key. ## Overview Step 3 of the key migration flow: after [Export Encrypted Key](https://docs.aureahub.com/docs/wallets-export-key.md), the device proves it holds the key by signing this challenge. The signature is then submitted to [Confirm Client Custody](https://docs.aureahub.com/docs/wallets-confirm-custody.md) (or [Register Device Key](https://docs.aureahub.com/docs/wallets-register-device.md)) — whichever of the two is called next consumes it. - The challenge is 32 random bytes, hex-encoded (64 characters), valid for **5 minutes**, and bound to the user and the wallet. - Sign the 32 bytes the hex decodes to, with the wallet's private key: EIP-191 `personal_sign` for EVM wallets, or an Ed25519 signature sent hex-encoded for Solana wallets. See [Non-Custodial Challenge](https://docs.aureahub.com/docs/wallets-client-challenge.md) for examples. - Only one unused challenge can exist per wallet. While one exists, this endpoint returns `409`, so request a challenge only when you are ready to sign it, and submit the signature within its 5-minute lifetime. - Wallets that are already `client_side` are rejected with `409`. ## Endpoint ### `GET /v1/wallets/migration-challenge` Authentication: bearer token required. Issues a 32-byte random challenge (TTL 5 min) to sign with the wallet's private key. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `walletId` | string (uuid) | yes | Wallet whose key will sign the challenge. | **Responses** `200` OK ```json { "challenge": "b54b30a6f3fbdd6cd84bfdd85aca1b2dc58c9c6b6d6c180e1c7a9bb7bd556f4c", "expiresAt": "2026-09-11T10:05:00.000Z" } ``` `404` Not Found ```json { "statusCode": 404, "error": "NotFoundError", "message": "Wallet not found" } ``` **Example request** ```bash curl "https://api.aureahub.com/v1/wallets/migration-challenge?walletId=3f8b2a1e-5c4d-4e7f-9a0b-1c2d3e4f5a6b" \ -H "Authorization: Bearer YOUR_TOKEN" ``` ## Errors Error responses | | | | | --- | --- | --- | | 400 | — | Request validation failed — `walletId` missing or not a UUID. | | 404 | — | `Wallet not found` — unknown id, or the wallet belongs to another user. | | 409 | — | `Wallet is already fully migrated to client-side custody` | | 409 | — | `A migration challenge is already pending for this wallet. Sign the existing challenge or wait for it to expire.` | ## Implementation ```javascript import { getBytes } from 'ethers'; // Fetch and sign in one go — the challenge lives for 5 minutes async function signMigrationChallenge(accessToken, walletId, walletKey) { const res = await fetch( `https://api.aureahub.com/v1/wallets/migration-challenge?walletId=${walletId}`, { headers: { Authorization: `Bearer ${accessToken}` } } ); if (!res.ok) throw new Error((await res.json()).message); const { challenge } = await res.json(); // EVM: EIP-191 personal_sign over the challenge bytes, with the wallet's key return walletKey.signMessage(getBytes('0x' + challenge)); } ``` Web version: https://docs.aureahub.com/#wallets-migration-challenge --- # Cancel Migration Abort a key migration before the key has been exported, returning the wallet to server custody. ## Overview A migration started with [Request Export Token](https://docs.aureahub.com/docs/wallets-export-token.md) can be cancelled only while the wallet is `client_side_pending` **and** no key has been exported yet. The user's password is verified again. On success the server, in one database transaction: - invalidates any unused export token for the wallet; - restores `keyManagementScheme` to `sss_2of2_akv` if the wallet has a database key share, or `aes_single` otherwise; - clears any key-deletion state. Server-held key material is never removed while a wallet is `client_side_pending`, so the wallet is server-custodial again immediately. > ⚠️ After a successful `POST /v1/wallets/export-key` this endpoint returns `409`: the migration must be completed with [Confirm Client Custody](https://docs.aureahub.com/docs/wallets-confirm-custody.md). ## Endpoint ### `POST /v1/wallets/cancel-migration` Authentication: bearer token required. Reverts a client_side_pending wallet to server custody. Blocked with 409 once the key has been exported. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `walletId` | string (uuid) | yes | Wallet whose migration should be cancelled. | | `password` | string | yes | The user's account password. | **Responses** `200` OK ```json { "cancelled": true } ``` `400` Bad Request ```json { "statusCode": 400, "error": "BadRequestError", "message": "Wallet is not in client_side_pending state" } ``` ## Errors Error responses | | | | | --- | --- | --- | | 400 | — | `Wallet is not in client_side_pending state` | | 400 | — | `Invalid password` | | 404 | — | `Wallet not found` — unknown id, or the wallet belongs to another user. | | 409 | — | `Key has already been transmitted to the device. Migration cannot be cancelled after export. Complete the migration instead.` | ## Implementation ```javascript async function cancelMigration(accessToken, walletId, password) { const res = await fetch('https://api.aureahub.com/v1/wallets/cancel-migration', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ walletId, password }) }); if (res.status === 409) { throw new Error('Key already exported — complete the migration instead'); } if (!res.ok) throw new Error((await res.json()).message); return res.json(); // { cancelled: true } } ``` Web version: https://docs.aureahub.com/#wallets-cancel-migration --- # Set Primary Wallet (atomic) Promote a wallet to primary and demote the user's other primary wallets on the same network type in a single database transaction. No request body. ## Overview Ownership is checked against the JWT: a wallet that belongs to another user of your tenant returns `403`, and an unknown id returns `404`. Inside one transaction the server then: 1. locks the target wallet row; 2. sets `isPrimary: false` on every other primary wallet of the same user on the same network type (mainnet or testnet); 3. sets `isPrimary: true` on the target wallet. > ⚠️ The demotion is **not limited to the target wallet's chain**: afterwards the target is the user's only primary wallet across all chains of that network type. The endpoint's OpenAPI description mentions the same chain, but the implementation applies no chain condition. Use [Set Primary Wallet](https://docs.aureahub.com/docs/wallets-primary.md) to change the primary wallet of one chain only. ## Endpoint ### `PATCH /v1/wallets/{id}/set-primary` Authentication: bearer token required. Atomically demotes the user's other primary wallets on the same network type and promotes the target wallet. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | Wallet to promote. | **Responses** `200` OK ```json { "id": "3f8b2a1e-5c4d-4e7f-9a0b-1c2d3e4f5a6b", "chain": "base", "address": "0x71C7656EC7ab88b098defB751B7401B5f6d8976F", "walletType": "eoa", "isPrimary": true, "isTestnet": false, "label": null, "isWatchOnly": false, "keyManagementScheme": "client_side", "hasBanking": false, "createdAt": "2026-09-01T08:00:00.000Z", "updatedAt": "2026-09-11T10:00:00.000Z" } ``` `403` Forbidden ```json { "error": "ForbiddenError", "message": "You do not have permission to update this wallet" } ``` `404` Not Found ```json { "error": "NotFoundError", "message": "Wallet not found" } ``` **Example request** ```bash curl -X PATCH https://api.aureahub.com/v1/wallets/WALLET_ID/set-primary \ -H "Authorization: Bearer YOUR_TOKEN" ``` ## Compared with PATCH /primary | | | | | --- | --- | --- | | Request body | **/primary:** `{ "isPrimary": true }` required | **/set-primary:** None | | Wallets demoted | **/primary:** Previous primary on the same chain and network type (database trigger) | **/set-primary:** All other primary wallets of the user on the same network type, on every chain | | Another user's wallet | **/primary:** `404` | **/set-primary:** `403` | | Already primary | **/primary:** Returned unchanged | **/set-primary:** Demotion still runs | ## Implementation ```javascript async function makeOnlyPrimary(token, walletId) { const res = await fetch( `https://api.aureahub.com/v1/wallets/${walletId}/set-primary`, { method: 'PATCH', headers: { Authorization: `Bearer ${token}` } } ); if (res.status === 403) throw new Error('Wallet belongs to another user'); if (!res.ok) throw new Error((await res.json()).message); return res.json(); // wallet object with isPrimary: true } ``` Web version: https://docs.aureahub.com/#wallets-set-primary --- # MPC Config Discover whether MPC (threshold-signature) wallets are enabled for the tenant and whether the current user already has an MPC wallet provisioned. ## Overview Aurea's **MPC wallets** are non-custodial 2-of-3 threshold-signature wallets. The three shares are held by the user's **device**, by Aurea's HSM-backed **cosigner**, and by a user-controlled **backup** (recoverable via passkey or recovery code). Aurea alone can never move funds — every signature requires two cooperating shares. Call this endpoint at app startup to decide whether to offer the MPC onboarding flow. It returns two booleans plus, if a wallet exists, its address and chain so you can skip the DKG step and jump straight to the wallet UI. > 💡 MPC enrolment is tenant-gated. If `mpcEnabled` is `false`, the DKG, register, and sign endpoints will reject calls with `403`. ### `GET /v1/mpc/config` Authentication: bearer token required. Returns the MPC feature flag for the tenant and the current user's MPC wallet, if any. **Responses** `200` OK ```json { "mpcEnabled": true, "userHasWallet": true, "wallet": { "walletId": "mpc-wallet-uuid", "address": "0xAbCd...", "chain": "base", "curve": "secp256k1", "isTestnet": false } } ``` `200` No Wallet ```json { "mpcEnabled": true, "userHasWallet": false, "wallet": null } ``` ## Implementation ```javascript // Decide whether to show MPC onboarding or jump to the wallet async function loadMpcState(token) { const r = await fetch('https://api.aureahub.com/v1/mpc/config', { headers: { Authorization: `Bearer ${token}` } }); const cfg = await r.json(); if (!cfg.mpcEnabled) return { mode: 'unavailable' }; if (!cfg.userHasWallet) return { mode: 'onboard' }; // run DKG return { mode: 'ready', wallet: cfg.wallet }; // existing wallet } ``` Web version: https://docs.aureahub.com/#mpc-config --- # Start DKG Ceremony Begin a 2-of-3 distributed key generation ceremony — the first step in provisioning a new MPC wallet. ## Overview This endpoint creates a relay session and triggers Aurea's HSM-backed cosigner to join. The browser-side party then joins via `/relay/session/{id}/join` and runs the DKG message loop with the cosigner. After the ceremony completes, the browser uploads its share to encrypted backup (passkey / recovery code) and the relay returns a verified Ethereum address. Finalise by calling **Register MPC Wallet** with the `sessionId`, `walletId`, and the recovered `address`. Aurea runs one of two threshold-signature engines, chosen per ceremony via the `scheme` field: the audited **CGGMP** (Dfns cggmp21) engine — recommended, with instant address generation — or the legacy **GG18** (tss-lib) engine. Both produce standard secp256k1 wallets whose addresses and signatures are indistinguishable on-chain, and both use the identical relay/join message loop. When `scheme` is omitted the ceremony defaults to `gg18`; pass `scheme: "cggmp"` for the audited engine. > ⚠️ Aurea NEVER sees the device or backup shares. If you lose both, the wallet is unrecoverable. Always verify the user has at least one backup factor (passkey or recovery code) before starting DKG. ### `POST /v1/mpc/wallets/dkg/start` Authentication: bearer token required. Creates a relay session, triggers the AUREA cosigner, returns the sessionId and provisional walletId. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `chain` | string | yes | Chain the wallet will operate on (e.g. base, ethereum, polygon, gnosis) | | `curve` | string | no | Curve (default: secp256k1) | | `scheme` | string | no | MPC engine: 'cggmp' (audited Dfns cggmp21 — recommended) or 'gg18' (legacy tss-lib). Default: 'gg18'. | | `isTestnet` | boolean | no | Provision against testnet (default: false) | **Responses** `200` OK ```json { "sessionId": "ses_01J…", "walletId": "mpc-wallet-uuid" } ``` `403` Forbidden ```json { "error": "MPC not enabled for this tenant" } ``` ## Implementation ```javascript // 1. Server-side: trigger the AUREA cosigner + reserve a walletId const { sessionId, walletId } = await fetch( 'https://api.aureahub.com/v1/mpc/wallets/dkg/start', { method: 'POST', headers: { Authorization: `Bearer ${hubJwt}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ chain: 'base', isTestnet: false }) } ).then(r => r.json()); // 2. Browser-side: join the session, run the DKG message loop with the engine // (loadEngineBrowser drives the proven WASM party — same wire envelope on every leg) // → after the loop completes, the engine returns { address, deviceShare, backupShare } // 3. Store deviceShare locally, encrypt backupShare with the user's passkey // 4. Call POST /v1/mpc/wallets to finalise — see "Register MPC Wallet" ``` Web version: https://docs.aureahub.com/#mpc-dkg-start --- # Register MPC Wallet Finalise a completed DKG ceremony — Aurea verifies the address against the relay record and persists the wallet. ## Overview Called by the browser after the DKG loop has converged. Aurea recomputes the public-key → address derivation from the broadcast shares posted to the relay, and only persists the wallet if it matches the address the client claims. This prevents a malicious client from registering an address it cannot actually sign for. Once registered, the wallet behaves like any other Aurea wallet — it appears in `GET /v1/wallets/`, can be the primary wallet, and can be used as the source/destination in transactions and swaps. ### `POST /v1/mpc/wallets` Authentication: bearer token required. Address-verified registration of a wallet whose DKG ceremony has completed. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `sessionId` | string | yes | sessionId returned by /dkg/start | | `walletId` | string | yes | walletId returned by /dkg/start | | `chain` | string | yes | Must match the chain passed to /dkg/start. A testnet's chain registry id (e.g. `polygon-amoy`) is stored under the family chain (`polygon`) on that testnet. | | `curve` | string | no | Curve (default: secp256k1) | | `address` | string | yes | 0x-prefixed address the engine derived locally | | `label` | string | no | Friendly label for the wallet | **Responses** `201` Created ```json { "id": "mpc-wallet-uuid", "chain": "base", "address": "0xAbCd...", "label": "Primary MPC", "walletType": "mpc", "isPrimary": false, "isTestnet": false, "created_at": "2026-06-12T10:00:00Z" } ``` `409` Mismatch ```json { "error": "address does not match relay shares" } ``` ## Implementation ```javascript // After the DKG loop returns { address } const wallet = await fetch('https://api.aureahub.com/v1/mpc/wallets', { method: 'POST', headers: { Authorization: `Bearer ${hubJwt}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId, walletId, chain: 'base', address, // 0x… from the engine label: 'Primary MPC' }) }).then(r => r.json()); ``` Web version: https://docs.aureahub.com/#mpc-wallet-register --- # Start Signing Ceremony Open a threshold-signing session against an MPC wallet — for either the normal DEVICE+AUREA flow or BACKUP-based recovery. ## Overview Signing an MPC transaction is a two-step process. First, the caller posts the message hash and committee to this endpoint, which creates a relay session and triggers the AUREA cosigner. Second, the device party joins the session and runs the ceremony loop. The final signature is recovered from the relay via `GET /relay/session/{id}/results`. ## Committees The MPC scheme is 2-of-3. Pick the committee based on which shares are available: - `["DEVICE","AUREA"]` — the normal path. The user has their device share; Aurea contributes its cosigner share. - `["BACKUP","AUREA"]` — recovery path. The user has lost their device but unlocked their backup share (passkey / recovery code). The `["DEVICE","BACKUP"]` committee is reserved for the self-custody / exit path and does **not** touch the Aurea cosigner — it runs entirely off-Aurea (see Cornerstone exit). ### `POST /v1/mpc/wallets/{walletId}/sign/start` Authentication: bearer token required. Creates a sign session, triggers the relevant AUREA cosigner, returns the sessionId. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `walletId` | string | yes | MPC wallet id (from Register MPC Wallet) | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `committee` | string[] | yes | Array of share monikers — e.g. ['DEVICE','AUREA'] or ['BACKUP','AUREA'] | | `msgHashHex` | string | yes | Keccak-256 hash (or curve-appropriate hash) of the message to sign, hex-encoded without 0x prefix | **Responses** `200` OK ```json { "sessionId": "ses_01J…", "committee": ["DEVICE", "AUREA"] } ``` `403` Forbidden ```json { "error": "wallet not owned by user" } ``` ## Implementation ```javascript // Sign an EVM transaction hash with DEVICE + AUREA const txHash = '…'; // hex, no 0x prefix (e.g. keccak256 of the RLP-encoded tx) const { sessionId } = await fetch( `https://api.aureahub.com/v1/mpc/wallets/${walletId}/sign/start`, { method: 'POST', headers: { Authorization: `Bearer ${hubJwt}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ committee: ['DEVICE', 'AUREA'], msgHashHex: txHash }) } ).then(r => r.json()); // Then: join the session as DEVICE, run the sign loop with the engine, recover // the signature via GET /v1/mpc/relay/session/{sessionId}/results ``` Web version: https://docs.aureahub.com/#mpc-sign-start --- # Service Token Bridge Exchange a first-party app session for a short-lived Hub JWT scoped to a single merchant — required to drive MPC ceremonies from a downstream product (e.g. aurea-pay). ## Overview Aurea's MPC Hub authenticates every relay/sign/DKG call with a JWT that carries `(sub, tenantId)`. First-party apps that orchestrate MPC for many merchants (typically a payment-link or POS product) cannot reuse their own session token — they need a scoped token per merchant. This endpoint accepts a service credential (shared between the app and the Hub) plus the target `tenantId` and `userId`, and returns a short-lived Hub JWT that the merchant's browser can then use for `/v1/mpc/*` calls. > ⚠️ The service credential is shared between trusted backends only. Never expose it to a browser or mobile client. ### `POST /v1/mpc/service/token` Authentication: bearer token required. Mints a short-lived Hub JWT scoped to (tenantId, userId). **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `tenantId` | string | yes | Target merchant's tenant id | | `userId` | string | yes | Target user (typically the merchant operator) | | `ttl` | integer | no | Token lifetime in seconds (default: 300, max: 3600) | **Responses** `200` OK ```json { "token": "eyJhbGciOiJIUzI1NiIs…", "expires_in": 300 } ``` ## Implementation ```javascript // Server-side proxy: mint a Hub JWT for the active merchant, hand it to the browser app.post('/api/mpc/hub-token', async (req, res) => { const { merchantTenantId, operatorUserId } = req.body; const r = await fetch('https://api.aureahub.com/v1/mpc/service/token', { method: 'POST', headers: { Authorization: `Bearer ${process.env.AUREA_HUB_SERVICE_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ tenantId: merchantTenantId, userId: operatorUserId }) }); res.json(await r.json()); // { token, expires_in } }); ``` Web version: https://docs.aureahub.com/#mpc-service-token --- # Admin: List MPC Wallets List all MPC wallets for a given tenant. Public metadata only — no key shares are ever exposed. ## Overview Tenant-admin endpoint used by ops dashboards to track MPC rollout. Returns wallet id, chain, address, owner user id, and creation timestamp. There is intentionally no way to read or export key shares through this API — those live exclusively on the user's device and in their encrypted backup. ### `GET /v1/mpc/admin/wallets/{tenantId}` Authentication: bearer token required. Lists public metadata for every MPC wallet under the tenant. Requires admin role. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `tenantId` | string | yes | Tenant whose MPC wallets to list | **Responses** `200` OK ```json { "items": [ { "id": "mpc-wallet-uuid", "userId": "user-uuid", "chain": "base", "address": "0xAbCd...", "isTestnet": false, "created_at": "2026-06-12T10:00:00Z" } ], "total": 1 } ``` Web version: https://docs.aureahub.com/#mpc-admin-list --- # Relay: Join Session Announce a party (DEVICE / BACKUP / AUREA) into an MPC ceremony session. ## Overview The relay is a thin message-broker layer used by every MPC ceremony — DKG, sign, and the self-custody exit. It holds **no key material**; it only forwards opaque protocol envelopes between parties identified by `moniker`. Each party announces itself with its long-lived public key so the others can authenticate inbound messages. ### `POST /v1/mpc/relay/session/{id}/join` Authentication: bearer token required. Registers a party in the session. Idempotent — re-joining returns 204. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Relay session id (from /dkg/start or /sign/start) | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `moniker` | string | yes | Party label — DEVICE, BACKUP, or AUREA | | `key` | string | yes | Hex-encoded party-long-term public key (for envelope authentication) | **Responses** `204` Joined Web version: https://docs.aureahub.com/#mpc-relay-join --- # Relay: List Parties Poll the roster of parties that have joined an MPC ceremony — used to wait for the AUREA cosigner before starting the message loop. ## Overview After triggering DKG or sign, the browser party joins and then polls this endpoint until `ready` flips to `true` (i.e. the AUREA cosigner has also joined). Once ready, both sides exchange envelopes via `/msg` / `/inbox` until the protocol completes. ### `GET /v1/mpc/relay/session/{id}/parties` Authentication: bearer token required. Returns the joined roster and whether the session is ready to proceed. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Relay session id | **Responses** `200` OK ```json { "ready": true, "parties": [ { "moniker": "DEVICE", "key": "0x04ab…" }, { "moniker": "AUREA", "key": "0x04cd…" } ] } ``` Web version: https://docs.aureahub.com/#mpc-relay-parties --- # Relay: Post Message Forward a ceremony envelope to another party (or broadcast to all). ## Overview Every protocol message produced by the engine — DKG round, signing share, etc. — is wrapped in an **envelope** and posted to the relay. The `wire` field is base64-encoded opaque protocol bytes; the relay treats it as a black box. Recipients pick up envelopes via `/inbox`. ### `POST /v1/mpc/relay/session/{id}/msg` Authentication: bearer token required. Posts one envelope. The relay never inspects the wire payload. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Relay session id | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `from` | string | yes | Sender moniker | | `to` | string | no | Recipient moniker — omit if broadcast=true | | `broadcast` | boolean | no | If true, delivered to every other party (default: false) | | `wire` | string | yes | Base64-encoded protocol envelope | **Responses** `204` Accepted Web version: https://docs.aureahub.com/#mpc-relay-msg --- # Relay: Read Inbox Drain inbound ceremony envelopes for a given party. ## Overview The browser party long-polls this endpoint to receive envelopes from the cosigner. Each call returns the envelopes that have arrived since the last drain and marks them as delivered — there is no need to ack. ### `GET /v1/mpc/relay/session/{id}/inbox` Authentication: bearer token required. Returns all undelivered envelopes for the given moniker, then marks them delivered. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Relay session id | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `moniker` | string | yes | Reader's party label (DEVICE / BACKUP / AUREA) | **Responses** `200` OK ```json [ { "from": "AUREA", "to": "DEVICE", "broadcast": false, "wire": "BASE64…" }, { "from": "AUREA", "to": null, "broadcast": true, "wire": "BASE64…" } ] ``` Web version: https://docs.aureahub.com/#mpc-relay-inbox --- # Relay: Get Message Hash Fetch the message-hash that the signing ceremony was opened against — used by a joining party to verify it is signing what it expects. ## Overview When a session is opened with `/sign/start`, the requested `msgHashHex` is recorded server-side. The DEVICE (or BACKUP) party fetches it here before contributing its share, so it can double-check the hash matches the locally computed hash of the transaction being signed — defense-in-depth against a tampered client. ### `GET /v1/mpc/relay/session/{id}/msghash` Authentication: bearer token required. Returns the msgHashHex captured at /sign/start. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Relay session id | **Responses** `200` OK ```json { "msgHashHex": "9a3b…f0" } ``` Web version: https://docs.aureahub.com/#mpc-relay-msghash --- # Relay: Post Result Publish a party's final ceremony output (e.g. its signature share, or a "done" marker) into the session. ## Overview Once the protocol loop converges, each party posts its terminal output here. The relay collects results per moniker and exposes them via `/results`. For signing, the combined signature is reconstructed by reading both parties' results. ### `POST /v1/mpc/relay/session/{id}/result` Authentication: bearer token required. Records a final result for the calling party. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Relay session id | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `moniker` | string | yes | Posting party label | | `result` | object | yes | Engine-defined result payload (e.g. signature share) | **Responses** `204` Recorded Web version: https://docs.aureahub.com/#mpc-relay-result --- # Relay: Read Results Read the final outputs posted by each party — the last step in any MPC ceremony. ## Overview The session terminator. After both parties have posted via `/result`, the orchestrating party polls this endpoint to combine the outputs into the final artefact — the wallet address for DKG, or the recovered signature for sign. ### `GET /v1/mpc/relay/session/{id}/results` Authentication: bearer token required. Returns a map of moniker → result payload. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Relay session id | **Responses** `200` OK ```json { "DEVICE": { "sigShare": "BASE64…" }, "AUREA": { "sigShare": "BASE64…" } } ``` Web version: https://docs.aureahub.com/#mpc-relay-results --- # Create Transaction Send a native coin or a token from a wallet the authenticated user owns. ## Overview This is the send endpoint for every wallet type. It stores a transaction record and then either signs and submits it for you (custodial wallets) or returns the hash your client must sign (non-custodial wallets). Which path runs depends on the wallet's `keyManagementScheme` — see **Custody Paths** below. - **Recipient:** pass exactly one of `toAddress` (an EVM `0x` address or a Solana base58 address) or `toUsername` (a user in your tenant). - **Chain:** `chain` defaults to the wallet's own chain. An EVM address is the same on every EVM network, so you can pass a different EVM chain to send from the same address there. - **Token:** set `tokenAddress` to the token contract (EVM) or mint (Solana). Omit it to send the native coin. > ⚠️ `amount` is an integer string in the token's smallest unit: `"10000000000000000"` is 0.01 of an 18-decimal coin, `"25000000"` is 25 of a 6-decimal token. A decimal such as `"0.01"` fails schema validation with `400`. See [Amount Units](https://docs.aureahub.com/docs/amounts.md). ## Endpoint ### `POST /v1/transactions/` Authentication: bearer token required. Creates a send from a wallet owned by the authenticated user. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `walletId` | string (uuid) | yes | Source wallet. Must belong to the authenticated user. | | `amount` | string | yes | Integer in the smallest unit (digits only). Must be greater than 0 and at most 1030. | | `toAddress` | string | no | Recipient address: `0x` followed by 40 hex characters, or a base58 string of 32–44 characters. Provide this or `toUsername`, not both. | | `toUsername` | string | no | Recipient username in your tenant (3–100 characters: letters, digits, `_`, `-`). Resolved to one of the recipient's wallets on the same network type (mainnet or testnet) and chain family (Solana or EVM), preferring their primary wallet. | | `chain` | string | no | Chain identifier. Defaults to the wallet's chain. | | `tokenAddress` | string | no | Token contract (EVM) or mint (Solana) address. Omit for the native coin. | | `gasLimit` | string | no | Gas limit (digits only). For non-custodial sends it replaces the server's estimate. | | `gasPrice` | string | no | Gas price in wei (digits only). Passed on only for custodial sends. | | `gasless` | boolean | no | Accepted by the schema (default `false`). The server decides whether a send is gasless — see Custody Paths. | **Responses** `201` Created (custodial) ```json { "id": "0b9d4c1e-3f6a-4b8e-9d2c-7a5e1f3b6c90", "txHash": "0x7f3a…", "chain": "gnosis", "type": "send", "status": "pending", "fromAddress": "0x2f4B…", "toAddress": "0x52908400098527886E0F7030069857D2E4169EE7", "token": { "address": null, "symbol": "xDAI", "name": null, "decimals": 18 }, "amount": "10000000000000000", "amountUsd": null, "amountEur": null, "fee": { "amount": null, "usd": null }, "gasUsed": null, "gasPrice": null, "blockNumber": null, "blockTimestamp": null, "createdAt": "2026-09-11T10:00:00.000Z", "updatedAt": "2026-09-11T10:00:01.000Z" } ``` `201` Created (non-custodial) ```json { "id": "0b9d4c1e-3f6a-4b8e-9d2c-7a5e1f3b6c90", "txHash": null, "status": "pending", "…": "other transaction fields as in the custodial example", "requiresClientSigning": true, "gasless": false, "preparedTxId": "5e0f7b8a-1c2d-4e3f-8a9b-0c1d2e3f4a5b", "txHashToSign": "0x4a7d…", "nonce": "12", "maxFeePerGas": "3000000000", "maxPriorityFeePerGas": "1500000000", "chainId": 100, "expiresAt": "2026-09-11T10:05:00.000Z" } ``` `201` Created (gasless) ```json { "id": "0b9d4c1e-3f6a-4b8e-9d2c-7a5e1f3b6c90", "txHash": null, "status": "pending", "…": "other transaction fields as in the custodial example", "requiresClientSigning": true, "gasless": true, "preparedTxId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "permitHashToSign": "0xc19e…", "nonce": "0", "expiresAt": "2026-09-11T10:05:00.000Z" } ``` `400` Bad Request ```json { "error": "Bad Request", "message": "Request validation failed" } ``` `400` Bad Request ```json { "error": "BadRequestError", "message": "Non-custodial Solana sends are not yet supported" } ``` `404` Not Found ```json { "error": "NotFoundError", "message": "Wallet not found" } ``` `422` Validation Error ```json { "statusCode": 422, "error": "ValidationError", "message": "Validation failed", "details": [ { "code": "custom", "message": "Must provide either toAddress OR toUsername (not both)", "path": [] } ] } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/transactions/ \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "walletId": "3f1c2b9e-8a4d-4c6e-9b2a-5d7e1f0a6c3b", "chain": "gnosis", "toAddress": "0x52908400098527886E0F7030069857D2E4169EE7", "amount": "10000000000000000" }' ``` ## Custody Paths | keyManagementScheme | What this endpoint does | | --- | --- | | `aes_single`, `sss_2of2_akv` | **Custodial.** Aurea signs and submits the transaction in the same call. EVM sends return `status: "pending"` with the on-chain `txHash`. Solana sends wait for confirmation and return `status: "confirmed"`. If submission fails, the record is saved as `failed` and the call returns `400` with `Failed to send transaction: …`. | | `client_side`, `client_side_pending`, `mpc_tss` | **Non-custodial, EVM only.** Aurea saves a `pending` record and returns `requiresClientSigning: true`, `txHash: null` and a `preparedTxId` that expires 5 minutes later (`expiresAt`). Sign and submit it with [Broadcast Transaction](https://docs.aureahub.com/docs/tx-broadcast.md). Solana wallets get `400` `Non-custodial Solana sends are not yet supported`. | ### Standard non-custodial send (`gasless: false`) - `txHashToSign` is the hash of an unsigned EIP-1559 transaction built and stored by the API. Sign it with the wallet key and broadcast with `v` = 0 or 1. - `maxFeePerGas` and `maxPriorityFeePerGas` are the network's current fee data multiplied by 1.5. - Gas limit: your `gasLimit` if given; otherwise 21000 for a native coin, and for a token the network estimate plus 30% (150000 if the estimate fails). - The numeric chain ID is looked up in the chain registry (or your tenant's enabled blockchain config). If none is found the call returns `400` `Chain "…" (isTestnet=…) is not registered in the chain registry`. ### Gasless token send (`gasless: true`) The API picks this path automatically when `tokenAddress` is set, the chain has a gasless transfer forwarder configured, and the token is registered as active with permit (EIP-2612) support. The response carries `permitHashToSign`, an EIP-712 digest, instead of `txHashToSign`. Sign it and broadcast with `v` = 27 or 28; a relayer then submits the transfer. Which chains and tokens qualify depends on the deployment's configuration. ## Errors For `400`, `401` and `404` the body is `{ "error", "message" }`. See [Error Handling](https://docs.aureahub.com/docs/errors.md). | Status | When | | --- | --- | | 400 | Schema validation failed (`Request validation failed`) — for example a decimal `amount`, a malformed address or a non-UUID `walletId`. Also: non-custodial Solana send; chain not found for a non-custodial send; token metadata could not be read (`Invalid token address or unable to fetch token information`); custodial submission failed (`Failed to send transaction: …`). | | 401 | Missing or invalid bearer token. | | 404 | `Wallet not found` (missing, or not owned by the caller); `User with username "…" not found`; `Recipient "…" does not have a … wallet`. | | 422 | Cross-field rules: neither or both of `toAddress` / `toUsername`; `amount` of 0; `amount` above 1030. The body includes `details`. | ## Implementation Token decimals are listed by `GET /v1/tokens/chain/{chain}`. Convert the human amount before sending, then branch on `requiresClientSigning`: ```javascript const API = 'https://api.aureahub.com'; // "1.5" with 6 decimals -> "1500000". Amount Units has a fuller helper. function toSmallest(amount, decimals) { const [whole, frac = ''] = String(amount).split('.'); const fraction = (frac + '0'.repeat(decimals)).slice(0, decimals); return (BigInt(whole || '0') * 10n ** BigInt(decimals) + BigInt(fraction || '0')).toString(); } async function createSend(token, { walletId, chain, toAddress, amount, decimals, tokenAddress }) { const res = await fetch(API + '/v1/transactions/', { method: 'POST', headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' }, body: JSON.stringify({ walletId, chain, toAddress, amount: toSmallest(amount, decimals), ...(tokenAddress ? { tokenAddress } : {}), }), }); const body = await res.json(); if (!res.ok) throw new Error(res.status + ' ' + body.message); return body; } const tx = await createSend(token, { walletId, chain: 'gnosis', toAddress, amount: '0.01', decimals: 18, }); if (tx.requiresClientSigning) { // Non-custodial: sign tx.txHashToSign (or tx.permitHashToSign when tx.gasless is true) // and call POST /v1/transactions/{id}/broadcast before tx.expiresAt. } else { // Custodial: poll GET /v1/transactions/{id}/status until confirmed or failed. } ``` Web version: https://docs.aureahub.com/#tx-create --- # Broadcast Transaction Submit the client's signature for a non-custodial send so Aurea can put it on-chain. ## Overview When [Create Transaction](https://docs.aureahub.com/docs/tx-create.md) returns `requiresClientSigning: true`, sign the returned hash on the client and send the signature components `v`, `r` and `s` here. The API rebuilds the stored transaction (or permit), checks that the signature recovers to the wallet address and submits it. It handles both standard and gasless sends, and updates the transaction record. ## Broadcast Signed Transaction ### `POST /v1/transactions/{id}/broadcast` Authentication: bearer token required. Checks a client signature for a prepared send and submits the transaction on-chain. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | Transaction `id` returned by Create Transaction. | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `preparedTxId` | string (uuid) | yes | `preparedTxId` from the Create Transaction response. | | `v` | integer | yes | `0` or `1` if you signed `txHashToSign`; `27` or `28` if you signed `permitHashToSign` (gasless). | | `r` | string | yes | `0x` followed by 64 hex characters. | | `s` | string | yes | `0x` followed by 64 hex characters. | **Responses** `200` OK ```json { "id": "0b9d4c1e-3f6a-4b8e-9d2c-7a5e1f3b6c90", "txHash": "0x7f3a…", "chain": "gnosis", "type": "send", "status": "pending", "fromAddress": "0x2f4B…", "toAddress": "0x52908400098527886E0F7030069857D2E4169EE7", "token": { "address": null, "symbol": "xDAI", "name": null, "decimals": 18 }, "amount": "10000000000000000", "amountUsd": null, "amountEur": null, "fee": { "amount": null, "usd": null }, "gasUsed": null, "gasPrice": null, "blockNumber": null, "blockTimestamp": null, "createdAt": "2026-09-11T10:00:00.000Z", "updatedAt": "2026-09-11T10:00:20.000Z" } ``` `400` Bad Request ```json { "statusCode": 400, "error": "BadRequestError", "message": "Transaction is not in pending state (current: confirmed)" } ``` `422` Wrong Signer ```json { "statusCode": 422, "error": "BlockchainError", "message": "Signature does not correspond to the expected wallet address", "code": "WRONG_SIGNER", "retryable": false, "details": { "code": "WRONG_SIGNER", "retryable": false } } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/transactions/0b9d4c1e-3f6a-4b8e-9d2c-7a5e1f3b6c90/broadcast \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "preparedTxId": "5e0f7b8a-1c2d-4e3f-8a9b-0c1d2e3f4a5b", "v": 1, "r": "0x…64 hex characters…", "s": "0x…64 hex characters…" }' ``` What happens: 1. The transaction must belong to the caller (`404` `Transaction not found`) and still be `pending`, and `preparedTxId` must be the one stored on it (`400`). 2. **Standard send:** the API re-derives the signing hash from the stored unsigned transaction, recovers the signer from `v`, `r`, `s` and requires it to equal the wallet address. `maxFeePerGas` must not exceed the configured cap (500 gwei by default). The signed transaction is then sent to the network. 3. **Gasless send:** the API re-derives the EIP-712 permit digest and checks the signer; a relayer submits the permit-and-transfer transaction and waits for one confirmation. 4. The record is updated with the on-chain `txHash` and `status: "pending"`. Follow it with [Get Tx Status](https://docs.aureahub.com/docs/tx-status.md). A prepared send can be broadcast once and expires 5 minutes after it was created. Rate limit: 20 requests per minute. ## Signing Sign the 32-byte hash itself — do not add an EIP-191 message prefix. `txHashToSign` is the keccak-256 hash of the unsigned EIP-1559 transaction; `permitHashToSign` is an EIP-712 digest. With ethers v6: ```javascript import { SigningKey } from 'ethers'; // tx = response of POST /v1/transactions/ with requiresClientSigning === true function signPreparedSend(tx, privateKey) { const key = new SigningKey(privateKey); if (tx.gasless) { const sig = key.sign(tx.permitHashToSign); return { preparedTxId: tx.preparedTxId, v: sig.v, r: sig.r, s: sig.s }; // v: 27 or 28 } const sig = key.sign(tx.txHashToSign); return { preparedTxId: tx.preparedTxId, v: sig.yParity, r: sig.r, s: sig.s }; // v: 0 or 1 } async function broadcast(token, tx, privateKey) { const res = await fetch('https://api.aureahub.com/v1/transactions/' + tx.id + '/broadcast', { method: 'POST', headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' }, body: JSON.stringify(signPreparedSend(tx, privateKey)), }); const body = await res.json(); if (!res.ok) throw new Error((body.code || res.status) + ': ' + body.message); return body; // transaction with txHash and status 'pending' } ``` > ℹ️ Wallets with `keyManagementScheme` `mpc_tss` produce the signature through the 2-of-3 MPC ceremony rather than with a single local key. The values you submit are the same: `preparedTxId`, `v`, `r`, `s`. ## Errors Errors from this endpoint that carry a `code` have the body `{ "statusCode", "error": "BlockchainError", "message", "code", "retryable", "details" }`. | Status | code | Cause | | --- | --- | --- | | 400 | — | `Transaction is not in pending state (current: …)`; `preparedTxId does not match the transaction record` (gasless: `… the stored preparedPermitId`); `Prepared transaction not found`. | | 404 | — | `Transaction not found` — missing, or not owned by the caller. | | 404 | PREPARED_TX_NOT_FOUND | The prepared transaction or gasless permit no longer matches a stored row. A gasless permit that belongs to a different transaction returns `422` with this code. | | 409 | PREPARED_TX_ALREADY_USED | Already broadcast. | | 410 | PREPARED_TX_EXPIRED | More than 5 minutes since creation. Create a new send. | | 422 | — | Body failed validation, for example `v` other than 0, 1, 27 or 28 (`Request validation failed`). | | 422 | SIGNATURE_DECODE_FAILED | `v`, `r`, `s` could not be decoded. | | 422 | WRONG_SIGNER | The signature does not recover to the wallet address. | | 422 | GAS_CAP_EXCEEDED | Standard send whose `maxFeePerGas` is above the cap. | | 500 | TX_SUBMISSION_FAILED | The relayer could not submit a gasless send (`retryable: true`). | Web version: https://docs.aureahub.com/#tx-broadcast --- # List Transactions Page through the authenticated user's blockchain transaction records, newest first. ## Overview Returns the transaction records the API holds for the authenticated user — sends created with [Create Transaction](https://docs.aureahub.com/docs/tx-create.md) as well as other on-chain records such as receives and swaps — ordered by creation time, newest first. Only mainnet records are returned unless you pass `isTestnet=true`. For a feed that also includes bank ramp fiat activity, use the [Unified Transaction Feed](https://docs.aureahub.com/docs/agg-tx-list.md). > ℹ️ `status` is one of `pending`, `confirmed`, `failed` or `cancelled`. Stored statuses are refreshed by [Get Tx Status](https://docs.aureahub.com/docs/tx-status.md) and by a background job, so a listed status can trail the chain. ## Endpoint ### `GET /v1/transactions/` Authentication: bearer token required. Returns the authenticated user's transaction records with page-based pagination. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `page` | integer | no | Page number, starting at 1. Default `1`. | | `limit` | integer | no | Results per page, 1–100. Default `20`. | | `walletId` | string (uuid) | no | Only records of this wallet. | | `chain` | string | no | Only records on this chain. | | `type` | string | no | `send`, `receive`, `swap`, `noah_payin` or `noah_payout`. See **Filters** before relying on it. | | `status` | string | no | `pending`, `confirmed`, `failed` or `cancelled`. | | `isTestnet` | boolean | no | `true` for testnet records, `false` for mainnet. Default `false`. | **Responses** `200` OK ```json { "data": [ { "id": "0b9d4c1e-3f6a-4b8e-9d2c-7a5e1f3b6c90", "txHash": "0x7f3a…", "chain": "gnosis", "type": "send", "status": "confirmed", "fromAddress": "0x2f4B…", "toAddress": "0x52908400098527886E0F7030069857D2E4169EE7", "token": { "address": null, "symbol": "xDAI", "name": null, "decimals": 18 }, "amount": "10000000000000000", "amountUsd": null, "amountEur": null, "fee": { "amount": "21000000000000", "usd": null }, "gasUsed": "21000", "gasPrice": null, "blockNumber": "41234567", "blockTimestamp": "2026-09-11T10:00:15.000Z", "createdAt": "2026-09-11T10:00:00.000Z", "updatedAt": "2026-09-11T10:01:00.000Z" } ], "pagination": { "page": 1, "limit": 20, "total": 1, "totalPages": 1 } } ``` The fields of each record are described on [Get Transaction](https://docs.aureahub.com/docs/tx-get.md). A `page` or `limit` outside its range, or an unknown `type` or `status` value, is rejected with `400`. ## Filters - `walletId`, `chain` and `status` match exactly. - `isTestnet` returns either testnet or mainnet records, never both. - `type`: in the current API source every accepted value is translated to the stored record type `transfer`, which is the type used for native-coin sends and receives. With `type` set, token transfers and swaps are not returned, and `send` and `receive` return the same records. To tell sends from receives, compare `fromAddress` with your wallet address, or use the Unified Transaction Feed, whose item types distinguish them. ## Implementation ```javascript async function listTransactions(token, { walletId, status, page = 1, limit = 20 } = {}) { const params = new URLSearchParams({ page: String(page), limit: String(limit) }); if (walletId) params.set('walletId', walletId); if (status) params.set('status', status); const res = await fetch('https://api.aureahub.com/v1/transactions/?' + params, { headers: { Authorization: 'Bearer ' + token }, }); if (!res.ok) throw new Error('List failed: ' + res.status); return res.json(); // { data, pagination: { page, limit, total, totalPages } } } // Walk every page async function* allTransactions(token, filters = {}) { for (let page = 1; ; page++) { const { data, pagination } = await listTransactions(token, { ...filters, page, limit: 100 }); yield* data; if (page >= pagination.totalPages) return; } } for await (const tx of allTransactions(token, { status: 'pending' })) { console.log(tx.id, tx.chain, tx.amount); } ``` Web version: https://docs.aureahub.com/#tx-list --- # Get Transaction Fetch one of the authenticated user's transaction records by ID. ## Overview Returns the record as it is currently stored — it does not query the chain. To refresh a `pending` transaction, call [Get Tx Status](https://docs.aureahub.com/docs/tx-status.md), which checks the chain and saves any change. A transaction that does not exist or belongs to another user returns `404`. ## Endpoint ### `GET /v1/transactions/{id}` Authentication: bearer token required. Returns a single transaction record of the authenticated user. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | Transaction ID. | **Responses** `200` OK ```json { "id": "0b9d4c1e-3f6a-4b8e-9d2c-7a5e1f3b6c90", "txHash": "0x7f3a…", "chain": "gnosis", "type": "send", "status": "confirmed", "fromAddress": "0x2f4B…", "toAddress": "0x52908400098527886E0F7030069857D2E4169EE7", "token": { "address": null, "symbol": "xDAI", "name": null, "decimals": 18 }, "amount": "10000000000000000", "amountUsd": null, "amountEur": null, "fee": { "amount": "21000000000000", "usd": null }, "gasUsed": "21000", "gasPrice": null, "blockNumber": "41234567", "blockTimestamp": "2026-09-11T10:00:15.000Z", "createdAt": "2026-09-11T10:00:00.000Z", "updatedAt": "2026-09-11T10:01:00.000Z" } ``` `404` Not Found ```json { "error": "NotFoundError", "message": "Transaction not found" } ``` ## Fields | Field | Meaning | | --- | --- | | txHash | On-chain hash, or `null` until the transaction has been submitted (for example a non-custodial send that has not been broadcast yet). | | type | Record type taken from the stored metadata, such as `send` or `receive`. | | status | `pending`, `confirmed`, `failed` or `cancelled`. | | token | Token `address`, `symbol`, `name` and `decimals` recorded when the transaction was created. `address` is `null` for the native coin; any value that was not recorded is `null`. | | amount | Amount as stored. For sends created with Create Transaction this is the smallest-unit integer string you sent. | | amountUsd, amountEur | Stored fiat values, or `null`. | | fee.amount | Gas used × gas price, in wei. Filled in when the status is resolved from an EVM receipt; otherwise `null`. | | gasUsed | Gas used, from the receipt. | | gasPrice | The `gasPrice` passed when the send was created, or `null`. | | blockNumber | Block number as a string, once resolved. | | blockTimestamp | Block time (ISO 8601), once resolved. | ## Implementation ```javascript async function getTransaction(token, txId) { const res = await fetch('https://api.aureahub.com/v1/transactions/' + txId, { headers: { Authorization: 'Bearer ' + token }, }); if (res.status === 404) return null; if (!res.ok) throw new Error('Failed to fetch transaction: ' + res.status); return res.json(); } // Human-readable amount from the smallest-unit string function formatAmount(tx) { const { decimals, symbol } = tx.token; if (decimals == null) return tx.amount + ' (smallest unit)'; const n = BigInt(tx.amount); const base = 10n ** BigInt(decimals); const frac = (n % base).toString().padStart(decimals, '0').replace(/0+$/, ''); return (n / base).toString() + (frac ? '.' + frac : '') + (symbol ? ' ' + symbol : ''); } ``` Web version: https://docs.aureahub.com/#tx-get --- # Get Transaction Status Check a transaction against the chain and get its current status and confirmation count. ## Overview Unlike [Get Transaction](https://docs.aureahub.com/docs/tx-get.md), this endpoint looks up the transaction receipt on the network (EVM chains) and saves any status change to the record. Poll it after a send until `status` is `confirmed` or `failed`. ## Endpoint ### `GET /v1/transactions/{id}/status` Authentication: bearer token required. Returns the transaction's status and confirmation count, refreshed from the chain where possible. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | Transaction ID. | **Responses** `200` OK ```json { "id": "0b9d4c1e-3f6a-4b8e-9d2c-7a5e1f3b6c90", "txHash": "0x7f3a…", "status": "confirmed", "confirmations": 3, "blockNumber": "41234567", "blockTimestamp": "2026-09-11T10:00:15.000Z" } ``` `404` Not Found ```json { "error": "NotFoundError", "message": "Transaction not found" } ``` ## How Status Is Resolved | Situation | Response | | --- | --- | | No receipt on the network yet | `pending`, `confirmations: 0`. | | Receipt with success status | `confirmed`; `confirmations` = latest block − transaction block + 1. | | Receipt with failure status | `failed`. | | Receipt lookup fails (RPC error, Solana, or no on-chain hash yet) | The stored status, with `confirmations: 0` and the stored block fields. | - When the status changes, the new status, block number, block time, gas used and fee are saved to the record. - `confirmed` means the receipt reports success; the API applies no confirmation threshold of its own. - Before a non-custodial send is broadcast, the record has no on-chain hash: this endpoint returns the stored `pending` status and a `txHash` placeholder that starts with `pending-`. Get Transaction returns `null` in that case. - Solana receipts are not looked up. Custodial Solana sends are already recorded as `confirmed` when the send call returns. - The API also runs a background job every 60 seconds that checks up to 20 `pending` EVM transactions that have an on-chain hash and are more than a minute old, so records advance even if you don't poll. ## Implementation The API does not prescribe a polling interval; pick one that suits your UI. ```javascript async function waitForFinalStatus(token, txId, { intervalMs = 5000, maxAttempts = 60 } = {}) { for (let attempt = 1; attempt <= maxAttempts; attempt++) { const res = await fetch('https://api.aureahub.com/v1/transactions/' + txId + '/status', { headers: { Authorization: 'Bearer ' + token }, }); if (res.status === 404) throw new Error('Transaction not found'); if (res.ok) { const s = await res.json(); if (s.status === 'confirmed' || s.status === 'failed') return s; } await new Promise(resolve => setTimeout(resolve, intervalMs)); } throw new Error('Still pending after ' + maxAttempts + ' checks'); } const result = await waitForFinalStatus(token, txId); console.log(result.status, result.blockNumber, result.confirmations); ``` Web version: https://docs.aureahub.com/#tx-status --- # Transaction Quote Estimate the network fee of a send, in the native coin and in EUR, before creating it. ## Overview Pass the wallet, chain, recipient, token and amount you intend to use with [Create Transaction](https://docs.aureahub.com/docs/tx-create.md). Nothing is stored or submitted. Rate limit: 60 requests per minute. ## Endpoint ### `POST /v1/transactions/quote` Authentication: bearer token required. Returns an estimated network fee for a prospective send. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `walletId` | string (uuid) | yes | Wallet that would send. Must belong to the authenticated user. | | `chain` | string | yes | Chain identifier. | | `toAddress` | string | yes | Recipient address. | | `tokenAddress` | string | no | Token contract address. Default `""` (native coin). | | `amount` | string | no | Amount in the smallest unit (digits). Default `"0"`. Used to estimate gas for token transfers. | | `isTestnet` | boolean | no | Default `false`. Set it to match the wallet's network. | **Responses** `200` OK ```json { "feeEur": 0.000027, "feeNativeAmount": "31500000000000", "feeNativeSymbol": "xDAI", "isFree": false, "estimatedArrival": "Instant" } ``` `404` Not Found ```json { "error": "NotFoundError", "message": "Wallet not found" } ``` | Field | Meaning | | --- | --- | | feeEur | Estimated fee in EUR (number). | | feeNativeAmount | Estimated fee in the native coin's smallest unit (wei on EVM chains), as a string. | | feeNativeSymbol | Native coin symbol from the chain registry. | | isFree | `true` when the quote is zero — see below for what that can mean. | | estimatedArrival | Always `"Instant"` in the current API. | ## How the Fee Is Estimated | Wallet / chain | Result | | --- | --- | | Custodial wallet (`aes_single`, `sss_2of2_akv`) | `feeEur: 0`, `feeNativeAmount: "0"`, `isFree: true`. | | Non-custodial wallet on `solana` or `algorand` | The same zero quote. | | Non-custodial wallet on an EVM chain | `feeNativeAmount` = gas limit × gas price. Gas limit: 21000 for a native coin; for a token, the network estimate plus 30% (150000 if the estimate fails). Gas price: the network's `maxFeePerGas` (or `gasPrice`) × 1.5. `feeEur` converts that amount as an 18-decimal value using the native token's EUR price from your tenant's token registry, or is `0` when no price is available. `isFree: false`. | | Non-custodial EVM wallet, but the RPC endpoint or fee data is unavailable | The zero quote with `isFree: true`. | > ℹ️ `isFree: true` is also returned when the fee could not be estimated, so for a non-custodial wallet it does not guarantee a free send. The quote prices a regular transfer and does not model gasless token sends. For non-custodial EVM wallets the chain must be in the chain registry, otherwise the call returns `400` (`Chain "…" (isTestnet=…) is not registered in the chain registry`). ## Implementation ```javascript async function quoteSend(token, { walletId, chain, toAddress, tokenAddress = '', amount = '0', isTestnet = false }) { const res = await fetch('https://api.aureahub.com/v1/transactions/quote', { method: 'POST', headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' }, body: JSON.stringify({ walletId, chain, toAddress, tokenAddress, amount, isTestnet }), }); const body = await res.json(); if (!res.ok) throw new Error(res.status + ' ' + body.message); return body; // { feeEur, feeNativeAmount, feeNativeSymbol, isFree, estimatedArrival } } const q = await quoteSend(token, { walletId, chain: 'gnosis', toAddress, amount: '10000000000000000', }); // Custodial wallets always quote zero; for non-custodial wallets a zero quote // can also mean the fee could not be estimated. const feeLabel = q.isFree ? 'No network fee quoted' : '≈ €' + q.feeEur.toFixed(4); ``` Web version: https://docs.aureahub.com/#tx-quote --- # Get Aggregated Transaction Fetch a single item of the Unified Transaction Feed by its ID. ## Overview The ID is looked up across the same five sources as the [Unified Transaction Feed](https://docs.aureahub.com/docs/agg-tx-list.md) — blockchain transaction records, bank ramp fiat deposits, the bank ramp pay-in transactions, the bank ramp payouts and the bank ramp pay-in checkout sessions — limited to the authenticated user. The item has the same shape as in the feed: its `type` tells you which fields are present. > ℹ️ `isTestnet` must match the item. It is compared with the testnet flag of blockchain records and with the sandbox flag of the bank ramp records; an item stored on the other network returns `404`. ## Endpoint ### `GET /v1/transactions/aggregated/{id}` Authentication: bearer token required. Returns one unified-feed item of the authenticated user. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | Item ID, as returned in the feed. | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `isTestnet` | boolean | no | Default `false`. Must match the item's testnet or sandbox flag. | **Responses** `200` OK ```json { "id": "0b9d4c1e-3f6a-4b8e-9d2c-7a5e1f3b6c90", "type": "blockchain_send", "status": "confirmed", "sortDate": "2026-09-11T10:00:15.000Z", "createdAt": "2026-09-11T10:00:00.000Z", "chain": "gnosis", "txHash": "0x7f3a…", "fromAddress": "0x2f4B…", "toAddress": "0x52908400098527886E0F7030069857D2E4169EE7", "fromUsername": "alice", "toUsername": "bob", "value": "10000000000000000", "isTestnet": false, "blockTimestamp": "2026-09-11T10:00:15.000Z", "metadata": { "type": "send", "tokenSymbol": "xDAI", "tokenDecimals": 18 } } ``` `404` Not Found ```json { "statusCode": 404, "error": "NotFoundError", "message": "Transaction not found" } ``` Field lists per `type` are on the [Unified Transaction Feed](https://docs.aureahub.com/docs/agg-tx-list.md) page. Web version: https://docs.aureahub.com/#tx-aggregated-get --- # List Tokens List the tokens configured for your tenant, grouped by chain. ## Overview Returns every chain with its tokens: symbol, name, contract address (`null` for the native coin), decimals, logo and whether it is the native coin. Use it to populate send and swap token selectors. - The bearer token is optional. With it, the list is your tenant's; without it, Aurea returns the list of its system tenant. - `isTestnet=true` returns testnet tokens; the default is mainnet. - For one chain only, use [Tokens by Chain](https://docs.aureahub.com/docs/tokens-by-chain.md). ## Endpoint ### `GET /v1/tokens/` Lists supported tokens across all chains, grouped by chain. Authentication is optional. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `isTestnet` | string | no | "true" for testnet tokens, "false" for mainnet (default) | **Responses** `200` OK ```json { "data": [ { "chain": "gnosis", "tokens": [ { "symbol": "xDAI", "name": "xDAI", "address": null, "decimals": 18, "logoUri": "", "isNative": true }, { "symbol": "EURe", "name": "", "address": "", "decimals": 18, "logoUri": "", "isNative": false } ] } ] } ``` ## Implementation ```javascript // Map "chain:address" (or "chain:native") -> token, for a token selector async function getTokenIndex(accessToken, { testnet = false } = {}) { const res = await fetch(`https://api.aureahub.com/v1/tokens/?isTestnet=${testnet}`, { headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {} }); if (!res.ok) throw new Error(`Token list failed: ${res.status}`); const { data } = await res.json(); const index = {}; for (const { chain, tokens } of data) { for (const t of tokens) index[`${chain}:${t.isNative ? 'native' : t.address.toLowerCase()}`] = { chain, ...t }; } return index; } ``` Web version: https://docs.aureahub.com/#tokens-list --- # Tokens by Chain Retrieve all tokens available on a specific blockchain — ideal for populating swap and send forms after a chain is selected. ## Overview When a user selects a source chain in a send or swap interface, call this endpoint to populate the token dropdown with only the assets available on that chain. This is more efficient than fetching all tokens and filtering client-side. ### `GET /v1/tokens/chain/{chain}` Authentication: bearer token required. Returns all tokens registered for a specific chain. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `chain` | string | yes | Chain identifier (e.g. ethereum, polygon, solana) | **Responses** `200` OK ```json { "chain": "ethereum", "items": [ { "symbol": "USDC", "name": "USD Coin", "contract_address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "decimals": 6, "logoUri": "https://..." }, { "symbol": "USDT", "name": "Tether USD", "contract_address": "0xdAC17F958D2ee523a2206206994597C13D831ec7", "decimals": 6 } ] } ``` `404` Not Found ```json { "detail": "Chain not supported" } ``` ## Implementation ```javascript // When user selects a chain, load its available tokens async function getTokensForChain(token, chain) { const res = await fetch( `https://api.aureahub.com/v1/tokens/chain/${chain}`, { headers: { Authorization: `Bearer ${token}` } } ); if (res.status === 404) throw new Error(`Chain '${chain}' is not supported`); const { items } = await res.json(); return items; } // Populate a token dropdown dynamically async function populateChainSelect(token, selectEl) { const chains = await getSupportedChains(token); selectEl.innerHTML = chains .map(c => ``) .join(''); } ``` Web version: https://docs.aureahub.com/#tokens-chains --- # Get Swap Quote Step 1 of every swap — get a LI.FI route for a wallet, together with the `transactionData` you will execute. ## Overview Aurea requests the quote from the **LI.FI** aggregator for the wallet identified by `walletId`, which must belong to the authenticated user. The response carries a `quoteId`, the estimated output (`toAmount`, `toAmountMin`), approval information (`approvalAddress`, `needsApproval`) and a `transactionData` object. Pass `quoteId` and `transactionData` unchanged to [Execute Swap](https://docs.aureahub.com/docs/swap-execute.md). - **Chains** — `fromChain` and `toChain` must be one of `ethereum`, `polygon`, `gnosis`, `bsc`, `arbitrum`, `optimism`, `base`, `avalanche`, `celo`, `flowevm`, `solana`. - **Tokens** — both tokens must be present in Aurea's token registry for their chain (see [Tokens by Chain](https://docs.aureahub.com/docs/tokens-by-chain.md)). Use the zero address `0x0000000000000000000000000000000000000000` for an EVM chain's native coin. On Solana, `So11111111111111111111111111111111111111112` and `11111111111111111111111111111111` are both treated as native SOL. - **Slippage** — `slippage` is a **percentage** (`0.5` = 0.5%). There is no `slippage_bps` field; the body schema does not allow additional properties. - **Destination** — without `toAddress`, a same-chain swap pays out to the source wallet. For a cross-chain swap Aurea uses the user's primary wallet on `toChain`. For an EVM destination without one, it falls back to another of the user's EVM wallets. For a Solana destination it uses the user's existing Solana wallet and, if there is none, **creates one during the quote call**. - **Rejected routes** — Aurea refuses routes that would deliver a different token than `toToken`, and routes that need a second signature on the destination chain. `requiresDestinationSignature` is therefore always `false` in a successful response. > ⚠️ **Testnet quotes are rejected.** The API's list of LI.FI-supported testnet chains is empty, so a quote with `isTestnet: true` fails with `400`. To exercise swap UI and history on testnet, use [Simulate Swap](https://docs.aureahub.com/docs/swap-simulate.md). ## Swap Flow 1. **Get Quote** — this endpoint. 2. **Approve** (EVM ERC-20 source tokens, when `needsApproval` is `true`) — [Approve Token](https://docs.aureahub.com/docs/swap-approve.md) with `spenderAddress` set to `approvalAddress`. Non-custodial wallets then sign and call [Broadcast Approve Tx](https://docs.aureahub.com/docs/swap-broadcast-approve.md). 3. **Execute** — [Execute Swap](https://docs.aureahub.com/docs/swap-execute.md) with `quoteId` and `transactionData`. Non-custodial EVM wallets then sign and call [Broadcast Swap Tx](https://docs.aureahub.com/docs/swap-broadcast.md). 4. **Track** — poll [Get Tx Status](https://docs.aureahub.com/docs/tx-status.md) with the returned `transactionId` while the status is `pending`. > ℹ️ When the response contains a `gasless` object (EUR.e → xDAI on Gnosis), you can use the permit-based flow instead: [Sign EIP-712 Permit](https://docs.aureahub.com/docs/swap-sign-permit.md), then [Execute Gasless](https://docs.aureahub.com/docs/swap-gasless.md). The [Token Swaps guide](https://docs.aureahub.com/docs/guide-swap.md) walks through every flow end to end. ## Endpoint ### `POST /v1/swap/quote` Authentication: bearer token required. Returns a LI.FI route, the estimated output, approval information and the transaction data to execute. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `walletId` | string (uuid) | yes | Source wallet. Must belong to the authenticated user. | | `fromChain` | string | yes | Source chain name, e.g. `polygon`. | | `toChain` | string | yes | Destination chain name. Same value as `fromChain` for a same-chain swap. | | `fromToken` | string | yes | Source token address: `0x` + 40 hex characters, or a Solana base58 address (32–44 characters). | | `toToken` | string | yes | Destination token address, same format as `fromToken`. | | `fromAmount` | string | yes | Amount to swap; must match `^\d+(\.\d+)?$`. Aurea forwards it to LI.FI as `fromAmount` without converting it — it is not scaled by token decimals. | | `slippage` | number | no | Maximum slippage as a **percentage**, from 0 to 50 — `0.5` means 0.5%. Defaults to `0.5`. Aurea divides the value by 100 before sending it to LI.FI. | | `toAddress` | string | no | Address that receives the output. When omitted, Aurea resolves the destination as described in the Overview. | | `isTestnet` | boolean | no | Use testnet chain IDs. Defaults to `false`. Currently rejected — see the warning above. | **Responses** `200` OK ```json { "quoteId": "…", "isTestnet": false, "sourceChainType": "evm", "fromChain": "polygon", "fromChainId": 137, "toChain": "polygon", "toChainId": 137, "fromToken": { "address": "0x…", "symbol": "…", "decimals": 6, "name": "…" }, "toToken": { "address": "0x0000000000000000000000000000000000000000", "symbol": "…", "decimals": 18, "name": "…" }, "fromAmount": "…", "toAmount": "…", "toAmountMin": "…", "tool": "…", "approvalAddress": "0x…", "needsApproval": true, "estimatedGas": "…", "estimatedGasCostWei": "…", "estimatedGasCostNative": "…", "nativeTokenSymbol": "MATIC", "nativeTokenBalance": "…", "transactionValueNative": "…", "totalCostRequired": "…", "sufficientBalance": true, "executionTime": 30, "transactionData": { "to": "0x…", "data": "0x…", "value": "0", "gasLimit": "…" }, "requiresDestinationSignature": false } ``` `400` Bad Request ```json { "statusCode": 400, "error": "BadRequestError", "message": "No swap route is available for this token pair at the moment. Try a different token, a different amount, or check back later." } ``` `401` Unauthorized ```json { "statusCode": 401, "error": "UnauthorizedError", "message": "Authentication required" } ``` `404` Not Found ```json { "statusCode": 404, "error": "NotFoundError", "message": "Wallet not found" } ``` Values shown as `…` depend on the quote; the other example values are illustrative. ## Response Fields - `quoteId` — LI.FI's quote id. Send it to Execute Swap, which stores it with the transaction. - `sourceChainType` — `evm` or `solana`; tells you which execution path applies. - `fromChainId`, `toChainId` — numeric chain IDs resolved by Aurea (Solana: `1399811149` mainnet, `1399811150` devnet). - `fromToken`, `toToken` — `{ address, symbol, decimals, name }` as returned by LI.FI. - `fromAmount`, `toAmount`, `toAmountMin` — LI.FI's estimate, as strings. - `tool` — name of the LI.FI tool (bridge or exchange) used by the route. - `approvalAddress` — the spender to approve. `null` for native source tokens, for Solana sources, and when LI.FI returns no approval address. - `needsApproval` — `true` only when the wallet's current allowance for `approvalAddress` is exactly `0`. It is not compared with `fromAmount`. - `estimatedGas` — gas units from LI.FI (`"0"` when LI.FI gives none). - `estimatedGasCostWei`, `estimatedGasCostNative`, `nativeTokenSymbol`, `transactionValueNative`, `totalCostRequired` — optional; omitted when there is no gas estimate or the gas price cannot be fetched. `nativeTokenBalance` and `sufficientBalance` are also omitted when the balance cannot be fetched. `sufficientBalance` is `true` when the native balance covers `totalCostRequired`. - `executionTime` — LI.FI's estimated execution duration. - `transactionData` — `{ to, data, value, gasLimit }`. For a Solana source, `data` holds the base64-encoded `VersionedTransaction`. - `requiresDestinationSignature` — always `false`. - `priceImpact` — declared in the response schema but not populated by the current implementation. - `gasless` — present only when gasless swaps are enabled on the deployment and the quote is EUR.e → native xDAI on Gnosis (both chains `gnosis`, `toToken` the zero address). `estimatedGasCost` is in wei. ```json { "gasless": { "supported": true, "permitRequired": true, "estimatedGasCost": "…", "estimatedGasCostXDAI": "0.000123", "currentNonce": 0, "permitDomain": { "name": "Monerium EURe", "version": "1", "chainId": 100, "verifyingContract": "0x420CA0f9B9b604cE0fd9C18EF134C705e5Fa3430" } } } ``` ## Errors Errors use the shape `{ statusCode, error, message }`, with `details` when available. - `400` `Request validation failed` — the body does not match the schema (`details` lists the problems). - `400` `Unsupported chain: …` — the chain name is not in the list above. - `400` `LI.FI does not support mainnet chain IDs: …` (or `testnet chain IDs`). - `400` `Token not found: … on … mainnet. Please ensure the token is supported on this network.` - `400` `No swap route is available for this token pair at the moment. …` — LI.FI error code 1002. Other LI.FI errors return `Unable to get a swap quote. Please try again or use a different token pair.` - `400` `No direct route is available for the requested token pair. …` — the route would deliver a different token. - `400` `No single-transaction route is available for this swap. …` — the route needs a signature on the destination chain. - `400` `Failed to check token allowance` or `Network mismatch: …` — the allowance lookup for `needsApproval` failed. - `404` `Wallet not found` — unknown wallet, or it belongs to another user. - `404` `No primary … wallet found for this user. …` — no destination could be resolved for a cross-chain swap; pass `toAddress`. ## Implementation ```javascript // Step 1: get a swap quote async function getSwapQuote(token, { walletId, fromChain, toChain, fromToken, toToken, fromAmount, slippage = 0.5 }) { const res = await fetch('https://api.aureahub.com/v1/swap/quote', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ walletId, fromChain, toChain, fromToken, toToken, fromAmount, // string, forwarded to LI.FI unchanged slippage // percent: 0.5 = 0.5% }) }); const body = await res.json(); if (!res.ok) throw new Error(`${res.status}: ${body.message}`); // Keep quoteId and transactionData for Execute Swap return body; } ``` Web version: https://docs.aureahub.com/#swap-quote --- # Check ERC-20 Allowance Read the on-chain ERC-20 allowance a wallet has granted to a spender — typically the `approvalAddress` from the quote. ## Overview The API calls `allowance(owner, spender)` on `tokenAddress`, where `owner` is the address of `walletId` and `spender` is `spenderAddress`. The lookup runs on `chain`, or on the wallet's own chain when `chain` is omitted. [Get Quote](https://docs.aureahub.com/docs/swap-quote.md) already performs this check against its `approvalAddress` and returns the result as `needsApproval`. Call this endpoint when you need a fresh reading — for example after broadcasting a non-custodial approval, because approvals do not create a transaction record you could poll. > ⚠️ `needsApproval` is `true` only when the allowance is exactly `0`; it is not compared with the amount you intend to swap. If you approved a specific amount, compare `allowance` (an integer string in the token's smallest unit) with your amount yourself. `requiredAmount` is declared in the response schema but is not populated. > ℹ️ Solana has no ERC-20 allowance. When the target chain is `solana`, the API returns `{ "allowance": "not_applicable", "needsApproval": false }` without reading the chain. ## Endpoint ### `POST /v1/swap/check-allowance` Authentication: bearer token required. Reads the current ERC-20 allowance of a wallet for a spender. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `walletId` | string (uuid) | yes | Token owner wallet. Must belong to the authenticated user. | | `tokenAddress` | string | yes | Token contract address: `0x` + 40 hex characters (the schema also accepts a Solana base58 address). | | `spenderAddress` | string | yes | Spender to check, `0x` + 40 hex characters — use `approvalAddress` from the quote. | | `chain` | string | no | Chain to read from. Defaults to the wallet's chain. | | `isTestnet` | boolean | no | Read from the testnet of the chain. Defaults to `false`. | **Responses** `200` OK ```json { "allowance": "0", "needsApproval": true } ``` `200` Solana ```json { "allowance": "not_applicable", "needsApproval": false } ``` `400` Bad Request ```json { "statusCode": 400, "error": "BadRequestError", "message": "Failed to check token allowance" } ``` `404` Not Found ```json { "statusCode": 404, "error": "NotFoundError", "message": "Wallet not found" } ``` Other `400` responses: `Request validation failed` (body does not match the schema), `Unsupported chain: …`, and `Network mismatch: …` when the RPC for the chain reports a different chain ID. ## Implementation ```javascript async function getAllowance(token, { walletId, tokenAddress, spenderAddress, chain }) { const res = await fetch('https://api.aureahub.com/v1/swap/check-allowance', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ walletId, tokenAddress, spenderAddress, chain }) }); const body = await res.json(); if (!res.ok) throw new Error(`${res.status}: ${body.message}`); return body; // { allowance, needsApproval } } // Is the allowance enough for a specific amount (token's smallest unit)? const { allowance } = await getAllowance(token, { walletId, tokenAddress: FROM_TOKEN, spenderAddress: quote.approvalAddress, chain: 'polygon' }); const enough = allowance === 'not_applicable' || BigInt(allowance) >= BigInt(requiredAmount); ``` Web version: https://docs.aureahub.com/#swap-allowance --- # Approve ERC-20 Token Grant the swap spender — the quote's `approvalAddress` — an ERC-20 allowance on the source token. ## Overview This endpoint builds an ERC-20 `approve(spenderAddress, amount)` call on `tokenAddress` from the wallet `walletId`. `amount` is an integer string in the token's smallest unit (pattern `^\d+$`). When it is omitted, the approval is for the maximum `uint256` value, i.e. unlimited. Call it when [Get Quote](https://docs.aureahub.com/docs/swap-quote.md) returns `needsApproval: true`. Use the quote's `approvalAddress` as `spenderAddress` and the chain you quoted on as `chain`; without `chain`, the wallet's own chain is used. The `gasless` field is accepted but not used by this endpoint. > ℹ️ Solana has nothing to approve. When the target chain is `solana`, the API returns `{ "txHash": "not_applicable", "status": "confirmed", "approvedAmount": "not_applicable", "gasUsed": "0" }` without sending a transaction. ## Custodial vs Non-Custodial **Custodial wallets** (Aurea holds the key): the API signs and sends the approval, **waits for the receipt**, and returns `txHash`, `status` (`confirmed` or `failed`), `approvedAmount` and `gasUsed`. Once the response shows `confirmed` you can execute the swap. **Non-custodial wallets** (key management scheme `client_side`, `client_side_pending` or `mpc_tss`): nothing is sent yet. The API prepares an unsigned EIP-1559 transaction, with fee caps set to 1.5× the network's current fee data. It returns `requiresClientSigning: true` with `preparedTxId`, `txHashToSign`, `chainId`, `nonce`, `maxFeePerGas`, `maxPriorityFeePerGas` and `expiresAt`. Sign `txHashToSign` with the wallet key and send `{ preparedTxId, v, r, s }` to [Broadcast Approve Tx](https://docs.aureahub.com/docs/swap-broadcast-approve.md) before `expiresAt` (5 minutes). > ⚠️ For non-custodial wallets the API does not check `spenderAddress` against the quote. Always pass the quote's `approvalAddress`. ## Endpoint ### `POST /v1/swap/approve` Authentication: bearer token required. Approves an ERC-20 spender. Custodial wallets: sent and confirmed. Non-custodial wallets: returns a transaction hash to sign. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `walletId` | string (uuid) | yes | Wallet that owns the tokens. Must belong to the authenticated user. | | `tokenAddress` | string | yes | ERC-20 token contract: `0x` + 40 hex characters (the schema also accepts a Solana base58 address). | | `spenderAddress` | string | yes | Spender, `0x` + 40 hex characters — the quote's `approvalAddress`. | | `amount` | string | no | Allowance in the token's smallest unit, digits only (`^\d+$`). Omit for an unlimited approval. | | `chain` | string | no | Chain to approve on. Defaults to the wallet's chain. | | `isTestnet` | boolean | no | Use the testnet of the chain. Defaults to `false`. | | `gasless` | boolean | no | Accepted; not used by this endpoint. | **Responses** `200` Custodial ```json { "txHash": "0x…", "status": "confirmed", "approvedAmount": "115792089237316195423570985008687907853269984665640564039457584007913129639935", "gasUsed": "…" } ``` `200` Non-custodial ```json { "requiresClientSigning": true, "preparedTxId": "…", "txHashToSign": "0x…", "chainId": 137, "nonce": "…", "maxFeePerGas": "…", "maxPriorityFeePerGas": "…", "expiresAt": "2026-09-11T10:05:00.000Z" } ``` `400` Bad Request ```json { "statusCode": 400, "error": "BadRequestError", "message": "Cannot approve from watch-only wallet" } ``` `404` Not Found ```json { "statusCode": 404, "error": "NotFoundError", "message": "Wallet not found" } ``` Other `400` responses include `Request validation failed`, `Failed to approve token: …` (custodial), `Network mismatch: …`, and for non-custodial wallets `Token contract rejected approval: check token address and spender`, `Cannot determine EIP-1559 gas fees for this network` and `Chain "…" (isTestnet=…) is not registered in the chain registry`. ## Implementation ```javascript import { SigningKey } from 'ethers'; // Signs a prepared transaction hash; v must be the recovery parity (0 or 1) const signDigest = async (digest) => { const sig = new SigningKey(privateKey).sign(digest); // or your MPC / device signer return { v: sig.yParity, r: sig.r, s: sig.s }; }; async function approveForSwap(token, { walletId, tokenAddress, spenderAddress, chain, amount }) { const post = async (path, body) => { const res = await fetch(`https://api.aureahub.com${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify(body) }); const json = await res.json(); if (!res.ok) throw new Error(`${res.status} ${json.code ?? ''}: ${json.message}`); return json; }; // amount omitted → unlimited allowance const result = await post('/v1/swap/approve', { walletId, tokenAddress, spenderAddress, chain, amount }); if (!result.requiresClientSigning) { // Custodial: the approval has already been mined if (result.status !== 'confirmed') throw new Error('Approval transaction failed'); return result.txHash; } // Non-custodial: sign txHashToSign and broadcast within 5 minutes const { v, r, s } = await signDigest(result.txHashToSign); const { txHash } = await post('/v1/swap/broadcast-approve', { preparedTxId: result.preparedTxId, v, r, s }); // Approvals have no transaction record: poll /v1/swap/check-allowance until it is in place return txHash; } ``` Web version: https://docs.aureahub.com/#swap-approve --- # Execute Swap Run a quoted swap from a wallet — sent immediately for custodial wallets, prepared for client signing for non-custodial wallets. ## Overview Send the `quoteId` and the `transactionData` object from [Get Quote](https://docs.aureahub.com/docs/swap-quote.md). The API does not fetch the quote again: the transaction it signs or prepares is built from the `transactionData` you send, and `quoteId` is stored with the transaction record. Pass `transactionData` unchanged. If the quote returned `needsApproval: true`, the approval must be in place first (see [Approve Token](https://docs.aureahub.com/docs/swap-approve.md)). Pass `chain` with the chain you quoted on; otherwise the wallet's own chain is used. The optional token and amount fields (`fromAmount`, `toTokenSymbol`, …) are saved in the transaction metadata for transaction history — copy them from the quote. They do not change what is executed. > ⚠️ Requests are not de-duplicated: every call signs and sends (or prepares) a new transaction. After a timeout, check [List Transactions](https://docs.aureahub.com/docs/tx-list.md) before retrying. ## Execution Paths The path depends on the chain (`chain`, or the wallet's chain) and on who holds the wallet key. - **EVM, custodial wallet** — the API checks that the wallet's native balance covers `transactionData.value` plus `gasLimit` × the current gas price, then signs and sends the transaction. It returns `transactionId`, `txHash` and `status: "pending"`; in this response `fromToken`, `toToken`, `fromAmount` and `expectedToAmount` are empty strings. Poll [Get Tx Status](https://docs.aureahub.com/docs/tx-status.md) with `transactionId`. A background job that runs every 60 seconds also resolves pending EVM transactions. - **EVM, non-custodial wallet** (key management scheme `client_side`, `client_side_pending` or `mpc_tss`) — `transactionData.to`, `value` and `gasLimit` are required. If `value` is greater than zero, the API checks that the balance covers it plus `gasLimit` × `maxFeePerGas`. It then prepares an unsigned EIP-1559 transaction (fee caps 1.5× current fee data) and creates a `pending` transaction record. The response has `requiresClientSigning: true` with `transactionId`, `preparedTxId`, `txHashToSign`, `nonce`, `maxFeePerGas`, `maxPriorityFeePerGas`, `chainId` and `expiresAt`; `txHash` is an empty string. Sign `txHashToSign` and call [Broadcast Swap Tx](https://docs.aureahub.com/docs/swap-broadcast.md). - **Solana** — see [Execute Solana Swap](https://docs.aureahub.com/docs/swap-execute-solana.md). Custodial Solana swaps return `status: "confirmed"`; non-custodial Solana wallets are rejected with `400`. > ⚠️ **Broadcast window (non-custodial).** Broadcast Swap Tx is refused with `QUOTE_EXPIRED` after `quoteExpiry`. If you did not send `quoteExpiry`, the limit is 60 seconds after this call. It is refused with `410` once the prepared transaction expires (`expiresAt`, 5 minutes). If the `quoteExpiry` you send is already in the past, this call itself fails with `400`. ## Endpoint ### `POST /v1/swap/execute` Authentication: bearer token required. Executes the transaction data of a LI.FI quote from a wallet. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `walletId` | string (uuid) | yes | Wallet that executes the swap. Must belong to the authenticated user. | | `quoteId` | string | yes | `quoteId` from Get Quote; stored with the transaction. | | `transactionData` | object | yes | `transactionData` from Get Quote: `data` (required), `to` (string or `null`), `value`, `gasLimit`. EVM swaps need `to`, `value` and `gasLimit`. | | `chain` | string | no | Chain to execute on. Defaults to the wallet's chain. | | `isTestnet` | boolean | no | Use the testnet of the chain. Defaults to `false`. | | `quoteExpiry` | string | no | ISO 8601 time after which a non-custodial broadcast is refused. Only used for non-custodial EVM wallets. | | `fromAmount` | string | no | Stored in transaction metadata and used as the record's amount. | | `toAmount` | string | no | Stored in transaction metadata. | | `fromTokenSymbol` | string | no | Stored in transaction metadata. | | `toTokenSymbol` | string | no | Stored in transaction metadata. | | `fromTokenDecimals` | integer | no | Stored in transaction metadata (0 or greater). | | `toTokenDecimals` | integer | no | Stored in transaction metadata (0 or greater). | | `fromTokenAddress` | string | no | Stored in transaction metadata. | | `toTokenAddress` | string | no | Stored in transaction metadata. | | `gasless` | boolean | no | Accepted; not used by this endpoint. Gasless swaps use `/v1/swap/execute-gasless`. | **Responses** `200` Custodial EVM ```json { "transactionId": "…", "txHash": "0x…", "status": "pending", "fromToken": "", "toToken": "", "fromAmount": "", "expectedToAmount": "" } ``` `200` Non-custodial EVM ```json { "transactionId": "…", "txHash": "", "status": "pending", "fromToken": "…", "toToken": "…", "fromAmount": "…", "expectedToAmount": "…", "requiresClientSigning": true, "preparedTxId": "…", "txHashToSign": "0x…", "nonce": "…", "maxFeePerGas": "…", "maxPriorityFeePerGas": "…", "chainId": 137, "expiresAt": "…" } ``` `400` Bad Request ```json { "statusCode": 400, "error": "BadRequestError", "message": "Insufficient MATIC balance for swap. Required: … MATIC (… MATIC swap value + … MATIC estimated gas), Available: … MATIC, Shortfall: … MATIC" } ``` `404` Not Found ```json { "statusCode": 404, "error": "NotFoundError", "message": "Wallet not found" } ``` In the non-custodial response, `fromToken`, `toToken`, `fromAmount` and `expectedToAmount` echo the optional fields you sent (empty strings otherwise). Other `400` responses include `Request validation failed`, `Unsupported chain: …`, `Network mismatch: …`, `Cannot execute swap from watch-only wallet`, `Failed to execute swap: …` (custodial), `transactionData.to is required for non-custodial EVM swaps` (likewise for `value` and `gasLimit`), `Insufficient balance: …` and `QUOTE_EXPIRED: The LiFi quote has already expired. Please request a new quote.` ## Complete Swap Flow A same-chain EVM swap covering custodial and non-custodial wallets. `signDigest` must return `{ v, r, s }` with `v` = 0 or 1. ```javascript async function executeFullSwap(token, { walletId, chain, fromToken, toToken, fromAmount }, signDigest) { const post = async (path, body) => { const res = await fetch(`https://api.aureahub.com${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify(body) }); const json = await res.json(); if (!res.ok) throw new Error(`${res.status} ${json.code ?? ''}: ${json.message}`); return json; }; // 1. Quote const quote = await post('/v1/swap/quote', { walletId, fromChain: chain, toChain: chain, fromToken, toToken, fromAmount, slippage: 0.5 }); // 2. Approval (only when the current allowance is zero) if (quote.needsApproval) { const approval = await post('/v1/swap/approve', { walletId, chain, tokenAddress: fromToken, spenderAddress: quote.approvalAddress }); if (approval.requiresClientSigning) { const sig = await signDigest(approval.txHashToSign); await post('/v1/swap/broadcast-approve', { preparedTxId: approval.preparedTxId, ...sig }); // Approvals have no transaction record: poll /v1/swap/check-allowance until it is in place } else if (approval.status !== 'confirmed') { throw new Error('Approval transaction failed'); } } // 3. Execute — transactionData exactly as quoted const exec = await post('/v1/swap/execute', { walletId, chain, quoteId: quote.quoteId, transactionData: quote.transactionData, fromAmount: quote.fromAmount, toAmount: quote.toAmount, fromTokenSymbol: quote.fromToken.symbol, toTokenSymbol: quote.toToken.symbol, fromTokenDecimals: quote.fromToken.decimals, toTokenDecimals: quote.toToken.decimals, fromTokenAddress: quote.fromToken.address, toTokenAddress: quote.toToken.address }); if (!exec.requiresClientSigning) return exec; // custodial: status 'pending' // 4. Non-custodial: sign and broadcast (60 s window when quoteExpiry is not sent) const sig = await signDigest(exec.txHashToSign); return post('/v1/swap/broadcast', { transactionId: exec.transactionId, preparedTxId: exec.preparedTxId, ...sig }); // Then poll GET /v1/transactions/{transactionId}/status until it is no longer 'pending' } ``` Web version: https://docs.aureahub.com/#swap-execute --- # Simulate Swap Create a confirmed swap record without calling LI.FI or any blockchain — for exercising swap UI and transaction-history flows. ## Overview This endpoint does not price anything and does not touch a chain. It writes a swap transaction for `walletId` with status `confirmed` and returns it with `simulation: true`. The `quoteId` starts with `sim_`, the placeholder `txHash` starts with `SIMULATED`, and `toAmount` equals `fromAmount`. The record uses `fromChain` as its chain and `fromToken` and `fromAmount` as its token and amount; the symbol and decimals fields are stored for display. Defaults when omitted: `fromTokenSymbol` `EURC_TEST`, `fromTokenDecimals` `6`, `toTokenSymbol` `SOL`, `toTokenDecimals` `9`. > ⚠️ The API describes this endpoint as being for testnet development and flow testing only, but the handler does not enforce that: it accepts any authenticated user and does not check `isTestnet`. The simulated swap appears in the user's transaction history as a confirmed swap. Use test wallets and pass `isTestnet: true`. ## Endpoint ### `POST /v1/swap/simulate` Authentication: bearer token required. Creates a confirmed simulated swap record. No LI.FI call and no on-chain transaction. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `walletId` | string (uuid) | yes | Wallet the record is created for. Must belong to the authenticated user. | | `fromChain` | string | yes | Recorded as the transaction's chain. | | `toChain` | string | yes | Required by the schema; not stored on the record. | | `fromToken` | string | yes | Recorded as the transaction's token address. | | `toToken` | string | yes | Recorded in the metadata as the destination token address. | | `fromAmount` | string | yes | Digits only (`^\d+$`). Returned as both `fromAmount` and `toAmount`. | | `isTestnet` | boolean | no | Stored on the record. | | `fromTokenSymbol` | string | no | Default `EURC_TEST`. | | `fromTokenDecimals` | integer | no | Default `6`. | | `toTokenSymbol` | string | no | Default `SOL`. | | `toTokenDecimals` | integer | no | Default `9`. | **Responses** `200` OK ```json { "transactionId": "…", "quoteId": "sim_1757584800000_k3j9x2", "txHash": "SIMULATED…", "status": "confirmed", "fromAmount": "1000000", "toAmount": "1000000", "simulation": true } ``` `404` Not Found ```json { "statusCode": 404, "error": "NotFoundError", "message": "Wallet not found" } ``` ## Implementation ```javascript // Create a simulated swap to test history and UI states — no LI.FI, no chain async function simulateSwap(token, { walletId, fromChain, toChain, fromToken, toToken, fromAmount }) { const res = await fetch('https://api.aureahub.com/v1/swap/simulate', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ walletId, fromChain, toChain, fromToken, toToken, fromAmount, // digits only isTestnet: true, fromTokenSymbol: 'EURC_TEST', fromTokenDecimals: 6, toTokenSymbol: 'SOL', toTokenDecimals: 9 }) }); const body = await res.json(); if (!res.ok) throw new Error(`${res.status}: ${body.message}`); // { transactionId, quoteId: 'sim_…', txHash: 'SIMULATED…', status: 'confirmed', simulation: true, ... } return body; } ``` Web version: https://docs.aureahub.com/#swap-simulate --- # Execute Gasless Swap Swap EUR.e for native xDAI on Gnosis, with the token approval supplied as an ERC-2612 permit instead of an approve transaction. ## Overview The gasless flow exists for one pair: **EUR.e → native xDAI on Gnosis**. The user does not send an `approve()` transaction. Instead, Aurea submits an ERC-2612 permit on-chain — from its relayer or through a sponsored relay — whenever the allowance is insufficient, and then runs the quote's swap transaction. - **Availability** — gasless swaps sit behind a deployment-level feature flag that is off by default. While it is off, this endpoint and Sign EIP-712 Permit return `503` `Gasless swaps are currently disabled`. A quote only contains a `gasless` object when the flag is on and the pair qualifies — use that object as your signal. - **Network** — execution is wired to Gnosis (chain ID `100`); this handler only logs `isTestnet`. - **Result** — depending on the wallet, the call either completes the swap or returns a transaction for the user to sign (see Responses by Path). ## Gasless Sequence 1. [Get Quote](https://docs.aureahub.com/docs/swap-quote.md) with `fromChain` and `toChain` both `gnosis`, `fromToken` = the EUR.e address and `toToken` = `0x0000000000000000000000000000000000000000`. Continue only if the response has a `gasless` object. 2. [Sign EIP-712 Permit](https://docs.aureahub.com/docs/swap-sign-permit.md) with `spenderAddress` = `quote.transactionData.to` and `amount` = the EUR.e amount in wei. The response is your `permitSignature` object. 3. **Execute Gasless** (this endpoint) with the same `amount`, the EUR.e `tokenAddress`, `quote.transactionData` and `permitSignature`. 4. If the response has `requiresClientSigning: true`, sign `txHashToSign` and call [Broadcast Gasless Tx](https://docs.aureahub.com/docs/swap-broadcast-gasless.md) before `expiresAt` (5 minutes). Otherwise the swap has already completed. > ℹ️ The permit is checked against `owner` = the wallet address, `spender` = `transactionData.to` and `value` = `amount`. Request the permit with exactly those values. ## Endpoint ### `POST /v1/swap/execute-gasless` Authentication: bearer token required. Executes an EUR.e to xDAI swap on Gnosis using an ERC-2612 permit. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `walletId` | string (uuid) | yes | Wallet holding the EUR.e. Must belong to the authenticated user. | | `quoteId` | string | yes | `quoteId` from Get Quote; stored with the transaction. | | `amount` | string | yes | EUR.e amount to swap, in wei, digits only (`^\d+$`). | | `tokenAddress` | string | yes | EUR.e contract address, `0x` + 40 hex characters. | | `transactionData` | object | yes | `transactionData` from the quote. Here `to` (a `0x` address), `data`, `value` and `gasLimit` are all required. | | `permitSignature` | object | yes | The object returned by Sign EIP-712 Permit: `v` (27 or 28), `r`, `s`, `deadline`, `nonce`, `permitRequired`. Its fields are optional in the schema; permit checks are skipped when `permitRequired` is `false` or `r`/`s` are all zeros. | | `signingMode` | string | no | `server` (default). See the note below about `client`. | | `isTestnet` | boolean | no | Accepted; only logged by this handler. | | `toAmount` | string | no | Stored in transaction metadata and echoed as `toAmount` in a completed response (`"0"` when omitted). | | `fromTokenSymbol` | string | no | Stored in transaction metadata. | | `fromTokenDecimals` | integer | no | Stored in transaction metadata. | | `toTokenSymbol` | string | no | Stored in transaction metadata. | | `toTokenDecimals` | integer | no | Stored in transaction metadata. | | `toTokenAddress` | string | no | Stored in transaction metadata. | **Responses** `200` Completed ```json { "requiresClientSigning": false, "transactionId": "…", "txHash": "0x…", "status": "confirmed", "executionMethod": "gelato-sponsored", "routingMethod": "lifi", "gasSponsored": true, "gasCostWei": "…", "gasCostDeducted": "…", "netAmountReceived": "0", "fromAmount": "1000000000000000000", "toAmount": "…", "gasUsed": "…", "gasPrice": "…" } ``` `200` Needs signature ```json { "requiresClientSigning": true, "transactionId": "…", "preparedTxId": "…", "txHashToSign": "0x…", "chainId": 100, "nonce": 12, "maxFeePerGas": "…", "maxPriorityFeePerGas": "…", "gasLimit": "…", "expiresAt": "…" } ``` `422` Daily Limit ```json { "statusCode": 422, "error": "ValidationError", "message": "Daily gasless swap limit exceeded. Maximum 5 swaps per day.", "details": { "currentCount": 5, "limit": 5 } } ``` `404` Not Found ```json { "statusCode": 404, "error": "NotFoundError", "message": "Wallet not found" } ``` > ℹ️ The schema also accepts `signingMode: "client"`, which requires `permitSignature.preparedPermitId` and a `metaTxSignature` object (`signature`, `preparedMetaTxId`). The current handler does not read `signingMode`, `preparedPermitId` or `metaTxSignature`, and no swap endpoint issues those ids — use the default server mode. ## Responses by Path - **Completed** (`requiresClientSigning: false`) — always for custodial wallets. Non-custodial wallets get it too when their xDAI balance does not cover the swap gas, or when Aurea's relayer is low on funds; their swap is then submitted through the sponsored relay. The call returns after the swap transaction is mined, with `status: "confirmed"`, `executionMethod` (`self-pay` or `gelato-sponsored`), `routingMethod` (`direct`, `custom-executor` or `lifi`) and `gasSponsored` (`true` for `gelato-sponsored`). `gasCostWei` and `gasCostDeducted` both equal `gasUsed` × `gasPrice`. `netAmountReceived` is currently always `"0"`; read the wallet balance for the xDAI actually received. - **Needs signature** (`requiresClientSigning: true`) — non-custodial wallets (`client_side`, `client_side_pending`, `mpc_tss`) that hold enough xDAI to pay the swap gas. If the allowance is insufficient, Aurea first submits the permit from its relayer. It then prepares the swap as an unsigned EIP-1559 transaction from the user's wallet. Sign `txHashToSign`, call [Broadcast Gasless Tx](https://docs.aureahub.com/docs/swap-broadcast-gasless.md), and track the swap with `transactionId`. ## Limits & Errors - `503` `Gasless swaps are currently disabled`. - `422` daily limit per user — `Daily gasless swap limit exceeded. Maximum N swaps per day.` N is deployment-specific and defaults to 5. - `422` permit problems — `Permit deadline has expired`, `Permit nonce mismatch. Expected …, got …`, `Invalid permit signature`. Permits from Sign EIP-712 Permit have a 15-minute deadline. - `400` `Estimated gas cost (… wei) exceeds maximum allowed (… wei)` — the maximum is deployment-specific and defaults to 0.01 xDAI. - `400` `Insufficient EUR.e balance. Required: … wei, Available: … wei`. - `400` `Request validation failed` — the body does not match the schema. - `404` `Wallet not found`. - If execution fails after the transaction record was created, the record is marked `failed` and the response is a blockchain error carrying `code` and `retryable`; its HTTP status depends on the error. ## Implementation ```javascript const API = 'https://api.aureahub.com'; const EURE = '0x420CA0f9B9b604cE0fd9C18EF134C705e5Fa3430'; // EUR.e on Gnosis — Sign Permit reads this contract const XDAI = '0x0000000000000000000000000000000000000000'; async function post(token, path, body) { const res = await fetch(API + path, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify(body) }); const json = await res.json(); if (!res.ok) throw new Error(`${res.status} ${json.code ?? ''}: ${json.message}`); return json; } // signDigest(hash) must return { v, r, s } with v = 0 or 1 (non-custodial wallets only) async function gaslessSwap(token, walletId, amountWei, signDigest) { const quote = await post(token, '/v1/swap/quote', { walletId, fromChain: 'gnosis', toChain: 'gnosis', fromToken: EURE, toToken: XDAI, fromAmount: amountWei // forwarded to LI.FI unchanged }); if (!quote.gasless) throw new Error('Gasless swap not available for this quote'); const permitSignature = await post(token, '/v1/swap/sign-permit', { walletId, quoteId: quote.quoteId, spenderAddress: quote.transactionData.to, amount: amountWei }); const result = await post(token, '/v1/swap/execute-gasless', { walletId, quoteId: quote.quoteId, amount: amountWei, tokenAddress: EURE, transactionData: quote.transactionData, permitSignature, toAmount: quote.toAmount, toTokenSymbol: quote.toToken.symbol }); if (!result.requiresClientSigning) return result; // completed, status 'confirmed' const { v, r, s } = await signDigest(result.txHashToSign); const sent = await post(token, '/v1/swap/broadcast-gasless', { preparedTxId: result.preparedTxId, v, r, s }); return { transactionId: result.transactionId, txHash: sent.txHash }; // poll /v1/transactions/{id}/status } ``` Web version: https://docs.aureahub.com/#swap-gasless --- # Broadcast Swap Transaction Non-custodial EVM swaps, step 2 — submit the signature for the `txHashToSign` returned by Execute Swap. ## Overview When [Execute Swap](https://docs.aureahub.com/docs/swap-execute.md) returns `requiresClientSigning: true`, sign `txHashToSign` with the wallet key. It is the keccak-256 hash of the unsigned EIP-1559 transaction. Send `transactionId`, `preparedTxId` and the signature here. You do not send a signed raw transaction: the API rebuilds the transaction it prepared, checks that the signature recovers to the wallet's address, and broadcasts it. Before broadcasting, the API checks that the transaction record is a swap owned by you, is still `pending` and references this `preparedTxId`. It also checks that the quote window has not closed: `quoteExpiry` from Execute Swap, or 60 seconds after Execute Swap when none was sent. On success the record gets the real `txHash` and stays `pending`; poll [Get Tx Status](https://docs.aureahub.com/docs/tx-status.md) with `transactionId`. - `v` is the recovery parity, `0` or `1`; `r` and `s` are `0x` + 64 hex characters. - `fromToken`, `toToken`, `fromAmount` and `expectedToAmount` in the response echo the optional metadata you sent to Execute Swap (empty strings otherwise). - The prepared transaction expires 5 minutes after Execute Swap (`expiresAt`). Rate limit: 10 requests per 10 seconds. > ⚠️ The prepared transaction is marked as used **before** the signature is checked. After a `422`, the same `preparedTxId` returns `409` — call Execute Swap again, with a fresh quote if the window has closed. ## Endpoint ### `POST /v1/swap/broadcast` Authentication: bearer token required. Validates a client signature for a prepared swap transaction and broadcasts it. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `transactionId` | string (uuid) | yes | `transactionId` from Execute Swap. | | `preparedTxId` | string (uuid) | yes | `preparedTxId` from Execute Swap. | | `v` | integer | yes | Signature recovery parity: `0` or `1`. | | `r` | string | yes | `0x` + 64 hex characters. | | `s` | string | yes | `0x` + 64 hex characters. | **Responses** `200` OK ```json { "transactionId": "…", "txHash": "0x…", "status": "pending", "fromToken": "…", "toToken": "…", "fromAmount": "…", "expectedToAmount": "…" } ``` `400` Quote Expired ```json { "statusCode": 400, "error": "BadRequestError", "message": "QUOTE_EXPIRED: The LiFi quote has expired. Please request a new quote." } ``` `422` Wrong Signer ```json { "statusCode": 422, "error": "BlockchainError", "message": "Signature does not correspond to the expected wallet address", "code": "WRONG_SIGNER", "retryable": false, "details": { "code": "WRONG_SIGNER", "retryable": false } } ``` ## Errors - `400` — `Request validation failed`; `Transaction is not a swap`; `Transaction is not in pending state (current: …)`; `preparedTxId does not match the transaction record`; `QUOTE_EXPIRED: …`; `Prepared transaction not found`. - `404` `Transaction not found` — unknown `transactionId`, or another user's transaction. `404` `PREPARED_TX_NOT_FOUND` — the prepared transaction does not belong to the transaction's wallet. - `409` `PREPARED_TX_ALREADY_USED` — already submitted. - `410` `PREPARED_TX_EXPIRED` — more than 5 minutes after Execute Swap. - `422` `SIGNATURE_DECODE_FAILED` — `v`, `r`, `s` could not be decoded. `422` `WRONG_SIGNER` — the signature does not recover to the wallet address. ## Implementation ```javascript import { SigningKey } from 'ethers'; // exec = response of POST /v1/swap/execute with requiresClientSigning: true async function broadcastSwap(token, exec, privateKey) { const sig = new SigningKey(privateKey).sign(exec.txHashToSign); const res = await fetch('https://api.aureahub.com/v1/swap/broadcast', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ transactionId: exec.transactionId, preparedTxId: exec.preparedTxId, v: sig.yParity, // 0 or 1 r: sig.r, s: sig.s }) }); const body = await res.json(); if (!res.ok) throw new Error(`${res.status} ${body.code ?? ''}: ${body.message}`); // { transactionId, txHash, status: 'pending', ... } — poll /v1/transactions/{id}/status return body; } ``` Web version: https://docs.aureahub.com/#swap-broadcast --- # Sign EIP-712 Permit Gasless swaps, step 2 — Aurea produces the EUR.e ERC-2612 permit for the wallet and returns its signature components. ## Overview This endpoint does **not** return typed data for the client to sign. Aurea signs the EIP-712 `Permit` itself and returns `{ v, r, s, deadline, nonce, permitRequired }`. Pass that object unchanged as `permitSignature` to [Execute Gasless](https://docs.aureahub.com/docs/swap-gasless.md). 1. Aurea reads the wallet's current EUR.e allowance for `spenderAddress`. 2. If the allowance already covers `amount`, no permit is needed and nothing is signed. The response has `permitRequired: false`, `v: 27`, all-zero `r` and `s`, the current permit `nonce` and a `deadline` 15 minutes ahead. This path works for any wallet, including non-custodial ones. 3. Otherwise Aurea signs with the wallet key it holds, using the token's current permit nonce and a `deadline` 900 seconds (15 minutes) from now, and returns `permitRequired: true`. If Aurea holds no key material for the wallet, the request fails with `400` `Cannot sign permit for watch-only wallet`. > ⚠️ Use `spenderAddress` = the quote's `transactionData.to` and the same `amount` you will send to Execute Gasless — Execute Gasless validates the permit against those values. `quoteId` is only logged, and `isTestnet` is not passed to the signer. Gasless swaps must be enabled on the deployment; otherwise the endpoint returns `503`. ## Endpoint ### `POST /v1/swap/sign-permit` Authentication: bearer token required. Signs an ERC-2612 permit for EUR.e server-side and returns v, r, s, deadline and nonce. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `walletId` | string (uuid) | yes | Wallet that owns the EUR.e. Must belong to the authenticated user. | | `quoteId` | string | yes | Quote the permit is for. Only logged. | | `spenderAddress` | string | yes | Permit spender, `0x` + 40 hex characters — the quote's `transactionData.to`. | | `amount` | string | yes | Permit value in wei, digits only (`^\d+$`). | | `isTestnet` | boolean | no | Accepted; not passed to the signer. | **Responses** `200` Signed ```json { "v": 28, "r": "0x…", "s": "0x…", "deadline": 1757585700, "nonce": 3, "permitRequired": true } ``` `200` Allowance sufficient ```json { "v": 27, "r": "0x0000000000000000000000000000000000000000000000000000000000000000", "s": "0x0000000000000000000000000000000000000000000000000000000000000000", "deadline": 1757585700, "nonce": 3, "permitRequired": false } ``` `400` Bad Request ```json { "statusCode": 400, "error": "BadRequestError", "message": "Cannot sign permit for watch-only wallet" } ``` `404` Not Found ```json { "statusCode": 404, "error": "NotFoundError", "message": "Wallet not found" } ``` When gasless swaps are disabled, the endpoint returns `503` with `Gasless swaps are currently disabled`. A body that does not match the schema returns `400` `Request validation failed`. ## Permit Details - **Token** — allowance and nonce are read from the EUR.e contract `0x420CA0f9B9b604cE0fd9C18EF134C705e5Fa3430` on Gnosis (chain ID `100`). - **Domain** — `name` `Monerium EURe`, `version` `1`, `chainId` `100`, `verifyingContract` `0x420CA0f9B9b604cE0fd9C18EF134C705e5Fa3430`. For wallets flagged as testnet, the signing domain uses `chainId` `10200` and `verifyingContract` `0xFD6F7A6a5c21A3f503EBaE7a473639974379c351`. - **Type** — `Permit(address owner, address spender, uint256 value, uint256 nonce, uint256 deadline)`, where `owner` is the wallet address. ## Implementation ```javascript // quote = response of POST /v1/swap/quote that contains a gasless object async function getPermit(token, { walletId, quote, amountWei }) { const res = await fetch('https://api.aureahub.com/v1/swap/sign-permit', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ walletId, quoteId: quote.quoteId, spenderAddress: quote.transactionData.to, // the permit is validated against this spender amount: amountWei // wei, digits only }) }); const body = await res.json(); if (!res.ok) throw new Error(`${res.status}: ${body.message}`); // { v, r, s, deadline, nonce, permitRequired } — pass as permitSignature to /v1/swap/execute-gasless return body; } ``` Web version: https://docs.aureahub.com/#swap-sign-permit --- # Broadcast Approve Tx Non-custodial approvals, step 2 — submit the signature for the `txHashToSign` returned by Approve Token. ## Overview When [Approve Token](https://docs.aureahub.com/docs/swap-approve.md) returns `requiresClientSigning: true`, sign its `txHashToSign` with the wallet key and send the signature here. You do not send a signed raw transaction. The API rebuilds the EIP-1559 transaction it prepared, checks that the signature recovers to the wallet's address, and broadcasts it. - `v` is the recovery parity, `0` or `1`; `r` and `s` are `0x` + 64 hex characters. - The prepared transaction expires 5 minutes after Approve Token created it (`expiresAt`). - The response is `{ txHash, status: "pending" }`. Approvals do not create a transaction record, so confirm the approval with [Check Allowance](https://docs.aureahub.com/docs/swap-allowance.md) before executing the swap. - Rate limit: 10 requests per 10 seconds. > ⚠️ The prepared transaction is marked as used **before** the signature is checked. If a request is rejected with `422`, retrying with the same `preparedTxId` returns `409` — call Approve Token again to prepare a new transaction. ## Endpoint ### `POST /v1/swap/broadcast-approve` Authentication: bearer token required. Validates a client signature for a prepared ERC-20 approval and broadcasts the transaction. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `preparedTxId` | string (uuid) | yes | `preparedTxId` from Approve Token. | | `v` | integer | yes | Signature recovery parity: `0` or `1`. | | `r` | string | yes | `0x` + 64 hex characters. | | `s` | string | yes | `0x` + 64 hex characters. | **Responses** `200` OK ```json { "txHash": "0x…", "status": "pending" } ``` `404` Not Found ```json { "statusCode": 404, "error": "NotFoundError", "message": "Prepared transaction not found" } ``` `422` Wrong Signer ```json { "statusCode": 422, "error": "BlockchainError", "message": "Signature does not correspond to the expected wallet address", "code": "WRONG_SIGNER", "retryable": false, "details": { "code": "WRONG_SIGNER", "retryable": false } } ``` ## Errors - `400` `Request validation failed` — the body does not match the schema. - `404` `Prepared transaction not found` — unknown `preparedTxId`, or it belongs to another user. - `409` `PREPARED_TX_ALREADY_USED` — this prepared transaction was already submitted. - `410` `PREPARED_TX_EXPIRED` — more than 5 minutes have passed; call Approve Token again. - `422` `SIGNATURE_DECODE_FAILED` — `v`, `r`, `s` could not be decoded. `422` `WRONG_SIGNER` — the signature does not recover to the wallet address. Blockchain errors (`409`, `410`, `422`) carry `code` and `retryable` next to `statusCode`, `error` and `message`. ## Implementation ```javascript import { SigningKey } from 'ethers'; // prepared = response of POST /v1/swap/approve with requiresClientSigning: true async function broadcastApproval(token, prepared, privateKey) { const sig = new SigningKey(privateKey).sign(prepared.txHashToSign); const res = await fetch('https://api.aureahub.com/v1/swap/broadcast-approve', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ preparedTxId: prepared.preparedTxId, v: sig.yParity, // 0 or 1 r: sig.r, s: sig.s }) }); const body = await res.json(); if (!res.ok) throw new Error(`${res.status} ${body.code ?? ''}: ${body.message}`); return body.txHash; // then poll /v1/swap/check-allowance } ``` Web version: https://docs.aureahub.com/#swap-broadcast-approve --- # Broadcast Gasless Tx Gasless swaps for non-custodial wallets — submit the signature for the `txHashToSign` returned by Execute Gasless. ## Overview When [Execute Gasless](https://docs.aureahub.com/docs/swap-gasless.md) returns `requiresClientSigning: true`, Aurea has already submitted the permit if one was needed and prepared the swap as an unsigned EIP-1559 transaction from the user's wallet on Gnosis. Sign `txHashToSign` with the wallet key and send `{ preparedTxId, v, r, s }` here. The API rebuilds the prepared transaction, checks that the signature recovers to the wallet's address, and broadcasts it. The swap transaction is sent from the user's wallet, which pays its gas. - The request carries no permit and no swap parameters — only the signature. `v` is the recovery parity, `0` or `1`. - The API attaches the resulting `txHash` to the wallet's most recent `pending` swap record created within the last 10 minutes — normally the `transactionId` returned by Execute Gasless. Track the swap with [Get Tx Status](https://docs.aureahub.com/docs/tx-status.md). - Only `txHash` and `status: "pending"` carry information in the response. `fromAmount`, `toAmount`, `gasUsed` and `gasPrice` are always `"0"`, `executionMethod` is `self-pay` and `routingMethod` is `direct`. - The prepared transaction expires 5 minutes after Execute Gasless (`expiresAt`). Rate limit: 10 requests per 10 seconds. > ⚠️ The prepared transaction is marked as used **before** the signature is checked. After a `422`, the same `preparedTxId` returns `409`. ## Endpoint ### `POST /v1/swap/broadcast-gasless` Authentication: bearer token required. Validates a client signature for a prepared gasless swap transaction and broadcasts it. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `preparedTxId` | string (uuid) | yes | `preparedTxId` from Execute Gasless. | | `v` | integer | yes | Signature recovery parity: `0` or `1`. | | `r` | string | yes | `0x` + 64 hex characters. | | `s` | string | yes | `0x` + 64 hex characters. | **Responses** `200` OK ```json { "txHash": "0x…", "status": "pending", "fromAmount": "0", "toAmount": "0", "gasUsed": "0", "gasPrice": "0", "executionMethod": "self-pay", "routingMethod": "direct" } ``` `404` Not Found ```json { "statusCode": 404, "error": "NotFoundError", "message": "Prepared transaction not found" } ``` `422` Wrong Signer ```json { "statusCode": 422, "error": "BlockchainError", "message": "Signature does not correspond to the expected wallet address", "code": "WRONG_SIGNER", "retryable": false, "details": { "code": "WRONG_SIGNER", "retryable": false } } ``` ## Errors - `400` `Request validation failed` — the body does not match the schema. - `404` `Prepared transaction not found` — unknown `preparedTxId`, or it belongs to another user. - `409` `PREPARED_TX_ALREADY_USED` — already submitted. - `410` `PREPARED_TX_EXPIRED` — more than 5 minutes after Execute Gasless. - `422` `SIGNATURE_DECODE_FAILED` or `WRONG_SIGNER`. ## Implementation ```javascript import { SigningKey } from 'ethers'; // result = response of POST /v1/swap/execute-gasless with requiresClientSigning: true async function broadcastGasless(token, result, privateKey) { const sig = new SigningKey(privateKey).sign(result.txHashToSign); const res = await fetch('https://api.aureahub.com/v1/swap/broadcast-gasless', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ preparedTxId: result.preparedTxId, v: sig.yParity, // 0 or 1 r: sig.r, s: sig.s }) }); const body = await res.json(); if (!res.ok) throw new Error(`${res.status} ${body.code ?? ''}: ${body.message}`); // Track with the transactionId returned by execute-gasless return { transactionId: result.transactionId, txHash: body.txHash }; } ``` Web version: https://docs.aureahub.com/#swap-broadcast-gasless --- # Execute Solana Swap Execute a LI.FI quote whose source chain is Solana. This path is an alias of Execute Swap. ## Overview `POST /v1/swap/execute-solana` uses the same request schema and the same handler as [Execute Swap](https://docs.aureahub.com/docs/swap-execute.md). The handler takes the Solana path when the target chain is `solana` — the target chain is `chain`, or the wallet's chain if omitted. With any other chain it runs the EVM path described on Execute Swap. For a quote with `sourceChainType: "solana"`, LI.FI supplies the swap as a base64-encoded Solana `VersionedTransaction` in `transactionData.data`. Pass `transactionData` unchanged. Aurea signs that transaction with the wallet's server-held keypair, sends it, and waits for confirmation before responding. - The response has `status: "confirmed"` and the Solana transaction signature in `txHash`; `fromToken`, `toToken`, `fromAmount` and `expectedToAmount` are empty strings. There is no broadcast step. - There is no approval step on Solana: Check Allowance and Approve Token return `not_applicable` values. - **Non-custodial Solana wallets are not supported.** Wallets with key management scheme `client_side`, `client_side_pending` or `mpc_tss` get `400` `Non-custodial Solana swaps are not yet supported`. - `quoteExpiry` and `gasless` have no effect on this path. ## Endpoint ### `POST /v1/swap/execute-solana` Authentication: bearer token required. Alias of POST /v1/swap/execute. For custodial Solana wallets, signs and sends the quote's VersionedTransaction. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `walletId` | string (uuid) | yes | Solana wallet that executes the swap. Must belong to the authenticated user. | | `quoteId` | string | yes | `quoteId` from Get Quote; stored with the transaction. | | `transactionData` | object | yes | `transactionData` from the quote. `data` (required) holds the base64 `VersionedTransaction`; `to` may be `null`; `value` and `gasLimit` are not needed on Solana. | | `chain` | string | no | `solana` selects the Solana path. Defaults to the wallet's chain. | | `isTestnet` | boolean | no | Use Solana devnet. Defaults to `false`. | | `fromAmount` | string | no | Stored in transaction metadata and used as the record's amount. | | `toAmount` | string | no | Stored in transaction metadata. | | `fromTokenSymbol` | string | no | Stored in transaction metadata. | | `toTokenSymbol` | string | no | Stored in transaction metadata. | | `fromTokenDecimals` | integer | no | Stored in transaction metadata. | | `toTokenDecimals` | integer | no | Stored in transaction metadata. | | `fromTokenAddress` | string | no | Stored in transaction metadata. | | `toTokenAddress` | string | no | Stored in transaction metadata. | | `quoteExpiry` | string | no | Accepted (shared schema); not used on the Solana path. | | `gasless` | boolean | no | Accepted (shared schema); not used. | **Responses** `200` OK ```json { "transactionId": "…", "txHash": "5Uf…", "status": "confirmed", "fromToken": "", "toToken": "", "fromAmount": "", "expectedToAmount": "" } ``` `400` Bad Request ```json { "statusCode": 400, "error": "BadRequestError", "message": "Non-custodial Solana swaps are not yet supported" } ``` `404` Not Found ```json { "statusCode": 404, "error": "NotFoundError", "message": "Wallet not found" } ``` Other `400` responses: `Request validation failed`, `Cannot execute swap from watch-only wallet`, and `Failed to execute Solana swap: …` when signing or sending fails. ## Implementation ```javascript // quote = response of POST /v1/swap/quote with sourceChainType 'solana' async function executeSolanaSwap(token, walletId, quote) { if (quote.sourceChainType !== 'solana') throw new Error('Not a Solana-source quote'); const res = await fetch('https://api.aureahub.com/v1/swap/execute-solana', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ walletId, chain: 'solana', quoteId: quote.quoteId, transactionData: quote.transactionData, // data = base64 VersionedTransaction fromAmount: quote.fromAmount, toAmount: quote.toAmount, fromTokenSymbol: quote.fromToken.symbol, toTokenSymbol: quote.toToken.symbol }) }); const body = await res.json(); if (!res.ok) throw new Error(`${res.status}: ${body.message}`); // { transactionId, txHash: '', status: 'confirmed', ... } return body; } ``` Web version: https://docs.aureahub.com/#swap-execute-solana --- # Onboarding Status Check whether the user has completed the bank ramp's onboarding (KYC) and can start EUR deposits. ## Overview Fiat pay-in and pay-out require the user to be onboarded with **The bank ramp**, Aurea's fiat partner. This endpoint returns what Aurea has stored for the user — it doesn't call the bank ramp. When the user has just come back from the hosted onboarding, call [Sync KYC Status](https://docs.aureahub.com/docs/payin-sync-status.md) instead, which pulls the latest state from the bank ramp. A user has a separate bank ramp profile in sandbox and in production, each with its own onboarding, KYC status, IBANs and history. Pass `isTestnet=true` to read the sandbox profile; without it, or with `false`, you get the production profile. Use the same value as for the [onboarding session](https://docs.aureahub.com/docs/payin-session.md). `canInitiateDeposit` is `true` only when `onboardingStatus` is `completed` and `kycStatus` is `approved`. A user who never started onboarding in that environment gets `not_started` for both statuses and no `customerId`. A user who opened the hosted page but was turned away on it reads the same way, and cannot be told apart — see [When the Bank Ramp Says No](https://docs.aureahub.com/docs/payin-session.md). `verification` tells you what the user must do next — `nextStep` — and why: each region's verification, what the bank ramp asks for, and the agreements (see Verification below). Your tenant must be connected to the bank ramp first — see [Bank Ramp Setup](https://docs.aureahub.com/docs/bank-setup.md). ## Payin Flow 1. **Onboarding Status** (this page) — is the user approved? 2. [**Onboarding Session**](https://docs.aureahub.com/docs/payin-session.md) — if not, send the user to the bank ramp's hosted KYC. 3. [**Sync KYC Status**](https://docs.aureahub.com/docs/payin-sync-status.md) — refresh the status when the user returns. 4. [**Initiate Deposit**](https://docs.aureahub.com/docs/payin-initiate.md) — a virtual IBAN for bank transfers. Card payments go through the [card onramp](https://docs.aureahub.com/docs/guide-card-onramp.md) instead. 5. [**Get Deposits**](https://docs.aureahub.com/docs/payin-deposits.md) — follow incoming bank transfers. ## Endpoint ### `GET /v1/ramp/bank/payin/onboarding-status` Authentication: bearer token required. Returns the user's bank ramp onboarding and KYC status in one environment, as stored by Aurea. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `isTestnet` | boolean | no | `true` for the sandbox profile; `false` or omitted for production. Any other value returns `400`. | **Responses** `200` OK ```json { "customerId": "3f6c2a8e-5b1d-4e7a-9c3f-2d8e1b4a6c90", "noahCustomerId": "aurea_5d1f0c9b2e7a4c3d8f6b1a0e9c2d7b4f5a3e", "onboardingStatus": "completed", "kycStatus": "approved", "canInitiateDeposit": true, "message": "Customer is fully onboarded and can initiate deposits", "verification": { "status": "Approved", "customerType": "Individual", "regions": [ { "entity": "Lt", "region": "EU", "status": "Approved", "rejection": null } ], "actionsRequired": [], "agreements": [ { "name": "NoahTermsOfService", "version": 1, "accepted": true, "acceptedAt": "2026-09-17T09:12:41Z" }, { "name": "NoahPrivacyPolicy", "version": 1, "accepted": true, "acceptedAt": "2026-09-17T09:12:41Z" } ], "nextStep": "approved" } } ``` Values: `onboardingStatus` is `not_started`, `pending`, `in_progress`, `completed` or `failed`; `kycStatus` is `not_started`, `pending`, `approved` or `rejected`. `kycTier` is included when the bank ramp has assigned one. ## Verification `verification` is what Aurea keeps from the bank ramp about the user's verification in that environment, from the bank ramp's `Customer` events and from [Sync KYC Status](https://docs.aureahub.com/docs/payin-sync-status.md). It never contains a reviewer's comment. | Field | Description | | --- | --- | | status | the bank ramp's overall verification: `Pending`, `Approved` (at least one region approved) or `Declined` (final: The bank ramp offboarded the user). `null` before the bank ramp has said anything. It never moves back: an approved user stays approved unless the bank ramp declines them. | | customerType | `Individual` or `Business`, once the bank ramp has said which; otherwise `null`. | | regions | One entry per the bank ramp legal entity that verifies the user: `entity` (`Lt`, `Us`, `Ca`), `region` (`EU`, `US`, `CA`), `status` for that region, and `rejection` for a declined region — `retry` when the user may try again, `final` when not, otherwise `null`. Check the region you need: a user can be approved in one region and declined in another. | | actionsRequired | What the bank ramp asks the user to do before the review continues, such as `DocumentReupload`, `ProofOfAddress`, `SelfieReupload` or `HighRiskInfo`. Empty when nothing is asked. The list can grow: show a generic message for a value you don't know. | | agreements | The agreements the bank ramp last listed for the user: `name`, `version`, `accepted`, `acceptedAt`. | | nextStep | What to do now: `start_onboarding` (no profile yet, or the bank ramp has said nothing) and `continue_onboarding` (the bank ramp asks for something, a region may retry, or an agreement is not accepted) — create an [onboarding session](https://docs.aureahub.com/docs/payin-session.md) and open it; `wait_for_review` — the bank ramp is reviewing; `approved` — nothing to do; `rejected` — the bank ramp declined the user for good. | `kycStatus` and `onboardingStatus` follow the overall status and never move back either: Pending is `pending`/`pending`, Approved `approved`/`completed`, Declined `rejected`/`failed`. ## Implementation ```javascript // Decide which fiat screen to show, in the environment the app runs in async function getFiatEligibility(token, { sandbox = false } = {}) { const res = await fetch(`https://api.aureahub.com/v1/ramp/bank/payin/onboarding-status?isTestnet=${sandbox}`, { headers: { Authorization: `Bearer ${token}` } }); if (!res.ok) throw new Error(`Onboarding status failed: ${res.status}`); const { canInitiateDeposit, verification } = await res.json(); if (canInitiateDeposit) return { eligible: true, nextStep: verification.nextStep }; switch (verification.nextStep) { case 'rejected': return { eligible: false, reason: 'kyc_rejected' }; case 'start_onboarding': case 'continue_onboarding': // open a new onboarding session: The bank ramp shows what is missing return { eligible: false, reason: 'needs_onboarding', actions: verification.actionsRequired }; default: return { eligible: false, reason: 'kyc_in_review' }; } } ``` Web version: https://docs.aureahub.com/#payin-status --- # Create Onboarding Session Get a hosted onboarding (KYC) link for the user and send them there. ## Overview The bank ramp runs the whole KYC flow — identity verification, documents, questionnaires — on its hosted page. Call this endpoint, open the returned `onboardingUrl` in a browser or an in-app browser, and the bank ramp sends the user back to your `returnUrl` when they finish. - `returnUrl` is handed to the bank ramp unchanged, and the bank ramp sends the user back to an `https://` address only: any other answers `400` with `details.code` `RETURN_URL_NOT_HTTPS`, before the bank ramp is called. Use an `https://` page, not an app deep link. (Payouts are different: there Aurea proxies the return, so deep links work.) - **The return URL must be one your tenant allows.** Once the Aurea operator has added your tenant's return URLs ([Bank Ramp Setup](https://docs.aureahub.com/docs/bank-setup.md)), any other `returnUrl` answers `400` with `details.code` `RETURN_URL_NOT_ALLOWED`, and the bank ramp is not called. A tenant with no return URLs yet is not checked. - **Retries.** Send an `Idempotency-Key` header to make a retry safe: the same key with the same body answers with the first answer, and the response header `idempotency-replayed` is `true`. See [Idempotency](https://docs.aureahub.com/docs/idempotency.md). - **KYC must be switched on** for your tenant in the environment `isTestnet` names ([Tenant Settings](https://docs.aureahub.com/docs/bank-settings.md)), otherwise `403` with `details.code` `NOAH_FUNCTION_OFF`, before the bank ramp is called and before an `Idempotency-Key` is replayed. - **One bank ramp customer per user and environment, for good.** The bank ramp rejects a second KYC application for a person, so Aurea always asks the bank ramp for the same customer: calling again resumes the user's onboarding, it never starts another one. - `customerType` (`Individual`, the bank ramp's default, or `Business`) and `locale` (the language of the bank ramp's page: `en`, `es`, `fr`, `de`, `it`, `pt`) are sent to the bank ramp only when you give them. Once the bank ramp has a type for the user, asking for the other one answers `409` with `details.code` `NOAH_CUSTOMER_TYPE_MISMATCH` and `details.customerType`, without calling the bank ramp. - `isTestnet: true` uses the bank ramp's sandbox; the default is production (see [Bank Ramp Setup](https://docs.aureahub.com/docs/bank-setup.md)). Each environment keeps its own profile for the user, with its own KYC: a user approved in sandbox still onboards separately in production. Use the same value later for the user's status, deposits and payouts. When the user comes back, call [Sync KYC Status](https://docs.aureahub.com/docs/payin-sync-status.md) to read the result from the bank ramp without waiting for the webhook. ## When the Bank Ramp Says No the bank ramp's hosted onboarding ends with an **available accounts** step, after the country, the email and the agreements. When the bank ramp has no virtual account to offer that person, that step is where the flow stops: *"based on the information you provided, we are currently unable to offer any virtual accounts to you due to regulatory restrictions"*. The page has no way forward — only Help and Exit. **Sending no `fiatOptions` does not skip that step.** Measured against the bank ramp's sandbox with fresh customers: a session that applied for EUR and a session that applied for nothing at all reached the same screen, in two different countries. Treat `fiatOptions` as what Aurea forwards to the hosted flow, not as a way to steer the hosted flow. **Neither Aurea nor you can see that this happened.** The bank ramp does not create the customer at all, so there is no status anywhere to read: [Onboarding Status](https://docs.aureahub.com/docs/payin-status.md) goes on answering `not_started`, [Sync KYC Status](https://docs.aureahub.com/docs/payin-sync-status.md) goes on answering that the bank ramp does not have the customer, and nothing tells this user apart from one who never opened the link. This is a limit of what the bank ramp exposes, not an omission in Aurea. So **treat a status that has not moved after the user came back as a failure, not as a reason to send them again** — a second link leads to the same wall. Count the attempts on your side: after the second one, tell the user the verification did not go through and give them a way to reach a person, instead of the same button. **A payout needs no virtual account** — a payout to a proven address settles on-chain — but that does not currently carry a user past this step, because the step runs either way. Send `fiatOptions: []` when your user genuinely wants no account, so the bank ramp is not asked for one; do not send it expecting a different hosted outcome. ## Outcome For a user Aurea already has, it first reads the customer from the bank ramp (and stores it, as [Sync KYC Status](https://docs.aureahub.com/docs/payin-sync-status.md) does). `outcome` then says what happened; only `session` has a link. | outcome | What to do | | --- | --- | | session | Open `onboardingUrl` before `expiresAt`: the bank ramp's link lasts **1 hour**, **24 hours** for a Business customer. A link Aurea already gave with more than five minutes left is given again **to the same request** — the same `returnUrl`, `fiatOptions`, `customerType`, `locale` and `metadata`; a request that changes any of them gets a new session from the bank ramp, made for what it asked. After those five minutes, call this endpoint again for a new one. A user the bank ramp approved but asks something of (`verification.nextStep` `continue_onboarding`) gets a session too: The bank ramp shows what is missing. | | approved | the bank ramp approved the user and asks for nothing: `alreadyCompleted` is `true`. Continue with [Initiate Deposit](https://docs.aureahub.com/docs/payin-initiate.md). An approved user stays approved when the bank ramp's answer says nothing about the verification; only the bank ramp declining them changes it. | | in_review | the bank ramp is reviewing the user and needs nothing now. Show that the verification is in progress; the bank ramp's `Customer` webhook, or [Sync KYC Status](https://docs.aureahub.com/docs/payin-sync-status.md), gives the result. | | declined | the bank ramp declined the user for good: no session is created and the bank ramp is not asked. | `verification` is the user's verification after the call, as [Onboarding Status](https://docs.aureahub.com/docs/payin-status.md) returns it. When Aurea had no profile for the user in that environment and the bank ramp already has the customer, the profile is created and its `customerId` returned. ## Endpoint ### `POST /v1/ramp/bank/payin/onboard-session` Authentication: bearer token required. Creates, or reuses, a hosted onboarding session for the authenticated user. **Headers** | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | no | Optional. Up to 255 letters, digits, - or _. The same key with the same body answers with the first answer instead of doing it again; see [Idempotency](https://docs.aureahub.com/docs/idempotency.md) | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `returnUrl` | string | yes | https:// URL, 10 to 1000 characters, the bank ramp redirects the user to when onboarding ends; any other answers 400 RETURN_URL_NOT_HTTPS | | `fiatOptions` | object[] | no | Virtual accounts to apply for on the user's behalf. Each item: { fiatCurrency: EUR \| USD \| GBP }. Omitted means EUR only; **an empty list applies for none**, so the bank ramp receives no FiatOptions — it does not change what the bank ramp’s hosted page shows: see [When the Bank Ramp Says No](https://docs.aureahub.com/docs/payin-session.md) | | `metadata` | object | no | Up to 10 text values attached to the bank ramp customer; any other value answers 400 | | `isTestnet` | boolean | no | true = the bank ramp's sandbox, false = production (default false) | | `customerType` | string | no | Individual (the bank ramp's default) or Business. Another type than the one the bank ramp has answers 409 NOAH_CUSTOMER_TYPE_MISMATCH | | `locale` | string | no | Language of the bank ramp's page: en, es, fr, de, it or pt (the bank ramp's default en) | **Responses** `201` Session ```json { "customerId": "3f6c2a8e-5b1d-4e7a-9c3f-2d8e1b4a6c90", "noahCustomerId": "aurea_5d1f0c9b2e7a4c3d8f6b1a0e9c2d7b4f5a3e", "onboardingUrl": "", "expiresAt": "2026-09-12T09:30:00.000Z", "alreadyCompleted": false, "outcome": "session", "message": "Onboarding session created. Direct customer to the onboardingUrl to complete KYC verification.", "verification": { "status": null, "customerType": null, "regions": [], "actionsRequired": [], "agreements": [], "nextStep": "start_onboarding" } } ``` `201` In review ```json { "customerId": "3f6c2a8e-5b1d-4e7a-9c3f-2d8e1b4a6c90", "noahCustomerId": "aurea_5d1f0c9b2e7a4c3d8f6b1a0e9c2d7b4f5a3e", "onboardingUrl": "", "alreadyCompleted": false, "outcome": "in_review", "message": "The bank ramp is reviewing this customer: no onboarding session is needed now. The bank ramp's Customer webhook reports the result.", "verification": { "status": "Pending", "customerType": "Individual", "regions": [ { "entity": "Lt", "region": "EU", "status": "Pending", "rejection": null } ], "actionsRequired": [], "agreements": [], "nextStep": "wait_for_review" } } ``` `400` Return URL not allowed ```json { "statusCode": 400, "error": "BadRequestError", "message": "This return URL is not one the tenant allows. Ask the Aurea operator to add it.", "details": { "code": "RETURN_URL_NOT_ALLOWED" } } ``` `409` Another customer type ```json { "statusCode": 409, "error": "ConflictError", "message": "The bank ramp has this customer as Individual: an onboarding session for another customer type is not possible.", "details": { "code": "NOAH_CUSTOMER_TYPE_MISMATCH", "customerType": "Individual" } } ``` `403` KYC switched off ```json { "statusCode": 403, "error": "ForbiddenError", "message": "The bank ramp's KYC is not switched on for this tenant in sandbox.", "details": { "code": "NOAH_FUNCTION_OFF", "function": "kyc", "environment": "sandbox" } } ``` `503` Bank Ramp not configured ```json { "statusCode": 503, "error": "NoahApiError", "message": "The bank ramp is not configured for this tenant in sandbox", "details": { "code": "NOAH_NOT_CONFIGURED" } } ``` When the user is already approved, the `201` body has `"outcome": "approved"`, `"alreadyCompleted": true`, `"onboardingUrl": ""` and the message `Customer already completed onboarding. Use /initiate to get virtual IBAN.` When the bank ramp declined the user for good, `"outcome": "declined"` and the message `Noah declined this customer for good: no onboarding session is created.` ## Implementation ```javascript // 1. Start (or resume) the onboarding async function startBankOnboarding(token, { sandbox = false } = {}) { const res = await fetch('https://api.aureahub.com/v1/ramp/bank/payin/onboard-session', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ returnUrl: 'https://app.example.com/kyc/done', fiatOptions: [{ fiatCurrency: 'EUR' }], isTestnet: sandbox }) }); if (!res.ok) throw new Error(`Onboarding session failed: ${res.status}`); const { outcome, onboardingUrl } = await res.json(); switch (outcome) { case 'session': window.location.href = onboardingUrl; // or open it in an in-app browser return; case 'approved': return showDepositOptions(); case 'in_review': return showVerificationInProgress(); // the bank ramp's Customer webhook brings the result case 'declined': return showKycDeclined(); } } // 2. On https://app.example.com/kyc/done — refresh the status from the bank ramp async function onKycReturn(token, { sandbox = false } = {}) { const res = await fetch('https://api.aureahub.com/v1/ramp/bank/payin/sync-status', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ isTestnet: sandbox }) // the same value as the session }); const { canInitiateDeposit, kycStatus, onboardingStatus } = await res.json(); if (canInitiateDeposit) return showDepositOptions(); if (kycStatus === 'rejected') return showKycRejected(); // Nothing moved: The bank ramp has no customer, so its page produced nothing — the user // was turned away on it. A second link leads to the same wall, so count and stop. if (onboardingStatus === 'not_started' && kycStatus === 'not_started') { const attempts = countKycAttempt(userId); // your own storage, not Aurea's return attempts >= 2 ? showKycUnavailable() : offerKycOnceMore(); } showKycInProgress(); // the bank ramp is reviewing — its Customer webhook brings the result } ``` Web version: https://docs.aureahub.com/#payin-session --- # Sync KYC / Onboarding Status Pull the user's latest onboarding and KYC status from the bank ramp and store it — useful right after the user returns from the hosted onboarding. ## Overview Aurea normally learns KYC results from the bank ramp's `Customer` webhook. When the user has just come back from the [onboarding session](https://docs.aureahub.com/docs/payin-session.md) and you don't want to wait for it, call this endpoint: Aurea asks the bank ramp for the customer, updates its record if anything changed, and returns the result. - Send `{ "isTestnet": true }` as the JSON body to sync the user's sandbox profile with the bank ramp's sandbox; without it Aurea syncs the production profile. The `?isTestnet=true` query parameter is still read when the body has no `isTestnet`; when both are sent, the body wins. - Aurea stores the bank ramp's answer by the same rules as the bank ramp's `Customer` event: the statuses never move back (an approved user stays approved unless the bank ramp declines them, even when the bank ramp's answer says nothing about the verification), and each region's verification, what the bank ramp asks the user to do and the agreements are kept and returned in `verification`, described on [Onboarding Status](https://docs.aureahub.com/docs/payin-status.md). If a bank ramp event changed the user while Aurea was asking the bank ramp, the actions and agreements that event brought are kept. - `synced` is `true` only when `kycStatus` or `onboardingStatus` changed. `false` means they were already up to date — or that the user isn't known to the bank ramp yet, in which case Aurea returns what it has stored. - A user who never started onboarding in that environment gets `not_started` statuses, and the bank ramp isn't called. ## Endpoint ### `POST /v1/ramp/bank/payin/sync-status` Authentication: bearer token required. Fetches the status of the user's profile in one environment from the bank ramp, stores it, and returns the up-to-date onboarding and KYC status. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `isTestnet` | boolean | no | Read only when the body has no `isTestnet`; same meaning | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `isTestnet` | boolean | no | `true` = the sandbox profile and the bank ramp's sandbox; `false` or omitted = production | **Responses** `200` OK ```json { "customerId": "3f6c2a8e-5b1d-4e7a-9c3f-2d8e1b4a6c90", "noahCustomerId": "aurea_5d1f0c9b2e7a4c3d8f6b1a0e9c2d7b4f5a3e", "onboardingStatus": "completed", "kycStatus": "approved", "canInitiateDeposit": true, "synced": true, "message": "Status synced from the bank ramp. Onboarding: completed, KYC: approved.", "verification": { "status": "Approved", "customerType": "Individual", "regions": [ { "entity": "Lt", "region": "EU", "status": "Approved", "rejection": null } ], "actionsRequired": [], "agreements": [ { "name": "NoahTermsOfService", "version": 1, "accepted": true, "acceptedAt": "2026-09-17T09:12:41Z" }, { "name": "NoahPrivacyPolicy", "version": 1, "accepted": true, "acceptedAt": "2026-09-17T09:12:41Z" } ], "nextStep": "approved" } } ``` `503` Bank Ramp not configured ```json { "statusCode": 503, "error": "NoahApiError", "message": "The bank ramp is not configured for this tenant in production", "details": { "code": "NOAH_NOT_CONFIGURED" } } ``` When nothing changed the message is `Status already up to date. Onboarding: …, KYC: ….` and `synced` is `false`. `verification.nextStep` answers `start_onboarding` for a user who never opened the link *and* for one the bank ramp turned away on its page — the bank ramp has no customer in either case. Straight after a return, read it as the second: do not open a new session automatically, or the user walks into the same wall for ever ([When the Bank Ramp Says No](https://docs.aureahub.com/docs/payin-session.md)). ## Implementation ```typescript // Call when the user lands on your returnUrl after the hosted onboarding async function refreshKycAfterReturn(token: string, { sandbox = false, attempts = 5 } = {}) { const url = 'https://api.aureahub.com/v1/ramp/bank/payin/sync-status'; for (let i = 0; i < attempts; i++) { const res = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ isTestnet: sandbox }) // the same value used for the onboarding session }); if (!res.ok) throw new Error(`Sync failed: ${res.status}`); const status = await res.json(); // approved, rejected, or something the user must do in a new onboarding session if (status.verification.nextStep !== 'wait_for_review') return status; await new Promise(r => setTimeout(r, 3_000)); // automated checks usually finish within seconds } return null; // still under review — the bank ramp's Customer webhook will update Aurea later } ``` Web version: https://docs.aureahub.com/#payin-sync-status --- # Bank Ramp Currencies & Networks List the stablecoins and networks a user can receive from a bank deposit or sell for fiat, in the bank ramp's sandbox or in production. ## Overview Aurea keeps a registry of the currency and network pairs it uses with the bank ramp, and your tenant offers all of them or the ones the Aurea operator chose ([Tenant Settings](https://docs.aureahub.com/docs/bank-settings.md)). [Initiate Deposit](https://docs.aureahub.com/docs/payin-initiate.md) and [Initiate Payout](https://docs.aureahub.com/docs/payout-initiate.md) check every request against the pairs your tenant offers, and this endpoint returns them for one environment, so your app offers exactly those. It answers from Aurea's records. - `isTestnet=true` lists the sandbox pairs; `false` or omitted lists the production pairs. - Names are the bank ramp's: the currency code is the asset in production and the asset with `_TEST` in the sandbox (`EURC_TEST`), and the network is the bank ramp's network name (`Solana`, `SolanaDevnet`, `PolygonTestAmoy`). Send them unchanged. - `payin: true`: Initiate Deposit accepts this `cryptoCurrency` on this `network`. `payout: true`: Initiate Payout accepts this `cryptoCurrency`. Only pairs with at least one of the two are listed, sorted by `cryptoCurrency` and then `network`. - `addressFormat` is the format of a destination on the network: `evm` (`0x` and 40 hex characters) or `solana` (base58). `chainId` is the EVM chain id, `null` on Solana. `decimals` is the precision of payout amounts: `cryptoAmount` is an integer in the token's smallest unit. - A user of a tenant that never switched the bank ramp on in that environment gets an empty list. An Aurea administrator's token, which names no tenant, gets every pair Aurea enables. - Pairs change when Aurea enables a new one or the operator changes your tenant's choice, so read the list when you show the options. - Answers `403` when your tenant does not use the bank ramp. ## Endpoint ### `GET /v1/ramp/bank/currencies` Authentication: bearer token required. Returns the currency and network pairs the user's tenant offers for pay-in or payout in one environment. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `isTestnet` | boolean | no | `true` for the sandbox pairs; `false` or omitted for production. Any other value returns `400`. | **Responses** `200` OK ```json { "environment": "production", "currencies": [ { "asset": "EURC", "cryptoCurrency": "EURC", "network": "Solana", "addressFormat": "solana", "chainId": null, "tokenAddress": "HzwqbKZw8HxMN6bF2yFZNrht3c2iXXzpKcFu7uBEDKtr", "decimals": 6, "payin": true, "payout": false } ] } ``` `403` Not the bank ramp ```json { "statusCode": 403, "error": "ForbiddenError", "message": "The bank ramp is not enabled for this tenant. Current banking provider: gnosis" } ``` The sandbox list today: `EURC_TEST`, `PYUSD_TEST`, `USDC_TEST` and `USDG_TEST` on `SolanaDevnet`, `USDC_TEST` on `PolygonTestAmoy` and on `CeloTestSepolia`, and `PYUSD_TEST` on `FlowEvmTest` — **all seven for pay-in and for payout**. In production: `EURC` on `Base` and `Solana`; `USDC` on `Base`, `Celo`, `Ethereum`, `PolygonPos` and `Solana`; `USDT` on `Celo` and `Ethereum`; and `PYUSD`, `USDG` and `USDPT` on `Solana`, each for pay-in and for payout. Every one of those token contracts was read from its own chain before it was enabled. Three pairs the registry knows are deliberately **off** — `USDC` on `Gnosis`, `USDT` on `PolygonPos` and `PYUSD` on `FlowEvm` — because more than one contract claims that name on those chains and only the bank ramp can say which one it credits. Ask for one and you get `400` `NOAH_PAIR_UNAVAILABLE` before anything is sent. This list is what *Aurea* enables. Your tenant may offer fewer ([Tenant Settings](https://docs.aureahub.com/docs/bank-settings.md)), and this endpoint answers the intersection — so read it rather than this paragraph. ## Implementation ```javascript async function depositOptions(token, isTestnet) { const res = await fetch(`https://api.aureahub.com/v1/ramp/bank/currencies?isTestnet=${isTestnet}`, { headers: { Authorization: `Bearer ${token}` } }); const data = await res.json(); if (!res.ok) throw new Error(data.message); // Offer the pay-in pairs, and send the chosen one to Initiate Deposit unchanged return data.currencies .filter((pair) => pair.payin) .map((pair) => ({ label: `${pair.asset} on ${pair.network}`, cryptoCurrency: pair.cryptoCurrency, network: pair.network })); } ``` Web version: https://docs.aureahub.com/#bank-currencies --- # Bank Ramp Tenant Settings Read what your tenant offers its users with the bank ramp in one environment — which functions are on, where pay-ins go, which currencies and networks, and the fees — so your app shows only what will work. ## Overview The Aurea operator switches the bank ramp on for your tenant, environment by environment ([Bank Ramp Setup](https://docs.aureahub.com/docs/bank-setup.md)). The bank ramp endpoints that create something follow those settings, and this endpoint returns them for one environment. It answers from Aurea's records. - `isTestnet=true` reads the sandbox settings; `false` or omitted reads production. - `switchedOn` is `false` when your tenant never switched the bank ramp on in that environment: every function is off and no currency is offered. - `functions.kyc` allows [Onboarding Session](https://docs.aureahub.com/docs/payin-session.md); `functions.payin` allows [Initiate Deposit](https://docs.aureahub.com/docs/payin-initiate.md) and [Simulate Deposit](https://docs.aureahub.com/docs/sandbox-deposit.md); `functions.payout` allows [Initiate Payout](https://docs.aureahub.com/docs/payout-initiate.md), [Simulate Payout](https://docs.aureahub.com/docs/sandbox-payout.md) and the reads that prepare a payout: [Countries](https://docs.aureahub.com/docs/payout-countries.md), [Search Channels](https://docs.aureahub.com/docs/payout-channels.md), [Channel Form](https://docs.aureahub.com/docs/payout-channel-form.md) and [Saved Beneficiaries](https://docs.aureahub.com/docs/payout-beneficiaries.md), a payout quote with its form steps ([Create a Quote](https://docs.aureahub.com/docs/payout-quote.md), [Answer a Form Step](https://docs.aureahub.com/docs/payout-quote-step.md)) and paying a quote from the user's own wallet ([Pay from a Wallet](https://docs.aureahub.com/docs/payout-wallet-pay.md)). A request for a function that is off answers `403` with `details.code` `NOAH_FUNCTION_OFF`. The other reads — onboarding status, payment methods, deposits, transactions, payout transactions, payout quotes and wallet payouts — always answer. - `modes.aureaWallets`: pay-ins are delivered to the user's Aurea wallets. `modes.standalone`: pay-ins are delivered to addresses outside Aurea that the user proved they own, and users can prove one ([Standalone Pay-In](https://docs.aureahub.com/docs/guide-standalone-payin.md)); asking for or verifying a proof while it is off answers `403` with `details.code` `NOAH_MODE_OFF`. With both, either kind of address is accepted. - `currencies`: the pairs your tenant offers, in the shape of [Currencies & Networks](https://docs.aureahub.com/docs/bank-currencies.md). - `fees`: the business fee set for your tenant, per flow and bank payment method type (`null` for every other type). `feePct` and `feeBase` are decimals written as text; `fiatCurrency` is the currency of `feeBase`, `null` when `feeBase` is zero. The pay-in fee reaches the bank ramp with every [Initiate Deposit](https://docs.aureahub.com/docs/payin-initiate.md), and the bank ramp takes it from each deposit. The payout fee for the channel's payment method type, or the one for every type, reaches the bank ramp with every [payout quote](https://docs.aureahub.com/docs/payout-quote.md) and shows in its `breakdown`; the hosted [Initiate Payout](https://docs.aureahub.com/docs/payout-initiate.md) sends none. - Answers `403` to a token without a tenant, and when your tenant does not use the bank ramp. ## Endpoint ### `GET /v1/ramp/bank/settings` Authentication: bearer token required. Returns what the user's tenant offers with the bank ramp in one environment. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `isTestnet` | boolean | no | `true` for the sandbox settings; `false` or omitted for production. Any other value returns `400`. | **Responses** `200` OK ```json { "environment": "sandbox", "switchedOn": true, "functions": { "kyc": true, "payin": true, "payout": true }, "modes": { "aureaWallets": true, "standalone": false }, "currencies": [ { "asset": "USDC", "cryptoCurrency": "USDC_TEST", "network": "PolygonTestAmoy", "addressFormat": "evm", "chainId": 80002, "tokenAddress": "0xae1D7d8B36E9AbA7D95A75c69d50b38E7e02A9DD", "decimals": 6, "payin": true, "payout": true } ], "fees": [ { "flow": "payin", "paymentMethodType": "BankSepa", "feePct": "0.5", "feeBase": "0", "fiatCurrency": null } ] } ``` `200` Never switched on ```json { "environment": "production", "switchedOn": false, "functions": { "kyc": false, "payin": false, "payout": false }, "modes": { "aureaWallets": false, "standalone": false }, "currencies": [], "fees": [] } ``` `403` No tenant ```json { "statusCode": 403, "error": "ForbiddenError", "message": "This request needs a tenant: an admin token without one has no the bank ramp settings." } ``` ## Implementation ```javascript async function noahMenu(token, isTestnet) { const res = await fetch(`https://api.aureahub.com/v1/ramp/bank/settings?isTestnet=${isTestnet}`, { headers: { Authorization: `Bearer ${token}` } }); const settings = await res.json(); if (!res.ok) throw new Error(settings.message); return { showKyc: settings.functions.kyc, showBankDeposit: settings.functions.payin && (settings.modes.aureaWallets || settings.modes.standalone), showProveAddress: settings.modes.standalone, showPayout: settings.functions.payout, depositPairs: settings.currencies.filter((pair) => pair.payin), payoutCurrencies: [...new Set(settings.currencies.filter((pair) => pair.payout).map((pair) => pair.cryptoCurrency))] }; } ``` Web version: https://docs.aureahub.com/#bank-settings --- # Initiate EUR Bank Deposit Assign the user a virtual IBAN: EUR bank transfers to it are converted by the bank ramp into crypto and sent to an on-chain address. ## Overview This endpoint creates a bank ramp *bank deposit → on-chain address* route for the user and returns the bank details to show them. There is no amount and no payment reference: the user can send any number of SEPA transfers to the IBAN. Each transfer appears in [Get Deposits](https://docs.aureahub.com/docs/payin-deposits.md) once the bank ramp reports it, and the converted crypto is sent to `destinationAddress`. - **Pay-in must be switched on** for your tenant in the pair's environment ([Tenant Settings](https://docs.aureahub.com/docs/bank-settings.md)), otherwise `403` with `details.code` `NOAH_FUNCTION_OFF`, before anything is read or sent. It is checked before an `Idempotency-Key` is replayed, so a key sent again after the switch-off is refused too. - **The pair must be offered.** `cryptoCurrency` on `network` must be a pay-in pair of [Currencies & Networks](https://docs.aureahub.com/docs/bank-currencies.md), which lists the pairs your tenant offers. Aurea enables today, in production, `EURC` on `Base` and `Solana`; `USDC` on `Base`, `Celo`, `Ethereum`, `PolygonPos` and `Solana`; `USDT` on `Celo` and `Ethereum`; and `PYUSD`, `USDG` and `USDPT` on `Solana`; in the sandbox, `EURC_TEST`, `PYUSD_TEST`, `USDC_TEST` or `USDG_TEST` on `SolanaDevnet`, `USDC_TEST` on `PolygonTestAmoy` or `CeloTestSepolia`, and `PYUSD_TEST` on `FlowEvmTest`. Any other pair answers `400` with `details.code` `NOAH_PAIR_UNAVAILABLE` and the `available` pairs your tenant offers, before anything is sent to the bank ramp. - **Sandbox or production is taken from `network`.** The bank ramp's test networks (`SolanaDevnet`, `PolygonTestAmoy`, `CeloTestSepolia`, `FlowEvmTest`) use the user's sandbox profile and the bank ramp's sandbox; mainnets (`Solana`, `PolygonPos`) use the production profile and credentials. If you also send `isTestnet`, it must agree with the network, otherwise `400` `NOAH_ENVIRONMENT_MISMATCH`. The defaults are the sandbox route `EURC_TEST` on `SolanaDevnet`, so always set both fields in production. - **Names.** Use the bank ramp's names, as Currencies & Networks lists them. `Polygon` and `Sepolia` are still read as `PolygonPos` and `EthereumTestSepolia`, and on a sandbox network an asset code is read as its sandbox code (`EURC` on `SolanaDevnet` is `EURC_TEST`). The response and the stored payment method carry the bank ramp's names. - The user must have completed the bank ramp's onboarding, with KYC approved, in that environment ([Onboarding Status](https://docs.aureahub.com/docs/payin-status.md) with the matching `isTestnet`). Without a profile there the call answers `404`; with onboarding or KYC incomplete, `422`. - **Destination.** If you leave out `destinationAddress`, a Solana route uses the user's Solana wallet of the same environment — the Devnet wallet for `SolanaDevnet`, the mainnet wallet for `Solana` — and answers `400` when the user has none. An EVM route (such as `PolygonTestAmoy`) has no default: without `destinationAddress` it answers `400` `destinationAddress is required for network …`. The default is an Aurea wallet, so with the standalone mode send the proven address. - **The destination must be an address your tenant delivers to** (`modes` in [Tenant Settings](https://docs.aureahub.com/docs/bank-settings.md)): - with the *Aurea wallets* mode, one of the user's Aurea wallets: a wallet Aurea created for the user, holds a key share of, or the user registered from their device. A watch-only import is not one, because it proves nothing about who controls the address; - with the *standalone* mode, an address the user proved they own in the network's environment and has not revoked ([Standalone Pay-In](https://docs.aureahub.com/docs/guide-standalone-payin.md)); - with both, either. An EVM address matches on any EVM chain and in any letter case. Any other address answers `400` with `details.code` `NOAH_DESTINATION_NOT_ALLOWED`, and nothing is sent to the bank ramp. - EVM routes also need a primary EVM wallet in Aurea, unless your tenant has the standalone mode in that environment; Solana routes never do. For your app to show what arrives on an EVM network, your tenant needs that network enabled and its tokens added from the catalog; Aurea sets both up for your tenant. - Aurea treats the assignment as valid for about a day (`expiresAt`): call this endpoint again to refresh it before showing the IBAN again. - **Business fee.** When your tenant has a pay-in fee in that environment (`fees` in [Tenant Settings](https://docs.aureahub.com/docs/bank-settings.md)), Aurea sends it to the bank ramp with the request, for every bank payment method type — the type's own fee, or else the fee for every other type — and the bank ramp takes it from each deposit. Without a fee nothing is added. A change to the fee is sent with the next call. - **What the bank ramp answered.** `virtualAccountId` is the bank ramp's id of the virtual account; `paymentMethodType` its primary rail (`BankSepa` for EUR); `fee` the bank ramp's fee for each deposit on it (`pct`, `base`, `min` as exact decimal text, in `currency`); `reference` the bank ramp's reference, as sent — the bank ramp doesn't document its use; `relatedPaymentMethods` the account's other rails, each with its own `accountNumber`, `bankCode` (ABA routing number for Fedwire and ACH, BIC for SWIFT) and fee, empty when there are none. A value the bank ramp didn't send in its documented form is `null`, or the rail is left out. When the bank ramp returns the same payment method again, its newest answer is kept, and a value the bank ramp no longer sends stays. - **The account's currency.** `fiatCurrency` is the currency the account was asked in, and the one Aurea keeps for it — a USD account is listed as USD by [Payment Methods](https://docs.aureahub.com/docs/payin-methods.md) and paid in dollars by [Simulate Deposit](https://docs.aureahub.com/docs/sandbox-deposit.md). Asking again refreshes an account stored before with another currency. - An answer from the bank ramp without its payment method id or account number answers `502` with `details.code` `NOAH_UNEXPECTED_RESPONSE`, and nothing is stored. - `POST /v1/ramp/bank/payin/initiate-deposit` is an alias with the same body and response. - **Retries.** Send an `Idempotency-Key` header to make a retry safe: the same key with the same body answers with the first answer, and the response header `idempotency-replayed` is `true`. See [Idempotency](https://docs.aureahub.com/docs/idempotency.md). The alias and this endpoint share the same keys. ## Endpoint ### `POST /v1/ramp/bank/payin/initiate` Authentication: bearer token required. Creates (or refreshes) the user's virtual IBAN for a crypto route and returns the bank details. **Headers** | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | no | Optional. Up to 255 letters, digits, - or _. The same key with the same body answers with the first answer instead of doing it again; see [Idempotency](https://docs.aureahub.com/docs/idempotency.md) | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `cryptoCurrency` | string | no | the bank ramp currency code of an enabled pay-in pair, e.g. EURC or EURC_TEST (default EURC_TEST). On a sandbox network an asset code such as EURC is read as EURC_TEST | | `network` | string | no | the bank ramp network name of that pair, e.g. Solana or SolanaDevnet (default SolanaDevnet). Also selects sandbox or production | | `destinationAddress` | string | no | Address that receives the crypto (26-255 characters), in the network's address format. Required in practice outside the sandbox | | `isTestnet` | boolean | no | Optional check: when sent, must match the network's environment, otherwise 400 NOAH_ENVIRONMENT_MISMATCH | | `fiatCurrency` | string | no | EUR, USD or GBP — the account's currency, which decides the banking rail the bank ramp uses: **EUR is SEPA, USD is ACH/Fedwire, GBP the local rail**. Default EUR. A user whose country is not eligible for that rail is refused by the bank ramp with 403 and `details.code` `NOAH_FORBIDDEN`, carrying the bank ramp's `denyReasons` — the bank ramp's country-by-rail policy decides it, not Aurea | **Responses** `201` Created ```json { "paymentMethodId": "7a1d3c5e-9b2f-4e6a-8c0d-1f3b5d7e9a2c", "noahPaymentMethodId": "", "paymentMethodType": "BankSepa", "virtualAccountId": "", "accountHolderName": "", "iban": "", "bic": "", "bankName": "", "bankAddress": { "street": "", "street2": null, "city": "", "postalCode": "", "state": "", "country": "" }, "reference": null, "fee": { "currency": "EUR", "pct": "0", "base": "0", "min": "0" }, "relatedPaymentMethods": [], "fiatCurrency": "EUR", "cryptoCurrency": "EURC", "network": "Solana", "destinationAddress": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU", "status": "active", "expiresAt": "2026-09-12T08:30:00.000Z", "message": "Virtual IBAN assigned successfully. Please note: IBAN expires in 24 hours and must be refreshed." } ``` `422` Not onboarded ```json { "statusCode": 422, "error": "ValidationError", "message": "Customer onboarding not completed or KYC not approved", "details": { "onboardingStatus": "pending", "kycStatus": "pending" } } ``` `400` No EVM wallet ```json { "statusCode": 400, "error": "BadRequestError", "message": "A primary EVM wallet is required to initiate a bank deposit. Please create or import an EVM wallet in the app first." } ``` `403` Pay-in switched off ```json { "statusCode": 403, "error": "ForbiddenError", "message": "The bank ramp's pay-in is not switched on for this tenant in sandbox.", "details": { "code": "NOAH_FUNCTION_OFF", "function": "payin", "environment": "sandbox" } } ``` `400` Not an Aurea wallet ```json { "statusCode": 400, "error": "BadRequestError", "message": "A pay-in is delivered to one of your Aurea wallets, and this address is not one of them.", "details": { "code": "NOAH_DESTINATION_NOT_ALLOWED", "environment": "sandbox" } } ``` `400` Not a proven address ```json { "statusCode": 400, "error": "BadRequestError", "message": "A pay-in is delivered to an address you proved you own, and this address is not one of them: prove it first with POST /v1/ramp/bank/addresses/challenge.", "details": { "code": "NOAH_DESTINATION_NOT_ALLOWED", "environment": "production" } } ``` `404` No customer ```json { "statusCode": 404, "error": "NotFoundError", "message": "Customer not found. Please onboard first." } ``` `502` the bank ramp's answer incomplete ```json { "statusCode": 502, "error": "NoahApiError", "message": "The bank ramp answered with an unexpected response (HTTP 200)", "details": { "code": "NOAH_UNEXPECTED_RESPONSE", "noahStatus": 200 } } ``` Other `400` messages: `Invalid destination address format` when the address doesn't match the network, and `No Solana Devnet wallet found…` or `No Solana mainnet wallet found…` when `destinationAddress` is missing on a Solana network and the user has no Solana wallet of that environment. ## Implementation ```javascript // Production: EUR bank transfers -> EURC on Solana, sent to the user's Solana wallet // idempotencyKey: made once for this request (e.g. crypto.randomUUID()) and sent again on every retry async function getVirtualIban(token, solanaAddress, idempotencyKey) { const res = await fetch('https://api.aureahub.com/v1/ramp/bank/payin/initiate', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, 'Idempotency-Key': idempotencyKey }, body: JSON.stringify({ cryptoCurrency: 'EURC', network: 'Solana', // a production network -> the production environment destinationAddress: solanaAddress // always set it outside the sandbox }) }); const data = await res.json(); if (!res.ok) throw new Error(`${res.status}: ${data.message}`); return { iban: data.iban, bic: data.bic, accountHolder: data.accountHolderName, bankName: data.bankName, refreshAfter: data.expiresAt }; } ``` Web version: https://docs.aureahub.com/#payin-initiate --- # Get Payment Methods List the virtual IBANs assigned to the user, with the crypto route each one converts deposits into. ## Overview Every call to [Initiate Deposit](https://docs.aureahub.com/docs/payin-initiate.md) gives the user a virtual IBAN bound to a route — crypto currency, network and destination address. Bank transfers to that IBAN are converted by the bank ramp and sent to the destination. This endpoint returns the payment methods Aurea has stored for the user; it doesn't call the bank ramp. `status` is `active`, `inactive` or `expired`. Aurea treats each assignment as valid for about a day: when `isExpired` is `true` (see `expiresAt`), call Initiate Deposit again with the same route before showing the IBAN. When the bank ramp returns the same payment method, Aurea updates the existing record. `expiresAt` is **Aurea's own refresh policy, not an expiry the bank ramp states**: the bank ramp's answer for a virtual account carries no expiry at all. Read it as «when Aurea will read this account from the bank ramp again». **`destinationRevoked`** is `true` when the destination of this account is an address the user *proved and then revoked* ([Revoke an Address](https://docs.aureahub.com/docs/bank-address-revoke.md)). A new deposit to a revoked address is already refused, but **the IBAN the bank ramp issued stays open** — only the bank ramp can close a virtual account — so a transfer to it would still deliver to an address its owner has disowned. **Stop showing that IBAN** and ask the user for an address they still own. The Aurea operator is told as well, and can ask the bank ramp to close it. It goes back to `false` if the user proves that same address again. Each payment method also carries what the bank ramp answered when it created the virtual account: `virtualAccountId`, the whole `bankAddress`, `reference`, `fee` and the account's other rails in `relatedPaymentMethods`, as in [Initiate Deposit](https://docs.aureahub.com/docs/payin-initiate.md). A payment method stored before Aurea kept them has `null` (and no rails) there, until Initiate Deposit is called again for its route. IBANs belong to the user's profile in one environment: a test network route (such as `SolanaDevnet`) to the sandbox profile, a mainnet route to the production one. Pass `isTestnet=true` to list the sandbox IBANs; without it you get the production ones, and `404` when the user has no profile in that environment. ## Endpoint ### `GET /v1/ramp/bank/payin/payment-methods` Authentication: bearer token required. Returns the virtual IBAN payment methods of the authenticated user's profile in one environment. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `isTestnet` | boolean | no | `true` for the sandbox profile; `false` or omitted for production. Any other value returns `400`. | **Responses** `200` OK ```json { "paymentMethods": [ { "id": "7a1d3c5e-9b2f-4e6a-8c0d-1f3b5d7e9a2c", "noahPaymentMethodId": "", "paymentMethodType": "", "accountHolderName": "", "iban": "", "bic": "", "bankName": "", "bankAddress": { "street": "", "street2": null, "city": "", "postalCode": "", "state": "", "country": "" }, "virtualAccountId": "", "reference": null, "fee": { "currency": "EUR", "pct": "0", "base": "0", "min": "0" }, "relatedPaymentMethods": [], "fiatCurrency": "EUR", "cryptoCurrency": "EURC_TEST", "network": "SolanaDevnet", "destinationAddress": "", "destinationRevoked": false, "status": "active", "isExpired": false, "lastRefreshedAt": "2026-09-11T09:30:00.000Z", "expiresAt": "2026-09-12T08:30:00.000Z", "createdAt": "2026-09-11T09:30:00.000Z" } ], "total": 1 } ``` `404` Not onboarded ```json { "statusCode": 404, "error": "NotFoundError", "message": "Customer not found" } ``` ## Implementation ```javascript // Show the user's bank details for each active route, in one environment async function getVirtualIbans(token, { sandbox = false } = {}) { const res = await fetch(`https://api.aureahub.com/v1/ramp/bank/payin/payment-methods?isTestnet=${sandbox}`, { headers: { Authorization: `Bearer ${token}` } }); if (res.status === 404) return []; // user not onboarded yet if (!res.ok) throw new Error(`Payment methods failed: ${res.status}`); const { paymentMethods } = await res.json(); return paymentMethods .filter(pm => pm.status === 'active') .map(pm => ({ iban: pm.iban, bic: pm.bic, holder: pm.accountHolderName, bank: pm.bankName, route: `${pm.fiatCurrency} → ${pm.cryptoCurrency} on ${pm.network}` })); } ``` Web version: https://docs.aureahub.com/#payin-methods --- # Get Customer Deposits List the EUR bank transfers the user has sent to their virtual IBANs. ## Overview A deposit is recorded when the bank ramp's `FiatDeposit` webhook reports a transfer to one of the user's [virtual IBANs](https://docs.aureahub.com/docs/payin-initiate.md), so a transfer appears here only after the bank ramp has seen it. The endpoint reads Aurea's records and doesn't call the bank ramp. `status` is Aurea's word: `pending`, `completed` or `failed`. A deposit becomes `completed` when the bank ramp reports it as settled, and the user then gets a push notification. `noahStatus` is the bank ramp's own (`Pending`, `Settled`, `Failed`), which only moves forward. - **Under review.** A deposit the bank ramp holds stays `pending` with `noahSubStatus` (`AmlScreening`, `UnderReview`, `Submitted`, `Confirming`) and, when the bank ramp needs documents, `requestForInformation` (`status` `AwaitingCustomer`, `UnderReview`, `Closed` or `Completed`; `type` `Manual` or `ProofOfAddress`). The bank ramp asks the customer by email; there is no API to answer. A value the bank ramp doesn't document is `null`. - **Refunded.** A rejected deposit is `failed` and `refunds` lists the bank ramp's refunds to the account that sent the money, each with `amount` (exact decimal text), `currency`, `status` (`Pending`, `Successful` or `Failed`) and `requestedAt`. - `paymentMethodId` is the virtual IBAN's `id` in [Payment Methods](https://docs.aureahub.com/docs/payin-methods.md); `paymentMethodType` how the money came (`BankSepa`, …); `paymentSystemId` the transfer's id in the payment system (IMAD, UETR, trace number). - The `status` filter accepts `under_review` and `refunded` but no deposit ever has them. `senderName` and `eddReason` are never filled (the bank ramp's sender is personal data Aurea doesn't keep) and come as `""`; `eddRequired` is always `false`. Results are paginated with `limit` and `offset`, and `fiatAmount` is a JSON number. Deposits belong to the user's profile in one environment. Pass `isTestnet=true` for the sandbox deposits; without it you get the production ones, and `404` when the user has no profile in that environment. ## Endpoint ### `GET /v1/ramp/bank/payin/deposits` Authentication: bearer token required. Returns the fiat deposits of the authenticated user's profile in one environment, newest first, with the bank ramp's status, review, request for information and refunds, and an optional status filter. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `status` | string | no | pending \| completed \| failed (under_review and refunded are accepted and never match) | | `limit` | integer | no | Page size (default 20) | | `offset` | integer | no | Number of deposits to skip (default 0) | | `isTestnet` | boolean | no | `true` for the sandbox profile; `false` or omitted for production. Any other value returns `400`. | **Responses** `200` OK ```json { "deposits": [ { "id": "b1e4c7a2-3d5f-4a8b-9c6e-0f2d4b6a8c13", "noahDepositId": "96369c50-7fd3-4222-a76d-1c054e6ea9de", "fiatAmount": 10.6, "fiatCurrency": "EUR", "senderName": "", "status": "pending", "eddRequired": false, "eddReason": "", "depositDate": "2026-09-17T08:12:00.000Z", "createdAt": "2026-09-17T08:12:30.000Z", "paymentMethodId": "3f5b7d9a-1c3e-4a5b-8d7f-9b1d3f5a7c9e", "noahStatus": "Pending", "noahSubStatus": "UnderReview", "requestForInformation": { "status": "AwaitingCustomer", "type": "Manual" }, "refunds": [], "paymentMethodType": "BankSepa", "paymentSystemId": "A10050DE67M10F1R23BL00D7KP", "updatedAt": "2026-09-17T08:12:30.000Z" } ], "total": 1, "limit": 20, "offset": 0 } ``` `404` Not onboarded ```json { "statusCode": 404, "error": "NotFoundError", "message": "Customer not found" } ``` ## Implementation ```javascript async function listDeposits(token, { status, limit = 20, offset = 0, sandbox = false } = {}) { const params = new URLSearchParams({ limit: String(limit), offset: String(offset), isTestnet: String(sandbox) }); if (status) params.set('status', status); const res = await fetch(`https://api.aureahub.com/v1/ramp/bank/payin/deposits?${params}`, { headers: { Authorization: `Bearer ${token}` } }); if (res.status === 404) return { deposits: [], total: 0, limit, offset }; // not onboarded yet if (!res.ok) throw new Error(`Deposits failed: ${res.status}`); return res.json(); // { deposits, total, limit, offset } } // e.g. when the app returns to the foreground const { deposits } = await listDeposits(token); const inFlight = deposits.filter(d => d.status === 'pending'); const needsCustomer = deposits.filter(d => d.requestForInformation?.status === 'AwaitingCustomer'); // the bank ramp emailed the customer const refunded = deposits.filter(d => d.refunds.length > 0); ``` Web version: https://docs.aureahub.com/#payin-deposits --- # Bank Ramp: List Customer Transactions Retrieve the bank ramp transactions — conversions, on-chain deliveries, payouts and reversals — Aurea has recorded for the user, with the deposit that paid for them. ## Overview Transactions are recorded from the bank ramp's `Transaction` webhooks, so they appear here only after the bank ramp has reported them. The endpoint reads Aurea's records and doesn't call the bank ramp. Pagination uses `limit` and `offset`. - **A bank transfer is two transactions** of one `ruleExecutionId`: the conversion (`direction` `In`, `network` `OffNetwork`, with `fiatAmount`, `fiatFeeAmount` and `fiatRate`) and the on-chain delivery (`direction` `Out` on the chain, with `blockchainTxHash` once broadcast). Both carry `depositId`, the `id` [Get Deposits](https://docs.aureahub.com/docs/payin-deposits.md) returns: The bank ramp names the deposit only on the conversion (`noahDepositId`), and Aurea gives the delivery the deposit of its rule execution. - `transactionType` is `purchase` for `In` and `withdrawal` for `Out`, the on-chain delivery included. `status` is Aurea's word (`pending`, `completed`, `failed`), `noahStatus` the bank ramp's; `noahSubStatus` and `requestForInformation` as on a deposit. - `refunds` (`status` `Pending`, `Settled` or `Failed`), `reversesTransactionId` (the bank ramp's id of the transaction given back) and `adjustment` (`ExchangeRateCorrection` or `Refund`) say what a transaction gives back or corrects. - `breakdown` is how the bank ramp computed the amount: `ChannelFee`, `BusinessFee`, `NetworkFee` and `Remaining` lines in the transaction's asset. For a purchase `cryptoAmount = (fiatAmount - fiatFeeAmount) / fiatRate`. - `fiatAmount` and `cryptoAmount` are JSON numbers; `networkFee`, `fiatFeeAmount`, `fiatRate` and the amounts in `breakdown` and `refunds` are exact decimal text. `exchangeRate` is not filled from the bank ramp's events: use `fiatRate`. An empty text field comes as `""`. - The filters accept `type=refund` and `status=cancelled` but no transaction ever has them. Transactions belong to the user's profile in one environment. Pass `isTestnet=true` for the sandbox transactions; without it you get the production ones, and `404` when the user has no profile in that environment. ## Endpoint ### `GET /v1/ramp/bank/payin/transactions` Authentication: bearer token required. Returns the bank ramp transactions of the authenticated user's profile in one environment, with optional filters. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `type` | string | no | purchase \| withdrawal (refund is accepted and never matches) | | `status` | string | no | pending \| completed \| failed (cancelled is accepted and never matches) | | `limit` | integer | no | Page size (default 20) | | `offset` | integer | no | Number of transactions to skip (default 0) | | `isTestnet` | boolean | no | `true` for the sandbox profile; `false` or omitted for production. Any other value returns `400`. | **Responses** `200` OK ```json { "transactions": [ { "id": "0d2f4b6a-8c1e-4a3b-9d5f-7e9a1c3b5d70", "noahTransactionId": "fee2b2a6-0da2-5473-a6a8-eac39cb279d9", "transactionType": "withdrawal", "fiatCurrency": "", "cryptoAmount": 107.683104, "cryptoCurrency": "USDC", "network": "Ethereum", "destinationAddress": "", "blockchainTxHash": "0x6b1f3c9e2a4d8b7c5e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d", "status": "completed", "transactionDate": "2026-09-17T08:14:04.000Z", "completedAt": "2026-09-17T08:32:10.000Z", "createdAt": "2026-09-17T08:14:05.000Z", "depositId": "b1e4c7a2-3d5f-4a8b-9c6e-0f2d4b6a8c13", "noahDepositId": null, "direction": "Out", "noahStatus": "Settled", "noahSubStatus": null, "requestForInformation": null, "refunds": [], "ruleId": "a23ed0ca-a205-5325-89b4-d2ac46e0b26b", "ruleExecutionId": "36c54907-fadd-5a48-91f5-1632253f9a08", "reversesTransactionId": null, "adjustment": null, "breakdown": [ { "type": "NetworkFee", "amount": "4.296693", "fixedAmount": null, "variableAmount": null }, { "type": "Remaining", "amount": "107.683104", "fixedAmount": null, "variableAmount": null } ], "networkFee": null, "fiatFeeAmount": null, "fiatRate": null, "paymentSystemId": null, "updatedAt": "2026-09-17T08:32:10.000Z" }, { "id": "4a6c8e0b-2d4f-4b6a-8c0e-2f4a6c8e0b1d", "noahTransactionId": "4068d70e-c31d-5e3b-959a-f27ea3cc5e1e", "transactionType": "purchase", "fiatAmount": 100, "fiatCurrency": "EUR", "cryptoAmount": 111.979797, "cryptoCurrency": "USDC", "network": "OffNetwork", "destinationAddress": "", "blockchainTxHash": "", "status": "completed", "transactionDate": "2026-09-17T08:14:02.000Z", "completedAt": "2026-09-17T08:14:20.000Z", "createdAt": "2026-09-17T08:14:03.000Z", "depositId": "b1e4c7a2-3d5f-4a8b-9c6e-0f2d4b6a8c13", "noahDepositId": "96369c50-7fd3-4222-a76d-1c054e6ea9de", "direction": "In", "noahStatus": "Settled", "noahSubStatus": null, "requestForInformation": null, "refunds": [], "ruleId": "a23ed0ca-a205-5325-89b4-d2ac46e0b26b", "ruleExecutionId": "36c54907-fadd-5a48-91f5-1632253f9a08", "reversesTransactionId": null, "adjustment": null, "breakdown": [ { "type": "ChannelFee", "amount": "1.13111", "fixedAmount": null, "variableAmount": null }, { "type": "Remaining", "amount": "111.979797", "fixedAmount": null, "variableAmount": null } ], "networkFee": null, "fiatFeeAmount": "1", "fiatRate": "0.8840880389680685", "paymentSystemId": "SEPA-20260917-998877", "updatedAt": "2026-09-17T08:14:20.000Z" } ], "total": 2, "limit": 20, "offset": 0 } ``` `404` Not onboarded ```json { "statusCode": 404, "error": "NotFoundError", "message": "Customer not found" } ``` Web version: https://docs.aureahub.com/#payin-transactions --- # Ask for an Address Challenge Get the message a user signs to prove they control an address outside Aurea — the first step of the standalone mode. ## Overview With the **standalone** mode your users bring their own wallet: a pay-in can be delivered to an address outside Aurea once the user has proved they control it ([Standalone Pay-In](https://docs.aureahub.com/docs/guide-standalone-payin.md)). The proof is a signature. This endpoint issues the message; the user signs it with the address's key; [Verify an Address](https://docs.aureahub.com/docs/bank-address-verify.md) checks the signature and keeps the address. - **The standalone mode must be switched on** for your tenant in that environment (`modes.standalone` in [Tenant Settings](https://docs.aureahub.com/docs/bank-settings.md)), otherwise `403` with `details.code` `NOAH_MODE_OFF`. - `isTestnet: true` proves the address for the sandbox; `false` or omitted, for production. A proof counts only in its own environment. - `family` is `evm` or `solana`. One EVM proof covers the address on every EVM network. - The response carries the address in the one form Aurea stores and compares: an EVM address with its checksum, a Solana address in base58. A mixed-case EVM address must have a valid checksum (all lowercase is fine). An address that is not one of its kind, or the EVM zero address, answers `400` `NOAH_ADDRESS_INVALID`. - `message` names the address, its kind, the environment, your tenant, the user, a random nonce and the expiry. **Sign it exactly as returned**, line breaks included: - **EVM**: an EIP-191 `personal_sign` from the account that owns the address — for example `signer.signMessage(message)` in ethers. A smart contract account cannot prove an address this way. - **Solana**: an ed25519 signature of the message's UTF-8 bytes, written in base58 — for example a wallet adapter's `signMessage`. Signing moves no funds and costs no fee. - A challenge can be verified for 10 minutes (`expiresAt`). A user holds at most 5 open challenges — ones neither used, burnt nor expired — across addresses and environments; beyond that `409` `NOAH_ADDRESS_CHALLENGE_LIMIT`. - Every call issues a new challenge, also for an address the user already proved. - Answers `403` to a token without a tenant, and when your tenant does not use the bank ramp. ## Endpoint ### `POST /v1/ramp/bank/addresses/challenge` Authentication: bearer token required. Issues the message that proves one address of the user in one environment. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `family` | string | yes | `evm` or `solana` | | `address` | string | yes | The address to prove, up to 100 characters | | `isTestnet` | boolean | no | `true` for the sandbox; `false` or omitted for production | **Responses** `201` Created ```json { "challengeId": "5b1d3f7e-2c4a-4e6b-9d8f-0a1b2c3d4e5f", "family": "evm", "address": "0xC0207704CaEB9342cf491Ad4a177F43592df65a7", "environment": "sandbox", "message": "Aurea asks you to prove that you control this address, to use it with the bank ramp.\nSigning this message moves no funds and costs nothing.\n\nAddress: 0xC0207704CaEB9342cf491Ad4a177F43592df65a7\nAddress type: EVM\nEnvironment: sandbox\nTenant: 3f0e1c2a-8b7d-4c6e-9a5f-1d2c3b4a5e6f\nUser: 9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d\nNonce: 8f3c2a9d4e1b7f6a0c5d2e8b3a9f1c7d4e6b0a2f8c3d5e9a1b7c4f0d6e2a8b3c\nIssued at: 2026-09-16T10:00:00.000Z\nExpires at: 2026-09-16T10:10:00.000Z", "issuedAt": "2026-09-16T10:00:00.000Z", "expiresAt": "2026-09-16T10:10:00.000Z" } ``` `403` Standalone off ```json { "statusCode": 403, "error": "ForbiddenError", "message": "The bank ramp's standalone mode is not switched on for this tenant in sandbox.", "details": { "code": "NOAH_MODE_OFF", "mode": "standalone", "environment": "sandbox" } } ``` `400` Not an address ```json { "statusCode": 400, "error": "BadRequestError", "message": "0xc0207704caeb9342cf491ad4a177f43592df65A7 is not a valid EVM address: its checksum is wrong", "details": { "code": "NOAH_ADDRESS_INVALID" } } ``` `409` Too many open ```json { "statusCode": 409, "error": "ConflictError", "message": "A user has at most 5 open challenges: sign one or let it expire first", "details": { "code": "NOAH_ADDRESS_CHALLENGE_LIMIT", "limit": 5 } } ``` ## Implementation ```javascript // EVM, in the browser with ethers v6: prove the connected account for the sandbox import { BrowserProvider } from 'ethers'; async function signAddressChallenge(token) { const signer = await new BrowserProvider(window.ethereum).getSigner(); const res = await fetch('https://api.aureahub.com/v1/ramp/bank/addresses/challenge', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ family: 'evm', address: await signer.getAddress(), isTestnet: true }) }); const challenge = await res.json(); if (!res.ok) throw new Error(`${res.status}: ${challenge.message}`); // EIP-191 personal_sign of the message exactly as returned const signature = await signer.signMessage(challenge.message); return { challengeId: challenge.challengeId, signature }; // send both to /verify } ``` Web version: https://docs.aureahub.com/#bank-address-challenge --- # Verify an Address Send the signed challenge: a valid signature proves the address, and pay-ins can be delivered to it. ## Overview The second step after [Ask for a Challenge](https://docs.aureahub.com/docs/bank-address-challenge.md). Aurea checks the signature against the challenge's message and address, then keeps the address for the user in the challenge's environment. - **The signature.** EVM: the 65-byte signature as `0x` and 130 hex characters, as `personal_sign` returns it. Solana: the 64-byte signature in base58. - `label` is optional: up to 100 characters, trimmed, for the user to recognise the address. - `201` with the proven address. `200` when the user already holds that address in that environment — proven with an earlier challenge, or this signed challenge sent again: the address is returned as it was first proven, label included. - **A wrong signature** answers `400` `NOAH_ADDRESS_SIGNATURE_INVALID`, with `details.reason` — `wrong_signer` when it reads but another key made it or it signs another text, `malformed` when it cannot be read — and `details.attemptsLeft`. The fifth wrong signature burns the challenge: `400` `NOAH_ADDRESS_CHALLENGE_BURNT`. - After `expiresAt` the challenge answers `400` `NOAH_ADDRESS_CHALLENGE_EXPIRED`, whatever the signature. A challenge already used or burnt answers `409` `NOAH_ADDRESS_CHALLENGE_USED`, unless its valid signature is sent again while the user still holds the address (`200`). In each of these cases, ask for a new challenge. - A challenge that is not the user's answers `404`, before your tenant's settings are read. - **The standalone mode must be switched on** for your tenant in the challenge's environment, otherwise `403` `NOAH_MODE_OFF`, and the challenge is left as it was. - Two users of your tenant may prove the same address: each of them holds its key. Aurea records every proof in your tenant's activity log, without the signature. - Answers `403` to a token without a tenant, and when your tenant does not use the bank ramp. ## Endpoint ### `POST /v1/ramp/bank/addresses/verify` Authentication: bearer token required. Checks the signature of one of the user's challenges and keeps the address it proves. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `challengeId` | string | yes | The `challengeId` of the challenge, a UUID | | `signature` | string | yes | The signature of the challenge's message, up to 200 characters: hex for EVM, base58 for Solana | | `label` | string | no | A name for the address, up to 100 characters | **Responses** `201` Proven ```json { "id": "7a1d3c5e-9b2f-4e6a-8c0d-1f3b5d7e9a2c", "family": "evm", "address": "0xC0207704CaEB9342cf491Ad4a177F43592df65a7", "label": "Hardware wallet", "proof": "eip191", "environment": "sandbox", "verifiedAt": "2026-09-16T10:02:41.000Z" } ``` `200` Already proven ```json { "id": "7a1d3c5e-9b2f-4e6a-8c0d-1f3b5d7e9a2c", "family": "evm", "address": "0xC0207704CaEB9342cf491Ad4a177F43592df65a7", "label": "Hardware wallet", "proof": "eip191", "environment": "sandbox", "verifiedAt": "2026-09-16T10:02:41.000Z" } ``` `400` Wrong signature ```json { "statusCode": 400, "error": "BadRequestError", "message": "The signature was not made with this address's key over the challenge's message.", "details": { "code": "NOAH_ADDRESS_SIGNATURE_INVALID", "reason": "wrong_signer", "attemptsLeft": 4 } } ``` `400` Burnt ```json { "statusCode": 400, "error": "BadRequestError", "message": "Too many wrong signatures: this challenge can no longer be used. Ask for a new one.", "details": { "code": "NOAH_ADDRESS_CHALLENGE_BURNT" } } ``` `400` Expired ```json { "statusCode": 400, "error": "BadRequestError", "message": "This challenge has expired. Ask for a new one.", "details": { "code": "NOAH_ADDRESS_CHALLENGE_EXPIRED" } } ``` `409` Used ```json { "statusCode": 409, "error": "ConflictError", "message": "This challenge was already used. Ask for a new one.", "details": { "code": "NOAH_ADDRESS_CHALLENGE_USED" } } ``` `403` Standalone off ```json { "statusCode": 403, "error": "ForbiddenError", "message": "The bank ramp's standalone mode is not switched on for this tenant in sandbox.", "details": { "code": "NOAH_MODE_OFF", "mode": "standalone", "environment": "sandbox" } } ``` `404` Not the user's ```json { "statusCode": 404, "error": "NotFoundError", "message": "Address challenge not found" } ``` A malformed signature answers the same `400` with `"reason": "malformed"` and the message `The signature cannot be read: an EVM address signs with a 65-byte hex signature, a Solana address with a 64-byte base58 one.` ## Implementation ```javascript async function verifyAddress(token, { challengeId, signature }, label) { const res = await fetch('https://api.aureahub.com/v1/ramp/bank/addresses/verify', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ challengeId, signature, label }) }); const data = await res.json(); if (res.ok) return data; // 201 proven, 200 already proven: data.id, data.address switch (data.details?.code) { case 'NOAH_ADDRESS_SIGNATURE_INVALID': throw new Error(`Signed with another key: ${data.details.attemptsLeft} attempts left`); case 'NOAH_ADDRESS_CHALLENGE_BURNT': case 'NOAH_ADDRESS_CHALLENGE_EXPIRED': case 'NOAH_ADDRESS_CHALLENGE_USED': throw new Error('Ask for a new challenge and sign it again'); default: throw new Error(`${res.status}: ${data.message}`); } } ``` Web version: https://docs.aureahub.com/#bank-address-verify --- # List Proven Addresses The addresses outside Aurea that the user proved in one environment and still holds. ## Overview - `isTestnet=true` lists the sandbox addresses; `false` or omitted, the production ones. The oldest proof comes first. - Revoked addresses are not listed ([Revoke an Address](https://docs.aureahub.com/docs/bank-address-revoke.md)). - Answers whatever your tenant's settings, so a user always sees what they proved, also after the standalone mode is switched off. Pay-ins are delivered to these addresses only while the mode is on ([Initiate Deposit](https://docs.aureahub.com/docs/payin-initiate.md)). - Answers `403` to a token without a tenant, and when your tenant does not use the bank ramp. It answers from Aurea's records. ## Endpoint ### `GET /v1/ramp/bank/addresses` Authentication: bearer token required. Returns the addresses the user proved in one environment and has not revoked. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `isTestnet` | boolean | no | `true` for the sandbox; `false` or omitted for production. Any other value returns `400`. | **Responses** `200` OK ```json { "environment": "sandbox", "addresses": [ { "id": "7a1d3c5e-9b2f-4e6a-8c0d-1f3b5d7e9a2c", "family": "evm", "address": "0xC0207704CaEB9342cf491Ad4a177F43592df65a7", "label": "Hardware wallet", "proof": "eip191", "environment": "sandbox", "verifiedAt": "2026-09-16T10:02:41.000Z" }, { "id": "2e4a6c8d-0b1f-4d3a-9e5c-7b9d1f3a5c7e", "family": "solana", "address": "91hjEa6yT8ZANwdM3phDi1CSxEouHpveu5WDwyhmAc7b", "label": null, "proof": "solana_ed25519", "environment": "sandbox", "verifiedAt": "2026-09-16T11:15:03.000Z" } ] } ``` `403` No tenant ```json { "statusCode": 403, "error": "ForbiddenError", "message": "This request needs a tenant: an admin token without one holds no addresses." } ``` ## Implementation ```javascript // The proven addresses a pay-in on this network may be delivered to async function provenAddressesFor(token, pair, isTestnet) { const res = await fetch(`https://api.aureahub.com/v1/ramp/bank/addresses?isTestnet=${isTestnet}`, { headers: { Authorization: `Bearer ${token}` } }); const data = await res.json(); if (!res.ok) throw new Error(data.message); // pair comes from Tenant Settings: addressFormat is evm or solana return data.addresses.filter((address) => address.family === pair.addressFormat); } ``` Web version: https://docs.aureahub.com/#bank-addresses-list --- # Revoke an Address Stop using an address the user proved: no new virtual IBAN can deliver to it. ## Overview - `204` with no body. `404` when the user holds no such address — revoked already, another user's, or never proven. - **`409` `NOAH_ADDRESS_IN_USE` while one of the user's payouts is still waiting for a deposit from that address** ([Pay from a Wallet](https://docs.aureahub.com/docs/payout-wallet-pay.md)). The answer names the payout waiting (`details.payoutId`) and when its rule stops holding the address (`details.expiresAt`), and the address is kept. Send that deposit, or wait until then. - After it, [Initiate Deposit](https://docs.aureahub.com/docs/payin-initiate.md) refuses the address with `400` `NOAH_DESTINATION_NOT_ALLOWED`. - **A virtual IBAN created earlier for that address keeps delivering there**: revoking changes nothing at the bank ramp. Stop showing that IBAN to the user. - Answers whatever your tenant's settings: a user can always stop using what they proved. The address can be proven again with a new challenge. - Aurea records every revocation in your tenant's activity log. - Answers `403` to a token without a tenant, and when your tenant does not use the bank ramp. ## Endpoint ### `DELETE /v1/ramp/bank/addresses/{id}` Authentication: bearer token required. Revokes one of the user's proven addresses. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | The address `id`, a UUID | **Responses** `204` No Content `404` Not Found ```json { "statusCode": 404, "error": "NotFoundError", "message": "This user holds no such address" } ``` `409` A payout is waiting for it ```json { "statusCode": 409, "error": "ConflictError", "message": "A payout is still waiting for a deposit from this address: send it, or wait until the payout's time runs out, and then stop using the address.", "details": { "code": "NOAH_ADDRESS_IN_USE", "payoutId": "6b1f0d24-9c3a-4f18-b7e5-2a8c4d6e0f13", "expiresAt": "2026-09-17T13:00:00.000Z" } } ``` ## Implementation ```javascript async function revokeAddress(token, addressId) { const res = await fetch(`https://api.aureahub.com/v1/ramp/bank/addresses/${addressId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } }); if (res.status === 204) return true; const data = await res.json(); throw new Error(`${res.status}: ${data.message}`); } ``` Web version: https://docs.aureahub.com/#bank-address-revoke --- # Payout Countries The countries where the bank ramp can pay out to the user, each with the fiat currencies it pays in. ## Overview The first step of a payout to a bank account: let the user pick the country and currency of the beneficiary, then [search the channels](https://docs.aureahub.com/docs/payout-channels.md) for that pair. Aurea asks the bank ramp for the user's own bank ramp customer, so the list is what that user can use. - Countries are ISO 3166-1 alpha-2 codes, sorted; each currency is listed once. `XX` names channels usable internationally. - **Payouts must be switched on** for your tenant in that environment ([Tenant Settings](https://docs.aureahub.com/docs/bank-settings.md)): otherwise `403` `NOAH_FUNCTION_OFF`. A tenant that does not use the bank ramp, or a token without a tenant, gets `403`. - The user needs a bank ramp profile in that environment ([Onboarding Session](https://docs.aureahub.com/docs/payin-session.md)): otherwise `404` with `details.code` `NOAH_CUSTOMER_NOT_FOUND`. KYC approval is not needed to read; the bank ramp's own refusals pass through with their status and `details`, for example `403` `NOAH_FORBIDDEN` with `denyReasons`. **A user who has not started their onboarding is a different case**: Aurea lets the read through and the bank ramp answers `404`, which arrives as `404` `NOAH_RESOURCE_NOT_FOUND`. The reason is worth knowing — **The bank ramp does not create the customer when the session is created, but when the user actually begins the hosted flow**, so until then the bank ramp has nobody by that id (measured against the bank ramp's sandbox on 21 September 2026: [Sync KYC Status](https://docs.aureahub.com/docs/payin-sync-status.md) answers `synced: false` and says the customer is not in the bank ramp yet). These reads become useful once the user has started onboarding. - `isTestnet=true` reads the bank ramp's sandbox with the user's sandbox profile; `false` or omitted reads production. Nothing is stored and nothing moves. ## Endpoint ### `GET /v1/ramp/bank/payout/countries` Authentication: bearer token required. Returns the countries the bank ramp pays out in for the user's bank ramp customer, with their fiat currencies. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `isTestnet` | boolean | no | `true` for the bank ramp's sandbox; `false` or omitted for production | **Responses** `200` OK ```json { "environment": "sandbox", "countries": [ { "country": "DE", "fiatCurrencies": ["EUR"] }, { "country": "US", "fiatCurrencies": ["USD"] }, { "country": "XX", "fiatCurrencies": ["USD"] } ] } ``` `404` No bank ramp profile ```json { "statusCode": 404, "error": "NotFoundError", "message": "No bank ramp customer profile in sandbox for this user: onboard with the bank ramp first.", "details": { "code": "NOAH_CUSTOMER_NOT_FOUND", "environment": "sandbox" } } ``` Web version: https://docs.aureahub.com/#payout-countries --- # Search Payout Channels The ways the bank ramp can pay out a cryptocurrency as fiat to a country and currency: fees, limits, processing time, the details the beneficiary needs and the user's saved accounts. ## Overview A channel is a country, a fiat currency and a payment method type (for example `BankSepa`), with its own fees, limits and form. Search right before showing channels to the user: The bank ramp changes channel ids and limits, so don't store them. - `cryptoCurrency` must be offered for payout by your tenant in that environment ([Currencies & Networks](https://docs.aureahub.com/docs/bank-currencies.md)): otherwise `400` `NOAH_PAIR_UNAVAILABLE`, naming what it offers. In the sandbox an asset code such as `EURC` is read as `EURC_TEST`. - Send `fiatAmount` to get each channel's `totalFee` for that amount. - Send `paymentMethodId`, from [Saved Beneficiaries](https://docs.aureahub.com/docs/payout-beneficiaries.md), and each channel's form asks only for what the bank ramp doesn't have yet. The search is a `POST` for this reason: the id contains the account number, and a URL is written to server logs. - Channels come one page at a time: send `nextPageToken` back as `pageToken` until it is `null`. Unknown body fields are ignored. - **Aurea does not pay out to cards.** A channel the bank ramp offers whose payment method is a card is left out of `channels`, and `cardChannelsHidden` counts how many — so a short list is never a mystery. - **Payouts must be switched on** for your tenant in that environment ([Tenant Settings](https://docs.aureahub.com/docs/bank-settings.md)): otherwise `403` `NOAH_FUNCTION_OFF`. A tenant that does not use the bank ramp, or a token without a tenant, gets `403`. - The user needs a bank ramp profile in that environment ([Onboarding Session](https://docs.aureahub.com/docs/payin-session.md)): otherwise `404` with `details.code` `NOAH_CUSTOMER_NOT_FOUND`. KYC approval is not needed to read; the bank ramp's own refusals pass through with their status and `details`, for example `403` `NOAH_FORBIDDEN` with `denyReasons`. **A user who has not started their onboarding is a different case**: Aurea lets the read through and the bank ramp answers `404`, which arrives as `404` `NOAH_RESOURCE_NOT_FOUND`. The reason is worth knowing — **The bank ramp does not create the customer when the session is created, but when the user actually begins the hosted flow**, so until then the bank ramp has nobody by that id (measured against the bank ramp's sandbox on 21 September 2026: [Sync KYC Status](https://docs.aureahub.com/docs/payin-sync-status.md) answers `synced: false` and says the customer is not in the bank ramp yet). These reads become useful once the user has started onboarding. - `isTestnet: true` reads the bank ramp's sandbox with the user's sandbox profile; `false` or omitted reads production. Nothing is stored and nothing moves. ## Endpoint ### `POST /v1/ramp/bank/payout/channels/search` Authentication: bearer token required. Returns one page of the channels a payout of cryptoCurrency can go through, for the user's bank ramp customer. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `cryptoCurrency` | string | yes | The currency the user pays out, e.g. EURC_TEST | | `country` | string | no | ISO 3166-1 alpha-2 country of the beneficiary, e.g. DE | | `fiatCurrency` | string | no | ISO 4217 currency the beneficiary receives, e.g. EUR | | `fiatAmount` | string | no | Decimal text, e.g. 100.50; with it each channel has a totalFee | | `paymentMethodId` | string | no | A saved payment method (1 to 150 characters): forms then ask only for what the bank ramp doesn't have | | `pageSize` | integer | no | 1 to 100; the bank ramp's default is 20 | | `pageToken` | string | no | nextPageToken of the previous page | | `isTestnet` | boolean | no | `true` for the bank ramp's sandbox; `false` or omitted for production | **Responses** `200` OK ```json { "environment": "sandbox", "cryptoCurrency": "EURC_TEST", "channels": [ { "channelId": "ebb9736b-08b3-599d-886b-10ee8aea82b5", "country": "DE", "fiatCurrency": "EUR", "paymentMethodCategory": "Bank", "paymentMethodType": "BankSepa", "rate": "0.98", "fee": { "fixed": "0.5", "percentage": "0.25", "fiatCurrency": "EUR" }, "totalFee": "0.75", "limits": { "min": "1", "max": "10000" }, "cryptoLimits": { "min": "1.53", "max": "10230" }, "processingSeconds": 86400, "processingTier": null, "issuer": null, "formSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "BankDetails": { "type": "object", "title": "Bank Details", "properties": { "AccountNumber": { "type": "string", "title": "IBAN", "minLength": 22, "maxLength": 22 } }, "required": ["AccountNumber"] } }, "required": ["BankDetails"] }, "formContentHash": "3f2a9c", "savedPaymentMethods": [] } ], "nextPageToken": null } ``` `400` Currency not offered ```json { "statusCode": 400, "error": "BadRequestError", "message": "USDT_TEST payouts are not available with the bank ramp in sandbox. Available: EURC_TEST, PYUSD_TEST, USDC_TEST, USDG_TEST.", "details": { "code": "NOAH_PAIR_UNAVAILABLE", "environment": "sandbox", "cryptoCurrency": "USDT_TEST", "available": [{ "cryptoCurrency": "EURC_TEST" }, { "cryptoCurrency": "PYUSD_TEST" }, { "cryptoCurrency": "USDC_TEST" }, { "cryptoCurrency": "USDG_TEST" }] } } ``` ## Channels - `rate` is fiat per unit of the cryptocurrency, without fees: it is not a quote. `fee.percentage` is a percent value (`"0.25"` is 0.25%); `fee` is `null` when the bank ramp sends none. - `limits` are in `fiatCurrency`; `cryptoLimits` are the same limits in the cryptocurrency, fees included, or `null`. `max` is `null` when the bank ramp sets no maximum. Every amount is exact decimal text. - `formSchema` is the JSON Schema of the beneficiary details the channel needs, as the bank ramp sent it (render it with a JSON Schema form library); `null` when nothing is needed. [Channel Form](https://docs.aureahub.com/docs/payout-channel-form.md) returns the same schema for one channel. - `savedPaymentMethods` are the user's recent payment methods on this channel, in the shape described in [Saved Beneficiaries](https://docs.aureahub.com/docs/payout-beneficiaries.md). - **Card channels are not listed**: The bank ramp pays a card only through its own hosted page. A channel the bank ramp sends without an id, type, country, currency, rate, minimum or processing time is left out too. ## Implementation ```javascript async function searchChannels(token, { country, fiatCurrency, fiatAmount }) { const channels = []; let pageToken; do { const res = await fetch('https://api.aureahub.com/v1/ramp/bank/payout/channels/search', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ cryptoCurrency: 'EURC_TEST', country, fiatCurrency, fiatAmount, pageToken, isTestnet: true }) }); const page = await res.json(); if (!res.ok) throw new Error(page.message); channels.push(...page.channels); pageToken = page.nextPageToken ?? undefined; } while (pageToken); // Offer only the channels whose fiat limits hold the amount return channels.filter((c) => Number(fiatAmount) >= Number(c.limits.min) && (c.limits.max === null || Number(fiatAmount) <= Number(c.limits.max))); } ``` Web version: https://docs.aureahub.com/#payout-channels --- # Payout Channel Form The beneficiary details a channel needs, as a JSON Schema to render or fill in. ## Overview Use it when you already have a channel and need its form again, or only the part a saved payment method still misses. - `channelId` is a channel id from [Search Channels](https://docs.aureahub.com/docs/payout-channels.md), a UUID: anything else answers `400`. - With `paymentMethodId` the schema asks only for what the bank ramp doesn't have yet; `formSchema` is `null` when the bank ramp needs nothing more. It's a `POST` because the id contains the account number. Send a body, even an empty `{}` for production. - **Payouts must be switched on** for your tenant in that environment ([Tenant Settings](https://docs.aureahub.com/docs/bank-settings.md)): otherwise `403` `NOAH_FUNCTION_OFF`. A tenant that does not use the bank ramp, or a token without a tenant, gets `403`. - The user needs a bank ramp profile in that environment ([Onboarding Session](https://docs.aureahub.com/docs/payin-session.md)): otherwise `404` with `details.code` `NOAH_CUSTOMER_NOT_FOUND`. KYC approval is not needed to read; the bank ramp's own refusals pass through with their status and `details`. **A user who has not started their onboarding is a different case**: Aurea lets the read through and the bank ramp answers `404`, which arrives as `404` `NOAH_RESOURCE_NOT_FOUND`. The reason is worth knowing — **The bank ramp does not create the customer when the session is created, but when the user actually begins the hosted flow**, so until then the bank ramp has nobody by that id (measured against the bank ramp's sandbox on 21 September 2026: [Sync KYC Status](https://docs.aureahub.com/docs/payin-sync-status.md) answers `synced: false` and says the customer is not in the bank ramp yet). These reads become useful once the user has started onboarding. - `isTestnet: true` reads the bank ramp's sandbox with the user's sandbox profile; `false` or omitted reads production. Nothing is stored and nothing moves. ## Endpoint ### `POST /v1/ramp/bank/payout/channels/{channelId}/form` Authentication: bearer token required. Returns the JSON Schema of the details a payout channel needs. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `channelId` | string | yes | the bank ramp's channel id (UUID) | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `paymentMethodId` | string | no | A saved payment method (1 to 150 characters) | | `isTestnet` | boolean | no | `true` for the bank ramp's sandbox; `false` or omitted for production | **Responses** `200` OK ```json { "environment": "sandbox", "channelId": "ebb9736b-08b3-599d-886b-10ee8aea82b5", "formSchema": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "PaymentPurpose": { "type": "string", "title": "Payment Purpose" } }, "required": ["PaymentPurpose"] }, "formContentHash": "a1b2c3" } ``` `200` Nothing more needed ```json { "environment": "sandbox", "channelId": "ebb9736b-08b3-599d-886b-10ee8aea82b5", "formSchema": null, "formContentHash": null } ``` Web version: https://docs.aureahub.com/#payout-channel-form --- # Saved Payout Beneficiaries The bank accounts and other payment methods the user can be paid out to, read live from the bank ramp. ## Overview The bank ramp keeps the payment methods a user was paid out to. Show them so the user can pay out to the same account again without typing it, and pass `paymentMethodId` to [Search Channels](https://docs.aureahub.com/docs/payout-channels.md) or [Channel Form](https://docs.aureahub.com/docs/payout-channel-form.md). - `paymentMethodId` is the bank ramp's id of the payment method. It contains the account number: send it only in a request body — the payout endpoints never take it in a URL, which servers write to their logs. - `details.type` says which fields apply: `bank` (`accountNumber`, `bankCode`, `routingNumber`, `swiftCode`, `bankName`, `bankingSystems`, `bankAddress`), `card` (`last4`, `scheme`), `identifier` (`identifierType` and `identifier`, for example a tax id), or `unknown` for a display type the bank ramp doesn't document, shown without fields. - `accountHolder` is the name on the account; `capabilities.payoutTo` tells whether a payout can go to it. - One page at a time: send `nextPageToken` back as `pageToken` until it is `null`. `pageSize` is 1 to 100. - **Payouts must be switched on** for your tenant in that environment ([Tenant Settings](https://docs.aureahub.com/docs/bank-settings.md)): otherwise `403` `NOAH_FUNCTION_OFF`. A tenant that does not use the bank ramp, or a token without a tenant, gets `403`. - The user needs a bank ramp profile in that environment ([Onboarding Session](https://docs.aureahub.com/docs/payin-session.md)): otherwise `404` with `details.code` `NOAH_CUSTOMER_NOT_FOUND`. The bank ramp's own refusals pass through with their status and `details`. - `isTestnet=true` reads the bank ramp's sandbox with the user's sandbox profile; `false` or omitted reads production. Nothing is stored and nothing moves. ## Endpoint ### `GET /v1/ramp/bank/payout/beneficiaries` Authentication: bearer token required. Returns one page of the payment methods the user's bank ramp customer can be paid out to. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `pageSize` | integer | no | 1 to 100; the bank ramp's default is 20 | | `pageToken` | string | no | nextPageToken of the previous page | | `isTestnet` | boolean | no | `true` for the bank ramp's sandbox; `false` or omitted for production | **Responses** `200` OK ```json { "environment": "sandbox", "beneficiaries": [ { "paymentMethodId": "", "paymentMethodType": null, "country": "DE", "paymentMethodCategory": "Bank", "details": { "type": "bank", "accountNumber": "", "bankCode": "", "routingNumber": null, "swiftCode": null, "bankName": "", "bankingSystems": [], "bankAddress": null, "last4": null, "scheme": null, "identifierType": null, "identifier": null }, "accountHolder": { "type": "Individual", "firstName": "", "middleName": null, "lastName": "", "nameLocal": null, "businessName": null }, "issuerName": "", "capabilities": { "payoutTo": true, "payoutFrom": false, "payinTo": false } } ], "nextPageToken": null } ``` Web version: https://docs.aureahub.com/#payout-beneficiaries --- # Create a Payout Quote Price a payout to a bank account with the bank ramp: fees, the amount the beneficiary receives, the rate, and a quote that locks them. ## Overview After the user has chosen a channel ([Search Channels](https://docs.aureahub.com/docs/payout-channels.md)) and filled in its form ([Channel Form](https://docs.aureahub.com/docs/payout-channel-form.md)) or picked a [saved beneficiary](https://docs.aureahub.com/docs/payout-beneficiaries.md), ask for a quote. Aurea reads the channel, adds your tenant's payout fee and asks the bank ramp to prepare the payout. Nothing moves: a quote is not a payout, and [List Payout Transactions](https://docs.aureahub.com/docs/payout-list.md) does not show it. - Send exactly one amount: `fiatAmount`, what the beneficiary receives, or `cryptoAmount`, what the user sends. Each is a decimal above zero written as text, up to 38 characters; Aurea sends and keeps it written plainly (`"09.50"` is `"9.5"`). - `cryptoCurrency` must be offered for payout by your tenant ([Currencies & Networks](https://docs.aureahub.com/docs/bank-currencies.md)): otherwise `400` `NOAH_PAIR_UNAVAILABLE`. In the sandbox an asset code such as `EURC` is read as `EURC_TEST`. - **Your fee.** The payout fee of your [Tenant Settings](https://docs.aureahub.com/docs/bank-settings.md) for the channel's payment method type — or the one for every type — goes to the bank ramp with the quote. It shows in `totalFee` and as the `BusinessFee` line of `breakdown`. - **A quote that locks.** With `quoted` (`true` unless you send `false`), once every form step is done the bank ramp signs a quote that fixes the rate and the beneficiary's amount until `quote.expiresAt`: `quote.locked` is `true`. The signed quote itself stays in Aurea. **Locked quotes are enabled per tenant**; where they are not, asking for one answers `400` `NOAH_INVALID_REQUEST` naming the `Quoted` field, and `quoted: false` is the way through — such a quote is still paid, by a rule that strikes the rate when the deposit lands ([Pay from a Wallet](https://docs.aureahub.com/docs/payout-wallet-pay.md)). - **Mind the channel's minimum.** A payout below a channel's `cryptoLimits.min` leaves the beneficiary nothing once the fixed fee is taken — the bank ramp prices it at `fiatAmount` `0` rather than refusing, and paying that quote answers `400` `NOAH_PAYOUT_AMOUNT_TOO_SMALL` ([Pay from a Wallet](https://docs.aureahub.com/docs/payout-wallet-pay.md)). Read `cryptoLimits` from [Search Channels](https://docs.aureahub.com/docs/payout-channels.md) and quote above it: a US wire rail charging a fixed $20, for instance, needs more than about 20 of the token before anything reaches the beneficiary. - **Card channels are refused** with `400` `NOAH_CHANNEL_NOT_SUPPORTED`: The bank ramp pays a card only through its hosted checkout. A channel the bank ramp describes without what a payout needs answers `502` `NOAH_UNEXPECTED_RESPONSE`. - **Payouts must be switched on** for your tenant in that environment: otherwise `403` `NOAH_FUNCTION_OFF`. A tenant that does not use the bank ramp, or a token without a tenant, gets `403`. - **KYC.** The user needs a bank ramp profile in that environment (`404` `NOAH_CUSTOMER_NOT_FOUND`) with a completed onboarding and an approved KYC: otherwise `422` `NOAH_KYC_NOT_APPROVED` with `onboardingStatus` and `kycStatus`. See [Onboarding Session](https://docs.aureahub.com/docs/payin-session.md). - the bank ramp's own refusals pass through with their status and `details`, for example `400` `NOAH_INVALID_REQUEST` with the form's `fields`. Nothing is stored then. - **Retries.** Send an `Idempotency-Key` header to make a retry safe: the same key with the same body answers with the first quote, the bank ramp is not asked twice, and the response header `idempotency-replayed` is `true`. Every check above is made before a key is replayed. See [Idempotency](https://docs.aureahub.com/docs/idempotency.md). - The form, the payment method id — it contains the account number — and the bank ramp's form session travel only in the request body and are never written to Aurea's logs. `isTestnet: true` uses the bank ramp's sandbox; `false` or omitted, production. ## Endpoint ### `POST /v1/ramp/bank/payout/quotes` Authentication: bearer token required. Prices a payout with the bank ramp, with your tenant's payout fee, and keeps the quote. **Headers** | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | no | Optional. Up to 255 letters, digits, - or _. The same key with the same body answers with the first quote; see [Idempotency](https://docs.aureahub.com/docs/idempotency.md) | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `channelId` | string | yes | A channel of Search Channels (UUID) | | `cryptoCurrency` | string | yes | The currency the user pays out, e.g. EURC_TEST | | `fiatAmount` | string | no | What the beneficiary receives, in the channel's currency. Send this or cryptoAmount | | `cryptoAmount` | string | no | What the user sends, in cryptoCurrency. Send this or fiatAmount | | `paymentMethodId` | string | no | A saved beneficiary of the user (1 to 150 characters) | | `form` | object | no | The beneficiary details, as the channel's formSchema asks; the bank ramp validates them | | `quoted` | boolean | no | Ask for a quote that locks the rate once every step is done; `true` unless `false` is sent | | `isTestnet` | boolean | no | `true` for the bank ramp's sandbox; `false` or omitted for production | **Responses** `201` Ready and locked ```json { "quoteId": "3f6c2a1e-8b4d-4c7a-9e2f-5d1b0a9c8e7f", "environment": "sandbox", "status": "ready", "channelId": "ebb9736b-08b3-599d-886b-10ee8aea82b5", "paymentMethodType": "BankSepa", "country": "DE", "fiatCurrency": "EUR", "cryptoCurrency": "EURC_TEST", "requestedFiatAmount": "9.5", "requestedCryptoAmount": null, "nextStep": null, "totalFee": "0.285", "cryptoAmountEstimate": "10.3", "cryptoAuthorizedAmount": "10.3", "fiatAmount": "9.5", "rate": "0.95", "breakdown": [ { "type": "ChannelFee", "amount": "0.2", "fixedAmount": "0.2", "variableAmount": null }, { "type": "BusinessFee", "amount": "0.1", "fixedAmount": null, "variableAmount": "0.1" }, { "type": "Remaining", "amount": "10", "fixedAmount": null, "variableAmount": null } ], "quote": { "locked": true, "expiresAt": "2026-09-17T12:30:00.000Z" }, "createdAt": "2026-09-17T12:00:00.000Z", "updatedAt": "2026-09-17T12:00:00.000Z" } ``` `201` A form step first ```json { "quoteId": "3f6c2a1e-8b4d-4c7a-9e2f-5d1b0a9c8e7f", "environment": "sandbox", "status": "needs_step", "channelId": "ebb9736b-08b3-599d-886b-10ee8aea82b5", "paymentMethodType": "BankSepa", "country": "DE", "fiatCurrency": "EUR", "cryptoCurrency": "EURC_TEST", "requestedFiatAmount": "9.5", "requestedCryptoAmount": null, "nextStep": { "stepId": "Vop", "stepType": "Ack", "schema": { "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { } } }, "totalFee": null, "cryptoAmountEstimate": null, "cryptoAuthorizedAmount": null, "fiatAmount": null, "rate": null, "breakdown": [], "quote": { "locked": false, "expiresAt": null }, "createdAt": "2026-09-17T12:00:00.000Z", "updatedAt": "2026-09-17T12:00:00.000Z" } ``` `422` KYC not approved ```json { "statusCode": 422, "error": "ValidationError", "message": "A payout needs a completed onboarding with the bank ramp and an approved KYC.", "details": { "code": "NOAH_KYC_NOT_APPROVED", "onboardingStatus": "completed", "kycStatus": "pending" } } ``` `400` Card channel ```json { "statusCode": 400, "error": "BadRequestError", "message": "The bank ramp pays a card only through its hosted checkout: a quote goes through a bank or identifier channel.", "details": { "code": "NOAH_CHANNEL_NOT_SUPPORTED", "paymentMethodCategory": "Card" } } ``` ## Form Steps The bank ramp may ask for a step before it prices the payout — a payee check (`Vop`), for instance. The quote is then `needs_step`: render `nextStep.schema` (`stepType` `Ack` is an acknowledgement, `DataEntry` asks for new details) and send the answers with [Answer a Form Step](https://docs.aureahub.com/docs/payout-quote-step.md). Repeat until the quote is `ready`. The amounts are the payout's only then. - `breakdown` is in `cryptoCurrency`: `ChannelFee` + `BusinessFee` + `Remaining` = `cryptoAmountEstimate`. `totalFee` is in the fiat currency. - Read a quote again at any time with [Get a Quote](https://docs.aureahub.com/docs/payout-quote-get.md); `quote.locked` turns `false` once it has expired. Ask for a new quote then. ## Implementation ```javascript // idempotencyKey: made once for this quote (e.g. crypto.randomUUID()) and sent again on every retry async function quotePayout(token, { channelId, fiatAmount, paymentMethodId, form }, idempotencyKey) { const res = await fetch('https://api.aureahub.com/v1/ramp/bank/payout/quotes', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey }, body: JSON.stringify({ isTestnet: true, channelId, cryptoCurrency: 'EURC_TEST', fiatAmount, // e.g. "9.5": what the beneficiary receives paymentMethodId, // a saved beneficiary, or leave it out and send form form }) }); const quote = await res.json(); if (!res.ok) throw Object.assign(new Error(quote.message), { status: res.status, details: quote.details }); return quote; // status "needs_step": show quote.nextStep; "ready": show the amounts } ``` Web version: https://docs.aureahub.com/#payout-quote --- # Answer a Quote's Form Step Send the bank ramp the answers to the step a payout quote is waiting for, and get the next step or the ready quote. ## Overview When [Create a Payout Quote](https://docs.aureahub.com/docs/payout-quote.md) answers `needs_step`, fill in `nextStep.schema` and send it here as `form`. Aurea sends it on the quote's form session, with the channel, the amount, the saved beneficiary and `quoted` as first asked; your fee is already on the session. The answer is the same quote: another step, or `ready` with the amounts. - A quote of another user or of the other environment, or an unknown id, answers `404` `NOAH_QUOTE_NOT_FOUND`. A quote that is already `ready` answers `409` `NOAH_QUOTE_READY` without calling the bank ramp. - The same checks as for a new quote come first: payouts switched on (`403` `NOAH_FUNCTION_OFF`), a bank ramp profile (`404` `NOAH_CUSTOMER_NOT_FOUND`) with an approved KYC (`422` `NOAH_KYC_NOT_APPROVED`), and a currency your tenant still offers for payout (`400` `NOAH_PAIR_UNAVAILABLE`). - the bank ramp's refusal of the answers passes through, for example `400` `NOAH_INVALID_REQUEST` with `fields`; the quote keeps waiting for its step. - `quoteId` is a UUID: anything else answers `400`. Send `isTestnet` as for the quote. ## Endpoint ### `POST /v1/ramp/bank/payout/quotes/{quoteId}/steps` Authentication: bearer token required. Answers the form step a payout quote is waiting for. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `quoteId` | string | yes | The quote's id (UUID) | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `form` | object | yes | The answers `nextStep.schema` asks for | | `isTestnet` | boolean | no | `true` for the bank ramp's sandbox; `false` or omitted for production | **Responses** `200` Ready ```json { "quoteId": "3f6c2a1e-8b4d-4c7a-9e2f-5d1b0a9c8e7f", "environment": "sandbox", "status": "ready", "channelId": "ebb9736b-08b3-599d-886b-10ee8aea82b5", "paymentMethodType": "BankSepa", "country": "DE", "fiatCurrency": "EUR", "cryptoCurrency": "EURC_TEST", "requestedFiatAmount": "9.5", "requestedCryptoAmount": null, "nextStep": null, "totalFee": "0.285", "cryptoAmountEstimate": "10.3", "cryptoAuthorizedAmount": "10.3", "fiatAmount": "9.5", "rate": "0.95", "breakdown": [ { "type": "ChannelFee", "amount": "0.2", "fixedAmount": "0.2", "variableAmount": null }, { "type": "BusinessFee", "amount": "0.1", "fixedAmount": null, "variableAmount": "0.1" }, { "type": "Remaining", "amount": "10", "fixedAmount": null, "variableAmount": null } ], "quote": { "locked": true, "expiresAt": "2026-09-17T12:30:00.000Z" }, "createdAt": "2026-09-17T12:00:00.000Z", "updatedAt": "2026-09-17T12:01:00.000Z" } ``` `409` Already ready ```json { "statusCode": 409, "error": "ConflictError", "message": "This payout quote needs no more form steps: it is ready.", "details": { "code": "NOAH_QUOTE_READY", "quoteId": "3f6c2a1e-8b4d-4c7a-9e2f-5d1b0a9c8e7f" } } ``` Web version: https://docs.aureahub.com/#payout-quote-step --- # Get a Payout Quote Read a payout quote as Aurea keeps it, without asking the bank ramp. ## Overview - Answers the same shape as [Create a Payout Quote](https://docs.aureahub.com/docs/payout-quote.md). `quote.locked` is worked out when you read: it turns `false` once `quote.expiresAt` has passed. - Only the user's own quotes, in the environment named by `isTestnet`: anything else answers `404` `NOAH_QUOTE_NOT_FOUND`. A token without a tenant gets `403`; `quoteId` that is not a UUID, `400`. - Nothing else is asked: a quote can still be read after payouts are switched off. ## Endpoint ### `GET /v1/ramp/bank/payout/quotes/{quoteId}` Authentication: bearer token required. Returns one of the user's payout quotes. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `quoteId` | string | yes | The quote's id (UUID) | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `isTestnet` | boolean | no | `true` for the bank ramp's sandbox; `false` or omitted for production | **Responses** `200` Expired ```json { "quoteId": "3f6c2a1e-8b4d-4c7a-9e2f-5d1b0a9c8e7f", "environment": "sandbox", "status": "ready", "channelId": "ebb9736b-08b3-599d-886b-10ee8aea82b5", "paymentMethodType": "BankSepa", "country": "DE", "fiatCurrency": "EUR", "cryptoCurrency": "EURC_TEST", "requestedFiatAmount": "9.5", "requestedCryptoAmount": null, "nextStep": null, "totalFee": "0.285", "cryptoAmountEstimate": "10.3", "cryptoAuthorizedAmount": "10.3", "fiatAmount": "9.5", "rate": "0.95", "breakdown": [ { "type": "Remaining", "amount": "10", "fixedAmount": null, "variableAmount": null } ], "quote": { "locked": false, "expiresAt": "2026-09-17T12:30:00.000Z" }, "createdAt": "2026-09-17T12:00:00.000Z", "updatedAt": "2026-09-17T12:00:00.000Z" } ``` `404` Not the user's ```json { "statusCode": 404, "error": "NotFoundError", "message": "No payout quote with this id for you in sandbox.", "details": { "code": "NOAH_QUOTE_NOT_FOUND", "environment": "sandbox" } } ``` Web version: https://docs.aureahub.com/#payout-quote-get --- # Pay Out a Quote from Your Wallet Bind a payout quote whose rate is locked to a bank ramp payout rule, and get the deposit the user's own wallet must send. ## Overview A [payout quote](https://docs.aureahub.com/docs/payout-quote.md) that is `ready` and still locks its rate becomes a payout here. Aurea asks the bank ramp for an automated payout rule for the source address, and answers where to send: `deposit.amountUnits` of `cryptoCurrency` to `deposit.address`, from `sourceAddress`, on `network`. The user's wallet sends that deposit; the bank ramp recognises it by the address it comes from and pays the beneficiary. Aurea never holds the money and never sends the deposit for the user. - **The body** is the quote (`quoteId`), the `network` the deposit travels on, and the `source` it comes from — exactly one of `source.walletId` and `source.address`. Both, or neither, answers `400`, as does a `network` that is not one of the bank ramp's names. Unknown fields are ignored. `isTestnet: true` uses the bank ramp's sandbox; `false` or omitted, production. - **The pair.** The quote's `cryptoCurrency` on `network` must be a payout pair the Aurea registry enables and your tenant offers ([Currencies & Networks](https://docs.aureahub.com/docs/bank-currencies.md)): otherwise `400` `NOAH_PAIR_UNAVAILABLE`, with `details.available` naming what is offered. The quote's currency is the one it was created with; you choose only the network. - **Two kinds of quote, two kinds of payout.** A quote that **locks a rate** (`quoted` true, signed by the bank ramp, not past `quote.expiresAt`) fixes the rate and both amounts until it expires. A quote the bank ramp answered but **did not sign** is paid too: the beneficiary still receives the quote's `fiatAmount`, and the bank ramp strikes the rate when the deposit lands — so `deposit.amount` is an estimate and the crypto actually sold may differ. Read `quote.locked` on [Get a Quote](https://docs.aureahub.com/docs/payout-quote-get.md) to know which you hold. - **What is still refused**: `409` `NOAH_QUOTE_NOT_LOCKED` with `details.reason` `not_ready` (the bank ramp still asks for a form step, so the quote has no amounts yet) or `expired` (a locked rate the bank ramp will no longer honour). Ask for a new quote; nothing is stored. A quote that leaves the beneficiary nothing — below the channel's `cryptoLimits.min` the fixed fee takes the whole payout, and the bank ramp prices it at `fiatAmount` `0` rather than refusing it — answers `400` `NOAH_PAYOUT_AMOUNT_TOO_SMALL` with `details.quoteId`: quote above that minimum. Nothing is stored for it either. - **A payout without a locked rate holds your source address for its whole window** — 24 hours by default. The bank ramp fires that rule on **any** deposit from the address, because matching an exact amount is fragile: a deposit a fraction short would be ignored and the money would sit at an address with no rule to act on it. A second payout from the same source answers `409` `NOAH_PAYOUT_SOURCE_BUSY` until the first is paid, refused or expired. - **A quote pays one payout.** A quote already bound, whatever that payout's status, answers `409` `NOAH_QUOTE_USED` with `details.quoteId` and `details.payoutId`. - **One source, one waiting payout per network and currency** — across every tenant, not only yours. While a payout from that address is waiting for its deposit of that currency on that network, another answers `409` `NOAH_PAYOUT_SOURCE_BUSY` with `details.network` and `details.cryptoCurrency`; the payout holding it is never described. The same address on another network, or with another currency, does not collide. The hold ends when the deposit is paid out, when the bank ramp refuses the rule, or when the rule expires — **30 minutes after the quote's `expiresAt`**, so that a deposit sent just before the rate ran out can still be matched. - **Before anything is stored**, and before an `Idempotency-Key` is looked at: your tenant must bank with the bank ramp and have payouts switched on in that environment (`403`, `403` `NOAH_FUNCTION_OFF`); the user needs a bank ramp profile there (`404` `NOAH_CUSTOMER_NOT_FOUND`) with a completed onboarding and an approved KYC (`422` `NOAH_KYC_NOT_APPROVED`); the quote must be the user's own in that environment (`404` `NOAH_QUOTE_NOT_FOUND`); and the pair and the source must be allowed. - **The bank ramp must be connected** for your tenant in that environment before the payout is stored: otherwise the call answers `503` `NOAH_NOT_CONFIGURED`, and no payout waits for a rule nobody asked for. - **Retries.** Send an `Idempotency-Key` header: the same key with the same body answers with the first payout, the bank ramp is not asked twice, and the response header `idempotency-replayed` is `true` — a replay works even after the quote has expired. Every check above is made before a key is replayed. See [Idempotency](https://docs.aureahub.com/docs/idempotency.md). - The source address, the deposit address, the bank ramp's form session and the signed quote never reach Aurea's logs, and the answer never carries the form session, the signed quote, the bank ramp's rule id, the rule's reference or the bank ramp customer id. ## The Source The bank ramp attributes the deposit to the rule by the address it came from, so the source must be an address the user controls. Which kinds you may send depends on your tenant's integration modes (`modes` in [Tenant Settings](https://docs.aureahub.com/docs/bank-settings.md)): - `source.walletId` — one of the user's Aurea wallets, with the *Aurea wallets* mode. The key decides, not the wallet's own chain or environment: an EVM wallet sends on every EVM network — an Ethereum mainnet wallet is a valid source on `PolygonTestAmoy`, because it is the same key — and a Solana wallet on Solana networks. Without the mode, `403` `NOAH_MODE_OFF` with `details.mode` `aureaWallets`. - `source.address` — an address outside Aurea that the user proved they own in that environment and has not revoked ([Ask for a Challenge](https://docs.aureahub.com/docs/bank-address-challenge.md), [List Addresses](https://docs.aureahub.com/docs/bank-addresses-list.md)), with the *standalone* mode. A proven EVM address is matched in any letter case. Without the mode, `403` `NOAH_MODE_OFF` with `details.mode` `standalone`. A source the user may not send from answers `400` `NOAH_SOURCE_NOT_ALLOWED` with `details.reason`, and the answer never repeats the address: - `wallet_not_found`: the wallet is not one of this user's wallets in your tenant. - `watch_only`: a watch-only import proves nothing about who holds the key, so it cannot send a payout. - `wrong_network`: an EVM wallet named for a Solana network, or a Solana wallet for an EVM network. - `address_not_proven`: the address is not one the user proved in that environment for the network's address format, or it has been revoked — including a revocation that lands while this very payout is being stored. Prove it first with [POST /v1/ramp/bank/addresses/challenge](https://docs.aureahub.com/docs/bank-address-challenge.md). `sourceAddress` in the answer is the form Aurea stores and compares: an EVM address with its checksum, a Solana address as base58. Send the deposit from exactly that address. While a payout of yours is waiting for its deposit, **the proof of that address cannot be revoked**: [Revoke an Address](https://docs.aureahub.com/docs/bank-address-revoke.md) answers `409` `NOAH_ADDRESS_IN_USE` until the rule lets go. The two are decided together, so a revocation never leaves a payout waiting on an address the user no longer holds, and a payout never opens on one that has just been revoked. ## Endpoint ### `POST /v1/ramp/bank/payout/payouts` Authentication: bearer token required. Binds a locked payout quote to a bank ramp payout rule and returns the deposit to send. **Headers** | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | no | Optional. Up to 255 letters, digits, - or _. The same key with the same body answers with the first payout; see [Idempotency](https://docs.aureahub.com/docs/idempotency.md) | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `quoteId` | string | yes | A ready payout quote of the user that still locks its rate (UUID) | | `network` | string | yes | the bank ramp's network the deposit is sent on, e.g. PolygonTestAmoy; a payout pair of Currencies & Networks | | `source` | object | yes | Where the deposit comes from: exactly one of the two fields below | | `source.walletId` | string | no | One of the user's Aurea wallets (UUID). Needs the Aurea wallets mode | | `source.address` | string | no | An address the user proved they own (1 to 128 characters). Needs the standalone mode | | `isTestnet` | boolean | no | `true` for the bank ramp's sandbox; `false` or omitted for production | **Responses** `201` Waiting for the deposit ```json { "payoutId": "6b1f0d24-9c3a-4f18-b7e5-2a8c4d6e0f13", "environment": "sandbox", "status": "pending", "quoteId": "3f6c2a1e-8b4d-4c7a-9e2f-5d1b0a9c8e7f", "network": "PolygonTestAmoy", "cryptoCurrency": "USDC_TEST", "sourceAddress": "", "deposit": { "address": "", "amount": "10.3", "amountUnits": "10300000", "tokenAddress": "0xae1D7d8B36E9AbA7D95A75c69d50b38E7e02A9DD", "decimals": 6, "chainId": 80002, "expiresAt": "2026-09-17T13:00:00.000Z", "uri": "ethereum:0xae1D7d8B36E9AbA7D95A75c69d50b38E7e02A9DD@80002/transfer?address=&uint256=10300000" }, "cryptoAuthorizedAmount": "10.506", "cryptoAuthorizedAmountUnits": "10506000", "fiatCurrency": "EUR", "fiatAmount": "9.5", "rate": "0.95", "totalFee": "0.285", "quote": { "expiresAt": "2026-09-17T12:30:00.000Z" }, "createdAt": "2026-09-17T12:02:00.000Z", "updatedAt": "2026-09-17T12:02:00.000Z" } ``` `409` Source busy ```json { "statusCode": 409, "error": "ConflictError", "message": "This source already has a payout waiting for its deposit of USDC_TEST on PolygonTestAmoy: send that deposit, or wait until it expires.", "details": { "code": "NOAH_PAYOUT_SOURCE_BUSY", "network": "PolygonTestAmoy", "cryptoCurrency": "USDC_TEST" } } ``` `409` Quote not locked ```json { "statusCode": 409, "error": "ConflictError", "message": "This payout quote cannot be paid out: its locked rate has expired. Ask for a new quote.", "details": { "code": "NOAH_QUOTE_NOT_LOCKED", "quoteId": "3f6c2a1e-8b4d-4c7a-9e2f-5d1b0a9c8e7f", "reason": "expired" } } ``` `400` Source refused ```json { "statusCode": 400, "error": "BadRequestError", "message": "A watch-only wallet cannot send a payout: the Hub has no proof you control its key.", "details": { "code": "NOAH_SOURCE_NOT_ALLOWED", "environment": "sandbox", "reason": "watch_only" } } ``` `502` Outcome unknown ```json { "statusCode": 502, "error": "NoahApiError", "message": "The bank ramp answered with an unexpected response (HTTP 200)", "details": { "code": "NOAH_UNEXPECTED_RESPONSE", "noahStatus": 200, "payoutId": "6b1f0d24-9c3a-4f18-b7e5-2a8c4d6e0f13", "outcome": "unknown" } } ``` ## The Answer - `payoutId` is what you keep: read the payout again with [Get a Wallet Payout](https://docs.aureahub.com/docs/payout-wallet-get.md). `environment` is `sandbox` or `production`. - `status` is one of `pending`, `processing`, `completed`, `failed`, `cancelled` and `expired`. It starts at `pending`: the payout is waiting for the deposit. `failed` means the bank ramp refused the rule; `expired`, that no deposit came before the rule's time ran out. - `deposit.address` is where to send. It is `null` when Aurea could not learn the bank ramp's answer — see **Unknown Outcome** below. - `deposit.amount` is what to send, written in `cryptoCurrency` (the quote's estimate, `"10.3"`); `deposit.amountUnits` is the same amount in the token's smallest unit (`"10300000"`). **Send exactly `amountUnits`**: it is the number a token transfer takes, and it needs no rounding of your own. - `deposit.tokenAddress` is the token contract on an EVM network or the mint on Solana, `decimals` its precision, and `chainId` the EVM chain id — `null` on Solana. All three are `null` when the Aurea registry no longer holds that pair with its token details; the amounts already stored on the payout do not change. - `cryptoAuthorizedAmount`, with `cryptoAuthorizedAmountUnits` beside it, is the most the rule may take from the deposit. It is the quote's authorised amount and is usually above `deposit.amount`. - `fiatAmount` in `fiatCurrency` is what the beneficiary receives, `rate` the quote's rate and `totalFee` every fee of the quote, in the fiat currency. - `quote.expiresAt` is when the deposit must have arrived and cleared for the quote's rate to hold. The source stays held for 30 minutes after it. - `deposit.expiresAt` is **the deadline the deposit itself has**: when the payout rule stops holding the source address — 30 minutes after `quote.expiresAt`. A deposit that arrives after it is matched by no rule. It is `null` only while Aurea never learned the bank ramp's answer. - `deposit.uri` is that same transfer written in the standard the chain family has, so no one retypes an amount: **EIP-681** on an EVM network (`ethereum:@/transfer?address=&uint256=`, the smallest unit) and **Solana Pay** on Solana (`solana:?spl-token=&amount=`, whole tokens). It carries no reference and no memo: The bank ramp matches the deposit by the address it comes from. It is `null` when the request cannot be written exactly — no deposit address yet, no token details — and the rest of the answer still says where and how much. Wallets honour these requests unevenly: **check the amount before signing**, and treat `amountUnits` as the truth. ## Unknown Outcome the bank ramp's trigger carries no nonce of its own, so asking twice would make two rules. Aurea therefore asks the bank ramp **once**, and when it cannot tell what happened it keeps the payout and the source's hold rather than trying again. - **The bank ramp refused** (it answered `400`-`499`): no rule exists. The payout becomes `failed`, the source is free again, and the bank ramp's refusal passes through as Aurea maps it — the bank ramp's `400` becomes `400` `NOAH_INVALID_REQUEST`, `402` becomes `400` `NOAH_INSUFFICIENT_BALANCE`, `403` becomes `403` `NOAH_FORBIDDEN`, `404` becomes `404` `NOAH_RESOURCE_NOT_FOUND`, `401` becomes `502` `NOAH_AUTHENTICATION_FAILED` and `429` becomes `503` `NOAH_RATE_LIMITED`. The same quote then answers `409` `NOAH_QUOTE_USED`; a new quote from the same source works. - **Anything else** — the bank ramp's `500` (`502` `NOAH_UPSTREAM_ERROR`), its `502`, `503` or `504` (`503` `NOAH_UNAVAILABLE`), no answer in time (`503` `NOAH_TIMEOUT`), no connection (`503` `NOAH_UNREACHABLE`), or an answer Aurea cannot read, one without a deposit address for the network included (`502` `NOAH_UNEXPECTED_RESPONSE`) — may have left a rule at the bank ramp. The answer then carries `details.payoutId` and `details.outcome` `unknown`: the payout stays `pending` with no deposit address, and the source stays held. The same happens when the bank ramp answered and Aurea could not write the answer down. > ⚠️ **Never retry blindly on an unknown outcome.** Read the payout with [Get a Wallet Payout](https://docs.aureahub.com/docs/payout-wallet-get.md) using `details.payoutId`, and tell the user to send the deposit only once an address is there. Sending the request again — with any key, or none — answers `409` `NOAH_QUOTE_USED` and asks the bank ramp nothing; another quote from the same source answers `409` `NOAH_PAYOUT_SOURCE_BUSY` until the rule expires. ## Errors Every body is the envelope of [Error Handling](https://docs.aureahub.com/docs/errors.md), with the code in `details.code`. | Status | details.code | When | | --- | --- | --- | | 400 | `—` | The body does not fit: `quoteId` or `source.walletId` is not a UUID, `network` is not one of the bank ramp's network names, `source` carries both fields or neither. Nothing is stored and the bank ramp is not called. | | 403 | `—` | The token names no tenant, or your tenant does not use the bank ramp. | | 403 | `NOAH_FUNCTION_OFF` | Payouts are switched off for your tenant in that environment (`details.function` `payout`, `details.environment`). | | 404 | `NOAH_CUSTOMER_NOT_FOUND` | The user has no bank ramp profile in that environment yet (`details.environment`). | | 422 | `NOAH_KYC_NOT_APPROVED` | The onboarding is not completed or the KYC is not approved (`details.onboardingStatus`, `details.kycStatus`). | | 404 | `NOAH_QUOTE_NOT_FOUND` | No payout quote with this id for this user in that environment (`details.environment`). | | 400 | `NOAH_PAIR_UNAVAILABLE` | The quote's currency on `network` is not enabled for payout by the registry, or not offered for payout by your tenant (`details.environment`, `details.cryptoCurrency`, `details.network`, `details.available`). | | 403 | `NOAH_MODE_OFF` | `source.walletId` without the Aurea wallets mode, or `source.address` without the standalone mode (`details.mode`, `details.environment`). | | 400 | `NOAH_SOURCE_NOT_ALLOWED` | The source cannot send this payout (`details.reason`: `wallet_not_found`, `watch_only`, `wrong_network`, `address_not_proven`; `details.environment`). The address is never repeated. | | 409 | `NOAH_QUOTE_NOT_LOCKED` | The quote cannot be paid (`details.quoteId`, `details.reason`: `not_ready` or `expired`). A quote that simply locks no rate is paid by a rule instead, not refused. | | 400 | `NOAH_PAYOUT_AMOUNT_TOO_SMALL` | The quote leaves the beneficiary nothing: below the channel's `cryptoLimits.min` the fixed fee takes the whole payout and the bank ramp prices it at zero (`details.quoteId`). Quote above the minimum; nothing is stored. | | 409 | `NOAH_QUOTE_USED` | The quote is already bound to a payout, whatever its status (`details.quoteId`, `details.payoutId`). | | 409 | `NOAH_PAYOUT_SOURCE_BUSY` | The source already has a payout waiting for its deposit of that currency on that network (`details.network`, `details.cryptoCurrency`). | | 400 / 409 | `IDEMPOTENCY_KEY_INVALID, IDEMPOTENCY_KEY_REUSED, IDEMPOTENCY_IN_PROGRESS` | The `Idempotency-Key` is not one, was used with a different body, or its first request is still running. See [Idempotency](https://docs.aureahub.com/docs/idempotency.md). | | 503 | `NOAH_NOT_CONFIGURED` | Aurea has not connected the bank ramp for your tenant in that environment; the bank ramp was not called and nothing is stored. | | 400 / 403 / 404 / 502 / 503 | `the bank ramp's own codes` | the bank ramp refused the rule: the payout is `failed` and the source is free again. See Unknown Outcome above. | | 502 / 503 | `with details.outcome unknown` | Aurea cannot tell whether the bank ramp made the rule. The payout stays `pending` and `details.payoutId` names it — read it, don't retry. | ## Implementation ```javascript // idempotencyKey: made once for this payout (e.g. crypto.randomUUID()) and sent again on every retry async function payQuoteFromWallet(token, { quoteId, network, walletId }, idempotencyKey) { const res = await fetch('https://api.aureahub.com/v1/ramp/bank/payout/payouts', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey }, body: JSON.stringify({ isTestnet: true, quoteId, network, // e.g. "PolygonTestAmoy" source: { walletId } // or { address } for an address the user proved }) }); const payout = await res.json(); if (!res.ok) { // Aurea could not tell whether the payout rule was made: read the payout, never resend if (payout.details?.outcome === 'unknown') { return readWalletPayout(token, payout.details.payoutId, true); } throw Object.assign(new Error(payout.message), { status: res.status, details: payout.details }); } // Send exactly deposit.amountUnits of the token to deposit.address, from payout.sourceAddress return payout; } ``` Web version: https://docs.aureahub.com/#payout-wallet-pay --- # List Wallet Payouts The user's payouts paid from their own wallet, newest first, in one environment. ## Overview - Lists the payouts created with [Pay Out a Quote from Your Wallet](https://docs.aureahub.com/docs/payout-wallet-pay.md) — the user's own, in the environment named by `isTestnet`, sorted newest first. Each item has exactly the shape of that endpoint's answer. - **Hosted checkout payouts are never listed here.** Those are the ones of [Initiate Payout](https://docs.aureahub.com/docs/payout-initiate.md), and [List Payout Transactions](https://docs.aureahub.com/docs/payout-list.md) lists them. - `page` is an integer from `1` (default `1`) and `limit` an integer from `1` to `100` (default `20`); anything outside answers `400`. `total` counts the user's payouts in that environment and `totalPages` follows `limit`. - It needs a tenant (`403` otherwise) and nothing else: payouts stay readable after your tenant switches the bank ramp payouts off, and a user without a bank ramp profile gets an empty list. The bank ramp is not called. ## Endpoint ### `GET /v1/ramp/bank/payout/payouts` Authentication: bearer token required. Returns the user's payouts paid from their own wallet in one environment. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `isTestnet` | boolean | no | `true` for the bank ramp's sandbox; `false` or omitted for production | | `page` | integer | no | Page number from 1 (default 1) | | `limit` | integer | no | Items per page, 1 to 100 (default 20) | **Responses** `200` OK ```json { "environment": "sandbox", "payouts": [ { "payoutId": "6b1f0d24-9c3a-4f18-b7e5-2a8c4d6e0f13", "environment": "sandbox", "status": "pending", "quoteId": "3f6c2a1e-8b4d-4c7a-9e2f-5d1b0a9c8e7f", "network": "PolygonTestAmoy", "cryptoCurrency": "USDC_TEST", "sourceAddress": "", "deposit": { "address": "", "amount": "10.3", "amountUnits": "10300000", "tokenAddress": "0xae1D7d8B36E9AbA7D95A75c69d50b38E7e02A9DD", "decimals": 6, "chainId": 80002, "expiresAt": "2026-09-17T13:00:00.000Z", "uri": "ethereum:0xae1D7d8B36E9AbA7D95A75c69d50b38E7e02A9DD@80002/transfer?address=&uint256=10300000" }, "cryptoAuthorizedAmount": "10.506", "cryptoAuthorizedAmountUnits": "10506000", "fiatCurrency": "EUR", "fiatAmount": "9.5", "rate": "0.95", "totalFee": "0.285", "quote": { "expiresAt": "2026-09-17T12:30:00.000Z" }, "createdAt": "2026-09-17T12:02:00.000Z", "updatedAt": "2026-09-17T12:02:00.000Z" } ], "total": 1, "page": 1, "limit": 20, "totalPages": 1 } ``` `403` No tenant ```json { "statusCode": 403, "error": "ForbiddenError", "message": "This request needs a tenant: an admin token without one reads no payout." } ``` Web version: https://docs.aureahub.com/#payout-wallet-list --- # Get a Wallet Payout Read one payout paid from the user's own wallet, as Aurea keeps it, without asking the bank ramp. ## Overview - Answers the same shape as [Pay Out a Quote from Your Wallet](https://docs.aureahub.com/docs/payout-wallet-pay.md), with `status` and `updatedAt` as they stand now. - **This is what you call after an unknown outcome.** `deposit.address` is `null` while Aurea does not know the bank ramp's answer: the user must not send anything yet. - Only the user's own wallet payouts, in the environment named by `isTestnet`. An unknown id, another user's or another tenant's payout, one of the other environment, and a hosted checkout payout of [Initiate Payout](https://docs.aureahub.com/docs/payout-initiate.md) all answer `404` `NOAH_PAYOUT_NOT_FOUND` with `details.environment`. - It needs a tenant (`403` otherwise) and nothing else, so a payout stays readable after payouts are switched off. `payoutId` that is not a UUID answers `400`. The bank ramp is not called. ## Endpoint ### `GET /v1/ramp/bank/payout/payouts/{payoutId}` Authentication: bearer token required. Returns one of the user's payouts paid from their own wallet. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `payoutId` | string | yes | The payout's id (UUID) | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `isTestnet` | boolean | no | `true` for the bank ramp's sandbox; `false` or omitted for production | **Responses** `200` Outcome unknown ```json { "payoutId": "6b1f0d24-9c3a-4f18-b7e5-2a8c4d6e0f13", "environment": "sandbox", "status": "pending", "quoteId": "3f6c2a1e-8b4d-4c7a-9e2f-5d1b0a9c8e7f", "network": "PolygonTestAmoy", "cryptoCurrency": "USDC_TEST", "sourceAddress": "", "deposit": { "address": null, "amount": "10.3", "amountUnits": "10300000", "tokenAddress": "0xae1D7d8B36E9AbA7D95A75c69d50b38E7e02A9DD", "decimals": 6, "chainId": 80002, "expiresAt": "2026-09-17T13:00:00.000Z", "uri": null }, "cryptoAuthorizedAmount": "10.506", "cryptoAuthorizedAmountUnits": "10506000", "fiatCurrency": "EUR", "fiatAmount": "9.5", "rate": "0.95", "totalFee": "0.285", "quote": { "expiresAt": "2026-09-17T12:30:00.000Z" }, "createdAt": "2026-09-17T12:02:00.000Z", "updatedAt": "2026-09-17T12:02:00.000Z" } ``` `404` Not the user's ```json { "statusCode": 404, "error": "NotFoundError", "message": "No payout with this id for you in sandbox.", "details": { "code": "NOAH_PAYOUT_NOT_FOUND", "environment": "sandbox" } } ``` Web version: https://docs.aureahub.com/#payout-wallet-get --- # Initiate Fiat Payout Create a hosted payout in the bank ramp's sandbox: the user picks a bank account on the hosted page and receives fiat for the crypto amount you specify. Production payouts are currently refused. ## Overview Aurea asks the bank ramp for the sell channel `cryptoCurrency` → `fiatCurrency`, computes the fiat amount at the channel's rate, checks the channel's minimum and maximum, and creates a hosted payout session. Open `checkoutUrl`: the user selects or adds a bank account and confirms in the bank ramp's interface. The request contains no IBAN. - The user must be KYC-approved — the same bank ramp onboarding used for pay-in. - `cryptoCurrency` must have a sandbox payout pair in [Currencies & Networks](https://docs.aureahub.com/docs/bank-currencies.md): today `EURC_TEST`, `PYUSD_TEST`, `USDC_TEST` or `USDG_TEST`. An asset code such as `EURC` is read as `EURC_TEST`. Any other currency answers `400` `NOAH_PAIR_UNAVAILABLE` before anything is read or sent. - **Sandbox only.** Send `isTestnet: true` and Aurea uses the bank ramp's sandbox. A production request (`isTestnet` `false` or omitted) is refused with `403` `PAYOUT_PRODUCTION_UNAVAILABLE` before anything is read or sent: this hosted payout is funded from the bank ramp balance Aurea keeps for your tenant, not from the user's wallet. - **Payouts must be switched on** for your tenant in the sandbox, and `cryptoCurrency` offered for payout ([Tenant Settings](https://docs.aureahub.com/docs/bank-settings.md)): otherwise `403` `NOAH_FUNCTION_OFF`, or `400` `NOAH_PAIR_UNAVAILABLE` naming the currencies your tenant offers. Both are checked after the production refusal, before anything is sent, and before an `Idempotency-Key` is replayed. A tenant that does not use the bank ramp gets `403`. - In the sandbox, Aurea checks that balance first and lets the bank ramp take up to 2% more than `cryptoAmount` (capped at the balance) to absorb rate movements. - `returnUrl` may be an `https://` URL or an app deep link: the hosted page returns the user to Aurea, which redirects them to your `returnUrl` unchanged. It must be one your tenant allows: once the Aurea operator has added your tenant's return URLs ([Bank Ramp Setup](https://docs.aureahub.com/docs/bank-setup.md)), any other answers `400` `RETURN_URL_NOT_ALLOWED` before anything is read or sent. A tenant with no return URLs yet is not checked. - **Retries.** Send an `Idempotency-Key` header to make a retry safe: the same key with the same body answers with the first answer, and the response header `idempotency-replayed` is `true`. See [Idempotency](https://docs.aureahub.com/docs/idempotency.md). A retry after a failed attempt sends the bank ramp the request of the first attempt again, so the bank ramp does not see a second payout. - Follow the payout with [Get Payout Transaction](https://docs.aureahub.com/docs/payout-get.md). The user gets a push notification when it completes or fails. ## Endpoint ### `POST /v1/ramp/bank/payout/initiate` Authentication: bearer token required. Creates a hosted payout session in the bank ramp's sandbox and returns the URL where the user completes it. Production requests return 403. **Headers** | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | no | Optional. Up to 255 letters, digits, - or _. The same key with the same body answers with the first answer instead of doing it again; see [Idempotency](https://docs.aureahub.com/docs/idempotency.md) | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `cryptoCurrency` | string | yes | Crypto to sell: a currency with a payout pair in Currencies & Networks, e.g. USDC_TEST (an asset code such as USDC is read as USDC_TEST) | | `cryptoAmount` | string | yes | Integer string in the token's smallest unit, with the decimals Currencies & Networks lists (6 for every current pair): "25000000" = 25 | | `fiatCurrency` | string | yes | Uppercase ISO 4217 code, e.g. EUR | | `returnUrl` | string | yes | Where the user lands afterwards: https URL or app deep link | | `isTestnet` | boolean | no | Must be true (bank ramp sandbox). false or omitted is refused with 403 | **Responses** `201` Created ```json { "payoutId": "e8b2d4f6-1a3c-4e5b-8d7f-9c0a2b4d6e81", "checkoutUrl": "", "fiatAmount": "23.12", "fiatCurrency": "EUR", "exchangeRate": "0.9248", "expiresAt": "2026-09-11T10:30:00.000Z" } ``` `403` Production ```json { "statusCode": 403, "error": "ForbiddenError", "message": "Bank payouts are temporarily unavailable. No funds have been moved.", "details": { "code": "PAYOUT_PRODUCTION_UNAVAILABLE" } } ``` `403` Payouts switched off ```json { "statusCode": 403, "error": "ForbiddenError", "message": "The bank ramp's payout is not switched on for this tenant in sandbox.", "details": { "code": "NOAH_FUNCTION_OFF", "function": "payout", "environment": "sandbox" } } ``` `400` KYC required ```json { "statusCode": 400, "error": "BadRequestError", "message": "KYC verification required before initiating a payout. Current KYC status: pending.", "details": { "kycStatus": "pending", "onboardingStatus": "pending" } } ``` Other `400` errors, all with a descriptive `message` and, where useful, `details`: - Return URL your tenant doesn't allow: `RETURN_URL_NOT_ALLOWED`. - Currency without a sandbox payout pair: `NOAH_PAIR_UNAVAILABLE`, e.g. `USDT_TEST payouts are not available with Noah sandbox. Available: EURC_TEST, PYUSD_TEST, USDC_TEST, USDG_TEST.` - User never onboarded: `You must complete onboarding before initiating a payout. Please use the onboarding flow first.` - No the bank ramp channel for the pair: `No payout channel available for USDC → EUR…` - Amount outside the channel limits: `Payout amount … is below the minimum of …` / `… exceeds the maximum of …` (details include the limit). - Balance too low: `Insufficient platform balance to process this payout…` `expiresAt` is `null` when the bank ramp doesn't return an expiry. ## Amounts `cryptoAmount` is an integer string in the token's smallest unit, and Aurea converts it with the currency's `decimals` from [Currencies & Networks](https://docs.aureahub.com/docs/bank-currencies.md) — 6 for every pair enabled today, so `"25000000"` means 25. The balance check and the 2% cap are computed in that smallest unit, without rounding. `fiatAmount` in the response is a decimal string with 2 decimals (crypto amount × channel rate), and `exchangeRate` is the channel rate as a string. ## Implementation ```javascript // "25" -> "25000000", "12.5" -> "12500000" (6 decimals, extra digits truncated) function toSixDecimals(amount) { const [whole, frac = ''] = String(amount).split('.'); return BigInt(whole + frac.padEnd(6, '0').slice(0, 6)).toString(); } // idempotencyKey: made once for this payout (e.g. crypto.randomUUID()) and sent again on every retry async function startPayout(token, amountUsdc, idempotencyKey) { const res = await fetch('https://api.aureahub.com/v1/ramp/bank/payout/initiate', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, 'Idempotency-Key': idempotencyKey }, body: JSON.stringify({ cryptoCurrency: 'USDC_TEST', cryptoAmount: toSixDecimals(amountUsdc), fiatCurrency: 'EUR', returnUrl: 'myapp://payout/done', isTestnet: true // production payouts are refused with 403 }) }); const data = await res.json(); if (res.status === 403 && data.details?.code === 'PAYOUT_PRODUCTION_UNAVAILABLE') { return showPayoutsUnavailable(); } if (!res.ok) throw new Error(data.message); // e.g. below the channel minimum savePayoutId(data.payoutId); if (await confirmQuote(`You will receive ${data.fiatAmount} ${data.fiatCurrency}`)) { window.location.href = data.checkoutUrl; // or open it in an in-app browser } } ``` Web version: https://docs.aureahub.com/#payout-initiate --- # List Payout Transactions Retrieve the authenticated user's fiat payouts, page by page. ## Overview Returns the payouts created with [Initiate Payout](https://docs.aureahub.com/docs/payout-initiate.md) in one environment: pass `isTestnet=true` for sandbox payouts; without it, or with `false`, you get production payouts. Pagination uses `page` and `limit`; there is no status filter, so filter on your side. - `status`: `pending`, `processing`, `completed`, `failed` or `cancelled`. - `cryptoAuthorizedAmount` is the maximum the bank ramp may take, in smallest units (6 decimals): the requested amount plus up to 2%. - `fiatAmount` and `exchangeRate` are the values quoted when the payout was created. - `returnUrl` is the Aurea return address given to the hosted page, not your app's `returnUrl`. ## Endpoint ### `GET /v1/ramp/bank/payout/transactions` Authentication: bearer token required. Returns a page of the authenticated user's payout records in one environment. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `page` | integer | no | Page number (default 1) | | `limit` | integer | no | Page size (default 20) | | `isTestnet` | boolean | no | `true` for sandbox payouts; `false` or omitted for production. Any other value returns `400`. | **Responses** `200` OK ```json { "payouts": [ { "id": "e8b2d4f6-1a3c-4e5b-8d7f-9c0a2b4d6e81", "externalId": "4b7d9f1a-2c3e-4a5b-8c6d-7e8f9a0b1c2d", "checkoutUrl": "", "checkoutExpiresAt": "2026-09-11T10:30:00.000Z", "cryptoCurrency": "USDC", "cryptoAuthorizedAmount": "25500000", "fiatCurrency": "EUR", "fiatAmount": "23.12", "exchangeRate": "0.9248", "status": "completed", "noahTransactionId": "", "returnUrl": "https://api.aureahub.com/v1/ramp/bank/payout/return?session=4b7d9f1a-2c3e-4a5b-8c6d-7e8f9a0b1c2d", "createdAt": "2026-09-11T10:00:00.000Z", "updatedAt": "2026-09-11T10:20:00.000Z" } ], "total": 1, "page": 1, "limit": 20, "totalPages": 1 } ``` ## Implementation ```javascript async function getPayoutHistory(token, { page = 1, sandbox = false } = {}) { const params = new URLSearchParams({ page: String(page), limit: '20', isTestnet: String(sandbox) }); const res = await fetch(`https://api.aureahub.com/v1/ramp/bank/payout/transactions?${params}`, { headers: { Authorization: `Bearer ${token}` } }); if (!res.ok) throw new Error(`Payout list failed: ${res.status}`); const { payouts, totalPages } = await res.json(); return { payouts, inProgress: payouts.filter(p => p.status === 'pending' || p.status === 'processing'), hasMore: page < totalPages }; } ``` Web version: https://docs.aureahub.com/#payout-list --- # Get Payout Transaction Fetch one of the user's payouts and its current status. ## Overview Use the `payoutId` returned by [Initiate Payout](https://docs.aureahub.com/docs/payout-initiate.md). Only the user who created the payout can read it; anyone else gets `404`. The fields are the same as in [List Payout Transactions](https://docs.aureahub.com/docs/payout-list.md). Pass the `isTestnet` value the payout was created with: a sandbox payout is found only with `isTestnet=true`, a production payout only without it (or with `false`). Asking in the other environment answers `404`. The status changes as the bank ramp reports the payout to Aurea: `pending` → `processing` → `completed` or `failed`, and never back. ## Endpoint ### `GET /v1/ramp/bank/payout/transactions/{id}` Authentication: bearer token required. Returns a single payout record owned by the authenticated user, in one environment. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Payout UUID (payoutId) | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `isTestnet` | boolean | no | `true` for a sandbox payout; `false` or omitted for production. Any other value returns `400`. | **Responses** `200` OK ```json { "id": "e8b2d4f6-1a3c-4e5b-8d7f-9c0a2b4d6e81", "externalId": "4b7d9f1a-2c3e-4a5b-8c6d-7e8f9a0b1c2d", "checkoutUrl": "", "checkoutExpiresAt": "2026-09-11T10:30:00.000Z", "cryptoCurrency": "USDC", "cryptoAuthorizedAmount": "25500000", "fiatCurrency": "EUR", "fiatAmount": "23.12", "exchangeRate": "0.9248", "status": "processing", "noahTransactionId": "", "returnUrl": "https://api.aureahub.com/v1/ramp/bank/payout/return?session=4b7d9f1a-2c3e-4a5b-8c6d-7e8f9a0b1c2d", "createdAt": "2026-09-11T10:00:00.000Z", "updatedAt": "2026-09-11T10:05:00.000Z" } ``` `404` Not Found ```json { "statusCode": 404, "error": "NotFoundError", "message": "Payout not found" } ``` ## Implementation ```javascript async function getPayout(token, payoutId, { sandbox = false } = {}) { const res = await fetch(`https://api.aureahub.com/v1/ramp/bank/payout/transactions/${payoutId}?isTestnet=${sandbox}`, { headers: { Authorization: `Bearer ${token}` } }); if (res.status === 404) throw new Error('Payout not found'); if (!res.ok) throw new Error(`Get payout failed: ${res.status}`); return res.json(); } ``` Web version: https://docs.aureahub.com/#payout-get --- # Onramp Configuration Whether the user's tenant offers card-to-crypto in an environment, and what the app needs to offer it. ## Overview - `switchedOn` is true only when the Aurea operator switched the onramp on for your tenant in that environment **and** connected it there. When it is false, hide the onramp: `publishableKey` and `defaultSourceCurrency` are `null` and `pairs` is empty. - `publishableKey` loads the payment widget (`StripeOnramp(publishableKey)`). - `provenAddresses`: whether a user may also receive at an address they proved ([Address Challenge](https://docs.aureahub.com/docs/card-onramp-address-challenge.md)); false while the onramp is off. - Each pair carries `aureaChain`, the name Aurea's wallets and `/v1/tokens/meta/chains` use, and `family` (`evm` or `solana`), so the app can pick the user's wallet for it, and `sourceCurrencies`, the fiat currencies it is sold in for that environment (see [Pairs and regions](https://docs.aureahub.com/docs/guide-card-onramp.md)): open the session in one of them. - Answers `403` to a token without a tenant. ## Endpoint ### `GET /v1/ramp/card/config` Authentication: bearer token required. What the user's tenant offers through the card onramp in one environment. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `isTestnet` | boolean | no | `true` for the sandbox (the payment provider test mode); `false` or omitted for production | **Responses** `200` Switched on ```json { "environment": "sandbox", "switchedOn": true, "publishableKey": "pk_test_51QAbCdEfGhIjKlMnOpQrStUv", "defaultSourceCurrency": "eur", "provenAddresses": false, "pairs": [ { "name": "usdc/ethereum", "currency": "usdc", "network": "ethereum", "aureaChain": "ethereum", "family": "evm", "sourceCurrencies": ["eur", "usd"] }, { "name": "usdc/solana", "currency": "usdc", "network": "solana", "aureaChain": "solana", "family": "solana", "sourceCurrencies": ["usd"] } ] } ``` `200` Switched off ```json { "environment": "sandbox", "switchedOn": false, "publishableKey": null, "defaultSourceCurrency": null, "provenAddresses": false, "pairs": [] } ``` Web version: https://docs.aureahub.com/#card-onramp-config --- # Onramp Quotes the price for the pairs your tenant offers, before the widget opens. ## Overview - The payment provider's estimate of how much crypto an amount buys, with its transaction fee and the network fee, as the payment provider writes them. The widget may show a slightly different price if the quote has expired by then. - Without `pair`, every pair your tenant offers; with it, that one only. Without `sourceAmount`, the payment provider quotes 100. - Where your tenant does not offer the onramp in that environment: `403` `CARD_ONRAMP_OFF` (see [Card to Crypto](https://docs.aureahub.com/docs/guide-card-onramp.md)). A pair your tenant does not offer: `400` `CARD_ONRAMP_PAIR_UNAVAILABLE`. A pair named that is not sold in the currency asked: `400` `CARD_ONRAMP_PAIR_NOT_SOLD_IN_CURRENCY`; without `pair`, the pairs not sold in that currency are simply left out. An amount with more than 2 decimals: `400` `CARD_ONRAMP_AMOUNT_INVALID`. ## Endpoint ### `GET /v1/ramp/card/quotes` Authentication: bearer token required. the payment provider's quotes for the pairs the tenant offers. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `isTestnet` | boolean | no | `true` for the sandbox | | `pair` | string | no | One pair, for example `usdc/ethereum` | | `sourceCurrency` | string | no | `eur` or `usd`; the tenant's default otherwise | | `sourceAmount` | string | no | Fiat, at most 2 decimals | **Responses** `200` OK ```json { "environment": "sandbox", "sourceCurrency": "eur", "sourceAmount": "100.00", "quotes": [ { "pair": "usdc/ethereum", "destinationCurrency": "usdc", "destinationNetwork": "ethereum", "destinationAmount": "100.000000", "networkFee": "3.76", "transactionFee": "4.11", "sourceTotalAmount": "107.87" } ] } ``` Web version: https://docs.aureahub.com/#card-onramp-quotes --- # Open an Onramp Session A card onramp session that delivers crypto to one of the user's wallets, and the client secret that opens the payment widget. ## Overview - `walletId` names one of the user's wallets — or `addressId` an address the user proved, where your tenant allows it; exactly one of the two (`400` `CARD_ONRAMP_DESTINATION_INVALID`). Aurea reads the address. The wallet must be one whose key Aurea holds or has seen proven (a watch-only import is refused), an EVM wallet for an EVM pair and a Solana wallet for a Solana pair. The session is **locked to that address and network** — see [Where the crypto goes](https://docs.aureahub.com/docs/guide-card-onramp.md). - `sourceAmount` (fiat, at most 2 decimals) or `destinationAmount` (crypto, at most the token's decimals), never both, only suggests an amount: the user can change it in the widget. - Aurea passes this request's IP address to the payment provider, which decides from it whether it can serve the user: call it from the user's device, not from your server. - With an `Idempotency-Key`, the same key and request answer `200` with the same session, its client secret and `idempotency-replayed: true`, and the payment provider is not asked for a second one; a session still `creating` is asked again with the same key at the payment provider. The same key with another request answers `409` `IDEMPOTENCY_KEY_REUSED`; a key whose session the payment provider refused, `409` `CARD_ONRAMP_SESSION_FAILED`. The key is looked up only after every other check has passed. - Refusals before the payment provider is called, when nothing is stored: `403` `CARD_ONRAMP_OFF` where your tenant does not offer the onramp in that environment; `400` `CARD_ONRAMP_PAIR_UNAVAILABLE` (with `details.available`), `CARD_ONRAMP_PAIR_NOT_SOLD_IN_CURRENCY` (with `details.sourceCurrencies`), `CARD_ONRAMP_DESTINATION_NOT_ALLOWED`, `CARD_ONRAMP_AMOUNT_INVALID`, `IDEMPOTENCY_KEY_INVALID`; `404` `CARD_ONRAMP_WALLET_NOT_FOUND`. - The payment provider's refusals, with the payment provider's status, code and request id in `details`: `403` `CARD_ONRAMP_CUSTOMER_UNSUPPORTED` (the payment provider cannot serve this user), `400` `CARD_ONRAMP_INVALID_REQUEST`, `503` `CARD_ONRAMP_DISABLED`, `CARD_ONRAMP_MERCHANT_NOT_SET_UP`, `CARD_ONRAMP_AUTHENTICATION_FAILED` or `CARD_ONRAMP_RATE_LIMITED`, `502` when the payment provider cannot be reached. The session is then `failed`, except after no answer or a rate limit, when it stays `creating`. ## Endpoint ### `POST /v1/ramp/card/sessions` Authentication: bearer token required. Opens a card onramp session for one of the user's wallets. **Headers** | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | no | Up to 255 letters, digits, `-` or `_`; see above | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `walletId` | string (uuid) | no | One of the user's wallets — or addressId instead | | `addressId` | string (uuid) | no | An address the user proved (403 `CARD_ONRAMP_PROVEN_ADDRESSES_OFF` where your tenant does not allow it, 404 `CARD_ONRAMP_ADDRESS_NOT_FOUND` once revoked) — or walletId instead | | `pair` | string | yes | `currency/network`, one of the configuration's pairs | | `isTestnet` | boolean | no | `true` for the sandbox | | `sourceCurrency` | string | no | `eur` or `usd`; the tenant's default otherwise | | `sourceAmount` | string | no | Suggested fiat amount | | `destinationAmount` | string | no | Suggested crypto amount | **Responses** `201` Created ```json { "session": { "id": "444417bc-0675-4d3a-8831-bd1cbedc9738", "environment": "sandbox", "status": "initialized", "providerStatus": "initialized", "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": null, "networkFee": null, "transactionFee": null }, "transactionId": null, "failureCode": null, "createdAt": "2026-09-24T14:30:00.274Z", "updatedAt": "2026-09-24T14:30:00.289Z" }, "clientSecret": "cos_1QAbCdEfGhIjKlMn_secret_Xy9ZkLmNoPqRsTuVwXyZ01", "publishableKey": "pk_test_51QAbCdEfGhIjKlMnOpQrStUv" } ``` `200` Replayed ```json { "session": { "id": "444417bc-0675-4d3a-8831-bd1cbedc9738", "environment": "sandbox", "status": "initialized", "providerStatus": "initialized", "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": null, "networkFee": null, "transactionFee": null }, "transactionId": null, "failureCode": null, "createdAt": "2026-09-24T14:30:00.274Z", "updatedAt": "2026-09-24T14:30:00.309Z" }, "clientSecret": "cos_1QAbCdEfGhIjKlMn_secret_Xy9ZkLmNoPqRsTuVwXyZ01", "publishableKey": "pk_test_51QAbCdEfGhIjKlMnOpQrStUv" } ``` `403` Onramp off ```json { "statusCode": 403, "error": "ForbiddenError", "message": "The card onramp is not switched on for this tenant in sandbox.", "details": { "code": "CARD_ONRAMP_OFF", "environment": "sandbox" } } ``` `400` Pair not offered ```json { "statusCode": 400, "error": "BadRequestError", "message": "This tenant does not offer usdc/base through the onramp in sandbox.", "details": { "code": "CARD_ONRAMP_PAIR_UNAVAILABLE", "available": [ "usdc/ethereum", "usdc/solana" ] } } ``` `403` the payment provider cannot serve the user ```json { "statusCode": 403, "error": "StripeOnrampError", "message": "The card onramp cannot be offered to this customer", "details": { "code": "CARD_ONRAMP_CUSTOMER_UNSUPPORTED", "providerStatus": 400, "providerCode": "crypto_onramp_unsupportable_customer", "providerParam": "customer_ip_address", "providerRequestId": "req_8Kd02LmQpXyZ01" } } ``` `409` Key reused ```json { "statusCode": 409, "error": "ConflictError", "message": "This Idempotency-Key was sent with another request", "details": { "code": "IDEMPOTENCY_KEY_REUSED" } } ``` Web version: https://docs.aureahub.com/#card-onramp-create --- # Get an Onramp Session One of the user's sessions, as the payment provider sees it now. ## Overview - While the session is not final, Aurea reads it again from the payment provider and applies the answer; its status only moves forward (see [Statuses](https://docs.aureahub.com/docs/guide-card-onramp.md)). - While it is `initialized` or `requires_payment` the answer carries `clientSecret` and `publishableKey`, so the app can open the widget again where the user left it; otherwise both are `null`. - Once `fulfillment_complete`, `transactionId` is the on-chain hash of the delivery and `amounts` what the payment provider charged and delivered, in `amounts.sourceCurrency` (the currency the payment provider charged in: the user can switch it in the widget). - Answers whatever your tenant's settings: switching the onramp off stops new sessions, not the reading of existing ones. `404` `CARD_ONRAMP_SESSION_NOT_FOUND` for a session that is not the user's. ## Endpoint ### `GET /v1/ramp/card/sessions/{id}` Authentication: bearer token required. One of the user's onramp sessions. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | The session's `id` | **Responses** `200` Delivered ```json { "session": { "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": "45.980000", "networkFee": "1.92", "transactionFee": "2.10" }, "transactionId": "0x5c0e9b2f3a1d4c6e8f7a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e", "failureCode": null, "createdAt": "2026-09-24T14:30:00.274Z", "updatedAt": "2026-09-24T14:30:00.333Z" }, "clientSecret": null, "publishableKey": null } ``` Web version: https://docs.aureahub.com/#card-onramp-get --- # List Onramp Sessions The user's sessions in one environment, newest first. ## Overview - As stored — the payment provider is not asked. [Read one session](https://docs.aureahub.com/docs/card-onramp-get.md) to refresh it. - No client secret is answered here. ## Endpoint ### `GET /v1/ramp/card/sessions` Authentication: bearer token required. The user's onramp sessions in one environment. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `isTestnet` | boolean | no | `true` for the sandbox | | `limit` | integer | no | 1–100, default 20 | | `offset` | integer | no | From 0 | **Responses** `200` OK ```json { "sessions": [ { "id": "6c91c08f-0568-4a54-8fdd-dd8e99baed9e", "environment": "sandbox", "status": "failed", "providerStatus": null, "providerSessionId": null, "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": null, "destinationAmount": null }, "amounts": { "sourceCurrency": "eur", "sourceAmount": null, "destinationAmount": null, "networkFee": null, "transactionFee": null }, "transactionId": null, "failureCode": "CARD_ONRAMP_CUSTOMER_UNSUPPORTED", "createdAt": "2026-09-24T14:30:00.320Z", "updatedAt": "2026-09-24T14:30:00.326Z" } ], "total": 2 } ``` Web version: https://docs.aureahub.com/#card-onramp-list --- # Hosted Page Link A link to Aurea's hosted page for one of the user's open sessions, for an app with no web page of its own. ## Overview - The session must be the user's (`404` `CARD_ONRAMP_SESSION_NOT_FOUND`) and still payable — `initialized` or `requires_payment` as the payment provider has it now — else `409` `CARD_ONRAMP_SESSION_CLOSED` with `details.status`. - `returnUrl` must be one of your tenant's return URLs, a deep link or an https origin the Aurea operator allowed: `400` `RETURN_URL_NOT_ALLOWED` otherwise, and always while your tenant allows none. Without it, the page tells the user to close it when the session ends. - `url` works for 30 minutes; its token is in the fragment. Each call makes a new link. See [Card to Crypto](https://docs.aureahub.com/docs/guide-card-onramp.md), Hosted page. - The body is checked as sent: an unknown field, locale or theme answers `400`. ## Endpoint ### `POST /v1/ramp/card/sessions/{id}/hosted-link` Authentication: bearer token required. A link to the hosted page for one of the user's open sessions. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | The session's `id` | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `returnUrl` | string | no | Where to send the browser back: a deep link or an https URL your tenant allows | | `locale` | string | no | `en` (default) or `it` | | `theme` | string | no | `light` (default) or `dark` | **Responses** `201` Created ```json { "url": "https://api.aureahub.com/v1/ramp/card/hosted#fse17-3QkR0t56XcLd_a2JH9CldWnE1bVXPtdoMS1BU", "expiresAt": "2026-09-24T15:40:10.912Z" } ``` `409` No longer payable ```json { "statusCode": 409, "error": "ConflictError", "message": "This session can no longer be paid: only an initialized session, or one waiting for its payment, is opened in the hosted page.", "details": { "code": "CARD_ONRAMP_SESSION_CLOSED", "status": "fulfillment_processing" } } ``` `400` Return URL not allowed ```json { "statusCode": 400, "error": "BadRequestError", "message": "This return URL is not one the tenant allows. Ask the Aurea operator to add it.", "details": { "code": "RETURN_URL_NOT_ALLOWED" } } ``` Web version: https://docs.aureahub.com/#card-onramp-hosted-link --- # Hosted Page Read What the hosted page reads with its link's token. The page calls it itself; your app never needs to. ## Overview - Answers the session's status (refreshed from the payment provider while not final) with what the page shows the user — `pair` (`currency`, `network`), `destination` (`kind`, `address`), `requested`, `amounts` (in `amounts.sourceCurrency`, the currency the payment provider charges in) and `transactionId`, never an internal id — the client secret and publishable key while it can be paid, the return URL, the page's locale and theme, and your tenant's `name`, `logoUrl` and `primaryColor` (`null` when unset or not a `#rrggbb` value). - `404` `CARD_ONRAMP_LINK_NOT_FOUND` for a token no link has, `410` `CARD_ONRAMP_LINK_EXPIRED` after 30 minutes. No authentication: the token is the credential. At most 30 calls a minute from one address; `Cache-Control: no-store`. ## Endpoint ### `POST /v1/ramp/card/hosted/session` Authentication: none (public endpoint). What the hosted page needs, for its link's token. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `token` | string | yes | The token from the link's fragment | **Responses** `200` OK ```json { "session": { "id": "444417bc-0675-4d3a-8831-bd1cbedc9738", "status": "requires_payment", "environment": "sandbox", "pair": { "currency": "usdc", "network": "ethereum" }, "destination": { "kind": "proven_address", "address": "0x973Eb7c2BCae2FECBc012F1762AddB30143eA62e" }, "requested": { "sourceCurrency": "eur", "sourceAmount": "50.00", "destinationAmount": null }, "amounts": { "sourceCurrency": "eur", "sourceAmount": "50.00", "destinationAmount": "56.573880" }, "transactionId": null }, "clientSecret": "cos_1QAbCdEfGhIjKlMn_secret_Xy9ZkLmNoPqRsTuVwXyZ01", "publishableKey": "pk_test_51QAbCdEfGhIjKlMnOpQrStUv", "returnUrl": "brandbank://onramp/done", "locale": "en", "theme": "light", "tenant": { "name": "Brand Bank", "logoUrl": "https://api.aureahub.com/uploads/logos/brand.png", "primaryColor": "#0A7C3E" } } ``` `410` Expired ```json { "statusCode": 410, "error": "GoneError", "message": "This link has expired", "details": { "code": "CARD_ONRAMP_LINK_EXPIRED" } } ``` Web version: https://docs.aureahub.com/#card-onramp-hosted-session --- # Address Challenge The message a user signs to prove they control an address the onramp may deliver to. ## Overview - Needs the onramp offered in that environment and your tenant's `provenAddresses` on: `403` `CARD_ONRAMP_OFF` or `CARD_ONRAMP_PROVEN_ADDRESSES_OFF`. - `message` is the exact text to sign, line breaks included. It names your tenant by its own name, the address and its kind, the environment, your tenant's and the user's ids, a nonce and when it expires — ten minutes after it was issued. - An EVM address is stored with its checksum: a mixed-case address with a wrong checksum, or the zero address, is refused; a Solana address must be 32 bytes in base58 (`400` `CARD_ONRAMP_ADDRESS_INVALID`). - A user holds at most five open challenges (`409` `CARD_ONRAMP_ADDRESS_CHALLENGE_LIMIT`, with `details.limit`). ## Endpoint ### `POST /v1/ramp/card/addresses/challenge` Authentication: bearer token required. A challenge for proving one address. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `isTestnet` | boolean | no | `true` for the sandbox | | `family` | string | yes | `evm` or `solana` | | `address` | string | yes | The address | **Responses** `201` Issued ```json { "challengeId": "0f6a2c1e-8b3d-4e5f-9a7b-1c2d3e4f5a6b", "family": "evm", "address": "0x52908400098527886E0F7030069857D2E4169EE7", "environment": "sandbox", "message": "Brand Bank asks you to prove that you control this address, to receive the crypto you buy by card.\nSigning this message moves no funds and costs nothing.\n\nAddress: 0x52908400098527886E0F7030069857D2E4169EE7\nAddress type: EVM\nEnvironment: sandbox\nTenant: 7c1e2d3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f\nUser: 5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d\nNonce: 3f9a…\nIssued at: 2026-09-24T15:00:00.000Z\nExpires at: 2026-09-24T15:10:00.000Z", "issuedAt": "2026-09-24T15:00:00.000Z", "expiresAt": "2026-09-24T15:10:00.000Z" } ``` `403` Not allowed ```json { "statusCode": 403, "error": "ForbiddenError", "message": "This tenant does not let the onramp deliver to an address you proved.", "details": { "code": "CARD_ONRAMP_PROVEN_ADDRESSES_OFF", "environment": "sandbox" } } ``` Web version: https://docs.aureahub.com/#card-onramp-address-challenge --- # Verify an Address Keep the address a signed challenge proves. ## Overview - EVM: the `personal_sign` (EIP-191) signature, 65 bytes in hex, from the address's key — the key controls the address on every EVM chain, so one proof serves every EVM pair. A smart account (EIP-1271) cannot prove an address this way. Solana: the ed25519 signature of the message's bytes, 64 bytes in base58. - `201` with the proven address; `200` with it when the user already held it. - A wrong signature: `400` `CARD_ONRAMP_ADDRESS_SIGNATURE_INVALID` with `details.reason` (`malformed` or `wrong_signer`) and `details.attemptsLeft`; after five, `CARD_ONRAMP_ADDRESS_CHALLENGE_BURNT`. `400` `CARD_ONRAMP_ADDRESS_CHALLENGE_EXPIRED`, `409` `CARD_ONRAMP_ADDRESS_CHALLENGE_USED`, `404` `CARD_ONRAMP_ADDRESS_CHALLENGE_NOT_FOUND` for a challenge that is not the user's. - `label` is the user's name for the address, up to 100 characters. ## Endpoint ### `POST /v1/ramp/card/addresses/verify` Authentication: bearer token required. Keeps the address a signed challenge proves. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `challengeId` | string (uuid) | yes | The challenge's id | | `signature` | string | yes | The signature over the challenge's message | | `label` | string \| null | no | The user's name for the address | **Responses** `201` Proven ```json { "id": "b7e1c2d3-4f5a-4b6c-8d7e-9f0a1b2c3d4e", "family": "evm", "address": "0x52908400098527886E0F7030069857D2E4169EE7", "label": "Ledger", "proof": "eip191", "environment": "sandbox", "verifiedAt": "2026-09-24T15:01:12.340Z" } ``` `400` Wrong signer ```json { "statusCode": 400, "error": "BadRequestError", "message": "The signature was not made with this address's key over the challenge's message.", "details": { "code": "CARD_ONRAMP_ADDRESS_SIGNATURE_INVALID", "reason": "wrong_signer", "attemptsLeft": 4 } } ``` Web version: https://docs.aureahub.com/#card-onramp-address-verify --- # Proven Addresses The addresses the user proved in one environment and still holds. ## Overview - Oldest first; answered whatever your tenant's settings, so a user always sees what they proved. ## Endpoint ### `GET /v1/ramp/card/addresses` Authentication: bearer token required. The user's proven addresses in one environment. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `isTestnet` | boolean | no | `true` for the sandbox | **Responses** `200` OK ```json { "environment": "sandbox", "addresses": [ { "id": "b7e1c2d3-4f5a-4b6c-8d7e-9f0a1b2c3d4e", "family": "evm", "address": "0x52908400098527886E0F7030069857D2E4169EE7", "label": "Ledger", "proof": "eip191", "environment": "sandbox", "verifiedAt": "2026-09-24T15:01:12.340Z" } ] } ``` Web version: https://docs.aureahub.com/#card-onramp-addresses --- # Revoke an Address Stop using an address the user proved. ## Overview - `204`. New sessions can no longer deliver to it; a session already opened for it is locked to it at the payment provider and still delivers there. - `404` `CARD_ONRAMP_ADDRESS_NOT_FOUND` when the user holds no such live address. To use it again, prove it again. ## Endpoint ### `DELETE /v1/ramp/card/addresses/{id}` Authentication: bearer token required. Revokes one of the user's proven addresses. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | The address's `id` | **Responses** `204` Revoked Web version: https://docs.aureahub.com/#card-onramp-address-revoke --- # Name an End User Make, or read, one of your end users by your own id. Optional: the wallet and purchase calls make the end user too. ## Overview - Answers `201` the first time an id is named, `200` afterwards, with the wallets you attested. - The end user has no Aurea credentials and never signs in: it exists so its purchases and events keep an owner. - `403` `CARD_ONRAMP_TENANT_WALLETS_OFF` until tenant wallets are switched on for your tenant. ## Endpoint ### `PUT /v1/ramp/card/customers/{externalId}` Authentication: signed by the tenant's server — headers `x-tenant-api-key`, `x-timestamp`, `x-signature` (see Authentication → Tenant signature). No user token. Makes or reads the end user. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `externalId` | string | yes | Your own id of the end user, 1 to 100 characters: letters, digits, dot, underscore, colon, at sign or hyphen | **Responses** `201` Created ```json { "customer": { "externalId": "user_8421", "createdAt": "2026-09-25T01:46:56.601Z", "wallets": [] } } ``` `403` Tenant wallets off ```json { "statusCode": 403, "error": "ForbiddenError", "message": "This tenant cannot open card purchases for its own wallets. Ask the Aurea operator to switch it on.", "details": { "code": "CARD_ONRAMP_TENANT_WALLETS_OFF" } } ``` Web version: https://docs.aureahub.com/#card-wallets-customer-put --- # Get an End User One of your end users and the wallets you attested for it, in both environments. ## Overview - `404` `CARD_ONRAMP_CUSTOMER_NOT_FOUND` for an id no call has named. ## Endpoint ### `GET /v1/ramp/card/customers/{externalId}` Authentication: signed by the tenant's server — headers `x-tenant-api-key`, `x-timestamp`, `x-signature` (see Authentication → Tenant signature). No user token. Reads the end user and its wallets. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `externalId` | string | yes | Your own id of the end user, 1 to 100 characters: letters, digits, dot, underscore, colon, at sign or hyphen | **Responses** `200` OK ```json { "customer": { "externalId": "user_8421", "createdAt": "2026-09-25T01:46:56.601Z", "wallets": [ { "environment": "sandbox", "family": "evm", "address": "0x2f8C1e4d6B3A9E0F7C5D2B1A8E6f4c3d0b9A7E51", "isDefault": true, "label": "Main wallet", "attestedAt": "2026-09-25T01:46:56.620Z" } ] } } ``` `404` Never named ```json { "statusCode": 404, "error": "NotFoundError", "message": "No call has named this end user", "details": { "code": "CARD_ONRAMP_CUSTOMER_NOT_FOUND" } } ``` Web version: https://docs.aureahub.com/#card-wallets-customer-get --- # Attest a Default Wallet The wallet a purchase of that family uses when it names no address, in one environment. ## Overview - Your signed call is your statement that the wallet serves that end user: the user signs nothing. - The wallet it replaces stays attested, no longer the default. The same address again answers `changed: false`; a new label is kept. - A mixed-case EVM address must carry a valid checksum; the zero address is refused (`400` `CARD_ONRAMP_ADDRESS_INVALID`). ## Endpoint ### `PUT /v1/ramp/card/customers/{externalId}/wallets/{family}` Authentication: signed by the tenant's server — headers `x-tenant-api-key`, `x-timestamp`, `x-signature` (see Authentication → Tenant signature). No user token. Attests the default wallet of one family. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `externalId` | string | yes | Your own id of the end user, 1 to 100 characters: letters, digits, dot, underscore, colon, at sign or hyphen | | `family` | string | yes | evm (every EVM pair) or solana | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `address` | string | yes | The wallet address | | `isTestnet` | boolean | no | true for the sandbox | | `label` | string | no | Your name for the wallet, at most 100 characters | **Responses** `200` Attested ```json { "wallet": { "environment": "sandbox", "family": "evm", "address": "0x2f8C1e4d6B3A9E0F7C5D2B1A8E6f4c3d0b9A7E51", "isDefault": true, "label": "Main wallet", "attestedAt": "2026-09-25T01:46:56.620Z" }, "changed": true } ``` Web version: https://docs.aureahub.com/#card-wallets-wallet-put --- # Revoke a Default Wallet Stop using the end user's default wallet of one family in one environment. ## Overview - A purchase already opened for it is locked to it and still delivers there; a new purchase then needs an address. - `404` `CARD_ONRAMP_WALLET_NOT_FOUND` when there is no default wallet of that family. ## Endpoint ### `DELETE /v1/ramp/card/customers/{externalId}/wallets/{family}` Authentication: signed by the tenant's server — headers `x-tenant-api-key`, `x-timestamp`, `x-signature` (see Authentication → Tenant signature). No user token. Revokes the default wallet of one family. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `externalId` | string | yes | Your own id of the end user, 1 to 100 characters: letters, digits, dot, underscore, colon, at sign or hyphen | | `family` | string | yes | evm (every EVM pair) or solana | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `isTestnet` | boolean | no | true for the sandbox | **Responses** `204` Revoked Web version: https://docs.aureahub.com/#card-wallets-wallet-delete --- # Open a Purchase A card purchase for your end user, to the wallet the call names or to its default wallet of the pair's family. ## Overview - `customerIp` is the end user's public IP address as your server saw it: the payment provider decides from it whether it can serve the user. A private, reserved or malformed address answers `400` `CARD_ONRAMP_CUSTOMER_IP_INVALID`. - With `address`, the wallet is attested by this call and kept, not made the default. Without it, the default wallet of the pair's family is used; `409` `CARD_ONRAMP_WALLET_MISSING` when there is none. - The answer carries `hostedLink` (send the user there, valid 30 minutes) and the client secret to embed the payment widget instead. - The amount only suggests one: the user can change it, and the currency, in the widget. The session is locked to the wallet and the network. - An `Idempotency-Key` answers `200` with the same session and a fresh link; the user's IP address is left out of the comparison. - Every refusal before the payment provider is called writes nothing. ## Endpoint ### `POST /v1/ramp/card/customers/{externalId}/sessions` Authentication: signed by the tenant's server — headers `x-tenant-api-key`, `x-timestamp`, `x-signature` (see Authentication → Tenant signature). No user token. Opens a card purchase for the end user. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `externalId` | string | yes | Your own id of the end user, 1 to 100 characters: letters, digits, dot, underscore, colon, at sign or hyphen | **Headers** | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | no | Up to 255 letters, digits, - or _; your order id for example | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `pair` | string | yes | currency/network, one of the pairs your tenant offers | | `customerIp` | string | yes | The end user public IPv4 or IPv6 address | | `isTestnet` | boolean | no | true for the sandbox | | `address` | string | no | The wallet of this purchase; the default wallet when omitted | | `sourceAmount` | string | no | Suggested fiat amount, at most 2 decimals | | `destinationAmount` | string | no | Suggested crypto amount, instead | | `sourceCurrency` | string | no | eur or usd | | `returnUrl` | string | no | Where the hosted page sends the user back; one your tenant allows | | `locale` | string | no | en or it | | `theme` | string | no | light or dark | **Responses** `201` Created ```json { "session": { "id": "1a9f1da0-40b0-4b90-b33c-ddf9d31922d1", "environment": "sandbox", "status": "initialized", "providerStatus": "initialized", "providerSessionId": "cos_test000001", "pair": { "name": "usdc/base", "currency": "usdc", "network": "base", "aureaChain": "base", "family": "evm", "sourceCurrencies": ["usd"] }, "destination": { "kind": "tenant_wallet", "walletId": null, "addressId": null, "address": "0x2f8C1e4d6B3A9E0F7C5D2B1A8E6f4c3d0b9A7E51" }, "customer": { "externalId": "user_8421" }, "requested": { "sourceCurrency": "usd", "sourceAmount": "50.00", "destinationAmount": null }, "amounts": { "sourceCurrency": "usd", "sourceAmount": "50.00", "destinationAmount": null, "networkFee": null, "transactionFee": null }, "transactionId": null, "failureCode": null, "createdAt": "2026-09-25T01:46:56.634Z", "updatedAt": "2026-09-25T01:46:56.663Z" }, "clientSecret": "cos_test000001_secret_standin1", "publishableKey": "pk_test_51StandInPublishable0000", "hostedLink": { "url": "https://api.aureahub.com/v1/ramp/card/hosted#UF5gTU_QTII1iZrx9S6LoMtntQydBcASR6xG8AzhE90", "expiresAt": "2026-09-25T02:16:56.670Z" } } ``` `409` No wallet ```json { "statusCode": 409, "error": "ConflictError", "message": "This end user has no default evm wallet in sandbox: name the wallet with address, or attest a default one first.", "details": { "code": "CARD_ONRAMP_WALLET_MISSING", "family": "evm", "environment": "sandbox" } } ``` `400` Not a public IP ```json { "statusCode": 400, "error": "BadRequestError", "message": "customerIp is the end user's public IPv4 or IPv6 address: the payment provider decides from it whether it can serve the user", "details": { "code": "CARD_ONRAMP_CUSTOMER_IP_INVALID" } } ``` Web version: https://docs.aureahub.com/#card-wallets-session-create --- # List Purchases The end user's purchases in one environment, newest first. ## Overview - As stored: the payment provider is not asked, and no client secret is answered. Read one purchase to refresh it. ## Endpoint ### `GET /v1/ramp/card/customers/{externalId}/sessions` Authentication: signed by the tenant's server — headers `x-tenant-api-key`, `x-timestamp`, `x-signature` (see Authentication → Tenant signature). No user token. Lists the end user's purchases. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `externalId` | string | yes | Your own id of the end user, 1 to 100 characters: letters, digits, dot, underscore, colon, at sign or hyphen | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `isTestnet` | boolean | no | true for the sandbox | | `limit` | integer | no | 1 to 100, 20 by default | | `offset` | integer | no | 0 by default | **Responses** `200` OK ```json { "sessions": [ { "id": "1a9f1da0-40b0-4b90-b33c-ddf9d31922d1", "environment": "sandbox", "status": "initialized", "providerStatus": "initialized", "providerSessionId": "cos_test000001", "pair": { "name": "usdc/base", "currency": "usdc", "network": "base", "aureaChain": "base", "family": "evm", "sourceCurrencies": ["usd"] }, "destination": { "kind": "tenant_wallet", "walletId": null, "addressId": null, "address": "0x2f8C1e4d6B3A9E0F7C5D2B1A8E6f4c3d0b9A7E51" }, "customer": { "externalId": "user_8421" }, "requested": { "sourceCurrency": "usd", "sourceAmount": "50.00", "destinationAmount": null }, "amounts": { "sourceCurrency": "usd", "sourceAmount": "50.00", "destinationAmount": null, "networkFee": null, "transactionFee": null }, "transactionId": null, "failureCode": null, "createdAt": "2026-09-25T01:46:56.634Z", "updatedAt": "2026-09-25T01:46:56.679Z" } ], "total": 1 } ``` Web version: https://docs.aureahub.com/#card-wallets-sessions-list --- # Get a Purchase One of the end user's purchases as it stands now. ## Overview - Read again from the payment provider while it is not final; its status only moves forward. - While it is initialized or requires_payment, the answer carries the client secret again. - `404` `CARD_ONRAMP_SESSION_NOT_FOUND` for a purchase that is not this end user's. ## Endpoint ### `GET /v1/ramp/card/customers/{externalId}/sessions/{id}` Authentication: signed by the tenant's server — headers `x-tenant-api-key`, `x-timestamp`, `x-signature` (see Authentication → Tenant signature). No user token. Reads one purchase. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `externalId` | string | yes | Your own id of the end user, 1 to 100 characters: letters, digits, dot, underscore, colon, at sign or hyphen | | `id` | string (uuid) | yes | The session id | **Responses** `200` OK ```json { "session": { "id": "1a9f1da0-40b0-4b90-b33c-ddf9d31922d1", "environment": "sandbox", "status": "initialized", "providerStatus": "initialized", "providerSessionId": "cos_test000001", "pair": { "name": "usdc/base", "currency": "usdc", "network": "base", "aureaChain": "base", "family": "evm", "sourceCurrencies": ["usd"] }, "destination": { "kind": "tenant_wallet", "walletId": null, "addressId": null, "address": "0x2f8C1e4d6B3A9E0F7C5D2B1A8E6f4c3d0b9A7E51" }, "customer": { "externalId": "user_8421" }, "requested": { "sourceCurrency": "usd", "sourceAmount": "50.00", "destinationAmount": null }, "amounts": { "sourceCurrency": "usd", "sourceAmount": "50.00", "destinationAmount": null, "networkFee": null, "transactionFee": null }, "transactionId": null, "failureCode": null, "createdAt": "2026-09-25T01:46:56.634Z", "updatedAt": "2026-09-25T01:46:56.679Z" }, "clientSecret": "cos_test000001_secret_standin1", "publishableKey": "pk_test_51StandInPublishable0000" } ``` Web version: https://docs.aureahub.com/#card-wallets-session-get --- # List Tenant Webhooks The webhooks that tell your server about bank ramp and card onramp events, one per environment, without their secrets. ## Overview - Lists the webhook of each environment that has one, production first, and the event types there are. How deliveries work: [Events to Your Server](https://docs.aureahub.com/docs/guide-events.md). - The secret is never returned. `secretHint` is the four characters before its final `=`, so you can tell which secret the webhook uses. - For an Aurea administrator, or the tenant's own administrator (role `tenantadmin`): any other token answers `403`. `404` when the tenant doesn't exist. ## Endpoint ### `GET /v1/admin/tenants/{id}/webhooks` Authentication: bearer token required. Lists the tenant's webhook of each environment, without secrets, and the event types. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Tenant UUID | **Responses** `200` OK ```json { "webhooks": [ { "id": "5c03a06e-55f0-425a-8562-bd7bae35825d", "environment": "production", "url": "https://hooks.example.com/aurea/events", "eventTypes": [], "status": "active", "secretHint": "q0Rk", "secretRotatedAt": "2026-09-17T09:02:11.030Z", "createdAt": "2026-09-17T09:02:11.030Z", "updatedAt": "2026-09-17T09:05:40.072Z" } ], "eventTypes": ["ramp.kyc.updated", "ramp.deposit.updated", "ramp.payout.updated", "ramp.transaction.updated", "ramp.card_session.updated"] } ``` `403` Another tenant ```json { "statusCode": 403, "error": "ForbiddenError", "message": "Admin access required or you can only access your own tenant." } ``` Web version: https://docs.aureahub.com/#tenant-webhooks-list --- # Save a Tenant Webhook Make or change the webhook of one environment. The secret is shown when the webhook is made, and never again. ## Overview - **`201` with `webhook` and `secret`** when the environment had no webhook. Store the secret now: it signs every delivery and is never shown again (see [Replace the Secret](https://docs.aureahub.com/docs/tenant-webhook-rotate.md)). - **`200` with `webhook`** when it changes an existing one. The secret stays, and so does the status when the body doesn't name one. - `url`: `https://` on the default port with a full public DNS name — the rules are in [Events to Your Server](https://docs.aureahub.com/docs/guide-events.md). `eventTypes`: the types it receives, at most five; `[]` receives all of them. A type named twice counts once, and the answer lists the types in a fixed order. `status`: `active` (the default for a new webhook) or `disabled`. - The body is checked as sent: a field that doesn't belong, or a string where a list goes, is refused rather than ignored or converted. - For an Aurea administrator, or the tenant's own administrator. Every save is written to the tenant's activity log with what it replaced, never with the secret. ## Endpoint ### `PUT /v1/admin/tenants/{id}/webhooks/{environment}` Authentication: bearer token required. Makes the environment's webhook (201, with the secret) or changes it (200). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Tenant UUID | | `environment` | string | yes | `production` or `sandbox` | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `url` | string | yes | Your server's address, up to 2048 characters | | `eventTypes` | string[] | yes | Any of `ramp.kyc.updated`, `ramp.deposit.updated`, `ramp.payout.updated`, `ramp.transaction.updated`, `ramp.card_session.updated`; empty for all | | `status` | string | no | `active` or `disabled`. Omitted: `active` for a new webhook, unchanged for an existing one | **Responses** `201` Made ```json { "webhook": { "id": "5c03a06e-55f0-425a-8562-bd7bae35825d", "environment": "production", "url": "https://hooks.example.com/aurea/events", "eventTypes": ["ramp.deposit.updated", "ramp.payout.updated"], "status": "active", "secretHint": "q0Rk", "secretRotatedAt": "2026-09-17T09:02:11.030Z", "createdAt": "2026-09-17T09:02:11.030Z", "updatedAt": "2026-09-17T09:02:11.030Z" }, "secret": "whsec_… (44 characters of base64, ending q0Rk=)" } ``` `200` Changed ```json { "webhook": { "id": "5c03a06e-55f0-425a-8562-bd7bae35825d", "environment": "production", "url": "https://hooks.example.com/aurea/events", "eventTypes": [], "status": "active", "secretHint": "q0Rk", "secretRotatedAt": "2026-09-17T09:02:11.030Z", "createdAt": "2026-09-17T09:02:11.030Z", "updatedAt": "2026-09-17T09:05:40.072Z" } } ``` `400` Refused ```json { "statusCode": 400, "error": "BadRequestError", "message": "The webhook URL must use https", "details": { "code": "NOAH_WEBHOOK_INVALID", "field": "url", "reason": "not_https" } } ``` The `secret` is `whsec_` followed by the base64 of 32 random bytes; the example above shortens it. ## Refusals Nothing is saved, and the answer is `400` with `details.code` `NOAH_WEBHOOK_INVALID` and the field at fault in `details.field`: - `url` an address Aurea won't call, with `details.reason`: `not_https`, `port`, `ip_address`, `local_host`, `host_name` (not a full DNS name), `credentials`, `fragment`, `whitespace_or_control`, `backslash`, `dot_segment` or `not_a_url`. An empty address, one longer than 2048 characters or one that isn't a string has `details.issues` instead of a reason. - `eventTypes`: an unknown type, more than four entries, or not a list. - `status`: neither `active` nor `disabled`. - `body`: a field that doesn't belong, for example `secret` — the API always makes the secret. - `environment`: neither `production` nor `sandbox`. On the other webhook endpoints such an environment answers `400` `Request validation failed`. With a field that doesn't match its type, `details.issues` lists each problem with its path. `details.code` `NOAH_WEBHOOK_CONFLICT` (`400`) means another request made the same environment's webhook at the same moment: send the change again. Web version: https://docs.aureahub.com/#tenant-webhook-save --- # Replace a Webhook's Secret A new secret for the webhook of one environment, shown once. It signs every delivery from now on. ## Overview - The old secret stops at once, for retries too. Deliveries your server refuses until it has the new secret are retried ([Events to Your Server](https://docs.aureahub.com/docs/guide-events.md)), so put it in place within a few minutes. - `secretHint` and `secretRotatedAt` change with it. The rest of the webhook stays as it is. - `404` with `details.code` `NOAH_WEBHOOK_NOT_FOUND` when the environment has no webhook. - For an Aurea administrator, or the tenant's own administrator. Written to the tenant's activity log with the new hint only. ## Endpoint ### `POST /v1/admin/tenants/{id}/webhooks/{environment}/rotate-secret` Authentication: bearer token required. Gives the environment's webhook a new secret and returns it once. No body. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Tenant UUID | | `environment` | string | yes | `production` or `sandbox` | **Responses** `200` Replaced ```json { "webhook": { "id": "5c03a06e-55f0-425a-8562-bd7bae35825d", "environment": "production", "url": "https://hooks.example.com/aurea/events", "eventTypes": [], "status": "active", "secretHint": "8vNw", "secretRotatedAt": "2026-09-17T11:30:02.202Z", "createdAt": "2026-09-17T09:02:11.030Z", "updatedAt": "2026-09-17T11:30:02.202Z" }, "secret": "whsec_… (44 characters of base64, ending 8vNw=)" } ``` `404` No webhook ```json { "statusCode": 404, "error": "NotFoundError", "message": "This tenant has no webhook in production", "details": { "code": "NOAH_WEBHOOK_NOT_FOUND" } } ``` Web version: https://docs.aureahub.com/#tenant-webhook-rotate --- # Send a Test Delivery One signed `webhook.test` to the webhook of one environment, right now, with what happened. ## Overview - Signed and sent like every delivery ([Events to Your Server](https://docs.aureahub.com/docs/guide-events.md)), also while the webhook is switched off. The body's `data` holds `tenantId`, `webhookId` and a `message`. - `delivered` is `true` when your server answered `2xx` within 10 seconds. `statusCode` is your server's answer, or `null` when there was none; `error` says why the delivery couldn't be made (for example an address that doesn't resolve or isn't public), or is `null`. - Never retried. At most **10 a minute** from one client address; more answers `429`. - `404` with `details.code` `NOAH_WEBHOOK_NOT_FOUND` when the environment has no webhook. For an Aurea administrator, or the tenant's own administrator; written to the tenant's activity log with the outcome. ## Endpoint ### `POST /v1/admin/tenants/{id}/webhooks/{environment}/test` Authentication: bearer token required. Sends a signed test delivery to the environment's webhook and reports the outcome. No body. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Tenant UUID | | `environment` | string | yes | `production` or `sandbox` | **Responses** `200` Delivered ```json { "id": "evt_4b7e19c0d25a8f63e1b9d07c5a2f8e14", "type": "webhook.test", "delivered": true, "statusCode": 204, "error": null, "durationMs": 142 } ``` `200` Not delivered ```json { "id": "evt_84708362734f456322930105a8f8f596", "type": "webhook.test", "delivered": false, "statusCode": null, "error": "getaddrinfo ENOTFOUND hooks.example.invalid", "durationMs": 1061 } ``` `429` Too many ```json { "statusCode": 429, "error": "Error", "message": "Rate limit exceeded, retry in 1 minute" } ``` Web version: https://docs.aureahub.com/#tenant-webhook-test --- # Remove a Tenant Webhook Stop telling your server about bank ramp and card onramp events in one environment. ## Overview - `204` with no body. The webhook and its secret are deleted, and deliveries still waiting are dropped. To stop only for a while, save it with `"status": "disabled"` instead ([Save a Webhook](https://docs.aureahub.com/docs/tenant-webhook-save.md)). - `404` with `details.code` `NOAH_WEBHOOK_NOT_FOUND` when the environment has no webhook. - For an Aurea administrator, or the tenant's own administrator. Written to the tenant's activity log with the address. ## Endpoint ### `DELETE /v1/admin/tenants/{id}/webhooks/{environment}` Authentication: bearer token required. Deletes the environment's webhook and its secret. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Tenant UUID | | `environment` | string | yes | `production` or `sandbox` | **Responses** `204` No Content `404` No webhook ```json { "statusCode": 404, "error": "NotFoundError", "message": "This tenant has no webhook in sandbox", "details": { "code": "NOAH_WEBHOOK_NOT_FOUND" } } ``` Web version: https://docs.aureahub.com/#tenant-webhook-remove --- # Unified Transaction Feed One paginated, date-sorted list of the user's blockchain transactions, bank ramp fiat activity and card purchases of crypto. ## Overview The feed combines six sources for the authenticated user: blockchain transaction records, bank ramp fiat deposits, the bank ramp pay-in transactions (those with a crypto amount), the bank ramp payouts, the bank ramp pay-in checkout sessions, and the crypto bought by card through the card onramp. Items are sorted by `sortDate`, newest first, then by `createdAt`. Each item's `type` tells you which fields it carries — see **Item Types**. To fetch one item later, use [Get Aggregated Transaction](https://docs.aureahub.com/docs/tx-aggregated-get.md). ## Endpoint ### `GET /v1/transactions/aggregated/` Authentication: bearer token required. Returns a paginated feed of blockchain and the bank ramp items for the authenticated user, newest first. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `page` | integer | no | Page number, starting at 1. Default `1`. | | `limit` | integer | no | Items per page, 1–100. Default `20`. | | `types` | string | no | Comma-separated item types to include, e.g. `blockchain_send,noah_payout`. Takes precedence over `type`. | | `type` | string | no | A single item type (older form of `types`). | | `payinTransactionType` | string | no | Restricts `noah_payin_transaction` items to this transaction type (for example `purchase`, `withdrawal`, `refund`). Other items are unaffected. | | `counterpartyAddress` | string | no | Only blockchain items where this address is the sender or recipient (case-insensitive exact match). The bank ramp items are excluded. | | `counterpartyUsername` | string | no | Only blockchain items whose sender or recipient username contains this text (case-insensitive). The bank ramp items are excluded. Ignored when `counterpartyAddress` is set. | | `isTestnet` | boolean | no | Default `false`. Selects testnet blockchain items and is also matched against the sandbox flag of the bank ramp items. | **Responses** `200` OK ```json { "data": [ { "id": "0b9d4c1e-3f6a-4b8e-9d2c-7a5e1f3b6c90", "type": "blockchain_send", "status": "confirmed", "sortDate": "2026-09-11T10:00:15.000Z", "createdAt": "2026-09-11T10:00:00.000Z", "chain": "gnosis", "txHash": "0x7f3a…", "fromAddress": "0x2f4B…", "toAddress": "0x52908400098527886E0F7030069857D2E4169EE7", "fromUsername": "alice", "toUsername": "bob", "value": "10000000000000000", "isTestnet": false, "blockTimestamp": "2026-09-11T10:00:15.000Z", "metadata": { "type": "send", "tokenSymbol": "xDAI", "tokenDecimals": 18 } }, { "id": "7c2e9a41-5b3d-4f10-8e6a-2d9c4b1f0e73", "type": "noah_payout", "status": "…", "sortDate": "2026-09-09T14:30:00.000Z", "createdAt": "2026-09-09T14:30:00.000Z", "fiatAmount": "…", "fiatCurrency": "…", "cryptoCurrency": "…", "cryptoAuthorizedAmount": "…", "exchangeRate": null, "noahTransactionId": null } ], "pagination": { "page": 1, "limit": 20, "total": 2, "totalPages": 1 } } ``` ## Item Types Every item has `id`, `type`, `status`, `sortDate` and `createdAt`. `status` is the status of the source record, so its values differ between types. | type | Additional fields | sortDate | | --- | --- | --- | | blockchain_send blockchain_receive blockchain_swap | `chain`, `txHash`, `fromAddress`, `toAddress`, `fromUsername`, `toUsername`, `value`, `isTestnet`, `blockTimestamp`, `metadata`. A transfer is a send when its sender is the wallet's own address and a receive otherwise; swap records are `blockchain_swap`. `value` is the amount stored on the record — the smallest-unit string for sends created with Create Transaction. | Block time, or `createdAt` | | noah_fiat_deposit | `fiatAmount`, `fiatCurrency`, `senderName`, `noahDepositId`, `depositDate` | Deposit date, or `createdAt` | | noah_payin_transaction | `transactionType`, `fiatAmount`, `fiatCurrency`, `cryptoAmount`, `cryptoCurrency`, `exchangeRate`, `blockchainTxHash`, `network`, `noahTransactionId` | Transaction date, or `createdAt` | | noah_payout | `fiatAmount`, `fiatCurrency`, `cryptoCurrency`, `cryptoAuthorizedAmount`, `exchangeRate`, `noahTransactionId` | `createdAt` | | noah_payin_checkout | `fiatAmount`, `fiatCurrency`, `cryptoCurrency`, `cryptoAmount`, `exchangeRate`, `externalId`, `checkoutUrl` | `createdAt` | | card_onramp_purchase | A card onramp session the user paid (`status` `fulfillment_processing` or `fulfillment_complete`); its `id` is the session's, which [Get a Session](https://docs.aureahub.com/docs/card-onramp-get.md) reads. `fiatAmount`, `fiatCurrency` (what the payment provider charged), `cryptoAmount`, `cryptoCurrency`, `network` (the payment provider's name), `chain` (Aurea's), `toAddress`, `blockchainTxHash` (once delivered), `isTestnet` | `createdAt` | ## Filters - `types` wins over `type`; both match the item `type` exactly. - `counterpartyAddress` and `counterpartyUsername` return blockchain items only. If both are sent, only `counterpartyAddress` is applied. - `payinTransactionType` only narrows `noah_payin_transaction` items; other items still appear. - `isTestnet` applies to all six sources: testnet flag for blockchain records, sandbox flag for the bank ramp and card onramp records. - There are no status, wallet or date-range filters. For wallet- or status-filtered blockchain records use [List Transactions](https://docs.aureahub.com/docs/tx-list.md). ## Implementation ```javascript async function getActivityPage(token, { page = 1, limit = 20, types } = {}) { const params = new URLSearchParams({ page: String(page), limit: String(limit) }); if (types) params.set('types', types.join(',')); const res = await fetch('https://api.aureahub.com/v1/transactions/aggregated/?' + params, { headers: { Authorization: 'Bearer ' + token }, }); if (!res.ok) throw new Error('Feed error: ' + res.status); return res.json(); // { data, pagination: { page, limit, total, totalPages } } } function describe(item) { switch (item.type) { case 'blockchain_send': return 'Sent on ' + item.chain; case 'blockchain_receive': return 'Received on ' + item.chain; case 'blockchain_swap': return 'Swap on ' + item.chain; case 'noah_fiat_deposit': return 'Fiat deposit ' + item.fiatAmount + ' ' + item.fiatCurrency; case 'noah_payin_transaction': return 'Pay-in (' + item.transactionType + ')'; case 'noah_payout': return 'Payout ' + item.fiatAmount + ' ' + item.fiatCurrency; case 'noah_payin_checkout': return 'Pay-in checkout'; default: return item.type; } } const { data, pagination } = await getActivityPage(token, { types: ['blockchain_send', 'blockchain_receive'], }); data.forEach(item => console.log(item.sortDate, describe(item), item.status)); ``` Web version: https://docs.aureahub.com/#agg-tx-list --- # Register Device Token Register a Firebase Cloud Messaging (FCM) token so the user's device receives Aurea's push notifications. ## Overview Aurea sends push notifications through **Firebase Cloud Messaging** — for example when a bank ramp deposit arrives or a payout completes. After the user signs in, get the device's FCM token from the Firebase SDK and register it here, together with the platform. Tokens are stored per token: registering a token that is already stored updates that record instead of adding a duplicate, so it is safe to call this on every app start or whenever Firebase issues a new token. ## Endpoint ### `POST /v1/notifications/device-token` Authentication: bearer token required. Registers (or updates) an FCM device token for the authenticated user. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `fcmToken` | string | yes | Firebase Cloud Messaging token from the Firebase SDK | | `platform` | string | yes | ios \| android \| web | **Responses** `201` Created ```json { "success": true, "message": "Device token registered successfully" } ``` ## Implementation ```javascript import { getMessaging, getToken } from 'firebase/messaging'; async function registerForPush(accessToken) { if (await Notification.requestPermission() !== 'granted') return; const fcmToken = await getToken(getMessaging(), { vapidKey: 'YOUR_VAPID_KEY' }); const res = await fetch('https://api.aureahub.com/v1/notifications/device-token', { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ fcmToken, platform: 'web' }) }); if (!res.ok) throw new Error(`Token registration failed: ${res.status}`); return fcmToken; // keep it to unregister on sign-out } ``` Web version: https://docs.aureahub.com/#notif-register --- # Unregister Device Token Remove a device's FCM token so it stops receiving push notifications. ## Overview Call this when the user signs out, before discarding the access token, so notifications don't keep arriving on a device that is no longer signed in — especially on shared devices. ## Endpoint ### `DELETE /v1/notifications/device-token` Authentication: bearer token required. Removes the given FCM device token. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `fcmToken` | string | yes | The FCM token that was registered for this device | **Responses** `200` OK ```json { "success": true, "message": "Device token unregistered successfully" } ``` ## Implementation ```javascript async function signOut(accessToken, fcmToken) { if (fcmToken) { try { await fetch('https://api.aureahub.com/v1/notifications/device-token', { method: 'DELETE', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ fcmToken }) }); } catch (err) { console.warn('Could not unregister the push token', err); // don't block sign-out } } clearLocalSession(); } ``` Web version: https://docs.aureahub.com/#notif-unregister --- # Get Inbox Fetch the authenticated user's in-app notification inbox, newest first. ## Overview The inbox keeps the notifications sent to the user, independently of push delivery. Use it for a notifications screen; [Unread Count](https://docs.aureahub.com/docs/notif-inbox-unread.md) backs a badge, and [Mark as Read](https://docs.aureahub.com/docs/notif-inbox-read.md) updates `is_read`. Paginate with `limit` and `skip`; note that the item fields are snake_case. ## Endpoint ### `GET /v1/notifications/inbox` Authentication: bearer token required. Returns the user's notifications, newest first. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | number | no | Page size, 1-100 (default 20) | | `skip` | number | no | Number of notifications to skip (default 0) | **Responses** `200` OK ```json { "notifications": [ { "id": "5e2d9c41-7a3b-4f6e-9d18-2c4b6a8e0f31", "campaign_id": "a91f3c7e-2b5d-4e8a-b6c0-1d3f5a7e9b24", "title": "Deposit arrived", "body": "…", "deep_link_screen": null, "is_read": false, "created_at": "2026-09-11T08:15:00.000Z" } ], "total": 24 } ``` Web version: https://docs.aureahub.com/#notif-inbox --- # Unread Count Return the count of unread notifications for the authenticated user. ## Overview Use this endpoint to drive a bell badge in your UI. It is cheap to call — the server maintains a cached counter — and can be polled on a short interval or refreshed on app focus. ### `GET /v1/notifications/inbox/unread-count` Authentication: bearer token required. Returns the number of unread inbox items. **Responses** `200` OK ```json { "unread": 3 } ``` Web version: https://docs.aureahub.com/#notif-inbox-unread --- # Mark as Read Mark one or more inbox notifications as read, or mark all at once. ## Overview Pass an array of notification `ids` to mark specific items read. Omit `ids` entirely to mark the entire inbox as read — useful for a "mark all as read" action. The response returns how many items were updated. ### `PATCH /v1/notifications/inbox/read` Authentication: bearer token required. Marks notifications as read. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `ids` | string[] | no | IDs of notifications to mark read. Omit to mark all. | **Responses** `200` OK ```json { "updated": 3 } ``` Web version: https://docs.aureahub.com/#notif-inbox-read --- # List DApps Retrieve the dApp catalogue configured for the authenticated user's tenant — the entries to show in an in-app dApp browser. ## Overview Returns the tenant's **active** dApps, ordered by `sortOrder` and then by creation time. The catalogue is managed through the `/v1/admin/dapps` endpoints. The dApps browser is a per-tenant feature. When it is not enabled for the tenant, this endpoint still returns `200` with an empty `dapps` array. Use `dappsEnabled` from Available Features (`GET /v1/tenant/available-features`) to decide whether to show the browser at all. See the [DApps Browser](https://docs.aureahub.com/docs/guide-dapps.md) guide for the full integration. ## Endpoint ### `GET /v1/dapps/` Authentication: bearer token required. Lists the active dApps of the caller's tenant. Empty when the dApps browser is not enabled for the tenant. **Responses** `200` OK ```json { "dapps": [ { "id": "9d6f1c2a-7b3e-4f58-a1c4-2e9b8d7f6a51", "tenantId": "5a2e8c71-3f4b-4d9a-b6e2-81c7f0d3a9b4", "name": "Example DEX", "url": "https://dex.example.com", "iconUrl": "https://dex.example.com/icon.png", "description": "Swap tokens on Gnosis", "category": "dex", "sortOrder": 0, "isActive": true, "createdAt": "2026-06-01T09:00:00.000Z", "updatedAt": "2026-06-01T09:00:00.000Z" } ] } ``` `200` Not Enabled ```json { "dapps": [] } ``` **Example request** ```bash curl https://api.aureahub.com/v1/dapps/ \ -H "Authorization: Bearer YOUR_TOKEN" ``` `iconUrl`, `description` and `category` may be `null`. ## Implementation ```javascript async function listDapps(token) { const res = await fetch('https://api.aureahub.com/v1/dapps/', { headers: { Authorization: `Bearer ${token}` } }); if (!res.ok) throw new Error(`DApps error: ${res.status}`); const { dapps } = await res.json(); return dapps; // already sorted by sortOrder, then creation time } ``` Web version: https://docs.aureahub.com/#dapps-list --- # Get Transaction Nonce Get the next pending transaction nonce of an EVM address — used when building a transaction requested by a dApp. ## Overview The server calls `eth_getTransactionCount(address, "pending")` on the RPC node configured for `chainSlug` and returns the result as a decimal integer. This is an on-chain account nonce, not a sign-in nonce. - **With `address`:** the address is used directly. It is not checked against the caller's wallets. - **Without `address`:** the server uses the caller's *earliest-created* mainnet wallet whose `chain` equals `chainSlug`, and returns `404` if there is none. The OpenAPI description calls this the primary wallet, but the implementation does not look at the primary flag — pass `address` to be explicit. - `chainSlug` must be a chain registered with an RPC URL (for example `gnosis`); otherwise the call returns `400`. - Rate limit: 30 requests per minute. ## Endpoint ### `GET /v1/dapps/nonce` Authentication: bearer token required. Returns the pending nonce (eth_getTransactionCount, 'pending') for an EVM address on the given chain. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `chainSlug` | string | yes | Chain identifier, e.g. `gnosis`. | | `address` | string | no | EVM address (`0x` + 40 hex characters). If omitted, the caller's earliest-created mainnet wallet on `chainSlug` is used. | **Responses** `200` OK ```json { "nonce": 42 } ``` `400` No RPC ```json { "error": "NO_RPC_URL", "message": "No RPC URL configured for chain 'unknownchain'" } ``` `404` No Wallet ```json { "error": "WALLET_NOT_FOUND", "message": "No wallet found for chain 'gnosis'" } ``` `422` RPC Error ```json { "error": "RPC_ERROR", "code": -32000, "message": "error message returned by the RPC node" } ``` **Example request** ```bash curl "https://api.aureahub.com/v1/dapps/nonce?chainSlug=gnosis&address=0x71C7656EC7ab88b098defB751B7401B5f6d8976F" \ -H "Authorization: Bearer YOUR_TOKEN" ``` If the RPC node does not answer within 15 seconds the call returns `504` with `{ "error": "RPC_TIMEOUT", "message": "The RPC node did not respond in time" }`. On `422`, `code` and `message` are the JSON-RPC error returned by the node. ## Implementation ```javascript async function getPendingNonce(token, chainSlug, address) { const params = new URLSearchParams({ chainSlug, address }); const res = await fetch(`https://api.aureahub.com/v1/dapps/nonce?${params}`, { headers: { Authorization: `Bearer ${token}` } }); if (!res.ok) throw new Error((await res.json()).message); const { nonce } = await res.json(); return nonce; // decimal integer } ``` Web version: https://docs.aureahub.com/#dapps-nonce --- # Estimate Gas Estimate the gas limit of an EVM transaction on a given chain, with a 20% safety buffer applied. ## Overview The server calls `eth_estimateGas` on the RPC node configured for `chainSlug` and returns `gasLimit` as a hex string equal to the node's estimate × 1.2, rounded up. - Only `to`, `data` and `value` are sent to the node — **no `from` address**. Calls whose outcome depends on the sender (balances, allowances, access control) can revert during estimation and return `422`. - `data` and `value` are passed to the node unchanged, so use JSON-RPC hex encoding (for example `value=0x2386f26fc10000`). - No gas price or fee fields are returned. - `chainSlug` must be a chain registered with an RPC URL; otherwise the call returns `400`. ## Endpoint ### `GET /v1/dapps/estimate-gas` Authentication: bearer token required. Estimates gas with eth_estimateGas and returns the estimate plus 20% as a hex gasLimit. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `chainSlug` | string | yes | Chain identifier, e.g. `gnosis`. | | `to` | string | yes | Destination or contract address. | | `data` | string | no | Hex-encoded calldata. | | `value` | string | no | Value in wei as a JSON-RPC hex quantity. | **Responses** `200` OK ```json { "gasLimit": "0x6270" } ``` `400` No RPC ```json { "error": "NO_RPC_URL", "message": "No RPC URL configured for chain 'unknownchain'" } ``` `422` RPC Error ```json { "error": "RPC_ERROR", "message": "error message returned by the RPC node" } ``` **Example request** ```bash curl "https://api.aureahub.com/v1/dapps/estimate-gas?chainSlug=gnosis&to=0x71C7656EC7ab88b098defB751B7401B5f6d8976F&value=0x2386f26fc10000" \ -H "Authorization: Bearer YOUR_TOKEN" ``` In the example above a plain transfer estimated at 21,000 gas (`0x5208`) is returned as `0x6270` (25,200). If the RPC node does not answer within 15 seconds the call returns `504` with `{ "error": "RPC_TIMEOUT", "message": "The RPC node did not respond in time" }`. ## Implementation ```javascript async function estimateGas(token, chainSlug, tx) { const params = new URLSearchParams({ chainSlug, to: tx.to }); if (tx.data) params.set('data', tx.data); // 0x-prefixed calldata if (tx.value) params.set('value', tx.value); // hex quantity, e.g. '0x0' const res = await fetch(`https://api.aureahub.com/v1/dapps/estimate-gas?${params}`, { headers: { Authorization: `Bearer ${token}` } }); if (!res.ok) throw new Error((await res.json()).message); const { gasLimit } = await res.json(); return gasLimit; // hex string, already includes the 20% buffer } ``` Web version: https://docs.aureahub.com/#dapps-gas --- # Sign Personal Message Handle a dApp's `personal_sign` (EIP-191) request. Custodial wallets are signed server-side; wallets without a server-held key get the digest back to sign on the client. ## Overview The server looks up `walletAddress` among the caller's wallets (case-insensitive) and returns `404` if it is not one of them. `message` is interpreted as hex bytes when it starts with `0x`, and as UTF-8 text otherwise. - **Server holds the key** (custodial wallets, including wallets whose migration is still `client_side_pending`): the key is decrypted and the response is `{ "signature": "0x…" }` — a 65-byte EIP-191 signature. - **No server-held key** (`client_side` and MPC `mpc_tss` wallets): nothing is signed and the response is `{ "requiresClientSigning": true, "messageHash": "0x…", "message": "…" }`. See below. - Only EVM addresses are accepted. Rate limit: 20 requests per minute. ## Endpoint ### `POST /v1/dapps/sign-personal-message` Authentication: bearer token required. EIP-191 personal_sign: returns a server signature for custodial wallets, or the digest to sign for wallets without a server-held key. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `message` | string | yes | Message to sign: `0x`-prefixed hex bytes, or plain UTF-8 text. | | `walletAddress` | string | yes | One of the caller's EVM wallet addresses (`0x` + 40 hex characters). | **Responses** `200` Custodial ```json { "signature": "0x…65-byte signature, 130 hex characters…" } ``` `200` Client Signing ```json { "requiresClientSigning": true, "messageHash": "0x…32-byte EIP-191 digest…", "message": "Sign in to Example DEX" } ``` `404` Not Found ```json { "error": "WALLET_NOT_FOUND", "message": "No wallet found for address '0x71C7656EC7ab88b098defB751B7401B5f6d8976F'" } ``` `500` Key Error ```json { "error": "KEY_DECRYPTION_FAILED", "message": "Unable to access the signing key. Please try again later." } ``` ## Wallets Without a Server Key `messageHash` is the full EIP-191 digest — `keccak256("\x19Ethereum Signed Message:\n" + length + messageBytes)`. The prefix is already applied, so sign it as a raw 32-byte digest; do **not** pass it to `signMessage`, which would prefix it a second time. Signing the digest directly gives the same signature as `signMessage(messageBytes)` on the original message. For MPC wallets, the digest is what the threshold signing ceremony signs — see [Start Signing Ceremony](https://docs.aureahub.com/docs/mpc-sign-start.md). The API does not accept the resulting signature back: return it to the dApp. ## Implementation ```javascript // Called by the dApp browser for personal_sign(message, address) // localWallet: ethers Wallet for client_side wallets (unused for custodial ones) async function handlePersonalSign(token, message, walletAddress, localWallet) { const res = await fetch('https://api.aureahub.com/v1/dapps/sign-personal-message', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ message, walletAddress }) }); if (!res.ok) throw new Error((await res.json()).message); const result = await res.json(); if (result.signature) return result.signature; // signed by the server // requiresClientSigning: sign the digest as-is (it already carries the EIP-191 prefix) return localWallet.signingKey.sign(result.messageHash).serialized; } ``` Web version: https://docs.aureahub.com/#dapps-sign --- # Broadcast DApp Transaction Relay an already-signed raw EVM transaction to the RPC node of the given chain. ## Overview The server sends `signedTransaction` unchanged with `eth_sendRawTransaction` and returns the transaction hash reported by the node. It does not decode the transaction, check who signed it, or create an Aurea transaction record. The dApps endpoints never sign transactions — sign with a key you control first (see [DApps Browser](https://docs.aureahub.com/docs/guide-dapps.md)). - `chainSlug` must be a registered chain with an RPC URL, otherwise `400` (`UNKNOWN_CHAIN` or `NO_RPC_URL`). - If the node rejects the transaction, the call returns `422` with the node's error message. - If the node does not answer within 15 seconds, the call returns `504` `RPC_TIMEOUT`. - Rate limit: 10 requests per 10 seconds. ## Endpoint ### `POST /v1/dapps/broadcast` Authentication: bearer token required. Broadcasts a signed raw transaction with eth_sendRawTransaction and returns the transaction hash. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `signedTransaction` | string | yes | `0x`-prefixed hex of the signed raw transaction. | | `chainSlug` | string | yes | Chain identifier, e.g. `gnosis`. | **Responses** `200` OK ```json { "txHash": "0x…32-byte transaction hash…" } ``` `400` Unknown Chain ```json { "error": "UNKNOWN_CHAIN", "message": "Chain 'unknownchain' is not registered" } ``` `422` RPC Error ```json { "error": "RPC_ERROR", "message": "error message returned by the RPC node" } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/dapps/broadcast \ -H "Authorization: Bearer YOUR_TOKEN" \ -H "Content-Type: application/json" \ -d '{"chainSlug": "gnosis", "signedTransaction": "0x02f8..."}' ``` ## Implementation ```javascript async function broadcastDappTx(token, chainSlug, signedTransaction) { const res = await fetch('https://api.aureahub.com/v1/dapps/broadcast', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ chainSlug, signedTransaction }) }); if (!res.ok) { const { error, message } = await res.json(); // e.g. RPC_ERROR, RPC_TIMEOUT throw new Error(`${error}: ${message}`); } const { txHash } = await res.json(); return txHash; } ``` Web version: https://docs.aureahub.com/#dapps-broadcast --- # Portfolio Performance Daily EUR and USD totals of the user's portfolio over a period, with the change between the first and last day. ## Overview A nightly job stores one snapshot per user per day. This endpoint returns the snapshots for the requested `period`, oldest first, and computes the change in EUR between the first and the last one. - `dataAvailable` is `true` when there are at least two snapshots; otherwise `changeEur` and `changePercent` are `null`. - `changePercent` is relative to the first snapshot, and is also `null` when that first total is 0. - New users have no history until the nightly job has run. - Limited to 20 requests per minute. ## Endpoint ### `GET /v1/portfolio/performance` Authentication: bearer token required. Returns daily portfolio snapshots and the EUR change over the requested period. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `period` | string | no | 24h \| 1W \| 1M \| 1Y (default 1M) | | `isTestnet` | string | no | "true" for testnet balances, "false" for mainnet | **Responses** `200` OK ```json { "period": "1M", "dataAvailable": true, "changeEur": 320.5, "changePercent": 2.64, "snapshots": [ { "date": "2026-08-12", "totalEur": 12130, "totalUsd": 13248.6 }, { "date": "2026-09-11", "totalEur": 12450.5, "totalUsd": 13598.7 } ] } ``` `429` Rate limited ```json { "error": "…", "message": "…" } ``` ## Implementation ```javascript async function getPerformance(accessToken, period = '1M') { const res = await fetch(`https://api.aureahub.com/v1/portfolio/performance?period=${period}`, { headers: { Authorization: `Bearer ${accessToken}` } }); if (!res.ok) throw new Error(`Portfolio request failed: ${res.status}`); const { dataAvailable, changeEur, changePercent, snapshots } = await res.json(); return { chart: snapshots.map(s => ({ x: s.date, y: s.totalEur })), summary: dataAvailable ? `${changeEur >= 0 ? '+' : ''}${changeEur.toFixed(2)} EUR${changePercent === null ? '' : ` (${changePercent.toFixed(2)}%)`}` : 'Not enough history yet' }; } ``` Web version: https://docs.aureahub.com/#portfolio-perf --- # List Swap Routes Get the destination chains and tokens configured for a source token — use it to populate the "To" selector of a swap UI. ## Overview Swap routes are a catalogue that administrators maintain through the `/v1/admin/swap-routes` endpoints. Each route links a source chain and token to a destination chain and token. This endpoint returns the active routes for one source token. - **Authentication is optional.** With a valid Bearer token you get your tenant's routes; if your tenant has none for that source token, the system tenant's routes are returned instead. Without a token — or with an invalid or expired one — the request is not rejected and you get the system tenant's routes. - `fromToken` is matched case-insensitively. `fromChain` may be a chain name or a numeric chain ID; a known numeric ID is converted to the chain name. - `isTestnet` is accepted but does not currently filter the result. - Results are cached on the server for up to 5 minutes. > 💡 Routes describe what your UI should offer. [Get Quote](https://docs.aureahub.com/docs/swap-quote.md) does not check them, so a listed route can still fail to quote — for example when LI.FI has no route at that moment. ## Endpoint ### `GET /v1/swap/routes` Authentication: none (public endpoint). Returns the active destination routes for a source token. A Bearer token is optional. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `fromChain` | string | yes | Source chain name (e.g. `polygon`) or numeric chain ID. | | `fromToken` | string | yes | Source token address: `0x` + 40 hex characters, or a Solana base58 address (32–44 characters). | | `isTestnet` | string | no | `true` or `false`. Accepted; not currently used to filter routes. | **Responses** `200` OK ```json { "data": [ { "id": "…", "toChain": "gnosis", "toToken": { "address": "0x…", "symbol": "…", "name": "…", "decimals": 18, "logoUri": null, "isNative": false } } ] } ``` `400` Bad Request ```json { "error": "Bad Request", "message": "Request validation failed" } ``` `422` Invalid fromToken ```json { "statusCode": 422, "error": "ValidationError", "message": "Invalid query parameters", "details": [ { "path": ["fromToken"], "message": "Invalid token address format" } ] } ``` A missing `fromChain` or `fromToken` returns `400`; a malformed `fromToken` returns `422`. Routes are ordered by `toChain`, then by token symbol. When a destination token is not found in the token registry, `toToken` carries placeholders: `symbol` and `name` are the first 8 characters of the address, `decimals` is `18`, `logoUri` is `null` and `isNative` is `false`. ## Implementation ```javascript async function getSwapDestinations({ fromChain, fromToken, token }) { const params = new URLSearchParams({ fromChain, fromToken }); const res = await fetch(`https://api.aureahub.com/v1/swap/routes?${params}`, { // Optional: send the user's token to get tenant-specific routes headers: token ? { Authorization: `Bearer ${token}` } : {} }); const body = await res.json(); if (!res.ok) throw new Error(`Routes error ${res.status}: ${body.message}`); return body.data; // [{ id, toChain, toToken: { address, symbol, name, decimals, logoUri, isNative } }] } // Populate the "To" selector for the chosen source token async function populateToSelect(selectEl, { fromChain, fromToken }, token) { const routes = await getSwapDestinations({ fromChain, fromToken, token }); selectEl.innerHTML = ''; for (const route of routes) { const opt = document.createElement('option'); opt.value = JSON.stringify({ toChain: route.toChain, toToken: route.toToken.address }); opt.textContent = `${route.toToken.symbol} on ${route.toChain}`; selectEl.appendChild(opt); } } ``` Web version: https://docs.aureahub.com/#swap-routes-list --- # Simulate Deposit Ask the bank ramp's sandbox to simulate a bank transfer to a sandbox virtual account, in that account's own currency, to test your pay-in flow without real money. ## Overview - First create a sandbox virtual account with [Initiate Deposit](https://docs.aureahub.com/docs/payin-initiate.md) on a sandbox network (for example `SolanaDevnet`), and pass its `paymentMethodId` — Aurea's ID, also listed by [Payment Methods](https://docs.aureahub.com/docs/payin-methods.md) with `isTestnet=true`. - Only the user's own **sandbox** accounts can be used: an account of another user, or a production account, answers `404` and the bank ramp isn't called. - **The transfer is in the account's own currency**, the `currency` the account was created in: a USD account is paid in dollars, an EUR account in euros. `amount` is in that currency. - Pay-in must be switched on for your tenant in the sandbox ([Tenant Settings](https://docs.aureahub.com/docs/bank-settings.md)), otherwise `403` `NOAH_FUNCTION_OFF`. - Aurea asks the bank ramp's **sandbox** to simulate the transfer for your tenant. It doesn't create the deposit itself: the deposit appears in [Get Deposits](https://docs.aureahub.com/docs/payin-deposits.md) (with `isTestnet=true`) when the bank ramp reports the deposit to Aurea, which it does once the bank ramp is connected for your tenant in the sandbox ([Bank Ramp Setup](https://docs.aureahub.com/docs/bank-setup.md)). > ℹ️ There is no separate sandbox host: call the base URL of the Aurea deployment you use. The simulation works on any deployment, because it only ever reaches the bank ramp's sandbox. ## Endpoint ### `POST /v1/ramp/bank/payin/sandbox/simulate-deposit` Authentication: bearer token required. Triggers a simulated bank transfer in the bank ramp's sandbox to the given virtual account, in the account's own currency. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `paymentMethodId` | string | yes | Aurea payment method UUID of a sandbox virtual account | | `amount` | number | yes | Amount in the account's currency, greater than 0 and at most 15000 | | `reference` | string | no | Reference the bank ramp attaches to the simulated deposit | **Responses** `200` OK ```json { "success": true, "message": "Deposit simulated successfully. Webhook events will be triggered shortly." } ``` `404` Not a sandbox account of the user ```json { "statusCode": 404, "error": "NotFoundError", "message": "Payment method not found" } ``` `403` Pay-in switched off ```json { "statusCode": 403, "error": "ForbiddenError", "message": "The bank ramp's pay-in is not switched on for this tenant in sandbox.", "details": { "code": "NOAH_FUNCTION_OFF", "function": "payin", "environment": "sandbox" } } ``` ## Implementation ```javascript const API = 'https://api.aureahub.com'; // base URL of the Aurea deployment you use const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }; // 1. Sandbox virtual account (sandbox network -> sandbox profile) const pm = await fetch(`${API}/v1/ramp/bank/payin/initiate`, { method: 'POST', headers, body: JSON.stringify({ cryptoCurrency: 'EURC_TEST', network: 'SolanaDevnet' }) // delivered to the user's Solana Devnet wallet }).then(r => r.json()); // 2. Simulated bank transfer await fetch(`${API}/v1/ramp/bank/payin/sandbox/simulate-deposit`, { method: 'POST', headers, body: JSON.stringify({ paymentMethodId: pm.paymentMethodId, amount: 250, reference: 'Test deposit 1' }) }); // 3. The deposit shows up once it has reached Aurea const { deposits } = await fetch(`${API}/v1/ramp/bank/payin/deposits?isTestnet=true`, { headers }).then(r => r.json()); ``` Web version: https://docs.aureahub.com/#sandbox-deposit --- # Simulate Payout Completion Move one of the user's payouts to `completed` or `failed` without the bank ramp, to test your payout screens. ## Overview Create a payout with [Initiate Payout](https://docs.aureahub.com/docs/payout-initiate.md) and keep its `payoutId`, then call this endpoint. Aurea sets the payout's status to the chosen `outcome` and sends the user the same push notification as a real completion or failure. The bank ramp is not contacted, and no webhook is involved. > ℹ️ Only the user's own **sandbox** payouts can be simulated: a production payout, or another user's, answers `404`. Payouts must be switched on for your tenant in the sandbox, otherwise `403` `NOAH_FUNCTION_OFF`. The simulation works on any Aurea deployment; there is no separate sandbox host. ## Endpoint ### `POST /v1/ramp/bank/payout/sandbox/simulate` Authentication: bearer token required. Sets the status of one of the authenticated user's sandbox payouts to completed or failed. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `payoutId` | string | yes | Payout UUID returned by Initiate Payout | | `outcome` | string | no | completed (default) or failed | **Responses** `200` OK ```json { "success": true, "message": "Payout simulation triggered: completed" } ``` `404` Not a sandbox payout of the user ```json { "statusCode": 404, "error": "NotFoundError", "message": "Payout not found" } ``` `403` Payouts switched off ```json { "statusCode": 403, "error": "ForbiddenError", "message": "The bank ramp's payout is not switched on for this tenant in sandbox.", "details": { "code": "NOAH_FUNCTION_OFF", "function": "payout", "environment": "sandbox" } } ``` ## Implementation ```javascript const API = 'https://api.aureahub.com'; // base URL of the Aurea deployment you use const headers = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }; // 1. Create a sandbox payout (isTestnet: true selects the sandbox) const payout = await fetch(`${API}/v1/ramp/bank/payout/initiate`, { method: 'POST', headers, body: JSON.stringify({ cryptoCurrency: 'EURC_TEST', cryptoAmount: '20000000', fiatCurrency: 'EUR', returnUrl: 'myapp://payout/done', isTestnet: true }) }).then(r => r.json()); // 2. Force the outcome await fetch(`${API}/v1/ramp/bank/payout/sandbox/simulate`, { method: 'POST', headers, body: JSON.stringify({ payoutId: payout.payoutId, outcome: 'completed' }) }); // 3. Read it back from the sandbox: status is now "completed" const current = await fetch(`${API}/v1/ramp/bank/payout/transactions/${payout.payoutId}?isTestnet=true`, { headers }).then(r => r.json()); ``` Web version: https://docs.aureahub.com/#sandbox-payout