# Aurea API Docs — complete documentation --- --- > Generated from https://docs.aureahub.com (281 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 `v1` in the path, `1.0.0` in its OpenAPI document — 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)). Every operation declares its answers with their schemas. 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). 4. When the user signs out, revoke the refresh token at [POST /v1/auth/logout](https://docs.aureahub.com/docs/auth-logout.md). Where Aurea enables [two-factor sign-in](https://docs.aureahub.com/docs/guide-two-factor.md) for your tenant, the accounts that run it — every role but the End User — give an authenticator app's code after their password. Instead of signing with the API secret, your server can sign those requests with a key pair it holds: register the public key as a **server credential** and send a short-lived ES256 token (Server Tokens below, and [Server Credentials](https://docs.aureahub.com/docs/guide-server-credentials.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. On each of them a [server token](https://docs.aureahub.com/docs/guide-server-credentials.md) may take the place of the HMAC headers: in `Authorization` where the tenant signature is the only credential, in `X-Aurea-Server-Token` where `Authorization` carries the user's access token. A tenant on the institutional security profile must use one (`401` `SERVER_TOKEN_REQUIRED`). | Route | Tenant signature | Bearer token | | --- | --- | --- | | `POST /v1/auth/registerPOST /v1/auth/loginPOST /v1/auth/mfa/verifyPOST /v1/auth/googlePOST /v1/auth/apple` | Required: HMAC headers or a server token | — | | `POST /v1/auth/forgot-password` | Required: HMAC headers or a server token | — | | `GET /v1/tenant/public` | Required: HMAC headers or a server token | — | | `POST /v1/mpc/service/token` | Required: HMAC headers or a server token | — | | `GET, PUT /v1/ramp/card/customers/{externalId}GET, POST /v1/ramp/card/customers/{externalId}/sessionsGET /v1/ramp/card/customers/{externalId}/sessions/{id}PUT, DELETE /v1/ramp/card/customers/{externalId}/wallets/{family}` | Required: HMAC headers or a server token | — | | `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: HMAC headers or a server token in `X-Aurea-Server-Token` | Required | | `GET, POST /v1/earn/*` | Required: HMAC headers or a server token in `X-Aurea-Server-Token` | Required | | `POST /v1/auth/refreshPOST /v1/auth/logout` | — | — (the refresh token goes in the body) | | `GET /v1/auth/reset-password/validatePOST /v1/auth/reset-password` | — | — (the reset token is the credential) | | `GET /healthGET /health/ready` | — | — | | `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 | | role | The user's role, as stored: one of the values under Roles below — `user` for an end user | | 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). ## Roles Every user has one role, carried in the access token's `role` claim and answered as `role` wherever a user is returned. It decides which routes the token may call; a route a role may not call answers `403`. Every role acts on its own tenant only. | role | What it may do | | --- | --- | | user | **End User.** Its own account, wallets and payments only. | | owner | **Owner.** Everything an Admin does, and acts on the tenant's other administrators, other Owners included. Named only by Aurea; a tenant may have several. | | tenantadmin | **Admin.** Manages the tenant, its users and credentials; initiates, signs and approves payments. | | non_signing_admin | **Non-signing Admin.** Manages the tenant, its users, settings, credentials and webhooks, and approves; never initiates or signs a payment, never issues a credential that pays. | | signer | **Signer.** Initiates and signs payments — its own wallets', and the tenant's agents'; manages no user and no setting. | | approver | **Approver.** Approves or rejects the payments waiting for an approval; initiates nothing. | | editor | **Editor.** Edits the tenant's catalogue (tokens, dApps) and the agent payments' configuration; never pays, signs or approves. | | viewer | **Viewer.** Reads the tenant's operations — configuration without secrets, tokens, dApps, chains, agent payments; changes nothing. | | auditor | **Auditor.** Reads what a Viewer reads, and the users, the activity log, the [audit trail](https://docs.aureahub.com/docs/audit-events.md) and the webhooks; changes nothing. | | security_admin | **Security Admin.** Manages the tenant's credentials and webhooks; reads the users, the activity log and the audit trail; never moves or approves funds. | | security_auditor | **Security Auditor.** Reads the tenant's security configuration (configuration without secrets, webhooks), the users, the activity log and the audit trail; changes nothing. | - **Wallets and payments.** Every role of a tenant reads its own wallets, transactions and notifications. Creating, importing or exporting a wallet, sending, swapping, approving a token, the dApps, the MPC ceremonies, the bank ramp, the card onramp and Earn's deposits and withdrawals are for `user`, `owner`, `tenantadmin` and `signer` only — and so is cancelling, speeding up or dropping one's own [operation](https://docs.aureahub.com/docs/guide-operations.md). - **Accounts.** The roles that read the tenant's transactions — `owner`, `tenantadmin`, `non_signing_admin`, `signer`, `approver`, `editor`, `viewer`, `auditor` — read the tenant's [accounts](https://docs.aureahub.com/docs/guide-accounts.md); `owner`, `tenantadmin` and `non_signing_admin`, who manage its users, create and change them. - **Giving a role.** A role is given only within the giver's own: every permission of the role given must be one the giver holds, so an Admin gives any tenant role but `owner`, and a Non-signing Admin never a role that signs. Anything else answers `403` `Cannot assign a role that holds permissions your role does not`; an Owner is named only by Aurea (`403` `Only an Aurea administrator can name an owner`). - **Acting on another account.** Changing, deleting, or sending a password reset or an email change to another user is refused for an account whose role holds a permission the caller's does not (`Cannot change the account of a user whose role holds permissions yours does not`), and for another administrator's account unless the caller is an Owner (`Only the tenant's owner can change another administrator's account`). - **Address book.** The roles that read the tenant's transactions read its [address book](https://docs.aureahub.com/docs/guide-address-book.md); `owner`, `tenantadmin`, `non_signing_admin` and `editor` add, change, remove and prove entries; `owner`, `tenantadmin` and `non_signing_admin` approve and reject them and change its settings. - **Groups and the tenant's quorum.** `owner`, `tenantadmin`, `non_signing_admin`, `auditor`, `security_admin` and `security_auditor` read the tenant's [groups](https://docs.aureahub.com/docs/guide-groups.md) and its [quorum requests](https://docs.aureahub.com/docs/guide-tenant-quorum.md); `owner`, `tenantadmin`, `non_signing_admin` and `security_admin` manage the groups and decide the quorum requests they may. - **Two-factor sign-in.** Every role but `user` enrols its own [second factor](https://docs.aureahub.com/docs/guide-two-factor.md), where Aurea enables it; `owner`, `tenantadmin` and `non_signing_admin` reset another user's, under the rules of acting on another account. - **Security and governance.** `owner`, `tenantadmin`, `non_signing_admin` and `security_admin` manage the tenant's [server credentials](https://docs.aureahub.com/docs/guide-server-credentials.md), write and publish its [policy sets](https://docs.aureahub.com/docs/guide-policy-sets.md), and [freeze](https://docs.aureahub.com/docs/guide-freezes.md) its users, wallets and accounts and quarantine its pending operations; `auditor` and `security_auditor` also read the policy sets, the freezes and the quarantines. Every role of a tenant reads its own [operations](https://docs.aureahub.com/docs/guide-operations.md); the roles that read the tenant's transactions read all of them. `approver`, `owner`, `tenantadmin` and `non_signing_admin` register their own approval keys and approve or reject the operations a policy holds ([Approvals](https://docs.aureahub.com/docs/guide-approvals.md), institutional tenants). ## 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. - **Logging out:** [Log Out](https://docs.aureahub.com/docs/auth-logout.md) revokes a refresh token on the server; the access tokens already issued stay valid until they expire. > ℹ️ 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'), }); ``` ## Server Tokens On the routes the tenant signature opens on its own, your server can send `Authorization: Bearer ` instead of the three HMAC headers — never both (`400` `SERVER_AUTH_SCHEMES_CONFLICT`). The token is a compact JWS signed ES256 with the private key of one of your tenant's **server credentials**, whose public key you registered; Aurea never holds the private key. ```text header { "alg": "ES256", "typ": "aurea-server+jwt", "kid": "" } claims { "uri": "POST /v1/auth/login", "bodyHash": "", "nonce": "<16–128 of A–Z a–z 0–9 _ ->", "iat": 1791297243, "exp": 1791297273 } ``` - `uri` is the method, a space, and the path with its query exactly as sent; `exp` is at most 30 seconds after `iat`; a nonce is accepted once. - A refused token answers `401` with `details.code` (`SERVER_TOKEN_*`, `SERVER_CREDENTIAL_*`) and the same code in the `WWW-Authenticate` header. - A bearer token whose `typ` is not `aurea-server+jwt` is not a server token: the request needs the HMAC headers as before. - On Earn's routes and the savings vault's, where `Authorization` carries the user's access token, the server token goes in its own header, `X-Aurea-Server-Token`, instead of the HMAC headers; the credential must be of the user's tenant. - A credential registered since 6 October 2026 (`roleNarrows` `true`) reaches only the routes whose permission its role holds: `403` `SERVER_CREDENTIAL_ROLE_NOT_ALLOWED` elsewhere. - A tenant on the institutional security profile must sign with a server token wherever one is accepted: HMAC signing there answers `401` `SERVER_TOKEN_REQUIRED`. Registering a key, signing with it, rotating, the IP allow-list and every refusal: [Server Credentials](https://docs.aureahub.com/docs/guide-server-credentials.md). ## 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. **Where Aurea enables it**, this answer becomes the `401` `Invalid signature` below — the same status, headers and body as a wrong signature — so that an answer never tells which keys exist: treat both alike. | | 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. | | 401 | `This tenant signs with a server token: HMAC v1 is not accepted on this route` | A tenant on the institutional security profile signed with HMAC where a server token is accepted (`details.code` `SERVER_TOKEN_REQUIRED`), after the signature was checked. See [Server Credentials](https://docs.aureahub.com/docs/guide-server-credentials.md). | | 423 | `This tenant is frozen: nothing can be done for it until it is lifted.` | Aurea froze the tenant (`details.code` `TENANT_FROZEN`), after the signature was checked. See [Freezes & Quarantines](https://docs.aureahub.com/docs/guide-freezes.md). | ## 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 and `SyntaxError` on a body that is not JSON. | | 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 (`SyntaxError`, see Validation Errors), 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 server token (`SERVER_TOKEN_*`, `SERVER_CREDENTIAL_*`), 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, still running, or of a request whose outcome is not known (`IDEMPOTENCY_KEY_REUSED`, `IDEMPOTENCY_IN_PROGRESS`, `IDEMPOTENCY_OUTCOME_UNKNOWN`). | | 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`. Also a signed transaction a node refused (`RPC_ERROR`) or a signature from another key (`WRONG_SIGNER`). | | 423 | Frozen or quarantined (`LockedError`): the tenant, the user or the wallet is frozen, or the operation quarantined — `details.code` `TENANT_FROZEN`, `USER_FROZEN`, `WALLET_FROZEN` or `OPERATION_QUARANTINED`; or, for an institutional tenant, its policy froze the wallet (`POLICY_FROZEN`). It answers the same until it is lifted: don't retry. See [Freezes & Quarantines](https://docs.aureahub.com/docs/guide-freezes.md). | | 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. **A body that is not JSON → `400`.** With `Content-Type: application/json`, a body that does not parse is answered before any check, with `"error": "SyntaxError"` and the JSON parser's own message, which gives the position and its line and column. For the body `{"refreshToken": "abc"` (the closing brace missing): ```json { "statusCode": 400, "error": "SyntaxError", "message": "Expected ',' or '}' after property value in JSON at position 22 (line 1 column 23)" } ``` The wording comes from the runtime's parser and is not part of the API: show it to a developer, never parse it. ## 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`, `PREPARED_TX_SUPERSEDED` (`409`: a newer preparation for the same wallet and chain took its nonce), `PREPARED_TX_CANCELLED` (`409`: its operation was cancelled), `SIGNATURE_DECODE_FAILED`, `WRONG_SIGNER`, `GAS_CAP_EXCEEDED` (`422`: the prepared fee is above the cap), `NO_ROUTE_FOUND`, `NO_LIQUIDITY_POOL` and `CONTRACT_NOT_DEPLOYED`, and `RPC_ERROR` (`422`) when a node refuses a swap or an approval broadcast: the message is the node's, and `details.rpcCode` its error code. - **Moving a custodial key to the user's device** ([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](https://docs.aureahub.com/docs/wallets-confirm-custody.md), [Cancel Migration](https://docs.aureahub.com/docs/wallets-cancel-migration.md)) refuses with a `BlockchainError` whose code is in `code` and in `details.code`: `KEY_ALREADY_MIGRATED` (`409`), `MIGRATION_PENDING` (`409`), `EXPORT_TOKEN_USED` and `EXPORT_TOKEN_EXPIRED` (`410`), `EXPORT_ALREADY_TRANSMITTED` (`409`). Each page lists which message goes with which code. 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` and `IDEMPOTENCY_KEY_REQUIRED` (`400`; the second for an institutional tenant's money route sent without a key), `IDEMPOTENCY_KEY_REUSED`, `IDEMPOTENCY_IN_PROGRESS` and `IDEMPOTENCY_OUTCOME_UNKNOWN` (`409`): see [Idempotency](https://docs.aureahub.com/docs/idempotency.md). - `EARN_*`: the Earn products — see [Earn](https://docs.aureahub.com/docs/guide-earn.md). - `WALLET_CHAIN_UNKNOWN` (`400`): a wallet made or registered on a chain Aurea does not know — see [Create Wallet](https://docs.aureahub.com/docs/wallets-create.md). - `ACCOUNT_*` (`400`, `404`, `409`), `ACCOUNTS_BULK_DUPLICATE_REF` (`400`), `ACCOUNTS_BULK_NOT_FOUND` (`404`) and `ACCOUNTS_BULK_OFF` (`503`): your tenant's accounts — see [Accounts](https://docs.aureahub.com/docs/guide-accounts.md). - `TENANT_FROZEN`, `USER_FROZEN`, `ACCOUNT_FROZEN`, `WALLET_FROZEN` and `OPERATION_QUARANTINED` (`423`), and `FREEZE_STATUS_UNREAD` (`503`, nothing was signed: retry): a freeze or a quarantine — see [Freezes & Quarantines](https://docs.aureahub.com/docs/guide-freezes.md). Placing and lifting them: `FREEZE_TARGET_NOT_FOUND`, `FREEZE_NOT_FOUND`, `OPERATION_NOT_FOUND` and `QUARANTINE_NOT_FOUND` (`404`), `ALREADY_FROZEN`, `FREEZE_NOT_ACTIVE`, `ALREADY_QUARANTINED`, `QUARANTINE_NOT_ACTIVE`, `OPERATION_NOT_PENDING` (with `details.state`) and `OPERATION_NOT_QUARANTINABLE` (with `details.reason`) (`409`), `FREEZE_SELF` and `FREEZE_LIFT_PLATFORM_ONLY` (`403`), `FREEZE_UNTIL_PAST` and `FREEZE_REASON_INVALID` (`400`). - `SERVER_AUTH_SCHEMES_CONFLICT` (`400`), `SERVER_TOKEN_*` and `SERVER_CREDENTIAL_*` (`401`): a refused server token, and `SERVER_CREDENTIAL_ROLE_NOT_ALLOWED` (`403`, with `details.permission`) for a credential whose role does not reach the route; `SERVER_TOKEN_REQUIRED` (`401`): an institutional tenant's HMAC signature where a server token is accepted; `SERVER_CREDENTIAL_*` also on the credential routes (`400`, `403`, `404`, `409`), with `TENANT_NOT_FOUND` (`404`) — see [Server Credentials](https://docs.aureahub.com/docs/guide-server-credentials.md). - `WEBHOOK_ENDPOINT_INVALID` (`400`, with `details.field`, and `details.reason` for an address), `WEBHOOK_ENDPOINT_LIMIT` (`409`), `WEBHOOK_ENDPOINT_NOT_FOUND` (`404`), `WEBHOOK_QUERY_INVALID` and `WEBHOOK_RESEND_INVALID` (`400`, with `details.field`): your tenant's webhook endpoints, deliveries and resends — see [Events to Your Server](https://docs.aureahub.com/docs/guide-events.md). - `POLICY_*`: your tenant's policy sets, and — for an institutional tenant — the refusals of its policy when something would be signed: `POLICY_BLOCKED` and `POLICY_APPROVAL_REQUIRED` (`403`), `POLICY_FROZEN` (`423`), `POLICY_SCREENING_UNAVAILABLE` and `POLICY_UNAVAILABLE` (`503`), and, when Aurea enables designated signers, `POLICY_SIGNER_NOT_DESIGNATED` (`403`) — see [Policy Sets](https://docs.aureahub.com/docs/guide-policy-sets.md). - `GROUP_*` (`400`, `404`, `409`): your tenant's groups of users — see [Groups](https://docs.aureahub.com/docs/guide-groups.md). - `ADDRESS_BOOK_*` (`400`, `403`, `404`, `409`, `410`, `422`, `503`): your tenant's address book — and, where Aurea enables it for your tenant, a destination outside it: `ADDRESS_BOOK_DESTINATION_NOT_ALLOWED` and `ADDRESS_BOOK_DESTINATION_UNRESOLVED` (`403`), `ADDRESS_BOOK_UNAVAILABLE` (`503`) — see [Address Book](https://docs.aureahub.com/docs/guide-address-book.md). - `MFA_REQUIRED` (`401`, the login's answer with an `mfaToken`), `MFA_ENROLLMENT_REQUIRED` and `MFA_NOT_AVAILABLE` (`403`), `MFA_CHALLENGE_INVALID` (`401`), `MFA_CODE_INVALID` (`401` at the second step, `400` elsewhere), `MFA_OFF`, `MFA_NO_PENDING`, `MFA_NOT_ENROLLED` and `MFA_REMOVAL_REFUSED` (`409`): two-factor sign-in — see [Two-Factor Sign-In](https://docs.aureahub.com/docs/guide-two-factor.md). - `CURRENT_PASSWORD_REQUIRED` (`400`), `CURRENT_PASSWORD_INVALID` (`403`) and `CURRENT_PASSWORD_UNAVAILABLE` (`409`): changing one's own password or email where Aurea has your tenant ask for the current password — see [Update User](https://docs.aureahub.com/docs/users-update.md). - `QUORUM_*` (`400`, `403`, `404`, `409`), and the `202` `{ "state": "awaiting_quorum", "requestId" }` of an administrator's action held for another administrator: your tenant's quorum — see [Your Tenant's Quorum](https://docs.aureahub.com/docs/guide-tenant-quorum.md). - `PERSONAL_SIGN_NOT_ALLOWED`, `MESSAGE_DOMAIN_NOT_ALLOWED`, `RAW_SIGN_NOT_ALLOWED`, `APPROVAL_SPENDER_NOT_ALLOWED` and `SWAP_ROUTER_NOT_ALLOWED` (`403`), `APPROVAL_AMOUNT_REQUIRED` (`400`), `APPROVAL_RESET_REQUIRED`, `SWAP_QUOTE_UNKNOWN`, `TX_NOT_SIMULATED` and `TX_SIMULATION_DIVERGES` (`409`): the signing rules and the transaction simulation, where Aurea enables them for your tenant — see [Signing Rules](https://docs.aureahub.com/docs/guide-signing-rules.md). - `APPROVAL_*` (`400`, `403`, `404`, `409`): approval keys and decisions — see [Approvals](https://docs.aureahub.com/docs/guide-approvals.md) — except the three token-approval refusals of the signing rules above. - `OPERATION_NOT_FOUND` (`404`): an operation you may not read, or none — see [Operations and Fees](https://docs.aureahub.com/docs/guide-operations.md). Cancelling one: `OPERATION_CANCEL_FORBIDDEN` (`403`), `OPERATION_ALREADY_BROADCAST`, `OPERATION_FINAL` and `OPERATION_IN_PROGRESS` (`409`, with `details.state`), and `OPERATION_CANCELLED` (`409`, with `details.operationId`) at the broadcast of what it prepared — see [Cancel an Operation](https://docs.aureahub.com/docs/operation-cancel.md). Speeding one up or dropping it: `OPERATIONS_RBF_OFF` and `OPERATION_RBF_FORBIDDEN` (`403`), the `OPERATION_*` and `OPERATION_RBF_*` refusals (`409`) — see [Speed Up](https://docs.aureahub.com/docs/operation-speed-up.md). `FEE_CHAIN_NOT_CONFIGURED` and `WALLET_NOT_FOUND` (`404`), `FEE_ASSET_INVALID` (`400`) and `FEE_ESTIMATE_UNAVAILABLE` (`502`): the fee, spendable-amount and address routes. - `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, the second step of two-factor sign-in, Google and Apple sign-in, and **60 per minute** each for refresh and logout. 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. > 💡 Every response carries the request's ID in `X-Request-ID`, and browsers may read it. You can send your own `x-request-id` header: the API keeps it when it is 8 to 64 letters, digits, `.`, `_` or `-`, and uses an ID of its own otherwise. 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 | The networks Aurea supports: 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 POST /v1/auth/logout | 60 per minute each | | POST /v1/auth/mfa/verify POST /v1/auth/mfa/totp POST /v1/auth/mfa/totp/confirm POST /v1/auth/mfa/recovery-codes DELETE /v1/auth/mfa/totp | 20 per minute each | | 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 (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 the swap router unchanged, and the router 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 routes that move funds, and 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 Without a key, endpoints don't detect duplicate requests: a repeated `POST` is processed again. These make a retry safe: - the optional `Idempotency-Key` header of the **money routes** — sends, broadcasts, swaps, approvals, the dApp broadcast, and an operation's speed-up and drop (below); - the same header on 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 (below), on the card onramp's [Open a Session](https://docs.aureahub.com/docs/card-onramp-create.md) and [Open a Purchase](https://docs.aureahub.com/docs/card-wallets-session-create.md), and on [Earn](https://docs.aureahub.com/docs/guide-earn.md)'s deposits, withdrawals and broadcasts, as their pages describe; - the `idempotencyKey` body field of [agent payment executions](https://docs.aureahub.com/docs/agentic-api.md). ## Idempotency-Key on Money Routes These routes accept an `Idempotency-Key` header of up to 255 letters, digits, `-` or `_`. It is optional — a request without it behaves exactly as it always has — except for a tenant on the institutional security profile, which must send it: without it the call answers `400` `IDEMPOTENCY_KEY_REQUIRED` and nothing runs. | Route | Page | | --- | --- | | POST /v1/transactions/ | [Create Transaction](https://docs.aureahub.com/docs/tx-create.md) | | POST /v1/transactions/{id}/broadcast | [Broadcast Transaction](https://docs.aureahub.com/docs/tx-broadcast.md) | | POST /v1/swap/approve | [Approve Token](https://docs.aureahub.com/docs/swap-approve.md) | | POST /v1/swap/execute | [Execute Swap](https://docs.aureahub.com/docs/swap-execute.md) | | POST /v1/swap/execute-solana | [Execute Solana Swap](https://docs.aureahub.com/docs/swap-execute-solana.md) | | POST /v1/swap/execute-gasless | [Execute Gasless](https://docs.aureahub.com/docs/swap-gasless.md) | | POST /v1/swap/broadcast | [Broadcast Swap Tx](https://docs.aureahub.com/docs/swap-broadcast.md) | | POST /v1/swap/broadcast-approve | [Broadcast Approve Tx](https://docs.aureahub.com/docs/swap-broadcast-approve.md) | | POST /v1/swap/broadcast-gasless | [Broadcast Gasless Tx](https://docs.aureahub.com/docs/swap-broadcast-gasless.md) | | POST /v1/dapps/broadcast | [Broadcast DApp Transaction](https://docs.aureahub.com/docs/dapps-broadcast.md) | | POST /v1/operations/{id}/speed-up | [Speed Up (switched off until Aurea turns it on)](https://docs.aureahub.com/docs/operation-speed-up.md) | | POST /v1/operations/{id}/drop | [Drop (switched off until Aurea turns it on)](https://docs.aureahub.com/docs/operation-drop.md) | The routes that move funds in the products Aurea switches on per tenant — the savings supply, withdrawal and broadcast, and the euro IBAN payout order — take the header with the same rules. [Contact us for details](https://www.aureahub.com/contact). | Request | Answer | | --- | --- | | **No header** | Runs as it always has. The answer carries no `idempotency-replayed` header. For an institutional tenant: `400` `IDEMPOTENCY_KEY_REQUIRED`, nothing runs. | | **A new key** | Runs. Its answer is kept for 24 hours and carries `idempotency-replayed: false`. | | **The same key and the same request, after an answer** | The first answer again — same status, same body — with `idempotency-replayed: true`. Nothing is signed, sent or broadcast a second time. | | **The same key and a different request** | `409` `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 after a first request that may have signed or sent and never answered** | `409` `IDEMPOTENCY_OUTCOME_UNKNOWN`. Aurea will not run it again, even after 24 hours: check whether the operation happened, then use a new key. | | **The same key and the same request, after a refusal made before anything was signed or sent** | Runs again: a validation error, a signature from another key or an unknown id frees the key. | | **The same key and the same request, after a `202`** | Institutional tenants: the call's operation was held for approval ([Approvals](https://docs.aureahub.com/docs/guide-approvals.md)). The same `202` again, with `idempotency-replayed: true` — never a second operation. It does not change once the operation is approved: follow the operation. | | **A key with other characters, or longer than 255** | `400` `IDEMPOTENCY_KEY_INVALID`. Nothing runs. | | **A key sent with a token that belongs to no tenant** | `400` `IDEMPOTENCY_KEY_INVALID`. Nothing runs. | - **A key belongs to your tenant and the route**, not to the user who sends it: two of your users sending the same key to the same route share it. Make every key unique — a UUID — and never derive it from the request. The same key on another route is another key. - **The same request** means the same path parameters, query and body; two bodies with the same fields and values, in any order, are the same. - **What is kept.** An answer is kept; an error is not. A request refused before anything was signed or sent frees its key. A request that fails after it signed or sent — a node's refusal of a broadcast, for example — leaves its key unknown, and so does a `5xx` that came after that point: the client gets the same `5xx` as without a key, and the key then answers `409` `IDEMPOTENCY_OUTCOME_UNKNOWN`. The dApp broadcast answers its own refusals (`400` `UNKNOWN_CHAIN` and `NO_RPC_URL`, the node's `422`): those are its answer, and are replayed. - **24 hours.** After 24 hours a key that was answered or freed is forgotten: the same key runs again. A key whose outcome is unknown is kept until Aurea settles it. The refusals have the usual error envelope, with the code in `details.code`: ```json { "statusCode": 409, "error": "ConflictError", "message": "This Idempotency-Key was used for a different request.", "details": { "code": "IDEMPOTENCY_KEY_REUSED" } } ``` | Status | details.code | message | | --- | --- | --- | | 400 | IDEMPOTENCY_KEY_INVALID | `An Idempotency-Key is up to 255 letters, digits, - or _.` | | 400 | IDEMPOTENCY_KEY_INVALID | `An Idempotency-Key belongs to a tenant: this caller has none.` | | 400 | IDEMPOTENCY_KEY_REQUIRED | `This request needs an Idempotency-Key header.` | | 409 | IDEMPOTENCY_KEY_REUSED | `This Idempotency-Key was used for a different request.` | | 409 | IDEMPOTENCY_IN_PROGRESS | `The first request with this Idempotency-Key is still running. Try again in a moment.` | | 409 | IDEMPOTENCY_OUTCOME_UNKNOWN | `The first request with this Idempotency-Key may have taken effect, and its outcome is not known yet. It will not be run again: check the operation before retrying with a new key.` | ```javascript // One key per send: create it once, keep it with the pending send, reuse it on every retry const idempotencyKey = crypto.randomUUID(); async function send(token, request) { const res = await fetch('https://api.aureahub.com/v1/transactions/', { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey }, body: JSON.stringify(request) // the same request on every retry }); const body = await res.json(); if (res.status === 409 && body.details?.code === 'IDEMPOTENCY_IN_PROGRESS') return retryLater(); if (res.status === 409 && body.details?.code === 'IDEMPOTENCY_OUTCOME_UNKNOWN') return checkTheSendFirst(); // never resend blindly return body; // on a retry, the first answer: res.headers.get('idempotency-replayed') === 'true' } ``` ## 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) | Without `Idempotency-Key`, sends a second transaction. With the key of the first request, answers with the first transaction. | | POST /v1/swap/execute | Without a key, sends, or prepares, a second swap. With the key of the first request, answers with the first answer. | | 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`. A signature from another key spends nothing: it is refused with `422` `WRONG_SIGNER`, and the right signature still goes through (a gasless permit is the exception — see [Broadcast Transaction](https://docs.aureahub.com/docs/tx-broadcast.md)). | | 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 - Send an `Idempotency-Key` wherever the route takes one: a retry with the same key and request can't run twice. - If a request that moves money times out or the connection drops and you sent no key, 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`: its operation was cancelled before anything was broadcast ([Cancel an Operation](https://docs.aureahub.com/docs/operation-cancel.md)), or a drop of it was mined ([Drop](https://docs.aureahub.com/docs/operation-drop.md)) — both act on recorded operations only ([Operations and Fees](https://docs.aureahub.com/docs/guide-operations.md)). ```text pending ──► confirmed │ ├──► failed └──► cancelled ``` ## 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 ``` ## Operation Returned by [List Operations](https://docs.aureahub.com/docs/operations-list.md) and [Get an Operation](https://docs.aureahub.com/docs/operation-get.md) — empty until Aurea turns operation recording on ([Operations and Fees](https://docs.aureahub.com/docs/guide-operations.md), where every sub-state is listed). `state` only moves forward: ```text SUBMITTED ─► PENDING_SCREENING ─► PENDING_ENRICHMENT ─► PENDING_POLICY ─► PENDING_AUTHORIZATION ─► QUEUED ─► PENDING_SIGNATURE ─► SIGNED ─► BROADCASTING ─► CONFIRMING ─► COMPLETED any state not final ─► FAILED or CANCELLING ─► CANCELLED, COMPLETED or FAILED up to SIGNED ─► CANCELLED up to QUEUED ─► BLOCKED up to PENDING_SIGNATURE ─► REJECTED ``` Final: `COMPLETED`, `CANCELLED`, `BLOCKED`, `REJECTED`, `FAILED`. An operation is `CANCELLED` when its initiator cancels it before anything was sent ([Cancel an Operation](https://docs.aureahub.com/docs/operation-cancel.md)) or a drop of its transaction is mined, and `CANCELLING` while that drop is on its way ([Drop](https://docs.aureahub.com/docs/operation-drop.md), switched off until Aurea turns it on). Only a tenant on the institutional security profile, whose policy is enforced, sees one `BLOCKED` (by its policy) or `REJECTED` (by an approver). `legacyStatus` — the transaction's `status` — is `confirmed` once `COMPLETED`, `failed` after `FAILED`, `BLOCKED` or `REJECTED`, `cancelled` after `CANCELLED`, and `pending` until then. ## Policy Set Returned by the [policy set](https://docs.aureahub.com/docs/guide-policy-sets.md) routes. Only a draft's rules change; a submitted, active or archived version never does. ```text draft ──► awaiting_quorum ──► active ──► archived (another version published in its place) │ │ └──────────────┴──► archived (withdrawn) ``` ## 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 The card issuer's 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 — sent to the webhook of each environment and to up to ten webhook endpoints per environment, with a log of every attempt and a resend —, 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. The `operation.*` events a webhook endpoint can ask for follow sends, approvals and swaps, but they are sent only while Aurea records operations, which is switched off until Aurea turns it on ([Operations and Fees](https://docs.aureahub.com/docs/guide-operations.md)), and only to the endpoints that name them. The same addresses can be told when one of your server credentials is revoked (`server_credential.revoked`, to the endpoints that name it), and — once Aurea turns them on — when an [account](https://docs.aureahub.com/docs/guide-accounts.md) is created, changed or given a wallet (`account.*`), when a transfer into one of your wallets is recorded (`transfer.inbound.*`), and when an [address book](https://docs.aureahub.com/docs/guide-address-book.md) entry moves or an operation goes outside the book (`address_book.*`) — each to the endpoints that name them ([Event Types](https://docs.aureahub.com/docs/guide-events.md)). ## 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 by Aurea's delivery worker: there is nothing to call on your side. ## 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 the card issuer 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 the card issuer 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: CARD_ISSUER_SIWE_DOMAIN, // as the card issuer requires uri: CARD_ISSUER_SIWE_URI, // as the card issuer requires 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), the issuer's 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 the quote }) }).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 --- # Accounts Group a customer's wallets under your own reference for that customer, find the account behind any address of your tenant, and create thousands of accounts at once. ## Overview - **An account** is your tenant's container of wallets on any chain, with `customerRefId` — your own reference for the customer: 1 to 128 of `A–Z a–z 0–9 _ . : @ / + -`, starting with a letter or a digit, unique in your tenant and never changed once set —, a `name`, up to 20 `tags`, and `hidden` (left out of the console's default list; nothing else changes). - **Its users**: an `owner` — the user whose wallets it holds, at most one — and `viewer`s, users who may see it, all users of your tenant. A wallet is always a user's: the wallets an account holds are its owner's wallets, made and listed as that user's (they are also in the owner's own [List Wallets](https://docs.aureahub.com/docs/wallets-list.md)). For a customer who is not one of your users, name one of your tenant's users as the owner. - **Nothing that exists changes.** Every wallet is in its user's **default account** until you put it in another, and every other route answers as before. - The routes: [List](https://docs.aureahub.com/docs/accounts-list.md), [Create](https://docs.aureahub.com/docs/account-create.md), [Get](https://docs.aureahub.com/docs/account-get.md) and [Change](https://docs.aureahub.com/docs/account-update.md) an account, [Add a Wallet](https://docs.aureahub.com/docs/account-wallet-add.md), [Find by Address](https://docs.aureahub.com/docs/account-lookup.md), and the bulk creation ([Create in Bulk](https://docs.aureahub.com/docs/accounts-bulk-create.md), [List](https://docs.aureahub.com/docs/accounts-bulk-list.md), [Report](https://docs.aureahub.com/docs/accounts-bulk-get.md)). ```json { "id": "3b8f2c6e-1d4a-4e9b-8f7c-2a5d6e9b1c40", "customerRefId": "cust-00042", "name": "Savings, customer 42", "tags": ["retail", "eu"], "hidden": false, "isDefault": false, "frozen": false, "users": [ { "userId": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "access": "owner" } ], "wallets": [ { "walletId": "7c1e9b24-3f5a-4d8e-9b6c-1a2f3e4d5c6b", "chain": "evm", "family": "evm", "isTestnet": false, "address": "0x52908400098527886E0F7030069857D2E4169EE7", "keyManagementScheme": "sss_2of2_akv", "label": null, "isPrimary": false } ], "createdBy": "0d2f6b8a-4c1e-4a93-b7d5-2e9f1c3a8b60", "createdAt": "2026-10-07T09:12:40.511Z", "updatedAt": "2026-10-07T09:13:02.118Z" } ``` ## Who Calls the Routes - **Your tenant's administrators, with their access token**, on your own tenant. Reading — the list, an account, the lookup, the bulk reports — is for the roles that read the tenant's transactions: `owner`, `tenantadmin`, `non_signing_admin`, `signer`, `approver`, `editor`, `viewer` and `auditor`. Creating, changing, adding a wallet and the bulk creation are for `owner`, `tenantadmin` and `non_signing_admin`, who manage your users ([Roles](https://docs.aureahub.com/docs/authentication.md)). Any other role, an end user's included: `403` `Tenant admin access required. Your role does not have permission to perform this action.` - Only an access token opens them: your server calls them signed in as one of your administrators. The tenant signature and server tokens are not accepted here. - **A request written wrong is refused whole**, never repaired: `400` `ACCOUNT_REQUEST_INVALID`, with each problem in `details.errors` (up to 50) as `field: reason`. A field the route does not take is refused too. ```json { "statusCode": 400, "error": "BadRequestError", "message": "Invalid request", "details": { "code": "ACCOUNT_REQUEST_INVALID", "errors": [ "customerRefId: 1 to 128 of A–Z a–z 0–9 _ . : @ / + -, starting with a letter or digit", "(body): Unrecognized key(s) in object: 'owner'" ] } } ``` ## The Default Account - Every user of your tenant has one: **its id is the user's id**, its owner is the user (`isDefault` `true`), and it holds the user's wallets that are in no other account — on every chain, and as many EVM addresses as the user has. - Nothing is stored for it until someone writes to it: it is computed when read, and the first change — a name, tags, `hidden`, a viewer, a `customerRefId` — stores it. Reading writes nothing. Its `createdAt` is the user's; `createdBy` is `null` until it is first written. - [List Accounts](https://docs.aureahub.com/docs/accounts-list.md) shows a default account while it holds a wallet, or once it was written to; [Get an Account](https://docs.aureahub.com/docs/account-get.md) reads any user's default account by the user's id. - Its owner is always its user (`400` `ACCOUNT_DEFAULT_OWNER`). Putting a wallet in another account takes it out of the default one; nothing else moves it, and no route takes a wallet out of an account. ## One EVM Key per Account - An account you create holds **one EVM address per network, valid on every EVM chain** — the same address on each —, and one wallet per chain and network for Solana. Each of its wallets carries `family`: `evm` for an EVM chain, else the chain itself. - Asking for a wallet on any EVM chain — `gnosis`, `base`, or `evm` itself — makes the account's EVM key once, stored on chain `evm`; asking again for any EVM chain answers `409` `ACCOUNT_CHAIN_TAKEN` with the key it holds (`details.walletId`, `details.address`). The mainnet key and the testnet key are two (`isTestnet`): a testnet key never signs on a mainnet. - Requests made at the same time for the same account and chain make one wallet; the others answer `409` `ACCOUNT_CHAIN_TAKEN`. ## Wallets in an Account [Add a Wallet](https://docs.aureahub.com/docs/account-wallet-add.md) takes one of two bodies. The account needs an owner first (`409` `ACCOUNT_OWNER_REQUIRED`): - **`{ "chain", "isTestnet", "name" }` — Aurea makes it** for the account's owner, by the same path as [Create Wallet](https://docs.aureahub.com/docs/wallets-create.md), when your tenant's wallets are custodial (Aurea holds their keys). Where the keys are on the user's device or 2-of-3 MPC, Aurea makes none: `409` `ACCOUNT_WALLET_BY_CLIENT`, with `details.walletMode` `non_custodial` or `mpc`. The user's device makes the wallet as always — [Register Client Wallet](https://docs.aureahub.com/docs/wallets-client-register.md), or the [MPC key generation](https://docs.aureahub.com/docs/mpc-dkg-start.md) — and you put it in with the second body. - **`{ "walletId" }` — put one of the owner's wallets in it**, one that is in no other account. An EVM wallet goes in with every wallet of the owner at the same address on the same network — one per EVM chain the address is registered on, the address compared without regard to case —, and a wallet registered later at that address joins it. - The account lists each wallet it holds; for an EVM address registered on several EVM chains, one entry per chain, all with `family` `evm` and the same address. A deactivated wallet is not shown. ## Find the Account of an Address A deposit arrives at one of your addresses: [Find by Address](https://docs.aureahub.com/docs/account-lookup.md) tells you whose it is. ```bash curl "https://api.aureahub.com/v1/accounts/lookup?address=0x52908400098527886e0f7030069857d2e4169ee7&chain=base" \ -H "Authorization: Bearer $TENANT_ADMIN_TOKEN" ``` - Every active wallet of your tenant at the address — one per chain and network it is registered on —, each with its account: the account that holds it, or its user's default account (`isDefault` `true`), and that account's `customerRefId`. - An EVM address is compared without regard to case, a Solana address exactly. Anything else: `400` `ACCOUNT_ADDRESS_INVALID`; no wallet of your tenant there: `404` `ACCOUNT_ADDRESS_UNKNOWN`. - `chain` (a slug, or a testnet's id) and `isTestnet` narrow it. An EVM chain matches the address's wallets on **every** EVM chain: a deposit seen on `base` to an address registered only on `gnosis` is found. ## Create Accounts in Bulk > ℹ️ **Switched off until Aurea turns it on.** Until then [Create in Bulk](https://docs.aureahub.com/docs/accounts-bulk-create.md) answers `503` `ACCOUNTS_BULK_OFF` (`Bulk creation of accounts is switched off.`) to a request written right. [Contact us](https://www.aureahub.com/contact) to use it. - Up to **10,000 accounts** in one request, each with its `customerRefId` — required here: it is what makes the job safe to send again —, and optionally `wallets`, up to 20 `{ "chain", "isTestnet" }` made for every account. Every EVM chain named is the account's one EVM key; a family named twice is one wallet. The body may be up to 6 MiB. - **Checked whole before anything is stored**: a `customerRefId` named twice (`400` `ACCOUNTS_BULK_DUPLICATE_REF`, the repeated ones in `details.customerRefIds`); with `wallets`, a custodial tenant (`409` `ACCOUNT_WALLET_BY_CLIENT`), chains a wallet can be made on (`400` `ACCOUNT_CHAIN_UNKNOWN`) and an owner for every account (`400` `ACCOUNT_OWNER_REQUIRED`, the accounts' places in `details.indexes`); every user named one of yours (`400` `ACCOUNT_USER_UNKNOWN`). - **`202` with the job** (`jobId`, `status` `queued`, `total`): the accounts are made in the background, the oldest job first, each job in the order of its request; wallets are made one at a time. - An account whose `customerRefId` your tenant already has is `skipped` (`reason` `ACCOUNT_REF_EXISTS`, `accountId` the one that holds it): sending the same request again makes nothing twice. A user gone meanwhile fails the account (`ACCOUNT_USER_UNKNOWN`); a wallet the wallet path refuses fails it with that path's code, and one that fails on Aurea's side is tried again, three tries in all. - Follow it with [Bulk Creation Report](https://docs.aureahub.com/docs/accounts-bulk-get.md): the job's `status` (`queued`, `running`, `completed` once no account is pending), its `counts`, and each account in the request's order — `pending`, `created`, `skipped` or `failed`, with its `accountId` and `reason`. ## Freeze an Account - Your tenant's freezers — `owner`, `tenantadmin`, `non_signing_admin` and `security_admin` — freeze an account with [Freeze](https://docs.aureahub.com/docs/freezes-create.md), `target` `account` and `id` the account's id — for a user's default account, the user's id. - **Its wallets stop signing**: every route that would sign or send with a wallet it holds answers `423` `ACCOUNT_FROZEN` (`This wallet's account is frozen: its wallets cannot sign until the freeze is lifted.`), as a frozen wallet does — every wallet of your tenant at the address of a wallet the account holds, on every chain. Reads go on, and the user's wallets outside the account still sign. The account's `frozen` is `true`. - Freezing an account you own, or your own default account, is allowed; lifting that freeze is not (`403` `FREEZE_SELF`). Another tenant's account: `404` `FREEZE_TARGET_NOT_FOUND` (`No such account here.`). More: [Freezes & Quarantines](https://docs.aureahub.com/docs/guide-freezes.md). ## Events - `account.created` and `account.updated` — `data` the account as [Get an Account](https://docs.aureahub.com/docs/account-get.md) answers it —, and `account.wallet_added` — `data` `accountId`, `customerRefId`, `walletId`, `chain`, `family`, `isTestnet`, `address` and `how`: `created` (made for the account) or `attached` (one of the owner's wallets put in it). - **Switched off until Aurea turns them on: none is sent today.** Once on, they reach only the [webhook endpoints](https://docs.aureahub.com/docs/webhook-endpoint-add.md) that name them — never an endpoint that names no type, never the webhook of an environment. An account's news goes to your production endpoints; a wallet's to the endpoints of its network (a testnet wallet's: sandbox). - Each is queued with the change itself: no news of a change that did not happen, no change without its news ([Events to Your Server](https://docs.aureahub.com/docs/guide-events.md)). ## Operations and the Audit Trail - Every operation's answer carries `sourceAccountId`: the account of its wallet — the account that holds it, else its user's default account (the user's id) —, or `null` when it has no wallet ([Operations and Fees](https://docs.aureahub.com/docs/guide-operations.md)). The `operation.*` events do not carry it. - The [audit trail](https://docs.aureahub.com/docs/audit-events.md), when on, records `account.created`, `account.updated` and `account.bulk_requested` (category `governance`), `account.wallet_created` and `account.wallet_attached` (category `keys`) — with ids, counts, chains and field names, never a `customerRefId`, a name, a tag or an address. Web version: https://docs.aureahub.com/#guide-accounts --- # 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. 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 - Send an `Idempotency-Key` with `POST /v1/transactions/` and its broadcast, and retry with the same key: the same request answers with the first answer instead of sending again ([Idempotency](https://docs.aureahub.com/docs/idempotency.md)). Without a key, 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`. - Preparing a new send, approval or swap for the same wallet on the same chain before a standard send was broadcast gives the new one that send's nonce: the send answers `409` `PREPARED_TX_SUPERSEDED`. Broadcast the newest preparation. - 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`. ## Institutional Tenants For a tenant on the institutional security profile, `POST /v1/transactions/` and its broadcast need an `Idempotency-Key`, and the tenant's [policy set](https://docs.aureahub.com/docs/guide-policy-sets.md) decides before anything is signed, handed out to sign, or sent. A rule that asks for an approval holds the send: `POST /v1/transactions/` answers `202` with the operation to follow, and nothing is signed or sent until the rule's approvers approve it. A custodial send is then signed and sent; a non-custodial one is prepared, and you sign and broadcast it as above — [Approvals](https://docs.aureahub.com/docs/guide-approvals.md). Web version: https://docs.aureahub.com/#guide-send-transaction --- # Operations and Fees One public state machine for everything that moves funds, beside the transactions you already read — and the fee levels, spendable amounts and address checks to prepare a send. ## Overview - **An operation** is what a money route started — a send, an approval, a swap, a savings deposit or withdrawal, a payout order to a bank account, a dApp broadcast — followed from the request to its end, with every state it passed and when. - **It changes nothing you already read.** The money routes answer exactly as before, and a transaction keeps its four statuses ([Enums & State Machines](https://docs.aureahub.com/docs/enums-states.md)): an operation's `legacyStatus` is that status, projected from its state. The one exception is a tenant on the institutional security profile, whose policy can hold an operation for approval: the route then answers `202` with the operation to follow (Institutional Tenants, below). - Read them with [List Operations](https://docs.aureahub.com/docs/operations-list.md) and [Get an Operation](https://docs.aureahub.com/docs/operation-get.md). A user reads their own; the tenant roles that read the tenant's transactions — `owner`, `tenantadmin`, `non_signing_admin`, `signer`, `approver`, `editor`, `viewer`, `auditor` — read every operation of the tenant; `security_admin` and `security_auditor` read only their own. - Webhook endpoints that name them receive the `operation.created`, `operation.state_changed`, `operation.completed` and `operation.failed` events ([Events to Your Server](https://docs.aureahub.com/docs/guide-events.md)) — an endpoint that names no type does not: `data` is the operation as Get an Operation answers it, without `events`; `operation.state_changed` adds `previousState` and `previousSubState`. - `tokenizedOperationId` is the tokenized assets operation an operation carries out, when it carries one out; `null` for every other operation. - `sourceAccountId` is the [account](https://docs.aureahub.com/docs/guide-accounts.md) of its wallet — the account that holds the wallet, else its user's default account (the user's id) —, or `null` when it has no wallet. The reads carry it; the `operation.*` events do not. ## Switched Off Until Aurea Turns It On Aurea records operations only once it turns recording on. Until then nothing is recorded and no `operation.*` event is sent: List Operations answers an empty page, and a read by id answers `404` `OPERATION_NOT_FOUND`. ```json { "operations": [], "nextCursor": null } ``` Cancelling, speeding up and dropping act on recorded operations only. The fee, spendable-amount and address routes below need nothing switched on. ## States ```text SUBMITTED → PENDING_SCREENING → PENDING_ENRICHMENT → PENDING_POLICY → PENDING_AUTHORIZATION → QUEUED → PENDING_SIGNATURE → SIGNED → BROADCASTING → CONFIRMING → COMPLETED ways out: CANCELLING (then CANCELLED, COMPLETED or FAILED), CANCELLED, BLOCKED, REJECTED, FAILED ``` - **Final**: `COMPLETED`, `CANCELLED`, `BLOCKED`, `REJECTED`, `FAILED`. A state only moves forward, and never leaves a final one. - `FAILED` and `CANCELLING` can follow any state that is not final; `CANCELLED` only while nothing was sent (up to `SIGNED`); `BLOCKED` up to `QUEUED`; `REJECTED` up to `PENDING_SIGNATURE`. - **Today** screening, enrichment and the queue stop nothing: an operation passes them with the sub-state `not_gated`. Policy and authorization stop nothing either — `not_gated`, and no operation `BLOCKED` or `REJECTED` — except for a tenant on the institutional security profile, whose policy is enforced (Institutional Tenants, below). The states are there so the machine doesn't change when they start to act. - An operation is `CANCELLED` when its initiator cancels it before anything of it was sent, or when a drop of its transaction is mined; `CANCELLING` while that drop is on its way (Cancel, Speed Up, Drop, below). - An operation's `events` list every state it passed, in order, with its sub-state, failure reason, hash and time: a state an operation only passed on the way is listed too. - Treat a state or sub-state you don't know as not final. ## Sub-States | state | subState | Meaning | | --- | --- | --- | | SUBMITTED | — | Recorded. | | PENDING_SCREENING | not_gated | Passed without a screening: there is none yet. | | PENDING_ENRICHMENT | not_gated | Passed without a decoding or a simulation: there is none yet. | | PENDING_POLICY | not_gated | Passed without a policy that can stop it: your tenant's policy is not enforced ([Policy Sets](https://docs.aureahub.com/docs/guide-policy-sets.md)). | | PENDING_POLICY | evaluated | Institutional tenants: evaluated by your tenant's policy, which is enforced; its verdict decides what follows. | | PENDING_AUTHORIZATION | not_gated | Passed without an approval: none is asked for. | | PENDING_AUTHORIZATION | not_required | Institutional tenants: passed without an approval — your tenant's policy allowed it as it is. | | PENDING_AUTHORIZATION | awaiting_approval | Institutional tenants: held for approval by your tenant's policy. Nothing is signed or sent until the rule's approvers approve it ([An Operation's Approval](https://docs.aureahub.com/docs/operation-approval.md)). | | PENDING_AUTHORIZATION | approved | Institutional tenants: approved by the rule's threshold of approvers; it goes on as the call that started it would have. | | QUEUED | not_gated | Passed without a queue of its own: the wallet's nonce is taken as before. | | PENDING_SIGNATURE | awaiting_client_signature | Handed to the user's device (or MPC) to sign: what it signs comes back with the broadcast. | | PENDING_SIGNATURE | signing | Signed by Aurea for the wallet it holds the key of. | | SIGNED | signed_by_client | Signed on the user's side. | | SIGNED | signed_by_hub | Signed by Aurea. | | BROADCASTING | sending | Being handed to the chain's node, or to the provider. | | BROADCASTING | outcome_unknown | Handed over without an answer: it may still go through, and is never sent again by itself. | | CONFIRMING | awaiting_confirmation | On its way on chain, waiting for its receipt. | | CONFIRMING | awaiting_provider | Accepted by the provider, which has not settled it yet. | | CONFIRMING | awaiting_delivery | A swap mined on its source chain that the swap router has not delivered yet (a cross-chain or intent swap). Only while Aurea tracks swap deliveries, which is switched off until Aurea turns it on; the transaction's `status` is not affected. | | CONFIRMING | awaiting_finalization | A Solana transaction the cluster confirmed, waiting to be finalized. Only where Aurea runs the operations' reconciliation, which is switched off until Aurea turns it on; its answer and its transaction already say `confirmed`. | | CONFIRMING | confirming | Mined, and waiting until its chain has built the platform's confirmations for that chain on it. Only where Aurea waits for confirmations, which is switched off until Aurea turns it on (Waiting for Confirmations, below). | | CONFIRMING | reorged | Its block left the chain in a reorganisation: the same signed transaction was handed to the node again, and it waits to be mined again (confirmations only). | | CONFIRMING | sped_up | Its transaction was replaced by the same one at the same nonce with a higher fee ([Speed Up](https://docs.aureahub.com/docs/operation-speed-up.md)): whichever of them is mined ends it. | | COMPLETED | confirmed | Mined and successful. | | COMPLETED | processed | Settled by the provider. | | COMPLETED | signed | A signature, not a transaction: the message or digest is signed, and nothing is sent on chain. | | COMPLETED | delivered | A swap the swap router delivered as quoted (delivery tracking only). | | COMPLETED | delivered_other_token | A swap the swap router delivered in another token than the one quoted, on a partial route (delivery tracking only). | | CANCELLING | drop_sent | A 0-value transaction to the wallet itself was sent at its transaction's nonce with a higher fee ([Drop](https://docs.aureahub.com/docs/operation-drop.md)): `CANCELLED` if it is mined, `COMPLETED` or `FAILED` if the original is. | | FAILED | not_sent | Refused or failed before anything was signed or sent. | | FAILED | submission_failed | Sending it failed: the node or the provider refused it, or did not take it. | | FAILED | reverted | Mined, and reverted. | | FAILED | never_broadcast | Never signed and sent back within an hour: it can no longer be broadcast. | | FAILED | provider_rejected | Refused by the provider after it took the order. | | FAILED | delivery_failed | A swap mined on its source chain that the swap router reports failed on the way (delivery tracking only). | | FAILED | refunded | A swap the swap router could not deliver and refunded on the source chain (delivery tracking only). | | FAILED | approval_expired | Institutional tenants: held for approval and not approved within the approval window, where one applies. Nothing was signed or sent. | | FAILED | dropped | Its block left the chain in a reorganisation, and another transaction of the wallet took its nonce: it can never be mined (confirmations only). | With a failing end, `failureReason` is the code of the answer that ended it, for example `RPC_ERROR`; a cancelled operation's is `OPERATION_CANCELLED`, a dropped one's `OPERATION_DROPPED`. `BLOCKED`, `REJECTED` and `CANCELLED` carry no sub-state. ## Cancel, Speed Up, Drop The initiator of an operation — holding the permission that starts it, so `user`, `owner`, `tenantadmin` or `signer` — can stop it, or push its transaction on its way: | Route | When | What happens | | --- | --- | --- | | [Cancel](https://docs.aureahub.com/docs/operation-cancel.md) | Nothing of it was sent: waiting for the client's signature, or held for approval (institutional tenants). | `CANCELLED`. What it prepared can no longer be broadcast (`409` `OPERATION_CANCELLED`), a prepared transaction's nonce is free again, its transaction is `cancelled`; a held one's approval request is `withdrawn`. | | [Speed Up](https://docs.aureahub.com/docs/operation-speed-up.md) | A custodial EVM transaction sent and not mined. | The same transaction at the same nonce, for at least 12.5% more fee: `CONFIRMING` `sped_up`, ended by whichever of the two is mined. | | [Drop](https://docs.aureahub.com/docs/operation-drop.md) | A custodial EVM transaction sent and not mined. | A 0-value transaction to the wallet itself at the same nonce: `CANCELLING` `drop_sent`, then `CANCELLED` if the drop is mined, or as the original ended if it is. | - Speed Up and Drop — replacement by fee — are **switched off until Aurea turns them on**: until then they answer `403` `OPERATIONS_RBF_OFF`. A client-side wallet's replacement must be signed on the user's device, and an MPC wallet's is not available. - All three act on recorded operations only (above). Once one of a replaced operation's transactions is mined, the transaction's own record follows it: `confirmed` under the mined hash, or `cancelled` for a mined drop. ## Waiting for Confirmations Where Aurea waits for confirmations — switched off until Aurea turns it on —, a mined operation is not `COMPLETED` at once: it waits in `CONFIRMING` `confirming` until its chain has built the platform's confirmations for that chain on it. - `CONFIRMING` `reorged`: its block left the chain in a reorganisation; the same signed transaction was handed to the node again, and it waits to be mined again. - `FAILED` `dropped`: its block left the chain, and another transaction of the wallet took its nonce — it can never be mined. Mined and reverted: `FAILED` `reverted`. - Solana, where Aurea runs the operations' reconciliation: `CONFIRMING` `awaiting_finalization`, a transaction the cluster confirmed waiting to be finalized; its answer and its transaction already say `confirmed`. - Each move is an `operation.state_changed` event to the endpoints that name it, as any other. Until this is switched on, a mined transaction completes its operation at its receipt. ## Institutional Tenants: Policy and Approvals For a tenant on the institutional security profile — set by Aurea with the tenant ([contact us](https://www.aureahub.com/contact)) — operations are recorded, and the tenant's [policy set](https://docs.aureahub.com/docs/guide-policy-sets.md) is enforced at every signing step: - **Allowed:** `PENDING_POLICY` `evaluated` → `PENDING_AUTHORIZATION` `not_required` → on its way as usual. - **Blocked or frozen:** `BLOCKED`, `failureReason` `POLICY_BLOCKED` or `POLICY_FROZEN`. Any other refusal of the policy ends it `FAILED` `not_sent` with the refusal's code. - **Held for approval** on the money routes that start an operation — [Create Transaction](https://docs.aureahub.com/docs/tx-create.md), [Approve Token](https://docs.aureahub.com/docs/swap-approve.md), [Execute Swap](https://docs.aureahub.com/docs/swap-execute.md), [Execute Solana Swap](https://docs.aureahub.com/docs/swap-execute-solana.md), [Execute Gasless](https://docs.aureahub.com/docs/swap-gasless.md), and the savings vault's supply and withdrawal: the route answers `202` with `operationId`, `state` `PENDING_AUTHORIZATION`, `subState` `awaiting_approval` and the approval request. Then `approved` and on its way — a client-side or MPC wallet's to `PENDING_SIGNATURE` `awaiting_client_signature`, its prepared transaction in [An Operation's Approval](https://docs.aureahub.com/docs/operation-approval.md) — or `REJECTED` (`failureReason` `APPROVAL_REJECTED`), or `FAILED` `approval_expired` (`APPROVAL_EXPIRED`) where an approval window applies. [Approvals](https://docs.aureahub.com/docs/guide-approvals.md). - **The money routes need an `Idempotency-Key`**: `400` `IDEMPOTENCY_KEY_REQUIRED` without one. The key of a held call answers its `202` again; follow the operation for the outcome. ```json "events": [ { "seq": 1, "state": "SUBMITTED", "subState": null, "failureReason": null, "txHash": null, "at": "2026-10-06T12:01:44.120Z" }, { "seq": 2, "state": "PENDING_SCREENING", "subState": "not_gated", "failureReason": null, "txHash": null, "at": "2026-10-06T12:01:44.120Z" }, { "seq": 3, "state": "PENDING_ENRICHMENT", "subState": "not_gated", "failureReason": null, "txHash": null, "at": "2026-10-06T12:01:44.120Z" }, { "seq": 4, "state": "PENDING_POLICY", "subState": "evaluated", "failureReason": null, "txHash": null, "at": "2026-10-06T12:01:44.120Z" }, { "seq": 5, "state": "PENDING_AUTHORIZATION", "subState": "awaiting_approval", "failureReason": null, "txHash": null, "at": "2026-10-06T12:01:44.131Z" }, { "seq": 6, "state": "PENDING_AUTHORIZATION", "subState": "awaiting_approval", "failureReason": null, "txHash": null, "at": "2026-10-06T12:20:03.511Z" }, { "seq": 7, "state": "PENDING_AUTHORIZATION", "subState": "approved", "failureReason": null, "txHash": null, "at": "2026-10-06T12:24:51.302Z" }, { "seq": 8, "state": "QUEUED", "subState": "not_gated", "failureReason": null, "txHash": null, "at": "2026-10-06T12:24:51.990Z" }, { "seq": 9, "state": "PENDING_SIGNATURE", "subState": "awaiting_client_signature", "failureReason": null, "txHash": null, "at": "2026-10-06T12:24:52.004Z" } ] ``` A held client-side send's history: each approval short of the threshold is a step of its own in the same sub-state (`seq` 6), and the threshold moves it on. ## Types | type | Started by | | --- | --- | | TRANSFER | [Create Transaction](https://docs.aureahub.com/docs/tx-create.md), continued by [Broadcast Transaction](https://docs.aureahub.com/docs/tx-broadcast.md) for a client-side wallet; a dApp broadcast of a transfer. | | APPROVE | [Approve Token](https://docs.aureahub.com/docs/swap-approve.md) and its broadcast; a dApp broadcast of an approval. | | SWAP | Execute Swap, Execute Solana Swap, Execute Gasless and their broadcasts. | | CONTRACT_CALL | A dApp broadcast that calls a contract. | | DEPLOY | A dApp broadcast with no destination. | | RAW | A dApp broadcast Aurea cannot read; an MPC signature of a digest that is none of the wallet's prepared transactions. | | TYPED_MESSAGE | A message a key Aurea holds signs: the permit of a custodial gasless swap, a dApp's personal message signed with a custodial key. It ends `COMPLETED` / `signed`. | | EARN | A savings deposit or withdrawal. | | PAYOUT | A payout order to a bank account. | `origin` names the call that started it, for example `transactions.create`. A request that stops before its first signing step — a validation error, an unknown id, a replayed `Idempotency-Key` — records nothing. ## The Transaction's Status `legacyStatus` is the operation's state as a transaction's `status` says it, and the transaction the same calls wrote (`transactionId`) carries that very status: - `confirmed` once `COMPLETED`; - `failed` after `FAILED`, `BLOCKED` or `REJECTED`; - `cancelled` after `CANCELLED`; - `pending` until then. ## Fees and the Most You Can Send - [Estimate Fees](https://docs.aureahub.com/docs/fees-estimate.md): `LOW`, `MEDIUM` and `HIGH` fee levels for one of your tenant's chains, read from the chain's recent fees through your tenant's own node. On EVM chains, from the last 20 blocks' fee history: each level's tip is the median, over the blocks, of the 10th, 50th or 90th percentile of the tips paid (never below the level under it), and its `maxFeePerGas` is the next block's base fee × 1.125, × 1.5 or × 2, plus the tip. A chain without a fee history or a base fee uses its gas price, × 1, × 1.125 or × 1.25 (`model` `legacy`). On Solana, the 10th, 50th or 90th percentile of the priority fees recent blocks paid, on top of 5000 lamports a signature. - Each level's `networkFee` is what a plain native transfer costs at most at that level: 21 000 gas at `maxFeePerGas` on EVM; one signature and the priority fee of 200 000 compute units on Solana. - [Most a Wallet Can Send](https://docs.aureahub.com/docs/wallet-max-spendable.md): for the native coin, its balance minus the fee of sending it at the level, never below zero; for a token, its whole balance, with `feeCovered` saying whether the native balance pays the fee (a token transfer's gas as the node estimates it, plus 30%). - Amounts are decimal text in the smallest unit (wei, lamports, the token's smallest unit); a fee per gas is in wei, a Solana priority fee in micro-lamports per compute unit. Nothing is signed or sent. ## Address Checks [Validate an Address](https://docs.aureahub.com/docs/address-validate.md) says whether an address is well formed for a chain and what sits there, before you send to it. The address goes in the body, so it stays out of URLs and logs. - **EVM**: `0x` and 40 hex digits. All lower or all upper case carries no checksum (`checksum` `absent`: valid, but a typo would not have been caught); mixed case must be the EIP-55 checksum (`valid`), else the address is refused (`reason` `checksum`). `normalized` is the checksummed form. `kind`, from one read of your tenant's node: `eoa`, `contract`, `delegated_eoa` (an EIP-7702 delegation), or `unknown` when the node does not answer. - **Solana**: base58 of exactly 32 bytes; `kind` `wallet` on the curve, `program_derived` off it — no key can sign for such an address. `checksum` is `not_applicable`. Web version: https://docs.aureahub.com/#guide-operations --- # Token Swaps Every swap starts with a 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 Aurea's swap router. [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 the router 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 the swap router 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"`, the quote carries 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 the swap router 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 the swap router. There is no `slippage_bps` field, and sending `50` means 50%. - `fromAmount` on the quote must match `^\d+(\.\d+)?$` and is forwarded to the swap router 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. - Approve, execute and broadcast take an `Idempotency-Key`: retried with the same key, the same request answers with the first answer instead of running again ([Idempotency](https://docs.aureahub.com/docs/idempotency.md)). Without a key, requests are not de-duplicated, so a repeated `/execute` sends or prepares another transaction: after a timeout, check the wallet's transactions before retrying. - A non-custodial swap prepared while an earlier preparation of the same wallet on the same chain was still unsigned takes its nonce: broadcasting the earlier one answers `409` `PREPARED_TX_SUPERSEDED`. - The three broadcast endpoints are limited to 10 requests per 10 seconds. - **Institutional tenants:** every swap route that moves funds needs an `Idempotency-Key`, and the tenant's [policy set](https://docs.aureahub.com/docs/guide-policy-sets.md) decides before anything is signed. A rule asking for an approval holds an approval or a swap — Approve Token, Execute Swap, Execute Solana Swap and Execute Gasless answer `202` with the operation to follow — and refuses a broadcast (`403` `POLICY_APPROVAL_REQUIRED`) unless its operation was approved: [Approvals](https://docs.aureahub.com/docs/guide-approvals.md). - 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 `owner`, `tenantadmin`, `non_signing_admin` or `security_admin`) 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) — always, when the session has no `clientSecret` (see **Opening the widget**). 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 wallets live on some networks only: a pair whose `aureaWallets` is `false` in [the configuration](https://docs.aureahub.com/docs/card-onramp-config.md) is delivered to an address the user proved or a wallet your server attests, and a session to one of the user's Aurea wallets is refused (`400` `CARD_ONRAMP_DESTINATION_NOT_ALLOWED`) — Aurea would neither show nor move what arrives there. - 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 — and, with the second payment provider below, 73 more pairs on 26 networks. [Configuration](https://docs.aureahub.com/docs/card-onramp-config.md) lists the ones your tenant offers. 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. In the first payment provider's widget the user can still switch currency: 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 second payment provider's widget keeps the session's currency. Aurea can also serve your tenant with a second payment provider, which sells pairs and currencies the first does not — ask your Aurea contact. Your app changes nothing: `sourceCurrencies` then names every currency either provider sells a pair in, and each session goes to the provider that sells its pair in its currency, or — where both do — to the one that gives the user more crypto for what they pay. A session opened for a user the first provider cannot serve in their country goes to the second instead, where it sells the pair in that currency. The table above is the first provider's. The second payment provider sells, in the sandbox, every pair above in EUR and in USD, and 67 more pairs in both — measured on its widget, pair by pair: - Ethereum: `musd`, `eurcv`, `usdcv`, `usat`, `uni`, `link`, `wbtc`, `shib`, `aave`, `usde`, `ape`, `fdusd`, `mana`, `chz`, `xaut`, `usdg`, `rlusd`, `aurora`, `pyusd`, `arkm`, `ausd`, `eurc`, `defi`, `eurau` - Solana: `usdt` - BNB Smart Chain: `bnb`, `usdt`, `usdc`, `fdusd`, `twt`, `pbx`, `strx` - Polygon: `usdt`, `dai`, `weth` - Base: `eurc`, `eurau` - Monad: `mon` - Arbitrum: `eth`, `usdc` - Optimism: `eth` - ZKsync: `eth` - Linea: `eth`, `usdc`, `musd` - Immutable zkEVM: `eth`, `usdc`, `imx` - B3: `eth` - Unichain: `eth`, `usdc` - Soneium: `eth` - Celo: `usdt`, `cusd` - Avalanche: `ausd` - SKALE Europa: `usdc` - Ronin: `usdc`, `ron`, `weth` - Etherlink: `usdc` - Cronos: `cro` - Mantle: `mnt` - XDC Network: `xdc` - Beam: `beam` - Moonbeam: `glmr` - Aurora: `aurora` - Henesys: `nxpc` It also lists `dai/ethereum`, `xtz/etherlink`, `wxtz/etherlink`, `bonk/solana`, `slp/ronin`, `musd/monad`, which it does not sell in the sandbox. In production it sells nothing yet: `sourceCurrencies` says so, and will name its currencies once Aurea has measured them there. Most of these tokens are not ones Aurea wallets show: their `aureaWallets` is `false`, and they go to an address the user proved or to a wallet your server attests. 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 How firm the amount is depends on the payment provider serving the session. In the first one's widget (a session answered with a `clientSecret`), `sourceAmount` (fiat) or `destinationAmount` (crypto) only suggests an amount: the user can change it, and the fiat currency. The second one's widget (a session that opens only in the hosted page, `clientSecret` `null`) keeps the session's fiat currency, and a `sourceAmount` you send is fixed there too; a `destinationAmount` is passed to it as the crypto amount, and without an amount the user types one. Payment providers count fees differently — one adds them on top of `sourceAmount`, another takes them from it — so compare prices with [Quotes](https://docs.aureahub.com/docs/card-onramp-quotes.md), whose `sourceTotalAmount` is always what the user pays in total. 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 first payment provider's 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. A session served by a payment provider whose widget opens only in Aurea's hosted page has `clientSecret` and `publishableKey` `null` from the start: send the user to the hosted page for it. The hosted page opens every session, whichever provider serves it, so an app that always uses it needs no case of its own. ## 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`, `refunded` 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 serving the session is named once, in a small line under the widget. - **Open it on the user's device.** Some payment providers tie their widget to the address the page is opened from: open the link in the user's browser, never from a server or through a proxy. - **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). | | `refunded` | The payment provider took the payment and gave it back to the card: it could not deliver the crypto. | A status only moves forward: an older answer never undoes a newer one; `fulfillment_complete`, `rejected`, `refunded` and `failed` are final. `providerStatus` is the payment provider's own word, and can name a status added later. A declined card does not end a session: the user can try again in the widget, and `providerStatus` says what the payment provider answered. ## 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", "aureaWallets": true, "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`, not a `refunded` one — 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 --- # Earn Yield products for your users' stablecoins: a deposit from the user's own wallet, a position that grows with a variable rate, and a withdrawal back to the same wallet — through one API, whatever product serves it. ## Overview The routes under `/v1/earn` list the products your tenant offers, quote a deposit or a withdrawal, make it from one of the user's wallets, and follow it to the end. A product is identified by its `productId` (for example `lumen` or `stablecoin-savings-usdc`) and holds one asset; its rate is variable and is always answered with the time it was read. > 💬 Earn products are switched on per tenant by Aurea. [Contact us for details](https://www.aureahub.com/contact). ## Switched Off Until Enabled Earn is off for every tenant until Aurea switches a product on for it, on mainnet or testnet, with the smallest and largest deposit your tenant allows. Until then: - [Products](https://docs.aureahub.com/docs/earn-products.md) answers `"products": []` and [Positions](https://docs.aureahub.com/docs/earn-positions.md) `"positions": []`; - a quote, a deposit, a withdrawal or a broadcast answers `403` `EARN_PRODUCT_OFF` (`This product is not offered on this network.`), and a `productId` that is no product at all `404` `EARN_PRODUCT_NOT_FOUND`; - a deposit also needs the product's disclosure, published by Aurea and accepted by the user (below): until it is published, `409` `EARN_DISCLOSURE_NOT_PUBLISHED`. A product your tenant switched off, or one that is paused, takes no new deposit; a withdrawal of a position goes on, as long as your tenant offered the product at some point. ## Calling the Earn Routes - Every Earn route needs **both** the user's access token (`Authorization: Bearer …`) and your tenant's request signature (`x-tenant-api-key`, `x-timestamp`, `x-signature` — see [Authentication](https://docs.aureahub.com/docs/authentication.md)). The API secret stays on your server, so call them from there, on the user's behalf. - **Or a server token** instead of the signature: a token of one of your [server credentials](https://docs.aureahub.com/docs/guide-server-credentials.md) in `X-Aurea-Server-Token`, next to the user's `Authorization` — never with the signature headers (`400` `SERVER_AUTH_SCHEMES_CONFLICT`). The credential must be of the user's tenant (`401` `SERVER_CREDENTIAL_TENANT_MISMATCH`). A tenant on the institutional security profile must use one: its HMAC signature here answers `401` `SERVER_TOKEN_REQUIRED`. - The token must belong to your tenant. Deposits, withdrawals, broadcasts and acceptances are for the roles that sign — `user`, `owner`, `tenantadmin`, `signer` ([Roles](https://docs.aureahub.com/docs/authentication.md)). - Amounts are integer strings in the asset's smallest unit ([Amount Units](https://docs.aureahub.com/docs/amounts.md)); a withdrawal may say `"max"` for the whole position. - `isTestnet` picks the network: `true` testnet, `false` mainnet. - The writes — deposits, withdrawals, broadcasts and acceptances — allow 10 requests a minute. ## The Flow 1. **Show what is offered:** [Products](https://docs.aureahub.com/docs/earn-products.md), with each product's state and rate on every chain it is on, and your tenant's deposit limits. 2. **Before the first deposit, the disclosure:** show the user the product's disclosure ([Disclosure](https://docs.aureahub.com/docs/earn-disclosure.md) — its risks, and that its yield is variable) and record their acceptance with the `version`, `language` and `sha256` they were shown ([Accept a Disclosure](https://docs.aureahub.com/docs/earn-disclosure-accept.md)). A new version asks every user again; a withdrawal never waits for it. 3. **Quote:** [Quote](https://docs.aureahub.com/docs/earn-quote.md) makes the same checks as the write and says what it would do — the route, who signs, the wallet, the amount expected, the fee, what the wallet holds and the exact approval a deposit needs. 4. **Deposit or withdraw:** [Deposit](https://docs.aureahub.com/docs/earn-deposit.md) or [Withdraw](https://docs.aureahub.com/docs/earn-withdraw.md) answers the operation. 5. **Sign, when the wallet is the user's own:** send the signatures with [Send Signatures](https://docs.aureahub.com/docs/earn-broadcast.md). 6. **Follow it:** [Get an Operation](https://docs.aureahub.com/docs/earn-operation-get.md) and [Positions](https://docs.aureahub.com/docs/earn-positions.md). ## Who Signs The wallet decides. Name it with `walletId` — one of the user's EVM wallets on the network — or leave it out, and Aurea takes the first wallet somebody can sign for on the product's chain, then on any EVM chain, the primary first. A watch-only import is never used (`400` `EARN_WALLET_NOT_SIGNABLE`). | Wallet | What happens | | --- | --- | | **Custodial (Aurea holds the key)** | `signer: "server"`. Aurea signs and sends every step at once and answers the operation `completed`, `failed`, or `broadcast` when a receipt is late. | | **Non-custodial (`client_side`) or MPC (`mpc_tss`)** | `signer: "client"`. The operation is answered `submitted` with `signing.steps`. Sign each step's `txHashToSign` with the wallet — the device key, or the MPC signing ceremony — and send the signatures, in the order of the steps, to [Send Signatures](https://docs.aureahub.com/docs/earn-broadcast.md) before `signing.expiresAt`. Aurea checks each against the wallet and sends the transactions exactly as it built them: you never send a transaction. | Each step says what it is — `purpose` (`approve`, `deposit` or `withdraw`), `amount` and `counterparty` — so the user can be shown what they sign. A deposit's approval is of exactly the amount, never more, and only when the allowance does not already cover it. ## Routes and Chains A product may be reached in more than one way. Without `route`, Aurea takes the first open to the wallet, in this order; with it, that route or `409` `EARN_ROUTE_NOT_OFFERED` (with the open `routes`). - `direct` — into the product on its own chain. - `bridge` — from another chain, through a cross-chain router: the operation is `bridging` while the message travels (`bridge.messageId`) and `completed` once the product took it. The fee of the message is paid with the transaction in the chain's native token (`nativeFee` in the quote). - `secondary_market` — the product's share token bought or sold on the market, at once. `chainId` picks the chain; without it, the one the wallet is on. A chain the product is not on answers `400` `EARN_CHAIN_NOT_OFFERED` (with the product's `chains`). ## Withdrawals and Redemptions A withdrawal pays to the wallet the position is held by. Some products pay a withdrawal later: the operation is `completed` once the request is made, and its `redemption` follows the request until it is paid: ```text requested → queued → paid ↘ failed | cancelled (the shares returned) ``` `queued` means waiting for the product's liquidity — usually under two days, at most about thirty. `assetsPaid` and `paidTxHash` are filled in when it is paid. ## Operation States ```text submitted → broadcast → bridging (bridge route only) → completed ↘ failed submitted → expired (the client's signatures never came) ``` States only move forward. Reading an operation brings it up to date first: a `broadcast` one is read from its chain and completed or failed when its receipts say so; one whose signatures never came in time is `expired`. A `failed` operation carries `failureCode`. ## Readings and Limits - A product's state and rate are read at one block, and `observedAt` is that block's time. `fresh` says whether the reading is recent enough to decide a deposit or a withdrawal on. - Before a deposit, a withdrawal or a broadcast, Aurea reads the product again: a reading older than the Hub's limit answers `503` `EARN_READING_STALE`, an unreadable one `503` `EARN_READING_FAILED`, a product not taking the operation on chain `409` `EARN_PRODUCT_NOT_ACCEPTING`. A quote answers the reading's age and state instead of refusing. - The state on chain: `active`; `frozen` (withdrawals only); `paused` or `inactive` (nothing). - Your tenant's limits apply to each deposit (`400` `EARN_AMOUNT_OUT_OF_LIMITS`); the wallet must hold the amount (`400` `EARN_INSUFFICIENT_BALANCE`), and the position the amount withdrawn (`400` `EARN_INSUFFICIENT_POSITION`). ## Retries Deposits, withdrawals and broadcasts take an optional `Idempotency-Key` header, up to 255 letters, digits, `-` or `_`, and their answers always carry `idempotency-replayed`: - the same key with the same request answers the first answer again, with `idempotency-replayed: true`, and nothing is signed or sent twice; - the same key with another request answers `409` `IDEMPOTENCY_KEY_REUSED`; while the first is still running, `409` `IDEMPOTENCY_IN_PROGRESS`; a key that is not one, `400` `IDEMPOTENCY_KEY_INVALID`; - the checks that need no chain — the product switched on, the disclosure accepted — are made before a key is replayed. A deposit refused for its disclosure frees its key: the same key deposits once the user has accepted. Once a transaction is out, a write does not fail: it answers the operation in the state it reached, so a retry is answered rather than sent again. ## Errors Earn's refusals carry their code in `details.code`, with what it is about beside it (`productId`, `operationId`…): ```json { "statusCode": 403, "error": "ForbiddenError", "message": "This product is not offered on this network.", "details": { "code": "EARN_PRODUCT_OFF", "productId": "lumen" } } ``` | Status | details.code | When | | --- | --- | --- | | 400 | EARN_AMOUNT_OUT_OF_LIMITS | Below or above your tenant's deposit limits, or below what the bridge's fee leaves (`limit`, `minimum`). | | 400 | EARN_INSUFFICIENT_BALANCE | The wallet does not hold the amount. | | 400 | EARN_INSUFFICIENT_POSITION | The position does not hold the amount. | | 400 | EARN_NO_WALLET | The user has no wallet that can be used. | | 400 | EARN_WALLET_NOT_SIGNABLE | A watch-only address. | | 400 | EARN_CHAIN_NOT_OFFERED | The product is not on that chain. | | 400 | EARN_SIGNATURES_MISMATCH | Not one signature per step. | | 400 | EARN_BROADCAST_REFUSED | The node refused the first step (`reason`: `insufficient_gas`, `nonce_conflict`, `reverted` or `rejected`); nothing was sent, and the same signatures may be sent again. | | 403 | EARN_PRODUCT_OFF | Your tenant does not offer the product on this network (for a deposit: no longer). | | 403 | EARN_TENANT_REQUIRED | A token without a tenant. | | 404 | EARN_PRODUCT_NOT_FOUND | No product with this identifier. | | 404 | EARN_WALLET_NOT_FOUND | `walletId` is not one of the user's wallets on the network. | | 404 | EARN_OPERATION_NOT_FOUND | Not one of the user's operations. | | 409 | EARN_PRODUCT_PAUSED | The product takes no new deposit. | | 409 | EARN_PRODUCT_NOT_ACCEPTING | The product does not take the operation on chain now (`reason`). | | 409 | EARN_ROUTE_NOT_OFFERED | The route asked for is not open to the wallet (`routes`). | | 409 | EARN_OPERATION_NOT_AWAITING_SIGNATURES | The operation is in another state. | | 409 | EARN_DISCLOSURE_NOT_PUBLISHED | The product's disclosure is not published: no deposit yet. | | 409 | EARN_DISCLOSURE_NOT_ACCEPTED | The user has not accepted the current version (`version`). | | 409 | EARN_DISCLOSURE_VERSION_MISMATCH | The acceptance is not of the current text; nothing is recorded. | | 410 | EARN_SIGNING_EXPIRED | Past `signing.expiresAt`. | | 422 | EARN_SIGNATURE_INVALID | A signature not made by the operation's wallet. | | 500 | EARN_SIGNING_FAILED | Aurea could not sign for a custodial wallet. | | 503 | EARN_READING_STALE | The product's reading is too old to decide on. | | 503 | EARN_READING_FAILED | The product could not be read. | | 503 | EARN_PRODUCT_UNAVAILABLE | The product cannot be reached now. | | 503 | EARN_ROUTE_UNAVAILABLE | The route cannot be used now (for the market: `reason`). | [Contact us for details](https://www.aureahub.com/contact). Web version: https://docs.aureahub.com/#guide-earn --- # 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 — to one webhook per environment, and to up to ten endpoints per environment, each with the event types it asks for. ## 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 one of your tenant's addresses in that environment asks for that kind of change, posts the updated record to it — normally a few seconds after the bank ramp's event reached Aurea. - **Two kinds of address, the same deliveries.** The **webhook of each environment** — one per environment — and **webhook endpoints** — up to 10 per environment, each with its own URL, secret, event types, label and status, for example one per service of yours. An event goes to every active address that asks for its type, with the same body and event id, each signed with that address's own secret. - **Production and sandbox are separate.** Each environment has its own addresses; a sandbox event never reaches a production address. - **Who sets them up:** your tenant's administrator (role `owner`, `tenantadmin`, `non_signing_admin` or `security_admin`), with the endpoints under **Your Webhooks** and **Webhook Endpoints** in the API Reference; the webhook of each environment also in the Aurea Admin console. 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 addresses.** 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 — of the webhook of an environment and of an endpoint alike — 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.field` `url` and `details.reason` — `details.code` `NOAH_WEBHOOK_INVALID` for the webhook of an environment, `WEBHOOK_ENDPOINT_INVALID` for an endpoint — 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. ## Endpoints Add an endpoint with [Add an Endpoint](https://docs.aureahub.com/docs/webhook-endpoint-add.md): its environment, address, the event types it asks for and an optional label. The answer carries its `secret`, shown only then. ```bash curl -X POST https://api.aureahub.com/v1/admin/tenants/$TENANT_ID/webhook-endpoints \ -H "Authorization: Bearer $TENANT_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "environment": "sandbox", "url": "https://ledger.example.com/aurea/events", "eventTypes": ["ramp.deposit.updated", "ramp.transaction.updated"], "description": "Ledger service" }' ``` - Up to 10 per environment, beside the webhook of the environment, which works as before. Each has its own secret: verify each address's deliveries with that address's secret. - An endpoint can ask for every published type (Event Types). An empty list receives the five `ramp.*` and the two `earn.*` types only: every other type — `operation.*`, `tokenized_asset.*`, `server_credential.revoked`, `account.*`, `transfer.inbound.*`, `address_book.*` and any type published later — reaches only the endpoints that name it. The webhook of an environment can name only the five `ramp.*` types. - [Change](https://docs.aureahub.com/docs/webhook-endpoint-change.md) its address, types, label or status; [give it a new secret](https://docs.aureahub.com/docs/webhook-endpoint-rotate.md); [send it a test](https://docs.aureahub.com/docs/webhook-endpoint-test.md); [remove it](https://docs.aureahub.com/docs/webhook-endpoint-remove.md). [List Endpoints](https://docs.aureahub.com/docs/webhook-endpoints-list.md) also lists every type with the version of its body. ## Event Types `eventTypes` lists the types an address receives. An address with an empty list — an endpoint, or the webhook of an environment — receives the types published with the first endpoints: the five `ramp.*` types and the two `earn.*` types. Every other type, and every type published from now on, reaches only the endpoints that name it, so a new type never changes what an existing address receives: name `operation.*`, `tokenized_asset.*`, `server_credential.revoked`, `account.*`, `transfer.inbound.*` and `address_book.*` to receive them. | 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. | | earn.operation.updated, earn.redemption.updated | **Not sent yet.** Published, and an endpoint may ask for them, but Aurea does not send them today. | An [Earn](https://docs.aureahub.com/docs/guide-earn.md) deposit or withdrawal, and a redemption the product queued. | | operation.created, operation.state_changed, operation.completed, operation.failed | **Only while Aurea records operations**, which is switched off until Aurea turns it on ([Operations and Fees](https://docs.aureahub.com/docs/guide-operations.md)): an operation was recorded, moved, completed or failed. Only to the endpoints that name them. | The operation as [Get an Operation](https://docs.aureahub.com/docs/operation-get.md) answers it, without `events` — `tokenizedOperationId` included; `operation.state_changed` adds `previousState` and `previousSubState`. | | tokenized_asset.paused, tokenized_asset.resumed, tokenized_asset.corporate_action | **Not sent yet.** Published, and an endpoint may ask for them, but Aurea does not send them today. | A tokenized asset your tenant offers was paused or trades again on a chain, or has a new dividend or shares multiplier. | | tokenized_asset.operation.quoted, tokenized_asset.operation.submitted, tokenized_asset.operation.completed, tokenized_asset.operation.failed, tokenized_asset.operation.expired | **Not sent yet.** Published, and an endpoint may ask for them, but Aurea does not send them today. | A tokenized assets operation was created, sent its first transaction, completed on chain, failed (`failureCode` says why) or ran out of time before it was carried out. | | server_credential.revoked | One of your tenant's [server credentials](https://docs.aureahub.com/docs/guide-server-credentials.md) was revoked: its tokens are refused from the next request. Sent to the endpoints that name it in **both** environments — a credential serves your server in both —, with one event `id`, and only once the revocation is saved. | The credential as [List Credentials](https://docs.aureahub.com/docs/server-credentials-list.md) answers it: `id`, `tenantId`, `name`, `role`, `status` `revoked`, `publicKey`, `thumbprint`, `allowedIps`, `expiresAt`, `createdAt`, `createdBy`, `updatedAt`, `updatedBy`, `lastUsedAt`, `revokedAt`, `revokedBy`, `revokeReason`, `roleNarrows`. Never a secret: the credential holds none. | | account.created, account.updated, account.wallet_added | **Only while Aurea sends account events**, which is switched off until Aurea turns it on ([Accounts](https://docs.aureahub.com/docs/guide-accounts.md)): an account was created — by a call, or by a bulk creation —, its name, tags, visibility, users or `customerRefId` changed, or a wallet was made for it or put in it. Only to the endpoints that name them: an account's news to your production endpoints, a wallet's to those of its network. | `account.created` and `account.updated`: the account as [Get an Account](https://docs.aureahub.com/docs/account-get.md) answers it. `account.wallet_added`: `accountId`, `customerRefId`, `walletId`, `chain`, `family`, `isTestnet`, `address`, and `how` — `created` (made for the account) or `attached` (one of its owner's wallets put in it). | | transfer.inbound.recorded, transfer.inbound.removed | **Only while Aurea records the transfers into your wallets and sends their events**, both switched off until Aurea turns them on: a transfer into one of your tenant's wallets was recorded once its block had the platform's confirmations (or came back after a re-organisation of the chain), or a re-organisation took a recorded transfer's block away — it then counts nowhere until a new block carries it again. Only to the endpoints that name them: a testnet wallet's to your sandbox endpoints, a mainnet wallet's to your production ones. | `id`, `walletId`, `accountId`, `userId`, `chain`, `chainId`, `isTestnet`, `txHash`, `logIndex`, `blockNumber`, `blockHash`, `blockTime`, `from`, `to`, `asset` (`native`, a token contract or a Solana mint), `symbol`, `decimals`, `amount` (in the asset's smallest unit, as text), `recordedAt`, `removed` and `removedAt`. | | address_book.entry.requested, address_book.entry.approved, address_book.entry.activated, address_book.entry.rejected, address_book.entry.removed, address_book.entry.proven | **Only while Aurea sends address book events**, which is switched off until Aurea turns it on ([Address Book](https://docs.aureahub.com/docs/guide-address-book.md)): an entry waits for its approval (added, or its address changed), was approved (it cools until `activeAt`, or is active at once with no cooling period), became active, was rejected, was removed, or its owner proved the address. Only to the endpoints that name them: a testnet entry's to your sandbox endpoints, a mainnet entry's to your production ones. | The entry as [Get an Entry](https://docs.aureahub.com/docs/address-book-entry-get.md) answers it, without `history`. | | address_book.destination.unlisted | **Only while Aurea sends address book events, and only for a tenant whose signing points read its address book** ([Address Book](https://docs.aureahub.com/docs/guide-address-book.md)): an operation went to an address outside the book, or was refused for it. Only to the endpoints that name it. | `point` (the signing point), `verdict` — `unlisted` (outside the book), `unresolved` (its chain or network could not be told) or `one_time` (outside the book, allowed by your one-time addresses) —, `mode` (`log` or `enforce`), `refused`, `family`, `network`, `chain`, `chainId`, `address`, `sourceWalletId`, `occurredAt`. | - Each type's body is published with a version in the [OpenAPI document](https://api.aureahub.com/v1/docs/json), as the schema `WebhookEvent..v` (all at version 1). A change that could break a receiver — a field removed, renamed or retyped — comes as a new version; a field added to `data` does not, so ignore fields you don't know. - `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 to an address that 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 and every resend of this event, and at every address it goes to; 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 address'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 `..`, keyed with the address's secret | | X-Webhook-Jws | Only once Aurea signs its deliveries with its own key — **not yet**: a detached ES256 signature of the body (Verify the JWS) | ## Verify the Signature Check every delivery before trusting it, with the secret of the address it came to. 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 verifyDelivery( 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/events', express.raw({ type: 'application/json' }), async (req, res) => { const verified = verifyDelivery( req.body, req.get('x-webhook-id'), req.get('x-webhook-timestamp'), req.get('x-webhook-signature'), process.env.AUREA_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_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 the API's own code: they accept a genuine one — also with non-ASCII text in the body, and also when it carries the JWS header — 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. ## Verify the JWS > ℹ️ **Not sent yet.** Aurea adds the `X-Webhook-Jws` header once it turns its signing key on; until then [the key set](https://docs.aureahub.com/docs/webhook-jwks.md) is empty and only the HMAC signature is sent. The HMAC signature stays on every delivery either way, so a server that verifies it needs no change. Verifying the JWS as well proves the delivery came from Aurea, not only from someone holding your secret. The header is a detached JWS (RFC 7515, appendix F) over the exact body: `..`, base64url, the payload left out. Put the base64url of the raw body between the two dots and verify the result with Aurea's public keys from `GET /v1/webhooks/jwks.json`. Its protected header: ```json { "alg": "ES256", "kid": "bzrB8K7EPXdFI4zfAYcUYhyeSrqILkaklrG_kf-IR5I", "typ": "aurea-webhook+jws", "aud": "6f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", "iat": 1791297243, "jti": "evt_9f2c41d87a0b5e36c1d4f8a2b7e05c93" } ``` - `kid`: the key — find it in the key set; when it is new to you, read the set again (a new key is published before it signs anything). - `aud`: **your tenant's id.** The key is Aurea's and signs every tenant's deliveries, so check that `aud` is yours, or another tenant's delivery would verify too. - `iat`: the attempt's time, the same as `X-Webhook-Timestamp`; refuse one more than five minutes away. `jti`: the event, the same as `X-Webhook-Id`. Node.js, with nothing but its built-in `crypto`: ```javascript import { createPublicKey, verify } from 'node:crypto'; const JWKS_URL = 'https://api.aureahub.com/v1/webhooks/jwks.json'; const TOLERANCE_SECONDS = 300; let keys = new Map(); // kid → public key let fetchedAt = 0; // The key a delivery names. A kid not seen yet reads the set again, at most once a minute. async function signingKey(kid) { if (!keys.has(kid) && Date.now() - fetchedAt > 60_000) { const set = await fetch(JWKS_URL).then((res) => res.json()); keys = new Map(set.keys.map((jwk) => [jwk.kid, createPublicKey({ key: jwk, format: 'jwk' })])); fetchedAt = Date.now(); } return keys.get(kid); } // True only for a delivery Aurea signed, for your tenant, less than five minutes ago. // headers: the request's headers with lower-case names (Node's req.headers). export async function verifyDeliveryJws(rawBody, headers, myTenantId) { const parts = String(headers['x-webhook-jws'] ?? '').split('.'); if (parts.length !== 3 || parts[1] !== '') return false; // detached:
.. let header; try { header = JSON.parse(Buffer.from(parts[0], 'base64url').toString('utf8')); } catch { return false; } if (typeof header !== 'object' || header === null) return false; if (header.alg !== 'ES256' || header.typ !== 'aurea-webhook+jws') return false; if (header.aud !== myTenantId) return false; // Aurea's key signs every tenant's deliveries if (header.jti !== headers['x-webhook-id'] || String(header.iat) !== headers['x-webhook-timestamp']) return false; if (!Number.isInteger(header.iat) || Math.abs(Date.now() / 1000 - header.iat) > TOLERANCE_SECONDS) return false; const key = await signingKey(header.kid); if (!key) return false; const signingInput = `${parts[0]}.${Buffer.from(rawBody).toString('base64url')}`; try { return verify('sha256', Buffer.from(signingInput), { key, dsaEncoding: 'ieee-p1363' }, Buffer.from(parts[2], 'base64url')); } catch { return false; } } ``` Or with any JOSE library — here `jose`: ```javascript import { compactVerify, createRemoteJWKSet } from 'jose'; const jwks = createRemoteJWKSet(new URL('https://api.aureahub.com/v1/webhooks/jwks.json')); // Resolves the protected header, or throws, for a delivery Aurea signed for your tenant. export async function verifyWithJose(rawBody, headers, myTenantId) { const [header, , signature] = String(headers['x-webhook-jws'] ?? '').split('.'); const payload = Buffer.from(rawBody).toString('base64url'); const { protectedHeader } = await compactVerify(`${header}.${payload}.${signature}`, jwks, { algorithms: ['ES256'], }); if (protectedHeader.typ !== 'aurea-webhook+jws') throw new Error('not a webhook signature'); if (protectedHeader.aud !== myTenantId) throw new Error('not for this tenant'); if (protectedHeader.jti !== headers['x-webhook-id']) throw new Error('another event'); if (Math.abs(Date.now() / 1000 - protectedHeader.iat) > 300) throw new Error('stale'); return protectedHeader; } ``` Both were checked against deliveries signed by the API's own code and its published key set: they accept a genuine delivery, also after the next key becomes the one in use, and refuse a changed body or protected header, a delivery for another tenant, one signed more than five minutes ago, a `kid` the set doesn't hold, and the other published key's `kid` on a signature it didn't make. ## 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. You can put it back in the queue yourself (Send Again), or read the state through the API. - Every attempt carries the same body and `X-Webhook-Id`, with its own timestamp and signature. - The address is read again at each attempt: a replaced secret signs the retries, and an address removed, switched off or no longer asking for the type stops them. ## Deliveries and Attempts - [List Deliveries](https://docs.aureahub.com/docs/webhook-deliveries-list.md): one row per event and address — the webhook of an environment or an endpoint — with its status (`pending`, `sending`, `delivered`, `skipped`, `retrying`, `failed`, `cancelled`), its attempts, the next one and the last one. Never the body. - [The attempts log](https://docs.aureahub.com/docs/webhook-attempts-list.md): every attempt that posted, with its time, duration, and your server's status code or why none came. Kept 30 days. - Both cover the webhook of each environment and the endpoints, and are read by the roles that read the webhooks — also `auditor` and `security_auditor`. ## Send Again [Send Again](https://docs.aureahub.com/docs/webhook-deliveries-resend.md) puts deliveries back in the queue with 10 new attempts: one event's, those queued in a time range (at most 31 days), or every one that failed — in one environment, at every address or one. The same body goes again with the same event id, signed when it is sent: a server that already has it drops it by its `id`. ```bash curl -X POST https://api.aureahub.com/v1/admin/tenants/$TENANT_ID/webhook-deliveries/resend \ -H "Authorization: Bearer $TENANT_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "environment": "production", "failed": true }' ``` ## 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 or a delivery is sent again. Keep the `id`s you handled and answer `2xx` to one you already have. Several of your addresses get the same event with the same `id`. ## Send a Test [Send a Test](https://docs.aureahub.com/docs/tenant-webhook-test.md), and [its endpoint version](https://docs.aureahub.com/docs/webhook-endpoint-test.md), post one `webhook.test` right away, signed like every delivery, and answer what happened. It also works while the address is switched off, is never retried, is not in the attempts log, 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) (or [an endpoint's](https://docs.aureahub.com/docs/webhook-endpoint-rotate.md)) answers with a new secret, shown only then, and **every delivery to that address 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 address uses. ## Switch Off or Remove - Saving the webhook with `"status": "disabled"`, or changing an endpoint to it, 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) or [an endpoint](https://docs.aureahub.com/docs/webhook-endpoint-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, sent again — is written to your tenant's activity log with who did it, never with a 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 --- # Server Credentials Sign your server's calls with a key pair your server holds instead of the API secret: register the public key once, then send each request with a short-lived ES256 token. ## Overview - **Another way to sign the same calls.** Wherever your tenant's signature is asked for, your server can send, instead of the three HMAC headers, a **server token**: a token it signs with a P-256 private key whose public key you registered as a **server credential** — in `Authorization: Bearer` on the routes the signature opens on its own, in `X-Aurea-Server-Token` where `Authorization` carries a user's access token. [HMAC request signing](https://docs.aureahub.com/docs/authentication.md) with the API key and secret keeps working — except for a tenant on the institutional security profile (Institutional Tenants, below): each request uses one or the other. - **Aurea never holds the private key.** You make the key pair on your server and send only the public key; a key that carries its private part is refused. No answer ever contains a secret. - **Several at once.** Up to 10 active credentials per tenant, each with its own name, role, IP allow-list and expiry. To rotate: register the new key, move your server to it, revoke the old one — both work in between. - **One token, one request.** A token names the request's method, path and query, and the hash of its body; it lives 30 seconds at most and is accepted once. - Nothing to switch on: every tenant can register credentials, and a tenant that registers none sees no change. - Managed by the tenant roles `owner`, `tenantadmin`, `non_signing_admin` and `security_admin` on their own tenant ([Roles](https://docs.aureahub.com/docs/authentication.md)), with the routes under **Server Credentials** in the API Reference. ## Where a Token Is Accepted **In `Authorization: Bearer`**, on exactly the routes the tenant's signature opens on its own: - `POST /v1/auth/register`, `/v1/auth/login`, `/v1/auth/google`, `/v1/auth/apple` and `/v1/auth/forgot-password`; - `GET /v1/tenant/public`; - `POST /v1/mpc/service/token`; - the [card onramp for your own wallets](https://docs.aureahub.com/docs/guide-card-onramp-wallets.md): `GET` and `PUT /v1/ramp/card/customers/{externalId}`, `GET` and `POST …/sessions`, `GET …/sessions/{id}`, `PUT` and `DELETE …/wallets/{family}`. The request then runs exactly as an HMAC-signed one: same tenant, same answers. A bearer token whose header's `typ` is not `aurea-server+jwt` — a user's access token, for example — is not a server token, and the request goes to HMAC signing; a request without a server token is handled exactly as before. **In `X-Aurea-Server-Token`**, on the routes where `Authorization` carries the signed-in user's access token and your tenant's signature goes with it: [Earn](https://docs.aureahub.com/docs/guide-earn.md)'s routes, and the savings vault's, a product Aurea switches on per tenant. ```http Authorization: Bearer X-Aurea-Server-Token: ``` - The same token, claims and checks as in `Authorization` (Sign a Request), with `uri` the method and path of this request — sent instead of the three HMAC headers, never with them (`400` `SERVER_AUTH_SCHEMES_CONFLICT`). - The user's access token is checked first, as without the header. The credential must then be of the user's own tenant: `401` `SERVER_CREDENTIAL_TENANT_MISMATCH` otherwise. - A refusal's challenge has its own scheme, so a client never takes it for its user's session refused: `WWW-Authenticate: AureaServerToken error="invalid_token", error_description=""` (`invalid_request` for two schemes), with the code in `details.code` as everywhere. - The header is ignored on every other route. ## What a Credential's Role Reaches A credential carries one of your tenant's roles — `tenantadmin`, `non_signing_admin`, `signer`, `approver`, `editor`, `viewer`, `auditor`, `security_admin` or `security_auditor` — given as a user's role is: never above the caller's own. A token never reaches a route other than those above; among them: - **A credential registered since 6 October 2026** (`roleNarrows` `true`) reaches only the routes whose permission its role holds. Elsewhere its token answers `403` `SERVER_CREDENTIAL_ROLE_NOT_ALLOWED` (`This server credential's role does not hold the permission this route needs`), with the permission the route needs in `details.permission` and in the challenge's `scope` — `Bearer error="insufficient_scope", error_description="SERVER_CREDENTIAL_ROLE_NOT_ALLOWED", scope=""`. Nothing is done, and the token's nonce is not used. - **A credential registered before** (`roleNarrows` `false`) reaches all of them whatever its role, as the API key does. - `roleNarrows` never changes — changing a credential's role keeps it. To have a role that narrows, register a new credential and revoke the old one. | Route | Roles that reach it (roleNarrows true) | | --- | --- | | GET /v1/tenant/public | every role | | POST /v1/auth/register, /v1/auth/login, /v1/auth/google, /v1/auth/apple, /v1/auth/forgot-password | `tenantadmin`, `non_signing_admin` | | POST /v1/mpc/service/token | `tenantadmin`, `signer` | | The card onramp for your own wallets: /v1/ramp/card/customers/… | `tenantadmin`, `signer` | | Earn's reads: products, disclosure, quote, positions, operations | every role | | Earn's writes: accepting a disclosure, deposits, withdrawals, sending signatures | `tenantadmin`, `signer` | | The savings vault's routes | `tenantadmin`, `signer` | ## Institutional Tenants - A tenant on the institutional security profile — set by Aurea with the tenant ([contact us](https://www.aureahub.com/contact)) — **signs with a server token wherever one is accepted**: the routes above, in `Authorization` or in `X-Aurea-Server-Token`. Its HMAC signature there answers `401` `SERVER_TOKEN_REQUIRED` (`This tenant signs with a server token: HMAC v1 is not accepted on this route`), with `WWW-Authenticate: Bearer error="invalid_request", error_description="SERVER_TOKEN_REQUIRED"`. The sign-in routes' error bodies keep only `error` and `message`: read the header there. - It is checked once the HMAC signature is proven — a wrong signature still answers `Invalid signature` — and nothing is consumed. - The IP allow-list and the expiry stay optional, for institutional tenants too. - Every other tenant keeps HMAC signing on every route, with no deadline. ## Register a Key Make the key pair where your server runs and keep the private key in your secret store. `newCredentialKeys()` below gives the public key to register; `serverToken()` signs a request with the private key. ```javascript import { createHash, generateKeyPairSync, randomBytes, sign } from 'node:crypto'; // Once, on your server: a P-256 key pair. Register the public key; keep the private key secret. export function newCredentialKeys() { const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' }); const { kty, crv, x, y } = publicKey.export({ format: 'jwk' }); return { publicKey: { kty, crv, x, y }, // the body's publicKey when you register the credential privateKeyPem: privateKey.export({ format: 'pem', type: 'pkcs8' }), }; } const part = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); // A token for one request: valid 30 seconds, used once. export function serverToken(credentialId, privateKeyPem, method, pathWithQuery, body = '') { const iat = Math.floor(Date.now() / 1000); const header = { alg: 'ES256', typ: 'aurea-server+jwt', kid: credentialId }; const claims = { uri: `${method.toUpperCase()} ${pathWithQuery}`, bodyHash: createHash('sha256').update(body).digest('base64url'), nonce: randomBytes(24).toString('base64url'), iat, exp: iat + 30, }; const signingInput = `${part(header)}.${part(claims)}`; const signature = sign('sha256', Buffer.from(signingInput), { key: privateKeyPem, dsaEncoding: 'ieee-p1363' }); return `${signingInput}.${signature.toString('base64url')}`; } ``` Register the public key with [Register a Credential](https://docs.aureahub.com/docs/server-credential-create.md). The answer's `id` is the credential, and the `kid` your tokens name: ```bash curl -X POST https://api.aureahub.com/v1/admin/tenants/$TENANT_ID/server-credentials \ -H "Authorization: Bearer $TENANT_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Payments server", "role": "signer", "publicKey": { "kty": "EC", "crv": "P-256", "x": "kTxt6XgA4C3r8RE3iDiksXglx-XqsKZBKcJXz8xb9Bg", "y": "2UOzdwbCN0bU_vBmtae1dx458v3t1PXqowMQIp9qahU" }, "allowedIps": ["203.0.113.0/24"], "expiresAt": "2027-10-06T00:00:00Z" }' ``` ## Sign a Request Send `Authorization: Bearer ` and none of the HMAC headers. The token is a compact JWS — `header.claims.signature`, each part unpadded base64url, at most 4096 characters: | Part | What it holds | | --- | --- | | header | `{ "alg": "ES256", "typ": "aurea-server+jwt", "kid": "" }` — those three members and no other. | | claims.uri | The method, one space, and the path with its query exactly as sent: `POST /v1/auth/login`, `GET /v1/tenant/public?lang=en`. | | claims.bodyHash | SHA-256 of the exact body bytes, unpadded base64url; of the empty string when there is no body (`47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU`). Send the very string you hashed. | | claims.nonce | 16 to 128 of `A–Z a–z 0–9 _ -`, new for every token. | | claims.iat, claims.exp | Whole Unix seconds; `exp` after `iat` by 30 seconds at most. Accepted from 10 seconds before `iat` to 10 seconds after `exp`. | | signature | ES256 over `header.claims`, in its 64-byte form (r and s, 32 bytes each), as JOSE writes it — not DER. | ```javascript // 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', Authorization: `Bearer ${serverToken(CREDENTIAL_ID, process.env.AUREA_SERVER_KEY_PEM, 'POST', path, body)}`, }, body, }); // GET without a body; a query string is part of the signed path const branding = await fetch('https://api.aureahub.com/v1/tenant/public', { headers: { Authorization: `Bearer ${serverToken(CREDENTIAL_ID, process.env.AUREA_SERVER_KEY_PEM, 'GET', '/v1/tenant/public')}` }, }); ``` - **Sign every request, retries included.** A nonce is accepted once per credential: a token sent again answers `401` `SERVER_TOKEN_REPLAYED`. - On routes that check the request body, that check runs before the token's, as it does before an HMAC signature's. The sample was checked against the API's own verification: tokens for a `POST` with a body and a `GET` with a query are accepted, the registered key is accepted as sent, and another body, another path or query, or another key's signature is refused. ## Rotate, Restrict, Revoke - **Rotate with overlap.** Register the new key, move your server to it, then revoke the old credential. A key is registered once, ever — after its revocation and in any tenant: registering it again answers `409` `SERVER_CREDENTIAL_KEY_IN_USE`. A credential's key never changes: a new key is a new credential. - **IP allow-list.** `allowedIps`: IPv4 or IPv6 networks (`203.0.113.0/24`, `2001:db8::/32`) or bare addresses, at most 50, matched against the address your server's call comes from as Aurea sees it — an `X-Forwarded-For` header you write is not trusted. Empty: any address. Write an IPv4 network as IPv4 (not IPv4-mapped IPv6), with no host bits under the mask. Changing it applies to the next token. - **Expiry.** `expiresAt` in the future, at most 5 years ahead; past it the credential's tokens answer `401` `SERVER_CREDENTIAL_EXPIRED` and it can no longer be changed. `null`: no expiry. - **Revoke** with a reason: for good, and at once. Its next token answers `401` `SERVER_CREDENTIAL_REVOKED`; it stays listed with `status` `revoked`, and never changes again. There is no delete. Your webhook endpoints that name `server_credential.revoked` are told, in both environments ([Events to Your Server](https://docs.aureahub.com/docs/guide-events.md)). - `lastUsedAt` is the last accepted token, written at most once a minute: it tells you whether a credential is still in use before you revoke it. ## Refusals A token is checked in this order; the first check that fails answers, and nothing is done. The code is in `details.code` and in the `WWW-Authenticate` header — `Bearer error="invalid_token", error_description=""`, `invalid_request` for two schemes, `insufficient_scope` for a role that does not reach the route; in `X-Aurea-Server-Token`, the scheme is `AureaServerToken`. The sign-in routes keep only `error` and `message` in their error bodies: there, read the header. ```json { "statusCode": 401, "error": "UnauthorizedError", "message": "The server token was signed for another body", "details": { "code": "SERVER_TOKEN_BODY_MISMATCH" } } ``` | Status | details.code | message | | --- | --- | --- | | 400 | SERVER_AUTH_SCHEMES_CONFLICT | `Send either a server token or the HMAC headers (x-tenant-api-key, x-timestamp, x-signature), not both` | | 401 | SERVER_TOKEN_MALFORMED | `Malformed server token` | | 401 | SERVER_TOKEN_LIFETIME_INVALID | `A server token's exp must be after its iat, by 30 seconds at most` | | 401 | SERVER_TOKEN_NOT_YET_VALID | `Server token issued in the future` | | 401 | SERVER_TOKEN_EXPIRED | `Server token expired` | | 401 | SERVER_CREDENTIAL_UNKNOWN | `Unknown server credential` | | 401 | SERVER_TOKEN_SIGNATURE_INVALID | `Invalid server token signature` | | 401 | SERVER_CREDENTIAL_REVOKED | `Server credential revoked` | | 401 | SERVER_CREDENTIAL_EXPIRED | `Server credential expired` | | 401 | SERVER_CREDENTIAL_TENANT_INACTIVE | `The server credential's tenant is not active` | | 401 | SERVER_CREDENTIAL_TENANT_MISMATCH | `The server credential belongs to another tenant than the signed-in user` | | 401 | SERVER_CREDENTIAL_IP_NOT_ALLOWED | `This address is not in the server credential's allow-list` | | 401 | SERVER_TOKEN_URI_MISMATCH | `The server token was signed for another method or URL` | | 401 | SERVER_TOKEN_BODY_MISMATCH | `The server token was signed for another body` | | 403 | SERVER_CREDENTIAL_ROLE_NOT_ALLOWED | `This server credential's role does not hold the permission this route needs` | | 401 | SERVER_TOKEN_REPLAYED | `Server token already used` | | 423 | TENANT_FROZEN | `This tenant is frozen: nothing can be done for it until it is lifted.` | - `SERVER_TOKEN_MALFORMED`: not three canonical base64url parts, a header member other than `alg`, `typ` and `kid`, an `alg` other than `ES256`, a `kid` that is not a UUID, a claim missing or of the wrong shape, a signature that is not 64 bytes, or more than 4096 characters. - Until the signature is proven, a token learns nothing about the credential: anyone who knows an id but not the key gets `SERVER_CREDENTIAL_UNKNOWN` or `SERVER_TOKEN_SIGNATURE_INVALID`. - `SERVER_CREDENTIAL_TENANT_MISMATCH`: in `X-Aurea-Server-Token` only. `SERVER_CREDENTIAL_ROLE_NOT_ALLOWED`: a credential whose role narrows (What a Credential's Role Reaches), with `details.permission`. - The nonce is the last check: a token refused for any other reason can be corrected and sent again with the same nonce. - `TENANT_FROZEN`: Aurea froze your tenant ([Freezes & Quarantines](https://docs.aureahub.com/docs/guide-freezes.md)); its API key is refused the same way. Web version: https://docs.aureahub.com/#guide-server-credentials --- # Two-Factor Sign-In A second factor at sign-in for the accounts that run your tenant: an authenticator app's six-digit code after the password, and ten recovery codes for the day the phone is lost. ## Overview - **For every role but the End User**: `owner`, `tenantadmin`, `non_signing_admin`, `signer`, `approver`, `editor`, `viewer`, `auditor`, `security_admin` and `security_auditor` ([Roles](https://docs.aureahub.com/docs/authentication.md)). An End User's sign-in (`user`) never changes: its management routes answer `403` `MFA_NOT_AVAILABLE`. - **An authenticator app** — any app that reads an `otpauth://` QR code (TOTP, RFC 6238: SHA-1, 6 digits, a new code every 30 seconds) — and **ten recovery codes**, each usable once instead of a code. - **Asked after the password**: [Login](https://docs.aureahub.com/docs/auth-login.md) answers `401` `MFA_REQUIRED` with an `mfaToken`, and [Sign In: Second Step](https://docs.aureahub.com/docs/mfa-verify.md) gives the tokens for the code. - **Each account manages its own factor** with its access token (the routes under **Two-Factor Sign-In** in the API Reference); an administrator resets another user's (A Lost Phone). - Written to your tenant's [audit trail](https://docs.aureahub.com/docs/audit-events.md), while it is on (category `access`): `mfa.enrolled`, `mfa.recovery_codes_regenerated`, `mfa.removed`, `mfa.reset`, and `session.started` with `via` `password+totp` or `password+recovery_code` — never a secret or a code. ## When Aurea Enables It Two-factor sign-in is available when Aurea enables it for your tenant ([contact us](https://www.aureahub.com/contact)). [Your Second Factor](https://docs.aureahub.com/docs/mfa-status.md) answers the account's `mode`: | mode | What the accounts meet | | --- | --- | | off | **Every tenant unless Aurea enables it for yours.** Nothing is asked at sign-in. Enrolling answers `409` `MFA_OFF`. | | log | **Asked of the enrolled.** An account with an authenticator app gives its code at every sign-in; one without signs in with its password as before. This is when your accounts enrol. | | enforce | **Required.** An enrolled account gives its code; one that is not enrolled is refused at sign-in — `403` `MFA_ENROLLMENT_REQUIRED`, no tokens. Removing the app is refused (`409` `MFA_REMOVAL_REFUSED`): replace it instead. A tenant on the institutional security profile is in `enforce` by its profile. | - Aurea moves a tenant to `log` first: have every account that signs in enrol then, before `enforce`. - The second factor is asked at the password sign-in. Sessions already open, and their refresh tokens, keep working. ## Enrol an Authenticator App 1. [Enrol an App](https://docs.aureahub.com/docs/mfa-totp-start.md): `201` with `secret` (base32), `otpauthUri` and `expiresAt` (15 minutes). Show `otpauthUri` as a QR code — the app lists it under your tenant's name and the username — or let the user type `secret`. 2. [Confirm the App](https://docs.aureahub.com/docs/mfa-totp-confirm.md) with a code the app shows, before `expiresAt`: the app becomes the account's second factor, and the answer carries the ten `recoveryCodes` — shown only now. ```javascript const auth = { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }; // 1. A new secret: show otpauthUri as a QR code const enrolment = await fetch('https://api.aureahub.com/v1/auth/mfa/totp', { method: 'POST', headers: auth }) .then((r) => r.json()); showQrCode(enrolment.otpauthUri); // 2. The first code the app shows const { recoveryCodes } = await fetch('https://api.aureahub.com/v1/auth/mfa/totp/confirm', { method: 'POST', headers: auth, body: JSON.stringify({ code: codeTypedByTheUser }), // six digits }).then((r) => r.json()); showOnce(recoveryCodes); // ten codes: the user stores them safely ``` - The secret and the recovery codes are answered with `Cache-Control: no-store`: never log or keep them on your side. - An enrolment not confirmed within 15 minutes lapses: `409` `MFA_NO_PENDING` — start again. ## Sign In: the Second Step For an enrolled account, [Login](https://docs.aureahub.com/docs/auth-login.md) checks the password and answers, instead of the tokens: ```json { "statusCode": 401, "error": "MfaRequiredError", "message": "A second factor is required", "details": { "code": "MFA_REQUIRED", "mfaToken": "eyJ2IjoxLCJzdWIiOiI…", "methods": ["totp", "recovery_code"], "expiresIn": 300 } } ``` 1. Ask the user for the code their app shows (or a recovery code). 2. Within `expiresIn` seconds (5 minutes), send `{ mfaToken, code }` — or `{ mfaToken, recoveryCode }` — to [POST /v1/auth/mfa/verify](https://docs.aureahub.com/docs/mfa-verify.md), signed as the login is signed (the HMAC headers or a server token). 3. The answer is the login's: `accessToken`, `refreshToken`, `expiresIn`, `user`. A `createWallet` sent to the login is not carried over: no `wallet` comes back. ```javascript async function signIn(username, password, askForCode) { const login = await signedPost('/v1/auth/login', { username, password }); // see Login → Implementation if (login.status === 200) return login.json(); const body = await login.json(); if (login.status === 401 && body.details?.code === 'MFA_REQUIRED') { const code = await askForCode(); // six digits from the authenticator app const verify = await signedPost('/v1/auth/mfa/verify', { mfaToken: body.details.mfaToken, code }); if (verify.ok) return verify.json(); // the login's answer throw new Error((await verify.json()).details?.code); // MFA_CODE_INVALID: ask again; MFA_CHALLENGE_INVALID: sign in again } throw new Error(body.details?.code ?? body.message); } ``` - **The code of the moment**, or the one before or after it (a phone's clock may drift by one step). Each code is accepted once: after signing in with one, the next action that asks for a code takes the next one. - **A wrong code** answers `401` `MFA_CODE_INVALID`; ask again with the same `mfaToken` while it is valid. It counts as a failed sign-in: where Aurea throttles failed sign-ins for your tenant, too many answer `429` with `Retry-After`. - **An expired `mfaToken`** answers `401` `MFA_CHALLENGE_INVALID` (`The sign-in has expired: sign in again`): start again from the password. The `mfaToken` is not an access token: no other route accepts it. ## Recovery Codes - Ten codes such as `K7M2P-9QXR4`, each usable once in place of a code at the second step (`recoveryCode`). Case, dashes and spaces don't matter. - [New Recovery Codes](https://docs.aureahub.com/docs/mfa-recovery-codes.md), with a current code of the app: ten new ones, and the old ones stop working. [Your Second Factor](https://docs.aureahub.com/docs/mfa-status.md) tells how many are left (`recoveryCodesLeft`). ## Replace or Remove the App - **A new phone**: enrol again ([Enrol an App](https://docs.aureahub.com/docs/mfa-totp-start.md), then confirm). The old app stays the factor until the new one is confirmed; then it is replaced, with ten new recovery codes. - **No second factor**: [Remove the App](https://docs.aureahub.com/docs/mfa-totp-remove.md), with a current code — the app and the recovery codes go. Refused where a second factor is required (`409` `MFA_REMOVAL_REFUSED`). ## A Lost Phone - With a recovery code: sign in with it, then enrol the new phone. - Without one: an administrator of your tenant removes the user's factor with [Reset a User's Factor](https://docs.aureahub.com/docs/mfa-reset.md) — under the same rules as any change to another user's account ([Roles](https://docs.aureahub.com/docs/authentication.md)). The user then enrols a new app. Where your tenant's quorum holds administrators' actions, the reset waits for another administrator ([Your Tenant's Quorum](https://docs.aureahub.com/docs/guide-tenant-quorum.md)). ## Refusals The usual envelope, the code in `details.code`: | Status | details.code | message | | --- | --- | --- | | 401 | MFA_REQUIRED | `A second factor is required` — the login's answer for an enrolled account, with `mfaToken`, `methods` and `expiresIn` | | 403 | MFA_ENROLLMENT_REQUIRED | `A second factor is required for this account and none is enrolled` — at the login, where it is required | | 401 | MFA_CHALLENGE_INVALID | `The sign-in has expired: sign in again` | | 401 | MFA_CODE_INVALID | `The code is not correct` — at the second step; it counts as a failed sign-in | | 400 | MFA_CODE_INVALID | `The code is not correct` — confirming, new recovery codes, removing | | 403 | MFA_NOT_AVAILABLE | `A second factor is for the accounts that administer` — an End User | | 409 | MFA_OFF | `Second factors are not in use for this account` | | 409 | MFA_NO_PENDING | `No enrolment is waiting: start one again` | | 409 | MFA_NOT_ENROLLED | `No second factor is enrolled` | | 409 | MFA_REMOVAL_REFUSED | `A second factor is required for this account: enrol a new one instead of removing it` | - At the second step, sending both a code and a recovery code, or neither, answers `422` (`Give either a code or a recovery code`). Too many failed sign-ins, where Aurea throttles them: `429` `Too many failed sign-in attempts. Try again in 5 minutes.` with `Retry-After`. - Rate limits: 20 requests per minute per client IP on each route that takes a code or starts an enrolment, and on the second step. Web version: https://docs.aureahub.com/#guide-two-factor --- # Freezes & Quarantines Stop a user's account or a wallet at once, on every server, and hold one pending operation before it is signed — until someone lifts it. ## Overview - **A freeze** stops a user of your tenant, one of its wallets, or the wallets of one of its [accounts](https://docs.aureahub.com/docs/guide-accounts.md), until it is lifted or until the time you set. **A quarantine** holds one operation that is waiting for something — a signature, a deposit, an approval — before anything of it is signed or sent. - **In force when the call answers.** Freezing and lifting answer about a second after they are written, so that by then every server of the API knows: the very next request is refused, or allowed again. - Nothing is frozen until someone freezes it: with nothing frozen, every route answers as before. - Each freeze and quarantine keeps its reason, who placed it, when, and — once lifted — who lifted it, when and why. Lifted ones stay listed. - Your tenant's administrators act on its users, wallets, accounts and operations, never on the tenant itself: only Aurea freezes a whole tenant, and only Aurea lifts what Aurea placed. ## What a Freeze Stops | Frozen | Refused | details.code | | --- | --- | --- | | user | Every request made with the user's access token — reads included —, the user's sign-in (once the password is checked), token refresh and Google or Apple sign-in, every signature of the user's wallets, and the MPC relay of the user's sessions. | USER_FROZEN | | wallet | Every route that would sign or send with it — sends and broadcasts, swaps and approvals, gasless sends, dApp signatures and broadcasts, Earn, MPC signing and auxiliary ceremonies, and the MPC relay of its sessions. Its owner still reads it. A wallet is its address in your tenant: freezing one freezes every wallet of the tenant with that address, on every chain. | WALLET_FROZEN | | account | What a frozen wallet stops, for every wallet the account holds — every wallet of the tenant at the address of one of them, on every chain. Reads go on, and the user's wallets outside the account still sign. A user's default account is frozen by the user's id. | ACCOUNT_FROZEN | | tenant (Aurea only) | Every request of the tenant's users and administrators, its API key and its server tokens, its sign-ins, registrations and refreshes, and every signature of its wallets. | TENANT_FROZEN | Public routes called without a token are not affected. ## The 423 Answer A frozen account, wallet or tenant, and a quarantined operation, answer `423 Locked`: ```json { "statusCode": 423, "error": "LockedError", "message": "This wallet is frozen: it cannot sign until the freeze is lifted.", "details": { "code": "WALLET_FROZEN" } } ``` | details.code | message | | --- | --- | | TENANT_FROZEN | `This tenant is frozen: nothing can be done for it until it is lifted.` | | USER_FROZEN | `This account is frozen: nothing can be done with it until it is lifted.` | | ACCOUNT_FROZEN | `This wallet's account is frozen: its wallets cannot sign until the freeze is lifted.` | | WALLET_FROZEN | `This wallet is frozen: it cannot sign until the freeze is lifted.` | | OPERATION_QUARANTINED | `This operation is quarantined: it will not be signed or sent until the quarantine is lifted.` | - When more than one applies, the widest is answered: the tenant, then the user, then the account, then the wallet. - Don't retry a `423`: it answers the same until the freeze or the quarantine is lifted, or a freeze's `until` passes. - Where a wallet is about to sign and Aurea cannot read whether it is frozen, nothing is signed: `503` with `details.code` `FREEZE_STATUS_UNREAD` (`Whether this wallet may sign could not be checked. Nothing was signed; try again.`). That one can be retried. ## Freeze and Lift ```bash # Freeze a user until lifted curl -X POST https://api.aureahub.com/v1/admin/tenants/$TENANT_ID/freezes \ -H "Authorization: Bearer $TENANT_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "target": "user", "id": "'$USER_ID'", "reason": "Account takeover reported by the customer" }' # Lift it curl -X POST https://api.aureahub.com/v1/admin/tenants/$TENANT_ID/freezes/$FREEZE_ID/lift \ -H "Authorization: Bearer $TENANT_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "reason": "Identity confirmed, password reset" }' ``` - `target`: `user`, `wallet` or `account`; `id`: the user's, the wallet's or the account's id — for a user's default account, the user's id; `reason`: 1 to 500 characters; `until` (optional): a time in the future when the freeze ends by itself. - One freeze at a time per user, wallet or account: a second answers `409` `ALREADY_FROZEN`. A freeze whose `until` passed is closed when the next one is placed. - Nobody freezes their own user, nor lifts a freeze of their own user, of one of their wallets, or of an account they own — their own default account included (`403` `FREEZE_SELF`). Freezing one of your own wallets or accounts is allowed. - [List Freezes](https://docs.aureahub.com/docs/freezes-list.md) shows the active ones by default; `status=lifted` or `all` shows the rest — a freeze past its `until` counts as lifted. ## Quarantine an Operation A quarantine holds one operation that is still waiting, at the one place it would be signed or sent; the rest of the wallet works as usual. Lifted, the operation goes on from where it waited — its own deadline is not extended. | operation | Waiting when | Held at | | --- | --- | --- | | earn_operation | An [Earn operation](https://docs.aureahub.com/docs/earn-operation-get.md) that is `submitted` and waits for the user's signatures (client-side or MPC wallet). | [Send Signatures](https://docs.aureahub.com/docs/earn-broadcast.md), and the MPC signing of its steps. | | bank_payout | A [payout paid from a wallet](https://docs.aureahub.com/docs/payout-wallet-get.md) that is `pending` its deposit from one of the user's Aurea wallets. | Every route that would send the deposit from that wallet to the payout's deposit address. | | agent_execution | An [agent payment](https://docs.aureahub.com/docs/agentic-api.md) waiting for an approval (`approval_required`). | Its approval: nothing runs; it can still be rejected. | - Anything not waiting answers `409` `OPERATION_NOT_PENDING`, with its `details.state`. A payout Aurea signs nothing for answers `409` `OPERATION_NOT_QUARANTINABLE`, with `details.reason` `hosted_payout` (paid on the bank ramp's page) or `external_source` (deposited from an address outside Aurea). - One quarantine at a time per operation: `409` `ALREADY_QUARANTINED`. ## Who May - **Freeze, lift and quarantine**: the tenant roles `owner`, `tenantadmin`, `non_signing_admin` and `security_admin`, on their own tenant ([Roles](https://docs.aureahub.com/docs/authentication.md)). A freeze protects and moves nothing, so the Non-signing Admin may place one. - **Read** the freezes and quarantines: those four and `auditor` and `security_auditor`. - What an Aurea administrator placed, only Aurea lifts (`403` `FREEZE_LIFT_PLATFORM_ONLY`); a freeze of your tenant itself too. Your tenant's users never reach an Aurea account: `404` `FREEZE_TARGET_NOT_FOUND`. - A frozen tenant's own administrators are frozen with it. Web version: https://docs.aureahub.com/#guide-freezes --- # Policy Sets Write your tenant's signing policy as versions — ordered rules with a verdict each —, check them, try them on your own history, and publish one through a second administrator. On the institutional security profile, the active set decides every signature. ## Overview - **A policy set** is one version of your tenant's signing policy: an ordered list of rules. Each rule names the operations it applies to — their type, who starts them, which wallet signs, where they go, the chain and the asset, the amount in EUR, the time — and a verdict: `ALLOW`, `SCREEN`, `REQUIRE_APPROVAL`, `BLOCK` or `FREEZE`. The first rule an operation matches decides. - Your administrators write a set as a **draft**, **validate** it, **simulate** it on the tenant's past operations, **submit** it, and **publish** it as the tenant's active set — through another administrator when the tenant has more than one. Every version is kept; an older one can be published again. - The routes are `/v1/policy-sets`, on the caller's own tenant. Reading, validating and simulating: the tenant roles `owner`, `tenantadmin`, `non_signing_admin`, `auditor`, `security_admin` and `security_auditor`. Writing, submitting, publishing, withdrawing and rolling back: `owner`, `tenantadmin`, `non_signing_admin` and `security_admin` ([Roles](https://docs.aureahub.com/docs/authentication.md)). Any other role answers `403` `Tenant admin access required. Your role does not have permission to perform this action.` ## Enforced or Logged What the active set does depends on your tenant's security profile, which Aurea sets with your tenant ([contact us](https://www.aureahub.com/contact)); the simulation's `tenantProfile` says which yours is. | Profile | The active set | | --- | --- | | `institutional` | **Enforced.** At every signing step — before anything is signed, handed out to sign, or sent — the active set decides, and the call answers as below. No rule matched, or no active set: `BLOCK`. An operation of type `RAW`, `TYPED_MESSAGE` or `KEY_EXPORT` needs a rule that names its type. | | `legacy` — every other tenant | **Evaluated and logged, never enforced.** A published set refuses, holds and changes nothing: every payment is answered exactly as it would be without it. When policy evaluation is on, Aurea logs the verdict on its side; that evaluation is switched off until Aurea turns it on. | Use [Simulate](https://docs.aureahub.com/docs/policy-set-simulate.md) to see what a set would decide on your own history, under your profile and under `institutional`, before you publish it. ## When the Policy Decides For an institutional tenant, at every call that signs with a key Aurea holds, prepares a transaction or a permit for a client-side or MPC wallet to sign, starts an MPC signing ceremony, or broadcasts what was signed: sends, token approvals, swaps (gasless ones too), dApp signatures and broadcasts, [Earn](https://docs.aureahub.com/docs/guide-earn.md), the savings vault, and the key export. - **The key export is decided twice**: when its token is requested ([Request Export Token](https://docs.aureahub.com/docs/wallets-export-token.md) — a refusal leaves the wallet as it was, with no token) and when the key is exported ([Export Encrypted Key](https://docs.aureahub.com/docs/wallets-export-key.md)). It is a `KEY_EXPORT` operation: without a rule naming that type, it is blocked. - **A broadcast is decided again.** An operation prepared for a client-side or MPC wallet is decided when it is prepared and again when what was signed is broadcast, or when an MPC wallet's signing ceremony starts. - **The money routes need an `Idempotency-Key`** for an institutional tenant: `400` `IDEMPOTENCY_KEY_REQUIRED` without one ([Idempotency](https://docs.aureahub.com/docs/idempotency.md)). - **Rolling windows are measured.** A window rule's total is the EUR amount of the operations that signed under the policy in the last `windowSeconds`, for the same initiator, source wallet or destination; an operation is counted once, even when it signs twice. An operation that ended without moving anything — `FAILED`, `BLOCKED`, `REJECTED`, `CANCELLED` — stops counting, and one without an EUR price is not counted. Two operations that would both fill the same window are decided one after the other: only one fits. - **A recorded operation shows the decision** ([Operations and Fees](https://docs.aureahub.com/docs/guide-operations.md)): `PENDING_POLICY` `evaluated`, then `PENDING_AUTHORIZATION` `not_required` when it may sign, `awaiting_approval` while it waits for approvers, `approved` once approved; a block or a freeze ends it `BLOCKED` (`failureReason` `POLICY_BLOCKED` or `POLICY_FROZEN`), any other refusal `FAILED` `not_sent`. ## Refusals at Signing For an institutional tenant only, before anything is signed, handed out to sign, or sent: | Verdict | Status | details.code | What happens | | --- | --- | --- | --- | | ALLOW | — | — | Signed. The operation's EUR amount counts in the rules' rolling windows. | | REQUIRE_APPROVAL | 202 | — | On the money routes that start an operation: held until the rule's approvers approve it — [Approvals](https://docs.aureahub.com/docs/guide-approvals.md). | | REQUIRE_APPROVAL | 403 | POLICY_APPROVAL_REQUIRED | Everywhere else, unless the operation was approved for that rule. `The tenant's policy requires an approval for this operation, which cannot be held for approval here: nothing was signed or sent.` | | BLOCK, or no rule | 403 | POLICY_BLOCKED | `The tenant's policy blocks this operation: nothing was signed or sent.` | | ALLOW or REQUIRE_APPROVAL, by a rule naming designated signers | 403 | POLICY_SIGNER_NOT_DESIGNATED | **When Aurea enables designated signers** (Designated Signers below): the operation's initiator is not one of the rule's designated signers. Refused before anything is held, signed or sent. `The tenant's policy names who may sign this operation, and you are not one of them: nothing was signed or sent.` | | FREEZE | 423 | POLICY_FROZEN | The source wallet is frozen as well, with `details.freezeId`: it signs nothing until one of your administrators lifts the freeze ([Freezes & Quarantines](https://docs.aureahub.com/docs/guide-freezes.md)). `The tenant's policy froze this wallet: nothing was signed or sent, and it cannot sign until the freeze is lifted.` | | SCREEN | 503 | POLICY_SCREENING_UNAVAILABLE | There is no screening yet, so an operation a `SCREEN` rule decides is never signed. `The tenant's policy asks for a screening of this operation, which is not available: nothing was signed or sent.` | | — | 503 | POLICY_UNAVAILABLE | The policy could not be evaluated in time, or at all: nothing is signed. Try again. `The tenant's policy could not be evaluated: nothing was signed or sent. Try again.` | ```json { "statusCode": 403, "error": "ForbiddenError", "message": "The tenant's policy blocks this operation: nothing was signed or sent.", "details": { "code": "POLICY_BLOCKED", "verdict": "BLOCK", "ruleId": "e2b6d9f1-7a3c-4c58-8b0e-5f1a4d2c9b73", "ruleName": "No contract deployments", "reason": "rule_matched" } } ``` - `details.reason`: `rule_matched`; `missing_data` — a restrictive rule decided because the operation lacked what it checks, named in `details.missing` (e.g. `["amount"]`); `no_matching_rule`; `no_policy_set`; `explicit_rule_required` — a `RAW`, `TYPED_MESSAGE` or `KEY_EXPORT` operation no rule names; `engine_error`. `ruleId` and `ruleName` are `null` when no rule decided; `POLICY_UNAVAILABLE` names no verdict, rule or reason. - The details never repeat a rule's content: no amount, address or person it names. - On a broadcast the prepared transaction or permit was used before the refusal: prepare it again. A client-side send refused when it was prepared keeps its `pending` transaction record, which fails like any preparation never broadcast. - A frozen wallet, user or tenant answers its own `423` first (`WALLET_FROZEN`, `USER_FROZEN`, `TENANT_FROZEN`). ## The Life Cycle ```text draft ──submit──► awaiting_quorum ──publish──► active ──(another version published)──► archived │ │ └──────archive─────────┴──► archived ``` - **draft**: written by its author, the administrator who created it. Only the author replaces its rules and submits it; another administrator copies it into a draft of their own (`copyFromSetId`). - **awaiting_quorum**: submitted, once valid. From here on a version never changes. - **active**: the tenant's policy — one at a time. Publishing a set archives the one it replaces. - **archived**: replaced, or withdrawn while a draft or awaiting. The active set is never withdrawn on its own: publish its replacement. - **Rollback**: a version that was once active is copied, rules and all, into a new version, authored by the caller and submitted — published at once where the tenant's quorum is off, else waiting for another administrator. - Each set is the tenant's next `version`, from 1. Who published a set (`activatedBy`) and who archived it (`archivedBy`) stay on it. ## Who Publishes The administrators counted are the active users whose role writes policy sets (`owner`, `tenantadmin`, `non_signing_admin`, `security_admin`); a suspended one doesn't count. | Your tenant | Who publishes a submitted set | | --- | --- | | One administrator | That administrator, alone: there is no one else to ask. | | Two administrators or more | An administrator other than the set's author — `403` `POLICY_QUORUM_AUTHOR` for the author. The publication is recorded as that administrator's approval (`approverUserIds`). | | Security profile `institutional` | Always an administrator other than the author: with a single administrator, the set waits until a second one exists. | The count is taken when the set is submitted — `requiredApprovals`: `1` when another administrator must publish it, else `0` — and again when it is published, so the rule applies from the day a second administrator exists. Your tenant's security profile is set by Aurea: `legacy` unless Aurea made it `institutional` (the simulation's `tenantProfile` says which). ## Rules A rule has a `name` (1–200 characters) and a `verdict`; every condition is optional. A condition left out, or `null`, matches anything; a list holds 1 to 500 values. A set holds at most 500 rules, in order. ```json { "rules": [ { "name": "Large transfers need two approvers", "verdict": "REQUIRE_APPROVAL", "operationTypes": ["TRANSFER"], "amountMinRef": "10000", "approvalThreshold": 2, "approverIds": ["0d2f6b8a-4c1e-4a93-b7d5-2e9f1c3a8b60", "6a1c9e3f-2b7d-4f50-8e4a-0c5d3b9f7e12"] }, { "name": "No contract deployments", "verdict": "BLOCK", "operationTypes": ["DEPLOY"] }, { "name": "Everything else", "verdict": "ALLOW" } ] } ``` | Field | Matches | | --- | --- | | verdict | `ALLOW`, `SCREEN`, `REQUIRE_APPROVAL`, `BLOCK` or `FREEZE`, from the least to the most restrictive. | | operationTypes | `TRANSFER`, `CONTRACT_CALL`, `APPROVE`, `TYPED_MESSAGE`, `RAW`, `DEPLOY`, `KEY_EXPORT`. | | initiatorKinds, initiatorIds | Who starts it: `user`, `group`, `api_credential`, `agent`, `platform`; and their ids — a [group](https://docs.aureahub.com/docs/guide-groups.md)'s id for `group`. | | sourceKinds, sourceIds | Whose key signs: `custodial`, `client_side`, `mpc`, `platform`, `group`; and the wallets' ids — for `group`, a group's id: the wallets of its members. | | destinationKinds, destinationAddresses | Where it goes: `internal` (a wallet of the tenant), `one_time`, `address_book`, `contract`, `dapp`; and the addresses. | | chains, assets | Chain slugs (`ethereum`, `base`, `solana`…); assets as `:` or `:native`. | | amountMinRef, amountMaxRef | The amount in EUR, inclusive, as decimal text (up to 20 digits and 18 decimals). | | windowScope, windowSeconds, windowMinRef, windowMaxRef | A rolling total per `initiator`, `source` or `destination` over 60 seconds to 366 days; scope, seconds and a minimum or a maximum go together. Measured for an institutional tenant (When the Policy Decides); in a simulation, and for any other tenant, the total is not known, and such a rule is only ever a possible match. | | methodSelectors | Four-byte function selectors, `0x` and 8 hex digits. | | timeDays, timeFromMinute, timeToMinute | ISO weekdays (1 = Monday) and minutes of the day, in UTC; `[from, to)`, wrapping past midnight when `from > to`. Both minutes or neither. | | screeningResults | `PASSED`, `FLAGGED`, `PENDING`, `BYPASSED`. There is no screening yet: such a rule is only ever a possible match. | | approvalThreshold, approverIds, approversExcludeInitiator | `REQUIRE_APPROVAL` only, and required there: how many approvals (1–20), from whom, and whether the initiator is kept from approving its own (default `true`). Name users whose role approves — `approver`, `owner`, `tenantadmin`, `non_signing_admin` ([Approvals](https://docs.aureahub.com/docs/guide-approvals.md)) — or a [group](https://docs.aureahub.com/docs/guide-groups.md) of them: its members are asked, and the threshold counts people. | | designatedSignerIds | The only users who may sign what the rule lets through (Designated Signers below). Left out or `null`: anyone may. | - **A missing value never allows.** When an operation can't be checked against a condition — its EUR amount is unknown because its token has no price, for example — a rule that would allow it does not match, and a restrictive one counts as a possible match. - **No rule matched**: the profile's default — `ALLOW` for `legacy`, `BLOCK` for `institutional`. Under `institutional`, `RAW`, `TYPED_MESSAGE` and `KEY_EXPORT` also need a rule that names them; a rule for every type covers them only when it blocks or freezes. - A rule written wrong is refused with its reasons, never repaired: `400` `POLICY_RULES_INVALID`, each reason in `details.errors` with the rule's position. ## Designated Signers A rule's `designatedSignerIds` name the only users who may sign what it lets through — an operation it allows, or holds for approval. - **Available when Aurea enables it** ([contact us](https://www.aureahub.com/contact)). Until then the list is stored, validated and shown, and refuses no one. - **Once enabled, for a tenant on the institutional security profile**: when the rule that decides names designated signers and the operation's initiator is not one of them, the call answers `403` `POLICY_SIGNER_NOT_DESIGNATED` — before anything is held for approval, signed or sent — and a recorded operation ends `BLOCKED` with that code. The details name the rule, never the people. For any other tenant it is only logged on Aurea's side, as every evaluation is. - **Who signs** is the operation's initiator: the user who calls, or, for an operation approved after a hold, the user who started it. An agent, an API credential or the platform is never a designated signer. - It counts only for `ALLOW` and `REQUIRE_APPROVAL`: a rule that blocks, freezes or asks for a screening answers as it always does. A rule naming none restricts no one. - Name the designated signers as users: the signer check compares the initiator with the ids the rule names, and does not read a [group](https://docs.aureahub.com/docs/guide-groups.md)'s members. ## Validate and Simulate - [Validate](https://docs.aureahub.com/docs/policy-set-validate.md) checks the set as a whole and the people and wallets its rules name. **Errors** stop a submission: `APPROVAL_THRESHOLD_UNREACHABLE` (fewer distinct approvers than the threshold), `APPROVER_UNKNOWN`, `DESIGNATED_SIGNER_UNKNOWN` (not users of the tenant). **Warnings** don't: `EMPTY_SET`, `SHADOWED` and `SHADOWED_UNDER_LEGACY` (a rule that a rule before it with no condition hides), `WINDOW_NOT_MEASURED` (a window rule: its total is not known to a simulation, nor outside the institutional profile), `SCREENING_NOT_AVAILABLE`, `SOURCE_WALLET_UNKNOWN`, `INITIATOR_USER_UNKNOWN`, and — once Aurea enables groups in policy rules — `GROUP_UNKNOWN` (an id named as a group of initiators or sources that is none of your tenant's groups, users or wallets). With groups enabled, a group's members count towards a threshold. - [Simulate](https://docs.aureahub.com/docs/policy-set-simulate.md) runs a set — a draft or any version — on the tenant's operations of the last `days` (default 30, at most 90), the newest `limit` of them (default 2000, at most 5000), each at its own time: counts per verdict and per rule, under your tenant's profile and under `institutional`, with up to 10 operation ids per rule. Read only; amounts in EUR from the token prices Aurea keeps. At most 10 calls a minute. ## Refusals | Status | details.code | message | | --- | --- | --- | | 400 | POLICY_RULES_INVALID | `Invalid policy rules`, with `details.errors` | | 400 | POLICY_REQUEST_INVALID | `Invalid request` (with `details.errors`), or `Give rules or copyFromSetId, not both` | | 403 | POLICY_SET_NOT_AUTHOR | `Only the author of a draft edits or submits it: copy it into a draft of your own` | | 403 | POLICY_QUORUM_AUTHOR | `The tenant's quorum applies: an administrator other than the author publishes this policy set` | | 404 | POLICY_SET_NOT_FOUND | `Policy set not found` — also another tenant's set. | | 404 | POLICY_COPY_SOURCE_NOT_FOUND | `The set to copy is not found` | | 409 | POLICY_SET_NOT_DRAFT | `A policy set that is active is not edited: copy it into a new draft` (its status named) | | 409 | POLICY_SET_NOT_AWAITING | `A policy set that is draft is not published: submit a draft first` (its status named) | | 409 | POLICY_SET_ACTIVE | `The active policy set is archived only when another version is published in its place` | | 409 | POLICY_SET_ARCHIVED | `The policy set is already archived` | | 409 | POLICY_SET_NOT_ARCHIVED | `Only an archived version is rolled back to` | | 409 | POLICY_SET_NEVER_ACTIVE | `This version was never active: copy it into a draft instead` | | 422 | POLICY_SET_INVALID | `The policy set is not valid`, with the validation's `details.errors` | | 503 | POLICY_SIMULATION_TIMEOUT | `The tenant's history could not be read in time: simulate a shorter window or fewer operations` | Every `409` carries the set's status in `details.status`. Web version: https://docs.aureahub.com/#guide-policy-sets --- # Groups Name a set of your tenant's users once — "Treasury signers", "Finance approvers" — and let your policy rules name the group instead of each person. ## Overview - **A group** is a named set of your tenant's users: a `name` (1 to 100 characters, trimmed, unique in your tenant whatever its case), an optional `description` (up to 500 characters) and its members. It starts empty. - **A group grants no permission.** What a member may do is still its role's ([Roles](https://docs.aureahub.com/docs/authentication.md)); a group only lets a [policy rule](https://docs.aureahub.com/docs/guide-policy-sets.md) name several people at once. - A member is a user of your tenant whose account is not deleted. A user may be in several groups. - The routes are under **Groups** in the API Reference, on the caller's own tenant. Deleting a group removes its memberships; a rule that named it then names no one by it. - Each change is written to your tenant's [audit trail](https://docs.aureahub.com/docs/audit-events.md), while it is on: `group.created`, `group.changed` (the fields changed), `group.deleted` (how many members it had), `group.member_added` and `group.member_removed` (the user's id), category `governance` — never a group's name or description. ## Who Manages Them - **Reading** the groups and their members: `owner`, `tenantadmin`, `non_signing_admin`, `auditor`, `security_admin` and `security_auditor` — the roles that read the policy sets. - **Creating, changing and deleting** a group, and adding and removing members: `owner`, `tenantadmin`, `non_signing_admin` and `security_admin` — the roles that write the policy sets. - Any other role answers `403`. ## In Policy Rules A rule names a group by its `id`, among its initiators, its sources or its approvers: | Where | What it means | | --- | --- | | `initiatorKinds` with `group`, the group's id in `initiatorIds` | An operation started by a member of the group matches. | | `sourceKinds` with `group`, the group's id in `sourceIds` | An operation signed by a wallet of a member of the group matches. | | the group's id in `approverIds` | The approval asks the group's members; `approvalThreshold` counts people, not groups. A user named directly stays named. | ```json { "name": "Large transfers need two of Finance", "verdict": "REQUIRE_APPROVAL", "operationTypes": ["TRANSFER"], "amountMinRef": "10000", "approvalThreshold": 2, "approverIds": ["3b8e1f6a-2c4d-4e9f-a1b7-6d5c4e3f2a10"] } ``` - **Membership is read when an operation is decided**: a member added later counts from then on, one removed no longer does. A deleted user is no longer asked as an approver. - **An empty group names no one** — never "any approver". - **A membership that cannot be read never allows**: a rule naming the group does not allow the operation, and a restrictive one counts as a possible match, as for any missing value. - [Validate](https://docs.aureahub.com/docs/policy-set-validate.md) counts a group's members towards a threshold, and warns with `GROUP_UNKNOWN` when a rule names, as a group of initiators or sources, an id that is none of your tenant's groups, users or wallets. [Simulate](https://docs.aureahub.com/docs/policy-set-simulate.md) reads the groups as a decision would. - Name [designated signers](https://docs.aureahub.com/docs/guide-policy-sets.md) as users: the signer check does not read a group's members. - Like every rule, it decides for a tenant on the institutional security profile, and is evaluated and logged for any other ([Enforced or Logged](https://docs.aureahub.com/docs/guide-policy-sets.md)). ## Until Aurea Switches Them On > ℹ️ **Groups in policy rules are available when Aurea enables them** — [contact us](https://www.aureahub.com/contact). The group routes work today; what changes is how a rule reads a group's id. - **Until then**, policies read no group: a group among a rule's initiators or sources matches no one, and a group's id among `approverIds` is read as a user's id — it is none, so [Validate](https://docs.aureahub.com/docs/policy-set-validate.md) answers `APPROVER_UNKNOWN` and the set cannot be submitted. - **Once Aurea enables them**, every rule that names a group reads its members as described above — the rules already published included. ## Refusals The usual envelope, with the code in `details.code`: | Status | details.code | message | | --- | --- | --- | | 400 | GROUP_REQUEST_INVALID | `A group's name is 1 to 100 characters`, or `Invalid request` with each problem in `details.errors` — a field that doesn't belong, a description over 500 characters, an id that is not a UUID, a change that names neither the name nor the description (`(body): Change the name or the description`) | | 404 | GROUP_NOT_FOUND | `No such group in the tenant` — also another tenant's group | | 404 | GROUP_USER_NOT_FOUND | `No such user in the tenant` | | 404 | GROUP_NOT_MEMBER | `The user is not a member` | | 409 | GROUP_NAME_TAKEN | `The tenant has a group by that name` | | 409 | GROUP_ALREADY_MEMBER | `The user is a member already` | Web version: https://docs.aureahub.com/#guide-groups --- # Approvals When your tenant's policy asks for an approval, the money route answers `202` and holds the operation — nothing signed or sent — until the rule's approvers approve it, each with a signature of their own key. Then it goes on as the call would have. ## Overview - **For tenants on the institutional security profile.** Aurea sets a tenant's profile with the tenant — [contact us](https://www.aureahub.com/contact). Every other tenant is on the `legacy` profile: its policy set is never enforced, nothing is ever held, and its approvers' inbox stays empty. - **A rule asks.** A rule of your tenant's active [policy set](https://docs.aureahub.com/docs/guide-policy-sets.md) with the verdict `REQUIRE_APPROVAL` names how many approvals (`approvalThreshold`, 1–20), from whom (`approverIds`) and whether the initiator is kept from approving their own operation (`approversExcludeInitiator`, `true` by default). - **The operation waits.** On the money routes that start an operation, the call answers `202` with the operation and its approval request; nothing is signed, handed out to sign or sent. Anywhere else, the same rule refuses: `403` `POLICY_APPROVAL_REQUIRED`. - **Approvers sign their decision** with an approval key: a P-256 key pair they make themselves, whose public key they register. Aurea never holds the private key. - **The threshold approves, one rejection ends it.** Once the rule's threshold of distinct approvers approved, the operation goes on server-side, exactly once, as the call that started it would have. - Each decision is on the operation's history and, while your [audit trail](https://docs.aureahub.com/docs/audit-events.md) is on, in it: `approval.requested`, `approval.approved`, `approval.rejected`, `approval.threshold_reached`, `approval.expired` and `approval.continued` (category `governance`), `approval_key.registered` and `approval_key.revoked` (category `security`) — never a key, a signature or a payload. ## Who Approves - The routes under **Approvals** in the API Reference are for the tenant roles that approve — `approver`, `owner`, `tenantadmin` and `non_signing_admin` ([Roles](https://docs.aureahub.com/docs/authentication.md)) — on their own tenant. Any other role answers `403`. - A request's approvers are those users, narrowed by the rule: the users its `approverIds` names when it names some (name users with one of those roles: no other role reaches the routes), any of them otherwise — and never the operation's initiator while `approversExcludeInitiator` is `true`. - Each approver decides a request once. The threshold counts distinct approvers. ## Approval Keys Each approver registers their own keys with [Register an Approval Key](https://docs.aureahub.com/docs/approval-key-register.md): `name` (1 to 100 characters) and `publicKey`, the public half of a P-256 key pair as a JWK. ```javascript // In the approver's browser or device (WebCrypto): the private key never leaves it const { publicKey, privateKey } = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, false, // the private key cannot be exported ['sign', 'verify'], ); const { kty, crv, x, y } = await crypto.subtle.exportKey('jwk', publicKey); const res = await fetch('https://api.aureahub.com/v1/approval-keys', { method: 'POST', headers: { Authorization: `Bearer ${approverToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'Laptop key', publicKey: { kty, crv, x, y } }), }); const key = await res.json(); // key.id is the kid your decisions name; keep privateKey (e.g. in IndexedDB) ``` - `publicKey`: `{ "kty": "EC", "crv": "P-256", "x": "…", "y": "…" }`, `x` and `y` 32 bytes each in unpadded base64url, a point on the curve. `alg` (`ES256`), `use` (`sig`), `kid`, `key_ops` and `ext` are accepted and dropped. A JWK that carries its private part (`d`) is refused — `400` `APPROVAL_KEY_PRIVATE` — and that key pair should not be used. - **At most two active keys per approver.** To rotate, register the new one, move to it, then revoke the old one with [Revoke an Approval Key](https://docs.aureahub.com/docs/approval-key-revoke.md); a third answers `409` `APPROVAL_KEY_LIMIT`. - **A key is registered once, ever** — also after its revocation: `409` `APPROVAL_KEY_IN_USE`. A new key is a new key pair. - **Revocation is final.** A decision signed with a revoked key is refused from then on (`403` `APPROVAL_SIGNATURE_KEY_UNKNOWN`); the decisions it signed before stand. - Each approver lists, registers and revokes only their own keys. `lastUsedAt` is the last decision a key signed. ## When an Operation Is Held These routes start an operation, and can hold it: | Route | Page | | --- | --- | | POST /v1/transactions/ | [Create Transaction](https://docs.aureahub.com/docs/tx-create.md) | | POST /v1/swap/approve | [Approve Token](https://docs.aureahub.com/docs/swap-approve.md) | | POST /v1/swap/execute | [Execute Swap](https://docs.aureahub.com/docs/swap-execute.md) | | POST /v1/swap/execute-solana | [Execute Solana Swap](https://docs.aureahub.com/docs/swap-execute-solana.md) | | POST /v1/swap/execute-gasless | [Execute Gasless](https://docs.aureahub.com/docs/swap-gasless.md) | So do the savings vault's supply and withdrawal, in the products Aurea switches on per tenant ([contact us for details](https://www.aureahub.com/contact)). - **Held before anything leaves.** A custodial wallet's operation is held before Aurea signs it; a client-side or MPC wallet's before anything is handed out to sign — the `202` carries no hash to sign. - **Held only at the call's first signing step**, before anything of that call was signed or sent. A later step of the same call, once an earlier one was signed, cannot be held: there a rule asking for an approval refuses. - **Refused, not held** — `403` `POLICY_APPROVAL_REQUIRED`, nothing signed or sent — everywhere else a rule asks for an approval: the broadcasts ([Broadcast Transaction](https://docs.aureahub.com/docs/tx-broadcast.md), [Broadcast Swap Tx](https://docs.aureahub.com/docs/swap-broadcast.md), [Broadcast Approve Tx](https://docs.aureahub.com/docs/swap-broadcast-approve.md), [Broadcast Gasless Tx](https://docs.aureahub.com/docs/swap-broadcast-gasless.md)), which carry on what was prepared and decided before; the [dApp broadcast](https://docs.aureahub.com/docs/dapps-broadcast.md) and a dApp's personal message; an MPC signing ceremony of anything but a transaction Aurea prepared for an approved operation; [Sign EIP-712 Permit](https://docs.aureahub.com/docs/swap-sign-permit.md); the [key export](https://docs.aureahub.com/docs/wallets-export-token.md); and [Earn](https://docs.aureahub.com/docs/guide-earn.md). - **Your operations are recorded.** A held operation is followed as an [operation](https://docs.aureahub.com/docs/guide-operations.md), and an institutional tenant's operations are recorded. The route answers `202 Accepted`: ```json { "operationId": "1d7a3c52-8e4b-4f19-a0c6-2b9e7d5f3a18", "state": "PENDING_AUTHORIZATION", "subState": "awaiting_approval", "approval": { "id": "c3e1a9f4-6b2d-4e8a-9f17-5d0c2b8e4a63", "ruleId": "e2b6d9f1-7a3c-4c58-8b0e-5f1a4d2c9b73", "ruleName": "Large transfers need two approvers", "threshold": 2, "expiresAt": null } } ``` - Nothing was signed, handed out or sent, and no transaction record is kept for it: the approved operation writes its own. - **The `Idempotency-Key`** — which an institutional tenant's money routes require (`400` `IDEMPOTENCY_KEY_REQUIRED` without it) — keeps this answer: the same request with the same key answers this `202` again (`idempotency-replayed: true`), and never holds a second operation. It does not tell you the outcome: follow the operation. ## Follow a Held Operation - [Get an Operation](https://docs.aureahub.com/docs/operation-get.md): `PENDING_AUTHORIZATION` `awaiting_approval` while it waits; `approved` once approved, then on its way like any operation — or `REJECTED` (`failureReason` `APPROVAL_REJECTED`), or `FAILED` `approval_expired` where an approval window applies, or `CANCELLED` when its initiator cancels it while it waits ([Cancel an Operation](https://docs.aureahub.com/docs/operation-cancel.md)). - The `operation.state_changed` event, on a [webhook endpoint](https://docs.aureahub.com/docs/guide-events.md) that names it, at each move. - [An Operation's Approval](https://docs.aureahub.com/docs/operation-approval.md) (`GET /v1/operations/{id}/approval`): the initiator's view — the request, its decisions, and, once approved and gone on, `result`: the answer the call would have given. ## Decide 1. [Waiting for You](https://docs.aureahub.com/docs/approvals-inbox.md) (`GET /v1/approvals`) lists the requests you may decide now: your tenant's, pending, within their window, naming you (or any approver), not your own operation when the rule keeps the initiator apart, not decided by you yet — newest first. 2. [Get a Request](https://docs.aureahub.com/docs/approval-get.md) (`GET /v1/approvals/{operationId}`) gives what the operation moves (`chain`, `asset`, `amount` in the asset's smallest unit, `amountRef` in EUR, `destination`), the rule, the decisions so far, `canDecide`, and the two payloads to sign: `payloads.approve` and `payloads.reject`. 3. Sign the payload of your decision with one of your active approval keys (Sign a Decision). 4. Send it: [Approve](https://docs.aureahub.com/docs/approval-approve.md) (`POST /v1/approvals/{operationId}/approve`) or [Reject](https://docs.aureahub.com/docs/approval-reject.md) (`…/reject`, with an optional `reason` of 1 to 500 characters). The answer is the request as it stands. ## Sign a Decision `signature` is a compact JWS — `header.payload.signature`, each part unpadded base64url, at most 4096 characters: | Part | What it holds | | --- | --- | | header | `{ "alg": "ES256", "typ": "aurea-approval+jwt", "kid": "" }` — those three members and no other. | | payload | The string of `payloads.approve` or `payloads.reject`, byte for byte. Don't parse it and write it again: sign the very string the API gave. | | signature | ES256 over `header.payload`, in its 64-byte form (r and s, 32 bytes each), as JOSE writes it — not DER. WebCrypto's ECDSA signature is already in that form. | The payload is JSON with its members in this order and no whitespace: the decision, what moves, the request it answers, the operation, the rule that asked, the window's end and the tenant. ```json {"action":"approve","amount":"35000000000","approvalId":"c3e1a9f4-6b2d-4e8a-9f17-5d0c2b8e4a63","asset":"0x2a22f9c3b484c3629090feed35f17ff8f88f76f0","chain":"gnosis","chainId":100,"destination":"0x52908400098527886e0f7030069857d2e4169ee7","expiresAt":null,"operationId":"1d7a3c52-8e4b-4f19-a0c6-2b9e7d5f3a18","ruleId":"e2b6d9f1-7a3c-4c58-8b0e-5f1a4d2c9b73","tenantId":"6f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0","v":1} ``` ```javascript const b64url = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes))) .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); const utf8 = (text) => new TextEncoder().encode(text); // keyId: the approval key's id; privateKey: its WebCrypto private key; payload: payloads.approve or payloads.reject async function signDecision(keyId, privateKey, payload) { const header = b64url(utf8(JSON.stringify({ alg: 'ES256', typ: 'aurea-approval+jwt', kid: keyId }))); const signingInput = `${header}.${b64url(utf8(payload))}`; const signature = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, privateKey, utf8(signingInput)); return `${signingInput}.${b64url(signature)}`; } const request = await fetch(`https://api.aureahub.com/v1/approvals/${operationId}`, { headers: { Authorization: `Bearer ${approverToken}` }, }).then((r) => r.json()); if (request.canDecide) { const signature = await signDecision(key.id, privateKey, request.payloads.approve); await fetch(`https://api.aureahub.com/v1/approvals/${operationId}/approve`, { method: 'POST', headers: { Authorization: `Bearer ${approverToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ signature }), }); } ``` - A signature answers one request with one decision: an approval's payload sent to Reject, or another request's, is refused (`400` `APPROVAL_SIGNATURE_PAYLOAD_MISMATCH`). - A signature is used once, ever: sent again, it answers `409` `APPROVAL_SIGNATURE_USED`. - On a server with Node's `crypto.sign`, pass `dsaEncoding: 'ieee-p1363'` to get the 64-byte form, as in [Server Credentials](https://docs.aureahub.com/docs/guide-server-credentials.md). The sample was checked against the API's own verification: its signature over a payload is accepted, and another payload, another key's signature or a changed header is refused. ## The Order of the Checks A decision is checked in one go, the request locked, in this order; the first check that fails answers, and nothing is recorded: | # | Status | details.code | When | | --- | --- | --- | --- | | 1 | 404 | APPROVAL_NOT_FOUND | The operation has no approval request in your tenant. | | 2 | 409 | APPROVAL_NOT_PENDING | The request is decided already: `details.status` `approved`, `rejected`, `expired` or `withdrawn`. | | 3 | 409 | APPROVAL_EXPIRED | Its window has ended (only where an approval window applies). | | 4 | 403 | APPROVAL_NOT_APPROVER | You are not one of its approvers: the rule names others, or it is your own operation and the rule keeps the initiator apart. | | 5 | 409 | APPROVAL_ALREADY_DECIDED | You decided it already. | | 6 | 400 | APPROVAL_SIGNATURE_MALFORMED | `signature` is not a compact JWS with exactly that header and a 64-byte signature. | | 7 | 403 | APPROVAL_SIGNATURE_KEY_UNKNOWN | `kid` is not one of your active approval keys. | | 8 | 400 | APPROVAL_SIGNATURE_PAYLOAD_MISMATCH | The signed payload is not this request's payload for this decision. | | 9 | 403 | APPROVAL_SIGNATURE_INVALID | The signature does not verify with that key. | | 10 | 409 | APPROVAL_SIGNATURE_USED | This signature was used already. | ## How a Request Ends - **Approved** once the threshold of distinct approvers approved: the operation goes `PENDING_AUTHORIZATION` `approved`, and on (After the Approval). - **Rejected** by one rejection of one of its approvers: the operation is `REJECTED`, `failureReason` `APPROVAL_REJECTED`. Nothing was signed or sent. - **No window, by default.** A request waits until it is decided: `expiresAt` is `null` in every answer and in the signed payload. - **A window, where Aurea sets one** for the platform (5 minutes to 23 hours): `expiresAt` is its end, and a request still pending then **expires** — the operation is `FAILED` `approval_expired` (`failureReason` `APPROVAL_EXPIRED`), nothing signed or sent. - **Withdrawn** when its initiator cancels the operation while it waits ([Cancel an Operation](https://docs.aureahub.com/docs/operation-cancel.md)): `status` `withdrawn`, the operation `CANCELLED` (`failureReason` `OPERATION_CANCELLED`). Nothing was signed or sent. A request an approver decided first is not withdrawn. - A request ends once: a decision afterwards answers `409` `APPROVAL_NOT_PENDING`. ## After the Approval The approved operation goes on server-side, once, with the request it was held with and as the user who made it — the path the call would have taken: - **A custodial wallet's** operation is signed and sent. - **A client-side or MPC wallet's** is prepared: the operation moves to `PENDING_SIGNATURE` `awaiting_client_signature` with its `preparedTxId`, and [An Operation's Approval](https://docs.aureahub.com/docs/operation-approval.md) gives the prepared transaction in `result.body` — the answer the call would have given, with `txHashToSign` (or `permitHashToSign`), `preparedTxId` and `expiresAt`. Sign and broadcast it as always, before its `expiresAt`: like every preparation it lives 5 minutes, from the moment the approved operation went on. For an approved operation, the hour after which an operation never broadcast fails (`FAILED` `never_broadcast`) counts from its approval. - **The policy decides again** at the signing step: the rule it was approved for now lets it sign. Anything else decides as for any call — another rule asking for an approval holds it again (a new request for the same operation, and `result` is that `202`), a block refuses it (`result` is the refusal). - **A swap goes on with the quote it was held with**: past the quote's validity it fails as the call would have (`result`: `400` `QUOTE_EXPIRED: …`). - `result` is `{ "status", "body" }`: `201` and the transaction for a send, `200` and the swap's answer, or the error the call would have answered. It is `null` until the operation went on. - An approved operation that this version of the API cannot carry on is not sent: `result` is `503` `APPROVAL_CONTINUATION_UNAVAILABLE` and the operation `FAILED` `not_sent`. ## Refusals The usual envelope, the code in `details.code`; never a key, a signature or a payload: ```json { "statusCode": 409, "error": "ConflictError", "message": "This approval request is decided already", "details": { "code": "APPROVAL_NOT_PENDING", "status": "approved" } } ``` | Status | details.code | message | | --- | --- | --- | | 400 | APPROVAL_KEY_INVALID | `publicKey must be a P-256 public key as a JWK: …` — the reason, e.g. `crv must be "P-256"`, `x must be 32 bytes in unpadded base64url`, `the point is not on the curve` | | 400 | APPROVAL_KEY_PRIVATE | `publicKey carries a private key ("d"): send the public key only, and do not use this key pair` | | 400 | APPROVAL_KEY_NAME_INVALID | `A key's name is 1 to 100 characters` | | 400 | APPROVAL_REASON_INVALID | `A reason is 1 to 500 characters` | | 400 | APPROVAL_CURSOR_INVALID | `Invalid cursor` | | 400 | APPROVAL_SIGNATURE_MALFORMED | `The signature must be a compact JWS (ES256, typ "aurea-approval+jwt", kid: one of your approval keys) over the canonical approval payload` | | 400 | APPROVAL_SIGNATURE_PAYLOAD_MISMATCH | `The signed payload is not this request's canonical "approve" payload (GET /v1/approvals/{operationId}: payloads)` (`"reject"` on Reject) | | 403 | APPROVAL_NOT_APPROVER | `You are not one of this request's approvers` | | 403 | APPROVAL_SIGNATURE_KEY_UNKNOWN | `The signature's kid is not one of your active approval keys` | | 403 | APPROVAL_SIGNATURE_INVALID | `The signature does not verify with that approval key` | | 404 | APPROVAL_KEY_NOT_FOUND | `No such approval key of yours` | | 404 | APPROVAL_NOT_FOUND | `No approval request for this operation here` | | 409 | APPROVAL_KEY_LIMIT | `At most 2 active approval keys: revoke one first` | | 409 | APPROVAL_KEY_IN_USE | `This key is registered already: a key is registered once, ever` | | 409 | APPROVAL_KEY_REVOKED | `This key is revoked already` | | 409 | APPROVAL_NOT_PENDING | `This approval request is decided already`, with `details.status` | | 409 | APPROVAL_EXPIRED | `This approval request's window has ended` | | 409 | APPROVAL_ALREADY_DECIDED | `You decided this request already` | | 409 | APPROVAL_SIGNATURE_USED | `This signature was used already` | - A role that does not approve answers `403` (`Tenant admin access required. Your role does not have permission to perform this action.`); a frozen account or tenant `423` ([Freezes & Quarantines](https://docs.aureahub.com/docs/guide-freezes.md)); an id that is not a UUID `400` `Request validation failed`. - Registering a key: at most 10 a minute. Approving and rejecting: at most 30 a minute each. Web version: https://docs.aureahub.com/#guide-approvals --- # Address Book Keep the addresses your tenant's operations may send to: each added under an approval, active only after a cooling period, an external one proven by its owner if you ask for it. Where Aurea enables it for your tenant, an operation to an address outside the book is refused before anything is signed. ## Overview - **An entry** is an address your tenant names: **internal** (one of your own wallets), **external** (someone else's address — a supplier, a customer, an exchange) or **contract** (a contract your operations call — a swap router, a vault). Each has a label, tags, your own reference for the counterparty, and optionally an account whose wallets it serves. - **Added under an approval, active after a cooling period.** A new entry — and an entry whose address changes — waits for an administrator's approval, then cools for your tenant's cooling period (24 hours unless you change it) before an operation may send to it. - **The book works today for every tenant**: add, approve, prove and change entries and settings at any time. **What the book does to your operations is available when Aurea enables it for your tenant** — first recording every destination outside the book, then refusing them (At the Signing Points). Until then your operations are signed and sent exactly as before, whatever the book holds. - The routes are under **Address Book** in the API Reference, on the caller's own tenant. Your administrators can do the same in the Aurea Admin console's address book. ## Who Does What | What | Roles ([Roles](https://docs.aureahub.com/docs/authentication.md)) | | --- | --- | | Read the entries, their history, the settings and their changes | `owner`, `tenantadmin`, `non_signing_admin`, `signer`, `approver`, `editor`, `viewer`, `auditor` — the roles that read the tenant's operations | | Add, change and remove entries; ask for and send an ownership proof | `owner`, `tenantadmin`, `non_signing_admin`, `editor` | | Approve or reject an entry; change the settings; approve or reject a settings change | `owner`, `tenantadmin`, `non_signing_admin` | Any other role answers `403` `Tenant admin access required. Your role does not have permission to perform this action.` ## Entries | kind | You send | What it is | | --- | --- | --- | | internal | `walletId` — never an address | One of your tenant's wallets that is not deactivated. Its family, network and address are the wallet's, read from it; its `chain` is `null`. | | external | `family`, `network`, `address`, and optionally `chain` | Someone else's address, on EVM or Solana. Its owner can prove they hold it (Prove an Address). | | contract | `network`, `chain`, `address` (`family`, if sent, is `evm`) | An EVM contract. Aurea reads its code on that chain through the platform's own node: no code there answers `422` `ADDRESS_BOOK_ADDRESS_NOT_A_CONTRACT`; a node that cannot answer, `503` `ADDRESS_BOOK_CONTRACT_UNVERIFIED` (`details.retryable`: try again). | - **`family`** `evm` or `solana`; **`network`** `mainnet` or `testnet`. - **`chain`**: an EVM chain's slug — `ethereum`, `polygon`, `gnosis`, `bsc`, `arbitrum`, `optimism`, `base`, `avalanche`, `celo`, `flowevm` —: the entry serves that chain only. `null`, left out or `evm`: every EVM chain of the network. Solana is one chain: `solana` or `null`. A contract names its chain. - **`address`**: on EVM, `0x` and 40 hex digits, kept with its EIP-55 checksum. All lower or all upper case is accepted; a mixed-case address must carry the right checksum (`400` `ADDRESS_BOOK_ADDRESS_CHECKSUM_INVALID`: a typo, most likely). On Solana, base58 of a 32-byte key, kept exactly as written. The zero address and Solana's all-zero key are refused. - **`label`** (required): 1 to 100 characters, no space at either end. **`tags`**: up to 20, each named once, 1 to 50 of `A–Z a–z 0–9 _ . : -` starting with a letter or a digit. **`customerRefId`**: your own reference for the counterparty, 1 to 128 of `A–Z a–z 0–9 _ . : @ / + -` starting with a letter or a digit. - **`accountId`**: one of your tenant's [accounts](https://docs.aureahub.com/docs/guide-accounts.md). The entry then serves only that account's wallets; without it, every wallet of your tenant. - **One live entry per address** — per account, family, network, chain and address, among the entries pending, cooling or active: `409` `ADDRESS_BOOK_ENTRY_EXISTS`. A rejected or removed address may be added again as a new entry. ## The Life of an Entry ```text pending_approval ──approve──► cooling ──its cooling period ends──► active │ └──approve, with no cooling period──────────────────────► active ├──reject──► rejected (final) └──remove──► removed (final) cooling, active ──remove──► removed (final) pending_approval, cooling, active ──its address changes──► pending_approval (next version) ``` - **Only an active entry is a destination.** There is no way to `active` but an approval and the end of its cooling period. - **A label, tags or `customerRefId` change at once**, whatever the state ([Change an Entry](https://docs.aureahub.com/docs/address-book-entry-change.md)). - **A new address** — `address`, `network` or `chain`; an internal entry's `walletId` — sends the entry back to `pending_approval` as its next `version`, unproven: it is approved again and cools again. Its `kind`, `family` and `accountId` never change: add another entry. - **Rejected and removed are final**: the entry never changes again (`409` `ADDRESS_BOOK_ENTRY_FINAL`). Removal is immediate from any live state and soft — the entry and its history stay readable. - **The history** of an entry ([Get an Entry](https://docs.aureahub.com/docs/address-book-entry-get.md)) keeps every move, oldest first, with each version's address and who made it — `scope` `tenant` for your users, `job` for the activation. ## Approval - [Approve an Entry](https://docs.aureahub.com/docs/address-book-entry-approve.md) moves it from `pending_approval` to `cooling` until `activeAt` — or straight to `active` when the cooling period is 0. [Reject an Entry](https://docs.aureahub.com/docs/address-book-entry-reject.md) ends it; whoever may approve may reject, who asked included. - **Your tenant's quorum.** On a tenant on the institutional security profile, or with two administrators or more — active users with the role `owner`, `tenantadmin` or `non_signing_admin` —, an administrator other than who asked approves: `403` `ADDRESS_BOOK_QUORUM_REQUESTER` for who asked. A tenant with a single administrator approves alone until it has a second. The rule is the one in force when the approval is given; `approval.requiredApprovals` is `1` when it asked for someone else. - Who asked is whoever added the entry or last changed its address. - Where your settings require a proof, an external entry is proven before it is approved (`409` `ADDRESS_BOOK_PROOF_REQUIRED`). ## Cooling Period - **24 hours by default** (`86400` seconds), from 0 to 30 days (`2592000`) in your settings. A tenant on the institutional security profile cools at least 24 hours: a shorter setting is refused (`400` `ADDRESS_BOOK_COOLING_TOO_SHORT`), and an approval never starts less. - **At approval**, the entry keeps the period it starts (`coolingPeriodSeconds`) and `activeAt` = `approval.approvedAt` + that period. A later change of the settings never moves an approval given before. `effectiveCoolingPeriodSeconds` in the settings is the period an approval given now would start. - **Active at `activeAt`.** From then the entry reads `active` and is a destination, whether or not its activation was written yet; while it cools, `coolingSecondsLeft` counts down. ## Prove an Address An external entry's owner can prove they hold its key: they sign a message Aurea writes for that entry. Only an external entry is proven (`409` `ADDRESS_BOOK_PROOF_NOT_EXTERNAL`): an internal one is your own wallet, a contract has no key. 1. [Ask for a challenge](https://docs.aureahub.com/docs/address-book-proof-challenge.md): `201` with `challengeId` and the `message` to sign. Valid 10 minutes, used once; at most 5 open per entry (`409` `ADDRESS_BOOK_CHALLENGE_LIMIT`). 2. The address's owner signs `message` exactly as given: on EVM with `personal_sign` (EIP-191) — the signature as `0x` and 130 hex digits; on Solana an ed25519 signature of the message's UTF-8 bytes, in base58. Only a key signs: an EVM smart-contract account cannot prove an address. 3. [Send the signature](https://docs.aureahub.com/docs/address-book-proof-verify.md) with the `challengeId`: the entry is proven — `proof.proven` `true`, its `method` (`eip191` or `solana_ed25519`) and `provenAt`. Its status and version don't change. ```text Aurea asks you to prove that you control this address, to receive funds from an Aurea address book. Signing this message moves no funds and costs nothing. Address: 0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed Address type: EVM Network: mainnet Tenant: 6f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0 Entry: 8f3c2a71-5d4e-4b9a-a6c1-2e7f9d0b3c58 (version 1) Nonce: 9b1f6c0d2e4a8c3b7f5e1d9a0c2b4e6f8a1c3e5d7b9f0a2c4e6b8d0f1a3c5e7b Issued at: 2026-10-09T08:05:00.000Z Expires at: 2026-10-09T08:15:00.000Z ``` - The message names your tenant's id, the entry and its version: the person who signs sees them. - **Five tries.** A signature that cannot be read (`400` `ADDRESS_BOOK_SIGNATURE_MALFORMED`) or is not the address's over this challenge (`400` `ADDRESS_BOOK_SIGNATURE_WRONG_SIGNER`) leaves `details.attemptsLeft`; the fifth burns the challenge (`409` `ADDRESS_BOOK_CHALLENGE_BURNT`) — ask for a new one. - Past its 10 minutes: `410` `ADDRESS_BOOK_CHALLENGE_EXPIRED`. Used already: `409` `ADDRESS_BOOK_CHALLENGE_USED`. Issued before the entry's address changed: `409` `ADDRESS_BOOK_CHALLENGE_STALE`. - A new address clears the proof: prove the new one. ## Settings | Setting | Default | What it does | | --- | --- | --- | | coolingPeriodSeconds | `86400` | The cooling period an approval starts: 0 to 30 days (at least 24 hours on the institutional profile). | | proofRequired | `false` | `true`: an external entry is proven before it is approved. | | oneTimeAddresses | `false` | `true`: an operation may also go to an address outside the book — recorded as a one-time address, never refused for it. | - **A change that raises or keeps the protections applies at once**: [Change the Settings](https://docs.aureahub.com/docs/address-book-settings-put.md) answers `200`, `state` `applied`. - **A change that lowers one** — a shorter cooling period, the proof no longer required, one-time addresses switched on — **waits for its approval**: `202`, `state` `pending`. [Approve a Change](https://docs.aureahub.com/docs/address-book-change-approve.md) applies it, by an administrator other than who asked where your tenant's quorum applies (as for an entry); [Reject a Change](https://docs.aureahub.com/docs/address-book-change-reject.md) refuses it — or withdraws it, when who asked rejects it. So one-time addresses are switched on only through an approved change. - **One change waits at a time**: another answers `409` `ADDRESS_BOOK_SETTINGS_PENDING` with `details.changeId`. Every change asked for is listed in [Setting Changes](https://docs.aureahub.com/docs/address-book-changes-list.md). ## At the Signing Points What the book does to your operations is set by Aurea, tenant by tenant, and shown in your settings' `mode`: | mode | What your operations meet | | --- | --- | | off | **Every tenant today, unless Aurea enables it for yours.** Nothing is read: every operation is signed and sent as before, whatever the book holds. | | log | **Recorded, never refused.** Each operation's destination is judged against the book when it is about to be signed; one outside the book is recorded and, where Aurea sends address book events, told to your endpoints that name `address_book.destination.unlisted`. Every answer is the one it would be without the book. A tenant on the institutional security profile is in `log` by its profile. | | enforce | **Refused before anything is signed.** Aurea switches a tenant from `log` to `enforce` (`enforced` `true`) once its book holds its destinations. A destination outside the book answers `403` `ADDRESS_BOOK_DESTINATION_NOT_ALLOWED` — nothing signed, handed out to sign or sent. | A destination is allowed when it is, on the operation's family, network and chain: - **an active entry** — one serving every wallet, or the account of the wallet that signs — whose `chain` is that chain or `null`; - **one of your tenant's own wallets**, even without an entry; - **any address, with one-time addresses on**: recorded as `one_time`, never refused. - **Where it applies**: wherever Aurea is about to sign, hand out to sign or send a transaction that has a destination — sends and their broadcasts, token approvals and swaps (custodial and client-side), dApp broadcasts, and the other routes that sign a transaction. A contract call's destination is the contract — a swap's router, a vault, a pool —: list the contracts you use as `contract` entries before `enforce`. A message to sign has no destination: the book does not judge it. - **The refusals** carry the signing point in `details.point`, and are answered as they are by every money route: | Status | details.code | message | | --- | --- | --- | | 403 | ADDRESS_BOOK_DESTINATION_NOT_ALLOWED | `The destination is not an active entry of the tenant's address book, and one-time addresses are off` | | 403 | ADDRESS_BOOK_DESTINATION_UNRESOLVED | `The address book cannot tell this destination's chain or network, and the tenant only sends to its address book` | | 503 | ADDRESS_BOOK_UNAVAILABLE | `The address book could not be read: nothing was signed` — with `details.retryable` `true`: try again. | ```json { "statusCode": 403, "error": "ForbiddenError", "message": "The destination is not an active entry of the tenant's address book, and one-time addresses are off", "details": { "code": "ADDRESS_BOOK_DESTINATION_NOT_ALLOWED", "point": "custodial_transfer" } } ``` [Contact us](https://www.aureahub.com/contact) to have your tenant's signing points read its book. ## Events and Audit - **Events**, where Aurea sends address book events (switched off until Aurea turns it on), only to the [webhook endpoints](https://docs.aureahub.com/docs/guide-events.md) that name them: `address_book.entry.requested`, `.approved`, `.activated`, `.rejected`, `.removed` and `.proven` with the entry, and `address_book.destination.unlisted` — [Event Types](https://docs.aureahub.com/docs/guide-events.md). A testnet entry's events go to your sandbox endpoints. - **Your [audit trail](https://docs.aureahub.com/docs/audit-events.md)**, while it is on, records every move — `address_book.entry.requested`, `address_changed`, `changed`, `approved`, `activated`, `rejected`, `removed`, `proven`; `address_book.settings.requested`, `changed`, `rejected` (category `governance`); `address_book.enforcement.changed` when Aurea switches refusing on or off (category `security`) — never an address, a label, a tag or your reference. ## Refusals The usual envelope, the code in `details.code`: | Status | details.code | message | | --- | --- | --- | | 400 | ADDRESS_BOOK_REQUEST_INVALID | `Invalid request`, each problem in `details.errors` (e.g. `walletId: an internal entry names one of the tenant's wallets`, `(body): nothing to change`, an unknown field or query parameter, an id that is not a UUID); or, on a change, `An internal entry's address is its wallet's: name another walletId`, `Only an internal entry names a wallet`, `An entry keeps its chain family: add another entry for another family` | | 400 | ADDRESS_BOOK_ADDRESS_CHECKSUM_INVALID | `The address has a wrong EIP-55 checksum: check it for a typo, or send it in lower case` | | 400 | ADDRESS_BOOK_ADDRESS_INVALID | `The address is not one of its family`, or `The zero address cannot be an entry: nobody holds its key` | | 400 | ADDRESS_BOOK_CHAIN_UNKNOWN | `The address book does not know the chain … for …`, `A contract entry names the EVM chain its code is on`, or `The address book keeps EVM and Solana addresses` (an internal entry's wallet on another chain) | | 400 | ADDRESS_BOOK_WALLET_NOT_FOUND | `The tenant holds no such wallet` | | 400 | ADDRESS_BOOK_ACCOUNT_NOT_FOUND | `The tenant holds no such account` | | 400 | ADDRESS_BOOK_COOLING_TOO_SHORT | `An institutional tenant's cooling period is at least 24 hours` | | 400 | ADDRESS_BOOK_SIGNATURE_MALFORMED | `The signature cannot be read`, with `details.attemptsLeft` | | 400 | ADDRESS_BOOK_SIGNATURE_WRONG_SIGNER | `The signature is not the address's over this challenge`, with `details.attemptsLeft` | | 403 | ADDRESS_BOOK_QUORUM_REQUESTER | `The tenant's quorum applies: an administrator other than who asked approves this entry` (`… this change` for a settings change) | | 404 | ADDRESS_BOOK_ENTRY_NOT_FOUND | `Address book entry not found` — also another tenant's entry | | 404 | ADDRESS_BOOK_CHALLENGE_NOT_FOUND | `This entry has no such challenge` | | 404 | ADDRESS_BOOK_SETTINGS_CHANGE_NOT_FOUND | `Settings change not found` | | 409 | ADDRESS_BOOK_ENTRY_EXISTS | `The book already holds a live entry for this address, chain and account` | | 409 | ADDRESS_BOOK_ENTRY_FINAL | `A rejected entry never changes again` (or `removed`), with `details.status` | | 409 | ADDRESS_BOOK_ENTRY_NOT_PENDING | `Only an entry pending its approval is approved` (`… is rejected`), with `details.status` | | 409 | ADDRESS_BOOK_ENTRY_CHANGED | `The entry changed while this request was read: send it again` | | 409 | ADDRESS_BOOK_PROOF_REQUIRED | `The tenant requires an external address to be proven before it is approved` | | 409 | ADDRESS_BOOK_PROOF_NOT_EXTERNAL | `Only an external address is proven: an internal one is the tenant's own wallet, a contract has no key` | | 409 | ADDRESS_BOOK_CHALLENGE_LIMIT | `An entry has at most 5 open challenges: sign one or let it expire first` | | 409 | ADDRESS_BOOK_CHALLENGE_USED | `This challenge was already used` | | 409 | ADDRESS_BOOK_CHALLENGE_STALE | `The entry's address changed after this challenge was issued: ask for a new one` | | 409 | ADDRESS_BOOK_CHALLENGE_BURNT | `Too many wrong signatures (5): this challenge is burnt, ask for a new one` | | 409 | ADDRESS_BOOK_SETTINGS_PENDING | `A change of the settings already waits for its approval: approve, reject or withdraw it first`, with `details.changeId` | | 409 | ADDRESS_BOOK_SETTINGS_CHANGE_NOT_PENDING | `Only a change waiting for its approval is decided`, with `details.status` | | 410 | ADDRESS_BOOK_CHALLENGE_EXPIRED | `This challenge has expired: ask for a new one` | | 422 | ADDRESS_BOOK_ADDRESS_NOT_A_CONTRACT | `No contract exists at this address on chain 8453`, with `details.chainId` | | 503 | ADDRESS_BOOK_CONTRACT_UNVERIFIED | `The contract cannot be checked: the node for chain 8453 did not answer` (or `no node is configured for chain …`), with `details.chainId` and `details.retryable` `true` | - The refusals that name a field carry it in `details.field` (`address`, `chain`, `walletId`, `accountId`, `coolingPeriodSeconds`). - At the signing points: `ADDRESS_BOOK_DESTINATION_NOT_ALLOWED`, `ADDRESS_BOOK_DESTINATION_UNRESOLVED` and `ADDRESS_BOOK_UNAVAILABLE` (At the Signing Points). - A role that may not call a route answers `403`; a frozen user or tenant `423` ([Freezes & Quarantines](https://docs.aureahub.com/docs/guide-freezes.md)). Web version: https://docs.aureahub.com/#guide-address-book --- # Signing Rules What Aurea checks before it signs for your tenant: which messages a wallet may sign, approvals for the exact amount with nothing left over, swaps signed from the route Aurea quoted, and each transaction simulated before it leaves. ## Overview | Rule | What it does in `enforce` | | --- | --- | | Messages | A wallet signs a `personal_sign` only from one of your enabled dApps or under the rules of the app's own flows, typed data only for the EIP-712 domains allowed, and a raw digest only under a rule for it. | | Exact approvals | A token approval is for the operation's exact amount, to a spender on the allowlist; an allowance left over after a custodial swap is revoked. | | Swaps built on the server | A swap is signed from the route Aurea kept with its quote, never from the transaction the client sends. | | Transaction simulation | Each transaction is decoded and simulated before it is signed; one that cannot be, or whose outcome is not what was asked, is refused. | - **Each rule is available when Aurea enables it for your tenant** ([contact us](https://www.aureahub.com/contact)). Until then your wallets sign exactly as before. - What the rules said about an operation is in [Get an Operation](https://docs.aureahub.com/docs/operation-get.md): `signingChecks` and `simulations`. ## Off, Log, Enforce | Mode | What your calls meet | | --- | --- | | off | **Every tenant unless Aurea enables a rule for yours.** Nothing is checked, nothing is kept, every answer is as before. | | log | Each check is made and kept — what `enforce` would refuse or change, recorded beside the signing — and **nothing is refused or changed**: every answer is the one it would be without the rule. | | enforce | The rule applies: the refusals below, before anything is signed or sent. A tenant on the institutional security profile is in `enforce` for every rule by its profile. | Aurea sets each rule's mode for your tenant separately. ## Messages - **A dApp's `personal_sign`** ([Sign Personal Message](https://docs.aureahub.com/docs/dapps-sign.md)): send the origin of the dApp that asks in `dappOrigin` — `https://app.example.com`. In `enforce` the message is signed only when that origin is one of your tenant's active dApps ([List DApps](https://docs.aureahub.com/docs/dapps-list.md)) with the dApps switched on for your tenant. Origins compare scheme, host and port: the path is ignored, and another subdomain is another origin. - **Without `dappOrigin`**, a `personal_sign` is signed only when it is one of the messages of the app's own flows that Aurea knows; a message that names a dApp is never judged by those. Send `dappOrigin` from your dApp browser now: until the rule is on it changes nothing. - **Typed data (EIP-712)** — [Sign EIP-712 Permit](https://docs.aureahub.com/docs/swap-sign-permit.md), and an MPC wallet's signing ceremony — is signed only for a domain Aurea builds itself or one allowed for your tenant. - **A raw digest** an MPC wallet's [signing ceremony](https://docs.aureahub.com/docs/mpc-sign-start.md) is started for — not a transaction or a permit Aurea prepared, nor a message it checked — is signed only under a rule Aurea sets for your tenant on request. ## Exact Approvals - **[Approve Token](https://docs.aureahub.com/docs/swap-approve.md) needs `amount`** — the swap's amount in the token's smallest unit. Without it, or for the largest possible amount (unlimited): `400` `APPROVAL_AMOUNT_REQUIRED`. `"0"` is accepted: it revokes. Send the amount now: until the rule is on, it changes nothing. - **The spender** must be on the allowlist of the contracts Aurea approves — the swap router and the gasless executor it uses, and the contracts of its own registry: `403` `APPROVAL_SPENDER_NOT_ALLOWED` otherwise. The same holds for a permit's spender ([Sign EIP-712 Permit](https://docs.aureahub.com/docs/swap-sign-permit.md)). - **A token that takes a new allowance only from zero**, with one still left for that spender: a custodial wallet's is reset to 0 by Aurea first; a client-side or MPC wallet answers `409` `APPROVAL_RESET_REQUIRED` — approve `"0"`, then the amount. - **After a custodial swap**, an allowance the router left unused is revoked once the swap is mined. A client-side or MPC wallet's is not: Aurea holds no key to revoke it. - **The savings vault** approves the supply's exact amount — zero first where the token needs it — and revokes what is left. Withdrawing everything is never an approval, and is unchanged. ## Swaps Built on the Server - [Get Quote](https://docs.aureahub.com/docs/swap-quote.md) keeps the quote's route. [Execute Swap](https://docs.aureahub.com/docs/swap-execute.md) and [Execute Gasless](https://docs.aureahub.com/docs/swap-gasless.md) on an EVM chain then sign **that route** — its call data and value — whatever transaction the client sends; a gasless swap's permit is checked against the route's target, amount and token. - **Execute a quote you asked for**, for the same wallet, while it is valid: otherwise `409` `SWAP_QUOTE_UNKNOWN`, with `details.reasons` — `quote_unknown`, `other_user`, `other_wallet`, `quote_expired`, `chain_differs`, `route_not_kept`. Ask for a new quote. - A quote whose router is not on the allowlist of the routers Aurea signs for: `403` `SWAP_ROUTER_NOT_ALLOWED`. - Solana swaps are unchanged. ## Transaction Simulation - Each transaction Aurea is about to sign or send is **decoded** — what it calls, with what — and **simulated** on the platform's node of its chain, and the outcome is compared with what was asked: the amount, the asset and the recipient, an approval's spender. Each simulation is kept, with the operation where it is recorded ([Get an Operation](https://docs.aureahub.com/docs/operation-get.md), `simulations`). - **In `enforce`** the signing waits for it: a transaction that cannot be decoded and simulated answers `409` `TX_NOT_SIMULATED`, one whose outcome diverges from what was asked `409` `TX_SIMULATION_DIVERGES` — with `details.reasons` and `details.simulationId` (the entry of `simulations`) — and a recorded operation ends `FAILED` `not_sent`. One that matches goes on to your policy. - **Never refused for it**, only recorded: a message signature, a Solana transaction (decoded, not simulated), a raw digest of an MPC wallet (left to your policy), and a transaction the gasless relayer signs. - A dApp's transaction has nothing precise asked of it: only a revert diverges. ## Refusals In `enforce`, before anything is signed or sent; each carries `details.code`, and the money routes answer it as it is: | Status | details.code | message | | --- | --- | --- | | 403 | PERSONAL_SIGN_NOT_ALLOWED | `This message is not one the tenant allows its wallets to sign (a personal_sign comes from one of the tenant's enabled dApps, or is one of the explicit rules): nothing was signed.` | | 403 | MESSAGE_DOMAIN_NOT_ALLOWED | `The typed data's EIP-712 domain is not one the tenant allows: nothing was signed.` | | 403 | RAW_SIGN_NOT_ALLOWED | `A raw digest is signed only by an explicit rule of the tenant: nothing was signed.` | | 400 | APPROVAL_AMOUNT_REQUIRED | `An approval names the exact amount of the operation it is for: without one it would be unlimited, which the tenant does not sign.` | | 403 | APPROVAL_SPENDER_NOT_ALLOWED | `The spender of this approval is not on the allowlist of the contracts the Hub approves: nothing was signed.` | | 409 | APPROVAL_RESET_REQUIRED | `This token takes a new allowance only from zero, and one is left for this spender: approve an amount of 0 first, then the operation's amount.` | | 409 | SWAP_QUOTE_UNKNOWN | `This swap names no quote the Hub issued to this wallet that is still valid: ask for a quote, then execute it.` | | 403 | SWAP_ROUTER_NOT_ALLOWED | `The quote's router is not on the allowlist of the routers the Hub signs for: nothing was signed.` | | 409 | TX_NOT_SIMULATED | `This transaction could not be decoded and simulated, which the tenant requires before it is signed: nothing was signed or sent.` | | 409 | TX_SIMULATION_DIVERGES | `The simulated outcome of this transaction diverges from what was asked: nothing was signed or sent.` | ```json { "statusCode": 403, "error": "ForbiddenError", "message": "This message is not one the tenant allows its wallets to sign (a personal_sign comes from one of the tenant's enabled dApps, or is one of the explicit rules): nothing was signed.", "details": { "code": "PERSONAL_SIGN_NOT_ALLOWED", "reasons": ["dapp_not_enabled"], "checkId": "b81f2c64-9d3e-4a57-8c10-5e7a2f9d4b36" } } ``` - The message refusals' `details.reasons`: `no_dapp_origin`, `dapp_not_enabled`, `dapps_not_enabled`, `dapp_origin_unreadable`, `unreadable`, `domain_not_allowed`, `no_raw_rule`, and those of the app's own flows. `details.checkId`: the check Aurea kept — quote it when you contact us. - The simulation's `details.reasons` are those of `simulations` ([Get an Operation](https://docs.aureahub.com/docs/operation-get.md)). Web version: https://docs.aureahub.com/#guide-signing-rules --- # Your Tenant's Quorum Four eyes on your administrators' sensitive actions: a change to users and roles, credentials and secrets, or custody waits — nothing done — until another administrator of your tenant approves it with their password. ## Overview - **A covered action** — creating, deleting or changing another user, a role, an API key or secret, a webhook or its secret, a server credential, your custody settings — **answers `202`** instead of doing it: ```json { "state": "awaiting_quorum", "requestId": "4e2b9c17-8a3d-4f60-b5e1-7c9d0a2f6b38" } ``` - **Another administrator approves it** with [Approve](https://docs.aureahub.com/docs/quorum-request-approve.md) and their own password; the action then runs at once, as the administrator who asked for it. **The one who asked reads its answer once** with [Read the Answer](https://docs.aureahub.com/docs/quorum-request-result.md) — a new API secret, a webhook's secret: shown to them only. - Every request — held, or only recorded — is listed under **Quorum** in the API Reference, with its decisions. - Each decision is written to your tenant's [audit trail](https://docs.aureahub.com/docs/audit-events.md), while it is on, as `admin_quorum.*`. ## When Aurea Enables It Your tenant's quorum is available when Aurea enables it for your tenant ([contact us](https://www.aureahub.com/contact)): | Mode | A covered action of one of your administrators | | --- | --- | | off | **Every tenant unless Aurea enables it for yours.** Done at once, as before; nothing recorded. | | log | Done at once, as before, and recorded as a request in state `observed` — so you see who did what before turning it on. | | enforce | **Held**: `202` `awaiting_quorum`, nothing done until another administrator approves it. A tenant on the institutional security profile is in `enforce` by its profile. | - **Never locked out.** When no other active administrator of your tenant could approve the action, it is done at once and recorded as `observed`, as in `log` (its `mode` reads `enforce`). - A request's `mode` is the one in force when it was asked. ## Covered Actions | Route | action | | --- | --- | | POST /v1/users/ | `users.tenant_user.create_own` | | PUT /v1/users/{id} — another user's account | `users.account.update` | | DELETE /v1/users/{id} — another user's account | `users.account.delete` | | POST /v1/admin/tenants/{tenantId}/users | `users.tenant_user.create` | | DELETE /v1/admin/tenants/{tenantId}/users/{userId} | `users.tenant_user.delete` | | PATCH /v1/admin/tenants/{tenantId}/users/{userId}/role | `users.role.change` | | POST, DELETE /v1/admin/tenants/{tenantId}/users/{userId}/email-change | `users.email_change.start`, `users.email_change.cancel` | | POST /v1/admin/tenants/{tenantId}/users/{userId}/password-reset POST /v1/auth/admin/send-reset | `users.password_reset.send`, `users.password_reset.send_support` | | DELETE /v1/admin/tenants/{tenantId}/users/{userId}/mfa | `users.mfa.reset` | | POST /v1/admin/tenants/{id}/rotate-key POST /v1/admin/tenants/{id}/rotate-secret | `credentials.api_key.rotate`, `credentials.api_secret.rotate` | | PUT, DELETE /v1/admin/tenants/{id}/webhooks/{environment} POST …/webhooks/{environment}/rotate-secret | `credentials.webhook.save`, `credentials.webhook.delete`, `credentials.webhook_secret.rotate` | | POST /v1/admin/tenants/{id}/webhook-endpoints PATCH, DELETE …/webhook-endpoints/{endpointId} POST …/webhook-endpoints/{endpointId}/rotate-secret | `credentials.webhook_endpoint.create`, `.change`, `.delete`, `credentials.webhook_endpoint_secret.rotate` | | POST /v1/admin/tenants/{id}/server-credentials PATCH …/server-credentials/{credentialId} | `credentials.server_credential.create`, `credentials.server_credential.update` | | PATCH /v1/admin/tenants/{id} — when it changes your custody settings | `custody.tenant_config.change` | - One's own account is never held: your own password or email change ([Update User](https://docs.aureahub.com/docs/users-update.md)) goes through at once. - Policy sets and the [address book](https://docs.aureahub.com/docs/guide-address-book.md) have their own approvals, on their own routes. ## Who Decides - **Another active administrator of your tenant whose role could take the action itself**: `owner`, `tenantadmin` or `non_signing_admin` for users, roles and custody; those and `security_admin` for credentials, secrets and webhooks. Never who asked, never an auditor, never another tenant's — and never Aurea's administrators. - The roles that read the requests: `owner`, `tenantadmin`, `non_signing_admin`, `auditor`, `security_admin` and `security_auditor`. ## Approve, Reject, Cancel - [Approve](https://docs.aureahub.com/docs/quorum-request-approve.md) and [Reject](https://docs.aureahub.com/docs/quorum-request-reject.md) take the deciding administrator's own `password` — checked as the sign-in checks it: a wrong one answers `403` `QUORUM_PASSWORD_INVALID` and counts as a failed sign-in (`429` with `Retry-After` where Aurea throttles failed sign-ins for your tenant) — and an optional `reason` (up to 500 characters). - **Approved, the action runs now**, as who asked, with their role as it is now: `state` becomes `executed` (`resultStatus`: the HTTP status it answered) or `failed` (`error`: why — for example `QUORUM_REQUESTER_INACTIVE` when who asked is no longer an active administrator whose role takes the action, or the action's own refusal). The approval answers the request, never the action's body. - **Rejected** or **cancelled** — [Cancel](https://docs.aureahub.com/docs/quorum-request-cancel.md) is who asked, with an optional `reason` —: nothing runs, and the held input is deleted. - **Expired**: a request not decided by its `expiresAt` — 24 hours after it was asked, by default — ends, and nothing runs. ## Read the Answer - Once the action ran, who asked reads its answer with [Read the Answer](https://docs.aureahub.com/docs/quorum-request-result.md): `{ "statusCode", "body" }` — what the call would have answered, a new key or secret included (`secretAnswer` `true`). - **Once**: the answer is deleted as it is read; a second read answers `404` `QUORUM_NO_RESULT`. Unread, it is deleted after a while — 24 hours after the action ran, by default. `resultAvailable` tells whether it waits. ## A Request's States ```text awaiting_quorum ──approve──► approved ──► executed | failed ├──reject──► rejected ├──cancel──► cancelled └──expiresAt passes──► expired observed: recorded only (log, or no other administrator could approve): it was done at once ``` - An approved action whose end was not recorded within 15 minutes is `failed` with `QUORUM_OUTCOME_UNKNOWN` (`The approved action did not record its end: it may or may not have run`): check what it changed before asking again. ## Refusals The usual envelope, the code in `details.code`. Approve and Reject check in this order: | Status | details.code | message | | --- | --- | --- | | 404 | QUORUM_REQUEST_NOT_FOUND | `Quorum request not found` — also another tenant's request | | 403 | QUORUM_OWN_REQUEST | `The requester cannot decide its own request: another administrator of the tenant approves or rejects it` | | 403 | QUORUM_NOT_TENANT_APPROVER | `Only another active administrator of the tenant whose role could take this action decides it` | | 429 | — | `Too many failed password attempts. Try again in 5 minutes.`, with `details.retryAfterSeconds` and `Retry-After` — where Aurea throttles failed sign-ins for your tenant | | 403 | QUORUM_PASSWORD_INVALID | `The password is not correct` | | 409 | QUORUM_NOT_AWAITING | `The request is rejected` (its state), with `details.state` | | 409 | QUORUM_EXPIRED | `The request has expired` | - `403` `QUORUM_NOT_REQUESTER`: `Only the requester cancels a request`, `Only the requester reads the answer`. - `404` `QUORUM_NO_RESULT`: `The answer was already read`, or `No answer is waiting for this request`. - `400` `QUORUM_CURSOR_INVALID`: `The cursor is not one this list gave`. Web version: https://docs.aureahub.com/#guide-tenant-quorum --- # Data Residency Aurea Hub runs in Microsoft Azure's Italy North region, in Milan. Every copy of your data that Aurea keeps stays in the European Union. ## Overview - **Primary region: Italy North.** The API, the database that holds your tenant, its users, wallets, transactions and audit trail, the platform's share of every MPC signature, and the platform's operational logs all run and are stored there. - **Everything else stays in the EU.** Database backups, the keys the platform holds and the services the platform sends email and translations through are in EU regions (see the table below). - **Aurea does not move your data outside the EU.** Services you choose to enable process what they need under their own terms (see Services You Enable). ## Where Each Part Runs | Part of the platform | What it holds | Region | | --- | --- | --- | | API and database | Your tenant, its users, accounts, wallets, transactions, operations, events and audit trail | Italy North | | MPC co-signer | The platform's part of each MPC signature. It holds no complete key. | Italy North | | Operational logs | Request records: routes, tenant and request ids, client IP addresses | Italy North | | Database backups | Daily and pre-upgrade copies of the database, kept in two regions so the service can be restored if a whole region fails | Italy North and a second EU region | | Keys the platform holds | Custodial wallet keys, encrypted in the database; the key shares the platform keeps for MPC wallets, in Azure Key Vault. Keys of non-custodial wallets stay on your users' devices. | Italy North; Key Vault in an EU region | | Transactional email | Password resets and confirmations sent to your users | Azure Communication Services, data located in Europe | | Onboarding translations | The onboarding texts you write, translated into your users' languages (no personal data) | EU region | | Agentic payments assistant | What a user types to an agent, when your tenant uses agentic payments | EU region | ## Services You Enable Some features reach services outside the platform. They process the data each request needs under their own terms and in their own locations: - the bank ramp, the card onramp, swaps, Earn and tokenized assets, when you switch them on for your tenant; - the blockchain networks your wallets use, and the nodes the platform reads them through; - push notifications, delivered through the notification service of each user's device platform. Data written to a public blockchain, such as addresses and transfers, is public and has no location. ## Changes Changes to these regions are announced in advance. Web version: https://docs.aureahub.com/#guide-data-residency --- # 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 `tenantadmin` is always scoped to their own tenant. ### 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 52 endpoints under `https://api.aureahub.com/v1/agent-payments` you can call. Unless noted, every route requires a tenant-admin JWT. 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" } ``` ### `POST /v1/agent-payments/agents/:id/status` Authentication: bearer token required. Activate a pending or suspended agent, or suspend an active one. Only active agents can move money. To revoke an agent, delete it. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | uuid | yes | Agent id | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `status` | enum | yes | `active` or `suspended` | **Responses** `200` OK ```json { "id": "11111111-1111-1111-1111-111111111111", "name": "Acme Storefront Agent", "agentType": "merchant", "status": "suspended", "updatedAt": "2026-06-24T11:00:00.000Z" } ``` `404` Not Found ```json { "statusCode": 404, "error": "NotFoundError", "message": "Agent not found" } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/agent-payments/agents/11111111-1111-1111-1111-111111111111/status \ -H "Authorization: Bearer YOUR_TENANT_ADMIN_JWT" \ -H "Content-Type: application/json" \ -d '{ "status": "suspended" }' ``` ### `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" } ] } ``` ## 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 } ``` 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 status, the server's time and the process uptime in seconds. 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 its database or the services it calls (the ramps, the RPC nodes) are reachable. For the database, use `GET /health/ready` below. ### `GET /health` Authentication: none (public endpoint). Returns a 200 OK if the API process is running. **Responses** `200` OK ```json { "status": "ok", "timestamp": "2026-10-05T21:41:25.492Z", "uptime": 12831.572459447 } ``` ## Readiness Whether the API reaches its database: `200` when a trivial query answers within 2 seconds, `503` otherwise. The outcome is kept for 5 seconds, so a burst of probes costs one query, and the answer is never cached (`Cache-Control: no-store`). No authentication. It names no error. ### `GET /health/ready` Authentication: none (public endpoint). Readiness: 200 when the API reaches its database, 503 otherwise. **Responses** `200` Ready ```json { "status": "ok", "checks": { "database": "ok" } } ``` `503` Unavailable ```json { "status": "unavailable", "checks": { "database": "unreachable" } } ``` ## 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*. > ℹ️ **Two-factor sign-in, where Aurea enables it for your tenant.** For an account of any role but the End User with an authenticator app enrolled, the right password answers `401` with `details.code` `MFA_REQUIRED` and an `mfaToken` instead of the tokens: finish with [Sign In: Second Step](https://docs.aureahub.com/docs/mfa-verify.md). Where it is required, an account without one answers `403` `MFA_ENROLLMENT_REQUIRED`. These two answers carry the full envelope, with `details`. End users always sign in as before. See [Two-Factor Sign-In](https://docs.aureahub.com/docs/guide-two-factor.md). > ⚠️ 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" } ``` `401` Second Factor ```json { "statusCode": 401, "error": "MfaRequiredError", "message": "A second factor is required", "details": { "code": "MFA_REQUIRED", "mfaToken": "eyJ2IjoxLCJzdWIiOiI…", "methods": ["totp", "recovery_code"], "expiresIn": 300 } } ``` `403` Enrol First ```json { "statusCode": 403, "error": "MfaEnrollmentRequiredError", "message": "A second factor is required for this account and none is enrolled", "details": { "code": "MFA_ENROLLMENT_REQUIRED" } } ``` `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` — except the second factor's `401` and `403`, which carry `details` —; `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 | `A second factor is required` | details.code MFA_REQUIRED: the password is right and the account has an authenticator app (two-factor sign-in on). Go on with Sign In: Second Step. | | 403 | `A second factor is required for this account and none is enrolled` | details.code MFA_ENROLLMENT_REQUIRED: two-factor sign-in is required for the account and it has no authenticator app. No tokens. | | 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. Where Aurea answers unknown keys as wrong signatures, 401 Invalid signature instead (see Authentication). | | 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). - **Logged out.** A refresh token revoked with [Log Out](https://docs.aureahub.com/docs/auth-logout.md) is rejected. - **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` | The token was revoked by Log Out; or, with rotation on, it 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"}' ``` ## Reset by an administrator Your support team can send a reset email to one of your users from a trusted backend, using the bearer token of a role that manages your tenant's users (`owner`, `tenantadmin` or `non_signing_admin`), within the rules of [Roles](https://docs.aureahub.com/docs/authentication.md). ### `POST /v1/auth/admin/send-reset` Authentication: bearer token required. Sends a reset email to one of your tenant's users. Requires the token of a role that manages your tenant's users. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `userId` | string | yes | ID of the user to send a reset link to | **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 --- # Log Out End a session on the server: the refresh token you send is revoked, and it can no longer be exchanged for an access token. ## Overview - **The refresh token is the credential**, as on [Refresh Token](https://docs.aureahub.com/docs/auth-refresh.md): no tenant signature and no bearer token. Send the refresh token of the session that ends. - **`200` `{ "success": true }`**, also for a token already revoked: calling it twice is safe. - **From then on, [Refresh Token](https://docs.aureahub.com/docs/auth-refresh.md) refuses that token**: `401` `Refresh token is no longer valid`. Where refresh-token rotation is on, every refresh token of the same sign-in is revoked with it. - **Access tokens already issued stay valid until they expire** (15 minutes by default): drop them on the client when you log out. - Only this session ends: the user's other sign-ins — other devices, other browsers — keep their refresh tokens. - An administrator's logout is written to your tenant's [audit trail](https://docs.aureahub.com/docs/audit-events.md) as `session.ended` (category `access`), while the trail is on; an End User's is not. - Rate limit: 60 requests per minute per client IP, as Refresh Token. ## Endpoint ### `POST /v1/auth/logout` Revokes a refresh token on the server. No tenant signature and no bearer token are needed. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `refreshToken` | string | yes | The refresh token to revoke | **Responses** `200` OK ```json { "success": true } ``` `401` Unauthorized ```json { "error": "UnauthorizedError", "message": "Invalid or expired refresh token" } ``` `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/logout \ -H "Content-Type: application/json" \ -d '{"refreshToken":""}' ``` ## Errors On this endpoint, `401` bodies contain only `error` and `message`. A `401` means there is no session to end with that token: sign the user out on the client anyway. | Status | message | When | | --- | --- | --- | | 401 | `Invalid or expired refresh token` | The token is malformed, badly signed or expired. | | 401 | `Invalid token type` | The token is not a refresh token (for example, an access token). | | 400 | `Request validation failed` | The body has no refreshToken. | | 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. | Web version: https://docs.aureahub.com/#auth-logout --- # Your Second Factor Whether your account is asked a second factor at sign-in, and what it has enrolled. ## Overview - `mode`: `off`, `log` (asked once enrolled) or `enforce` (required) — [When Aurea Enables It](https://docs.aureahub.com/docs/guide-two-factor.md). `required`: `true` in `enforce`. - `enrolled`: an authenticator app is the account's factor; `methods` is then `["totp", "recovery_code"]`, else `[]`. `pending`: an enrolment waits for its first code. `recoveryCodesLeft`: the recovery codes not used yet. - Any signed-in account; an End User's answers `mode` `off`. ## Endpoint ### `GET /v1/auth/mfa` Authentication: bearer token required. The caller's second factor. **Responses** `200` OK ```json { "mode": "log", "required": false, "enrolled": true, "pending": false, "methods": ["totp", "recovery_code"], "recoveryCodesLeft": 9 } ``` **Example request** ```bash curl https://api.aureahub.com/v1/auth/mfa \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` Web version: https://docs.aureahub.com/#mfa-status --- # Enrol an Authenticator App A new secret for an authenticator app, shown once. It becomes your second factor once its first code is confirmed. ## Overview - `201` with `secret` (base32, 32 characters), `otpauthUri` — show it as a QR code: the app lists it under your tenant's name and the username — and `expiresAt`, 15 minutes from now. No body. `Cache-Control: no-store`. - Then [Confirm the App](https://docs.aureahub.com/docs/mfa-totp-confirm.md) with a code it shows. A factor already enrolled stays until then. - `409` `MFA_OFF` where second factors are not in use; `403` `MFA_NOT_AVAILABLE` for an End User. 20 requests per minute per client IP. ## Endpoint ### `POST /v1/auth/mfa/totp` Authentication: bearer token required. Starts enrolling an authenticator app. **Responses** `201` Created ```json { "secret": "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP", "otpauthUri": "otpauth://totp/Acme%20Bank:marco.cfo?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Acme+Bank&algorithm=SHA1&digits=6&period=30", "expiresAt": "2026-10-09T09:15:00.000Z" } ``` `409` Off ```json { "statusCode": 409, "error": "ConflictError", "message": "Second factors are not in use for this account", "details": { "code": "MFA_OFF" } } ``` `403` End User ```json { "statusCode": 403, "error": "ForbiddenError", "message": "A second factor is for the accounts that administer", "details": { "code": "MFA_NOT_AVAILABLE" } } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/auth/mfa/totp \ -H "Authorization: Bearer $ACCESS_TOKEN" ``` Web version: https://docs.aureahub.com/#mfa-totp-start --- # Confirm the Authenticator App The enrolment's first code: the app becomes your second factor, and your ten recovery codes are shown — once. ## Overview - `code`: six digits the app shows now. `200` with `enrolled` `true` and `recoveryCodes`: store them before anything else — they are never shown again. `Cache-Control: no-store`. - An app enrolled before is replaced, and its recovery codes with it. - `400` `MFA_CODE_INVALID` for a wrong code; `409` `MFA_NO_PENDING` when no enrolment waits (or it is older than 15 minutes); `409` `MFA_OFF`; `403` `MFA_NOT_AVAILABLE`. 20 requests per minute per client IP. ## Endpoint ### `POST /v1/auth/mfa/totp/confirm` Authentication: bearer token required. Confirms the enrolment with its first code. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `code` | string | yes | Six digits from the authenticator app | **Responses** `200` Enrolled ```json { "enrolled": true, "recoveryCodes": [ "K7M2P-9QXR4", "B3NWT-6HJ8D", "Y5CFL-2ZQ9M", "R8VKD-4PXN7", "H2JTS-7MCW5", "P9QLB-3XKF6", "N4WZR-8DJT2", "C6MHV-5LQP9", "T3XNF-9BKR4", "W7DPJ-2VMZ8" ] } ``` `400` Wrong code ```json { "statusCode": 400, "error": "BadRequestError", "message": "The code is not correct", "details": { "code": "MFA_CODE_INVALID" } } ``` `409` None waiting ```json { "statusCode": 409, "error": "ConflictError", "message": "No enrolment is waiting: start one again", "details": { "code": "MFA_NO_PENDING" } } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/auth/mfa/totp/confirm \ -H "Authorization: Bearer $ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "code": "492039" }' ``` Web version: https://docs.aureahub.com/#mfa-totp-confirm --- # New Recovery Codes Ten new recovery codes; the old ones stop working. ## Overview - `code`: a current code of the authenticator app. `200` with `recoveryCodes`, shown only now (`Cache-Control: no-store`). - `400` `MFA_CODE_INVALID`; `409` `MFA_NOT_ENROLLED` without an app, `MFA_OFF` where second factors are not in use; `403` `MFA_NOT_AVAILABLE`. 20 requests per minute per client IP. ## Endpoint ### `POST /v1/auth/mfa/recovery-codes` Authentication: bearer token required. Replaces the recovery codes, with a current code. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `code` | string | yes | Six digits from the authenticator app | **Responses** `200` OK ```json { "recoveryCodes": [ "F4KQN-7WZP2", "M9TXB-3HLD6", "Q2VRJ-8CNF5", "D7PLW-4KXM9", "Z3HBT-6RQJ2", "L8NCV-2PWD7", "G5XKM-9TBQ3", "S2JFR-7LVH8", "V6DQN-3MZK4", "E9WPT-5CXB2" ] } ``` `409` Not enrolled ```json { "statusCode": 409, "error": "ConflictError", "message": "No second factor is enrolled", "details": { "code": "MFA_NOT_ENROLLED" } } ``` Web version: https://docs.aureahub.com/#mfa-recovery-codes --- # Remove the Authenticator App Your second factor and its recovery codes go; the next sign-in asks only the password. ## Overview - `code`: a current code of the app. `204`, no body. - Refused where a second factor is required (`409` `MFA_REMOVAL_REFUSED`, before the code is read): to change phones, enrol the new one instead ([Replace or Remove the App](https://docs.aureahub.com/docs/guide-two-factor.md)). - `400` `MFA_CODE_INVALID`; `409` `MFA_NOT_ENROLLED`, `MFA_OFF`; `403` `MFA_NOT_AVAILABLE`. 20 requests per minute per client IP. ## Endpoint ### `DELETE /v1/auth/mfa/totp` Authentication: bearer token required. Removes the authenticator app, with a current code. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `code` | string | yes | Six digits from the authenticator app | **Responses** `204` Removed `409` Required ```json { "statusCode": 409, "error": "ConflictError", "message": "A second factor is required for this account: enrol a new one instead of removing it", "details": { "code": "MFA_REMOVAL_REFUSED" } } ``` Web version: https://docs.aureahub.com/#mfa-totp-remove --- # Sign In: the Second Step After the password, the code: the answer is the login's, with the tokens. ## Overview - When [Login](https://docs.aureahub.com/docs/auth-login.md) answers `401` with `details.code` `MFA_REQUIRED`, send its `details.mfaToken` here within 5 minutes, with `code` (six digits of the authenticator app) **or** `recoveryCode` (one of the recovery codes) — not both: `422`. - **Signed as the login is**: the tenant's HMAC headers, or a server token in `Authorization` ([Authentication](https://docs.aureahub.com/docs/authentication.md)). No bearer token. - `200`: `accessToken`, `refreshToken`, `expiresIn` and `user`, as the login answers them. - `401` `MFA_CODE_INVALID` for a wrong or already used code — ask again with the same `mfaToken`; it counts as a failed sign-in (`429` with `Retry-After` where Aurea throttles them). `401` `MFA_CHALLENGE_INVALID` once the `mfaToken` has expired: sign in again. A suspended account answers `401` `Account is not active`; a frozen one `423`. - 20 requests per minute per client IP. [Two-Factor Sign-In](https://docs.aureahub.com/docs/guide-two-factor.md) walks through the flow. ## Endpoint ### `POST /v1/auth/mfa/verify` Authentication: signed by the tenant's server — headers `x-tenant-api-key`, `x-timestamp`, `x-signature` (see Authentication → Tenant signature). No user token. Completes a sign-in with the second factor. Requires tenant HMAC signing headers. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `mfaToken` | string | yes | `details.mfaToken` of the login's `401`, at most 2048 characters | | `code` | string | no | Six digits from the authenticator app | | `recoveryCode` | string | no | One of the recovery codes, 10–16 characters | **Responses** `200` Signed in ```json { "accessToken": "eyJhbGciOiJIUzI1NiIs...", "refreshToken": "eyJhbGciOiJIUzI1NiIs...", "expiresIn": 900, "user": { "id": "6a1c9e3f-2b7d-4f50-8e4a-0c5d3b9f7e12", "username": "marco.cfo", "email": "marco@acme.example", "qrCode": "", "role": "tenantadmin", "createdAt": "2026-09-01T10:00:00.000Z" } } ``` `401` Wrong code ```json { "statusCode": 401, "error": "UnauthorizedError", "message": "The code is not correct", "details": { "code": "MFA_CODE_INVALID" } } ``` `401` Expired ```json { "statusCode": 401, "error": "UnauthorizedError", "message": "The sign-in has expired: sign in again", "details": { "code": "MFA_CHALLENGE_INVALID" } } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/auth/mfa/verify \ -H "Content-Type: application/json" \ -H "x-tenant-api-key: " \ -H "x-timestamp: " \ -H "x-signature: " \ -d '{"mfaToken":"","code":"492039"}' ``` Web version: https://docs.aureahub.com/#mfa-verify --- # Reset a User's Second Factor A user lost their phone and their recovery codes: an administrator removes their authenticator app, and they enrol a new one. ## Overview - For `owner`, `tenantadmin` and `non_signing_admin`, on a user of their own tenant, with the rules of every change to another user's account ([Roles](https://docs.aureahub.com/docs/authentication.md)): `403` `A tenant admin cannot change another tenant admin's account`, `Only the tenant's owner can change another administrator's account`, `Cannot change the account of a user whose role holds permissions yours does not`. - `200` `{ "removed": true }` — or `false` when the user had no factor. Their app and recovery codes are gone; they enrol again ([Two-Factor Sign-In](https://docs.aureahub.com/docs/guide-two-factor.md)). - `202` `{ "state": "awaiting_quorum", "requestId" }` where your tenant's quorum holds administrators' actions: nothing was done until another administrator approves it ([Your Tenant's Quorum](https://docs.aureahub.com/docs/guide-tenant-quorum.md)). - `404` `User not found`. Written to the audit trail as `mfa.reset`. ## Endpoint ### `DELETE /v1/admin/tenants/{tenantId}/users/{userId}/mfa` Authentication: bearer token required. Removes a user's authenticator app and recovery codes. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `tenantId` | string (uuid) | yes | Your tenant's id | | `userId` | string (uuid) | yes | The user's id | **Responses** `200` Removed ```json { "removed": true } ``` `202` Awaiting quorum ```json { "state": "awaiting_quorum", "requestId": "4e2b9c17-8a3d-4f60-b5e1-7c9d0a2f6b38" } ``` `403` Not allowed ```json { "statusCode": 403, "error": "ForbiddenError", "message": "Only the tenant's owner can change another administrator's account" } ``` **Example request** ```bash curl -X DELETE https://api.aureahub.com/v1/admin/tenants/$TENANT_ID/users/$USER_ID/mfa \ -H "Authorization: Bearer $ADMIN_TOKEN" ``` Web version: https://docs.aureahub.com/#mfa-reset --- # 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. > ℹ️ **Your tenant's quorum, where Aurea enables it for your tenant** (a tenant on the institutional security profile by its profile): this call answers `202` `{ "state": "awaiting_quorum", "requestId" }` — the **202** tab below — and nothing is done until another administrator of your tenant approves it; the answer it would have given is then read once by who asked ([Read the Answer](https://docs.aureahub.com/docs/quorum-request-result.md)). With no other administrator able to approve it, it is done at once. [Your Tenant's Quorum](https://docs.aureahub.com/docs/guide-tenant-quorum.md). Every other tenant gets the answers below, unchanged. ## 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 — any tenant role within your own (see [Roles](https://docs.aureahub.com/docs/authentication.md)); `user` when you send none. The user belongs to your token's tenant. This endpoint returns the new user object including a QR code for payment receiving. - A role your own does not cover answers `403` `Cannot assign a role that holds permissions your role does not`; `owner` answers `403` `Only an Aurea administrator can name an owner`. - A username or an email another active user of your tenant already has answers `409` (`Username already exists`, `Email already exists`), with the field in `details`. > ℹ️ This endpoint requires the bearer token of a role that manages the tenant's users: `owner`, `tenantadmin` or `non_signing_admin`. 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 | `user` (default), `tenantadmin`, `owner`, `non_signing_admin`, `signer`, `approver`, `editor`, `viewer`, `auditor`, `security_admin` or `security_auditor` — within your own role | **Responses** `201` Created ```json { "id": "uuid", "username": "bob", "email": "bob@example.com", "role": "user", "status": "active", "qrCode": "data:image/png;base64,..." } ``` `202` Awaiting quorum ```json { "state": "awaiting_quorum", "requestId": "4e2b9c17-8a3d-4f60-b5e1-7c9d0a2f6b38" } ``` `403` Role Above Yours ```json { "statusCode": 403, "error": "ForbiddenError", "message": "Cannot assign a role that holds permissions your role does not" } ``` `409` Conflict ```json { "error": "ConflictError", "message": "Username already exists", "details": { "field": "username", "value": "bob" } } ``` ## 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 Change a user's email address or password. At least one of the two. ## Overview - **One's own account**: every signed-in user changes its own email or password — an account settings screen. - **Another user's account**: only the roles that manage the tenant's users (`owner`, `tenantadmin`, `non_signing_admin`), on their own tenant, under the rules of acting on another account ([Roles](https://docs.aureahub.com/docs/authentication.md)). Any other role answers `403` `You can only access your own account`. - **`200` with the user.** `email` must be an email address no other account of the tenant uses (`409` `Email already exists`); `password` 8 to 100 characters. - Where your tenant's quorum holds administrators' actions, a change of **another** user's account answers `202` `{ "state": "awaiting_quorum", "requestId" }` and waits for another administrator ([Your Tenant's Quorum](https://docs.aureahub.com/docs/guide-tenant-quorum.md)). One's own account is never held. ## Your Own Password or Email > ℹ️ **Asking for the current password is available when Aurea enables it for your tenant** ([contact us](https://www.aureahub.com/contact)). A tenant on the institutional security profile asks for it by its profile. Send `currentPassword` in your change screen now: until it is asked for, it is ignored. When it is asked for, a user changing **their own** password, or their own email to a different one: - **sends `currentPassword`** — without it, `400` `CURRENT_PASSWORD_REQUIRED`; wrong, `403` `CURRENT_PASSWORD_INVALID`, counted as a failed sign-in (where Aurea throttles failed sign-ins for your tenant, too many answer `429` with `Retry-After`); an account with no password (signed up with Google or Apple), `409` `CURRENT_PASSWORD_UNAVAILABLE` — set one with a [password reset link](https://docs.aureahub.com/docs/auth-forgot-password.md) first; - **ends their other sessions**: every refresh token issued before the change is refused from then on (`401` `Session expired after a password change — please sign in again`), the caller's own included — sign the user in again after the change. Access tokens already issued stay valid until they expire; - **is told by email**: a new password to the account's address; a new email to the old address (the new one masked) and to the new one. These refusals carry the full envelope, with `details.code`: ```json { "statusCode": 400, "error": "BadRequestError", "message": "The current password is required to change your password or email", "details": { "code": "CURRENT_PASSWORD_REQUIRED" } } ``` - `403` `CURRENT_PASSWORD_INVALID`: `The current password is not correct`. - `409` `CURRENT_PASSWORD_UNAVAILABLE`: `This account has no password: set one with a password reset link first`. - Another user's account, the same email again, and every other field are never asked for a password. ## Endpoint ### `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 (uuid) | yes | The user's id: your own, or a user of your tenant you may manage | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `email` | string | no | A new email address | | `password` | string | no | A new password, 8–100 characters | | `currentPassword` | string | no | Your current password, 1–100 characters, when you change your own password or email: required where your tenant asks for it, ignored otherwise and on another user's account | **Responses** `200` OK ```json { "id": "6a1c9e3f-2b7d-4f50-8e4a-0c5d3b9f7e12", "username": "marco.cfo", "email": "marco.rossi@acme.example", "qrCode": "data:image/png;base64,iVBORw0KGgo…", "displayName": null, "status": "active", "role": "tenantadmin", "createdAt": "2026-09-01T10:00:00.000Z", "updatedAt": "2026-10-09T09:12:31.000Z" } ``` `202` Awaiting quorum ```json { "state": "awaiting_quorum", "requestId": "4e2b9c17-8a3d-4f60-b5e1-7c9d0a2f6b38" } ``` `400` Current password ```json { "statusCode": 400, "error": "BadRequestError", "message": "The current password is required to change your password or email", "details": { "code": "CURRENT_PASSWORD_REQUIRED" } } ``` `403` Not yours ```json { "statusCode": 403, "error": "ForbiddenError", "message": "You can only access your own account" } ``` `404` Not Found ```json { "error": "NotFoundError", "message": "User not found" } ``` `409` Email taken ```json { "error": "ConflictError", "message": "Email already exists", "details": {} } ``` ## Implementation ```javascript // Account settings: change your own password, with the current one async function changeMyPassword(token, myUserId, currentPassword, newPassword) { const res = await fetch(`https://api.aureahub.com/v1/users/${myUserId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ password: newPassword, currentPassword }), }); const body = await res.json(); if (!res.ok) throw new Error(body.details?.code ?? body.message); // e.g. CURRENT_PASSWORD_INVALID // Where the current password is asked for, the other sessions ended: sign in again return body; } ``` 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. > ℹ️ **Your tenant's quorum, where Aurea enables it for your tenant** (a tenant on the institutional security profile by its profile): this call answers `202` `{ "state": "awaiting_quorum", "requestId" }` — the **202** tab below — and nothing is done until another administrator of your tenant approves it; the answer it would have given is then read once by who asked ([Read the Answer](https://docs.aureahub.com/docs/quorum-request-result.md)). With no other administrator able to approve it, it is done at once. [Your Tenant's Quorum](https://docs.aureahub.com/docs/guide-tenant-quorum.md). Every other tenant gets the answers below, unchanged. ## 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 `202` Awaiting quorum ```json { "state": "awaiting_quorum", "requestId": "4e2b9c17-8a3d-4f60-b5e1-7c9d0a2f6b38" } ``` `404` Not Found ```json { "error": "NotFoundError", "message": "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`. Any other chain: `400` `WALLET_CHAIN_UNKNOWN`, and nothing is made. | | `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` Unknown chain ```json { "statusCode": 400, "error": "BadRequestError", "message": "Unknown chain for a wallet: bitcoin. Use an EVM chain slug (ethereum, polygon, gnosis, bsc, arbitrum, optimism, base, avalanche, celo, flowevm), solana, evm, or a chain id from GET /v1/tokens/meta/chains.", "details": { "code": "WALLET_CHAIN_UNKNOWN" } } ``` **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.message || '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`. Any other chain: `400` `Unknown chain for a wallet: …`, and nothing is imported. | | `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. Any other chain: `400` `WALLET_CHAIN_UNKNOWN`, and nothing is registered. | | `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 | KEY_ALREADY_MIGRATED | `Wallet is already fully migrated to client-side custody` | | 409 | MIGRATION_PENDING | `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. - **Institutional tenants:** once the password is checked, the tenant's [policy set](https://docs.aureahub.com/docs/guide-policy-sets.md) decides the export (a `KEY_EXPORT` operation, blocked unless a rule names that type). A refusal — `403` `POLICY_BLOCKED` or `POLICY_APPROVAL_REQUIRED`, `423` `POLICY_FROZEN`, `503` `POLICY_SCREENING_UNAVAILABLE` or `POLICY_UNAVAILABLE` — leaves the wallet as it was, with no token. The export itself is decided again by [Export Encrypted Key](https://docs.aureahub.com/docs/wallets-export-key.md). > ⚠️ 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 | KEY_ALREADY_MIGRATED | `Migration is already in progress or completed for this wallet` | | 409 | KEY_ALREADY_MIGRATED | `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 | MIGRATION_PENDING | `Wallet is not in client_side_pending state. Call request-export-token first.` | | 410 | EXPORT_TOKEN_USED | `Key has already been exported to a device for this wallet` | | 410 | EXPORT_TOKEN_EXPIRED | `Export token not found or invalid` 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. | | 410 | EXPORT_TOKEN_USED | `Export token has already been used` | ## 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 | KEY_ALREADY_MIGRATED | `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 | EXPORT_ALREADY_TRANSMITTED | `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 --- # List Accounts Your tenant's accounts, newest first: those you created, and each user's default account while it holds a wallet or once it was written to. ## Overview - Each account as [Get an Account](https://docs.aureahub.com/docs/account-get.md) answers it, with its users and wallets. How accounts work: [Accounts](https://docs.aureahub.com/docs/guide-accounts.md). - Filters: `tag`, `customerRefId`, `hidden` (`true` or `false`; both when left out) and `userId` — the accounts the user owns or may see, and the user's default account. Page with `limit` (1–200, default 50) and `cursor`, the previous page's `nextCursor` (`null` on the last page). - For the roles that read the tenant's transactions — `owner`, `tenantadmin`, `non_signing_admin`, `signer`, `approver`, `editor`, `viewer`, `auditor` —, on their own tenant. ## Endpoint ### `GET /v1/accounts` Authentication: bearer token required. The tenant's accounts, newest first. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `tag` | string | no | Only accounts with this tag | | `customerRefId` | string | no | Only the account with this reference | | `hidden` | boolean | no | Only hidden (`true`) or only visible (`false`) accounts | | `userId` | string (uuid) | no | Only the accounts this user owns or may see, and the user's default account | | `cursor` | string (uuid) | no | The previous page's `nextCursor` | | `limit` | integer | no | 1–200, default 50 | **Responses** `200` OK ```json { "accounts": [ { "id": "3b8f2c6e-1d4a-4e9b-8f7c-2a5d6e9b1c40", "customerRefId": "cust-00042", "name": "Savings, customer 42", "tags": ["retail", "eu"], "hidden": false, "isDefault": false, "frozen": false, "users": [ { "userId": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "access": "owner" } ], "wallets": [ { "walletId": "7c1e9b24-3f5a-4d8e-9b6c-1a2f3e4d5c6b", "chain": "evm", "family": "evm", "isTestnet": false, "address": "0x52908400098527886E0F7030069857D2E4169EE7", "keyManagementScheme": "sss_2of2_akv", "label": null, "isPrimary": false } ], "createdBy": "0d2f6b8a-4c1e-4a93-b7d5-2e9f1c3a8b60", "createdAt": "2026-10-07T09:12:40.511Z", "updatedAt": "2026-10-07T09:13:02.118Z" }, { "id": "9a4e7c21-6b3d-4f58-a1e9-0c7b2d5f8e34", "customerRefId": null, "name": null, "tags": [], "hidden": false, "isDefault": true, "frozen": false, "users": [ { "userId": "9a4e7c21-6b3d-4f58-a1e9-0c7b2d5f8e34", "access": "owner" } ], "wallets": [ { "walletId": "2e6f9b13-8c4a-4d70-b5e2-1f3a7c9d0b56", "chain": "solana", "family": "solana", "isTestnet": false, "address": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU", "keyManagementScheme": "client_side", "label": "Main", "isPrimary": true } ], "createdBy": null, "createdAt": "2026-09-30T14:02:11.004Z", "updatedAt": "2026-09-30T14:02:11.004Z" } ], "nextCursor": null } ``` `403` Role ```json { "statusCode": 403, "error": "ForbiddenError", "message": "Tenant admin access required. Your role does not have permission to perform this action." } ``` **Example request** ```bash curl "https://api.aureahub.com/v1/accounts?tag=retail&limit=20" \ -H "Authorization: Bearer $TENANT_ADMIN_TOKEN" ``` Web version: https://docs.aureahub.com/#accounts-list --- # Create an Account An account of your tenant, with your reference for the customer, a name, tags and its users. It holds no wallet yet. ## Overview - Every field is optional. `customerRefId`: 1 to 128 of `A–Z a–z 0–9 _ . : @ / + -`, starting with a letter or a digit; unique in your tenant, and never changed once set. `name`: 1 to 100 characters, no space at either end. `tags`: up to 20, each 1 to 50 of `A–Z a–z 0–9 _ . : -` starting with a letter or a digit, each named once. `hidden`: left out of the console's default list (default `false`). - `users`: up to 50 of your tenant's users, each named once — `access` `owner` (at most one: the user whose wallets the account will hold; it needs one to hold a wallet) or `viewer`. - `201` with the account. Refusals: `409` `ACCOUNT_REF_TAKEN` (`Another account of the tenant holds this customerRefId.`, `details.accountId` naming it); `400` `ACCOUNT_USER_UNKNOWN` (`Not a user of this tenant.`, `details.userIds`) for a user that is not your tenant's, or was deleted; `400` `ACCOUNT_REQUEST_INVALID` for a request written wrong ([Accounts](https://docs.aureahub.com/docs/guide-accounts.md)). - For `owner`, `tenantadmin` and `non_signing_admin`, on their own tenant. With `account.created` to the endpoints that name it, once Aurea sends account events. ## Endpoint ### `POST /v1/accounts` Authentication: bearer token required. Creates an account of the tenant. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `customerRefId` | string \| null | no | Your reference for the customer; unique, set once | | `name` | string \| null | no | 1–100 characters | | `tags` | string[] | no | Up to 20, each named once | | `hidden` | boolean | no | Default `false` | | `users` | object[] | no | `[{ userId, access }]`, `access` `owner` (at most one) or `viewer` | **Responses** `201` Created ```json { "id": "3b8f2c6e-1d4a-4e9b-8f7c-2a5d6e9b1c40", "customerRefId": "cust-00042", "name": "Savings, customer 42", "tags": ["retail", "eu"], "hidden": false, "isDefault": false, "frozen": false, "users": [ { "userId": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "access": "owner" } ], "wallets": [], "createdBy": "0d2f6b8a-4c1e-4a93-b7d5-2e9f1c3a8b60", "createdAt": "2026-10-07T09:12:40.511Z", "updatedAt": "2026-10-07T09:12:40.511Z" } ``` `409` Reference taken ```json { "statusCode": 409, "error": "ConflictError", "message": "Another account of the tenant holds this customerRefId.", "details": { "code": "ACCOUNT_REF_TAKEN", "accountId": "3b8f2c6e-1d4a-4e9b-8f7c-2a5d6e9b1c40" } } ``` `400` Unknown user ```json { "statusCode": 400, "error": "BadRequestError", "message": "Not a user of this tenant.", "details": { "code": "ACCOUNT_USER_UNKNOWN", "userIds": ["e4b1c7d9-2f6a-4c83-9b0e-5a7d3f1c8e26"] } } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/accounts \ -H "Authorization: Bearer $TENANT_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "customerRefId": "cust-00042", "name": "Savings, customer 42", "tags": ["retail", "eu"], "users": [{ "userId": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "access": "owner" }] }' ``` Web version: https://docs.aureahub.com/#account-create --- # Get an Account One account with its users and wallets. A user's id reads that user's default account. ## Overview - `isDefault`: a user's default account — its id is the user's, and it holds the user's wallets that are in no other account. `frozen`: an active freeze of the account; its wallets refuse to sign ([Freeze an Account](https://docs.aureahub.com/docs/guide-accounts.md)). - `users`: `{ userId, access }`, `owner` or `viewer`; a default account's owner is its user. - `wallets`: each active wallet it holds — `walletId`, `chain` (the chain it is registered on; `evm` for the EVM key Aurea makes for an account), `family` (`evm` for an EVM chain, else the chain), `isTestnet`, `address`, `keyManagementScheme` (as [Get Wallet](https://docs.aureahub.com/docs/wallets-get.md) answers it), `label` and `isPrimary` (its owner's primary wallet on its chain and network). An EVM address registered on several EVM chains has one entry per chain. - `createdBy`: who made it; `null` for a default account never written to. - `404` `ACCOUNT_NOT_FOUND` (`No such account here.`) for an account that is not your tenant's. Same readers as [List Accounts](https://docs.aureahub.com/docs/accounts-list.md). ## Endpoint ### `GET /v1/accounts/{accountId}` Authentication: bearer token required. One account with its users and wallets. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `accountId` | string (uuid) | yes | The account's id; a user's id for that user's default account | **Responses** `200` OK ```json { "id": "3b8f2c6e-1d4a-4e9b-8f7c-2a5d6e9b1c40", "customerRefId": "cust-00042", "name": "Savings, customer 42", "tags": ["retail", "eu"], "hidden": false, "isDefault": false, "frozen": false, "users": [ { "userId": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "access": "owner" }, { "userId": "e4b1c7d9-2f6a-4c83-9b0e-5a7d3f1c8e26", "access": "viewer" } ], "wallets": [ { "walletId": "7c1e9b24-3f5a-4d8e-9b6c-1a2f3e4d5c6b", "chain": "evm", "family": "evm", "isTestnet": false, "address": "0x52908400098527886E0F7030069857D2E4169EE7", "keyManagementScheme": "sss_2of2_akv", "label": null, "isPrimary": false }, { "walletId": "4f8a2d6c-1b3e-4a59-8c70-d2e4f6a8b0c1", "chain": "solana", "family": "solana", "isTestnet": false, "address": "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM", "keyManagementScheme": "sss_2of2_akv", "label": null, "isPrimary": false } ], "createdBy": "0d2f6b8a-4c1e-4a93-b7d5-2e9f1c3a8b60", "createdAt": "2026-10-07T09:12:40.511Z", "updatedAt": "2026-10-07T09:20:15.730Z" } ``` `404` Not found ```json { "statusCode": 404, "error": "NotFoundError", "message": "No such account here.", "details": { "code": "ACCOUNT_NOT_FOUND" } } ``` Web version: https://docs.aureahub.com/#account-get --- # Change an Account Its name, tags, visibility and users, and its `customerRefId` once. ## Overview - Send at least one field; each follows the rules of [Create an Account](https://docs.aureahub.com/docs/account-create.md). `"name": null` clears the name; `tags` and `users` replace the whole list. - `customerRefId` is set once: changing one that is set answers `409` `ACCOUNT_REF_IMMUTABLE` (`The account's customerRefId is set and never changes.`); sending the same value again changes nothing. One another account holds: `409` `ACCOUNT_REF_TAKEN`. - **The owner does not change while the account holds wallets**: `409` `ACCOUNT_OWNER_HOLDS_WALLETS` (`The account holds wallets of its owner: its owner does not change.`). Viewers can always change. - **A default account** (its id is a user's): its owner is its user — name the user as `owner`, or leave the user out, else `400` `ACCOUNT_DEFAULT_OWNER` (`A default account belongs to its user: name it as the owner, or leave it out.`). The first change stores it. - `404` `ACCOUNT_NOT_FOUND`; `400` `ACCOUNT_USER_UNKNOWN`, `ACCOUNT_REQUEST_INVALID`. For `owner`, `tenantadmin` and `non_signing_admin`, on their own tenant. With `account.updated` to the endpoints that name it, once Aurea sends account events. ## Endpoint ### `PATCH /v1/accounts/{accountId}` Authentication: bearer token required. Changes an account's name, tags, visibility, users, or sets its customerRefId. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `accountId` | string (uuid) | yes | The account's id; a user's id for that user's default account | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `customerRefId` | string | no | Set once | | `name` | string \| null | no | 1–100 characters; `null` clears it | | `tags` | string[] | no | Replaces the tags | | `hidden` | boolean | no | Left out of the console's default list | | `users` | object[] | no | Replaces the users: `[{ userId, access }]` | **Responses** `200` Changed ```json { "id": "3b8f2c6e-1d4a-4e9b-8f7c-2a5d6e9b1c40", "customerRefId": "cust-00042", "name": "Savings, customer 42", "tags": ["retail", "eu", "vip"], "hidden": false, "isDefault": false, "frozen": false, "users": [ { "userId": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "access": "owner" }, { "userId": "e4b1c7d9-2f6a-4c83-9b0e-5a7d3f1c8e26", "access": "viewer" } ], "wallets": [], "createdBy": "0d2f6b8a-4c1e-4a93-b7d5-2e9f1c3a8b60", "createdAt": "2026-10-07T09:12:40.511Z", "updatedAt": "2026-10-07T09:30:02.118Z" } ``` `409` Reference set ```json { "statusCode": 409, "error": "ConflictError", "message": "The account's customerRefId is set and never changes.", "details": { "code": "ACCOUNT_REF_IMMUTABLE" } } ``` **Example request** ```bash curl -X PATCH https://api.aureahub.com/v1/accounts/3b8f2c6e-1d4a-4e9b-8f7c-2a5d6e9b1c40 \ -H "Authorization: Bearer $TENANT_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "tags": ["retail", "eu", "vip"] }' ``` Web version: https://docs.aureahub.com/#account-update --- # Add a Wallet to an Account Have Aurea make the account's wallet on a chain, or put one of its owner's wallets in it. ## Overview - **`{ "chain", "isTestnet", "name" }`**: Aurea makes the wallet for the account's owner, by the same path as [Create Wallet](https://docs.aureahub.com/docs/wallets-create.md) — for a tenant whose wallets are custodial. Any EVM chain asked, or `evm`, makes the account's one EVM key, on chain `evm`, valid on every EVM chain; `solana` its Solana wallet. `isTestnet` defaults to `false`; `name` is the wallet's label, 1 to 100 characters. - **`{ "walletId" }`**: one of the owner's wallets that is in no other account goes in it — an EVM wallet with every wallet of the owner at the same address on the same network. - `201` with the account and its wallets. The account holds one EVM address per network and one wallet per chain and network for any other chain: what it holds already answers `409` `ACCOUNT_CHAIN_TAKEN`. How it works: [Accounts](https://docs.aureahub.com/docs/guide-accounts.md). - For `owner`, `tenantadmin` and `non_signing_admin`, on their own tenant. With `account.wallet_added` to the endpoints that name it, once Aurea sends account events. ## Endpoint ### `POST /v1/accounts/{accountId}/wallets` Authentication: bearer token required. Makes the account's wallet on a chain, or puts one of its owner's wallets in it. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `accountId` | string (uuid) | yes | The account's id; a user's id for that user's default account, which takes `{ chain }` only | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `chain` | string | no | To make one: an EVM chain slug, `evm`, `solana`, or a testnet's id | | `isTestnet` | boolean | no | With `chain`; default `false` | | `name` | string | no | With `chain`: the wallet's label | | `walletId` | string (uuid) | no | Instead of `chain`: one of the owner's wallets to put in | **Responses** `201` Added ```json { "id": "3b8f2c6e-1d4a-4e9b-8f7c-2a5d6e9b1c40", "customerRefId": "cust-00042", "name": "Savings, customer 42", "tags": ["retail", "eu"], "hidden": false, "isDefault": false, "frozen": false, "users": [ { "userId": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "access": "owner" } ], "wallets": [ { "walletId": "7c1e9b24-3f5a-4d8e-9b6c-1a2f3e4d5c6b", "chain": "evm", "family": "evm", "isTestnet": false, "address": "0x52908400098527886E0F7030069857D2E4169EE7", "keyManagementScheme": "sss_2of2_akv", "label": "Customer 42", "isPrimary": false } ], "createdBy": "0d2f6b8a-4c1e-4a93-b7d5-2e9f1c3a8b60", "createdAt": "2026-10-07T09:12:40.511Z", "updatedAt": "2026-10-07T09:12:40.511Z" } ``` `409` Chain taken ```json { "statusCode": 409, "error": "ConflictError", "message": "The account holds its EVM key on this network already: one address, valid on every EVM chain.", "details": { "code": "ACCOUNT_CHAIN_TAKEN", "walletId": "7c1e9b24-3f5a-4d8e-9b6c-1a2f3e4d5c6b", "address": "0x52908400098527886E0F7030069857D2E4169EE7" } } ``` `409` Keys on the device ```json { "statusCode": 409, "error": "ConflictError", "message": "This tenant's wallets are made on the user's device (POST /v1/wallets/client): put it in the account with { walletId }.", "details": { "code": "ACCOUNT_WALLET_BY_CLIENT", "walletMode": "non_custodial" } } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/accounts/3b8f2c6e-1d4a-4e9b-8f7c-2a5d6e9b1c40/wallets \ -H "Authorization: Bearer $TENANT_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "chain": "base", "name": "Customer 42" }' ``` ## Refusals Nothing is made or moved. In the order they are checked: - `404` `ACCOUNT_NOT_FOUND` — `No such account here.` - `409` `ACCOUNT_OWNER_REQUIRED` — `The account has no owner: a wallet is its owner's. Name one first (users with access owner).` - **Making one** (`chain`): `409` `ACCOUNT_WALLET_BY_CLIENT`, with `details.walletMode` — your tenant's keys are on the user's device (`non_custodial`: `This tenant's wallets are made on the user's device (POST /v1/wallets/client): put it in the account with { walletId }.`) or 2-of-3 (`mpc`: `This tenant's wallets are 2-of-3: the user's device makes the wallet (the MPC key generation), then puts it in the account with { walletId }.`); `400` `ACCOUNT_CHAIN_UNKNOWN` — `Not a chain a wallet can be made on: …`; `409` `ACCOUNT_CHAIN_TAKEN`, with `details.walletId` and `details.address` — `The account holds its EVM key on this network already: one address, valid on every EVM chain.`, or for Solana `The account holds a wallet on this chain and network already: one per chain.` Then any refusal of the wallet path itself, as [Create Wallet](https://docs.aureahub.com/docs/wallets-create.md) answers it — for example `503` `WALLET_KEY_STORE_UNAVAILABLE`. - **Putting one in** (`walletId`): `400` `ACCOUNT_DEFAULT_ATTACH` — `A wallet in no account is already in its user's default account.`; `404` `ACCOUNT_WALLET_NOT_FOUND` — `No such wallet here.`; `409` `ACCOUNT_WALLET_NOT_OWNERS` — `Only a wallet of the account's owner can be put in it.`; `409` `ACCOUNT_WALLET_IN_ACCOUNT`, with `details.accountId` — `The wallet is in an account already.`, or `The wallet is in this account already.`; `409` `ACCOUNT_CHAIN_TAKEN` — the account already holds another EVM address on that network, or a wallet on that chain. - `400` `ACCOUNT_REQUEST_INVALID` — a body that is neither of the two, or mixes them. Web version: https://docs.aureahub.com/#account-wallet-add --- # Find the Account of an Address The reverse lookup: the account — and your reference for its customer — of each of your tenant's wallets at an address. ## Overview - `matches`: every active wallet of your tenant at the address, one per chain and network it is registered on, each with `accountId`, that account's `customerRefId`, and `isDefault` — `true` when the wallet is in no account you made, and so in its user's default account (whose `customerRefId` is `null` until you set one). `address` in a match is as the wallet stores it; the top-level `address` is as you asked. - An EVM address (`0x` and 40 hex digits) is compared without regard to case; a Solana address (base58) exactly. Anything else: `400` `ACCOUNT_ADDRESS_INVALID` (`Not an EVM (0x and 40 hex digits) or Solana (base58) address.`). No wallet of your tenant there: `404` `ACCOUNT_ADDRESS_UNKNOWN` (`No wallet of the tenant at this address.`). - `chain` (a slug, or a testnet's id) and `isTestnet` narrow it: an EVM chain matches the address's wallets on every EVM chain, any other chain its own. - Same readers as [List Accounts](https://docs.aureahub.com/docs/accounts-list.md). The address goes in the query: keep it out of your own logs if they are shared. ## Endpoint ### `GET /v1/accounts/lookup` Authentication: bearer token required. The account of each of the tenant's wallets at an address. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `address` | string | yes | An EVM or Solana address | | `chain` | string | no | Only the wallets serving this chain — for an EVM chain, the address's wallets on every EVM chain | | `isTestnet` | boolean | no | Only that network's wallets | **Responses** `200` Found ```json { "address": "0x52908400098527886e0f7030069857d2e4169ee7", "matches": [ { "accountId": "3b8f2c6e-1d4a-4e9b-8f7c-2a5d6e9b1c40", "customerRefId": "cust-00042", "isDefault": false, "walletId": "7c1e9b24-3f5a-4d8e-9b6c-1a2f3e4d5c6b", "chain": "evm", "isTestnet": false, "address": "0x52908400098527886E0F7030069857D2E4169EE7" } ] } ``` `404` Unknown ```json { "statusCode": 404, "error": "NotFoundError", "message": "No wallet of the tenant at this address.", "details": { "code": "ACCOUNT_ADDRESS_UNKNOWN" } } ``` **Example request** ```bash curl "https://api.aureahub.com/v1/accounts/lookup?address=0x52908400098527886e0f7030069857d2e4169ee7&chain=base" \ -H "Authorization: Bearer $TENANT_ADMIN_TOKEN" ``` Web version: https://docs.aureahub.com/#account-lookup --- # Create Accounts in Bulk Up to 10,000 accounts in one request, each with your reference for its customer, and optionally a wallet on each chain you list. > ℹ️ **Switched off until Aurea turns it on.** Until then a request written right answers `503` `ACCOUNTS_BULK_OFF` (`Bulk creation of accounts is switched off.`) and nothing is stored. [Contact us](https://www.aureahub.com/contact) to use it. ## Overview - `accounts`: 1 to 10,000, each with a `customerRefId` (required) and optionally `name`, `tags`, `hidden` and `users`, as in [Create an Account](https://docs.aureahub.com/docs/account-create.md). - `wallets` (optional): up to 20 `{ "chain", "isTestnet" }` made for every account, in the order listed — every EVM chain named is the account's one EVM key, made on chain `evm`; a family named twice is one wallet. They need a custodial tenant and an owner on every account. - **Checked whole first**: `400` `ACCOUNTS_BULK_DUPLICATE_REF` (`A customerRefId is named twice.`, up to 20 in `details.customerRefIds`); with `wallets`, `409` `ACCOUNT_WALLET_BY_CLIENT`, `400` `ACCOUNT_CHAIN_UNKNOWN` and `400` `ACCOUNT_OWNER_REQUIRED` (`Every account needs an owner to hold wallets: a wallet is its owner's.`, up to 20 places in `details.indexes`); `400` `ACCOUNT_USER_UNKNOWN`; `400` `ACCOUNT_REQUEST_INVALID`. Nothing is stored. - **`202` with the job.** The accounts are made in the background, each job in the order of its request. An account whose `customerRefId` your tenant already has is skipped, so the same request sent again makes nothing twice. Follow it with [Bulk Creation Report](https://docs.aureahub.com/docs/accounts-bulk-get.md). - The body may be up to 6 MiB. For `owner`, `tenantadmin` and `non_signing_admin`, on their own tenant. More: [Accounts](https://docs.aureahub.com/docs/guide-accounts.md). ## Endpoint ### `POST /v1/accounts/bulk` Authentication: bearer token required. Queues the creation of up to 10,000 accounts. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `accounts` | object[] | yes | 1–10,000: `{ customerRefId, name?, tags?, hidden?, users? }` | | `wallets` | object[] | no | Up to 20 `{ chain, isTestnet? }` made for every account | **Responses** `503` Switched off ```json { "statusCode": 503, "error": "ServiceUnavailableError", "message": "Bulk creation of accounts is switched off.", "details": { "code": "ACCOUNTS_BULK_OFF" } } ``` `202` Queued ```json { "jobId": "c41e8a7d-3b2f-4d96-a05e-9f7b1c3d5e82", "status": "queued", "total": 2 } ``` `400` Duplicate ```json { "statusCode": 400, "error": "BadRequestError", "message": "A customerRefId is named twice.", "details": { "code": "ACCOUNTS_BULK_DUPLICATE_REF", "customerRefIds": ["cust-00042"] } } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/accounts/bulk \ -H "Authorization: Bearer $TENANT_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "accounts": [ { "customerRefId": "cust-00043", "users": [{ "userId": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "access": "owner" }] }, { "customerRefId": "cust-00044", "users": [{ "userId": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "access": "owner" }] } ], "wallets": [{ "chain": "base" }, { "chain": "solana" }] }' ``` Web version: https://docs.aureahub.com/#accounts-bulk-create --- # List Bulk Creations Your tenant's bulk creations of accounts, newest first, with their counts. ## Overview - Each job: `status` (`queued` — nothing done yet; `running`; `completed` — every account created, skipped or failed), `total`, `counts` (`pending`, `created`, `skipped`, `failed`), the `wallets` made for each account, who asked (`requestedBy`), and when it was asked, started and finished. - Page with `limit` (1–200, default 50) and `cursor`, the previous page's `nextCursor`. While the bulk creation is switched off the list is empty. - Same readers as [List Accounts](https://docs.aureahub.com/docs/accounts-list.md). ## Endpoint ### `GET /v1/accounts/bulk` Authentication: bearer token required. The tenant's bulk creations, newest first. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `cursor` | string (uuid) | no | The previous page's `nextCursor` | | `limit` | integer | no | 1–200, default 50 | **Responses** `200` OK ```json { "jobs": [ { "id": "c41e8a7d-3b2f-4d96-a05e-9f7b1c3d5e82", "status": "completed", "total": 2, "counts": { "pending": 0, "created": 1, "skipped": 1, "failed": 0 }, "wallets": [ { "chain": "evm", "isTestnet": false }, { "chain": "solana", "isTestnet": false } ], "requestedBy": "0d2f6b8a-4c1e-4a93-b7d5-2e9f1c3a8b60", "createdAt": "2026-10-07T10:00:00.120Z", "startedAt": "2026-10-07T10:00:05.004Z", "finishedAt": "2026-10-07T10:00:07.881Z" } ], "nextCursor": null } ``` Web version: https://docs.aureahub.com/#accounts-bulk-list --- # Bulk Creation Report A bulk creation, its counts, and each of its accounts in the order of the request. ## Overview - The job as [List Bulk Creations](https://docs.aureahub.com/docs/accounts-bulk-list.md) shows it, plus `items`: `index` (its place in the request, from 0), `customerRefId`, `status` and `accountId` and `reason`. - `created`: `accountId` the account made. `skipped`: your tenant already had the `customerRefId` — `reason` `ACCOUNT_REF_EXISTS`, `accountId` the account that holds it. `failed`: `reason` the code that refused it — `ACCOUNT_USER_UNKNOWN`, a wallet path's code such as `WALLET_KEY_STORE_UNAVAILABLE`, `ACCOUNT_WALLET_AMBIGUOUS`… `pending`: not done yet. - Narrow with `status`; page with `limit` (1–1000, default 100) and `after`, the previous page's `nextAfter` (`null` on the last page). - `404` `ACCOUNTS_BULK_NOT_FOUND` (`No such bulk creation here.`) for a job that is not your tenant's. Same readers as [List Accounts](https://docs.aureahub.com/docs/accounts-list.md). ## Endpoint ### `GET /v1/accounts/bulk/{jobId}` Authentication: bearer token required. A bulk creation's report. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `jobId` | string (uuid) | yes | The `jobId` Create in Bulk answered | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `status` | string | no | `pending`, `created`, `skipped` or `failed` | | `after` | integer | no | The previous page's `nextAfter` | | `limit` | integer | no | 1–1000, default 100 | **Responses** `200` OK ```json { "id": "c41e8a7d-3b2f-4d96-a05e-9f7b1c3d5e82", "status": "completed", "total": 2, "counts": { "pending": 0, "created": 1, "skipped": 1, "failed": 0 }, "wallets": [ { "chain": "evm", "isTestnet": false }, { "chain": "solana", "isTestnet": false } ], "requestedBy": "0d2f6b8a-4c1e-4a93-b7d5-2e9f1c3a8b60", "createdAt": "2026-10-07T10:00:00.120Z", "startedAt": "2026-10-07T10:00:05.004Z", "finishedAt": "2026-10-07T10:00:07.881Z", "items": [ { "index": 0, "customerRefId": "cust-00043", "status": "created", "accountId": "6e2b9d14-7a3c-4f85-b1d0-8c4e2a6f9b37", "reason": null }, { "index": 1, "customerRefId": "cust-00044", "status": "skipped", "accountId": "1f7c3a95-2d8e-4b6a-9e04-5b3d7f1a2c68", "reason": "ACCOUNT_REF_EXISTS" } ], "nextAfter": null } ``` `404` Not found ```json { "statusCode": 404, "error": "NotFoundError", "message": "No such bulk creation here.", "details": { "code": "ACCOUNTS_BULK_NOT_FOUND" } } ``` Web version: https://docs.aureahub.com/#accounts-bulk-get --- # 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`. A `cggmp` wallet then runs one more ceremony, once, before its first signature: [Start Aux Ceremony](https://docs.aureahub.com/docs/mpc-aux-start.md). 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. A chain Aurea does not know: `400` `WALLET_CHAIN_UNKNOWN`, and nothing is registered. | | `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 the Auxiliary Ceremony A CGGMP wallet's second ceremony, run once after the DKG and before the first signature. ## Overview - A wallet made with `scheme: "cggmp"` ([Start DKG Ceremony](https://docs.aureahub.com/docs/mpc-dkg-start.md)) needs one more ceremony before it can sign: the auxiliary-information ceremony, which turns each party's key share into a share that can sign. Run it once, after [Register MPC Wallet](https://docs.aureahub.com/docs/mpc-wallet-register.md) and before the first [signing ceremony](https://docs.aureahub.com/docs/mpc-sign-start.md). - This call opens the session and has Aurea's cosigner join it; the device joins the returned `sessionId` through the relay and runs the ceremony's message loop, as for the DKG. The ceremony is heavy computation on the device: run it off the main thread. - A GG18 wallet has no auxiliary ceremony: `409`. ## Endpoint ### `POST /v1/mpc/wallets/{walletId}/aux/start` Authentication: bearer token required. Opens the CGGMP auxiliary-information ceremony of one of the user's MPC wallets. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `walletId` | string | yes | The MPC wallet's id | **Responses** `200` OK ```json { "sessionId": "aux-6f1c2e0a-…" } ``` `403` Not Yours ```json { "error": "not your wallet" } ``` `404` Not Found ```json { "error": "mpc wallet not found" } ``` `409` Not CGGMP ```json { "error": "aux ceremony is CGGMP-only" } ``` `429` Too Many ```json { "error": "too many aux requests" } ``` Web version: https://docs.aureahub.com/#mpc-aux-start --- # 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`. > ℹ️ **Message rules, where Aurea enables them for your tenant:** a digest that is not a transaction or permit Aurea prepared, nor a message it checked, is signed only under a rule Aurea sets for your tenant (`403` `RAW_SIGN_NOT_ALLOWED`); a message checked when its digest was handed out keeps that verdict (`PERSONAL_SIGN_NOT_ALLOWED`, `MESSAGE_DOMAIN_NOT_ALLOWED`). [Signing Rules](https://docs.aureahub.com/docs/guide-signing-rules.md). ## 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 --- # 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 | The reader's own party: `DEVICE`, `BACKUP` or `AUREA`. Missing, or any other value: `422`. | **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). > ℹ️ **Institutional tenants only.** For a tenant on the institutional security profile, this route needs an `Idempotency-Key`, and the tenant's policy decides before anything is signed or handed out to sign. A rule that asks for an approval holds the operation: the route answers `202` with the operation to follow (the **202** tab below), and nothing is signed or sent until the rule's approvers approve it — [Approvals](https://docs.aureahub.com/docs/guide-approvals.md). The policy's other refusals: [Policy Sets](https://docs.aureahub.com/docs/guide-policy-sets.md). Every other tenant gets the answers below, unchanged. ## Endpoint ### `POST /v1/transactions/` Authentication: bearer token required. Creates a send from a wallet owned by the authenticated user. **Headers** | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | no | Optional — required for a tenant on the institutional security profile (`400` `IDEMPOTENCY_KEY_REQUIRED` without it). Up to 255 letters, digits, - or _. The same key with the same request answers with the first answer (`idempotency-replayed: true`) instead of running it again; see [Idempotency](https://docs.aureahub.com/docs/idempotency.md) | **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" } ``` `202` Held for approval ```json { "operationId": "1d7a3c52-8e4b-4f19-a0c6-2b9e7d5f3a18", "state": "PENDING_AUTHORIZATION", "subState": "awaiting_approval", "approval": { "id": "c3e1a9f4-6b2d-4e8a-9f17-5d0c2b8e4a63", "ruleId": "e2b6d9f1-7a3c-4c58-8b0e-5f1a4d2c9b73", "ruleName": "Large transfers need two approvers", "threshold": 2, "expiresAt": null } } ``` `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. | **Headers** | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | no | Optional — required for a tenant on the institutional security profile (`400` `IDEMPOTENCY_KEY_REQUIRED` without it). Up to 255 letters, digits, - or _. The same key with the same request answers with the first answer (`idempotency-replayed: true`) instead of running it again; see [Idempotency](https://docs.aureahub.com/docs/idempotency.md) | **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)" } ``` `409` Superseded ```json { "statusCode": 409, "error": "BlockchainError", "message": "A newer transaction was prepared for this wallet: this one can no longer be broadcast", "code": "PREPARED_TX_SUPERSEDED", "retryable": false, "details": { "code": "PREPARED_TX_SUPERSEDED", "retryable": false } } ``` `409` Cancelled ```json { "statusCode": 409, "error": "ConflictError", "message": "This operation was cancelled: what it prepared can no longer be broadcast", "details": { "code": "OPERATION_CANCELLED", "operationId": "1d7a3c52-8e4b-4f19-a0c6-2b9e7d5f3a18" } } ``` `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. - **A signature from another key spends nothing** on a standard send: it is refused with `422` `WRONG_SIGNER`, and the same `preparedTxId` with the right signature still goes through, once. - **A gasless permit is the exception:** a permit signed by another key is refused with `422` `WRONG_SIGNER` and spent, so the user can prepare a new send straight away with [Create Transaction](https://docs.aureahub.com/docs/tx-create.md). - With an `Idempotency-Key`, the same key and request answer with the first broadcast instead of sending it again — see [Idempotency](https://docs.aureahub.com/docs/idempotency.md). - **One unsigned preparation per wallet and chain.** Preparing another send, approval or swap for the same wallet on the same chain while a standard send is still unsigned gives the newer one its nonce: the send can no longer be broadcast (`409` `PREPARED_TX_SUPERSEDED`), and nothing is spent. Broadcast the newer one. - `409` `OPERATION_CANCELLED` — its operation was cancelled by its initiator ([Cancel an Operation](https://docs.aureahub.com/docs/operation-cancel.md)): `This operation was cancelled: what it prepared can no longer be broadcast`, with `details.operationId`; from the prepared transaction itself, `409` `PREPARED_TX_CANCELLED` (`This transaction's operation was cancelled: it can no longer be broadcast`). Nothing is spent: prepare a new one. - **Institutional tenants only:** this route needs an `Idempotency-Key`, and the tenant's policy decides again before the transaction is sent. It never holds anything: a rule asking for an approval refuses with `403` `POLICY_APPROVAL_REQUIRED`, unless the operation was approved for that rule ([Approvals](https://docs.aureahub.com/docs/guide-approvals.md), [Policy Sets](https://docs.aureahub.com/docs/guide-policy-sets.md)). ## 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 — or, for a gasless send, its permit was spent by a signature from another key. | | 409 | PREPARED_TX_SUPERSEDED | `A newer transaction was prepared for this wallet: this one can no longer be broadcast` — standard send: a newer preparation for the same wallet and chain took its nonce while it was unsigned. Checked after `PREPARED_TX_NOT_FOUND`, `PREPARED_TX_ALREADY_USED` and `PREPARED_TX_EXPIRED`. | | 409 | PREPARED_TX_CANCELLED | `This transaction's operation was cancelled: it can no longer be broadcast` — its initiator cancelled it ([Cancel an Operation](https://docs.aureahub.com/docs/operation-cancel.md)). Nothing is marked used. Checked after `PREPARED_TX_ALREADY_USED`. | | 409 | OPERATION_CANCELLED | The same, found from its operation before anything runs: `This operation was cancelled: what it prepared can no longer be broadcast`, as `"error": "ConflictError"` with `details.code` and `details.operationId`. | | 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 Gwei by default): `maxFeePerGas (… Gwei) exceeds the allowed cap of 500 Gwei`. Nothing is sent. | | 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 Operations The operations the money routes started, newest first, with their public state. ## Overview - **Empty until Aurea turns recording on** ([Operations and Fees](https://docs.aureahub.com/docs/guide-operations.md)): until then every page is `{ "operations": [], "nextCursor": null }`. - A user reads their own operations, whatever `userId` says; the tenant roles that read the tenant's transactions read every operation of the tenant, and can narrow with `userId`. - Filters: `state`, `type`, `chain`, `walletId`, `userId`, `from` (created at or after) and `to` (created before; `400` `` `from` must be before `to` `` otherwise). Page with `limit` (1–200, default 50) and `cursor`, the previous page's `nextCursor` (`null` on the last page). - Each operation without its history: [Get an Operation](https://docs.aureahub.com/docs/operation-get.md) adds `events`. `tokenizedOperationId`: the tokenized assets operation it carries out, or `null`. `sourceAccountId`: the [account](https://docs.aureahub.com/docs/guide-accounts.md) of its wallet — or `null` without one. ## Endpoint ### `GET /v1/operations` Authentication: bearer token required. The caller's operations — or the tenant's, for the roles that read them — newest first. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `state` | string | no | One state, e.g. `CONFIRMING` | | `type` | string | no | One type, e.g. `TRANSFER` | | `chain` | string | no | A chain slug, e.g. `base` | | `walletId` | string | no | One wallet | | `userId` | string | no | One user's (tenant readers only) | | `from` | string (date-time) | no | Created at or after | | `to` | string (date-time) | no | Created before | | `cursor` | string | no | The previous page's `nextCursor` | | `limit` | integer | no | 1–200, default 50 | **Responses** `200` Recording off ```json { "operations": [], "nextCursor": null } ``` `200` Recording on ```json { "operations": [ { "id": "1d7a3c52-8e4b-4f19-a0c6-2b9e7d5f3a18", "type": "TRANSFER", "state": "CONFIRMING", "subState": "awaiting_confirmation", "failureReason": null, "legacyStatus": "pending", "origin": "transactions.create", "userId": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "walletId": "7c1e9b24-3f5a-4d8e-9b6c-1a2f3e4d5c6b", "chain": "base", "chainId": 8453, "isTestnet": false, "asset": "native", "amount": "1000000000000000000", "destination": "0x8ba1f109551bD432803012645Ac136ddd64DBA72", "externalTxId": null, "idempotencyKey": "order-7781", "transactionId": "4b2e6f81-0c3d-4a5e-9f7b-8d1c2e3f4a5b", "preparedTxId": "9e3d1a7c-5b2f-4c68-8a0e-3f6b1d9c4e27", "providerRef": null, "tokenizedOperationId": null, "txHashes": ["0x5c9e1b7a3d2f4e6c8b0a9d1e3f5c7b9a2d4e6f8a0c1b3d5e7f9a1c3e5b7d9f1a"], "createdAt": "2026-10-06T12:01:44.120Z", "updatedAt": "2026-10-06T12:02:03.587Z", "finishedAt": null, "sourceAccountId": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d" } ], "nextCursor": null } ``` `400` Window ```json { "statusCode": 400, "error": "BadRequestError", "message": "`from` must be before `to`" } ``` Web version: https://docs.aureahub.com/#operations-list --- # Get an Operation One operation, with every state it passed, and what its signing points decoded, simulated and checked. ## Overview - The operation as [List Operations](https://docs.aureahub.com/docs/operations-list.md) shows it — `sourceAccountId` included —, plus `events`: each state in order — `seq` from 1, `state`, `subState`, `failureReason`, `txHash` and `at`. - `404` `OPERATION_NOT_FOUND` (`Operation not found`) for an operation the caller may not read — another user's, for a user — or that doesn't exist; and for every id while recording is off. - An operation a policy held for approval (institutional tenants: `PENDING_AUTHORIZATION` `awaiting_approval`): its request, decisions and the answer it went on with are in [An Operation's Approval](https://docs.aureahub.com/docs/operation-approval.md). - `simulations` and `signingChecks`: what the operation's signing points did with it, oldest first — below. Both are `[]` where none ran: the simulation and the signing checks run only where Aurea enables them for your tenant ([Signing Rules](https://docs.aureahub.com/docs/guide-signing-rules.md)). [Get by Your Own Id](https://docs.aureahub.com/docs/operation-by-external-id.md) answers them too; [List Operations](https://docs.aureahub.com/docs/operations-list.md) does not. ## Simulations Each entry of `simulations` is one signing point's decoding and simulation of the transaction it was about to sign or send: | Field | Meaning | | --- | --- | | point | The signing point, e.g. `custodial_transfer`, `client_prepare`, `client_broadcast`, `custodial_swap`, `dapp_broadcast`, `mpc_sign_start`. | | mode | `log`: recorded beside the signing, never refusing; `enforce`: the signing waited for it. | | chain, chainId | Where the transaction runs. | | summary | What the transaction does, in words. | | decoded | The call decoded: its `kind` — `native_transfer`, `erc20_transfer`, `erc20_approve`, `erc20_permit`, `swap`, `lending_supply`, `lending_withdraw`, `vault_deposit`, `forwarded_transfer`, `safe_transaction`, `unknown_contract_call`, `solana_transaction`… —, its function, and the asset, amount, recipient or spender it names. A call is decoded only when its data is exactly what it decodes to; anything else is `unknown_contract_call`, never a guess. | | intent | What was asked — `send`, `approve`, `swap`, `supply`, `withdraw`, `spend` or `none` — with its asset, amount and counterparty. | | status | `simulated` (it would run), `reverted` (it would fail), `unavailable` (it could not be simulated — never taken for a success), `not_applicable` (a message signature: no transaction). | | method | How it was simulated: `eth_simulateV1` or `debug_traceCall` on the platform's node of the chain, or `provider`; `null` when it was not. | | verdict | `matches` (the outcome keeps to what was asked), `diverges` (it does not: `reasons`), `no_intent` (simulated, with nothing precise asked of it — a dApp's own call), `not_simulated`, `not_a_transaction`. | | reasons, findings | Why it diverges or was not simulated — `reverted`, `more_out_than_asked`, `unexpected_asset_out`, `unexpected_recipient`, `recipient_short`, `unexpected_approval`, `approval_differs`, `approval_not_set`, `nothing_received`, `less_in_than_asked`, `call_differs_from_request`, `simulation_unavailable`, `undecodable` —, and each reason with what it is about (`code`, `asset`, `address`, `field`, `expected`, `actual`, `detail`). | | refused | `true` when the signing was refused for it (`TX_NOT_SIMULATED` or `TX_SIMULATION_DIVERGES`). | | outcome | The simulation's result: `reason` (why it could not be simulated), `revertReason`, `gasUsed`, `balanceChanges` (`address`, `asset` — `native` or the token's address —, `delta` in the asset's smallest unit, negative when it leaves), `approvals` (`owner`, `token`, `spender`, `amount`, `kind` `erc20` or `operator`), `contractsTouched`, `durationMs`. | | id, createdAt | The entry's id and when it was made. | ## Signing Checks Each entry of `signingChecks` is what the message rules, the exact approvals or the server's build of a swap said at one signing point ([Signing Rules](https://docs.aureahub.com/docs/guide-signing-rules.md)): | Field | Meaning | | --- | --- | | point | The signing point. | | control | `message_signing` (the message rules), `exact_approvals` (exact approvals, the residue revoked) or `swap_server_build` (the swap's route built on the server). | | mode | `log` or `enforce`. | | subject | `personal_sign`, `eip712`, `raw`, `transaction`, `approval`, `revoke` or `swap_route`. | | verdict | `allowed` (a rule allowed it); in `log`, `would_refuse` and `would_change` — what `enforce` would refuse or change; in `enforce`, `refused` and `changed` — what it did; `unchanged`; `failed` (a revoke that could not be sent). | | code | The refusal's code, or `null`: `MESSAGE_DOMAIN_NOT_ALLOWED`, `PERSONAL_SIGN_NOT_ALLOWED`, `RAW_SIGN_NOT_ALLOWED`, `APPROVAL_SPENDER_NOT_ALLOWED`, `APPROVAL_AMOUNT_REQUIRED`, `APPROVAL_RESET_REQUIRED`, `SWAP_QUOTE_UNKNOWN`, `SWAP_ROUTER_NOT_ALLOWED`. | | rule | The rule that allowed it: `builtin:` (a rule Aurea builds in), `tenant:` (one of your tenant's rules) or `dapp:` (one of your enabled dApps); `null` otherwise. | | details | Numbers in the smallest unit, codes and public contract addresses: the token and the spender, the amount, the allowance before, the residue after, the reasons. | | id, createdAt | The entry's id and when it was made. | ## Endpoint ### `GET /v1/operations/{id}` Authentication: bearer token required. One operation with its history. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | The operation's id | **Responses** `200` OK ```json { "id": "1d7a3c52-8e4b-4f19-a0c6-2b9e7d5f3a18", "type": "TRANSFER", "state": "CONFIRMING", "subState": "awaiting_confirmation", "failureReason": null, "legacyStatus": "pending", "origin": "transactions.create", "userId": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "walletId": "7c1e9b24-3f5a-4d8e-9b6c-1a2f3e4d5c6b", "chain": "base", "chainId": 8453, "isTestnet": false, "asset": "native", "amount": "1000000000000000000", "destination": "0x8ba1f109551bD432803012645Ac136ddd64DBA72", "externalTxId": null, "idempotencyKey": "order-7781", "transactionId": "4b2e6f81-0c3d-4a5e-9f7b-8d1c2e3f4a5b", "preparedTxId": "9e3d1a7c-5b2f-4c68-8a0e-3f6b1d9c4e27", "providerRef": null, "tokenizedOperationId": null, "txHashes": ["0x5c9e1b7a3d2f4e6c8b0a9d1e3f5c7b9a2d4e6f8a0c1b3d5e7f9a1c3e5b7d9f1a"], "createdAt": "2026-10-06T12:01:44.120Z", "updatedAt": "2026-10-06T12:02:03.587Z", "finishedAt": null, "sourceAccountId": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "events": [ { "seq": 1, "state": "SUBMITTED", "subState": null, "failureReason": null, "txHash": null, "at": "2026-10-06T12:01:44.120Z" }, { "seq": 2, "state": "PENDING_SCREENING", "subState": "not_gated", "failureReason": null, "txHash": null, "at": "2026-10-06T12:01:44.120Z" }, { "seq": 3, "state": "PENDING_ENRICHMENT", "subState": "not_gated", "failureReason": null, "txHash": null, "at": "2026-10-06T12:01:44.120Z" }, { "seq": 4, "state": "PENDING_POLICY", "subState": "not_gated", "failureReason": null, "txHash": null, "at": "2026-10-06T12:01:44.120Z" }, { "seq": 5, "state": "PENDING_AUTHORIZATION", "subState": "not_gated", "failureReason": null, "txHash": null, "at": "2026-10-06T12:01:44.120Z" }, { "seq": 6, "state": "QUEUED", "subState": "not_gated", "failureReason": null, "txHash": null, "at": "2026-10-06T12:01:44.120Z" }, { "seq": 7, "state": "PENDING_SIGNATURE", "subState": "awaiting_client_signature", "failureReason": null, "txHash": null, "at": "2026-10-06T12:01:44.120Z" }, { "seq": 8, "state": "SIGNED", "subState": "signed_by_client", "failureReason": null, "txHash": null, "at": "2026-10-06T12:02:03.211Z" }, { "seq": 9, "state": "BROADCASTING", "subState": "sending", "failureReason": null, "txHash": null, "at": "2026-10-06T12:02:03.214Z" }, { "seq": 10, "state": "CONFIRMING", "subState": "awaiting_confirmation", "failureReason": null, "txHash": "0x5c9e1b7a3d2f4e6c8b0a9d1e3f5c7b9a2d4e6f8a0c1b3d5e7f9a1c3e5b7d9f1a", "at": "2026-10-06T12:02:03.587Z" } ], "simulations": [], "signingChecks": [] } ``` `404` Not found ```json { "statusCode": 404, "error": "NotFoundError", "message": "Operation not found", "details": { "code": "OPERATION_NOT_FOUND" } } ``` Web version: https://docs.aureahub.com/#operation-get --- # Get an Operation by Your Own Id The operation your tenant named with its own id, `externalTxId`, with every state it passed. ## Overview - **No route takes an `externalTxId` yet**: every operation's `externalTxId` is `null` today, and this read answers `404` `OPERATION_NOT_FOUND`. It is published so that clients can be ready; use [Get an Operation](https://docs.aureahub.com/docs/operation-get.md), or your `Idempotency-Key` in `idempotencyKey`, meanwhile. - `externalTxId`: 1 to 255 letters, digits, `_`, `.`, `:` or `-`; unique in your tenant for ever. The same readers and answer as Get an Operation. ## Endpoint ### `GET /v1/operations/by-external-id/{externalTxId}` Authentication: bearer token required. The operation the tenant named with its own id. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `externalTxId` | string | yes | Your id for the operation | **Responses** `404` Not found ```json { "statusCode": 404, "error": "NotFoundError", "message": "Operation not found", "details": { "code": "OPERATION_NOT_FOUND" } } ``` Web version: https://docs.aureahub.com/#operation-by-external-id --- # An Operation's Approval For the operations a policy held: the approval request, its decisions, and — once approved and gone on — the answer the call would have given. ## Overview - The initiator's view of an operation a money route held for approval (it answered `202`; [Approvals](https://docs.aureahub.com/docs/guide-approvals.md), institutional tenants only): the operation's newest request with the fields of [Get a Request](https://docs.aureahub.com/docs/approval-get.md), and its `decisions` — without the payloads to sign. - `result`: `null` until the approved operation went on; then `{ "status", "body" }`, the answer the call would have given — for a client-side or MPC wallet, the prepared transaction to sign and broadcast before its `expiresAt` (`txHashToSign` or `permitHashToSign`, `preparedTxId`); for a custodial wallet, the transaction sent; or the error the call would have answered. - The same readers as [Get an Operation](https://docs.aureahub.com/docs/operation-get.md): a user reads their own operations; the tenant roles that read the tenant's transactions read every operation of the tenant. - `404` `APPROVAL_NOT_FOUND` (`No approval request for this operation here`) for an operation that was never held, that you may not read, or that doesn't exist. ## Endpoint ### `GET /v1/operations/{id}/approval` Authentication: bearer token required. An operation's approval request, and the answer it went on with. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | The operation's id | **Responses** `200` Approved, prepared ```json { "id": "c3e1a9f4-6b2d-4e8a-9f17-5d0c2b8e4a63", "operationId": "1d7a3c52-8e4b-4f19-a0c6-2b9e7d5f3a18", "status": "approved", "origin": "transactions.create", "initiatorId": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "ruleId": "e2b6d9f1-7a3c-4c58-8b0e-5f1a4d2c9b73", "ruleName": "Large transfers need two approvers", "policySetVersion": 2, "threshold": 2, "approvals": 2, "rejections": 0, "approverIds": null, "excludeInitiator": true, "chain": "gnosis", "chainId": 100, "asset": "0x2a22f9c3b484c3629090feed35f17ff8f88f76f0", "amount": "35000000000", "amountRef": "32150.75", "destination": "0x52908400098527886e0f7030069857d2e4169ee7", "expiresAt": null, "createdAt": "2026-10-06T12:01:44.120Z", "decidedAt": "2026-10-06T12:24:51.302Z", "decisions": [ { "approverId": "6a1c9e3f-2b7d-4f50-8e4a-0c5d3b9f7e12", "decision": "approve", "at": "2026-10-06T12:20:03.511Z" }, { "approverId": "0d2f6b8a-4c1e-4a93-b7d5-2e9f1c3a8b60", "decision": "approve", "at": "2026-10-06T12:24:51.302Z" } ], "result": { "status": 201, "body": { "id": "0b9d4c1e-3f6a-4b8e-9d2c-7a5e1f3b6c90", "txHash": null, "status": "pending", "…": "the other fields of Create Transaction's answer", "requiresClientSigning": true, "gasless": false, "preparedTxId": "5e0f7b8a-1c2d-4e3f-8a9b-0c1d2e3f4a5b", "txHashToSign": "0x4a7d…", "chainId": 100, "expiresAt": "2026-10-06T12:29:52.004Z" } } } ``` `404` Never held ```json { "statusCode": 404, "error": "NotFoundError", "message": "No approval request for this operation here", "details": { "code": "APPROVAL_NOT_FOUND" } } ``` Web version: https://docs.aureahub.com/#operation-approval --- # Cancel an Operation Cancel one of your own operations while nothing of it was sent: what it prepared can no longer be broadcast. ## Overview - **Only while operations are recorded**, which Aurea switches on ([Operations and Fees](https://docs.aureahub.com/docs/guide-operations.md)). Until then there is no operation to cancel, and every id answers `404` `OPERATION_NOT_FOUND`. - **Waiting for the client's signature** (`PENDING_SIGNATURE` `awaiting_client_signature` — a client-side or MPC wallet's send, approval or swap handed out to sign): what the client was handed can no longer be broadcast, and a prepared transaction's nonce is free again for the wallet's next preparation. - **Held for approval** (`PENDING_AUTHORIZATION` `awaiting_approval`, institutional tenants): its approval request is `withdrawn` — an approver deciding afterwards gets `409` `APPROVAL_NOT_PENDING` ([Approvals](https://docs.aureahub.com/docs/guide-approvals.md)). - Either way it ends `CANCELLED`, `failureReason` `OPERATION_CANCELLED`, with an `operation.state_changed` event to the endpoints that name it. Its transaction, when it has one — never broadcast — is `cancelled` too: [Get Transaction](https://docs.aureahub.com/docs/tx-get.md) answers `status` `cancelled`, and nothing else of it changes. - **Its broadcast afterwards** is refused before anything runs: `409` `OPERATION_CANCELLED` (`This operation was cancelled: what it prepared can no longer be broadcast`, `details.operationId`) — for a prepared transaction possibly `409` `PREPARED_TX_CANCELLED` (`This transaction's operation was cancelled: it can no longer be broadcast`). Prepare a new one. - Cancelling a cancelled operation answers it again, `200`. A cancel and a broadcast at the same moment: one of them wins — never both. - **Once something was sent**, a cancel is refused (`409`, `details.state` the operation's state). A custodial EVM transaction sent but not mined can be [dropped](https://docs.aureahub.com/docs/operation-drop.md), where Aurea has replacement by fee switched on. | Status | details.code | When | | --- | --- | --- | | 409 | OPERATION_ALREADY_BROADCAST | `This operation was already handed to its chain or its provider: it can no longer be cancelled` — `BROADCASTING`, `CONFIRMING`, `COMPLETED` or `CANCELLING`, or its broadcast got there first. | | 409 | OPERATION_FINAL | `This operation has already ended` — `FAILED`, `BLOCKED` or `REJECTED`. | | 409 | OPERATION_IN_PROGRESS | `This operation is being signed or sent by the call that started it: it cannot be cancelled now` — passing its first steps, approved and going on, or being signed by Aurea. | | 403 | OPERATION_CANCEL_FORBIDDEN | `Only the operation's initiator, with the permission that starts it, may cancel it` | | 404 | OPERATION_NOT_FOUND | `Operation not found` — not one you may read, or none (and every id while recording is off). | ## Who May Cancel - **Only the operation's initiator**, holding the permission that starts it — the roles that sign: `user`, `owner`, `tenantadmin`, `signer`. Any other role: `403` `Your role does not have permission to perform this action.` - A tenant administrator who may read the operation but did not start it: `403` `OPERATION_CANCEL_FORBIDDEN`. So is an operation no user starts through a money route — an MPC signature of a digest, a message signed. Another user's operation, for an end user: `404`. ## Endpoint ### `POST /v1/operations/{id}/cancel` Authentication: bearer token required. Cancels one's own operation before anything of it is broadcast. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | The operation's id | **Responses** `200` Cancelled ```json { "id": "1d7a3c52-8e4b-4f19-a0c6-2b9e7d5f3a18", "type": "TRANSFER", "state": "CANCELLED", "subState": null, "failureReason": "OPERATION_CANCELLED", "legacyStatus": "cancelled", "origin": "transactions.create", "userId": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "walletId": "7c1e9b24-3f5a-4d8e-9b6c-1a2f3e4d5c6b", "chain": "base", "chainId": 8453, "isTestnet": false, "asset": "native", "amount": "1000000000000000000", "destination": "0x8ba1f109551bD432803012645Ac136ddd64DBA72", "externalTxId": null, "idempotencyKey": "order-7781", "transactionId": "4b2e6f81-0c3d-4a5e-9f7b-8d1c2e3f4a5b", "preparedTxId": "9e3d1a7c-5b2f-4c68-8a0e-3f6b1d9c4e27", "providerRef": null, "tokenizedOperationId": null, "txHashes": [], "createdAt": "2026-10-07T12:01:44.120Z", "updatedAt": "2026-10-07T12:02:30.402Z", "finishedAt": "2026-10-07T12:02:30.402Z", "sourceAccountId": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "events": [ { "seq": 1, "state": "SUBMITTED", "subState": null, "failureReason": null, "txHash": null, "at": "2026-10-07T12:01:44.120Z" }, { "seq": 2, "state": "PENDING_SCREENING", "subState": "not_gated", "failureReason": null, "txHash": null, "at": "2026-10-07T12:01:44.120Z" }, { "seq": 3, "state": "PENDING_ENRICHMENT", "subState": "not_gated", "failureReason": null, "txHash": null, "at": "2026-10-07T12:01:44.120Z" }, { "seq": 4, "state": "PENDING_POLICY", "subState": "not_gated", "failureReason": null, "txHash": null, "at": "2026-10-07T12:01:44.120Z" }, { "seq": 5, "state": "PENDING_AUTHORIZATION", "subState": "not_gated", "failureReason": null, "txHash": null, "at": "2026-10-07T12:01:44.120Z" }, { "seq": 6, "state": "QUEUED", "subState": "not_gated", "failureReason": null, "txHash": null, "at": "2026-10-07T12:01:44.120Z" }, { "seq": 7, "state": "PENDING_SIGNATURE", "subState": "awaiting_client_signature", "failureReason": null, "txHash": null, "at": "2026-10-07T12:01:44.120Z" }, { "seq": 8, "state": "CANCELLED", "subState": null, "failureReason": "OPERATION_CANCELLED", "txHash": null, "at": "2026-10-07T12:02:30.402Z" } ] } ``` `409` Already sent ```json { "statusCode": 409, "error": "ConflictError", "message": "This operation was already handed to its chain or its provider: it can no longer be cancelled", "details": { "code": "OPERATION_ALREADY_BROADCAST", "state": "CONFIRMING" } } ``` `403` Not the initiator ```json { "statusCode": 403, "error": "ForbiddenError", "message": "Only the operation's initiator, with the permission that starts it, may cancel it", "details": { "code": "OPERATION_CANCEL_FORBIDDEN" } } ``` `404` Not found ```json { "statusCode": 404, "error": "NotFoundError", "message": "Operation not found", "details": { "code": "OPERATION_NOT_FOUND" } } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/operations/1d7a3c52-8e4b-4f19-a0c6-2b9e7d5f3a18/cancel \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" ``` Web version: https://docs.aureahub.com/#operation-cancel --- # Speed Up an Operation's Transaction Replace a custodial EVM transaction that was sent but is not mined with the same transaction, at the same nonce, for a higher fee. > ℹ️ **Switched off until Aurea turns it on.** Until then this route answers `403` `OPERATIONS_RBF_OFF` (`Replacing a transaction by fee is not available on this platform`) to every caller allowed to call it, before anything is read or signed. It also needs operations to be recorded ([Operations and Fees](https://docs.aureahub.com/docs/guide-operations.md)). ## Overview - **Custodial EVM wallets only**: the replacement is signed with the key Aurea holds. A client-side wallet's key is on the user's device, so Aurea cannot sign its replacement (`409` `OPERATION_RBF_CLIENT_SIDE`: sign one there, at the same nonce with a higher fee); an MPC wallet's is not available (`409` `OPERATION_RBF_MPC`). - **An operation whose transaction was sent and is not mined**: `BROADCASTING`, or `CONFIRMING` `awaiting_confirmation` or `sped_up`. - The same transaction — destination, value, data, gas limit — at the same nonce, with a higher fee (The Fee). The operation goes `CONFIRMING` `sped_up`, keeps both hashes in `txHashes`, and ends with whichever of its transactions is mined: `COMPLETED` `confirmed`, or `FAILED` `reverted`; its last event names the mined hash. Speeding it up again replaces the newest. - **The transaction's own record follows the mined one**: once one is mined, [Get Transaction](https://docs.aureahub.com/docs/tx-get.md) answers `confirmed` under the mined hash. - `200` with `operation` — the operation with its `events` — and `replacement`: `kind` `speed_up`, its `txHash`, the hash it `replaces`, the `nonce`, and its fee in wei — `maxFeePerGas` and `maxPriorityFeePerGas`, or `gasPrice` for a legacy transaction. - **A money route**: an `Idempotency-Key` answers the first answer again — one replacement, never two — and is required for an institutional tenant ([Idempotency](https://docs.aureahub.com/docs/idempotency.md)). For an institutional tenant the policy decides before the replacement is signed, and a refusal leaves the operation as it was. A frozen wallet, account, user or tenant answers `423` ([Freezes & Quarantines](https://docs.aureahub.com/docs/guide-freezes.md)). - **Who**: the operation's initiator, holding the permission that starts it — `user`, `owner`, `tenantadmin`, `signer`. A tenant administrator who did not start it: `403` `OPERATION_RBF_FORBIDDEN` (`Only the operation's initiator, with the permission that starts it, may replace its transaction`); any other role: `403` `Your role does not have permission to perform this action.`; an operation you may not read: `404` `OPERATION_NOT_FOUND`. ## The Fee - Every fee field of the transaction it replaces, raised by **12.5%** and rounded up — or the network's current fee, when that is higher. `maxPriorityFeePerGas` never exceeds `maxFeePerGas`. - Never above the platform's cap on the fee per gas (500 Gwei by default): a minimum above it answers `422` `GAS_CAP_EXCEEDED` (`The replacement's fee (at least … Gwei) exceeds the allowed cap of … Gwei`), and nothing is sent. - A node that asks for more answers `409` `OPERATION_RBF_UNDERPRICED` (`The node asks a higher fee to replace this transaction: try again`). ## Refusals Nothing is signed or sent. Each carries its code in `details.code`: | Status | details.code | message | | --- | --- | --- | | 403 | OPERATIONS_RBF_OFF | `Replacing a transaction by fee is not available on this platform` | | 403 | OPERATION_RBF_FORBIDDEN | `Only the operation's initiator, with the permission that starts it, may replace its transaction` | | 404 | OPERATION_NOT_FOUND | `Operation not found` | | 409 | OPERATION_NOT_BROADCAST | `Nothing of this operation was broadcast: cancel it instead (POST /v1/operations/{id}/cancel)` | | 409 | OPERATION_FINAL | `This operation has already ended` | | 409 | OPERATION_NOT_REPLACEABLE | `This operation's transaction cannot be replaced in its state` | | 409 | OPERATION_DROPPING | `This operation's transaction is being dropped: drop it again with a higher fee, it cannot be sped up` | | 409 | OPERATION_RBF_NOT_EVM | `Only an EVM transaction can be replaced by fee` | | 409 | OPERATION_RBF_CLIENT_SIDE | `This wallet's key is on the user's device: the server cannot sign a replacement — sign one there, at the same nonce with a higher fee` | | 409 | OPERATION_RBF_MPC | `Replacing an MPC wallet's transaction by fee is not available yet` | | 409 | OPERATION_RBF_NOT_CUSTODIAL | `No key the server holds signs for this wallet: its transaction cannot be replaced here` | | 409 | OPERATION_RBF_NOT_SIGNED_BY_WALLET | `This operation's transaction was not signed by its wallet (a relayer carried it): it cannot be replaced here` | | 409 | OPERATION_TX_UNKNOWN | `The node no longer knows this operation's transaction: it may have been dropped from its pool` | | 409 | OPERATION_ALREADY_MINED | `This operation's transaction is already mined: it can no longer be replaced` | | 409 | OPERATION_RBF_BUSY | `Another transaction of this wallet is being sent: try again in a moment` | | 409 | OPERATION_RBF_UNDERPRICED | `The node asks a higher fee to replace this transaction: try again` | | 422 | GAS_CAP_EXCEEDED | `The replacement's fee (at least … Gwei) exceeds the allowed cap of … Gwei` | `OPERATION_NOT_REPLACEABLE`: `CONFIRMING` in another sub-state than `awaiting_confirmation` or `sped_up` — waiting for a provider, a delivery or confirmations —, or `CANCELLING` without a drop. `GAS_CAP_EXCEEDED` has the body of a `BlockchainError` ([Error Handling](https://docs.aureahub.com/docs/errors.md)). ## Endpoint ### `POST /v1/operations/{id}/speed-up` Authentication: bearer token required. Replaces one's own custodial EVM operation's unmined transaction with the same one at a higher fee. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | The operation's id | **Headers** | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | no | Optional — required for a tenant on the institutional security profile (`400` `IDEMPOTENCY_KEY_REQUIRED` without it). Up to 255 letters, digits, - or _. The same key with the same request answers with the first answer (`idempotency-replayed: true`) instead of running it again; see [Idempotency](https://docs.aureahub.com/docs/idempotency.md) | **Responses** `403` Switched off ```json { "statusCode": 403, "error": "ForbiddenError", "message": "Replacing a transaction by fee is not available on this platform", "details": { "code": "OPERATIONS_RBF_OFF" } } ``` `200` Sped up ```json { "operation": { "id": "8c2e5a71-4d9b-4f3e-a6c1-0b7d2e9f5a34", "type": "TRANSFER", "state": "CONFIRMING", "subState": "sped_up", "failureReason": null, "legacyStatus": "pending", "origin": "transactions.create", "userId": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "walletId": "7c1e9b24-3f5a-4d8e-9b6c-1a2f3e4d5c6b", "chain": "base", "chainId": 8453, "isTestnet": false, "asset": "native", "amount": "1000000000000000000", "destination": "0x8ba1f109551bD432803012645Ac136ddd64DBA72", "externalTxId": null, "idempotencyKey": null, "transactionId": "4b2e6f81-0c3d-4a5e-9f7b-8d1c2e3f4a5b", "preparedTxId": null, "providerRef": null, "tokenizedOperationId": null, "txHashes": [ "0x5c9e1b7a3d2f4e6c8b0a9d1e3f5c7b9a2d4e6f8a0c1b3d5e7f9a1c3e5b7d9f1a", "0x9d4f2a6c8e0b1d3f5a7c9e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f" ], "createdAt": "2026-10-07T12:01:44.120Z", "updatedAt": "2026-10-07T12:09:12.660Z", "finishedAt": null, "events": [ { "seq": 1, "state": "SUBMITTED", "subState": null, "failureReason": null, "txHash": null, "at": "2026-10-07T12:01:44.120Z" }, { "seq": 2, "state": "PENDING_SCREENING", "subState": "not_gated", "failureReason": null, "txHash": null, "at": "2026-10-07T12:01:44.120Z" }, { "seq": 3, "state": "PENDING_ENRICHMENT", "subState": "not_gated", "failureReason": null, "txHash": null, "at": "2026-10-07T12:01:44.120Z" }, { "seq": 4, "state": "PENDING_POLICY", "subState": "not_gated", "failureReason": null, "txHash": null, "at": "2026-10-07T12:01:44.120Z" }, { "seq": 5, "state": "PENDING_AUTHORIZATION", "subState": "not_gated", "failureReason": null, "txHash": null, "at": "2026-10-07T12:01:44.120Z" }, { "seq": 6, "state": "QUEUED", "subState": "not_gated", "failureReason": null, "txHash": null, "at": "2026-10-07T12:01:44.120Z" }, { "seq": 7, "state": "PENDING_SIGNATURE", "subState": "signing", "failureReason": null, "txHash": null, "at": "2026-10-07T12:01:44.131Z" }, { "seq": 8, "state": "SIGNED", "subState": "signed_by_hub", "failureReason": null, "txHash": null, "at": "2026-10-07T12:01:44.302Z" }, { "seq": 9, "state": "BROADCASTING", "subState": "sending", "failureReason": null, "txHash": null, "at": "2026-10-07T12:01:44.305Z" }, { "seq": 10, "state": "CONFIRMING", "subState": "awaiting_confirmation", "failureReason": null, "txHash": "0x5c9e1b7a3d2f4e6c8b0a9d1e3f5c7b9a2d4e6f8a0c1b3d5e7f9a1c3e5b7d9f1a", "at": "2026-10-07T12:01:44.588Z" }, { "seq": 11, "state": "CONFIRMING", "subState": "sped_up", "failureReason": null, "txHash": "0x9d4f2a6c8e0b1d3f5a7c9e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f", "at": "2026-10-07T12:09:12.660Z" } ] }, "replacement": { "kind": "speed_up", "txHash": "0x9d4f2a6c8e0b1d3f5a7c9e1b3d5f7a9c2e4b6d8f0a1c3e5b7d9f2a4c6e8b0d1f", "replaces": "0x5c9e1b7a3d2f4e6c8b0a9d1e3f5c7b9a2d4e6f8a0c1b3d5e7f9a1c3e5b7d9f1a", "nonce": 42, "maxFeePerGas": "3375000000", "maxPriorityFeePerGas": "1125000000" } } ``` `409` Client-side ```json { "statusCode": 409, "error": "ConflictError", "message": "This wallet's key is on the user's device: the server cannot sign a replacement — sign one there, at the same nonce with a higher fee", "details": { "code": "OPERATION_RBF_CLIENT_SIDE" } } ``` `422` Fee cap ```json { "statusCode": 422, "error": "BlockchainError", "message": "The replacement's fee (at least 562.5 Gwei) exceeds the allowed cap of 500 Gwei", "code": "GAS_CAP_EXCEEDED", "retryable": false, "details": { "code": "GAS_CAP_EXCEEDED", "retryable": false } } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/operations/8c2e5a71-4d9b-4f3e-a6c1-0b7d2e9f5a34/speed-up \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ -H "Idempotency-Key: 3f1c9a7e-speed-up-1" ``` Web version: https://docs.aureahub.com/#operation-speed-up --- # Drop an Operation's Transaction Replace a custodial EVM transaction that was sent but is not mined with a 0-value transaction to the wallet itself, at the same nonce, for a higher fee — so that the original never goes through. > ℹ️ **Switched off until Aurea turns it on.** Until then this route answers `403` `OPERATIONS_RBF_OFF` (`Replacing a transaction by fee is not available on this platform`) to every caller allowed to call it, before anything is read or signed. It also needs operations to be recorded ([Operations and Fees](https://docs.aureahub.com/docs/guide-operations.md)). ## Overview - The replacement sends nothing anywhere: 0 to the wallet's own address, 21,000 gas, at the original's nonce, with a fee raised as for [Speed Up](https://docs.aureahub.com/docs/operation-speed-up.md) (12.5% at least, or the network's current fee when higher, never above the platform's cap: `422` `GAS_CAP_EXCEEDED`). - The operation goes `CANCELLING` `drop_sent`. If the drop is mined, it ends `CANCELLED` (`failureReason` `OPERATION_DROPPED`) and its transaction's record is `cancelled`; if the original is mined first, it ends `COMPLETED` or `FAILED` as the original did, and the record follows it. - A drop can be dropped again with a higher fee; it cannot be sped up (`409` `OPERATION_DROPPING`). An operation sped up can be dropped. - `200` with `operation` and `replacement` (`kind` `drop`), as Speed Up answers. The same wallets, states, callers, `Idempotency-Key` and refusals as [Speed Up](https://docs.aureahub.com/docs/operation-speed-up.md) — custodial EVM wallets only, the operation's initiator only. - Nothing was sent yet? [Cancel it](https://docs.aureahub.com/docs/operation-cancel.md) instead (`409` `OPERATION_NOT_BROADCAST` here). ## Endpoint ### `POST /v1/operations/{id}/drop` Authentication: bearer token required. Replaces one's own custodial EVM operation's unmined transaction with a 0-value transaction to the wallet itself. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | The operation's id | **Headers** | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | no | Optional — required for a tenant on the institutional security profile (`400` `IDEMPOTENCY_KEY_REQUIRED` without it). Up to 255 letters, digits, - or _. The same key with the same request answers with the first answer (`idempotency-replayed: true`) instead of running it again; see [Idempotency](https://docs.aureahub.com/docs/idempotency.md) | **Responses** `403` Switched off ```json { "statusCode": 403, "error": "ForbiddenError", "message": "Replacing a transaction by fee is not available on this platform", "details": { "code": "OPERATIONS_RBF_OFF" } } ``` `200` Drop sent ```json { "operation": { "id": "8c2e5a71-4d9b-4f3e-a6c1-0b7d2e9f5a34", "type": "TRANSFER", "state": "CANCELLING", "subState": "drop_sent", "failureReason": null, "legacyStatus": "pending", "origin": "transactions.create", "userId": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "walletId": "7c1e9b24-3f5a-4d8e-9b6c-1a2f3e4d5c6b", "chain": "base", "chainId": 8453, "isTestnet": false, "asset": "native", "amount": "1000000000000000000", "destination": "0x8ba1f109551bD432803012645Ac136ddd64DBA72", "externalTxId": null, "idempotencyKey": null, "transactionId": "4b2e6f81-0c3d-4a5e-9f7b-8d1c2e3f4a5b", "preparedTxId": null, "providerRef": null, "tokenizedOperationId": null, "txHashes": [ "0x5c9e1b7a3d2f4e6c8b0a9d1e3f5c7b9a2d4e6f8a0c1b3d5e7f9a1c3e5b7d9f1a", "0x2b7e4d9a1c6f3e8b0d5a7c2f4e9b1d6a3c8f0e5b7d2a9c4f6e1b3d8a0c5f7e2b" ], "createdAt": "2026-10-07T12:01:44.120Z", "updatedAt": "2026-10-07T12:10:03.017Z", "finishedAt": null, "events": [ { "seq": 1, "state": "SUBMITTED", "subState": null, "failureReason": null, "txHash": null, "at": "2026-10-07T12:01:44.120Z" }, { "seq": 2, "state": "PENDING_SCREENING", "subState": "not_gated", "failureReason": null, "txHash": null, "at": "2026-10-07T12:01:44.120Z" }, { "seq": 3, "state": "PENDING_ENRICHMENT", "subState": "not_gated", "failureReason": null, "txHash": null, "at": "2026-10-07T12:01:44.120Z" }, { "seq": 4, "state": "PENDING_POLICY", "subState": "not_gated", "failureReason": null, "txHash": null, "at": "2026-10-07T12:01:44.120Z" }, { "seq": 5, "state": "PENDING_AUTHORIZATION", "subState": "not_gated", "failureReason": null, "txHash": null, "at": "2026-10-07T12:01:44.120Z" }, { "seq": 6, "state": "QUEUED", "subState": "not_gated", "failureReason": null, "txHash": null, "at": "2026-10-07T12:01:44.120Z" }, { "seq": 7, "state": "PENDING_SIGNATURE", "subState": "signing", "failureReason": null, "txHash": null, "at": "2026-10-07T12:01:44.131Z" }, { "seq": 8, "state": "SIGNED", "subState": "signed_by_hub", "failureReason": null, "txHash": null, "at": "2026-10-07T12:01:44.302Z" }, { "seq": 9, "state": "BROADCASTING", "subState": "sending", "failureReason": null, "txHash": null, "at": "2026-10-07T12:01:44.305Z" }, { "seq": 10, "state": "CONFIRMING", "subState": "awaiting_confirmation", "failureReason": null, "txHash": "0x5c9e1b7a3d2f4e6c8b0a9d1e3f5c7b9a2d4e6f8a0c1b3d5e7f9a1c3e5b7d9f1a", "at": "2026-10-07T12:01:44.588Z" }, { "seq": 11, "state": "CANCELLING", "subState": "drop_sent", "failureReason": null, "txHash": "0x2b7e4d9a1c6f3e8b0d5a7c2f4e9b1d6a3c8f0e5b7d2a9c4f6e1b3d8a0c5f7e2b", "at": "2026-10-07T12:10:03.017Z" } ] }, "replacement": { "kind": "drop", "txHash": "0x2b7e4d9a1c6f3e8b0d5a7c2f4e9b1d6a3c8f0e5b7d2a9c4f6e1b3d8a0c5f7e2b", "replaces": "0x5c9e1b7a3d2f4e6c8b0a9d1e3f5c7b9a2d4e6f8a0c1b3d5e7f9a1c3e5b7d9f1a", "nonce": 42, "maxFeePerGas": "3375000000", "maxPriorityFeePerGas": "1125000000" } } ``` `409` Not sent yet ```json { "statusCode": 409, "error": "ConflictError", "message": "Nothing of this operation was broadcast: cancel it instead (POST /v1/operations/{id}/cancel)", "details": { "code": "OPERATION_NOT_BROADCAST" } } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/operations/8c2e5a71-4d9b-4f3e-a6c1-0b7d2e9f5a34/drop \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ -H "Idempotency-Key: 3f1c9a7e-drop-1" ``` Web version: https://docs.aureahub.com/#operation-drop --- # Estimate a Chain's Fees LOW, MEDIUM and HIGH fee levels for one of your tenant's chains, from its recent fees. ## Overview - How the levels are made: [Fees and the Most You Can Send](https://docs.aureahub.com/docs/guide-operations.md). `model`: `eip1559`, `legacy` or `solana`. EVM fields are `null` on Solana and the other way round. - `chain`: the chain's slug as your tenant's chains name it (`ethereum`, `base`, `solana`…); `isTestnet` picks its test network. A chain your tenant doesn't have answers `404` `FEE_CHAIN_NOT_CONFIGURED`; a node that doesn't answer, `502` `FEE_ESTIMATE_UNAVAILABLE` (`The chain's node did not answer; try again`). - For every role of a tenant. At most 60 calls a minute. ## Endpoint ### `GET /v1/fees/estimate` Authentication: bearer token required. Fee levels for one of the tenant's chains. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `chain` | string | yes | The chain's slug | | `isTestnet` | boolean | no | Default `false` | **Responses** `200` EVM ```json { "chain": "base", "chainId": 8453, "isTestnet": false, "family": "evm", "model": "eip1559", "baseFeePerGas": "6000000", "levels": { "LOW": { "maxFeePerGas": "7750000", "maxPriorityFeePerGas": "1000000", "priorityFeeMicroLamports": null, "networkFee": "162750000000" }, "MEDIUM": { "maxFeePerGas": "10500000", "maxPriorityFeePerGas": "1500000", "priorityFeeMicroLamports": null, "networkFee": "220500000000" }, "HIGH": { "maxFeePerGas": "17000000", "maxPriorityFeePerGas": "5000000", "priorityFeeMicroLamports": null, "networkFee": "357000000000" } }, "estimatedAt": "2026-10-06T12:30:00.412Z" } ``` `404` Not your chain ```json { "statusCode": 404, "error": "NotFoundError", "message": "The tenant has no mainnet chain \"avalanche\"", "details": { "code": "FEE_CHAIN_NOT_CONFIGURED" } } ``` **Example request** ```bash curl "https://api.aureahub.com/v1/fees/estimate?chain=base" \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" ``` Web version: https://docs.aureahub.com/#fees-estimate --- # The Most a Wallet Can Send For one of your own wallets: how much of an asset it can send at a fee level. ## Overview - `asset`: `native` (the default), or a token's address — an SPL mint on Solana. `level`: `LOW`, `MEDIUM` (the default) or `HIGH` ([Estimate Fees](https://docs.aureahub.com/docs/fees-estimate.md)). - The native coin: `maxSpendable` is its balance minus `fee`, never below zero. A token: its whole balance, and `feeCovered` says whether the native balance pays the fee. Amounts in the smallest unit; `fee` is in the native coin's, the most it can cost on EVM. - Your own wallets only: another's, or one that doesn't exist, answers `404` `WALLET_NOT_FOUND` (`Wallet not found`). An asset that is neither `native` nor a token address of the wallet's chain: `400` `FEE_ASSET_INVALID`. A chain your tenant doesn't have: `404` `FEE_CHAIN_NOT_CONFIGURED`; a node that doesn't answer: `502` `FEE_ESTIMATE_UNAVAILABLE`. - At most 60 calls a minute. ## Endpoint ### `GET /v1/wallets/{id}/max-spendable` Authentication: bearer token required. The most one of the caller's wallets can send of an asset at a fee level. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | The wallet's id | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `asset` | string | no | `native` (default) or a token address | | `level` | string | no | `LOW`, `MEDIUM` (default) or `HIGH` | **Responses** `200` OK ```json { "walletId": "7c1e9b24-3f5a-4d8e-9b6c-1a2f3e4d5c6b", "chain": "base", "chainId": 8453, "isTestnet": false, "asset": "native", "level": "MEDIUM", "balance": "2500000000000000", "nativeBalance": "2500000000000000", "fee": "220500000000", "feeCovered": true, "maxSpendable": "2499779500000000" } ``` `400` Asset ```json { "statusCode": 400, "error": "BadRequestError", "message": "asset is \"native\" or a token address of the wallet's chain", "details": { "code": "FEE_ASSET_INVALID" } } ``` Web version: https://docs.aureahub.com/#wallet-max-spendable --- # Validate an Address Whether an address is well formed for a chain, its checksum, and what sits there. ## Overview - What each field means: [Address Checks](https://docs.aureahub.com/docs/guide-operations.md). An address that is not valid answers `200` with `valid: false`, `normalized: null`, `kind: null` and `reason` `format` or `checksum`. - `chain` must be one of your tenant's (`404` `FEE_CHAIN_NOT_CONFIGURED`). For every role of a tenant; at most 60 calls a minute. ## Endpoint ### `POST /v1/addresses/validate` Authentication: bearer token required. Checks an address for one of the tenant's chains. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `chain` | string | yes | The chain's slug | | `isTestnet` | boolean | no | Default `false` | | `address` | string | yes | The address, 1–128 characters | **Responses** `200` No checksum ```json { "chain": "base", "chainId": 8453, "isTestnet": false, "family": "evm", "valid": true, "normalized": "0x8ba1f109551bD432803012645Ac136ddd64DBA72", "checksum": "absent", "kind": "eoa", "reason": null } ``` `200` Bad checksum ```json { "chain": "base", "chainId": 8453, "isTestnet": false, "family": "evm", "valid": false, "normalized": null, "checksum": "invalid", "kind": null, "reason": "checksum" } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/addresses/validate \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "chain": "base", "address": "0x8ba1f109551bd432803012645ac136ddd64dba72" }' ``` Web version: https://docs.aureahub.com/#address-validate --- # 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 route from Aurea's swap router for a wallet, together with the `transactionData` you will execute. ## Overview Aurea requests the quote from its swap router 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 swap router supports no testnet chain, 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 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 the swap router 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 the swap router. | | `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` — the router'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 the router returns them. - `fromAmount`, `toAmount`, `toAmountMin` — the router's estimate, as strings. - `tool` — the bridge or exchange the route uses, as the router names it. - `approvalAddress` — the spender to approve. `null` for native source tokens, for Solana sources, and when the router 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 the router estimates (`"0"` when it 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` — the router'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` when a chain of the pair is not one the swap router supports — on testnet, none is: `Swaps are not available on mainnet chain IDs: … Supported mainnet chain IDs: …`, or `Swaps are not available on testnet chain IDs: … Swaps run on mainnet. Supported mainnet chain IDs: …`. The IDs are numeric chain IDs as you use them (Solana: `1399811149`). - `400` `Swaps are not set up for this tenant.` — Aurea has not set up swaps for your tenant: [contact us](https://www.aureahub.com/contact). - `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. …` — the router has no route for the pair. The router's other 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 the swap router 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`. > ℹ️ **Institutional tenants only.** For a tenant on the institutional security profile, this route needs an `Idempotency-Key`, and the tenant's policy decides before anything is signed or handed out to sign. A rule that asks for an approval holds the operation: the route answers `202` with the operation to follow (the **202** tab below), and nothing is signed or sent until the rule's approvers approve it — [Approvals](https://docs.aureahub.com/docs/guide-approvals.md). The policy's other refusals: [Policy Sets](https://docs.aureahub.com/docs/guide-policy-sets.md). Every other tenant gets the answers below, unchanged. > ℹ️ **Exact approvals, where Aurea enables them for your tenant:** `amount` is required — not unlimited (`400` `APPROVAL_AMOUNT_REQUIRED`), `"0"` revokes —; the spender must be on the allowlist of the contracts Aurea approves (`403` `APPROVAL_SPENDER_NOT_ALLOWED`); a client-side or MPC wallet resets an allowance left for that spender to `"0"` first where the token needs it (`409` `APPROVAL_RESET_REQUIRED`). Send the swap's amount now: until then it changes nothing. [Signing Rules](https://docs.aureahub.com/docs/guide-signing-rules.md). ## 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. **Headers** | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | no | Optional — required for a tenant on the institutional security profile (`400` `IDEMPOTENCY_KEY_REQUIRED` without it). Up to 255 letters, digits, - or _. The same key with the same request answers with the first answer (`idempotency-replayed: true`) instead of running it again; see [Idempotency](https://docs.aureahub.com/docs/idempotency.md) | **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" } ``` `202` Held for approval ```json { "operationId": "1d7a3c52-8e4b-4f19-a0c6-2b9e7d5f3a18", "state": "PENDING_AUTHORIZATION", "subState": "awaiting_approval", "approval": { "id": "c3e1a9f4-6b2d-4e8a-9f17-5d0c2b8e4a63", "ruleId": "e2b6d9f1-7a3c-4c58-8b0e-5f1a4d2c9b73", "ruleName": "Large transfers need two approvers", "threshold": 2, "expiresAt": null } } ``` `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. > ⚠️ Without an `Idempotency-Key`, requests are not de-duplicated: every call signs and sends (or prepares) a new transaction. Send a key and retry with it, or, without one, check [List Transactions](https://docs.aureahub.com/docs/tx-list.md) before retrying ([Idempotency](https://docs.aureahub.com/docs/idempotency.md)). ## 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`, and a wallet that is not a Solana wallet with `404` `Wallet not found`. > ⚠️ **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`. > ℹ️ **Institutional tenants only.** For a tenant on the institutional security profile, this route needs an `Idempotency-Key`, and the tenant's policy decides before anything is signed or handed out to sign. A rule that asks for an approval holds the operation: the route answers `202` with the operation to follow (the **202** tab below), and nothing is signed or sent until the rule's approvers approve it — [Approvals](https://docs.aureahub.com/docs/guide-approvals.md). The policy's other refusals: [Policy Sets](https://docs.aureahub.com/docs/guide-policy-sets.md). Every other tenant gets the answers below, unchanged. > ℹ️ **Swaps built on the server, where Aurea enables them for your tenant:** an EVM swap is signed from the route Aurea kept with its quote, not from the transaction data you send — execute a quote you asked for, for this wallet, while it is valid (`409` `SWAP_QUOTE_UNKNOWN`), whose router is on Aurea's allowlist (`403` `SWAP_ROUTER_NOT_ALLOWED`). A custodial swap's unused allowance is revoked once it is mined, with exact approvals on. [Signing Rules](https://docs.aureahub.com/docs/guide-signing-rules.md). ## Endpoint ### `POST /v1/swap/execute` Authentication: bearer token required. Executes the transaction data of a quote from a wallet. **Headers** | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | no | Optional — required for a tenant on the institutional security profile (`400` `IDEMPOTENCY_KEY_REQUIRED` without it). Up to 255 letters, digits, - or _. The same key with the same request answers with the first answer (`idempotency-replayed: true`) instead of running it again; see [Idempotency](https://docs.aureahub.com/docs/idempotency.md) | **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": "…" } ``` `202` Held for approval ```json { "operationId": "1d7a3c52-8e4b-4f19-a0c6-2b9e7d5f3a18", "state": "PENDING_AUTHORIZATION", "subState": "awaiting_approval", "approval": { "id": "c3e1a9f4-6b2d-4e8a-9f17-5d0c2b8e4a63", "ruleId": "e2b6d9f1-7a3c-4c58-8b0e-5f1a4d2c9b73", "ruleName": "Large transfers need two approvers", "threshold": 2, "expiresAt": null } } ``` `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 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 the swap router 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 router 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 router, 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. > ℹ️ **Institutional tenants only.** For a tenant on the institutional security profile, this route needs an `Idempotency-Key`, and the tenant's policy decides before anything is signed or handed out to sign. A rule that asks for an approval holds the operation: the route answers `202` with the operation to follow (the **202** tab below), and nothing is signed or sent until the rule's approvers approve it — [Approvals](https://docs.aureahub.com/docs/guide-approvals.md). The policy's other refusals: [Policy Sets](https://docs.aureahub.com/docs/guide-policy-sets.md). Every other tenant gets the answers below, unchanged. > ℹ️ **Swaps built on the server, where Aurea enables them for your tenant:** the permit is checked against the route Aurea kept with the quote — its target, amount and token —, and the quote must be yours, for this wallet, still valid (`409` `SWAP_QUOTE_UNKNOWN`), with a router on Aurea's allowlist (`403` `SWAP_ROUTER_NOT_ALLOWED`). [Signing Rules](https://docs.aureahub.com/docs/guide-signing-rules.md). ## Endpoint ### `POST /v1/swap/execute-gasless` Authentication: bearer token required. Executes an EUR.e to xDAI swap on Gnosis using an ERC-2612 permit. **Headers** | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | no | Optional — required for a tenant on the institutional security profile (`400` `IDEMPOTENCY_KEY_REQUIRED` without it). Up to 255 letters, digits, - or _. The same key with the same request answers with the first answer (`idempotency-replayed: true`) instead of running it again; see [Idempotency](https://docs.aureahub.com/docs/idempotency.md) | **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": "…" } ``` `202` Held for approval ```json { "operationId": "1d7a3c52-8e4b-4f19-a0c6-2b9e7d5f3a18", "state": "PENDING_AUTHORIZATION", "subState": "awaiting_approval", "approval": { "id": "c3e1a9f4-6b2d-4e8a-9f17-5d0c2b8e4a63", "ruleId": "e2b6d9f1-7a3c-4c58-8b0e-5f1a4d2c9b73", "ruleName": "Large transfers need two approvers", "threshold": 2, "expiresAt": null } } ``` `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 the swap router 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. - **A signature from another key spends nothing:** it is refused with `422` `WRONG_SIGNER`, and the same `preparedTxId` with the right signature still goes through, once. - **The node's refusal** (a nonce already used, a transaction already known, a fee too low…) answers `422` with `code` `RPC_ERROR`, the node's own message and `details.rpcCode`, the node's error code. The prepared transaction has been used: call Execute Swap again, with a fresh quote if the window has closed. - **A newer preparation** for the same wallet on the same chain — a send, an approval or another swap — made while this one was unsigned took its nonce: `409` `PREPARED_TX_SUPERSEDED`, nothing is spent. - **Institutional tenants only:** this route needs an `Idempotency-Key`, and the tenant's policy decides again before the transaction is sent. It never holds anything: a rule asking for an approval refuses with `403` `POLICY_APPROVAL_REQUIRED`, unless the operation was approved for that rule ([Approvals](https://docs.aureahub.com/docs/guide-approvals.md), [Policy Sets](https://docs.aureahub.com/docs/guide-policy-sets.md)). ## Endpoint ### `POST /v1/swap/broadcast` Authentication: bearer token required. Validates a client signature for a prepared swap transaction and broadcasts it. **Headers** | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | no | Optional — required for a tenant on the institutional security profile (`400` `IDEMPOTENCY_KEY_REQUIRED` without it). Up to 255 letters, digits, - or _. The same key with the same request answers with the first answer (`idempotency-replayed: true`) instead of running it again; see [Idempotency](https://docs.aureahub.com/docs/idempotency.md) | **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 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 } } ``` `422` Node Refused ```json { "statusCode": 422, "error": "BlockchainError", "message": "nonce too low: next nonce 8, tx nonce 7", "code": "RPC_ERROR", "retryable": false, "details": { "code": "RPC_ERROR", "retryable": false, "rpcCode": -32000 } } ``` The node's message and code above are an example: they are whatever the network's node answered. ## 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. `409` `PREPARED_TX_SUPERSEDED` — `A newer transaction was prepared for this wallet: this one can no longer be broadcast`. - `409` `OPERATION_CANCELLED` — its operation was cancelled by its initiator ([Cancel an Operation](https://docs.aureahub.com/docs/operation-cancel.md)): `This operation was cancelled: what it prepared can no longer be broadcast`, with `details.operationId`; from the prepared transaction itself, `409` `PREPARED_TX_CANCELLED` (`This transaction's operation was cancelled: it can no longer be broadcast`). Nothing is spent: prepare a new one. - `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; nothing is spent. `422` `GAS_CAP_EXCEEDED` — the prepared `maxFeePerGas` is above the cap (500 Gwei by default). `422` `RPC_ERROR` — the node refused the transaction. ## 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`. > ℹ️ **Where Aurea enables the signing rules for your tenant:** the permit's EIP-712 domain must be one Aurea builds or allows (`403` `MESSAGE_DOMAIN_NOT_ALLOWED`), and its spender on the allowlist of the contracts Aurea approves (`403` `APPROVAL_SPENDER_NOT_ALLOWED`). [Signing Rules](https://docs.aureahub.com/docs/guide-signing-rules.md). ## 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. - **A signature from another key spends nothing:** it is refused with `422` `WRONG_SIGNER`, and the same `preparedTxId` with the right signature still goes through, once. - **The node's refusal** (a nonce already used, a transaction already known, a fee too low…) answers `422` with `code` `RPC_ERROR`, the node's own message and `details.rpcCode`. The prepared transaction has been used: call Approve Token again to prepare a new one. ## Endpoint ### `POST /v1/swap/broadcast-approve` Authentication: bearer token required. Validates a client signature for a prepared ERC-20 approval and broadcasts the transaction. **Headers** | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | no | Optional — required for a tenant on the institutional security profile (`400` `IDEMPOTENCY_KEY_REQUIRED` without it). Up to 255 letters, digits, - or _. The same key with the same request answers with the first answer (`idempotency-replayed: true`) instead of running it again; see [Idempotency](https://docs.aureahub.com/docs/idempotency.md) | **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 } } ``` `422` Node Refused ```json { "statusCode": 422, "error": "BlockchainError", "message": "nonce too low: next nonce 8, tx nonce 7", "code": "RPC_ERROR", "retryable": false, "details": { "code": "RPC_ERROR", "retryable": false, "rpcCode": -32000 } } ``` The node's message and code above are an example: they are whatever the network's node answered. ## 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. `409` `PREPARED_TX_SUPERSEDED` — a newer send, approval or swap was prepared for the same wallet on the same chain while this one was unsigned (`A newer transaction was prepared for this wallet: this one can no longer be broadcast`); nothing is spent. - `409` `OPERATION_CANCELLED` — its operation was cancelled by its initiator ([Cancel an Operation](https://docs.aureahub.com/docs/operation-cancel.md)): `This operation was cancelled: what it prepared can no longer be broadcast`, with `details.operationId`; from the prepared transaction itself, `409` `PREPARED_TX_CANCELLED` (`This transaction's operation was cancelled: it can no longer be broadcast`). Nothing is spent: prepare a new one. - `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; nothing is spent. `422` `GAS_CAP_EXCEEDED` — the prepared `maxFeePerGas` is above the cap (500 Gwei by default). `422` `RPC_ERROR` — the node refused the transaction. - **Institutional tenants only:** this route needs an `Idempotency-Key`, and the tenant's policy decides again before the transaction is sent. It never holds anything: a rule asking for an approval refuses with `403` `POLICY_APPROVAL_REQUIRED`, unless the operation was approved for that rule ([Approvals](https://docs.aureahub.com/docs/guide-approvals.md), [Policy Sets](https://docs.aureahub.com/docs/guide-policy-sets.md)). 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. **Headers** | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | no | Optional — required for a tenant on the institutional security profile (`400` `IDEMPOTENCY_KEY_REQUIRED` without it). Up to 255 letters, digits, - or _. The same key with the same request answers with the first answer (`idempotency-replayed: true`) instead of running it again; see [Idempotency](https://docs.aureahub.com/docs/idempotency.md) | **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. `409` `PREPARED_TX_SUPERSEDED` — a newer send, approval or swap was prepared for the same wallet on Gnosis while this one was unsigned (`A newer transaction was prepared for this wallet: this one can no longer be broadcast`). - `409` `OPERATION_CANCELLED` — its operation was cancelled by its initiator ([Cancel an Operation](https://docs.aureahub.com/docs/operation-cancel.md)): `This operation was cancelled: what it prepared can no longer be broadcast`, with `details.operationId`. Nothing is spent: start a new swap. - `410` `PREPARED_TX_EXPIRED` — more than 5 minutes after Execute Gasless. - `422` `SIGNATURE_DECODE_FAILED`, `WRONG_SIGNER`, or `GAS_CAP_EXCEEDED` — the prepared `maxFeePerGas` is above the cap (500 Gwei by default). - **Institutional tenants only:** this route needs an `Idempotency-Key`, and the tenant's policy decides again before the transaction is sent. It never holds anything: a rule asking for an approval refuses with `403` `POLICY_APPROVAL_REQUIRED`, unless the operation was approved for that rule ([Approvals](https://docs.aureahub.com/docs/guide-approvals.md), [Policy Sets](https://docs.aureahub.com/docs/guide-policy-sets.md)). ## 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 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"`, the quote carries 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`. - **The wallet must be a Solana wallet.** An EVM wallet named for a swap on `solana` answers `404` `Wallet not found`, as a wallet that does not exist does. - `quoteExpiry` and `gasless` have no effect on this path. > ℹ️ **Institutional tenants only.** For a tenant on the institutional security profile, this route needs an `Idempotency-Key`, and the tenant's policy decides before anything is signed or handed out to sign. A rule that asks for an approval holds the operation: the route answers `202` with the operation to follow (the **202** tab below), and nothing is signed or sent until the rule's approvers approve it — [Approvals](https://docs.aureahub.com/docs/guide-approvals.md). The policy's other refusals: [Policy Sets](https://docs.aureahub.com/docs/guide-policy-sets.md). Every other tenant gets the answers below, unchanged. ## 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. **Headers** | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | no | Optional — required for a tenant on the institutional security profile (`400` `IDEMPOTENCY_KEY_REQUIRED` without it). Up to 255 letters, digits, - or _. The same key with the same request answers with the first answer (`idempotency-replayed: true`) instead of running it again; see [Idempotency](https://docs.aureahub.com/docs/idempotency.md) | **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": "" } ``` `202` Held for approval ```json { "operationId": "1d7a3c52-8e4b-4f19-a0c6-2b9e7d5f3a18", "state": "PENDING_AUTHORIZATION", "subState": "awaiting_approval", "approval": { "id": "c3e1a9f4-6b2d-4e8a-9f17-5d0c2b8e4a63", "ruleId": "e2b6d9f1-7a3c-4c58-8b0e-5f1a4d2c9b73", "ruleName": "Large transfers need two approvers", "threshold": 2, "expiresAt": null } } ``` `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 `The bank ramp 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. - 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 the bank ramp in 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; `aureaWallets`, whether Aurea wallets live on its network (when `false`, deliver to a proven address or a wallet your server attests); 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", "aureaWallets": true, "sourceCurrencies": ["eur", "usd"] }, { "name": "usdc/solana", "currency": "usdc", "network": "solana", "aureaChain": "solana", "family": "solana", "aureaWallets": true, "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 estimate of how much crypto an amount buys (`destinationAmount`) for what the user pays in total, fees included (`sourceTotalAmount`), with the transaction fee and the network fee, from the payment provider a session of that pair would go to — where two sell it, the one that gives more crypto for what is paid. Compare pairs and providers by `destinationAmount` against `sourceTotalAmount`. 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`, 100 is quoted. A pair a payment provider could not price now is left out. - 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. In the first payment provider's widget it only suggests an amount the user can change; in the second's, the fiat currency and a `sourceAmount` are fixed — see [Card to Crypto](https://docs.aureahub.com/docs/guide-card-onramp.md). - 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. - The session goes to the payment provider that sells the pair in the currency — where two do, the one that gives more crypto for what is paid; where the first cannot serve the user's country, the second, if it sells the pair in that currency. A session whose provider's widget opens only in the hosted page answers `clientSecret` and `publishableKey` `null`: open it with [Hosted Page Link](https://docs.aureahub.com/docs/card-onramp-hosted-link.md). Such a session is created once that provider priced exactly its pair, currency and amount (or 100 of the currency, without an amount); its refusal fails the session like any other below. - 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` (a watch-only wallet, a wallet of the other family, or an Aurea wallet on a network whose `aureaWallets` is `false`), `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", "aureaWallets": true, "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", "aureaWallets": true, "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` — and always for a session whose payment provider's widget opens only in the hosted page (open it again with a new [Hosted Page Link](https://docs.aureahub.com/docs/card-onramp-hosted-link.md)). - `providerSessionId` is the payment provider's own id, to quote to support; for a provider that makes an order each time its widget is used, it is the latest order's, `null` until the user has made one. - 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: in the first payment provider's widget the user can switch it). - 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", "aureaWallets": true, "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`, `networkName` as the page names it, and `explorerTx`, the explorer's transaction page it links a live delivery to, `null` in the sandbox), `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 in a widget the page mounts itself, the return URL, the page's locale and theme, `poweredBy` (the payment provider the page names), and your tenant's `name`, `logoUrl` and `primaryColor` (`null` when unset or not a `#rrggbb` value). - With `widget: true` — the page's first read only — a session whose payment provider's widget runs in a frame answers `widgetUrl`: made for the address the request came from, valid five minutes and once, once the payment provider priced exactly the session's pair, currency and amount. None is made while a payment of the session is under way. Where the payment provider no longer sells them, the read answers its refusal (`400` `CARD_ONRAMP_INVALID_REQUEST`) and the session stays open. - `404` `CARD_ONRAMP_LINK_NOT_FOUND` for a token no link has, `410` `CARD_ONRAMP_LINK_EXPIRED` after 30 minutes, `403` `CARD_ONRAMP_ORIGIN_NOT_ALLOWED` for a request a browser sends from another site's page, `400` `CARD_ONRAMP_CUSTOMER_IP_REQUIRED` for a widget asked from no public address. 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 | | `widget` | boolean | no | `true` on the page's first read: makes the widget of a payment provider that runs in a frame | **Responses** `200` OK ```json { "session": { "id": "444417bc-0675-4d3a-8831-bd1cbedc9738", "status": "requires_payment", "environment": "sandbox", "pair": { "currency": "usdc", "network": "ethereum", "networkName": "Ethereum", "explorerTx": null }, "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", "widgetUrl": null, "poweredBy": "Stripe", "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. - With the first payment provider the amount only suggests one: the user can change it, and the currency, in the widget. With the second, the currency and a `sourceAmount` are fixed. The session is locked to the wallet and the network either way. - 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 --- # Earn Products The Earn products your tenant offers on a network, each with its state and rate on every chain it is on. ## Overview - Only the products your tenant switched on for that network are listed: until Aurea switches one on, `products` is empty ([Earn](https://docs.aureahub.com/docs/guide-earn.md)). - Each venue is read now: `observedAt` is when the state and the rate were true, and `fresh` whether a deposit or a withdrawal may be decided on them. `readable: false` when the chain could not be read; `apy` is in percent. - `status` is the product's own (`active`, `paused`, `retired`); `acceptsDeposits` whether it takes new deposits. `limits` are your tenant's per deposit, in the asset's smallest unit (`null`: no limit). `disclosureVersion` is the version the user must have accepted before depositing. - User token, and the tenant signature or a server token ([Calling the Earn Routes](https://docs.aureahub.com/docs/guide-earn.md)). ## Endpoint ### `GET /v1/earn/products` Authentication: bearer token required. The Earn products the tenant offers on the network. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `isTestnet` | string | no | `true` testnet, `false` (default) mainnet | **Responses** `200` OK ```json { "isTestnet": false, "products": [ { "productId": "lumen", "name": "Lumen", "status": "active", "acceptsDeposits": true, "asset": { "symbol": "EUR.e" }, "disclosureVersion": 1, "limits": { "depositMinAmount": "1000000000000000000", "depositMaxAmount": null }, "venues": [ { "chain": "gnosis", "chainId": 100, "assetAddress": "0xcB44…C56E", "readable": true, "state": "active", "apy": 2.41, "apyBasisPoints": 241, "rateSource": "onchain", "observedAt": "2026-10-05T10:00:05.000Z", "fresh": true, "assetDecimals": 18 } ] } ] } ``` `200` Nothing Switched On ```json { "isTestnet": false, "products": [] } ``` Web version: https://docs.aureahub.com/#earn-products --- # A Product's Disclosure The text the user reads, and accepts, before their first deposit into a product. ## Overview - The product's current disclosure — its risks, and that its yield is variable and read at the time stated with the product — in the language asked for, or in English when there is none in that language. `body` is Markdown. - `accepted` says whether the user accepted this version. A deposit needs the current version accepted (`409` `EARN_DISCLOSURE_NOT_ACCEPTED`); accept it with [Accept a Disclosure](https://docs.aureahub.com/docs/earn-disclosure-accept.md), sending back `version`, `language` and `sha256` exactly as answered here. - A product whose disclosure Aurea has not published answers `409` `EARN_DISCLOSURE_NOT_PUBLISHED`, and takes no deposit. A `productId` that is no product answers `404` `EARN_PRODUCT_NOT_FOUND`. ## Endpoint ### `GET /v1/earn/products/{productId}/disclosure` Authentication: bearer token required. The product's current disclosure, and whether the user accepted it. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `productId` | string | yes | The product's identifier, for example `lumen` | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `language` | string | no | Two-letter language code; default `en` | **Responses** `200` OK ```json { "productId": "lumen", "version": 1, "language": "en", "title": "…", "body": "…", "sha256": "9f2c…64 hex characters…", "publishedAt": "2026-10-05T09:00:00.000Z", "accepted": false } ``` `409` Not Published ```json { "statusCode": 409, "error": "ConflictError", "message": "This product's disclosure is not published yet.", "details": { "code": "EARN_DISCLOSURE_NOT_PUBLISHED", "productId": "lumen" } } ``` Web version: https://docs.aureahub.com/#earn-disclosure --- # Accept a Product's Disclosure Records that the user accepted the disclosure they were shown. ## Overview - Send `version`, `language` and `sha256` exactly as [Disclosure](https://docs.aureahub.com/docs/earn-disclosure.md) answered them. Anything but the current text — an old version, another hash, a language that fell back to English — answers `409` `EARN_DISCLOSURE_VERSION_MISMATCH` (`This is not the product's current disclosure: read it again and accept that one.`), and nothing is recorded. - `201` the first time; accepting the same version again answers `200` with the first `acceptedAt`. - An acceptance belongs to the user and the version: a new version asks the user again. ## Endpoint ### `POST /v1/earn/products/{productId}/disclosure/acceptances` Authentication: bearer token required. Records the user's acceptance of the product's current disclosure. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `productId` | string | yes | The product's identifier | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `version` | integer | yes | The version the user read | | `language` | string | yes | Two-letter language code of the text the user read | | `sha256` | string | yes | The `sha256` of the text the user read, as the disclosure route answered it | **Responses** `201` Accepted ```json { "productId": "lumen", "version": 1, "language": "en", "acceptedAt": "2026-10-05T10:02:11.000Z" } ``` `409` Not the Current Text ```json { "statusCode": 409, "error": "ConflictError", "message": "This is not the product's current disclosure: read it again and accept that one.", "details": { "code": "EARN_DISCLOSURE_VERSION_MISMATCH", "productId": "lumen" } } ``` Web version: https://docs.aureahub.com/#earn-disclosure-accept --- # Quote a Deposit or a Withdrawal What a deposit or a withdrawal would do, before the user commits to it. ## Overview - The same checks as the write — the product switched on (`403` `EARN_PRODUCT_OFF`), the route and the chain, the limits, the wallet — then the route, who signs (`server` or `client`), the wallet, `expectedAmount`, `fee`, what the wallet or the position holds (`available`) and the exact approval a deposit needs first (`approvalAmount`, or `null`). - `reading` is the product's state and rate on that chain, with its age: a quote answers them instead of refusing a stale reading. - `amount` is `null` in the answer for a withdrawal of `max`. A product with share tokens adds `shares`; the bridge route adds `nativeFee`, the fee paid with the transaction in the chain's native token. ## Endpoint ### `GET /v1/earn/quote` Authentication: bearer token required. What a deposit or a withdrawal would do. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `productId` | string | yes | The product's identifier | | `side` | string | yes | `deposit` or `withdrawal` | | `amount` | string | yes | In the asset's smallest unit; a withdrawal may say `max` | | `isTestnet` | string | no | `true` testnet, `false` (default) mainnet | | `walletId` | string | no | One of the user's EVM wallets; without it, the first one somebody can sign for | | `route` | string | no | `direct`, `bridge` or `secondary_market`; without it, the first open to the wallet | | `chainId` | integer | no | The chain to quote on; without it, the one the wallet is on | **Responses** `200` OK ```json { "productId": "lumen", "side": "deposit", "route": "direct", "signer": "client", "walletId": "7c1e…", "walletAddress": "0x2f4B…", "chain": "gnosis", "chainId": 100, "assetAddress": "0xcB44…C56E", "amount": "50000000000000000000", "expectedAmount": "50000000000000000000", "fee": "0", "available": "120000000000000000000", "approvalAmount": "50000000000000000000", "reading": { "chain": "gnosis", "chainId": 100, "assetAddress": "0xcB44…C56E", "readable": true, "state": "active", "apy": 2.41, "apyBasisPoints": 241, "rateSource": "onchain", "observedAt": "2026-10-05T10:00:05.000Z", "fresh": true, "assetDecimals": 18 } } ``` `403` Not Offered ```json { "statusCode": 403, "error": "ForbiddenError", "message": "This product is not offered on this network.", "details": { "code": "EARN_PRODUCT_OFF", "productId": "lumen" } } ``` Web version: https://docs.aureahub.com/#earn-quote --- # Deposit into a Product Deposits an amount of the product's asset from one of the user's wallets. ## Overview - Your tenant must offer the product on the network and the product take deposits (`403` `EARN_PRODUCT_OFF`, `409` `EARN_PRODUCT_PAUSED`); your tenant's limits apply (`400` `EARN_AMOUNT_OUT_OF_LIMITS`) and the wallet must hold the amount (`400` `EARN_INSUFFICIENT_BALANCE`). - The user must have accepted the product's current disclosure: `409` `EARN_DISCLOSURE_NOT_PUBLISHED` while none is published (`This product takes no deposit yet: its disclosure is not published.`), `409` `EARN_DISCLOSURE_NOT_ACCEPTED` with its `version` until the user accepts it ([Disclosure](https://docs.aureahub.com/docs/earn-disclosure.md)). - The approval, when one is needed, is of exactly the amount. Before anything is built the product is read again ([Readings and Limits](https://docs.aureahub.com/docs/guide-earn.md)). - A custodial wallet: Aurea signs and sends at once and answers the operation `completed`, `failed`, or `broadcast` when a receipt is late. A client-side or MPC wallet: the operation is answered `submitted` with `signing.steps` — see below. - `201` with the operation. An optional `Idempotency-Key` makes a retry safe ([Retries](https://docs.aureahub.com/docs/guide-earn.md)). ## Endpoint ### `POST /v1/earn/deposits` Authentication: bearer token required. Deposits an amount of the product's asset from one of the user's wallets. **Headers** | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | no | Optional. Up to 255 letters, digits, - or _; see [Retries](https://docs.aureahub.com/docs/guide-earn.md) | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `productId` | string | yes | The product's identifier | | `amount` | string | yes | In the asset's smallest unit, as a string of digits | | `isTestnet` | boolean | yes | `true` testnet, `false` mainnet | | `walletId` | string | no | One of the user's EVM wallets on the network; without it, the first one somebody can sign for | | `route` | string | no | `direct`, `bridge` or `secondary_market` ([Routes and Chains](https://docs.aureahub.com/docs/guide-earn.md)) | | `chainId` | integer | no | The chain to operate on; without it, the one the wallet is on | **Responses** `201` Awaiting Signatures ```json { "operationId": "3f9a…", "productId": "lumen", "kind": "deposit", "route": "direct", "signer": "client", "state": "submitted", "isTestnet": false, "walletId": "7c1e…", "walletAddress": "0x2f4B…", "chain": "gnosis", "chainId": 100, "assetAddress": "0xcB44…C56E", "amount": "50000000000000000000", "withdrawAll": false, "quote": { "apy": 2.41, "apyBasisPoints": 241, "rateSource": "onchain", "observedAt": "2026-10-05T10:00:05.000Z", "expectedAmount": "50000000000000000000", "fee": "0", "assetDecimals": 18 }, "txHashes": [], "failureCode": null, "signing": { "walletAddress": "0x2f4B…", "expiresAt": "2026-10-05T10:16:00.000Z", "steps": [ { "stepIndex": 0, "purpose": "approve", "amount": "50000000000000000000", "counterparty": "0x…", "txHashToSign": "0x…", "to": "0xcB44…C56E", "data": "0x095ea7b3…", "value": "0", "chainId": 100, "nonce": 12, "gasLimit": "60000", "maxFeePerGas": "3000000000", "maxPriorityFeePerGas": "1000000000" }, { "stepIndex": 1, "purpose": "deposit", "amount": "50000000000000000000", "counterparty": "0x…", "txHashToSign": "0x…", "to": "0x…", "data": "0x…", "value": "0", "chainId": 100, "nonce": 13, "gasLimit": "300000", "maxFeePerGas": "3000000000", "maxPriorityFeePerGas": "1000000000" } ] }, "createdAt": "2026-10-05T10:01:00.000Z", "updatedAt": "2026-10-05T10:01:00.000Z", "broadcastAt": null, "completedAt": null, "failedAt": null } ``` `409` Disclosure Not Accepted ```json { "statusCode": 409, "error": "ConflictError", "message": "Accept the product's disclosure before depositing: GET /v1/earn/products/{productId}/disclosure, then POST its acceptance.", "details": { "code": "EARN_DISCLOSURE_NOT_ACCEPTED", "productId": "lumen", "version": 1 } } ``` ## Signing the Steps For a `signer: "client"` operation, sign each step's `txHashToSign` — the hash itself, with no message prefix — with the wallet, and send the signatures in the order of the steps to [Send Signatures](https://docs.aureahub.com/docs/earn-broadcast.md) before `signing.expiresAt`. An MPC wallet signs each hash with the signing ceremony ([Start Signing Ceremony](https://docs.aureahub.com/docs/mpc-sign-start.md)). ```javascript import { SigningKey } from 'ethers'; // operation = answer of POST /v1/earn/deposits with signer 'client' function signSteps(operation, privateKey) { const key = new SigningKey(privateKey); return operation.signing.steps.map((step) => { const sig = key.sign(step.txHashToSign); return { v: sig.yParity === 1 ? '0x1' : '0x0', r: sig.r, s: sig.s }; }); } ``` Web version: https://docs.aureahub.com/#earn-deposit --- # Withdraw from a Product Withdraws an amount, or the whole position, to the wallet the position is held by. ## Overview - `amount` in the asset's smallest unit, or `"max"` for the whole position (the operation then has `withdrawAll: true` and `amount: null`). The position must hold it (`400` `EARN_INSUFFICIENT_POSITION`). - A product your tenant switched off, or one that is paused, still pays out. A withdrawal never waits for a disclosure. - A product that pays withdrawals later answers the operation `completed` once the request is made, with a `redemption` that follows it until it is paid ([Withdrawals and Redemptions](https://docs.aureahub.com/docs/guide-earn.md)); `secondary_market` sells the shares at once. - Who signs, the answer and the retries are as for a [Deposit](https://docs.aureahub.com/docs/earn-deposit.md). ## Endpoint ### `POST /v1/earn/withdrawals` Authentication: bearer token required. Withdraws an amount, or the whole position, to the wallet the position is held by. **Headers** | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | no | Optional. Up to 255 letters, digits, - or _; see [Retries](https://docs.aureahub.com/docs/guide-earn.md) | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `productId` | string | yes | The product's identifier | | `amount` | string | yes | In the asset's smallest unit, or `max` | | `isTestnet` | boolean | yes | `true` testnet, `false` mainnet | | `walletId` | string | no | The wallet holding the position; without it, the first one somebody can sign for | | `route` | string | no | `direct`, `bridge` or `secondary_market` | | `chainId` | integer | no | The chain to operate on; without it, the one the wallet is on | **Responses** `201` Completed (custodial) ```json { "operationId": "8b2d…", "productId": "lumen", "kind": "withdrawal", "route": "direct", "signer": "server", "state": "completed", "isTestnet": false, "walletId": "5a90…", "walletAddress": "0x9C1d…", "chain": "gnosis", "chainId": 100, "assetAddress": "0xcB44…C56E", "amount": null, "withdrawAll": true, "quote": { "apy": 2.41, "apyBasisPoints": 241, "rateSource": "onchain", "observedAt": "2026-10-05T11:00:05.000Z", "expectedAmount": "50012000000000000000", "fee": "0", "assetDecimals": 18 }, "txHashes": ["0x…"], "failureCode": null, "signing": null, "createdAt": "2026-10-05T11:00:10.000Z", "updatedAt": "2026-10-05T11:00:22.000Z", "broadcastAt": "2026-10-05T11:00:12.000Z", "completedAt": "2026-10-05T11:00:22.000Z", "failedAt": null } ``` Web version: https://docs.aureahub.com/#earn-withdraw --- # Send an Operation's Signatures The client's signatures of an operation answered `submitted`; Aurea sends the steps exactly as it built them. ## Overview - One signature per step, in the order of the steps (`400` `EARN_SIGNATURES_MISMATCH` otherwise). Each is checked against the operation's wallet (`422` `EARN_SIGNATURE_INVALID`); the product is read again, then the steps are sent. You send signatures only, never a transaction. - `v` is the y parity, `0x0` or `0x1` (27 and 28 are read as 0 and 1); `r` and `s` are `0x` + 64 hex characters. - `404` `EARN_OPERATION_NOT_FOUND` (`No such operation`); `409` `EARN_OPERATION_NOT_AWAITING_SIGNATURES` for an operation in another state; `410` `EARN_SIGNING_EXPIRED` past `signing.expiresAt` (the operation is then `expired`: start it again); `400` `EARN_BROADCAST_REFUSED` when the node refuses the first step — nothing was sent, and the same signatures may be sent again. - Once a step is out, the answer is the operation in the state it reached. An optional `Idempotency-Key` makes a retry safe. ## Endpoint ### `POST /v1/earn/operations/{operationId}/broadcast` Authentication: bearer token required. Sends the signatures of an operation awaiting them. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `operationId` | string | yes | `operationId` of the operation | **Headers** | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | no | Optional. Up to 255 letters, digits, - or _; see [Retries](https://docs.aureahub.com/docs/guide-earn.md) | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `signatures` | array | yes | `[{ v, r, s }]`, one per step, in the order of the steps | **Responses** `200` OK ```json { "operationId": "3f9a…", "productId": "lumen", "kind": "deposit", "route": "direct", "signer": "client", "state": "completed", "txHashes": ["0x…", "0x…"], "signing": null, "broadcastAt": "2026-10-05T10:03:40.000Z", "completedAt": "2026-10-05T10:03:52.000Z" } ``` `410` Expired ```json { "statusCode": 410, "error": "GoneError", "message": "The time to sign this operation is over. Start it again.", "details": { "code": "EARN_SIGNING_EXPIRED", "expiresAt": "2026-10-05T10:16:00.000Z" } } ``` The 200 above shows the fields that changed; the answer is the whole operation, as [Deposit](https://docs.aureahub.com/docs/earn-deposit.md) returns it. Web version: https://docs.aureahub.com/#earn-broadcast --- # List Earn Operations The user's deposits and withdrawals on a network, newest first. ## Overview - Each item is an operation, as [Deposit](https://docs.aureahub.com/docs/earn-deposit.md) returns it. To bring one up to date, read it with [Get an Operation](https://docs.aureahub.com/docs/earn-operation-get.md). - `limit` from 1 to 100, default 20. ## Endpoint ### `GET /v1/earn/operations` Authentication: bearer token required. The user's Earn operations on the network, newest first. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `isTestnet` | string | no | `true` testnet, `false` (default) mainnet | | `limit` | integer | no | 1–100, default 20 | **Responses** `200` OK ```json { "isTestnet": false, "operations": [ { "operationId": "8b2d…", "productId": "lumen", "kind": "withdrawal", "state": "completed", "…": "…" }, { "operationId": "3f9a…", "productId": "lumen", "kind": "deposit", "state": "completed", "…": "…" } ] } ``` Web version: https://docs.aureahub.com/#earn-operations --- # Get an Earn Operation One of the user's operations, brought up to date first. ## Overview - A `broadcast` operation is read from its chain and completed or failed when its receipts say so; one whose signatures never came in time is `expired`. - The bridge route adds `bridge.messageId`; a withdrawal paid later adds `redemption` (`state`, `shares`, `assetsExpected`, `assetsPaid`, `requestId`, `paidTxHash`, `queuedAt`, `paidAt`, `updatedAt`) — see [Withdrawals and Redemptions](https://docs.aureahub.com/docs/guide-earn.md). - `404` `EARN_OPERATION_NOT_FOUND` for an operation that is not the user's. ## Endpoint ### `GET /v1/earn/operations/{operationId}` Authentication: bearer token required. One of the user's Earn operations, brought up to date. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `operationId` | string | yes | `operationId` of the operation | **Responses** `200` Redemption Queued ```json { "operationId": "c41e…", "productId": "stablecoin-savings-usdc", "kind": "withdrawal", "route": "direct", "signer": "client", "state": "completed", "amount": "250000000", "withdrawAll": false, "txHashes": ["0x…"], "redemption": { "state": "queued", "shares": "240129771", "assetsExpected": "250000000", "assetsPaid": null, "requestId": "1842", "paidTxHash": null, "queuedAt": "2026-10-05T12:01:00.000Z", "paidAt": null, "updatedAt": "2026-10-05T12:05:00.000Z" }, "…": "…" } ``` Web version: https://docs.aureahub.com/#earn-operation-get --- # Earn Positions What the user holds in the Earn products, read from the chain. ## Overview - For every product your tenant offers, or has offered, on the network, and every EVM wallet of the user somebody can sign for: `balance`, what the position is worth now, principal and yield together, in the asset's smallest unit. A product with share tokens adds `shares`. - Positions of zero are left out. `incomplete: true` when a chain could not be read: the list may miss a position. ## Endpoint ### `GET /v1/earn/positions` Authentication: bearer token required. The user's positions in the Earn products. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `isTestnet` | string | no | `true` testnet, `false` (default) mainnet | **Responses** `200` OK ```json { "isTestnet": false, "positions": [ { "productId": "lumen", "name": "Lumen", "chain": "gnosis", "chainId": 100, "assetAddress": "0xcB44…C56E", "assetSymbol": "EUR.e", "assetDecimals": 18, "walletId": "7c1e…", "walletAddress": "0x2f4B…", "balance": "50012000000000000000" } ], "incomplete": false } ``` Web version: https://docs.aureahub.com/#earn-positions --- # 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). - Up to ten more addresses per environment, each with its own secret and event types: [Webhook Endpoints](https://docs.aureahub.com/docs/webhook-endpoints-list.md). Their deliveries and this webhook's are listed together in [List Deliveries](https://docs.aureahub.com/docs/webhook-deliveries-list.md). - The secret is never returned. `secretHint` is the four characters before its final `=`, so you can tell which secret the webhook uses. - For a tenant role that reads the webhooks — `owner`, `tenantadmin`, `non_signing_admin`, `auditor`, `security_admin` or `security_auditor` ([Roles](https://docs.aureahub.com/docs/authentication.md)), on its own tenant: 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 the five `ramp.*` types and the two `earn.*` types — never a type published since: name those on a [webhook endpoint](https://docs.aureahub.com/docs/webhook-endpoint-add.md). 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 a tenant role that manages the webhooks — `owner`, `tenantadmin`, `non_signing_admin` or `security_admin` ([Roles](https://docs.aureahub.com/docs/authentication.md)), on its own tenant. Every save is written to the tenant's activity log with what it replaced, never with the secret. > ℹ️ **Your tenant's quorum, where Aurea enables it for your tenant** (a tenant on the institutional security profile by its profile): this call answers `202` `{ "state": "awaiting_quorum", "requestId" }` — the **202** tab below — and nothing is done until another administrator of your tenant approves it; the answer it would have given is then read once by who asked ([Read the Answer](https://docs.aureahub.com/docs/quorum-request-result.md)). With no other administrator able to approve it, it is done at once. [Your Tenant's Quorum](https://docs.aureahub.com/docs/guide-tenant-quorum.md). Every other tenant gets the answers below, unchanged. ## 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: those five and the two `earn.*` types | | `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=)" } ``` `202` Awaiting quorum ```json { "state": "awaiting_quorum", "requestId": "4e2b9c17-8a3d-4f60-b5e1-7c9d0a2f6b38" } ``` `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 a tenant role that manages the webhooks — `owner`, `tenantadmin`, `non_signing_admin` or `security_admin` ([Roles](https://docs.aureahub.com/docs/authentication.md)), on its own tenant. Written to the tenant's activity log with the new hint only. > ℹ️ **Your tenant's quorum, where Aurea enables it for your tenant** (a tenant on the institutional security profile by its profile): this call answers `202` `{ "state": "awaiting_quorum", "requestId" }` — the **202** tab below — and nothing is done until another administrator of your tenant approves it; the answer it would have given is then read once by who asked ([Read the Answer](https://docs.aureahub.com/docs/quorum-request-result.md)). With no other administrator able to approve it, it is done at once. [Your Tenant's Quorum](https://docs.aureahub.com/docs/guide-tenant-quorum.md). Every other tenant gets the answers below, unchanged. ## 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=)" } ``` `202` Awaiting quorum ```json { "state": "awaiting_quorum", "requestId": "4e2b9c17-8a3d-4f60-b5e1-7c9d0a2f6b38" } ``` `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 a tenant role that manages the webhooks (`owner`, `tenantadmin`, `non_signing_admin`, `security_admin`); 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 a tenant role that manages the webhooks — `owner`, `tenantadmin`, `non_signing_admin` or `security_admin` ([Roles](https://docs.aureahub.com/docs/authentication.md)), on its own tenant. Written to the tenant's activity log with the address. > ℹ️ **Your tenant's quorum, where Aurea enables it for your tenant** (a tenant on the institutional security profile by its profile): this call answers `202` `{ "state": "awaiting_quorum", "requestId" }` — the **202** tab below — and nothing is done until another administrator of your tenant approves it; the answer it would have given is then read once by who asked ([Read the Answer](https://docs.aureahub.com/docs/quorum-request-result.md)). With no other administrator able to approve it, it is done at once. [Your Tenant's Quorum](https://docs.aureahub.com/docs/guide-tenant-quorum.md). Every other tenant gets the answers below, unchanged. ## 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 `202` Awaiting quorum ```json { "state": "awaiting_quorum", "requestId": "4e2b9c17-8a3d-4f60-b5e1-7c9d0a2f6b38" } ``` `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 --- # List Webhook Endpoints Your tenant's webhook endpoints, without their secrets, and every event type an endpoint may ask for. ## Overview - An endpoint is one more address for your events, beside the [webhook of each environment](https://docs.aureahub.com/docs/tenant-webhooks-list.md): up to **10 per environment**, each with its own URL, secret, event types, label and status. How deliveries work: [Events to Your Server](https://docs.aureahub.com/docs/guide-events.md). - Production endpoints first, then sandbox, oldest first; `environment` keeps one of the two. The secret is never returned: `secretHint` is the four characters before its final `=`. - `eventTypes` in the answer lists every type an endpoint may ask for — thirty-two —, each with the version of its body and the name of the schema that describes it in the [OpenAPI document](https://api.aureahub.com/v1/docs/json) (`components.schemas`). - An endpoint's own `eventTypes` are the types it receives. Empty: the five `ramp.*` and the two `earn.*` types only — `operation.*`, `tokenized_asset.*`, `server_credential.revoked`, `account.*`, `transfer.inbound.*`, `address_book.*` and any type published later reach it only when it names them ([Event Types](https://docs.aureahub.com/docs/guide-events.md)). - For a tenant role that reads the webhooks — `owner`, `tenantadmin`, `non_signing_admin`, `auditor`, `security_admin` or `security_auditor` ([Roles](https://docs.aureahub.com/docs/authentication.md)), on its own tenant. `404` `Tenant not found` when the tenant doesn't exist. ## Endpoint ### `GET /v1/admin/tenants/{id}/webhook-endpoints` Authentication: bearer token required. Lists the tenant's webhook endpoints, without secrets, and the event types they may ask for. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Tenant UUID | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `environment` | string | no | `production` or `sandbox`; both when left out | **Responses** `200` OK ```json { "endpoints": [ { "id": "8a3c5e71-2b4d-4f60-9e1a-7c2d4b6f8e01", "environment": "production", "url": "https://ledger.example.com/aurea/events", "description": "Ledger service", "eventTypes": ["ramp.deposit.updated", "ramp.transaction.updated"], "status": "active", "secretHint": "Zt4w", "secretRotatedAt": "2026-10-06T08:12:40.511Z", "createdAt": "2026-10-06T08:12:40.511Z", "updatedAt": "2026-10-06T08:12:40.511Z" } ], "eventTypes": [ { "type": "ramp.kyc.updated", "version": 1, "summary": "A user's verification with the bank ramp changed", "schema": "#/components/schemas/WebhookEvent.ramp.kyc.updated.v1" }, { "type": "ramp.deposit.updated", "version": 1, "summary": "A fiat deposit to a user's virtual account changed", "schema": "#/components/schemas/WebhookEvent.ramp.deposit.updated.v1" }, { "type": "ramp.payout.updated", "version": 1, "summary": "A user's payout to a bank account changed status", "schema": "#/components/schemas/WebhookEvent.ramp.payout.updated.v1" }, { "type": "ramp.transaction.updated", "version": 1, "summary": "A bank ramp transaction of a user changed", "schema": "#/components/schemas/WebhookEvent.ramp.transaction.updated.v1" }, { "type": "ramp.card_session.updated", "version": 1, "summary": "A card onramp session changed status", "schema": "#/components/schemas/WebhookEvent.ramp.card_session.updated.v1" }, { "type": "earn.operation.updated", "version": 1, "summary": "An earn deposit or withdrawal changed state", "schema": "#/components/schemas/WebhookEvent.earn.operation.updated.v1" }, { "type": "earn.redemption.updated", "version": 1, "summary": "A redemption an earn provider queued changed state", "schema": "#/components/schemas/WebhookEvent.earn.redemption.updated.v1" }, { "type": "operation.created", "version": 1, "summary": "An operation was recorded (a send, an approval, a swap, a signature…), in the state its first step left it", "schema": "#/components/schemas/WebhookEvent.operation.created.v1" }, { "type": "operation.state_changed", "version": 1, "summary": "An operation moved: a new state, or a new sub-state of the same one", "schema": "#/components/schemas/WebhookEvent.operation.state_changed.v1" }, { "type": "operation.completed", "version": 1, "summary": "An operation completed (its final state COMPLETED)", "schema": "#/components/schemas/WebhookEvent.operation.completed.v1" }, { "type": "operation.failed", "version": 1, "summary": "An operation failed (its final state FAILED)", "schema": "#/components/schemas/WebhookEvent.operation.failed.v1" }, { "type": "tokenized_asset.paused", "version": 1, "summary": "A tokenized asset the tenant offers was paused on a chain: buying, selling or both", "schema": "#/components/schemas/WebhookEvent.tokenized_asset.paused.v1" }, { "type": "tokenized_asset.resumed", "version": 1, "summary": "A tokenized asset the tenant offers trades again on a chain", "schema": "#/components/schemas/WebhookEvent.tokenized_asset.resumed.v1" }, { "type": "tokenized_asset.corporate_action", "version": 1, "summary": "A tokenized asset the tenant offers has a new dividend or a new shares multiplier", "schema": "#/components/schemas/WebhookEvent.tokenized_asset.corporate_action.v1" }, { "type": "tokenized_asset.operation.quoted", "version": 1, "summary": "A tokenized assets operation was created: built, and for a trade its binding quote held", "schema": "#/components/schemas/WebhookEvent.tokenized_asset.operation.quoted.v1" }, { "type": "tokenized_asset.operation.submitted", "version": 1, "summary": "A tokenized assets operation's first transaction was sent", "schema": "#/components/schemas/WebhookEvent.tokenized_asset.operation.submitted.v1" }, { "type": "tokenized_asset.operation.completed", "version": 1, "summary": "A tokenized assets operation completed on chain", "schema": "#/components/schemas/WebhookEvent.tokenized_asset.operation.completed.v1" }, { "type": "tokenized_asset.operation.failed", "version": 1, "summary": "A tokenized assets operation failed (failureCode says why)", "schema": "#/components/schemas/WebhookEvent.tokenized_asset.operation.failed.v1" }, { "type": "tokenized_asset.operation.expired", "version": 1, "summary": "A tokenized assets operation's time ran out before it was carried out", "schema": "#/components/schemas/WebhookEvent.tokenized_asset.operation.expired.v1" }, { "type": "server_credential.revoked", "version": 1, "summary": "One of the tenant's server credentials was revoked: its tokens are refused from the next request (the credential as the API answers it)", "schema": "#/components/schemas/WebhookEvent.server_credential.revoked.v1" }, { "type": "account.created", "version": 1, "summary": "An account was created (one call, or one of a bulk creation)", "schema": "#/components/schemas/WebhookEvent.account.created.v1" }, { "type": "account.updated", "version": 1, "summary": "An account's name, tags, visibility, users or customerRefId changed", "schema": "#/components/schemas/WebhookEvent.account.updated.v1" }, { "type": "account.wallet_added", "version": 1, "summary": "A wallet was made for an account, or put in it", "schema": "#/components/schemas/WebhookEvent.account.wallet_added.v1" } ] } ``` `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/#webhook-endpoints-list --- # Add a Webhook Endpoint One more address for your tenant's events in one environment, with the event types it asks for. Its secret is shown in this answer, and never again. ## Overview - **`201` with `endpoint` and `secret`.** Store the secret now: it signs every delivery to this endpoint ([Verify the Signature](https://docs.aureahub.com/docs/guide-events.md)). A new one: [Give an Endpoint a New Secret](https://docs.aureahub.com/docs/webhook-endpoint-rotate.md). - `url`: `https://` on the default port with a full public DNS name — the same rules as the webhook of each environment, listed in [Register Your Server](https://docs.aureahub.com/docs/guide-events.md). - `eventTypes`: the types it receives, any of the thirty-two [List Endpoints](https://docs.aureahub.com/docs/webhook-endpoints-list.md) answers. `[]` receives the five `ramp.*` and the two `earn.*` types only: `operation.*`, `tokenized_asset.*`, `server_credential.revoked`, `account.*`, `transfer.inbound.*`, `address_book.*` and every type published later reach the endpoint only when it names them. A type named twice counts once; the answer lists them in the catalogue's order. - `description`: your own label, up to 200 characters without control characters, trimmed; `null` or empty: none. `status`: `active` (the default) or `disabled`. - At most 10 endpoints per environment: an eleventh answers `409` `WEBHOOK_ENDPOINT_LIMIT`. Two requests racing for the tenth place: one of them gets it. - 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 a tenant role that manages the webhooks — `owner`, `tenantadmin`, `non_signing_admin` or `security_admin` ([Roles](https://docs.aureahub.com/docs/authentication.md)), on its own tenant. Written to the tenant's activity log, never with the secret. > ℹ️ **Your tenant's quorum, where Aurea enables it for your tenant** (a tenant on the institutional security profile by its profile): this call answers `202` `{ "state": "awaiting_quorum", "requestId" }` — the **202** tab below — and nothing is done until another administrator of your tenant approves it; the answer it would have given is then read once by who asked ([Read the Answer](https://docs.aureahub.com/docs/quorum-request-result.md)). With no other administrator able to approve it, it is done at once. [Your Tenant's Quorum](https://docs.aureahub.com/docs/guide-tenant-quorum.md). Every other tenant gets the answers below, unchanged. ## Endpoint ### `POST /v1/admin/tenants/{id}/webhook-endpoints` Authentication: bearer token required. Adds a webhook endpoint in one environment and returns its secret once. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Tenant UUID | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `environment` | string | yes | `production` or `sandbox` | | `url` | string | yes | Your server's address, up to 2048 characters | | `eventTypes` | string[] | yes | The types it receives, up to 23; empty: the `ramp.*` and `earn.*` types only | | `description` | string \| null | no | Your label, up to 200 characters | | `status` | string | no | `active` (default) or `disabled` | **Responses** `201` Added ```json { "endpoint": { "id": "8a3c5e71-2b4d-4f60-9e1a-7c2d4b6f8e01", "environment": "production", "url": "https://ledger.example.com/aurea/events", "description": "Ledger service", "eventTypes": ["ramp.deposit.updated", "ramp.transaction.updated"], "status": "active", "secretHint": "Zt4w", "secretRotatedAt": "2026-10-06T08:12:40.511Z", "createdAt": "2026-10-06T08:12:40.511Z", "updatedAt": "2026-10-06T08:12:40.511Z" }, "secret": "whsec_… (44 characters of base64, ending Zt4w=)" } ``` `202` Awaiting quorum ```json { "state": "awaiting_quorum", "requestId": "4e2b9c17-8a3d-4f60-b5e1-7c9d0a2f6b38" } ``` `400` Refused ```json { "statusCode": 400, "error": "BadRequestError", "message": "The webhook URL must use https", "details": { "code": "WEBHOOK_ENDPOINT_INVALID", "field": "url", "reason": "not_https" } } ``` `409` Eleventh ```json { "statusCode": 409, "error": "ConflictError", "message": "A tenant has at most 10 webhook endpoints in production", "details": { "code": "WEBHOOK_ENDPOINT_LIMIT" } } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/admin/tenants/$TENANT_ID/webhook-endpoints \ -H "Authorization: Bearer $TENANT_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "environment": "sandbox", "url": "https://ledger.example.com/aurea/events", "eventTypes": ["ramp.deposit.updated"], "description": "Ledger service" }' ``` ## Refusals Nothing is saved, and the answer is `400` with `details.code` `WEBHOOK_ENDPOINT_INVALID`, the field at fault in `details.field` and, where the field's type is wrong, each problem in `details.issues`: - `url` an address Aurea won't call, with `details.reason` — `not_https`, `port`, `ip_address`, `local_host`, `host_name`, `credentials`, `fragment`, `whitespace_or_control`, `backslash`, `dot_segment` or `not_a_url`, as for the [webhook of each environment](https://docs.aureahub.com/docs/tenant-webhook-save.md). - `eventTypes`: not a list, more than thirty-two entries, or a type that is not published (`webhook.test` included: only a test sends it). - `environment`: neither `production` nor `sandbox` (`The environment is production or sandbox`). - `description`: longer than 200 characters, or with a control character. `status`: neither `active` nor `disabled`. - `body`: a field that doesn't belong, for example `secret` — the API always makes the secret (`Unrecognized key(s) in object: 'secret'`). - `id`: a tenant id that is not a UUID (`The tenant id must be a UUID`). Web version: https://docs.aureahub.com/#webhook-endpoint-add --- # Change a Webhook Endpoint Its address, its event types, its label or its status. The rest stays, and so does the secret. ## Overview - Send only what changes: at least one of `url`, `eventTypes`, `description` and `status`. An empty body answers `400` `Name at least one of url, eventTypes, description or status`. The environment never changes: add an endpoint in the other one instead. - Each field follows the rules of [Add an Endpoint](https://docs.aureahub.com/docs/webhook-endpoint-add.md), with the same `400` `WEBHOOK_ENDPOINT_INVALID` refusals. `"description": null` or `""` clears the label. - `"status": "disabled"` stops the deliveries: those still waiting finish as `skipped`, and changes that happen meanwhile are not sent later. `"active"` starts them again with the same secret. - Deliveries already queued go to the endpoint as it is when they are sent: a new address receives them, and an endpoint that no longer asks for their type skips them. - `404` `WEBHOOK_ENDPOINT_NOT_FOUND` for an endpoint that is not this tenant's. Same roles as Add an Endpoint; written to the tenant's activity log with what it replaced. > ℹ️ **Your tenant's quorum, where Aurea enables it for your tenant** (a tenant on the institutional security profile by its profile): this call answers `202` `{ "state": "awaiting_quorum", "requestId" }` — the **202** tab below — and nothing is done until another administrator of your tenant approves it; the answer it would have given is then read once by who asked ([Read the Answer](https://docs.aureahub.com/docs/quorum-request-result.md)). With no other administrator able to approve it, it is done at once. [Your Tenant's Quorum](https://docs.aureahub.com/docs/guide-tenant-quorum.md). Every other tenant gets the answers below, unchanged. ## Endpoint ### `PATCH /v1/admin/tenants/{id}/webhook-endpoints/{endpointId}` Authentication: bearer token required. Changes what the body names; the secret stays. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Tenant UUID | | `endpointId` | string | yes | The endpoint's id | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `url` | string | no | A new address | | `eventTypes` | string[] | no | The types it receives; empty: the `ramp.*` and `earn.*` types only | | `description` | string \| null | no | Your label; `null` or empty clears it | | `status` | string | no | `active` or `disabled` | **Responses** `200` Changed ```json { "endpoint": { "id": "8a3c5e71-2b4d-4f60-9e1a-7c2d4b6f8e01", "environment": "production", "url": "https://ledger.example.com/aurea/events", "description": "Ledger service", "eventTypes": [], "status": "active", "secretHint": "Zt4w", "secretRotatedAt": "2026-10-06T08:12:40.511Z", "createdAt": "2026-10-06T08:12:40.511Z", "updatedAt": "2026-10-06T09:30:02.118Z" } } ``` `202` Awaiting quorum ```json { "state": "awaiting_quorum", "requestId": "4e2b9c17-8a3d-4f60-b5e1-7c9d0a2f6b38" } ``` `404` Not found ```json { "statusCode": 404, "error": "NotFoundError", "message": "This tenant has no such webhook endpoint", "details": { "code": "WEBHOOK_ENDPOINT_NOT_FOUND" } } ``` Web version: https://docs.aureahub.com/#webhook-endpoint-change --- # Give an Endpoint a New Secret A new secret for one endpoint, shown once. It signs every delivery to that endpoint 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, so put it in place within a few minutes. - `secretHint` and `secretRotatedAt` change with it; the rest of the endpoint stays as it is. No body. - `404` `WEBHOOK_ENDPOINT_NOT_FOUND` for an endpoint that is not this tenant's. Same roles as [Add an Endpoint](https://docs.aureahub.com/docs/webhook-endpoint-add.md); written to the tenant's activity log with the new hint only. > ℹ️ **Your tenant's quorum, where Aurea enables it for your tenant** (a tenant on the institutional security profile by its profile): this call answers `202` `{ "state": "awaiting_quorum", "requestId" }` — the **202** tab below — and nothing is done until another administrator of your tenant approves it; the answer it would have given is then read once by who asked ([Read the Answer](https://docs.aureahub.com/docs/quorum-request-result.md)). With no other administrator able to approve it, it is done at once. [Your Tenant's Quorum](https://docs.aureahub.com/docs/guide-tenant-quorum.md). Every other tenant gets the answers below, unchanged. ## Endpoint ### `POST /v1/admin/tenants/{id}/webhook-endpoints/{endpointId}/rotate-secret` Authentication: bearer token required. Gives the endpoint a new secret and returns it once. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Tenant UUID | | `endpointId` | string | yes | The endpoint's id | **Responses** `200` Replaced ```json { "endpoint": { "id": "8a3c5e71-2b4d-4f60-9e1a-7c2d4b6f8e01", "environment": "production", "url": "https://ledger.example.com/aurea/events", "description": "Ledger service", "eventTypes": ["ramp.deposit.updated", "ramp.transaction.updated"], "status": "active", "secretHint": "c9Qe", "secretRotatedAt": "2026-10-06T10:01:17.904Z", "createdAt": "2026-10-06T08:12:40.511Z", "updatedAt": "2026-10-06T10:01:17.904Z" }, "secret": "whsec_… (44 characters of base64, ending c9Qe=)" } ``` `202` Awaiting quorum ```json { "state": "awaiting_quorum", "requestId": "4e2b9c17-8a3d-4f60-b5e1-7c9d0a2f6b38" } ``` Web version: https://docs.aureahub.com/#webhook-endpoint-rotate --- # Send a Test to an Endpoint One signed `webhook.test` to one endpoint, right now, with what happened. ## Overview - Signed and sent like every delivery ([Events to Your Server](https://docs.aureahub.com/docs/guide-events.md)), whether the endpoint is active or not. The body's `data` holds `tenantId`, `webhookId` (the endpoint's id) and a `message`. - `delivered` is `true` when your server answered `2xx` within 10 seconds. `statusCode` is your server's answer, or `null` when none came; `error` says why, or is `null`. - Not queued, never retried, and not in the [attempts log](https://docs.aureahub.com/docs/webhook-attempts-list.md). At most **10 a minute**; more answers `429`. - `404` `WEBHOOK_ENDPOINT_NOT_FOUND` for an endpoint that is not this tenant's. Same roles as [Add an Endpoint](https://docs.aureahub.com/docs/webhook-endpoint-add.md); written to the tenant's activity log with the outcome. ## Endpoint ### `POST /v1/admin/tenants/{id}/webhook-endpoints/{endpointId}/test` Authentication: bearer token required. Sends a signed test delivery to the endpoint and reports the outcome. No body. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Tenant UUID | | `endpointId` | string | yes | The endpoint's id | **Responses** `200` Delivered ```json { "id": "evt_3e8d0b6a91c47f25d0e1a9b8c7f60d42", "type": "webhook.test", "delivered": true, "statusCode": 200, "error": null, "durationMs": 187 } ``` `429` Too many ```json { "statusCode": 429, "error": "Error", "message": "Rate limit exceeded, retry in 1 minute" } ``` Web version: https://docs.aureahub.com/#webhook-endpoint-test --- # Remove a Webhook Endpoint Stop sending events to one endpoint, for good. ## Overview - `204` with no body. The endpoint and its secret are deleted; deliveries still queued for it finish as `skipped`, and its past deliveries and attempts stay readable. To stop only for a while, [change](https://docs.aureahub.com/docs/webhook-endpoint-change.md) it to `"status": "disabled"`. - `404` `WEBHOOK_ENDPOINT_NOT_FOUND` for an endpoint that is not this tenant's. Same roles as [Add an Endpoint](https://docs.aureahub.com/docs/webhook-endpoint-add.md); written to the tenant's activity log with the address. > ℹ️ **Your tenant's quorum, where Aurea enables it for your tenant** (a tenant on the institutional security profile by its profile): this call answers `202` `{ "state": "awaiting_quorum", "requestId" }` — the **202** tab below — and nothing is done until another administrator of your tenant approves it; the answer it would have given is then read once by who asked ([Read the Answer](https://docs.aureahub.com/docs/quorum-request-result.md)). With no other administrator able to approve it, it is done at once. [Your Tenant's Quorum](https://docs.aureahub.com/docs/guide-tenant-quorum.md). Every other tenant gets the answers below, unchanged. ## Endpoint ### `DELETE /v1/admin/tenants/{id}/webhook-endpoints/{endpointId}` Authentication: bearer token required. Deletes the endpoint and its secret. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Tenant UUID | | `endpointId` | string | yes | The endpoint's id | **Responses** `204` No Content `202` Awaiting quorum ```json { "state": "awaiting_quorum", "requestId": "4e2b9c17-8a3d-4f60-b5e1-7c9d0a2f6b38" } ``` `404` Not found ```json { "statusCode": 404, "error": "NotFoundError", "message": "This tenant has no such webhook endpoint", "details": { "code": "WEBHOOK_ENDPOINT_NOT_FOUND" } } ``` Web version: https://docs.aureahub.com/#webhook-endpoint-remove --- # List Webhook Deliveries What Aurea sent, or is sending, to your tenant's addresses: one row per event and address, newest first. ## Overview - Every delivery to the [webhook of each environment](https://docs.aureahub.com/docs/tenant-webhooks-list.md) (`endpoint.kind` `environment`) and to the [endpoints](https://docs.aureahub.com/docs/webhook-endpoints-list.md) (`endpoint.kind` `endpoint`). One event sent to three addresses is three deliveries with the same `eventId`. - With its status, its attempts and the last one. The body sent is never shown: the event is whatever its type carried ([Event Types](https://docs.aureahub.com/docs/guide-events.md)). Test deliveries are not here. - Filters: `environment`; `status`, a comma-separated list (an unknown status answers `400` `WEBHOOK_QUERY_INVALID`); `eventId`; `eventType`; `endpointId`; `from` and `to`, which bound when the delivery was queued. Page with `limit` (1–100, default 20) and `offset`; `total` counts every row the filters match. - Same roles as [List Endpoints](https://docs.aureahub.com/docs/webhook-endpoints-list.md). ## Statuses | status | Meaning | | --- | --- | | pending | Waiting for its first attempt. | | sending | Being posted. | | delivered | Your server answered `2xx`. | | skipped | Finished without being posted: its address was removed, switched off, or no longer asks for the type. | | retrying | An attempt failed; `nextAttemptAt` says when the next one is made. | | failed | Out of attempts (10). [Send it again](https://docs.aureahub.com/docs/webhook-deliveries-resend.md). | | cancelled | Dropped by the queue. | ## Endpoint ### `GET /v1/admin/tenants/{id}/webhook-deliveries` Authentication: bearer token required. The tenant's deliveries, newest first, with their status and last attempt. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Tenant UUID | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `environment` | string | no | `production` or `sandbox` | | `status` | string | no | Comma-separated: `pending`, `sending`, `delivered`, `skipped`, `retrying`, `failed`, `cancelled` | | `eventId` | string | no | `evt_` and 32 hex characters | | `eventType` | string | no | One event type | | `endpointId` | string | no | One endpoint, or the webhook of an environment, by id | | `from` | string (date-time) | no | Queued at or after | | `to` | string (date-time) | no | Queued before | | `limit` | integer | no | 1–100, default 20 | | `offset` | integer | no | Default 0 | **Responses** `200` OK ```json { "total": 2, "limit": 20, "offset": 0, "deliveries": [ { "id": "c41f2a8e-6d3b-4e7a-9f15-0b8d2c6e4a91", "eventId": "evt_9f2c41d87a0b5e36c1d4f8a2b7e05c93", "eventType": "ramp.deposit.updated", "environment": "production", "endpoint": { "kind": "endpoint", "id": "8a3c5e71-2b4d-4f60-9e1a-7c2d4b6f8e01" }, "status": "retrying", "attempts": 2, "maxAttempts": 10, "nextAttemptAt": "2026-10-06T09:16:05.000Z", "lastAttempt": { "startedAt": "2026-10-06T09:15:03.412Z", "outcome": "failed", "statusCode": 503, "durationMs": 211 }, "createdAt": "2026-10-06T09:14:03.418Z", "updatedAt": "2026-10-06T09:15:03.640Z", "completedAt": null }, { "id": "5e0a9c37-1b2d-4f8e-a6c4-7d9e3b1f2a05", "eventId": "evt_9f2c41d87a0b5e36c1d4f8a2b7e05c93", "eventType": "ramp.deposit.updated", "environment": "production", "endpoint": { "kind": "environment", "id": "5c03a06e-55f0-425a-8562-bd7bae35825d" }, "status": "delivered", "attempts": 1, "maxAttempts": 10, "nextAttemptAt": null, "lastAttempt": { "startedAt": "2026-10-06T09:14:03.502Z", "outcome": "delivered", "statusCode": 200, "durationMs": 143 }, "createdAt": "2026-10-06T09:14:03.418Z", "updatedAt": "2026-10-06T09:14:03.660Z", "completedAt": "2026-10-06T09:14:03.660Z" } ] } ``` `400` Unknown status ```json { "statusCode": 400, "error": "BadRequestError", "message": "Unknown delivery status \"lost\": each must be one of pending, sending, delivered, skipped, retrying, failed, cancelled", "details": { "code": "WEBHOOK_QUERY_INVALID", "field": "status" } } ``` `endpoint.id` of a delivery to the webhook of an environment is that webhook's id, or `null` once the webhook was removed. Web version: https://docs.aureahub.com/#webhook-deliveries-list --- # The Attempts Log Every attempt Aurea made to post a delivery to your tenant's addresses: when, how long, and what your server answered — or why no answer came. ## Overview - One row per attempt that posted, newest first, for the webhook of each environment and for the endpoints. `attempt` is the delivery's attempt number; after a [resend](https://docs.aureahub.com/docs/webhook-deliveries-resend.md) it starts again from 1 (tell them apart by time). - `outcome` is `delivered` for a `2xx`, else `failed`. `statusCode` is what your server answered, or `null` when no answer came; then `error` says why (a timeout, an address that is not public, a network error). Aurea never reads your answer's body, so the log doesn't keep it. - `url` is the address as it was when posted to. `jwsKid` names the key of the `X-Webhook-Jws` header, when one was sent ([Verify the JWS](https://docs.aureahub.com/docs/guide-events.md)). - Kept **30 days**. Test deliveries are not logged. - Filters: `environment`, `deliveryId`, `eventId`, `endpointId`, `outcome`, `from` and `to` (when the attempt started). Page with `limit` (1–100, default 20) and `offset`. Same roles as [List Endpoints](https://docs.aureahub.com/docs/webhook-endpoints-list.md). ## Endpoint ### `GET /v1/admin/tenants/{id}/webhook-attempts` Authentication: bearer token required. The log of the tenant's delivery attempts, newest first. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Tenant UUID | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `environment` | string | no | `production` or `sandbox` | | `deliveryId` | string | no | One delivery | | `eventId` | string | no | `evt_` and 32 hex characters | | `endpointId` | string | no | One endpoint, or the webhook of an environment, by id | | `outcome` | string | no | `delivered` or `failed` | | `from` | string (date-time) | no | Started at or after | | `to` | string (date-time) | no | Started before | | `limit` | integer | no | 1–100, default 20 | | `offset` | integer | no | Default 0 | **Responses** `200` OK ```json { "total": 2, "limit": 20, "offset": 0, "attempts": [ { "id": "0e7b3d91-5a2c-4f68-b1e4-9c8d7a6f5e32", "deliveryId": "c41f2a8e-6d3b-4e7a-9f15-0b8d2c6e4a91", "eventId": "evt_9f2c41d87a0b5e36c1d4f8a2b7e05c93", "eventType": "ramp.deposit.updated", "environment": "production", "endpoint": { "kind": "endpoint", "id": "8a3c5e71-2b4d-4f60-9e1a-7c2d4b6f8e01" }, "url": "https://ledger.example.com/aurea/events", "attempt": 2, "startedAt": "2026-10-06T09:15:03.412Z", "durationMs": 211, "outcome": "failed", "statusCode": 503, "error": null, "jwsKid": null }, { "id": "6b2f8e04-3c9a-4d17-8e5b-2a1c0f9d8e76", "deliveryId": "c41f2a8e-6d3b-4e7a-9f15-0b8d2c6e4a91", "eventId": "evt_9f2c41d87a0b5e36c1d4f8a2b7e05c93", "eventType": "ramp.deposit.updated", "environment": "production", "endpoint": { "kind": "endpoint", "id": "8a3c5e71-2b4d-4f60-9e1a-7c2d4b6f8e01" }, "url": "https://ledger.example.com/aurea/events", "attempt": 1, "startedAt": "2026-10-06T09:14:03.497Z", "durationMs": 10004, "outcome": "failed", "statusCode": null, "error": "The tenant's server did not answer within 10000 ms", "jwsKid": null } ] } ``` Web version: https://docs.aureahub.com/#webhook-attempts-list --- # Send Deliveries Again Put your tenant's deliveries back in the queue with a fresh set of attempts: one event's, those of a time range, or every one that failed. ## Overview - In one `environment`, name exactly one of: `eventId` (that event's deliveries), `from` and `to` (those queued in that range, at most 31 days), or `"failed": true` (every delivery that is `failed` or `retrying`). Add `endpointId` to take only one address's deliveries. - By event or range, every delivery that is not `pending` or `sending` goes again — a `delivered` one too. A delivery still waiting or being sent is left as it is. - The same body goes again with the same event id, signed when it is sent, to the address and with the secret it has then: a receiver that already has the event drops it. Each delivery gets 10 new attempts; its attempts log restarts from 1. - At most 500 deliveries a call, oldest first: `more` is `true` when others match — call again. At most **10 calls a minute**. - A body that doesn't fit answers `400` `WEBHOOK_RESEND_INVALID` with the field: `Name exactly one of: eventId, a range (from and to), or failed: true`, `A range needs both from and to`, `to must be after from`, `A range spans at most 31 days`, `An event id is evt_ and 32 hex characters`. - For a tenant role that manages the webhooks — `owner`, `tenantadmin`, `non_signing_admin` or `security_admin`. Written to the tenant's activity log with how many went again. ## Endpoint ### `POST /v1/admin/tenants/{id}/webhook-deliveries/resend` Authentication: bearer token required. Puts the chosen deliveries back in the queue with a fresh budget of attempts. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Tenant UUID | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `environment` | string | yes | `production` or `sandbox` | | `eventId` | string | no | One event's deliveries | | `from` | string (date-time) | no | With `to`: the deliveries queued in this range | | `to` | string (date-time) | no | After `from`, at most 31 days later | | `failed` | boolean | no | `true`: every delivery that failed or is waiting for a retry | | `endpointId` | string | no | Only this address's deliveries | **Responses** `200` Queued ```json { "resent": 2, "deliveryIds": ["c41f2a8e-6d3b-4e7a-9f15-0b8d2c6e4a91", "9d4e2b70-8a1f-4c3e-b5d6-1f0e9a8b7c64"], "more": false } ``` `400` Refused ```json { "statusCode": 400, "error": "BadRequestError", "message": "Name exactly one of: eventId, a range (from and to), or failed: true", "details": { "code": "WEBHOOK_RESEND_INVALID", "field": "eventId", "issues": [{ "path": "eventId", "message": "Name exactly one of: eventId, a range (from and to), or failed: true" }] } } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/admin/tenants/$TENANT_ID/webhook-deliveries/resend \ -H "Authorization: Bearer $TENANT_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "environment": "production", "failed": true }' ``` Web version: https://docs.aureahub.com/#webhook-deliveries-resend --- # Webhook Signing Keys The public keys that verify the `X-Webhook-Jws` header of Aurea's deliveries to your servers. ## Overview - **Empty today.** Aurea signs its deliveries with the HMAC header alone until it turns its signing key on; until then this answers `{ "keys": [] }` and no delivery carries `X-Webhook-Jws`. Verify the HMAC signature ([Verify the Signature](https://docs.aureahub.com/docs/guide-events.md)), and get ready for the JWS with [Verify the JWS](https://docs.aureahub.com/docs/guide-events.md). - When signing is on: the key in use first, then the next one, published before it signs anything. Match a delivery's `kid` to a key; read the set again when a `kid` is new to you. - A JSON Web Key Set (RFC 7517) of EC P-256 keys: `kid` is each key's RFC 7638 SHA-256 thumbprint. - Public, no authentication; `Cache-Control: public, max-age=300`. ## Endpoint ### `GET /v1/webhooks/jwks.json` Authentication: none (public endpoint). The public keys of the deliveries' JWS header. **Responses** `200` Today ```json { "keys": [] } ``` `200` Signing on ```json { "keys": [ { "kty": "EC", "crv": "P-256", "x": "ucZebSpfiPJzIy-Mqt5AwaTR4vyt89n-rlekWcuckyo", "y": "2owTJeLSSl0uvaCZQeXDEwmwo6f2vu7LZANa5ZO0Hfs", "kid": "bzrB8K7EPXdFI4zfAYcUYhyeSrqILkaklrG_kf-IR5I", "use": "sig", "alg": "ES256" } ] } ``` **Example request** ```bash curl https://api.aureahub.com/v1/webhooks/jwks.json ``` Web version: https://docs.aureahub.com/#webhook-jwks --- # Audit Trail What was done to your tenant's money, keys, governance, security, settings and access, by whom and on what — newest first. ## Overview - The trail is append only: an event is never changed or removed. Each event carries the hash of the one before it (`prevHash`) and its own (`hash`, SHA-256 of its content and that link), so the events of your tenant form one chain, numbered from 1 (`seq`). - An event is written in the same transaction as the action it records: an action whose event cannot be written does not happen. - `details` holds ids, amounts in the smallest unit, states and codes — never a key, a signature, a secret, a password or a person's data. ## Switched Off Until Aurea Turns It On Aurea records audit events only once it turns the trail on. Until then nothing is recorded, and every request answers an empty page: ```json { "events": [], "nextCursor": null } ``` When it is on, the trail records a wallet's key export (`wallet.key_exported`) and a wallet's deactivation (`wallet.deactivated`), category `keys`, and the changes Aurea makes to your tenant's security profile and its controls (`security_profile.changed`, `security_control.changed`), category `security`. For the [approvals](https://docs.aureahub.com/docs/guide-approvals.md): `approval_key.registered` and `approval_key.revoked`, category `security`; `approval.requested`, `approval.approved`, `approval.rejected`, `approval.threshold_reached`, `approval.expired`, `approval.withdrawn` and `approval.continued`, category `governance`. For the [accounts](https://docs.aureahub.com/docs/guide-accounts.md): `account.created`, `account.updated` and `account.bulk_requested`, category `governance`; `account.wallet_created` and `account.wallet_attached`, category `keys`. More actions are added over time. [Contact us for details](https://www.aureahub.com/contact). ## Who Reads It - The tenant roles `owner`, `tenantadmin`, `non_signing_admin`, `auditor`, `security_admin` and `security_auditor` ([Roles](https://docs.aureahub.com/docs/authentication.md)) read their own tenant's trail, whatever `tenantId` they send. Any other role answers `403` (`Tenant admin access required. Your role does not have permission to perform this action.`). ## Events | Field | Meaning | | --- | --- | | seq | The event's place in the chain, from 1. | | category | `money`, `keys`, `governance`, `security`, `settings` or `access`. | | action | What was done, as dotted lower-case words, e.g. `wallet.key_exported`. | | actor | Who did it: `kind` (`user`, `service`, `system` or `tenant_api`), `id` and `role`. | | subject | What it was done to: `type` and `id`, e.g. a wallet. | | details | The action's own facts; never a secret or a person's data. | | requestId | The `X-Request-ID` of the request that did it. | | createdAt | When. | | prevHash, hash | The chain's links. | ## Endpoint - The window is `from` (included) to `to` (excluded): by default the last 30 days, at most 366 days per request. Page through a longer period, or through a window with more events than `limit`, with `cursor`: pass the previous page's `nextCursor`, which is `null` on the last page. - `400` with the message `` `from` must be before `to` ``, or `At most 366 days per request: page through longer periods`. ### `GET /v1/audit-events` Authentication: bearer token required. The tenant's audit trail, newest first, in pages. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `category` | string | no | `money`, `keys`, `governance`, `security`, `settings` or `access` | | `action` | string | no | Only actions starting with this, e.g. `wallet.` | | `from` | string (date-time) | no | Oldest event time; default 30 days before `to` | | `to` | string (date-time) | no | Newest event time, excluded; default now. At most 366 days after `from` | | `cursor` | integer | no | The `nextCursor` of the previous page | | `limit` | integer | no | 1–500, default 100 | **Responses** `200` OK ```json { "events": [ { "seq": 2, "category": "keys", "action": "wallet.key_exported", "actor": { "kind": "user", "id": "5e1f…", "role": null }, "subject": { "type": "wallet", "id": "7c1e…" }, "details": { "chain": "ethereum", "isTestnet": false }, "requestId": "a91099c3593d6c58e547b4aa9f66d9ac", "createdAt": "2026-10-05T10:12:00.000Z", "prevHash": "4b0c…", "hash": "e7a2…" } ], "nextCursor": 2 } ``` `200` Switched Off ```json { "events": [], "nextCursor": null } ``` `400` Window Too Long ```json { "statusCode": 400, "error": "Bad Request", "message": "At most 366 days per request: page through longer periods" } ``` **Example request** ```bash curl "https://api.aureahub.com/v1/audit-events?category=keys&limit=50" \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" ``` Web version: https://docs.aureahub.com/#audit-events --- # Security Posture What protects your tenant now, in one read: its administrators and their second factor, the quorums, its security profile, where its keys are kept, the platform's last verified backup, its server credentials and its active freezes. ## Overview - Counts and states only: no names, no addresses, no keys. Read it as often as you like; it changes nothing. - Each part is computed at the time of the request (`generatedAt`). - The backup part is the platform's: one database holds every tenant, and Aurea tests restoring its backup every week. ## Who Reads It - The tenant roles `owner`, `tenantadmin`, `non_signing_admin`, `auditor`, `security_admin` and `security_auditor` read their own tenant's posture ([Authentication](https://docs.aureahub.com/docs/authentication.md)). Other roles get `403`. ## What It Says | Part | Meaning | | --- | --- | | mfa | `administrators`: active users with a role that manages the tenant; `enrolled`: those of them with a second factor at sign-in; `available`: whether the platform offers a second factor at sign-in. The posture does not count second factors yet: it answers `available` `false` and `enrolled` `0`, also where Aurea enabled [two-factor sign-in](https://docs.aureahub.com/docs/guide-two-factor.md) for your tenant — each account's own is in [Your Second Factor](https://docs.aureahub.com/docs/mfa-status.md). | | quorum | `policyPublication`: `applies` when a policy set is published only by an administrator other than its author (institutional tenants, and tenants with two policy administrators or more), else `off`; `policyAdministrators`: active users who write the tenant's policy sets; `platformAdminQuorum`: whether a change by Aurea's administrators that lowers your tenant's protection waits for a second Aurea administrator (`enforce`), is recorded (`log`) or neither (`off`). | | securityProfile | `profile`: `legacy` or `institutional`; `controls`: the tenant's security controls; `atStrongest`: those at their strongest setting; `overrides`: those set for the tenant apart from the platform's setting. | | custody | `tier`: where the tenant's new custodial keys are kept — `standard` (encrypted by the platform), `premium` or `institutional` (inside a hardware security module); `keyStoreReady`: whether the tier's key store is in place; `wallets`: the tenant's wallets that are not deactivated, by where their key is — `inHsm`, `encryptedInDatabase`, `splitWithKeyVault`, `nonCustodial` (key on the user's device only), `mpc` (two-of-three), `other`. | | backups | `status`: the platform's weekly restore test of its database backup — `verified` when the latest run passed, `failed` when it failed, `not_reported` before any run; `lastVerifiedAt`: when a restore test last passed (a failed latest run keeps the earlier time), or `null`. | | serverCredentials | The tenant's active [server credentials](https://docs.aureahub.com/docs/guide-server-credentials.md): `active`, `withoutExpiry`, `expiringWithin30Days`, `nextExpiresAt` (or `null`), `withAllowList` and `withoutAllowList` (an IP allow-list). | | freezes | Active [freezes](https://docs.aureahub.com/docs/guide-freezes.md): `active`, `byTarget` (`tenant`, `user`, `wallet`, `account`) and `byPlatform` — those Aurea placed. | ## Endpoint ### `GET /v1/security-posture` Authentication: bearer token required. Your tenant's security posture. **Responses** `200` OK ```json { "tenantId": "5e1f…", "tenantName": "Acme Bank", "generatedAt": "2026-10-08T09:00:00.000Z", "mfa": { "available": false, "administrators": 3, "enrolled": 0 }, "quorum": { "policyPublication": "applies", "policyAdministrators": 2, "platformAdminQuorum": "log" }, "securityProfile": { "profile": "institutional", "controls": 12, "atStrongest": 12, "overrides": 0 }, "custody": { "tier": "premium", "keyStoreReady": true, "wallets": { "inHsm": 40, "encryptedInDatabase": 0, "splitWithKeyVault": 0, "nonCustodial": 125, "mpc": 8, "other": 0 } }, "backups": { "status": "verified", "lastVerifiedAt": "2026-10-04T03:41:12.000Z" }, "serverCredentials": { "active": 2, "withoutExpiry": 0, "expiringWithin30Days": 1, "nextExpiresAt": "2026-10-30T00:00:00.000Z", "withAllowList": 2, "withoutAllowList": 0 }, "freezes": { "active": 1, "byTarget": { "tenant": 0, "user": 0, "wallet": 1, "account": 0 }, "byPlatform": 0 } } ``` `403` Role Cannot Read It ```json { "statusCode": 403, "error": "ForbiddenError", "message": "Tenant admin access required. Your role does not have permission to perform this action." } ``` **Example request** ```bash curl "https://api.aureahub.com/v1/security-posture" \ -H "Authorization: Bearer YOUR_ACCESS_TOKEN" ``` Web version: https://docs.aureahub.com/#security-posture --- # List Server Credentials Your tenant's server credentials, newest first — active, expired and revoked. ## Overview - Each credential with its public key, its thumbprint, its role, allow-list and expiry, who made and last changed it, when a token of it was last accepted, its revocation, and `roleNarrows` — whether its role decides which routes its tokens reach (`true` for a credential registered since 6 October 2026; [What a Credential's Role Reaches](https://docs.aureahub.com/docs/guide-server-credentials.md)). There is no private key to show: Aurea never has one. How credentials work: [Server Credentials](https://docs.aureahub.com/docs/guide-server-credentials.md). - `status`: `active` (its tokens are accepted), `expired` (past `expiresAt`) or `revoked`. Filter with `status` (default `all`); page with `limit` (1–100, default 50) and `cursor`, the previous page's `nextCursor` (`null` on the last page). A cursor this list didn't give answers `400` `SERVER_CREDENTIAL_CURSOR_INVALID` (`cursor is not one this list gave`). - For the tenant roles `owner`, `tenantadmin`, `non_signing_admin` and `security_admin` ([Roles](https://docs.aureahub.com/docs/authentication.md)), on their own tenant. ## Endpoint ### `GET /v1/admin/tenants/{id}/server-credentials` Authentication: bearer token required. The tenant's server credentials, newest first. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Tenant UUID | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `status` | string | no | `all` (default), `active`, `expired` or `revoked` | | `limit` | integer | no | 1–100, default 50 | | `cursor` | string | no | The previous page's `nextCursor` | **Responses** `200` OK ```json { "data": [ { "id": "2f6d8a4e-1c3b-4e5f-9a7d-0b2c4e6f8a1c", "tenantId": "6f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", "name": "Payments server", "role": "signer", "status": "active", "publicKey": { "kty": "EC", "crv": "P-256", "x": "kTxt6XgA4C3r8RE3iDiksXglx-XqsKZBKcJXz8xb9Bg", "y": "2UOzdwbCN0bU_vBmtae1dx458v3t1PXqowMQIp9qahU" }, "thumbprint": "eh1D5t_soPjcyGjYMXEeNtyTxia1As4QO91SxspJPfM", "allowedIps": ["203.0.113.0/24"], "expiresAt": "2027-10-06T00:00:00.000Z", "createdAt": "2026-10-06T08:40:12.307Z", "createdBy": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "updatedAt": "2026-10-06T08:40:12.307Z", "updatedBy": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "lastUsedAt": "2026-10-06T09:02:55.871Z", "revokedAt": null, "revokedBy": null, "revokeReason": null, "roleNarrows": true } ], "nextCursor": null } ``` `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/#server-credentials-list --- # Register a Server Credential Register the public key of a key pair your server holds. Its tokens are accepted from the moment this answers. ## Overview - `publicKey`: a P-256 public key as a JWK, `{ "kty": "EC", "crv": "P-256", "x": "…", "y": "…" }` — `x` and `y` 32 bytes each in unpadded base64url, a point on the curve. `alg` (`ES256`), `use` (`sig`), `kid`, `key_ops` and `ext`, which key generators add, are accepted and dropped. Never the private key: a JWK with `d` is refused, and that key pair should not be used. How to make one: [Server Credentials](https://docs.aureahub.com/docs/guide-server-credentials.md). - `name`: your label, 1 to 100 characters. `role`: one of `tenantadmin`, `non_signing_admin`, `signer`, `approver`, `editor`, `viewer`, `auditor`, `security_admin`, `security_auditor` — only a role whose permissions are all yours (a `security_admin` does not issue a `signer`). The role decides which routes the credential's tokens reach: it is registered with `roleNarrows` `true` ([What a Credential's Role Reaches](https://docs.aureahub.com/docs/guide-server-credentials.md)). - `allowedIps` (optional): up to 50 IPv4 or IPv6 networks or addresses; empty or left out: any address. `expiresAt` (optional): in the future, at most 5 years ahead; `null` or left out: no expiry. - `201` with the credential: its `id` is the `kid` of its tokens. At most 10 active credentials per tenant. - For the tenant roles `owner`, `tenantadmin`, `non_signing_admin` and `security_admin`, on their own tenant. > ℹ️ **Your tenant's quorum, where Aurea enables it for your tenant** (a tenant on the institutional security profile by its profile): this call answers `202` `{ "state": "awaiting_quorum", "requestId" }` — the **202** tab below — and nothing is done until another administrator of your tenant approves it; the answer it would have given is then read once by who asked ([Read the Answer](https://docs.aureahub.com/docs/quorum-request-result.md)). With no other administrator able to approve it, it is done at once. [Your Tenant's Quorum](https://docs.aureahub.com/docs/guide-tenant-quorum.md). Every other tenant gets the answers below, unchanged. ## Endpoint ### `POST /v1/admin/tenants/{id}/server-credentials` Authentication: bearer token required. Registers a P-256 public key as a server credential of the tenant. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Tenant UUID | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | 1–100 characters | | `role` | string | yes | A tenant role within yours | | `publicKey` | object | yes | The public key as a JWK: `kty`, `crv`, `x`, `y` | | `allowedIps` | string[] | no | Networks or addresses its tokens are accepted from | | `expiresAt` | string (date-time) \| null | no | When its tokens stop being accepted | **Responses** `201` Registered ```json { "id": "2f6d8a4e-1c3b-4e5f-9a7d-0b2c4e6f8a1c", "tenantId": "6f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", "name": "Payments server", "role": "signer", "status": "active", "publicKey": { "kty": "EC", "crv": "P-256", "x": "kTxt6XgA4C3r8RE3iDiksXglx-XqsKZBKcJXz8xb9Bg", "y": "2UOzdwbCN0bU_vBmtae1dx458v3t1PXqowMQIp9qahU" }, "thumbprint": "eh1D5t_soPjcyGjYMXEeNtyTxia1As4QO91SxspJPfM", "allowedIps": ["203.0.113.0/24"], "expiresAt": "2027-10-06T00:00:00.000Z", "createdAt": "2026-10-06T08:40:12.307Z", "createdBy": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "updatedAt": "2026-10-06T08:40:12.307Z", "updatedBy": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "lastUsedAt": null, "revokedAt": null, "revokedBy": null, "revokeReason": null, "roleNarrows": true } ``` `202` Awaiting quorum ```json { "state": "awaiting_quorum", "requestId": "4e2b9c17-8a3d-4f60-b5e1-7c9d0a2f6b38" } ``` `400` Private key ```json { "statusCode": 400, "error": "BadRequestError", "message": "publicKey carries a private key (\"d\"): send the public key only, and do not use this key pair", "details": { "code": "SERVER_CREDENTIAL_PRIVATE_KEY" } } ``` `409` Key in use ```json { "statusCode": 409, "error": "ConflictError", "message": "This public key is already registered: generate a new key pair", "details": { "code": "SERVER_CREDENTIAL_KEY_IN_USE" } } ``` ## Refusals Nothing is registered. A body that does not fit the schema — a missing field, a wrong type, a name of spaces only — answers `400` `Request validation failed`; the rest carry `details.code`: - `400` `SERVER_CREDENTIAL_KEY_INVALID` — `publicKey must be a P-256 public key as a JWK:` and why, for example `crv must be "P-256"`, `x must be 32 bytes in unpadded base64url` or `the point is not on the curve`. - `400` `SERVER_CREDENTIAL_PRIVATE_KEY` — the JWK carries `d`. - `400` `SERVER_CREDENTIAL_ROLE_INVALID` — `role must be one of tenantadmin, non_signing_admin, signer, approver, editor, viewer, auditor, security_admin, security_auditor`. `SERVER_CREDENTIAL_NAME_INVALID` — `name must hold 1 to 100 characters`. - `400` `SERVER_CREDENTIAL_IP_INVALID`, with `details.index` — `allowedIps[]` and why, for an entry that is not an address or network, has host bits under its mask, or is IPv4-mapped IPv6. - `400` `SERVER_CREDENTIAL_EXPIRY_INVALID` — `expiresAt must be a time in the future, at most 5 years ahead (or null: no expiry)`. - `403` `SERVER_CREDENTIAL_ROLE_ABOVE_OWN` — `A credential's role holds permissions your role does not`. - `404` `TENANT_NOT_FOUND` — `Tenant not found`. - `409` `SERVER_CREDENTIAL_KEY_IN_USE` — the key was registered before, here or anywhere, revoked or not. `SERVER_CREDENTIAL_LIMIT` — `A tenant holds at most 10 active server credentials: revoke one first`. Web version: https://docs.aureahub.com/#server-credential-create --- # Change a Server Credential Its name, role, allow-list or expiry. The key never changes: a new key is a new credential. ## Overview - Only the fields sent change; each follows the rules of [Register a Credential](https://docs.aureahub.com/docs/server-credential-create.md), with the same refusals. A new role keeps the credential's `roleNarrows`. `"expiresAt": null` removes the expiry; `"allowedIps": []` allows any address. A change applies to the credential's next token. - Nothing to change answers `400` `SERVER_CREDENTIAL_NOTHING_TO_CHANGE` (`Nothing to change: send name, role, allowedIps or expiresAt (a credential's key never changes: register a new credential instead)`). - `403` `SERVER_CREDENTIAL_ROLE_ABOVE_OWN` when the new role, or the credential's current one, holds a permission yours does not. - `404` `SERVER_CREDENTIAL_NOT_FOUND` (`Server credential not found`) for a credential that is not this tenant's. `409` `SERVER_CREDENTIAL_REVOKED` (`This server credential is revoked`), or `SERVER_CREDENTIAL_EXPIRED` (`This server credential has expired: register a new one`). - Same roles as Register a Credential. > ℹ️ **Your tenant's quorum, where Aurea enables it for your tenant** (a tenant on the institutional security profile by its profile): this call answers `202` `{ "state": "awaiting_quorum", "requestId" }` — the **202** tab below — and nothing is done until another administrator of your tenant approves it; the answer it would have given is then read once by who asked ([Read the Answer](https://docs.aureahub.com/docs/quorum-request-result.md)). With no other administrator able to approve it, it is done at once. [Your Tenant's Quorum](https://docs.aureahub.com/docs/guide-tenant-quorum.md). Every other tenant gets the answers below, unchanged. ## Endpoint ### `PATCH /v1/admin/tenants/{id}/server-credentials/{credentialId}` Authentication: bearer token required. Changes a server credential's name, role, allow-list or expiry. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Tenant UUID | | `credentialId` | string | yes | The credential's id | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | 1–100 characters | | `role` | string | no | A tenant role within yours | | `allowedIps` | string[] | no | Replaces the allow-list; empty: any address | | `expiresAt` | string (date-time) \| null | no | A new expiry; `null`: none | **Responses** `200` Changed ```json { "id": "2f6d8a4e-1c3b-4e5f-9a7d-0b2c4e6f8a1c", "tenantId": "6f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", "name": "Payments server", "role": "signer", "status": "active", "publicKey": { "kty": "EC", "crv": "P-256", "x": "kTxt6XgA4C3r8RE3iDiksXglx-XqsKZBKcJXz8xb9Bg", "y": "2UOzdwbCN0bU_vBmtae1dx458v3t1PXqowMQIp9qahU" }, "thumbprint": "eh1D5t_soPjcyGjYMXEeNtyTxia1As4QO91SxspJPfM", "allowedIps": ["203.0.113.0/24", "2001:db8::/32"], "expiresAt": null, "createdAt": "2026-10-06T08:40:12.307Z", "createdBy": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "updatedAt": "2026-10-06T11:20:48.015Z", "updatedBy": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "lastUsedAt": "2026-10-06T11:19:03.442Z", "revokedAt": null, "revokedBy": null, "revokeReason": null, "roleNarrows": true } ``` `202` Awaiting quorum ```json { "state": "awaiting_quorum", "requestId": "4e2b9c17-8a3d-4f60-b5e1-7c9d0a2f6b38" } ``` `409` Revoked ```json { "statusCode": 409, "error": "ConflictError", "message": "This server credential is revoked", "details": { "code": "SERVER_CREDENTIAL_REVOKED" } } ``` Web version: https://docs.aureahub.com/#server-credential-update --- # Revoke a Server Credential For good, and at once: its next token is refused, the credential never changes again, and your endpoints that ask for it are told. ## Overview - `reason`: 1 to 500 characters, kept with the credential (`revokeReason`), with who revoked it (`revokedBy`) and when (`revokedAt`); `updatedAt` and `updatedBy` keep the last change before it. - Its tokens answer `401` `SERVER_CREDENTIAL_REVOKED` from the next request, also a token signed before. The credential stays listed with `status` `revoked`; its key can never be registered again. - Revoking only protects: any of the roles that manage credentials revokes any credential of its tenant, whatever the credential's role. An expired credential can be revoked too. - `404` `SERVER_CREDENTIAL_NOT_FOUND` for a credential that is not this tenant's; `409` `SERVER_CREDENTIAL_REVOKED` when it already is. - It takes effect at once, whoever revokes. In the same step, the event `server_credential.revoked` — `data` the credential as this route answers it — is queued for every active webhook endpoint of your tenant that names it, in production and in sandbox, with one event id. An endpoint that names no type, and the webhook of an environment, never receive it: name it on an endpoint ([Events to Your Server](https://docs.aureahub.com/docs/guide-events.md)). ## Endpoint ### `POST /v1/admin/tenants/{id}/server-credentials/{credentialId}/revoke` Authentication: bearer token required. Revokes a server credential for good. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Tenant UUID | | `credentialId` | string | yes | The credential's id | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `reason` | string | yes | 1–500 characters | **Responses** `200` Revoked ```json { "id": "2f6d8a4e-1c3b-4e5f-9a7d-0b2c4e6f8a1c", "tenantId": "6f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", "name": "Payments server", "role": "signer", "status": "revoked", "publicKey": { "kty": "EC", "crv": "P-256", "x": "kTxt6XgA4C3r8RE3iDiksXglx-XqsKZBKcJXz8xb9Bg", "y": "2UOzdwbCN0bU_vBmtae1dx458v3t1PXqowMQIp9qahU" }, "thumbprint": "eh1D5t_soPjcyGjYMXEeNtyTxia1As4QO91SxspJPfM", "allowedIps": ["203.0.113.0/24"], "expiresAt": "2027-10-06T00:00:00.000Z", "createdAt": "2026-10-06T08:40:12.307Z", "createdBy": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "updatedAt": "2026-10-06T08:40:12.307Z", "updatedBy": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "lastUsedAt": "2026-10-20T07:41:09.006Z", "revokedAt": "2026-10-20T07:55:31.210Z", "revokedBy": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "revokeReason": "Rotated to the new payments server key", "roleNarrows": true } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/admin/tenants/$TENANT_ID/server-credentials/$CREDENTIAL_ID/revoke \ -H "Authorization: Bearer $TENANT_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "reason": "Rotated to the new payments server key" }' ``` Web version: https://docs.aureahub.com/#server-credential-revoke --- # Freeze a User, a Wallet or an Account Stop one of your tenant's users, one of its wallets, or the wallets of one of its accounts, on every server, from the moment this answers. ## Overview - `target` `user`: every request of that user is refused with `423` `USER_FROZEN`, reads included. `target` `wallet`: every signature and send of it is refused with `423` `WALLET_FROZEN`, on every chain it has in your tenant. `target` `account`: every wallet the [account](https://docs.aureahub.com/docs/guide-accounts.md) holds is refused so, with `423` `ACCOUNT_FROZEN`; `id` is the account's id, or a user's id for that user's default account. What each stops: [Freezes & Quarantines](https://docs.aureahub.com/docs/guide-freezes.md). - `201` with the freeze, about a second after it is written: by then it is in force everywhere. - `until` (optional): the freeze ends by itself then. It must be in the future: `400` `FREEZE_UNTIL_PAST` (`` `until` must be in the future. ``). - Refusals: `404` `FREEZE_TARGET_NOT_FOUND` (`No such user here.`, `No such wallet here.`, `No such account here.`) for a user, wallet or account that is not your tenant's; `409` `ALREADY_FROZEN` (`This is already frozen.`); `403` `FREEZE_SELF` (`Nobody freezes, or lifts a freeze of, their own account.`); `400` `FREEZE_REASON_INVALID` (`A reason of 1 to 500 characters is required.`). - For the tenant roles `owner`, `tenantadmin`, `non_signing_admin` and `security_admin`, on their own tenant. ## Endpoint ### `POST /v1/admin/tenants/{id}/freezes` Authentication: bearer token required. Freezes a user, a wallet or an account of the tenant. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Tenant UUID | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `target` | string | yes | `user`, `wallet` or `account` | | `id` | string | yes | The user's, the wallet's or the account's id (a user's default account: the user's id) | | `reason` | string | yes | 1–500 characters | | `until` | string (date-time) | no | When the freeze ends by itself; in the future. Left out: until lifted | **Responses** `201` Frozen ```json { "id": "a3e9c1d7-5b2f-4e86-9d0a-7c4b1e8f2d63", "target": "wallet", "targetId": "7c1e9b24-3f5a-4d8e-9b6c-1a2f3e4d5c6b", "tenantId": "6f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", "reason": "Device reported stolen", "frozenBy": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "frozenByRole": "security_admin", "frozenByPlatform": false, "frozenAt": "2026-10-06T10:42:05.118Z", "until": null, "active": true, "liftedAt": null, "liftedBy": null, "liftedByRole": null, "liftReason": null } ``` `409` Already frozen ```json { "statusCode": 409, "error": "ConflictError", "message": "This is already frozen.", "details": { "code": "ALREADY_FROZEN" } } ``` Web version: https://docs.aureahub.com/#freezes-create --- # List Freezes Your tenant's freezes, newest first — the tenant's own included, if Aurea placed one. ## Overview - `status`: `active` (default) — in force now; `lifted` — lifted, or past its `until`; `all`. `target`: `tenant`, `user`, `wallet` or `account`. `limit`: 1–200, default 50. - `frozenByPlatform` `true`: an Aurea administrator placed it, and only one lifts it. - For the tenant roles `owner`, `tenantadmin`, `non_signing_admin`, `auditor`, `security_admin` and `security_auditor`, on their own tenant. ## Endpoint ### `GET /v1/admin/tenants/{id}/freezes` Authentication: bearer token required. The tenant's freezes, newest first. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Tenant UUID | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `target` | string | no | `tenant`, `user`, `wallet` or `account` | | `status` | string | no | `active` (default), `lifted` or `all` | | `limit` | integer | no | 1–200, default 50 | **Responses** `200` OK ```json { "data": [ { "id": "a3e9c1d7-5b2f-4e86-9d0a-7c4b1e8f2d63", "target": "wallet", "targetId": "7c1e9b24-3f5a-4d8e-9b6c-1a2f3e4d5c6b", "tenantId": "6f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", "reason": "Device reported stolen", "frozenBy": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "frozenByRole": "security_admin", "frozenByPlatform": false, "frozenAt": "2026-10-06T10:42:05.118Z", "until": null, "active": true, "liftedAt": null, "liftedBy": null, "liftedByRole": null, "liftReason": null } ] } ``` Web version: https://docs.aureahub.com/#freezes-list --- # Lift a Freeze Let a frozen user, wallet or account act again, on every server, from the moment this answers. ## Overview - `reason`: 1 to 500 characters, kept as `liftReason` with who lifted it and when. `200` with the freeze, now `active: false`. - Never a freeze of your tenant itself, nor one an Aurea administrator placed: `403` `FREEZE_LIFT_PLATFORM_ONLY` (`Only an Aurea administrator lifts this freeze.`). Never a freeze of yourself, of one of your wallets, or of an account you own — your default account included: `403` `FREEZE_SELF`. - `404` `FREEZE_NOT_FOUND` (`No such freeze here.`) for a freeze that is not your tenant's; `409` `FREEZE_NOT_ACTIVE` (`This freeze is already lifted.`). - Same roles as [Freeze](https://docs.aureahub.com/docs/freezes-create.md). ## Endpoint ### `POST /v1/admin/tenants/{id}/freezes/{freezeId}/lift` Authentication: bearer token required. Lifts a freeze of the tenant's user or wallet. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Tenant UUID | | `freezeId` | string | yes | The freeze's id | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `reason` | string | yes | 1–500 characters | **Responses** `200` Lifted ```json { "id": "a3e9c1d7-5b2f-4e86-9d0a-7c4b1e8f2d63", "target": "wallet", "targetId": "7c1e9b24-3f5a-4d8e-9b6c-1a2f3e4d5c6b", "tenantId": "6f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", "reason": "Device reported stolen", "frozenBy": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "frozenByRole": "security_admin", "frozenByPlatform": false, "frozenAt": "2026-10-06T10:42:05.118Z", "until": null, "active": false, "liftedAt": "2026-10-06T15:03:44.902Z", "liftedBy": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "liftedByRole": "security_admin", "liftReason": "Device recovered" } ``` `403` Aurea's freeze ```json { "statusCode": 403, "error": "ForbiddenError", "message": "Only an Aurea administrator lifts this freeze.", "details": { "code": "FREEZE_LIFT_PLATFORM_ONLY" } } ``` Web version: https://docs.aureahub.com/#freeze-lift --- # Quarantine an Operation Hold one pending operation of your tenant before anything of it is signed or sent, until the quarantine is lifted. ## Overview - `operation`: `earn_operation`, `bank_payout` or `agent_execution`; `id`: the operation's id — the Earn operation's, the wallet payout's `payoutId`, the agent execution's. What counts as pending and where it is held: [Quarantine an Operation](https://docs.aureahub.com/docs/guide-freezes.md). - `201` with the quarantine; `walletId` is the wallet that would sign the operation. From then on, that signature or send answers `423` `OPERATION_QUARANTINED`. - Refusals: `404` `OPERATION_NOT_FOUND` (`No such operation here.`); `409` `OPERATION_NOT_PENDING` (`Only a pending operation can be quarantined: this one is not waiting for anything.`, with `details.state`), `OPERATION_NOT_QUARANTINABLE` (with `details.reason`), `ALREADY_QUARANTINED` (`This operation is already quarantined.`). - Same roles as [Freeze](https://docs.aureahub.com/docs/freezes-create.md). ## Endpoint ### `POST /v1/admin/tenants/{id}/quarantines` Authentication: bearer token required. Quarantines a pending operation of the tenant. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Tenant UUID | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `operation` | string | yes | `earn_operation`, `bank_payout` or `agent_execution` | | `id` | string | yes | The operation's id | | `reason` | string | yes | 1–500 characters | **Responses** `201` Quarantined ```json { "id": "f1c8a2e6-0d4b-4b97-8e3c-5a9d2f7b1c40", "operation": "bank_payout", "operationId": "3b9f6d2a-7e1c-4a85-b0d4-8c2e5f1a9b37", "tenantId": "6f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", "walletId": "7c1e9b24-3f5a-4d8e-9b6c-1a2f3e4d5c6b", "reason": "Beneficiary under review", "quarantinedBy": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "quarantinedByRole": "tenantadmin", "quarantinedByPlatform": false, "quarantinedAt": "2026-10-06T11:05:12.640Z", "active": true, "liftedAt": null, "liftedBy": null, "liftedByRole": null, "liftReason": null } ``` `409` Not pending ```json { "statusCode": 409, "error": "ConflictError", "message": "Only a pending operation can be quarantined: this one is not waiting for anything.", "details": { "code": "OPERATION_NOT_PENDING", "state": "completed" } } ``` Web version: https://docs.aureahub.com/#quarantines-create --- # List Quarantines Your tenant's quarantined operations, newest first. ## Overview - `status`: `active` (default), `lifted` or `all`. `operation`: `earn_operation`, `bank_payout` or `agent_execution`. `limit`: 1–200, default 50. - Same readers as [List Freezes](https://docs.aureahub.com/docs/freezes-list.md). ## Endpoint ### `GET /v1/admin/tenants/{id}/quarantines` Authentication: bearer token required. The tenant's quarantined operations, newest first. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Tenant UUID | **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `operation` | string | no | `earn_operation`, `bank_payout` or `agent_execution` | | `status` | string | no | `active` (default), `lifted` or `all` | | `limit` | integer | no | 1–200, default 50 | **Responses** `200` OK ```json { "data": [ { "id": "f1c8a2e6-0d4b-4b97-8e3c-5a9d2f7b1c40", "operation": "bank_payout", "operationId": "3b9f6d2a-7e1c-4a85-b0d4-8c2e5f1a9b37", "tenantId": "6f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", "walletId": "7c1e9b24-3f5a-4d8e-9b6c-1a2f3e4d5c6b", "reason": "Beneficiary under review", "quarantinedBy": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "quarantinedByRole": "tenantadmin", "quarantinedByPlatform": false, "quarantinedAt": "2026-10-06T11:05:12.640Z", "active": true, "liftedAt": null, "liftedBy": null, "liftedByRole": null, "liftReason": null } ] } ``` Web version: https://docs.aureahub.com/#quarantines-list --- # Lift a Quarantine Let a quarantined operation go on from where it waited. ## Overview - `reason`: 1 to 500 characters. `200` with the quarantine, now `active: false`. The operation's own deadline was not extended while it waited: an Earn operation's signing window or an approval's may have passed. - Never one an Aurea administrator placed: `403` `FREEZE_LIFT_PLATFORM_ONLY` (`Only an Aurea administrator lifts this quarantine.`). - `404` `QUARANTINE_NOT_FOUND` (`No such quarantine here.`); `409` `QUARANTINE_NOT_ACTIVE` (`This quarantine is already lifted.`). - Same roles as [Freeze](https://docs.aureahub.com/docs/freezes-create.md). ## Endpoint ### `POST /v1/admin/tenants/{id}/quarantines/{quarantineId}/lift` Authentication: bearer token required. Lifts a quarantine of the tenant's operation. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string | yes | Tenant UUID | | `quarantineId` | string | yes | The quarantine's id | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `reason` | string | yes | 1–500 characters | **Responses** `200` Lifted ```json { "id": "f1c8a2e6-0d4b-4b97-8e3c-5a9d2f7b1c40", "operation": "bank_payout", "operationId": "3b9f6d2a-7e1c-4a85-b0d4-8c2e5f1a9b37", "tenantId": "6f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", "walletId": "7c1e9b24-3f5a-4d8e-9b6c-1a2f3e4d5c6b", "reason": "Beneficiary under review", "quarantinedBy": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "quarantinedByRole": "tenantadmin", "quarantinedByPlatform": false, "quarantinedAt": "2026-10-06T11:05:12.640Z", "active": false, "liftedAt": "2026-10-06T11:47:30.215Z", "liftedBy": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "liftedByRole": "tenantadmin", "liftReason": "Beneficiary confirmed" } ``` Web version: https://docs.aureahub.com/#quarantine-lift --- # List Policy Sets Your tenant's policy sets — each a version of its signing policy — newest first, without their rules. ## Overview - `status` keeps one status: `draft`, `awaiting_quorum`, `active` or `archived`. Page with `limit` (1–200, default 50) and `beforeVersion`, the previous page's `nextBeforeVersion` (`null` on the last page). - The rules are in [Get a Policy Set](https://docs.aureahub.com/docs/policy-set-get.md). How sets work: [Policy Sets](https://docs.aureahub.com/docs/guide-policy-sets.md) — enforced on the institutional profile, evaluated and logged on any other. ## Endpoint ### `GET /v1/policy-sets` Authentication: bearer token required. The tenant's policy sets, newest version first, without rules. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `status` | string | no | `draft`, `awaiting_quorum`, `active` or `archived` | | `beforeVersion` | integer | no | The previous page's `nextBeforeVersion` | | `limit` | integer | no | 1–200, default 50 | **Responses** `200` OK ```json { "sets": [ { "id": "b7e2c9a4-3d1f-4a68-9c05-8e4b2d7f1a36", "tenantId": "6f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", "version": 2, "status": "active", "description": "Approvals above 10 000 EUR", "authorUserId": "0d2f6b8a-4c1e-4a93-b7d5-2e9f1c3a8b60", "requiredApprovals": 1, "approverUserIds": ["6a1c9e3f-2b7d-4f50-8e4a-0c5d3b9f7e12"], "submittedAt": "2026-10-06T09:20:41.003Z", "activatedAt": "2026-10-06T09:48:12.560Z", "archivedAt": null, "createdAt": "2026-10-06T09:02:18.744Z", "copiedFromSetId": null, "activatedBy": "6a1c9e3f-2b7d-4f50-8e4a-0c5d3b9f7e12", "archivedBy": null, "ruleCount": 3 } ], "nextBeforeVersion": null } ``` Web version: https://docs.aureahub.com/#policy-sets-list --- # Create a Draft Policy Set A new draft, the tenant's next version: empty, with the rules you give, or a copy of any version's rules. ## Overview - `rules`: the rules in order ([Rules](https://docs.aureahub.com/docs/guide-policy-sets.md)), or `copyFromSetId`: a version of any status to copy them from — not both (`400` `POLICY_REQUEST_INVALID` `Give rules or copyFromSetId, not both`). Neither: an empty draft. - `description`: optional, trimmed. The caller is the author: only the author edits and submits the draft. - `201` with the set and its rules, each with its `id` and `position` (from 0). A draft decides nothing: only the active set does. - `400` `POLICY_RULES_INVALID` for a rule written wrong; `404` `POLICY_COPY_SOURCE_NOT_FOUND` for a set to copy that is not your tenant's. ## Endpoint ### `POST /v1/policy-sets` Authentication: bearer token required. Creates a draft policy set, the tenant's next version. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `description` | string \| null | no | What the version is for | | `rules` | object[] | no | Up to 500 rules, in order | | `copyFromSetId` | string | no | A version whose rules to copy | **Responses** `201` Created ```json { "id": "4c8a1e5f-9b2d-4e73-a6c0-1d7f3b9e2a58", "tenantId": "6f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", "version": 3, "status": "draft", "description": "Block deployments", "authorUserId": "0d2f6b8a-4c1e-4a93-b7d5-2e9f1c3a8b60", "requiredApprovals": 0, "approverUserIds": [], "submittedAt": null, "activatedAt": null, "archivedAt": null, "createdAt": "2026-10-06T12:10:05.331Z", "copiedFromSetId": null, "activatedBy": null, "archivedBy": null, "ruleCount": 1, "rules": [ { "id": "e2b6d9f1-7a3c-4c58-8b0e-5f1a4d2c9b73", "position": 0, "name": "No contract deployments", "verdict": "BLOCK", "operationTypes": ["DEPLOY"], "initiatorKinds": null, "initiatorIds": null, "sourceKinds": null, "sourceIds": null, "destinationKinds": null, "destinationAddresses": null, "chains": null, "assets": null, "amountMinRef": null, "amountMaxRef": null, "windowScope": null, "windowSeconds": null, "windowMinRef": null, "windowMaxRef": null, "methodSelectors": null, "timeDays": null, "timeFromMinute": null, "timeToMinute": null, "screeningResults": null, "approvalThreshold": null, "approverIds": null, "approversExcludeInitiator": true, "designatedSignerIds": null } ] } ``` `400` Rule refused ```json { "statusCode": 400, "error": "BadRequestError", "message": "Invalid policy rules", "details": { "code": "POLICY_RULES_INVALID", "errors": ["0.verdict: Invalid enum value. Expected 'ALLOW' | 'SCREEN' | 'REQUIRE_APPROVAL' | 'BLOCK' | 'FREEZE', received 'DENY'"] } } ``` Web version: https://docs.aureahub.com/#policy-set-create --- # Get a Policy Set One version with its rules, in the order they are evaluated. ## Overview - The set as [List Policy Sets](https://docs.aureahub.com/docs/policy-sets-list.md) shows it, plus `rules` — each with every field, `null` where it matches anything — as [Create a Draft](https://docs.aureahub.com/docs/policy-set-create.md) answers it. - `404` `POLICY_SET_NOT_FOUND` (`Policy set not found`), also for another tenant's set. ## Endpoint ### `GET /v1/policy-sets/{setId}` Authentication: bearer token required. A policy set with its rules in order. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `setId` | string | yes | The set's id | **Responses** `404` Not found ```json { "statusCode": 404, "error": "NotFoundError", "message": "Policy set not found", "details": { "code": "POLICY_SET_NOT_FOUND" } } ``` Web version: https://docs.aureahub.com/#policy-set-get --- # Replace a Draft's Rules The draft's rules, replaced by these, in their order. ## Overview - Only a draft, and only by its author: `409` `POLICY_SET_NOT_DRAFT` for a set in another status (it never changes again: copy it into a new draft), `403` `POLICY_SET_NOT_AUTHOR` for another administrator. - `rules`: up to 500 ([Rules](https://docs.aureahub.com/docs/guide-policy-sets.md)); `[]` empties the draft. `400` `POLICY_RULES_INVALID` for a rule written wrong — nothing is replaced. - `200` with the set and its new rules. ## Endpoint ### `PUT /v1/policy-sets/{setId}/rules` Authentication: bearer token required. Replaces a draft's rules, in order. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `setId` | string | yes | The draft's id | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `rules` | object[] | yes | Up to 500 rules, in order | **Responses** `409` Not a draft ```json { "statusCode": 409, "error": "ConflictError", "message": "A policy set that is active is not edited: copy it into a new draft", "details": { "code": "POLICY_SET_NOT_DRAFT", "status": "active" } } ``` `403` Not the author ```json { "statusCode": 403, "error": "ForbiddenError", "message": "Only the author of a draft edits or submits it: copy it into a draft of your own", "details": { "code": "POLICY_SET_NOT_AUTHOR" } } ``` Web version: https://docs.aureahub.com/#policy-set-rules --- # Validate a Policy Set Errors that stop a submission, and warnings that don't. Changes nothing. ## Overview - Any version can be validated. `valid` is `true` when there is no error. Each finding has its `code`, the rule's `position` (`null` for the set as a whole) and a `message`. The codes: [Validate and Simulate](https://docs.aureahub.com/docs/guide-policy-sets.md). - Submitting validates again: a set with an error answers `422` `POLICY_SET_INVALID`. ## Endpoint ### `POST /v1/policy-sets/{setId}/validate` Authentication: bearer token required. Validates a policy set. No body. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `setId` | string | yes | The set's id | **Responses** `200` Valid ```json { "valid": true, "ruleCount": 3, "errors": [], "warnings": [] } ``` `200` Empty draft ```json { "valid": true, "ruleCount": 0, "errors": [], "warnings": [ { "code": "EMPTY_SET", "position": null, "message": "The set has no rule: a legacy tenant's default allows every operation, the institutional default blocks every one." } ] } ``` Web version: https://docs.aureahub.com/#policy-set-validate --- # Simulate a Policy Set on Your History What a set would have decided on your tenant's past operations, at each operation's own time. Changes nothing. ## Overview - Any version — a draft too. `days`: the window, default 30, at most 90. `limit`: the newest operations evaluated, default 2000, at most 5000; `operations.truncated` says the window held more (`truncatedBy` `limit`), or that the evaluation stopped at its time budget (`deadline`). - **What it reads**, newest first (`operations.bySource`): transactions sent from custodial wallets, transactions prepared for client-side and MPC wallets, gasless permits, Earn operations, and recorded [operations](https://docs.aureahub.com/docs/guide-operations.md) that have none of those. - **Amounts** in EUR from the token prices Aurea keeps; a signature whose token or price is not known stays unpriced (`amounts.unpriced`), and the rules treat its amount as missing. A rolling window's total and screening results are not known to a simulation. - **Results** under your tenant's profile (`results.tenant`) and under the institutional profile's defaults (`results.institutional`): counts per verdict, per rule (`matched`, and up to 10 operations as `{ source, id }`), for operations no rule decided (`defaultDecision`), and how many operations each reason was given for (`missing:`, `possible-match:`, `explicit-rule-required:`, `rule-matched`, `no-matching-rule`). - At most **10 a minute**. `503` `POLICY_SIMULATION_TIMEOUT` when the history cannot be read in time: simulate a shorter window or fewer operations. ## Endpoint ### `POST /v1/policy-sets/{setId}/simulate` Authentication: bearer token required. Evaluates a policy set on the tenant's past operations. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `setId` | string | yes | The set's id | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `days` | integer | no | 1–90, default 30 | | `limit` | integer | no | 1–5000, default 2000 | **Responses** `200` OK ```json { "policySetId": "4c8a1e5f-9b2d-4e73-a6c0-1d7f3b9e2a58", "policySetVersion": 3, "policySetStatus": "draft", "tenantProfile": "legacy", "window": { "from": "2026-09-06T12:15:00.000Z", "to": "2026-10-06T12:15:00.000Z", "days": 30 }, "limits": { "maxOperations": 2000, "samplePerRule": 10, "readBudgetMs": 5000, "evaluationBudgetMs": 10000 }, "operations": { "read": 412, "evaluated": 412, "truncated": false, "truncatedBy": null, "bySource": { "custodial_transaction": 301, "prepared_transaction": 99, "gasless_permit": 12, "earn_operation": 0, "operation": 0 } }, "amounts": { "referenceCurrency": "EUR", "priced": 398, "unpriced": 14 }, "results": { "tenant": { "profile": "legacy", "verdicts": { "ALLOW": 410, "SCREEN": 0, "REQUIRE_APPROVAL": 0, "BLOCK": 2, "FREEZE": 0 }, "rules": [ { "ruleId": "e2b6d9f1-7a3c-4c58-8b0e-5f1a4d2c9b73", "position": 0, "name": "No contract deployments", "verdict": "BLOCK", "matched": 2, "sample": [ { "source": "custodial_transaction", "id": "9a4c2e7b-1d5f-4b83-a0e6-3c8d1f5b7e29" }, { "source": "prepared_transaction", "id": "2e7b5d1a-8c3f-4e96-b4a0-6d1c9f3e5b82" } ] } ], "defaultDecision": { "verdict": "ALLOW", "matched": 410, "sample": [] }, "reasons": { "rule-matched": 2, "no-matching-rule": 410 } }, "institutional": { "profile": "institutional", "verdicts": { "ALLOW": 0, "SCREEN": 0, "REQUIRE_APPROVAL": 0, "BLOCK": 412, "FREEZE": 0 }, "rules": [ { "ruleId": "e2b6d9f1-7a3c-4c58-8b0e-5f1a4d2c9b73", "position": 0, "name": "No contract deployments", "verdict": "BLOCK", "matched": 2, "sample": [ { "source": "custodial_transaction", "id": "9a4c2e7b-1d5f-4b83-a0e6-3c8d1f5b7e29" }, { "source": "prepared_transaction", "id": "2e7b5d1a-8c3f-4e96-b4a0-6d1c9f3e5b82" } ] } ], "defaultDecision": { "verdict": "BLOCK", "matched": 410, "sample": [] }, "reasons": { "rule-matched": 2, "no-matching-rule": 410 } } } } ``` The example's `sample` lists of the default decision are shortened to `[]`; they hold up to 10 operations like the rules' samples. Web version: https://docs.aureahub.com/#policy-set-simulate --- # Submit a Draft `draft` → `awaiting_quorum`, once valid. From then on the version never changes. ## Overview - By the draft's author only (`403` `POLICY_SET_NOT_AUTHOR`), and only a draft (`409` `POLICY_SET_NOT_DRAFT`). - Validated first: with an error, `422` `POLICY_SET_INVALID` lists the errors and the set stays a draft. - `200` with the set: `submittedAt`, and `requiredApprovals` — `1` when another administrator must publish it, else `0` ([Who Publishes](https://docs.aureahub.com/docs/guide-policy-sets.md)). ## Endpoint ### `POST /v1/policy-sets/{setId}/submit` Authentication: bearer token required. Submits a valid draft for publication. No body. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `setId` | string | yes | The draft's id | **Responses** `422` Not valid ```json { "statusCode": 422, "error": "ValidationError", "message": "The policy set is not valid", "details": { "code": "POLICY_SET_INVALID", "errors": [ { "code": "APPROVER_UNKNOWN", "position": 0, "message": "Rule 0 names 1 approver(s) that are not users of the tenant." } ] } } ``` Web version: https://docs.aureahub.com/#policy-set-submit --- # Publish a Policy Set `awaiting_quorum` → `active`: the set becomes the tenant's policy, and the one it replaces is archived. ## Overview - When the tenant's quorum applies — two administrators or more, or the `institutional` profile — an administrator other than the set's author publishes it; the author answers `403` `POLICY_QUORUM_AUTHOR`. With a single administrator on the `legacy` profile, the author publishes alone. [Who Publishes](https://docs.aureahub.com/docs/guide-policy-sets.md). - Only a set awaiting its quorum: `409` `POLICY_SET_NOT_AWAITING` otherwise. - `200` with the set: `activatedAt`, `activatedBy` and, when the publisher is not the author, `approverUserIds`. - On the institutional profile the published set decides every signature from then on; on any other it is evaluated and logged, and publishing changes no answer of any route ([Policy Sets](https://docs.aureahub.com/docs/guide-policy-sets.md)). ## Endpoint ### `POST /v1/policy-sets/{setId}/publish` Authentication: bearer token required. Makes a submitted set the tenant's active one. No body. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `setId` | string | yes | The set's id | **Responses** `403` The author ```json { "statusCode": 403, "error": "ForbiddenError", "message": "The tenant's quorum applies: an administrator other than the author publishes this policy set", "details": { "code": "POLICY_QUORUM_AUTHOR" } } ``` `409` Not awaiting ```json { "statusCode": 409, "error": "ConflictError", "message": "A policy set that is draft is not published: submit a draft first", "details": { "code": "POLICY_SET_NOT_AWAITING", "status": "draft" } } ``` Web version: https://docs.aureahub.com/#policy-set-publish --- # Withdraw a Policy Set A draft, or a set awaiting its quorum, → `archived`. ## Overview - The active set is not withdrawn this way: publish the version that replaces it (`409` `POLICY_SET_ACTIVE`). An archived set answers `409` `POLICY_SET_ARCHIVED`. - `200` with the set: `archivedAt` and `archivedBy`. ## Endpoint ### `POST /v1/policy-sets/{setId}/archive` Authentication: bearer token required. Withdraws a draft or a set awaiting its quorum. No body. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `setId` | string | yes | The set's id | **Responses** `409` Active ```json { "statusCode": 409, "error": "ConflictError", "message": "The active policy set is archived only when another version is published in its place", "details": { "code": "POLICY_SET_ACTIVE", "status": "active" } } ``` Web version: https://docs.aureahub.com/#policy-set-archive --- # Roll Back to an Earlier Version Publish a version that was once active again, as a new version. ## Overview - `setId` names an archived version that was active once. Its rules are copied exactly into a new version (`copiedFromSetId`), authored by the caller and submitted. - `201` with the new version: `active` at once where the tenant's quorum is off, `awaiting_quorum` where it applies — then another administrator [publishes](https://docs.aureahub.com/docs/policy-set-publish.md) it. - `description`: optional, for the new version. - `409` `POLICY_SET_NOT_ARCHIVED` (`Only an archived version is rolled back to`) or `POLICY_SET_NEVER_ACTIVE` (`This version was never active: copy it into a draft instead`). ## Endpoint ### `POST /v1/policy-sets/{setId}/rollback` Authentication: bearer token required. Copies an archived, once-active version into a new version and submits it. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `setId` | string | yes | The archived version's id | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `description` | string \| null | no | For the new version | **Responses** `409` Never active ```json { "statusCode": 409, "error": "ConflictError", "message": "This version was never active: copy it into a draft instead", "details": { "code": "POLICY_SET_NEVER_ACTIVE", "status": "archived" } } ``` Web version: https://docs.aureahub.com/#policy-set-rollback --- # List Your Approval Keys The public keys you sign your approvals with, newest first — revoked ones included. ## Overview - Your own keys only: each approver sees and manages theirs. `status` is `active` or `revoked`; at most two are active at a time. - `id` is the `kid` your decisions name. `thumbprint` is the key's RFC 7638 SHA-256 thumbprint, base64url. `lastUsedAt`: the last decision it signed. - For the tenant roles that approve — `approver`, `owner`, `tenantadmin`, `non_signing_admin` ([Approvals](https://docs.aureahub.com/docs/guide-approvals.md)). ## Endpoint ### `GET /v1/approval-keys` Authentication: bearer token required. Your approval keys, newest first. **Responses** `200` OK ```json { "data": [ { "id": "7b2e4c91-5d3a-4f68-8e0b-1c9a7d3f5e24", "name": "Laptop key", "status": "active", "publicKey": { "kty": "EC", "crv": "P-256", "x": "oxctA6E_hib_b2IU2itzKLJ2wIh5f89eC4F5_-8jreA", "y": "HXPIR2nV0i74GXLTQpg6rHEbZ67c_D1xaJUIccZCrjg" }, "thumbprint": "CFGfOpgdwqX6UnGU5dwiwVwsk9edRucCD8GDfAr9xrI", "createdAt": "2026-10-06T09:12:40.511Z", "lastUsedAt": null, "revokedAt": null, "revokeReason": null } ] } ``` `403` Role ```json { "statusCode": 403, "error": "ForbiddenError", "message": "Tenant admin access required. Your role does not have permission to perform this action." } ``` Web version: https://docs.aureahub.com/#approval-keys-list --- # Register an Approval Key The public half of a P-256 key pair you made: the key your approvals are checked with. The private key stays with you. ## Overview - `name`: your label, 1 to 100 characters, trimmed. `publicKey`: `{ "kty": "EC", "crv": "P-256", "x", "y" }`, `x` and `y` 32 bytes each in unpadded base64url. `alg` (`ES256`), `use` (`sig`), `kid`, `key_ops` and `ext` are accepted and dropped. How to make the key pair: [Approvals](https://docs.aureahub.com/docs/guide-approvals.md). - `201` with the key: its `id` is the `kid` of the decisions it signs. - `400` `APPROVAL_KEY_INVALID` for a key that is not a P-256 public key (the reason in the message); `APPROVAL_KEY_PRIVATE` for a JWK that carries `d` — never stored or echoed: don't use that key pair; `APPROVAL_KEY_NAME_INVALID`. - `409` `APPROVAL_KEY_LIMIT` when you have two active keys — revoke one first; `APPROVAL_KEY_IN_USE` for a key registered before, also one revoked since: a key is registered once, ever. - At most 10 a minute. Written to your tenant's audit trail (`approval_key.registered`) while it is on. ## Endpoint ### `POST /v1/approval-keys` Authentication: bearer token required. Registers a P-256 public key you approve with. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | 1–100 characters | | `publicKey` | object | yes | The public key as a JWK: `kty` `EC`, `crv` `P-256`, `x`, `y` | **Responses** `201` Registered ```json { "id": "7b2e4c91-5d3a-4f68-8e0b-1c9a7d3f5e24", "name": "Laptop key", "status": "active", "publicKey": { "kty": "EC", "crv": "P-256", "x": "oxctA6E_hib_b2IU2itzKLJ2wIh5f89eC4F5_-8jreA", "y": "HXPIR2nV0i74GXLTQpg6rHEbZ67c_D1xaJUIccZCrjg" }, "thumbprint": "CFGfOpgdwqX6UnGU5dwiwVwsk9edRucCD8GDfAr9xrI", "createdAt": "2026-10-06T09:12:40.511Z", "lastUsedAt": null, "revokedAt": null, "revokeReason": null } ``` `400` Private key ```json { "statusCode": 400, "error": "BadRequestError", "message": "publicKey carries a private key (\"d\"): send the public key only, and do not use this key pair", "details": { "code": "APPROVAL_KEY_PRIVATE" } } ``` `409` Limit ```json { "statusCode": 409, "error": "ConflictError", "message": "At most 2 active approval keys: revoke one first", "details": { "code": "APPROVAL_KEY_LIMIT" } } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/approval-keys \ -H "Authorization: Bearer $APPROVER_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Laptop key", "publicKey": { "kty": "EC", "crv": "P-256", "x": "oxctA6E_hib_b2IU2itzKLJ2wIh5f89eC4F5_-8jreA", "y": "HXPIR2nV0i74GXLTQpg6rHEbZ67c_D1xaJUIccZCrjg" } }' ``` Web version: https://docs.aureahub.com/#approval-key-register --- # Revoke an Approval Key For good: decisions signed with it are refused from now on. ## Overview - One of your own keys. The body is optional: `reason`, 1 to 500 characters, kept as `revokeReason`. - `200` with the key, `status` `revoked` and `revokedAt`. It stays listed and never changes again; the decisions it signed before stand. A decision it signs from now on answers `403` `APPROVAL_SIGNATURE_KEY_UNKNOWN`. - To rotate: [register](https://docs.aureahub.com/docs/approval-key-register.md) the new key first, then revoke the old one. - `404` `APPROVAL_KEY_NOT_FOUND` (`No such approval key of yours`) for a key that is not yours; `409` `APPROVAL_KEY_REVOKED` (`This key is revoked already`). Written to your tenant's audit trail (`approval_key.revoked`) while it is on. ## Endpoint ### `POST /v1/approval-keys/{keyId}/revoke` Authentication: bearer token required. Revokes one of your approval keys, for good. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `keyId` | string (uuid) | yes | The key's id | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `reason` | string | no | 1–500 characters | **Responses** `200` Revoked ```json { "id": "7b2e4c91-5d3a-4f68-8e0b-1c9a7d3f5e24", "name": "Laptop key", "status": "revoked", "publicKey": { "kty": "EC", "crv": "P-256", "x": "oxctA6E_hib_b2IU2itzKLJ2wIh5f89eC4F5_-8jreA", "y": "HXPIR2nV0i74GXLTQpg6rHEbZ67c_D1xaJUIccZCrjg" }, "thumbprint": "CFGfOpgdwqX6UnGU5dwiwVwsk9edRucCD8GDfAr9xrI", "createdAt": "2026-10-06T09:12:40.511Z", "lastUsedAt": "2026-10-06T12:20:03.511Z", "revokedAt": "2026-11-02T08:30:00.000Z", "revokeReason": "Laptop replaced" } ``` `404` Not yours ```json { "statusCode": 404, "error": "NotFoundError", "message": "No such approval key of yours", "details": { "code": "APPROVAL_KEY_NOT_FOUND" } } ``` Web version: https://docs.aureahub.com/#approval-key-revoke --- # Operations Waiting for Your Approval The approval requests you may decide now, newest first. ## Overview - Your tenant's requests that are `pending` and within their window, that name you (or any approver), that are not your own operation when the rule keeps the initiator apart, and that you have not decided yet. A request leaves your list once you decided it, or once it ended. - Each request: the rule (`ruleId`, `ruleName`, `policySetVersion`), the `threshold` and the `approvals` and `rejections` so far, the approvers the rule names (`approverIds`, `null` for any approver), what moves (`chain`, `chainId`, `asset` — `native` or the token's address —, `amount` in its smallest unit, `amountRef` in EUR as the policy read it, `destination`), `origin` (the call that started it, e.g. `transactions.create`) and `expiresAt` (`null`: no window). A fact the operation did not have is `null`. - Page with `limit` (1–100, default 50) and `cursor`, the previous page's `nextCursor` (`null` on the last page); `400` `APPROVAL_CURSOR_INVALID` for a cursor that is not one. - For a tenant that is not on the institutional profile, nothing is ever held: the list is empty. The payloads to sign: [Get a Request](https://docs.aureahub.com/docs/approval-get.md). ## Endpoint ### `GET /v1/approvals` Authentication: bearer token required. The approval requests you may decide now. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `limit` | integer | no | 1–100, default 50 | | `cursor` | string | no | The previous page's `nextCursor` | **Responses** `200` OK ```json { "data": [ { "id": "c3e1a9f4-6b2d-4e8a-9f17-5d0c2b8e4a63", "operationId": "1d7a3c52-8e4b-4f19-a0c6-2b9e7d5f3a18", "status": "pending", "origin": "transactions.create", "initiatorId": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "ruleId": "e2b6d9f1-7a3c-4c58-8b0e-5f1a4d2c9b73", "ruleName": "Large transfers need two approvers", "policySetVersion": 2, "threshold": 2, "approvals": 1, "rejections": 0, "approverIds": null, "excludeInitiator": true, "chain": "gnosis", "chainId": 100, "asset": "0x2a22f9c3b484c3629090feed35f17ff8f88f76f0", "amount": "35000000000", "amountRef": "32150.75", "destination": "0x52908400098527886e0f7030069857d2e4169ee7", "expiresAt": null, "createdAt": "2026-10-06T12:01:44.120Z", "decidedAt": null } ], "nextCursor": null } ``` Web version: https://docs.aureahub.com/#approvals-inbox --- # Get an Approval Request An operation's approval request, its decisions, whether you may decide it now, and the two payloads a decision signs. ## Overview - The operation's newest request in your tenant — decided or not —, with the fields of [Waiting for You](https://docs.aureahub.com/docs/approvals-inbox.md), `status` (`pending`, `approved`, `rejected`, `expired`, or `withdrawn` — its initiator cancelled the operation while it waited), `decidedAt`, and `decisions`: who decided what, when (`approverId`, `decision`, `at`). - `canDecide`: whether you may decide it now — pending, within its window, you one of its approvers, not decided by you yet. - `payloads.approve` and `payloads.reject`: the exact strings to sign, one per decision. Sign the string as it is: [Sign a Decision](https://docs.aureahub.com/docs/guide-approvals.md). - `404` `APPROVAL_NOT_FOUND` (`No approval request for this operation here`) when the operation has none in your tenant. ## Endpoint ### `GET /v1/approvals/{operationId}` Authentication: bearer token required. An operation's approval request, with the payloads to sign. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `operationId` | string (uuid) | yes | The operation's id — the `operationId` of the `202` | **Responses** `200` OK ```json { "id": "c3e1a9f4-6b2d-4e8a-9f17-5d0c2b8e4a63", "operationId": "1d7a3c52-8e4b-4f19-a0c6-2b9e7d5f3a18", "status": "pending", "origin": "transactions.create", "initiatorId": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "ruleId": "e2b6d9f1-7a3c-4c58-8b0e-5f1a4d2c9b73", "ruleName": "Large transfers need two approvers", "policySetVersion": 2, "threshold": 2, "approvals": 1, "rejections": 0, "approverIds": null, "excludeInitiator": true, "chain": "gnosis", "chainId": 100, "asset": "0x2a22f9c3b484c3629090feed35f17ff8f88f76f0", "amount": "35000000000", "amountRef": "32150.75", "destination": "0x52908400098527886e0f7030069857d2e4169ee7", "expiresAt": null, "createdAt": "2026-10-06T12:01:44.120Z", "decidedAt": null, "decisions": [ { "approverId": "6a1c9e3f-2b7d-4f50-8e4a-0c5d3b9f7e12", "decision": "approve", "at": "2026-10-06T12:20:03.511Z" } ], "canDecide": true, "payloads": { "approve": "{\"action\":\"approve\",\"amount\":\"35000000000\",\"approvalId\":\"c3e1a9f4-6b2d-4e8a-9f17-5d0c2b8e4a63\",\"asset\":\"0x2a22f9c3b484c3629090feed35f17ff8f88f76f0\",\"chain\":\"gnosis\",\"chainId\":100,\"destination\":\"0x52908400098527886e0f7030069857d2e4169ee7\",\"expiresAt\":null,\"operationId\":\"1d7a3c52-8e4b-4f19-a0c6-2b9e7d5f3a18\",\"ruleId\":\"e2b6d9f1-7a3c-4c58-8b0e-5f1a4d2c9b73\",\"tenantId\":\"6f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0\",\"v\":1}", "reject": "{\"action\":\"reject\",\"amount\":\"35000000000\",\"approvalId\":\"c3e1a9f4-6b2d-4e8a-9f17-5d0c2b8e4a63\",\"asset\":\"0x2a22f9c3b484c3629090feed35f17ff8f88f76f0\",\"chain\":\"gnosis\",\"chainId\":100,\"destination\":\"0x52908400098527886e0f7030069857d2e4169ee7\",\"expiresAt\":null,\"operationId\":\"1d7a3c52-8e4b-4f19-a0c6-2b9e7d5f3a18\",\"ruleId\":\"e2b6d9f1-7a3c-4c58-8b0e-5f1a4d2c9b73\",\"tenantId\":\"6f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0\",\"v\":1}" } } ``` `404` Not found ```json { "statusCode": 404, "error": "NotFoundError", "message": "No approval request for this operation here", "details": { "code": "APPROVAL_NOT_FOUND" } } ``` Web version: https://docs.aureahub.com/#approval-get --- # Approve an Operation Your approval, signed with one of your approval keys. Once the rule's threshold of distinct approvers approved, the operation goes on. ## Overview - `signature`: a compact JWS of `payloads.approve` from [Get a Request](https://docs.aureahub.com/docs/approval-get.md), signed ES256 with one of your active approval keys — [Sign a Decision](https://docs.aureahub.com/docs/guide-approvals.md). - `200` with the request as it stands: `approvals` counts yours. When it reaches `threshold`, `status` is `approved`, the operation goes `PENDING_AUTHORIZATION` `approved`, and it goes on server-side as the call that started it would have: a custodial wallet's operation is signed and sent; a client-side or MPC wallet's is prepared for its owner to sign ([An Operation's Approval](https://docs.aureahub.com/docs/operation-approval.md), `result`). - The checks and their order — `404` `APPROVAL_NOT_FOUND`; `409` `APPROVAL_NOT_PENDING`, `APPROVAL_EXPIRED`; `403` `APPROVAL_NOT_APPROVER`; `409` `APPROVAL_ALREADY_DECIDED`; `400` `APPROVAL_SIGNATURE_MALFORMED`; `403` `APPROVAL_SIGNATURE_KEY_UNKNOWN`; `400` `APPROVAL_SIGNATURE_PAYLOAD_MISMATCH`; `403` `APPROVAL_SIGNATURE_INVALID`; `409` `APPROVAL_SIGNATURE_USED` —: [The Order of the Checks](https://docs.aureahub.com/docs/guide-approvals.md). Nothing is recorded when one fails. - At most 30 a minute. ## Endpoint ### `POST /v1/approvals/{operationId}/approve` Authentication: bearer token required. Approves an operation held for approval. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `operationId` | string (uuid) | yes | The operation's id | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `signature` | string | yes | The compact JWS of `payloads.approve`, at most 4096 characters | **Responses** `200` Approved ```json { "id": "c3e1a9f4-6b2d-4e8a-9f17-5d0c2b8e4a63", "operationId": "1d7a3c52-8e4b-4f19-a0c6-2b9e7d5f3a18", "status": "approved", "origin": "transactions.create", "initiatorId": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "ruleId": "e2b6d9f1-7a3c-4c58-8b0e-5f1a4d2c9b73", "ruleName": "Large transfers need two approvers", "policySetVersion": 2, "threshold": 2, "approvals": 2, "rejections": 0, "approverIds": null, "excludeInitiator": true, "chain": "gnosis", "chainId": 100, "asset": "0x2a22f9c3b484c3629090feed35f17ff8f88f76f0", "amount": "35000000000", "amountRef": "32150.75", "destination": "0x52908400098527886e0f7030069857d2e4169ee7", "expiresAt": null, "createdAt": "2026-10-06T12:01:44.120Z", "decidedAt": "2026-10-06T12:24:51.302Z" } ``` `403` Not an approver ```json { "statusCode": 403, "error": "ForbiddenError", "message": "You are not one of this request's approvers", "details": { "code": "APPROVAL_NOT_APPROVER" } } ``` `409` Decided ```json { "statusCode": 409, "error": "ConflictError", "message": "This approval request is decided already", "details": { "code": "APPROVAL_NOT_PENDING", "status": "approved" } } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/approvals/$OPERATION_ID/approve \ -H "Authorization: Bearer $APPROVER_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "signature": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImF1cmVhLWFwcHJvdmFsK2p3dCIsImtpZCI6Ii4uLiJ9.eyJhY3Rpb24iOiJhcHByb3ZlIiwi….…" }' ``` Web version: https://docs.aureahub.com/#approval-approve --- # Reject an Operation Your rejection, signed with one of your approval keys. One rejection ends the request: nothing is signed or sent. ## Overview - `signature`: a compact JWS of `payloads.reject` from [Get a Request](https://docs.aureahub.com/docs/approval-get.md) — [Sign a Decision](https://docs.aureahub.com/docs/guide-approvals.md). `reason`: optional, 1 to 500 characters, kept with your decision. - `200` with the request, `status` `rejected`. The operation is `REJECTED`, `failureReason` `APPROVAL_REJECTED`. - The same checks as [Approve](https://docs.aureahub.com/docs/approval-approve.md), in the same order, and `400` `APPROVAL_REASON_INVALID` (`A reason is 1 to 500 characters`) before them. An approval's payload signed and sent here answers `400` `APPROVAL_SIGNATURE_PAYLOAD_MISMATCH`. - At most 30 a minute. ## Endpoint ### `POST /v1/approvals/{operationId}/reject` Authentication: bearer token required. Rejects an operation held for approval. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `operationId` | string (uuid) | yes | The operation's id | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `signature` | string | yes | The compact JWS of `payloads.reject`, at most 4096 characters | | `reason` | string | no | 1–500 characters | **Responses** `200` Rejected ```json { "id": "c3e1a9f4-6b2d-4e8a-9f17-5d0c2b8e4a63", "operationId": "1d7a3c52-8e4b-4f19-a0c6-2b9e7d5f3a18", "status": "rejected", "origin": "transactions.create", "initiatorId": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "ruleId": "e2b6d9f1-7a3c-4c58-8b0e-5f1a4d2c9b73", "ruleName": "Large transfers need two approvers", "policySetVersion": 2, "threshold": 2, "approvals": 1, "rejections": 1, "approverIds": null, "excludeInitiator": true, "chain": "gnosis", "chainId": 100, "asset": "0x2a22f9c3b484c3629090feed35f17ff8f88f76f0", "amount": "35000000000", "amountRef": "32150.75", "destination": "0x52908400098527886e0f7030069857d2e4169ee7", "expiresAt": null, "createdAt": "2026-10-06T12:01:44.120Z", "decidedAt": "2026-10-06T12:24:51.302Z" } ``` `400` Other payload ```json { "statusCode": 400, "error": "BadRequestError", "message": "The signed payload is not this request's canonical \"reject\" payload (GET /v1/approvals/{operationId}: payloads)", "details": { "code": "APPROVAL_SIGNATURE_PAYLOAD_MISMATCH" } } ``` Web version: https://docs.aureahub.com/#approval-reject --- # List Groups Your tenant's groups, by name, with how many members each has. ## Overview - Every group of your tenant in one answer, in `data`, ordered by name whatever its case. The members are in [Get a Group](https://docs.aureahub.com/docs/group-get.md). - For `owner`, `tenantadmin`, `non_signing_admin`, `auditor`, `security_admin` and `security_auditor`. What groups are for: [Groups](https://docs.aureahub.com/docs/guide-groups.md). ## Endpoint ### `GET /v1/groups` Authentication: bearer token required. Your tenant's groups with their member counts. **Responses** `200` OK ```json { "data": [ { "id": "3b8e1f6a-2c4d-4e9f-a1b7-6d5c4e3f2a10", "name": "Finance approvers", "description": "Approve transfers above 10,000 EUR", "memberCount": 3, "createdBy": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "createdAt": "2026-10-08T09:12:40.118Z", "updatedAt": "2026-10-08T09:20:03.552Z" } ] } ``` `403` Role ```json { "statusCode": 403, "error": "ForbiddenError", "message": "Tenant admin access required. Your role does not have permission to perform this action." } ``` **Example request** ```bash curl https://api.aureahub.com/v1/groups \ -H "Authorization: Bearer $ADMIN_TOKEN" ``` Web version: https://docs.aureahub.com/#groups-list --- # Create a Group A new group of your tenant's users, empty: add its members next. ## Overview - `name`: 1 to 100 characters once trimmed, unique in your tenant whatever its case (`409` `GROUP_NAME_TAKEN`). `description`: up to 500 characters, or `null`. - `201` with the group, `memberCount` `0`. Add members with [Add a Member](https://docs.aureahub.com/docs/group-member-add.md). - For `owner`, `tenantadmin`, `non_signing_admin` and `security_admin`. The body is checked as sent: a field that doesn't belong answers `400` `GROUP_REQUEST_INVALID`. ## Endpoint ### `POST /v1/groups` Authentication: bearer token required. Creates an empty group in your tenant. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | yes | 1–100 characters, trimmed; unique in your tenant whatever its case | | `description` | string \| null | no | Up to 500 characters | **Responses** `201` Created ```json { "id": "3b8e1f6a-2c4d-4e9f-a1b7-6d5c4e3f2a10", "name": "Finance approvers", "description": "Approve transfers above 10,000 EUR", "memberCount": 0, "createdBy": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "createdAt": "2026-10-08T09:12:40.118Z", "updatedAt": "2026-10-08T09:12:40.118Z" } ``` `409` Name taken ```json { "statusCode": 409, "error": "ConflictError", "message": "The tenant has a group by that name", "details": { "code": "GROUP_NAME_TAKEN" } } ``` `400` Invalid ```json { "statusCode": 400, "error": "BadRequestError", "message": "A group's name is 1 to 100 characters", "details": { "code": "GROUP_REQUEST_INVALID" } } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/groups \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Finance approvers", "description": "Approve transfers above 10,000 EUR" }' ``` Web version: https://docs.aureahub.com/#group-create --- # Get a Group One group and its members. ## Overview - The group as [List Groups](https://docs.aureahub.com/docs/groups-list.md) answers it, with `members`, ordered by username: `userId`, `username`, `role`, who added them (`addedBy`) and when (`addedAt`). - `404` `GROUP_NOT_FOUND` (`No such group in the tenant`) for a group that is not your tenant's. Same roles as List Groups. ## Endpoint ### `GET /v1/groups/{groupId}` Authentication: bearer token required. One group with its members. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `groupId` | string (uuid) | yes | The group's id | **Responses** `200` OK ```json { "id": "3b8e1f6a-2c4d-4e9f-a1b7-6d5c4e3f2a10", "name": "Finance approvers", "description": "Approve transfers above 10,000 EUR", "memberCount": 2, "createdBy": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "createdAt": "2026-10-08T09:12:40.118Z", "updatedAt": "2026-10-08T09:20:03.552Z", "members": [ { "userId": "0d2f6b8a-4c1e-4a93-b7d5-2e9f1c3a8b60", "username": "giulia.finance", "role": "approver", "addedBy": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "addedAt": "2026-10-08T09:15:11.402Z" }, { "userId": "6a1c9e3f-2b7d-4f50-8e4a-0c5d3b9f7e12", "username": "marco.cfo", "role": "tenantadmin", "addedBy": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "addedAt": "2026-10-08T09:20:03.552Z" } ] } ``` `404` Not found ```json { "statusCode": 404, "error": "NotFoundError", "message": "No such group in the tenant", "details": { "code": "GROUP_NOT_FOUND" } } ``` Web version: https://docs.aureahub.com/#group-get --- # Change a Group Rename a group or change its description. Its id — what your rules name — stays. ## Overview - Send `name`, `description` or both; `"description": null` clears it. A body that names neither answers `400` `GROUP_REQUEST_INVALID`. - `200` with the group. A name another group of yours has answers `409` `GROUP_NAME_TAKEN`; a group that is not yours, `404` `GROUP_NOT_FOUND`. - For `owner`, `tenantadmin`, `non_signing_admin` and `security_admin`. ## Endpoint ### `PATCH /v1/groups/{groupId}` Authentication: bearer token required. Changes a group's name or description. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `groupId` | string (uuid) | yes | The group's id | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `name` | string | no | 1–100 characters, trimmed; unique in your tenant whatever its case | | `description` | string \| null | no | Up to 500 characters; `null` clears it | **Responses** `200` OK ```json { "id": "3b8e1f6a-2c4d-4e9f-a1b7-6d5c4e3f2a10", "name": "Finance approvers (EU)", "description": "Approve transfers above 10,000 EUR", "memberCount": 2, "createdBy": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "createdAt": "2026-10-08T09:12:40.118Z", "updatedAt": "2026-10-08T10:02:17.905Z" } ``` `400` Nothing to change ```json { "statusCode": 400, "error": "BadRequestError", "message": "Invalid request", "details": { "code": "GROUP_REQUEST_INVALID", "errors": ["(body): Change the name or the description"] } } ``` Web version: https://docs.aureahub.com/#group-update --- # Delete a Group The group and its memberships. A rule that names it then names no one by it. ## Overview - `204`, no body. The users stay; only the group and its memberships go. - Before deleting a group your active policy set names, publish a set that no longer names it: from the deletion on, its rules name no one by it. - `404` `GROUP_NOT_FOUND` for a group that is not yours. For `owner`, `tenantadmin`, `non_signing_admin` and `security_admin`. ## Endpoint ### `DELETE /v1/groups/{groupId}` Authentication: bearer token required. Deletes a group and its memberships. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `groupId` | string (uuid) | yes | The group's id | **Responses** `204` Deleted `404` Not found ```json { "statusCode": 404, "error": "NotFoundError", "message": "No such group in the tenant", "details": { "code": "GROUP_NOT_FOUND" } } ``` Web version: https://docs.aureahub.com/#group-delete --- # Add a Member to a Group One of your tenant's users joins the group. ## Overview - `userId`: a user of your tenant whose account is not deleted — `404` `GROUP_USER_NOT_FOUND` (`No such user in the tenant`) otherwise. Any role may be a member: a group grants no permission. - `200` with the group, its `memberCount` counting the new member. Already in it: `409` `GROUP_ALREADY_MEMBER`. - A policy decision reads the membership as it is then: the new member counts for every operation decided from now on ([Groups](https://docs.aureahub.com/docs/guide-groups.md)). - For `owner`, `tenantadmin`, `non_signing_admin` and `security_admin`. ## Endpoint ### `POST /v1/groups/{groupId}/members` Authentication: bearer token required. Adds one of your tenant's users to a group. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `groupId` | string (uuid) | yes | The group's id | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `userId` | string (uuid) | yes | A user of your tenant | **Responses** `200` Added ```json { "id": "3b8e1f6a-2c4d-4e9f-a1b7-6d5c4e3f2a10", "name": "Finance approvers", "description": "Approve transfers above 10,000 EUR", "memberCount": 3, "createdBy": "5d2c1b0a-9e8f-4a7b-8c6d-5e4f3a2b1c0d", "createdAt": "2026-10-08T09:12:40.118Z", "updatedAt": "2026-10-08T09:20:03.552Z" } ``` `409` Member already ```json { "statusCode": 409, "error": "ConflictError", "message": "The user is a member already", "details": { "code": "GROUP_ALREADY_MEMBER" } } ``` `404` No such user ```json { "statusCode": 404, "error": "NotFoundError", "message": "No such user in the tenant", "details": { "code": "GROUP_USER_NOT_FOUND" } } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/groups/$GROUP_ID/members \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "userId": "0d2f6b8a-4c1e-4a93-b7d5-2e9f1c3a8b60" }' ``` Web version: https://docs.aureahub.com/#group-member-add --- # Remove a Member from a Group The user leaves the group; the account stays as it is. ## Overview - `204`, no body. From the next decision on, the user no longer counts as the group's member. - `404` `GROUP_NOT_FOUND` for a group that is not yours, `GROUP_NOT_MEMBER` (`The user is not a member`) for a user who is not in it. - For `owner`, `tenantadmin`, `non_signing_admin` and `security_admin`. ## Endpoint ### `DELETE /v1/groups/{groupId}/members/{userId}` Authentication: bearer token required. Removes a user from a group. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `groupId` | string (uuid) | yes | The group's id | | `userId` | string (uuid) | yes | The member's user id | **Responses** `204` Removed `404` Not a member ```json { "statusCode": 404, "error": "NotFoundError", "message": "The user is not a member", "details": { "code": "GROUP_NOT_MEMBER" } } ``` Web version: https://docs.aureahub.com/#group-member-remove --- # List Address Book Entries Your tenant's address book, newest first, filtered as you ask. ## Overview - Every filter is optional and they add up. `status` is the entry's status now: an entry whose cooling period has ended counts as `active`. `chain` matches the entries of that chain exactly — not those for every chain (`chain` `null`). `tag` matches one of an entry's tags. `address`: an EVM address in any case is compared with its checksum. - Up to `limit` entries (1–200, default 50) in `entries`, and `nextCursor`: send it as `cursor` for the next page; `null` on the last one. The history of an entry is in [Get an Entry](https://docs.aureahub.com/docs/address-book-entry-get.md). - A query parameter that is not one of these answers `400` `ADDRESS_BOOK_REQUEST_INVALID`. For the roles that read the book ([Address Book](https://docs.aureahub.com/docs/guide-address-book.md)). ## Endpoint ### `GET /v1/address-book/entries` Authentication: bearer token required. Your tenant's address book entries, newest first. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `status` | string | no | `pending_approval`, `cooling`, `active`, `rejected` or `removed` | | `kind` | string | no | `internal`, `external` or `contract` | | `family` | string | no | `evm` or `solana` | | `network` | string | no | `mainnet` or `testnet` | | `chain` | string | no | A chain's slug, e.g. `base` | | `tag` | string | no | One tag | | `accountId` | string (uuid) | no | The entries serving that account's wallets | | `address` | string | no | An address, up to 100 characters | | `proven` | boolean | no | Proven by its owner, or not | | `cursor` | string (uuid) | no | The previous page's `nextCursor` | | `limit` | integer | no | 1–200, default 50 | **Responses** `200` OK ```json { "entries": [ { "id": "8f3c2a71-5d4e-4b9a-a6c1-2e7f9d0b3c58", "accountId": null, "kind": "external", "family": "evm", "network": "mainnet", "chain": "base", "chainId": null, "address": "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", "walletId": null, "label": "Supplier payouts", "tags": ["suppliers"], "customerRefId": "SUP-0042", "status": "active", "version": 1, "requestedBy": "0d2f6b8a-4c1e-4a93-b7d5-2e9f1c3a8b60", "requestedScope": "tenant", "requestedAt": "2026-10-08T08:00:12.000Z", "approval": { "approvedBy": "6a1c9e3f-2b7d-4f50-8e4a-0c5d3b9f7e12", "approvedScope": "tenant", "approvedAt": "2026-10-08T08:30:00.000Z", "requiredApprovals": 1 }, "coolingPeriodSeconds": 86400, "activeAt": "2026-10-09T08:30:00.000Z", "coolingSecondsLeft": null, "proof": { "proven": true, "method": "eip191", "provenAt": "2026-10-08T08:10:41.000Z" }, "rejectedBy": null, "rejectedAt": null, "removedBy": null, "removedAt": null, "createdAt": "2026-10-08T08:00:12.000Z", "updatedAt": "2026-10-08T08:30:00.000Z" } ], "nextCursor": null } ``` `400` Invalid ```json { "statusCode": 400, "error": "BadRequestError", "message": "Invalid request", "details": { "code": "ADDRESS_BOOK_REQUEST_INVALID", "errors": ["(body): Unrecognized key(s) in object: 'state'"] } } ``` **Example request** ```bash curl "https://api.aureahub.com/v1/address-book/entries?status=active&kind=external&limit=50" \ -H "Authorization: Bearer $TOKEN" ``` Web version: https://docs.aureahub.com/#address-book-entries-list --- # Add an Address Book Entry A new entry, pending its approval: one of your wallets, someone else's address, or a contract. ## Overview - **By `kind`**: `internal` takes `walletId` and nothing of the address; `external` takes `family`, `network`, `address` and optionally `chain`; `contract` takes `network`, `chain` and `address`, and its code is checked on that chain. The rules of each field: [Entries](https://docs.aureahub.com/docs/guide-address-book.md). - **`201` with the entry**, `status` `pending_approval`, `version` `1`. Next: prove it, if it is external and your settings ask for it, then approve it ([Approve an Entry](https://docs.aureahub.com/docs/address-book-entry-approve.md)). - The body is checked as sent: a field that doesn't belong — or one the kind doesn't take — answers `400` `ADDRESS_BOOK_REQUEST_INVALID` with each problem in `details.errors`. - Other refusals: `400` `ADDRESS_BOOK_WALLET_NOT_FOUND`, `ADDRESS_BOOK_ACCOUNT_NOT_FOUND`, `ADDRESS_BOOK_CHAIN_UNKNOWN`, `ADDRESS_BOOK_ADDRESS_CHECKSUM_INVALID`, `ADDRESS_BOOK_ADDRESS_INVALID`; `409` `ADDRESS_BOOK_ENTRY_EXISTS`; for a contract, `422` `ADDRESS_BOOK_ADDRESS_NOT_A_CONTRACT` and `503` `ADDRESS_BOOK_CONTRACT_UNVERIFIED` — [Refusals](https://docs.aureahub.com/docs/guide-address-book.md). - For `owner`, `tenantadmin`, `non_signing_admin` and `editor`. ## Endpoint ### `POST /v1/address-book/entries` Authentication: bearer token required. Adds an entry to your tenant's address book, pending its approval. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `kind` | string | yes | `internal`, `external` or `contract` | | `label` | string | yes | 1–100 characters, no space at either end | | `tags` | string[] | no | Up to 20, each named once: 1–50 of `A–Z a–z 0–9 _ . : -`, starting with a letter or a digit | | `customerRefId` | string \| null | no | Your reference for the counterparty: 1–128 of `A–Z a–z 0–9 _ . : @ / + -`, starting with a letter or a digit | | `accountId` | string (uuid) \| null | no | One of your accounts: the entry then serves only its wallets | | `family` | string | no | `evm` or `solana`. External: required. Contract: `evm` if sent. Internal: not sent | | `network` | string | no | `mainnet` or `testnet`. External and contract: required | | `chain` | string \| null | no | An EVM chain's slug; `null` or `evm`: every EVM chain of the network; Solana: `solana` or `null`. Contract: required, a slug | | `address` | string | no | External and contract: required. EVM: `0x` + 40 hex (a mixed-case one with its right checksum); Solana: base58 | | `walletId` | string (uuid) | no | Internal: required — one of your tenant's wallets | **Responses** `201` Created ```json { "id": "8f3c2a71-5d4e-4b9a-a6c1-2e7f9d0b3c58", "accountId": null, "kind": "external", "family": "evm", "network": "mainnet", "chain": "base", "chainId": null, "address": "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", "walletId": null, "label": "Supplier payouts", "tags": ["suppliers"], "customerRefId": "SUP-0042", "status": "pending_approval", "version": 1, "requestedBy": "0d2f6b8a-4c1e-4a93-b7d5-2e9f1c3a8b60", "requestedScope": "tenant", "requestedAt": "2026-10-08T08:00:12.000Z", "approval": null, "coolingPeriodSeconds": null, "activeAt": null, "coolingSecondsLeft": null, "proof": { "proven": false, "method": null, "provenAt": null }, "rejectedBy": null, "rejectedAt": null, "removedBy": null, "removedAt": null, "createdAt": "2026-10-08T08:00:12.000Z", "updatedAt": "2026-10-08T08:00:12.000Z" } ``` `400` Checksum ```json { "statusCode": 400, "error": "BadRequestError", "message": "The address has a wrong EIP-55 checksum: check it for a typo, or send it in lower case", "details": { "code": "ADDRESS_BOOK_ADDRESS_CHECKSUM_INVALID", "field": "address" } } ``` `409` Exists ```json { "statusCode": 409, "error": "ConflictError", "message": "The book already holds a live entry for this address, chain and account", "details": { "code": "ADDRESS_BOOK_ENTRY_EXISTS" } } ``` `422` Not a contract ```json { "statusCode": 422, "error": "ValidationError", "message": "No contract exists at this address on chain 8453", "details": { "code": "ADDRESS_BOOK_ADDRESS_NOT_A_CONTRACT", "field": "address", "chainId": 8453 } } ``` `503` Unverified ```json { "statusCode": 503, "error": "ServiceUnavailableError", "message": "The contract cannot be checked: the node for chain 8453 did not answer", "details": { "code": "ADDRESS_BOOK_CONTRACT_UNVERIFIED", "chainId": 8453, "retryable": true } } ``` **Example request** ```bash # Someone else's address, on Base only curl -X POST https://api.aureahub.com/v1/address-book/entries \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "kind": "external", "label": "Supplier payouts", "tags": ["suppliers"], "customerRefId": "SUP-0042", "family": "evm", "network": "mainnet", "chain": "base", "address": "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed" }' # One of your own wallets curl -X POST https://api.aureahub.com/v1/address-book/entries \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "kind": "internal", "label": "Treasury", "walletId": "7c1e9b24-3f5a-4d8e-9b6c-1a2f3e4d5c6b" }' # A contract your operations call curl -X POST https://api.aureahub.com/v1/address-book/entries \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "kind": "contract", "label": "Swap router", "network": "mainnet", "chain": "base", "address": "0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359" }' ``` Web version: https://docs.aureahub.com/#address-book-entry-add --- # Get an Address Book Entry One entry, with everything that happened to it. ## Overview - The entry as [List Entries](https://docs.aureahub.com/docs/address-book-entries-list.md) shows it, plus `history`, oldest first: each move's `version`, `event` (`requested`, `address_changed`, `changed`, `approved`, `activated`, `rejected`, `removed`, `proven`), `fromStatus` and `toStatus`, that version's `address`, `network` and `chain`, who made it (`actorKind` `user` or `system`, `actorId`, `actorRole`, `scope`) and `at`. - `status` is the entry's status now; `coolingSecondsLeft` counts down while it cools. `404` `ADDRESS_BOOK_ENTRY_NOT_FOUND` for an entry that is not your tenant's. ## Endpoint ### `GET /v1/address-book/entries/{entryId}` Authentication: bearer token required. One entry with its history. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `entryId` | string (uuid) | yes | The entry's id | **Responses** `200` OK ```json { "id": "8f3c2a71-5d4e-4b9a-a6c1-2e7f9d0b3c58", "accountId": null, "kind": "external", "family": "evm", "network": "mainnet", "chain": "base", "chainId": null, "address": "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", "walletId": null, "label": "Supplier payouts", "tags": ["suppliers"], "customerRefId": "SUP-0042", "status": "cooling", "version": 1, "requestedBy": "0d2f6b8a-4c1e-4a93-b7d5-2e9f1c3a8b60", "requestedScope": "tenant", "requestedAt": "2026-10-08T08:00:12.000Z", "approval": { "approvedBy": "6a1c9e3f-2b7d-4f50-8e4a-0c5d3b9f7e12", "approvedScope": "tenant", "approvedAt": "2026-10-08T08:30:00.000Z", "requiredApprovals": 1 }, "coolingPeriodSeconds": 86400, "activeAt": "2026-10-09T08:30:00.000Z", "coolingSecondsLeft": 52200, "proof": { "proven": true, "method": "eip191", "provenAt": "2026-10-08T08:10:41.000Z" }, "rejectedBy": null, "rejectedAt": null, "removedBy": null, "removedAt": null, "createdAt": "2026-10-08T08:00:12.000Z", "updatedAt": "2026-10-08T08:30:00.000Z", "history": [ { "version": 1, "event": "requested", "fromStatus": null, "toStatus": "pending_approval", "address": "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", "network": "mainnet", "chain": "base", "actorKind": "user", "actorId": "0d2f6b8a-4c1e-4a93-b7d5-2e9f1c3a8b60", "actorRole": "editor", "scope": "tenant", "at": "2026-10-08T08:00:12.000Z" }, { "version": 1, "event": "proven", "fromStatus": "pending_approval", "toStatus": "pending_approval", "address": "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", "network": "mainnet", "chain": "base", "actorKind": "user", "actorId": "0d2f6b8a-4c1e-4a93-b7d5-2e9f1c3a8b60", "actorRole": "editor", "scope": "tenant", "at": "2026-10-08T08:10:41.000Z" }, { "version": 1, "event": "approved", "fromStatus": "pending_approval", "toStatus": "cooling", "address": "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", "network": "mainnet", "chain": "base", "actorKind": "user", "actorId": "6a1c9e3f-2b7d-4f50-8e4a-0c5d3b9f7e12", "actorRole": "tenantadmin", "scope": "tenant", "at": "2026-10-08T08:30:00.000Z" } ] } ``` `404` Not found ```json { "statusCode": 404, "error": "NotFoundError", "message": "Address book entry not found", "details": { "code": "ADDRESS_BOOK_ENTRY_NOT_FOUND" } } ``` Web version: https://docs.aureahub.com/#address-book-entry-get --- # Change an Address Book Entry Its label, tags and reference change at once. A new address sends it back to its approval. ## Overview - **`label`, `tags`, `customerRefId`** (`null` clears it): changed at once; the status stays. - **A new address** — `address`, `network` or `chain` on an external or contract entry, `walletId` on an internal one — sends the entry back to `pending_approval` as its next `version`: the approval, the cooling period and the proof start again, and you become who asked. A contract's code is checked again. Sending the values it already has changes nothing. - `kind`, `family` and `accountId` never change: add another entry. A body with nothing to change answers `400` `ADDRESS_BOOK_REQUEST_INVALID` (`(body): nothing to change`). - `200` with the entry. A rejected or removed entry answers `409` `ADDRESS_BOOK_ENTRY_FINAL`; an address another live entry holds, `409` `ADDRESS_BOOK_ENTRY_EXISTS`; an entry changed by someone else meanwhile, `409` `ADDRESS_BOOK_ENTRY_CHANGED` — send it again. The address refusals are those of [Add an Entry](https://docs.aureahub.com/docs/address-book-entry-add.md). - For `owner`, `tenantadmin`, `non_signing_admin` and `editor`. ## Endpoint ### `PATCH /v1/address-book/entries/{entryId}` Authentication: bearer token required. Changes an entry; a new address waits for a new approval. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `entryId` | string (uuid) | yes | The entry's id | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `label` | string | no | 1–100 characters, no space at either end | | `tags` | string[] | no | The new tags, up to 20 | | `customerRefId` | string \| null | no | Your reference; `null` clears it | | `address` | string | no | External or contract: a new address | | `network` | string | no | External or contract: `mainnet` or `testnet` | | `chain` | string \| null | no | External or contract: a chain's slug; `null`: every chain of the family | | `walletId` | string (uuid) | no | Internal: another of your wallets | **Responses** `200` Address changed ```json { "id": "8f3c2a71-5d4e-4b9a-a6c1-2e7f9d0b3c58", "accountId": null, "kind": "external", "family": "evm", "network": "mainnet", "chain": null, "chainId": null, "address": "0xdbF03B407c01E7cD3CBea99509d93f8DDDC8C6FB", "walletId": null, "label": "Supplier payouts", "tags": ["suppliers"], "customerRefId": "SUP-0042", "status": "pending_approval", "version": 2, "requestedBy": "0d2f6b8a-4c1e-4a93-b7d5-2e9f1c3a8b60", "requestedScope": "tenant", "requestedAt": "2026-10-10T09:41:07.000Z", "approval": null, "coolingPeriodSeconds": null, "activeAt": null, "coolingSecondsLeft": null, "proof": { "proven": false, "method": null, "provenAt": null }, "rejectedBy": null, "rejectedAt": null, "removedBy": null, "removedAt": null, "createdAt": "2026-10-08T08:00:12.000Z", "updatedAt": "2026-10-10T09:41:07.000Z" } ``` `409` Final ```json { "statusCode": 409, "error": "ConflictError", "message": "A removed entry never changes again", "details": { "code": "ADDRESS_BOOK_ENTRY_FINAL", "status": "removed" } } ``` **Example request** ```bash curl -X PATCH https://api.aureahub.com/v1/address-book/entries/$ENTRY_ID \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "tags": ["suppliers", "eu"] }' ``` Web version: https://docs.aureahub.com/#address-book-entry-change --- # Remove an Address Book Entry Removed at once, whatever its state: no operation can send to it any more. ## Overview - From `pending_approval`, `cooling` or `active`. `200` with the entry, `status` `removed`, with `removedBy` and `removedAt`. - Final and soft: the entry and its history stay readable, and it never comes back — add the address again as a new entry, which is approved and cools like any other. Already rejected or removed: `409` `ADDRESS_BOOK_ENTRY_FINAL`. - For `owner`, `tenantadmin`, `non_signing_admin` and `editor`. ## Endpoint ### `DELETE /v1/address-book/entries/{entryId}` Authentication: bearer token required. Removes an entry (final). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `entryId` | string (uuid) | yes | The entry's id | **Responses** `200` Removed ```json { "id": "8f3c2a71-5d4e-4b9a-a6c1-2e7f9d0b3c58", "accountId": null, "kind": "external", "family": "evm", "network": "mainnet", "chain": "base", "chainId": null, "address": "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", "walletId": null, "label": "Supplier payouts", "tags": ["suppliers"], "customerRefId": "SUP-0042", "status": "removed", "version": 1, "requestedBy": "0d2f6b8a-4c1e-4a93-b7d5-2e9f1c3a8b60", "requestedScope": "tenant", "requestedAt": "2026-10-08T08:00:12.000Z", "approval": { "approvedBy": "6a1c9e3f-2b7d-4f50-8e4a-0c5d3b9f7e12", "approvedScope": "tenant", "approvedAt": "2026-10-08T08:30:00.000Z", "requiredApprovals": 1 }, "coolingPeriodSeconds": 86400, "activeAt": "2026-10-09T08:30:00.000Z", "coolingSecondsLeft": null, "proof": { "proven": true, "method": "eip191", "provenAt": "2026-10-08T08:10:41.000Z" }, "rejectedBy": null, "rejectedAt": null, "removedBy": "6a1c9e3f-2b7d-4f50-8e4a-0c5d3b9f7e12", "removedAt": "2026-11-02T15:20:09.000Z", "createdAt": "2026-10-08T08:00:12.000Z", "updatedAt": "2026-11-02T15:20:09.000Z" } ``` `409` Final ```json { "statusCode": 409, "error": "ConflictError", "message": "A removed entry never changes again", "details": { "code": "ADDRESS_BOOK_ENTRY_FINAL", "status": "removed" } } ``` Web version: https://docs.aureahub.com/#address-book-entry-remove --- # Approve an Address Book Entry The entry starts its cooling period, and is active when it ends. ## Overview - `pending_approval` → `cooling` until `activeAt` (`approval.approvedAt` + your cooling period), or `active` at once when the period is 0. `200` with the entry. No body. - Checked in this order: the entry is yours (`404` `ADDRESS_BOOK_ENTRY_NOT_FOUND`), not rejected or removed (`409` `ADDRESS_BOOK_ENTRY_FINAL`), pending (`409` `ADDRESS_BOOK_ENTRY_NOT_PENDING`); where your tenant's quorum applies — the institutional profile, or two administrators or more — you are not who asked (`403` `ADDRESS_BOOK_QUORUM_REQUESTER`); where your settings require it, an external entry is proven (`409` `ADDRESS_BOOK_PROOF_REQUIRED`). [Approval](https://docs.aureahub.com/docs/guide-address-book.md). - For `owner`, `tenantadmin` and `non_signing_admin`. ## Endpoint ### `POST /v1/address-book/entries/{entryId}/approve` Authentication: bearer token required. Approves an entry pending its approval. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `entryId` | string (uuid) | yes | The entry's id | **Responses** `200` Cooling ```json { "id": "8f3c2a71-5d4e-4b9a-a6c1-2e7f9d0b3c58", "accountId": null, "kind": "external", "family": "evm", "network": "mainnet", "chain": "base", "chainId": null, "address": "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", "walletId": null, "label": "Supplier payouts", "tags": ["suppliers"], "customerRefId": "SUP-0042", "status": "cooling", "version": 1, "requestedBy": "0d2f6b8a-4c1e-4a93-b7d5-2e9f1c3a8b60", "requestedScope": "tenant", "requestedAt": "2026-10-08T08:00:12.000Z", "approval": { "approvedBy": "6a1c9e3f-2b7d-4f50-8e4a-0c5d3b9f7e12", "approvedScope": "tenant", "approvedAt": "2026-10-08T08:30:00.000Z", "requiredApprovals": 1 }, "coolingPeriodSeconds": 86400, "activeAt": "2026-10-09T08:30:00.000Z", "coolingSecondsLeft": 86400, "proof": { "proven": true, "method": "eip191", "provenAt": "2026-10-08T08:10:41.000Z" }, "rejectedBy": null, "rejectedAt": null, "removedBy": null, "removedAt": null, "createdAt": "2026-10-08T08:00:12.000Z", "updatedAt": "2026-10-08T08:30:00.000Z" } ``` `403` Who asked ```json { "statusCode": 403, "error": "ForbiddenError", "message": "The tenant's quorum applies: an administrator other than who asked approves this entry", "details": { "code": "ADDRESS_BOOK_QUORUM_REQUESTER" } } ``` `409` Proof required ```json { "statusCode": 409, "error": "ConflictError", "message": "The tenant requires an external address to be proven before it is approved", "details": { "code": "ADDRESS_BOOK_PROOF_REQUIRED" } } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/address-book/entries/$ENTRY_ID/approve \ -H "Authorization: Bearer $ADMIN_TOKEN" ``` Web version: https://docs.aureahub.com/#address-book-entry-approve --- # Reject an Address Book Entry The entry is refused, for good. ## Overview - `pending_approval` → `rejected`, final: `200` with the entry, `rejectedBy` and `rejectedAt`. No body. Any role that approves may reject — who asked included, to take back a request. - Only a pending entry: `409` `ADDRESS_BOOK_ENTRY_NOT_PENDING` otherwise (an entry already cooling or active is removed instead — [Remove an Entry](https://docs.aureahub.com/docs/address-book-entry-remove.md)); `409` `ADDRESS_BOOK_ENTRY_FINAL` once rejected or removed. - For `owner`, `tenantadmin` and `non_signing_admin`. ## Endpoint ### `POST /v1/address-book/entries/{entryId}/reject` Authentication: bearer token required. Rejects an entry pending its approval (final). **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `entryId` | string (uuid) | yes | The entry's id | **Responses** `200` Rejected ```json { "id": "8f3c2a71-5d4e-4b9a-a6c1-2e7f9d0b3c58", "accountId": null, "kind": "external", "family": "evm", "network": "mainnet", "chain": "base", "chainId": null, "address": "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", "walletId": null, "label": "Supplier payouts", "tags": ["suppliers"], "customerRefId": "SUP-0042", "status": "rejected", "version": 1, "requestedBy": "0d2f6b8a-4c1e-4a93-b7d5-2e9f1c3a8b60", "requestedScope": "tenant", "requestedAt": "2026-10-08T08:00:12.000Z", "approval": null, "coolingPeriodSeconds": null, "activeAt": null, "coolingSecondsLeft": null, "proof": { "proven": false, "method": null, "provenAt": null }, "rejectedBy": "6a1c9e3f-2b7d-4f50-8e4a-0c5d3b9f7e12", "rejectedAt": "2026-10-08T08:22:51.000Z", "removedBy": null, "removedAt": null, "createdAt": "2026-10-08T08:00:12.000Z", "updatedAt": "2026-10-08T08:22:51.000Z" } ``` `409` Not pending ```json { "statusCode": 409, "error": "ConflictError", "message": "Only an entry pending its approval is rejected", "details": { "code": "ADDRESS_BOOK_ENTRY_NOT_PENDING", "status": "active" } } ``` Web version: https://docs.aureahub.com/#address-book-entry-reject --- # Ask for an Ownership Challenge The message an external address's owner signs to prove they hold it. ## Overview - `201` with `challengeId`, the entry's `entryVersion`, `family`, `network`, `address`, the `message` to sign, `issuedAt` and `expiresAt` (10 minutes later). No body. - Give `message` to the address's owner exactly as it is: EIP-191 `personal_sign` on EVM, an ed25519 signature of its UTF-8 bytes on Solana. Then [Prove Ownership](https://docs.aureahub.com/docs/address-book-proof-verify.md) with the signature — [Prove an Address](https://docs.aureahub.com/docs/guide-address-book.md). - An external entry only, not rejected or removed, in any other state (`409` `ADDRESS_BOOK_PROOF_NOT_EXTERNAL`, `ADDRESS_BOOK_ENTRY_FINAL`). At most 5 challenges open at once — not used, not expired (`409` `ADDRESS_BOOK_CHALLENGE_LIMIT`). - For `owner`, `tenantadmin`, `non_signing_admin` and `editor`. ## Endpoint ### `POST /v1/address-book/entries/{entryId}/proof/challenge` Authentication: bearer token required. Issues a message for the external address's owner to sign. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `entryId` | string (uuid) | yes | The entry's id | **Responses** `201` Created ```json { "challengeId": "c47e0b19-3d6a-4f25-9b81-6e2d5a0c7f34", "entryId": "8f3c2a71-5d4e-4b9a-a6c1-2e7f9d0b3c58", "entryVersion": 1, "family": "evm", "network": "mainnet", "address": "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", "message": "Aurea asks you to prove that you control this address, to receive funds from an Aurea address book.\nSigning this message moves no funds and costs nothing.\n\nAddress: 0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed\nAddress type: EVM\nNetwork: mainnet\nTenant: 6f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0\nEntry: 8f3c2a71-5d4e-4b9a-a6c1-2e7f9d0b3c58 (version 1)\nNonce: 9b1f6c0d2e4a8c3b7f5e1d9a0c2b4e6f8a1c3e5d7b9f0a2c4e6b8d0f1a3c5e7b\nIssued at: 2026-10-08T08:05:00.000Z\nExpires at: 2026-10-08T08:15:00.000Z", "issuedAt": "2026-10-08T08:05:00.000Z", "expiresAt": "2026-10-08T08:15:00.000Z" } ``` `409` Not external ```json { "statusCode": 409, "error": "ConflictError", "message": "Only an external address is proven: an internal one is the tenant's own wallet, a contract has no key", "details": { "code": "ADDRESS_BOOK_PROOF_NOT_EXTERNAL" } } ``` Web version: https://docs.aureahub.com/#address-book-proof-challenge --- # Prove an Entry's Owner The owner's signature over one of the entry's challenges: the entry is proven. ## Overview - `signature`: on EVM, `0x` and 130 hex digits — the EIP-191 `personal_sign` of the challenge's `message` by the entry's address; on Solana, the base58 ed25519 signature (64 bytes) of the message's UTF-8 bytes by the entry's key. - `200` with the entry: `proof.proven` `true`, `proof.method` `eip191` or `solana_ed25519`, `proof.provenAt`. Its status and version don't change; the challenge is used. - Checked in this order: the entry (`404`, `409` `ADDRESS_BOOK_ENTRY_FINAL`); the challenge is the entry's (`404` `ADDRESS_BOOK_CHALLENGE_NOT_FOUND`), not used (`409` `ADDRESS_BOOK_CHALLENGE_USED`), not expired (`410` `ADDRESS_BOOK_CHALLENGE_EXPIRED`), of the entry's current address (`409` `ADDRESS_BOOK_CHALLENGE_STALE`); the signature (`400` `ADDRESS_BOOK_SIGNATURE_MALFORMED` or `ADDRESS_BOOK_SIGNATURE_WRONG_SIGNER` with `details.attemptsLeft`; the fifth wrong one `409` `ADDRESS_BOOK_CHALLENGE_BURNT`). - For `owner`, `tenantadmin`, `non_signing_admin` and `editor`. ## Endpoint ### `POST /v1/address-book/entries/{entryId}/proof/verify` Authentication: bearer token required. Proves an external entry with its owner's signature. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `entryId` | string (uuid) | yes | The entry's id | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `challengeId` | string (uuid) | yes | One of the entry's challenges | | `signature` | string | yes | 1–200 characters: EVM `0x` + 130 hex; Solana base58 | **Responses** `200` Proven ```json { "id": "8f3c2a71-5d4e-4b9a-a6c1-2e7f9d0b3c58", "accountId": null, "kind": "external", "family": "evm", "network": "mainnet", "chain": "base", "chainId": null, "address": "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", "walletId": null, "label": "Supplier payouts", "tags": ["suppliers"], "customerRefId": "SUP-0042", "status": "pending_approval", "version": 1, "requestedBy": "0d2f6b8a-4c1e-4a93-b7d5-2e9f1c3a8b60", "requestedScope": "tenant", "requestedAt": "2026-10-08T08:00:12.000Z", "approval": null, "coolingPeriodSeconds": null, "activeAt": null, "coolingSecondsLeft": null, "proof": { "proven": true, "method": "eip191", "provenAt": "2026-10-08T08:10:41.000Z" }, "rejectedBy": null, "rejectedAt": null, "removedBy": null, "removedAt": null, "createdAt": "2026-10-08T08:00:12.000Z", "updatedAt": "2026-10-08T08:10:41.000Z" } ``` `400` Wrong signer ```json { "statusCode": 400, "error": "BadRequestError", "message": "The signature is not the address's over this challenge", "details": { "code": "ADDRESS_BOOK_SIGNATURE_WRONG_SIGNER", "attemptsLeft": 4 } } ``` `410` Expired ```json { "statusCode": 410, "error": "GoneError", "message": "This challenge has expired: ask for a new one", "details": { "code": "ADDRESS_BOOK_CHALLENGE_EXPIRED" } } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/address-book/entries/$ENTRY_ID/proof/verify \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "challengeId": "c47e0b19-3d6a-4f25-9b81-6e2d5a0c7f34", "signature": "0x…130 hex digits…" }' ``` Web version: https://docs.aureahub.com/#address-book-proof-verify --- # Address Book Settings Your cooling period, whether external addresses are proven first, whether one-time addresses are allowed — and what your signing points do with the book. ## Overview - `settings`: `coolingPeriodSeconds` (default `86400`), `proofRequired` and `oneTimeAddresses` (default `false`). `effectiveCoolingPeriodSeconds`: the period an approval given now starts — at least 24 hours on the institutional profile. `pendingChange`: the change waiting for its approval, or `null`. - `mode`: what your tenant's signing points do with the book — `off` (nothing: every tenant unless Aurea enables it for yours), `log` (destinations outside the book recorded, nothing refused), `enforce` (refused: `403` `ADDRESS_BOOK_DESTINATION_NOT_ALLOWED`). `enforced`: Aurea switched your tenant to refuse, effective while it is in `log`. [At the Signing Points](https://docs.aureahub.com/docs/guide-address-book.md). - For the roles that read the book. ## Endpoint ### `GET /v1/address-book/settings` Authentication: bearer token required. Your address book's settings and mode. **Responses** `200` OK ```json { "mode": "off", "enforced": false, "settings": { "coolingPeriodSeconds": 86400, "proofRequired": false, "oneTimeAddresses": false }, "effectiveCoolingPeriodSeconds": 86400, "pendingChange": null } ``` **Example request** ```bash curl https://api.aureahub.com/v1/address-book/settings \ -H "Authorization: Bearer $TOKEN" ``` Web version: https://docs.aureahub.com/#address-book-settings-get --- # Change the Address Book Settings Raise a protection at once; lower one through an approval. ## Overview - Send what changes: `coolingPeriodSeconds` (0 to `2592000`, 30 days), `proofRequired`, `oneTimeAddresses`. A field left out keeps its value; an empty body answers `400` `ADDRESS_BOOK_REQUEST_INVALID`. - **`200`, `state` `applied`**: the change raises or keeps every protection, and applies now — a longer cooling period, the proof required, one-time addresses off. - **`202`, `state` `pending`**: it lowers one — a shorter cooling period, the proof no longer required, one-time addresses on — and waits for [its approval](https://docs.aureahub.com/docs/address-book-change-approve.md). Nothing changed yet; `settings.pendingChange` is the change. - The answer is `{ state, settings, change }`: the settings as they are now, and the change, which is kept in [Setting Changes](https://docs.aureahub.com/docs/address-book-changes-list.md) either way. - Refused: another change waiting (`409` `ADDRESS_BOOK_SETTINGS_PENDING`, with `details.changeId`); a cooling period under 24 hours on the institutional profile (`400` `ADDRESS_BOOK_COOLING_TOO_SHORT`). - For `owner`, `tenantadmin` and `non_signing_admin`. ## Endpoint ### `PUT /v1/address-book/settings` Authentication: bearer token required. Changes the settings: at once when nothing is lowered, after an approval otherwise. **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `coolingPeriodSeconds` | integer | no | 0–2592000 seconds; at least 86400 on the institutional profile | | `proofRequired` | boolean | no | An external entry is proven before it is approved | | `oneTimeAddresses` | boolean | no | Operations may also go to addresses outside the book | **Responses** `200` Applied ```json { "state": "applied", "settings": { "mode": "off", "enforced": false, "settings": { "coolingPeriodSeconds": 86400, "proofRequired": true, "oneTimeAddresses": false }, "effectiveCoolingPeriodSeconds": 86400, "pendingChange": null }, "change": { "id": "2e9a7c41-0b5d-4f86-a3c2-9d1e8b7f6a05", "coolingPeriodSeconds": null, "proofRequired": true, "oneTimeAddresses": null, "lowers": false, "status": "applied", "requiredApprovals": 0, "requestedBy": "6a1c9e3f-2b7d-4f50-8e4a-0c5d3b9f7e12", "requestedScope": "tenant", "createdAt": "2026-10-08T09:00:00.000Z", "decidedBy": "6a1c9e3f-2b7d-4f50-8e4a-0c5d3b9f7e12", "decidedScope": "tenant", "decidedAt": "2026-10-08T09:00:00.000Z" } } ``` `202` Waits for approval ```json { "state": "pending", "settings": { "mode": "off", "enforced": false, "settings": { "coolingPeriodSeconds": 86400, "proofRequired": true, "oneTimeAddresses": false }, "effectiveCoolingPeriodSeconds": 86400, "pendingChange": { "id": "5b0d3e92-7c1a-4e68-b4f9-0a2c6d8e1f37", "coolingPeriodSeconds": 3600, "proofRequired": null, "oneTimeAddresses": null, "lowers": true, "status": "pending", "requiredApprovals": 1, "requestedBy": "6a1c9e3f-2b7d-4f50-8e4a-0c5d3b9f7e12", "requestedScope": "tenant", "createdAt": "2026-10-08T09:05:00.000Z", "decidedBy": null, "decidedScope": null, "decidedAt": null } }, "change": { "id": "5b0d3e92-7c1a-4e68-b4f9-0a2c6d8e1f37", "coolingPeriodSeconds": 3600, "proofRequired": null, "oneTimeAddresses": null, "lowers": true, "status": "pending", "requiredApprovals": 1, "requestedBy": "6a1c9e3f-2b7d-4f50-8e4a-0c5d3b9f7e12", "requestedScope": "tenant", "createdAt": "2026-10-08T09:05:00.000Z", "decidedBy": null, "decidedScope": null, "decidedAt": null } } ``` `409` One waits ```json { "statusCode": 409, "error": "ConflictError", "message": "A change of the settings already waits for its approval: approve, reject or withdraw it first", "details": { "code": "ADDRESS_BOOK_SETTINGS_PENDING", "changeId": "5b0d3e92-7c1a-4e68-b4f9-0a2c6d8e1f37" } } ``` **Example request** ```bash curl -X PUT https://api.aureahub.com/v1/address-book/settings \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "proofRequired": true }' ``` Web version: https://docs.aureahub.com/#address-book-settings-put --- # List the Settings' Changes Every change of the address book's settings asked for, newest first. ## Overview - Each change names only the settings it changes (the others are `null`), whether it `lowers` a protection, its `status` — `pending`, `applied`, `rejected` or `withdrawn` —, who asked and who decided. - `status` keeps one status; `limit` 1–100, default 20. One page: there is no cursor. - For the roles that read the book. ## Endpoint ### `GET /v1/address-book/settings/changes` Authentication: bearer token required. The settings' changes, newest first. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `status` | string | no | `pending`, `applied`, `rejected` or `withdrawn` | | `limit` | integer | no | 1–100, default 20 | **Responses** `200` OK ```json { "changes": [ { "id": "5b0d3e92-7c1a-4e68-b4f9-0a2c6d8e1f37", "coolingPeriodSeconds": 3600, "proofRequired": null, "oneTimeAddresses": null, "lowers": true, "status": "pending", "requiredApprovals": 1, "requestedBy": "6a1c9e3f-2b7d-4f50-8e4a-0c5d3b9f7e12", "requestedScope": "tenant", "createdAt": "2026-10-08T09:05:00.000Z", "decidedBy": null, "decidedScope": null, "decidedAt": null } ] } ``` Web version: https://docs.aureahub.com/#address-book-changes-list --- # Approve a Settings Change The change waiting for its approval is applied. ## Overview - `200`, `{ state, settings, change }` with `state` `applied` and the settings as they are now. No body. - Where your tenant's quorum applies — the institutional profile, or two administrators or more — an administrator other than who asked approves: `403` `ADDRESS_BOOK_QUORUM_REQUESTER`. A tenant with a single administrator approves its own change. A cooling period under 24 hours is refused again on the institutional profile (`400` `ADDRESS_BOOK_COOLING_TOO_SHORT`). - `404` `ADDRESS_BOOK_SETTINGS_CHANGE_NOT_FOUND`; `409` `ADDRESS_BOOK_SETTINGS_CHANGE_NOT_PENDING` (with `details.status`) once it was decided. - For `owner`, `tenantadmin` and `non_signing_admin`. ## Endpoint ### `POST /v1/address-book/settings/changes/{changeId}/approve` Authentication: bearer token required. Applies a settings change waiting for its approval. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `changeId` | string (uuid) | yes | The change's id | **Responses** `200` Applied ```json { "state": "applied", "settings": { "mode": "off", "enforced": false, "settings": { "coolingPeriodSeconds": 3600, "proofRequired": true, "oneTimeAddresses": false }, "effectiveCoolingPeriodSeconds": 3600, "pendingChange": null }, "change": { "id": "5b0d3e92-7c1a-4e68-b4f9-0a2c6d8e1f37", "coolingPeriodSeconds": 3600, "proofRequired": null, "oneTimeAddresses": null, "lowers": true, "status": "applied", "requiredApprovals": 1, "requestedBy": "6a1c9e3f-2b7d-4f50-8e4a-0c5d3b9f7e12", "requestedScope": "tenant", "createdAt": "2026-10-08T09:05:00.000Z", "decidedBy": "0f8e7d6c-5b4a-4392-8170-6e5d4c3b2a19", "decidedScope": "tenant", "decidedAt": "2026-10-08T10:12:44.000Z" } } ``` `403` Who asked ```json { "statusCode": 403, "error": "ForbiddenError", "message": "The tenant's quorum applies: an administrator other than who asked approves this change", "details": { "code": "ADDRESS_BOOK_QUORUM_REQUESTER" } } ``` Web version: https://docs.aureahub.com/#address-book-change-approve --- # Reject a Settings Change The change waiting for its approval is refused — or withdrawn, by who asked for it. ## Overview - `200`, `{ state, settings, change }`: `state` `rejected`, or `withdrawn` when who asked rejects it. The settings stay as they were. No body. - `404` `ADDRESS_BOOK_SETTINGS_CHANGE_NOT_FOUND`; `409` `ADDRESS_BOOK_SETTINGS_CHANGE_NOT_PENDING` once it was decided. - For `owner`, `tenantadmin` and `non_signing_admin`. ## Endpoint ### `POST /v1/address-book/settings/changes/{changeId}/reject` Authentication: bearer token required. Refuses, or withdraws, a settings change waiting for its approval. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `changeId` | string (uuid) | yes | The change's id | **Responses** `200` Withdrawn ```json { "state": "withdrawn", "settings": { "mode": "off", "enforced": false, "settings": { "coolingPeriodSeconds": 86400, "proofRequired": true, "oneTimeAddresses": false }, "effectiveCoolingPeriodSeconds": 86400, "pendingChange": null }, "change": { "id": "5b0d3e92-7c1a-4e68-b4f9-0a2c6d8e1f37", "coolingPeriodSeconds": 3600, "proofRequired": null, "oneTimeAddresses": null, "lowers": true, "status": "withdrawn", "requiredApprovals": 1, "requestedBy": "6a1c9e3f-2b7d-4f50-8e4a-0c5d3b9f7e12", "requestedScope": "tenant", "createdAt": "2026-10-08T09:05:00.000Z", "decidedBy": "6a1c9e3f-2b7d-4f50-8e4a-0c5d3b9f7e12", "decidedScope": "tenant", "decidedAt": "2026-10-08T09:31:02.000Z" } } ``` `409` Decided ```json { "statusCode": 409, "error": "ConflictError", "message": "Only a change waiting for its approval is decided", "details": { "code": "ADDRESS_BOOK_SETTINGS_CHANGE_NOT_PENDING", "status": "applied" } } ``` Web version: https://docs.aureahub.com/#address-book-change-reject --- # List Quorum Requests Your administrators' covered actions — held for another administrator, or only recorded — newest first. ## Overview - `state`, `action` and `requestedBy` narrow it; up to `limit` (1–100, default 50) in `requests`, and `nextCursor` to send as `cursor` for the next page (`null` on the last). The decisions are in [Get a Request](https://docs.aureahub.com/docs/quorum-request-get.md). - Only your tenant's requests. For `owner`, `tenantadmin`, `non_signing_admin`, `auditor`, `security_admin` and `security_auditor`. [Your Tenant's Quorum](https://docs.aureahub.com/docs/guide-tenant-quorum.md). ## Endpoint ### `GET /v1/quorum/requests` Authentication: bearer token required. Your tenant's quorum requests, newest first. **Query parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `state` | string | no | `observed`, `awaiting_quorum`, `approved`, `rejected`, `expired`, `executed`, `failed` or `cancelled` | | `action` | string | no | An action key, e.g. `users.role.change` ([Covered Actions](https://docs.aureahub.com/docs/guide-tenant-quorum.md)) | | `requestedBy` | string (uuid) | no | The administrator who asked | | `cursor` | string | no | The previous page's `nextCursor` | | `limit` | integer | no | 1–100, default 50 | **Responses** `200` OK ```json { "requests": [ { "id": "4e2b9c17-8a3d-4f60-b5e1-7c9d0a2f6b38", "action": "users.role.change", "group": "users_and_roles", "state": "awaiting_quorum", "mode": "enforce", "target": { "tenantId": "6f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", "userId": "0d2f6b8a-4c1e-4a93-b7d5-2e9f1c3a8b60", "resourceType": "role", "resourceId": "approver" }, "details": {}, "requestedBy": "6a1c9e3f-2b7d-4f50-8e4a-0c5d3b9f7e12", "requestedAt": "2026-10-09T09:00:00.000Z", "route": "PATCH /v1/admin/tenants/:tenantId/users/:userId/role", "requestId": "9c4e7a12-3b6d-4f80-a1c5-2e8b0d7f6a93", "requiredApprovals": 1, "approvals": 0, "expiresAt": "2026-10-10T09:00:00.000Z", "decidedAt": null, "executedAt": null, "resultStatus": null, "secretAnswer": false, "resultAvailable": false, "resultReadAt": null, "error": null } ], "nextCursor": null } ``` **Example request** ```bash curl "https://api.aureahub.com/v1/quorum/requests?state=awaiting_quorum" \ -H "Authorization: Bearer $ADMIN_TOKEN" ``` Web version: https://docs.aureahub.com/#quorum-requests-list --- # Get a Quorum Request One request, with its decisions. ## Overview - `action` and its `group` (`users_and_roles`, `credentials_and_secrets` or `policy_and_custody`); `state`; `mode` (`log` or `enforce`, when it was asked); `target` and `details` — what the action changes, by name, never a secret (often `{}`); `route` (the method and route pattern); `requestId` (the `X-Request-ID` of the call that asked); `expiresAt`, `decidedAt`, `executedAt`; `resultStatus`, `secretAnswer`, `resultAvailable`, `resultReadAt`; `error` (`code`, `message`) when it failed. - `decisions`: each `adminId`, `decision` (`approve`, `reject` or `cancel`), `reason` and `decidedAt`. - `404` `QUORUM_REQUEST_NOT_FOUND` for a request that is not your tenant's. Same roles as List Requests. ## Endpoint ### `GET /v1/quorum/requests/{id}` Authentication: bearer token required. One quorum request with its decisions. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | The request's id (`requestId` of the `202`) | **Responses** `200` Executed ```json { "id": "4e2b9c17-8a3d-4f60-b5e1-7c9d0a2f6b38", "action": "users.role.change", "group": "users_and_roles", "state": "executed", "mode": "enforce", "target": { "tenantId": "6f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", "userId": "0d2f6b8a-4c1e-4a93-b7d5-2e9f1c3a8b60", "resourceType": "role", "resourceId": "approver" }, "details": {}, "requestedBy": "6a1c9e3f-2b7d-4f50-8e4a-0c5d3b9f7e12", "requestedAt": "2026-10-09T09:00:00.000Z", "route": "PATCH /v1/admin/tenants/:tenantId/users/:userId/role", "requestId": "9c4e7a12-3b6d-4f80-a1c5-2e8b0d7f6a93", "requiredApprovals": 1, "approvals": 1, "expiresAt": "2026-10-10T09:00:00.000Z", "decidedAt": "2026-10-09T09:20:11.000Z", "executedAt": "2026-10-09T09:20:11.000Z", "resultStatus": 200, "secretAnswer": false, "resultAvailable": true, "resultReadAt": null, "error": null, "decisions": [ { "adminId": "0f8e7d6c-5b4a-4392-8170-6e5d4c3b2a19", "decision": "approve", "reason": "Agreed in the Monday call", "decidedAt": "2026-10-09T09:20:11.000Z" } ] } ``` `404` Not found ```json { "statusCode": 404, "error": "NotFoundError", "message": "Quorum request not found", "details": { "code": "QUORUM_REQUEST_NOT_FOUND" } } ``` Web version: https://docs.aureahub.com/#quorum-request-get --- # Approve a Quorum Request Another administrator's held action, approved with your password: it runs now. ## Overview - `password`: your own (1–200 characters), checked as the sign-in checks it. `reason`: optional, up to 500 characters. - `200` with the request: the action ran at once as who asked, with their role as it is now — `state` `executed`, or `failed` with `error`. Never the action's body: who asked reads it with [Read the Answer](https://docs.aureahub.com/docs/quorum-request-result.md). - For an active administrator of your tenant other than who asked, whose role could take the action ([Who Decides](https://docs.aureahub.com/docs/guide-tenant-quorum.md)). The checks and their order: [Refusals](https://docs.aureahub.com/docs/guide-tenant-quorum.md). ## Endpoint ### `POST /v1/quorum/requests/{id}/approve` Authentication: bearer token required. Approves another administrator's held action, which then runs. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | The request's id | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `password` | string | yes | Your own password | | `reason` | string | no | Up to 500 characters | **Responses** `200` Executed ```json { "id": "4e2b9c17-8a3d-4f60-b5e1-7c9d0a2f6b38", "action": "credentials.api_secret.rotate", "group": "credentials_and_secrets", "state": "executed", "mode": "enforce", "target": { "tenantId": "6f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0", "userId": null, "resourceType": null, "resourceId": null }, "details": {}, "requestedBy": "6a1c9e3f-2b7d-4f50-8e4a-0c5d3b9f7e12", "requestedAt": "2026-10-09T09:00:00.000Z", "route": "POST /v1/admin/tenants/:id/rotate-secret", "requestId": "1f7b3d95-6c2e-4a08-b4d1-8e5a9c0f2b67", "requiredApprovals": 1, "approvals": 1, "expiresAt": "2026-10-10T09:00:00.000Z", "decidedAt": "2026-10-09T09:20:11.000Z", "executedAt": "2026-10-09T09:20:11.000Z", "resultStatus": 200, "secretAnswer": true, "resultAvailable": true, "resultReadAt": null, "error": null, "decisions": [ { "adminId": "0f8e7d6c-5b4a-4392-8170-6e5d4c3b2a19", "decision": "approve", "reason": null, "decidedAt": "2026-10-09T09:20:11.000Z" } ] } ``` `403` Own request ```json { "statusCode": 403, "error": "ForbiddenError", "message": "The requester cannot decide its own request: another administrator of the tenant approves or rejects it", "details": { "code": "QUORUM_OWN_REQUEST" } } ``` `409` Decided ```json { "statusCode": 409, "error": "ConflictError", "message": "The request is rejected", "details": { "code": "QUORUM_NOT_AWAITING", "state": "rejected" } } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/quorum/requests/$REQUEST_ID/approve \ -H "Authorization: Bearer $OTHER_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "password": "", "reason": "Agreed in the Monday call" }' ``` Web version: https://docs.aureahub.com/#quorum-request-approve --- # Reject a Quorum Request Another administrator's held action, refused with your password: nothing runs. ## Overview - `password`: your own; `reason`: optional, up to 500 characters. `200` with the request, `state` `rejected`; the held input is deleted. - The same deciders and checks as [Approve](https://docs.aureahub.com/docs/quorum-request-approve.md). ## Endpoint ### `POST /v1/quorum/requests/{id}/reject` Authentication: bearer token required. Rejects another administrator's held action. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | The request's id | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `password` | string | yes | Your own password | | `reason` | string | no | Up to 500 characters | **Responses** `403` Wrong password ```json { "statusCode": 403, "error": "ForbiddenError", "message": "The password is not correct", "details": { "code": "QUORUM_PASSWORD_INVALID" } } ``` `409` Expired ```json { "statusCode": 409, "error": "ConflictError", "message": "The request has expired", "details": { "code": "QUORUM_EXPIRED", "state": "expired" } } ``` Web version: https://docs.aureahub.com/#quorum-request-reject --- # Cancel Your Quorum Request Take back an action you asked for, before anyone decides it. ## Overview - Who asked only (`403` `QUORUM_NOT_REQUESTER`: `Only the requester cancels a request`), while it waits. `reason`: optional, up to 500 characters. No password. - `200` with the request, `state` `cancelled`: nothing runs, and the held input is deleted. Decided already: `409` `QUORUM_NOT_AWAITING`. - For the roles that decide requests (`owner`, `tenantadmin`, `non_signing_admin`, `security_admin`). ## Endpoint ### `POST /v1/quorum/requests/{id}/cancel` Authentication: bearer token required. Cancels your own request while it waits. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | The request's id | **Request body** | Name | Type | Required | Description | | --- | --- | --- | --- | | `reason` | string | no | Up to 500 characters | **Responses** `403` Not yours ```json { "statusCode": 403, "error": "ForbiddenError", "message": "Only the requester cancels a request", "details": { "code": "QUORUM_NOT_REQUESTER" } } ``` Web version: https://docs.aureahub.com/#quorum-request-cancel --- # Read the Answer of Your Approved Request What your held action answered when it ran after its approval — once. ## Overview - Who asked only (`403` `QUORUM_NOT_REQUESTER`: `Only the requester reads the answer`), once the request is `executed`. No body. - `200` `{ "statusCode", "body" }`: the status and the body the call would have answered — a new API key or secret, a webhook's secret (`secretAnswer` `true`) —, `body` `null` when it had none. `Cache-Control: no-store`. - **Deleted as it is read**: a second read answers `404` `QUORUM_NO_RESULT` (`The answer was already read`). Unread, it is deleted after a while — 24 hours after the action ran, by default (`No answer is waiting for this request`). Store a secret before anything else. ## Endpoint ### `POST /v1/quorum/requests/{id}/result` Authentication: bearer token required. Reads, once, the answer of your approved action. **Path parameters** | Name | Type | Required | Description | | --- | --- | --- | --- | | `id` | string (uuid) | yes | The request's id | **Responses** `200` OK ```json { "statusCode": 200, "body": { "apiSecret": "" } } ``` `404` Read already ```json { "statusCode": 404, "error": "NotFoundError", "message": "The answer was already read", "details": { "code": "QUORUM_NO_RESULT" } } ``` **Example request** ```bash curl -X POST https://api.aureahub.com/v1/quorum/requests/$REQUEST_ID/result \ -H "Authorization: Bearer $ADMIN_TOKEN" ``` Web version: https://docs.aureahub.com/#quorum-request-result --- # 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. - **Send `dappOrigin`**, the origin of the dApp that asks (scheme, host and port: `https://app.example.com`). Where Aurea enables the message rules for your tenant — a tenant on the institutional security profile by its profile — a dApp's message is signed only when that origin is one of your tenant's active dApps, with the dApps switched on for your tenant; otherwise `403` `PERSONAL_SIGN_NOT_ALLOWED` and nothing is signed. Until then `dappOrigin` changes nothing. See [Signing Rules](https://docs.aureahub.com/docs/guide-signing-rules.md). ## 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). | | `dappOrigin` | string | no | The origin of the dApp that asks for the signature, e.g. `https://app.example.com`. Read by the message rules, where Aurea enables them for your tenant; ignored otherwise. | **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, dappUrl) { 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, dappOrigin: new URL(dappUrl).origin }) // dappUrl: the page that asks }); 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. **Headers** | Name | Type | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | string | no | Optional — required for a tenant on the institutional security profile (`400` `IDEMPOTENCY_KEY_REQUIRED` without it). Up to 255 letters, digits, - or _. The same key with the same request answers with the first answer (`idempotency-replayed: true`) instead of running it again; see [Idempotency](https://docs.aureahub.com/docs/idempotency.md) | **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 the swap router 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