# Deprecation policy
Source: https://docs.etherfuse.com/changelog/deprecation-policy
How Etherfuse announces, deprecates, and retires API behavior, and how much notice you get.
This page explains how the Etherfuse API changes over time and what you can rely on. Breaking
changes and deprecations are also announced in the [changelog](/changelog), tagged `Breaking` or
`Deprecated`.
## Lifecycle
Every endpoint, field, or flow moves through four stages:
| Stage | What it means |
| - | - |
| **Active** | Fully supported. Safe to build on. |
| **Deprecated** | Still works, but scheduled for removal. Announced in the changelog with a migration path. Start migrating. |
| **Sunset** | A fixed end date is set. The behavior still works until that date. |
| **Removed** | No longer available after the sunset date. |
## Breaking vs. non-breaking changes
What counts as **non-breaking** (no migration required):
* New endpoints, resources, or optional request parameters.
* New fields in a response or webhook payload.
* New webhook event types or enum values.
What counts as **breaking** (we provide a migration path):
* Removing or renaming an endpoint or field.
* Changing the type or meaning of an existing field.
* Changing an authentication flow.
## How we announce changes
When a breaking change or deprecation is scheduled, we use the loudest path available, because this
is money infrastructure:
1. A [changelog](/changelog) entry tagged `Breaking` or `Deprecated`, with migration steps.
2. A direct email to API-key holders.
3. An in-dashboard banner for breaking changes.
4. The affected endpoint is marked deprecated in the [API reference](/api-reference/introduction).
## What's currently deprecated
The current list of deprecated endpoints and flows (with replacements and status) lives on the
[Deprecations](/changelog/deprecations) page. Each deprecation is also announced in the
[changelog](/changelog) when it's scheduled, per the policy above.
# Deprecations
Source: https://docs.etherfuse.com/changelog/deprecations
Endpoints and flows that are deprecated, their replacements, and sunset dates.
These endpoints and flows are **deprecated**. They keep working until their **sunset date**, after which they're removed; migrate off them before then (see each entry's replacement). An entry with no sunset date keeps working until one is announced. How deprecations are announced and retired is described in the [deprecation policy](/changelog/deprecation-policy).
Customer onboarding's default is a **single hosted flow**: create the customer's `personal` organization, then launch them into `/idv`, which runs identity verification **and** agreement signing in one step. See [KYC WebSDK](/guides/kyc-websdk). The onboarding entries below were deprecated as a result.
**A programmatic path exists again**, on new endpoints under `/ramp/customer/{customer_id}/verification`. It is not a revival of the endpoints below: the shapes and semantics differ: real multipart uploads, an explicit submission order, `202` responses. See [KYC API](/guides/kyc-api), or [KYC Sumsub Shared Token](/guides/kyc-sumsub-shared-token) if your customers are already verified with you. The endpoints below remain deprecated on their stated dates.
## Presigned onboarding URL
`POST /ramp/onboarding-url`
The presigned "hosted UI" onboarding link is replaced by the `/idv` launch. Create the customer's org, then launch them into `/idv`.
* **Status:** Deprecated. **Sunset: August 16, 2026.**
* **Replacement:** [KYC WebSDK](/guides/kyc-websdk): create org, then launch `/idv`.
* **Reference:** [POST /ramp/onboarding-url](/api-reference/deprecated/generate-onboarding-url)
* **Change (Week of August 7, 2026):** `userInfo` is now required. Requests without a non-empty `email` and `displayName` are rejected.
## Presigned bank-account registration
`POST /ramp/onboarding/bank-accounts`
Registering a bank account using the presigned URL for auth is replaced by registering it with your API key.
* **Status:** Deprecated. **Sunset: August 16, 2026.**
* **Replacement:** [POST /ramp/customer/\{customer\_id}/bank-account](/api-reference/bank-accounts/create-bank-account-api) with your API key.
## Programmatic KYC submission
`POST /ramp/customer/{customer_id}/kyc`
Submitting a customer's identity fields over the API is replaced by the customer entering them inside the `/idv` launch.
* **Status:** Deprecated. **Sunset: August 16, 2026.**
* **Replacement:** [`POST /ramp/customer/{customer_id}/verification`](/guides/kyc-api#1-submit-personal-data) to submit identity data over the API, or the [`/idv` launch](/guides/kyc-websdk) to have the customer enter it.
* **Reference:** [POST /ramp/customer/\{customer\_id}/kyc](/api-reference/deprecated/submit-kyc)
## KYC document upload
`POST /ramp/customer/{customer_id}/kyc/documents`
Uploading ID and selfie images over the API is replaced by document capture inside the `/idv` launch.
* **Status:** Deprecated. **No sunset date set yet.**
* **Replacement:** [`POST /ramp/customer/{customer_id}/verification/documents`](/guides/kyc-api#3-upload-documents) to upload documents over the API, or the [`/idv` launch](/guides/kyc-websdk) to have the customer capture them.
* **Reference:** [POST /ramp/customer/\{customer\_id}/kyc/documents](/api-reference/deprecated/upload-kyc-docs)
## `/agreements` launch
`scope: agreements` · `target: /agreements`
Launching the customer into a separate agreements-signing step is replaced by signing inside `/idv`.
* **Status:** Deprecated. **Sunset: August 16, 2026.**
* **Replacement:** agreement signing happens automatically inside the [`/idv` launch](/guides/user-launch-flows#identity-verification).
## Direct agreement-signing endpoints
`POST /ramp/agreements/electronic-signature` · `POST /ramp/agreements/terms-and-conditions` · `POST /ramp/agreements/customer-agreement`
* **Status:** Removed.
* **Replacement:** agreements are signed inside the [`/idv` launch](/guides/kyc-websdk).
## Inline `bankAccount` on `POST /ramp/organization`
`POST /ramp/organization` no longer creates a bank account inline, and the response no
longer includes `bankAccount`. Bank accounts can only be registered once the owning
organization is approved.
* **Status:** Removed.
* **Replacement:** create the organization with [`POST /ramp/organization`](/api-reference/organizations/create-child-org), then register the bank account separately with [`POST /ramp/customer/{customer_id}/bank-account`](/api-reference/bank-accounts/create-bank-account-api) after the organization is approved.
* **Reference:** [Bank account creation](/api-reference/bank-accounts/create-bank-account-api)
## Per-wallet KYC status fields
`Wallet.kycStatus` and `Wallet.kycOnChain` have been removed from wallet list, search, get, and register responses. KYC state is organization-level.
* **Status:** Removed.
* **Replacement:** query [`GET /ramp/customer/{customer_id}/kyc`](/api-reference/kyc/get-kyc-status) or consume the [`kyc_updated`](/guides/verifying-webhooks) webhook.
* **Reference:** [Get KYC status](/api-reference/kyc/get-kyc-status)
* **Change (Week of August 14, 2026):** removed from the `Wallet` object.
## KYB progress endpoint
`GET /ramp/organization/{id}/kyb/progress`
Per-requirement KYB counts are replaced by a single status. The requirement breakdown they
counted belongs to the in-house KYB flow, which business verification no longer uses.
* **Status:** Deprecated. **Sunset: August 16, 2026.**
* **Replacement:** [`GET /ramp/organization/{id}/kyb/status`](/api-reference/organizations/get-kyb-status), which returns the same shape the [`kyb_updated`](/guides/verifying-webhooks) webhook delivers.
* **Reference:** [Get KYB progress](/api-reference/deprecated/get-kyb-progress)
## Requirement counts on `kyb_updated`
The `approved`, `submitted`, `notStarted` and `total` counts have been removed from the
[`kyb_updated`](/guides/verifying-webhooks) webhook payload. It now carries `organizationId`,
`status` and `approvedAt`.
* **Status:** Removed.
* **Replacement:** `status` alone. `submitted` means everything the business can do is done and it is Etherfuse's turn, which is what the counts were read for.
* **Reference:** [kyb\_updated payload](/api-reference/webhooks/kyb_updated)
**Not deprecated:** `GET /ramp/customer/{customer_id}/kyc` (KYC status) and the
[`kyc_updated`](/guides/verifying-webhooks) webhook are unchanged; keep using them to track KYC
state.
## Stablebond cost lookup
`GET /lookup/bonds/cost` · `GET /lookup/bonds/cost/{identifier}`
These price stablebonds only, so they cannot answer for a principal token such as MEXe.
`/lookup/tokens/cost` prices every Etherfuse token, bonds and principal tokens alike, and
renames the `bond_*` fields to `token_*` accordingly.
* **Status:** Deprecated. **No sunset date.** They keep working with their existing fields.
* **Replacement:** [`GET /lookup/tokens/cost`](/api-reference/lookup/list-token-costs) and [`GET /lookup/tokens/cost/{identifier}`](/api-reference/lookup/get-token-cost).
* **Reference:** [List stablebond costs](/api-reference/lookup/list-stablebond-costs) · [Get stablebond cost](/api-reference/lookup/get-stablebond-cost)
Field mapping:
| `/lookup/bonds/cost` | `/lookup/tokens/cost` |
| - | - |
| `bond_symbol` | `token_symbol` |
| `bond_cost_in_fiat` | `token_cost_in_fiat` |
| `bond_cost_in_usd` | `token_cost_in_usd` |
| `sources[].bond_cost_in_usd` | `sources[].token_cost_in_usd` |
| `mint` (the payment token, not the bond) | `payment_mint`, and `token_mint` for the token itself |
| `symbol` (always `"USDC"`) | dropped; use `payment_mint` |
| `bond_cost_in_payment_token` | dropped; it duplicated `bond_cost_in_fiat` |
| n/a | `token_kind`, `underlying_bond_symbol` |
The list endpoint is also keyed differently: `/lookup/bonds/cost` keys by Solana mint address,
`/lookup/tokens/cost` keys by uppercased token symbol.
# Get payment account balances
Source: https://docs.etherfuse.com/v2/api-reference/assets/get-payment-account-balances
openapi-v2.json GET /organization/{organizationId}/payment_account/{id}/balances
Reads the balances of one of the organization's wallets: every supported Etherfuse asset it holds, with the value of each in the asset's own fiat currency and in USD.
We read the address the wallet was registered with, on the chain it was registered on. Send `chain` to read an EVM wallet on another EVM chain. A bank account has no address and returns `422` `unsupported`. An address that is not valid on `chain` returns `400` `invalid-request`.
Assets with a zero balance are left out, so an empty `balances` is a successful read of a wallet holding nothing. `currency` is per balance, because one wallet can hold CETES in MXN and TESOURO in BRL at once. `balance` carries the asset's own decimal precision; the two value fields carry six decimal places. Interest-bearing assets accrue all the time, so two reads seconds apart can differ for an unchanged balance.
Balance reads are rate limited per organization. Only calls that reach the chain count, so a `400`, `404` or `422` does not use the limit. Over the limit, the call returns `429` `rate-limit-exceeded` with a `Retry-After` header.
# List supported assets
Source: https://docs.etherfuse.com/v2/api-reference/assets/list-supported-assets
openapi-v2.json GET /supported_assets
Lists the markets and tokens a quote can use. Each item's `asset` is the value to send as `sourceAsset` or `destinationAsset` on [Create quote](/v2/api-reference/quotes/create-quote).
- **Markets** are a currency in a country, such as MXN in Mexico, with the rails that move it (`spei`, `pix`).
- **Tokens** are the stablebonds and stablecoins on each chain.
With no filters you get every market and every token we support. Each filter narrows the lists, and filters combine:
- `chain` keeps tokens on that chain. Markets have no chain, so none come back.
- `currency` keeps markets in that currency and tokens backed by it.
- `paymentAccountId` keeps what that account can send or receive. A bank account gives its market and the rails it is registered on; a wallet gives the tokens on each chain it is registered on.
- `organizationId` does the same for every payment account the organization holds.
A payment account or organization your API key cannot act for returns `404` `not-found`. With both ids, the account must belong to that organization.
# Exchange a partner JWT
Source: https://docs.etherfuse.com/v2/api-reference/authentication/exchange-a-partner-jwt
openapi-v2.json POST /auth/token
OAuth 2.0 token endpoint ([RFC 6749 §3.2](https://www.rfc-editor.org/rfc/rfc6749#section-3.2)). Exchanges a partner-signed JWT for an Etherfuse access/refresh token pair, or refreshes an existing session.
Call this from your backend to provision a user ahead of a launch, or to prefetch a `refresh_token` that speeds up the launch redirect. Your backend signs a JWT with the key behind your registered JWKS and posts it here. To drop a user straight into the app in a browser, use [POST /auth/launch](/v2/api-reference/authentication/launch-a-user-into-the-app) instead.
## Grant types
- `urn:ietf:params:oauth:grant-type:jwt-bearer` ([RFC 7523](https://www.rfc-editor.org/rfc/rfc7523)). Put the partner JWT in `assertion`.
- `refresh_token` ([RFC 6749 §6](https://www.rfc-editor.org/rfc/rfc6749#section-6)). Put a prior `refresh_token` in `refresh_token`.
## The JWT
Sign it with the key behind your registered JWKS (see [Sign a user JWT](/v2/guides/jwt-authentication#sign-a-user-jwt) for full detail). Claims:
- **`iss`**: your registered issuer.
- **`sub`**: the person signing in; a UUID is _strongly recommended_ (for an individual customer it's the same value as their organization's `id`, and for a business member it's their `memberId`). Must name a person, never a business: a business org id is rejected with `invalid_grant`, and a `sub` can't be reused as a business org id ([POST /organization](/v2/api-reference/organizations/create-organization) returns 409). A non-UUID `sub` still signs in, but can't be addressed through the Ramp API.
- **`aud`**: the token endpoint URL, `https://api.etherfuse.com/auth/token` (`https://api.sand.etherfuse.com/auth/token` in sandbox).
- **`scope`**: required. Names the flow the user may complete (see [User Launch Flows](/v2/guides/user-launch-flows#scopes)); an unrecognized scope is rejected with `invalid_scope`.
- **`jti`** / **`nonce`**: a fresh value, unique per token, for replay protection. You can send it as `jti` (the standard JWT ID) or `nonce` (the standard OIDC claim).
- **`exp`, `iat`**: required; keep tokens short-lived.
- **`email`, `name`**: required; populate the user's profile. `picture` is optional.
This endpoint is unauthenticated: the signed JWT (or refresh token) is the credential. Do **not** send an API key.
# Launch a user into the app
Source: https://docs.etherfuse.com/v2/api-reference/authentication/launch-a-user-into-the-app
openapi-v2.json POST /auth/launch
> ⚠️ **Different host.** Unlike every other endpoint in this reference, `/auth/launch` lives on the **app host** (`app.etherfuse.com` / `sandbox.etherfuse.com`), **not** the API host (`api.etherfuse.com`). It is a browser-facing page, not a JSON API.
Drops one of your users into the Etherfuse app with an authenticated session. Use it for **browser** flows, such as sending a user into KYB. Your server POSTs an HTML form (or redirects the browser) to this URL; the app establishes the session and redirects the user to `target`. To embed the flow in an iframe or popup instead, use the [postMessage variant](/v2/api-reference/authentication/launch-a-user-via-postmessage).
## Targets
The `target` and `scope` for every flow you can launch a user into (and what to know about each) are documented in [User Launch Flows](/v2/guides/user-launch-flows). A `target` must be one of those allowed paths (optionally with a query string such as `?org=`); any other path is rejected with `invalid_target`.
The JWT's `sub` is _strongly recommended_ to be a UUID identifying the person signing in (see [Sign a user JWT](/v2/guides/jwt-authentication#sign-a-user-jwt) for how `sub` relates to an individual's organization `id` and a business member's `memberId`), and its `scope` follows the same rules as [`/auth/token`](/v2/api-reference/authentication/exchange-a-partner-jwt). The endpoint is unauthenticated: the JWT or refresh token is the credential.
```html Form POST theme={null}
```
```html Into an iframe theme={null}
```
# Launch a user via postMessage
Source: https://docs.etherfuse.com/v2/api-reference/authentication/launch-a-user-via-postmessage
openapi-v2.json GET /auth/launch
> ⚠️ **Different host.** Like [POST /auth/launch](/v2/api-reference/authentication/launch-a-user-into-the-app), this lives on the **app host** (`app.etherfuse.com` / `sandbox.etherfuse.com`), **not** the API host. It is a browser-facing page, not a JSON API.
The `postMessage` variant of launch, for embedding the flow in an **iframe or popup**. Open `/auth/launch` with no body; on load it asks its embedder (your page) for credentials over `postMessage`, and you reply. Use this when you don't want to server-render a [form POST](/v2/api-reference/authentication/launch-a-user-into-the-app). It also lets you handle session expiration and launch into different targets seamlessly, without a redirect page.
## postMessage contract
1. On load, the launch frame posts an `etherfuse:auth:request` message to its embedder.
2. Your page replies with an `etherfuse:auth:response` carrying the fields below.
3. If you need a moment to sign a JWT, send `etherfuse:auth:await` first so the frame doesn't time out.
## Fields
- **`grant_type`** and **`assertion`**: a raw partner JWT (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`) or a `refresh_token` from a prior [server-side exchange](/v2/api-reference/authentication/exchange-a-partner-jwt) (`grant_type=refresh_token`).
- **`target`**: the app path to land on. Must be an allowed target. See [User Launch Flows](/v2/guides/user-launch-flows#targets). Any other path is rejected.
- **`return_url`** *(optional)*: where to send the user when they leave.
The JWT's `sub` and `scope` follow the same rules as [POST /auth/launch](/v2/api-reference/authentication/launch-a-user-into-the-app).
```html iframe theme={null}
```
```javascript window.open theme={null}
// Open WITHOUT noopener so the window keeps `window.opener` and can
// postMessage back to your page.
const launcher = window.open("https://sandbox.etherfuse.com/auth/launch", "etherfuse");
window.addEventListener("message", (event) => {
if (event.origin !== "https://sandbox.etherfuse.com") return;
if (event.data?.type !== "etherfuse:auth:request") return;
launcher.postMessage(
{
type: "etherfuse:auth:response",
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion: SIGNED_JWT,
target: "/kyb",
},
event.origin,
);
});
```
# Introduction
Source: https://docs.etherfuse.com/v2/api-reference/introduction
The Etherfuse REST API: base URLs, auth, and conventions.
The Etherfuse API is a REST API. It accepts and returns JSON and uses standard HTTP verbs and status codes. Every endpoint page includes a **"Try it"** playground and code samples.
## What is in the reference
* **Payment accounts, quotes and orders** (`/organization/{organizationId}/*`): register the bank accounts and wallets money moves between, price a transfer, create the order and track it. `organizationId` is your own organization or one of your direct customer organizations. [`GET /orders`](/v2/api-reference/orders/list-orders) lists the orders of your organization and all its customer organizations. Start with [Register payment account](/v2/api-reference/payment-accounts/register-payment-account).
* **Webhook subscriptions** (`/webhook_subscription*`): receive `orderUpdated`, `paymentAccountUpdated`, `kycUpdated` and `kybUpdated` events. One subscription covers your organization and every customer organization.
* **Organizations, members, KYC and KYB**: read your organization and set your default partner fee (`/me`), create and list customer organizations (`/organization`, `/organizations`), manage a business's members, and verify customers. Start with [Create customer organization](/v2/api-reference/organizations/create-organization).
* **Assets, statements and lookup**: supported assets, wallet balances, monthly partner statements, and public reference data such as exchange rates and stablebonds. The `/lookup/*` endpoints need no API key.
## Base URLs
| Environment | Base URL |
| - | - |
| **Sandbox** | `https://api.sand.etherfuse.com` |
| **Production** | `https://api.etherfuse.com` |
Build and test against the sandbox first. See [Initial Setup](/v2/initial-setup). The endpoints are the same in both environments; only the base URL and API key differ.
## Authentication
Send your API key in the `Authorization` header, with **no `Bearer` prefix**. See [Authentication](/v2/authentication) for the sandbox and production key rules.
```bash theme={null}
curl https://api.sand.etherfuse.com/organization//payment_accounts \
-H "Authorization: "
```
## Conventions
RFC 9457 problem details with a stable `type` for every error.
`pageSize` and `pageNumber` query parameters, and one page envelope.
Creating an order twice from the same quote returns the same order.
Signed events that carry the full order or payment account.
## Use the spec directly
[Download `openapi-v2.json`](https://docs.etherfuse.com/openapi-v2.json) to import into Postman or Insomnia, or to generate a client. To use natural language instead, connect the [MCP server](/v2/mcp-overview).
# Get KYB status
Source: https://docs.etherfuse.com/v2/api-reference/kyb/get-kyb-status
openapi-v2.json GET /organization/{organizationId}/kyb
Returns a business organization's KYB status: the same `status` and `approvedAt` the `kybUpdated` webhook sends. Subscribe to `kybUpdated` to hear about changes, and read this to reconcile after downtime. A personal organization returns `400` `invalid-request`; read its KYC with [Get KYC status](/v2/api-reference/kyc/get-kyc-status).
# Get KYC status
Source: https://docs.etherfuse.com/v2/api-reference/kyc/get-kyc-status
openapi-v2.json GET /organization/{organizationId}/kyc
Returns the organization's KYC status. The overall `status` uses the same logic as orders, so `approved` is the only value that lets the customer transact. Submission details such as rejection reasons and timestamps are visible only to the partner that submitted the data.
## The requirements breakdown
Pass `?requirements=true` to add a `requirements` array describing, step by step, what this customer's verification still needs. Each entry carries a `type`, a `status`, and a `requiresLaunch` flag, which separates "I still owe data over the API" from "it is time to launch the customer". It is opt-in because it calls the verification provider; without it the response leaves `requirements` out.
The breakdown covers personal verification only. Requesting it for a business organization returns `400` `invalid-request`; track a business with [`GET /organization/{organizationId}/kyb`](/v2/api-reference/kyb/get-kyb-status) instead.
`requirements` is left out until the customer has been routed to their country's requirements. After that the list holds the customer's current stage, plus requirements already satisfied. It never lists a later stage's requirements, so it grows as they advance: `proofOfAddress` appears only when the identity document carried no address, and `customerAgreement` appears last. Send only what is listed as `pending` or `actionRequired`; a requirement sent before it is listed is accepted and then refused in the background. Read the list again after every `kycUpdated`; no single response is the full list. See [Requirements unlock in stages](/v2/guides/onboarding#requirements-unlock-in-stages).
## Prefer the webhook
Subscribe to `kycUpdated` to learn that something changed. Read this endpoint on demand, or after that webhook when you need to know what is outstanding. With `?requirements=true` it calls the verification provider every time, so do not poll it tightly. See [Tracking verification status](/v2/guides/onboarding#tracking-verification-status).
# Import a verified session
Source: https://docs.etherfuse.com/v2/api-reference/kyc/import-a-verified-session
openapi-v2.json POST /organization/{organizationId}/verification/import
Import a verification the customer already passed on the partner's own Sumsub level, handed over as a share token. Prerequisites, and what does and doesn't transfer: [KYC Sumsub Shared Token](/v2/guides/kyc-sumsub-shared-token).
Answers **`200`**, not `202`: the import is committed before the response, which carries the same requirements breakdown as [`GET /organization/{organizationId}/kyc?requirements=true`](/v2/api-reference/kyc/get-kyc-status).
That breakdown covers the identity stage only. Send what it lists, then wait for `kycUpdated` and read the requirements again: `proofOfAddress` and `customerAgreement` appear only when the customer reaches their stages. See [Requirements unlock in stages](/v2/guides/onboarding#requirements-unlock-in-stages).
**Only `shareToken` is accepted.** The verification level and the customer's identity are ours to decide. The token is a credential; it is never logged and appears in no response.
No country is sent: it is read from the shared verification and used to route the customer. The customer's registered `userInfo.email` is required and is set on the imported applicant.
**Nothing is trusted wholesale.** Everything imported is re-checked against Etherfuse's own requirements, so a session approved in the partner's account is not automatically approved here.
# List occupation categories
Source: https://docs.etherfuse.com/v2/api-reference/kyc/list-occupation-categories
openapi-v2.json GET /verification/occupations
The values [`POST /organization/{organizationId}/verification/questionnaire`](/v2/api-reference/kyc/submit-a-verification-questionnaire) accepts for `industry`. Send the `code`.
The codes are opaque, so this is the only way to know them. We read them live from the same definition the submission validates against, so the two always agree.
# Submit a verification questionnaire
Source: https://docs.etherfuse.com/v2/api-reference/kyc/submit-a-verification-questionnaire
openapi-v2.json POST /organization/{organizationId}/verification/questionnaire
Submit the customer's answers to one questionnaire, named by `type`.
| `type` | Answers | Applies to |
|---|---|---|
| `occupation` | `jobTitle`, `industry` | Every customer |
| `agreement` | `acceptTerms`, `signatureName`, `signatureDate` | Customers who accepted under your terms |
Documents are uploaded through [`POST /organization/{organizationId}/verification/documents`](/v2/api-reference/kyc/upload-verification-documents), including a region's tax document.
**`agreement` is a legal declaration, affirmed by `acceptTerms: true`.** By default the customer accepts the agreement themselves in `/idv`. Send `agreement` only if your own terms incorporate ours and you already collected their acceptance. [The customer agreement](/v2/guides/kyc-api#the-customer-agreement) sets out what you are declaring.
**Submit the customer's personal data first**, or this answers `409`. As with document upload, the stage that asks these questions doesn't exist on the customer until then.
**Send `agreement` only once `customerAgreement` is in the requirements list.** It belongs to the last stage, after the identity stage and any proof of address, so do not send it together with `occupation`. An early `agreement` answers `202`, then is refused in the background, and a `kycUpdated` webhook arrives with `needsWork: true`. See [Requirements unlock in stages](/v2/guides/onboarding#requirements-unlock-in-stages).
**Answers `202`, not `200`.** `industry` is resolved against the live category list in the background, so an unrecognized value doesn't fail the request: it leaves the occupation requirement outstanding.
# Submit verification data
Source: https://docs.etherfuse.com/v2/api-reference/kyc/submit-verification-data
openapi-v2.json POST /organization/{organizationId}/verification
First call in the [KYC API](/v2/guides/kyc-api) flow. Nothing else is accepted before it: `country` is what routes the customer to their country's requirements.
**Answers `202`, not `200`.** We validate the request before we answer and record it with the verification provider after. A `202` means accepted, not satisfied. Read the result from the `kycUpdated` webhook or [`GET /organization/{organizationId}/kyc?requirements=true`](/v2/api-reference/kyc/get-kyc-status).
Submitting again overwrites what was sent before, and never moves the customer's review status on its own. Personal organizations only.
# Upload verification documents
Source: https://docs.etherfuse.com/v2/api-reference/kyc/upload-verification-documents
openapi-v2.json POST /organization/{organizationId}/verification/documents
Upload the customer's documents as `multipart/form-data`. Send at least one of `id_front`, `id_back`, `proof_of_address` or `tax_document`.
**Submit the customer's personal data first.** Until [`POST /organization/{organizationId}/verification`](/v2/api-reference/kyc/submit-verification-data) has run the customer has no stages to upload against, so this answers `409`.
**Answers `202`, not `200`.** We read and check every part before we answer, and send the upload to the verification provider after. Watch [`GET /organization/{organizationId}/kyc?requirements=true`](/v2/api-reference/kyc/get-kyc-status) for the requirement to go satisfied.
**Upload what the current stage asks for.** Send a document once its requirement is listed. The exception is `proof_of_address`: you may send it with the identity document, before its own stage, as long as the identity stage has not been submitted yet. After that, wait for `proofOfAddress` to be listed. See [Requirements unlock in stages](/v2/guides/onboarding#requirements-unlock-in-stages).
# Get exchange rate
Source: https://docs.etherfuse.com/v2/api-reference/lookup/get-exchange-rate
openapi-v2.json GET /lookup/exchange_rate/usd_to_{currency}
Returns the current USD-to-specified-currency exchange rate, with a
per-provider `sources` breakdown.
Public. No API key required.
# Get stablebond
Source: https://docs.etherfuse.com/v2/api-reference/lookup/get-stablebond
openapi-v2.json GET /lookup/stablebond/{identifier}
Returns one stablebond with its current price, supply, and per-chain breakdown, from the same snapshot as `GET /lookup/stablebonds`.
`identifier` can be any of:
- the bond symbol or name, matched case-insensitively (`CETES`, `cetes`, `Etherfuse CETES`)
- the Solana mint address
- a chain's `tokenIdentifier` from the `blockchains` array (EVM addresses are matched case-insensitively)
- a Stellar asset as `CODE-ISSUER` or `CODE:ISSUER`, or the issuer address alone
When more than one bond matches, the first bond in `GET /lookup/stablebonds` order is returned. Etherfuse bonds on Stellar share an issuer, so an issuer address alone does not identify a specific bond. Use the symbol or the full `CODE-ISSUER` asset instead.
Public, no API key required.
# Get token cost
Source: https://docs.etherfuse.com/v2/api-reference/lookup/get-token-cost
openapi-v2.json GET /lookup/tokens/cost/{identifier}
Returns current pricing for one Etherfuse token, either a stablebond or a
principal token.
`identifier` is the token symbol or its Solana mint address. Symbols are
matched case-insensitively, so `mexe`, `MEXE` and `MEXe` all resolve to the
same token.
This replaces `GET /lookup/bonds/cost/{identifier}`, which is deprecated and
covers only stablebonds.
Public, no API key required.
# List country codes
Source: https://docs.etherfuse.com/v2/api-reference/lookup/list-country-codes
openapi-v2.json GET /lookup/country-codes
Returns supported countries with ISO 3166-1 alpha-2 codes, alpha-3 codes, and
English names. Use the alpha-2 code for `registrationInfo.countryOfBirth` and
`registrationInfo.countryOfIncorporation` when registering a payment account.
Public. No API key required.
# List exchange rates
Source: https://docs.etherfuse.com/v2/api-reference/lookup/list-exchange-rates
openapi-v2.json GET /lookup/exchange_rate
Returns current USD exchange rates for all supported currencies, each keyed by
`usd_to_` with a per-provider `sources` breakdown.
Public. No API key required.
# List restricted countries
Source: https://docs.etherfuse.com/v2/api-reference/lookup/list-restricted-countries
openapi-v2.json GET /lookup/restricted-countries
Returns the countries that are effectively restricted for KYC or KYB based on
external consensus and internal overrides. Each entry shows whether KYC, KYB, or both
are blocked.
Public. No API key required.
# List stablebonds
Source: https://docs.etherfuse.com/v2/api-reference/lookup/list-stablebonds
openapi-v2.json GET /lookup/stablebonds
Returns all stablebonds with current prices, supplies, and multi-chain
breakdown. Data is cached for ~5 minutes.
Public. No API key required.
# List token costs
Source: https://docs.etherfuse.com/v2/api-reference/lookup/list-token-costs
openapi-v2.json GET /lookup/tokens/cost
Returns current pricing for every Etherfuse token: stablebonds (CETES,
TESOURO, USTRY, ...) and principal tokens (MEXe, ...). Each entry carries the
token's cost in its denomination currency and in USD, plus a `sources` map
giving the exchange rate and derived USD cost from each FX provider, so you
can run your own oracle-safety checks.
Keyed by uppercased token symbol (`CETES`, `MEXE`). New tokens appear here
automatically once they are registered; nothing needs to change on your side.
This replaces `GET /lookup/bonds/cost`, which is deprecated and covers only
stablebonds.
Public, no API key required.
# Add organization member
Source: https://docs.etherfuse.com/v2/api-reference/members/add-organization-member
openapi-v2.json POST /organization/{organizationId}/member
Adds a person to a customer business organization, by `email` or `memberId`. A `memberId` is the `sub` you sign into the person's JWT. Members sign in with that JWT and complete the organization's KYB.
- **By `email`**: if the email belongs to someone who already signed in through your JWT, they become a member at once. Otherwise we create a pending invite, accepted the first time that email signs in with your `kyb` JWT. Send `emailInviteLink` to have us email them that link, or leave it out and tell them yourself.
- **By `memberId`**: adds someone by their `sub`, and sends no email. The `sub` exists once that person has exchanged a JWT with [POST /auth/token](/v2/api-reference/authentication/exchange-a-partner-jwt) or been launched. An unknown `sub` returns `404`, and a business organization's id is rejected.
Adding someone who was removed earlier brings them back. Adding a current member returns `409` `conflict`. The response lists the members and invites: someone added at once is under `members`, an invite is under `invited`.
Your own organization's members are managed in the Etherfuse portal, so naming it returns `403` `forbidden`. A personal organization has no members and returns `400` `invalid-request`.
# List a member's organizations
Source: https://docs.etherfuse.com/v2/api-reference/members/list-member-organizations
openapi-v2.json GET /member/{memberId}/organizations
Lists the customer business organizations a person belongs to, by their `memberId` (the JWT `sub`). This is the reverse of [List organization members](/v2/api-reference/members/list-organization-members).
Only your own customer organizations are searched. A `memberId` that belongs to none of them returns an empty `items`.
# List organization members
Source: https://docs.etherfuse.com/v2/api-reference/members/list-organization-members
openapi-v2.json GET /organization/{organizationId}/members
Lists a customer business organization's members and pending invites. The organization's own service account is never listed.
# Remove organization member
Source: https://docs.etherfuse.com/v2/api-reference/members/remove-organization-member
openapi-v2.json DELETE /organization/{organizationId}/member/{memberId}
Removes a member from a customer business organization.
# Revoke organization invite
Source: https://docs.etherfuse.com/v2/api-reference/members/revoke-organization-invite
openapi-v2.json DELETE /organization/{organizationId}/member_invite/{email}
Cancels a pending invite to a customer business organization, so the link in the invite email stops working.
# Cancel order
Source: https://docs.etherfuse.com/v2/api-reference/orders/cancel-order
openapi-v2.json POST /organization/{organizationId}/order/{id}/cancel
Requests cancellation of an order and returns the order. No request body. An order that can no longer be canceled returns `409` `order-not-cancelable`.
# Create order
Source: https://docs.etherfuse.com/v2/api-reference/orders/create-order
openapi-v2.json POST /organization/{organizationId}/order
Creates an order from a quote and returns the full order. Use the `organizationId` the quote was created with. The order's `id` is the `quoteId`.
One quote makes one order. Sending the same `quoteId` again returns the existing order with `200`, even after the quote has expired, so a retry never creates a second order. If two requests with the same `quoteId` arrive at the same time, one gets `201` and the other gets `409` `conflict`; read the order to get it.
`fundingInstructions.reference` is set when the order is created. `fundingInstructions.destination` is absent until the funding details are available. Subscribe to `orderUpdated` to receive the order when they arrive, or read the order again.
# Get order
Source: https://docs.etherfuse.com/v2/api-reference/orders/get-order
openapi-v2.json GET /organization/{organizationId}/order/{id}
Returns one of the organization's orders. An order that belongs to a different organization returns `404`.
# List orders
Source: https://docs.etherfuse.com/v2/api-reference/orders/list-orders
openapi-v2.json GET /orders
Lists orders belonging to the API key's organization and all its customer organizations, newest first. Each order's `organizationId` is the organization it belongs to. Pass `organizationId` to list one organization's orders; an organization your API key cannot act for returns `404` `not-found`.
# Prepare claim
Source: https://docs.etherfuse.com/v2/api-reference/orders/prepare-claim
openapi-v2.json POST /organization/{organizationId}/order/{id}/claim
Prepares the claim on a `claimable` order and returns what the customer does next. This call does not claim the funds; the customer finishes the claim with what we return. We build it on every call and never store it, because it goes stale. No request body.
The wallet's custody decides the `kind`:
- `stellarTransaction`: a self-held wallet signs and submits `xdr`.
- `authorization`: an embedded wallet's holder approves the order `authorization` named by `authorizationId`.
# Refresh funding instructions
Source: https://docs.etherfuse.com/v2/api-reference/orders/refresh-funding-instructions
openapi-v2.json POST /organization/{organizationId}/order/{id}/funding_instructions/refresh
Requests new funding instructions for an order and returns the order as it is. No request body. When the order is `created`, before its funding deadline, and either its funding destination has expired or its instructions carry a `preparedTransaction`, we get new instructions from the payment provider. Refresh a prepared transaction after its `refreshAfter`, or whenever submitting it fails because the wallet's sequence number or nonce moved. On Stellar and EVM chains, a refresh after the old transaction landed changes nothing. New instructions arrive in an `orderUpdated` webhook and on GET. Otherwise nothing changes. Calling it again before the new instructions arrive does not request them twice.
# Submit authorization
Source: https://docs.etherfuse.com/v2/api-reference/orders/submit-authorization
openapi-v2.json POST /organization/{organizationId}/order/{id}/authorization
Approves the order's pending `authorization` and returns the order. Sign the UTF-8 bytes of `authorization.method.message` with the P-256 key registered as the embedded wallet's `signerPublicKey`, using ECDSA with SHA-256, and send the signature hex-encoded, as DER or as the 128-character `r||s` form WebCrypto produces.
We verify the signature, then move the funds (`reason` `funding`) or claim them (`reason` `claim`). `authorization` is null on the returned order, and `orderUpdated` follows as the order progresses.
An authorization expires at `expiresAt`. We then replace it with a new one with a new `id`. Read the order again and sign the new `message`.
# Create customer organization
Source: https://docs.etherfuse.com/v2/api-reference/organizations/create-organization
openapi-v2.json POST /organization
Creates a customer organization under yours. It starts unapproved; it is approved once its KYC or KYB is. Register its bank accounts and wallets afterwards with [Register payment account](/v2/api-reference/payment-accounts/register-payment-account).
All fields are optional, so `{}` creates a bare personal organization. If `id` is already in use the request returns `409` `conflict`.
## Account types
- `personal`: an individual who completes KYC. Send `userInfo` so we can email them about their verification.
- `business`: a company that completes KYB. `userInfo` is rejected. Send `country` to set up KYB at creation, and add the people who complete it with [Add organization member](/v2/api-reference/members/add-organization-member).
`accountType` defaults to `personal` today and will become required. Send it explicitly.
## Signing customers in
For a **personal** organization whose customer signs in with a JWT (see [JWT User Authentication](/v2/guides/jwt-authentication)), send the JWT `sub` as `id`. A **business** organization has its own id; never reuse a customer's `sub` as one, which returns `409`.
# Get your organization
Source: https://docs.etherfuse.com/v2/api-reference/organizations/get-me
openapi-v2.json GET /me
Returns the organization that owns your API key. Use its `id` as `organizationId` when a call acts for your own organization. A `200` also confirms the key works.
# Get organization
Source: https://docs.etherfuse.com/v2/api-reference/organizations/get-organization
openapi-v2.json GET /organization/{organizationId}
Returns your organization or one of your customer organizations.
# List customer organizations
Source: https://docs.etherfuse.com/v2/api-reference/organizations/list-organizations
openapi-v2.json GET /organizations
Lists the customer organizations you created, newest first. Removed organizations are not listed. Your own organization is not in the list; read it with [`GET /me`](/v2/api-reference/organizations/get-me).
# Set your default partner fee
Source: https://docs.etherfuse.com/v2/api-reference/organizations/update-me
openapi-v2.json PATCH /me
Sets the partner fee, in basis points, that quotes add when the request sends no `partnerFeeBpsOverride`. See [Fees](/v2/fees).
# Rename organization
Source: https://docs.etherfuse.com/v2/api-reference/organizations/update-organization
openapi-v2.json PATCH /organization/{organizationId}
Changes the organization's `displayName`. A business organization's legal name does not change.
# Get payment account
Source: https://docs.etherfuse.com/v2/api-reference/payment-accounts/get-payment-account
openapi-v2.json GET /organization/{organizationId}/payment_account/{id}
Returns one of the organization's payment accounts with its per-rail readiness. An account the organization removed returns `404`.
# Get registration requirements
Source: https://docs.etherfuse.com/v2/api-reference/payment-accounts/get-registration-requirements
openapi-v2.json GET /organization/{organizationId}/payment_account/requirements
Returns what you need to send to register an account of this kind for the organization in the route: the `accountDetails` fields for the identifier kind, and the `registrationInfo` fields the route requires. `registrationInfo.missing` lists the required fields we hold no verified value for, so you ask the customer only for those. Pass the same `kind`, `country`, `currency` and `accountDetails.kind` you would send to register.
# List payment accounts
Source: https://docs.etherfuse.com/v2/api-reference/payment-accounts/list-payment-accounts
openapi-v2.json GET /organization/{organizationId}/payment_accounts
Lists the organization's payment accounts, oldest first. Accounts the organization removed are not listed.
# Register payment account
Source: https://docs.etherfuse.com/v2/api-reference/payment-accounts/register-payment-account
openapi-v2.json POST /organization/{organizationId}/payment_account
Registers a bank account or wallet for the organization in the route and returns it with its per-rail readiness.
State where the account lives in one of two ways:
- **Bank account:** set `kind` to `bank`, send `country` and `currency` (for example `MX` and `MXN`), and describe the account in `accountDetails` (for example a `clabe`).
- **Wallet:** set `kind` to `wallet` and send `accountDetails` of kind `address` with its `chain`. Do not send `country` or `currency` for a wallet. A wallet requires no `registrationInfo`, and we ignore any you send.
Registering does not prove that the customer owns the account.
**Registration info.** A bank account route requires holder fields such as `accountHolderName` and `taxId`. We fill each one from the organization's verified identity when we hold it. When a required field is still missing, the request fails with `422` `registration-info-required`, and the problem lists every required field in `required` and the ones still needed in `missing`, as JSON Pointers into the request (for example `#/registrationInfo/taxId`). Send the missing fields in `registrationInfo` and retry. A supplied `taxId` must match the verified one (`422` `holder-tax-id-mismatch`).
You can send any registration field, including ones this route does not require; we check each value's format and ignore the fields the route does not use. A name that is not a registration field returns `400` `validation-error`.
**Readiness.** A new account starts `pending`, and so does each rail direction. We register it with the payment provider, then move each direction to `ready` or `rejected`. Subscribe to `paymentAccountUpdated`, or read the account again, and quote a direction once it is `ready`.
**Registering an account again.** If the organization already holds the account, you get it back with `200`. When a direction was `rejected`, we register the account with the provider again using the `registrationInfo` you send, and those directions go back to `pending`. Otherwise we change nothing. If another organization holds it, the request fails with `409` `payment-account-unavailable`.
# Update payment account
Source: https://docs.etherfuse.com/v2/api-reference/payment-accounts/update-payment-account
openapi-v2.json PATCH /organization/{organizationId}/payment_account/{id}
Changes the label the organization gave the account. Nothing else on an account changes through this endpoint.
# Create quote
Source: https://docs.etherfuse.com/v2/api-reference/quotes/create-quote
openapi-v2.json POST /organization/{organizationId}/quote
Prices a transfer from one of the organization's payment accounts to another. Both accounts must belong to the organization in the route. Pass the quote's `quoteId` to `POST /organization/{organizationId}/order` with the same `organizationId` before `expiresAt`; a quote is valid for 2 minutes.
One side must be a wallet. Supported shapes: fiat to crypto (onramp), crypto to fiat (offramp), a same-chain swap between two wallets, and a cross-chain transfer of the same token between wallets on two supported chains. Fiat to fiat is not supported.
**Rails.** Each side moves on one rail. Omit `sourceRail` or `destinationRail` and we use the account's default rail for that direction (the rail with `isDefault: true` in the account's `rails`). When two of an account's rails tie, name the rail. The account's status for that direction on that rail must be `ready`, or the quote fails with `409` `payment-account-not-ready`.
**Assets.** Send an `asset` from [`GET /supported_assets`](/v2/api-reference/assets/list-supported-assets): a currency code such as `MXN` for a fiat side, a token's `asset` for a crypto side. Filter that list by `paymentAccountId` to see what each account can use.
**Approval.** The organization must be approved, or the quote fails with `409` `organization-not-approved`.
# Simulate fiat received
Source: https://docs.etherfuse.com/v2/api-reference/sandbox/simulate-fiat-received
openapi-v2.json POST /organization/{organizationId}/order/{id}/fiat_received
Sandbox only. Returns `403` `forbidden` in production.
Records a simulated fiat deposit for an order whose source is a bank account on `spei` or `pix`, so you can test an onramp without sending money. The order then continues as it would after a real deposit. Returns `200` with an empty body.
# Download statement CSV
Source: https://docs.etherfuse.com/v2/api-reference/statements/download-statement-csv
openapi-v2.json GET /partner_statement/{year}-{month}/csv
Downloads one closed month's statement as CSV, for example `/partner_statement/2026-6/csv`. The year and month are one path segment; the month may be zero padded or not. Returns a `text/csv` attachment named `partner-statement-2026-06.csv`.
# Download statement PDF
Source: https://docs.etherfuse.com/v2/api-reference/statements/download-statement-pdf
openapi-v2.json GET /partner_statement/{year}-{month}/pdf
Downloads one closed month's statement as PDF, for example `/partner_statement/2026-6/pdf`. The year and month are one path segment; the month may be zero padded or not. Returns an `application/pdf` attachment named `partner-statement-2026-06.pdf`.
# List partner statements
Source: https://docs.etherfuse.com/v2/api-reference/statements/list-partner-statements
openapi-v2.json GET /partner_statements
Returns your monthly revenue share statements, newest first. They belong to the organization that owns your API key, which must be a partner organization; a customer organization returns `403` `forbidden`.
Only closed months are listed. A month still open does not appear and its CSV and PDF return `404`. A closed month carries no amount until a payout is recorded, because the payout is converted at the rate on that day.
# kybUpdated
Source: https://docs.etherfuse.com/v2/api-reference/webhook-events/kybupdated
openapi-v2.json webhook kybUpdated
Fired when a business organization's KYB status changes.
**Statuses:** `not_started`, `in_progress` (the customer has something left to submit), `submitted` (everything requested has been submitted or there are open notes, Etherfuse's turn), `approved`, `denied`.
Fires only on an actual status transition, never on individual document or answer edits that leave the status unchanged. `approvedAt` is set once the organization is approved.
# orderUpdated
Source: https://docs.etherfuse.com/v2/api-reference/webhook-events/orderupdated
openapi-v2.json webhook orderUpdated
Fired when an onramp or offramp order changes status.
**Statuses:** `created` → `funded` → `completed` (or `failed`, `refunded`, `canceled`). Offramp orders additionally emit `finalized` after the reversal window closes.
The `X-Signature` header contains an HMAC-SHA256 signature over the [RFC 8785](https://datatracker.ietf.org/doc/html/rfc8785)-canonicalized JSON body. See [Verifying Webhooks](/guides/verifying-webhooks).
# paymentAccountUpdated
Source: https://docs.etherfuse.com/v2/api-reference/webhook-events/paymentaccountupdated
openapi-v2.json webhook paymentAccountUpdated
Sent to `paymentAccountUpdated` subscriptions when a payment account's `status` or the `status` of any rail direction changes. `paymentAccount` is the same object `GET /organization/{organizationId}/payment_account/{id}` returns, and `organizationId` is the organization that holds it.
The `X-Signature` header contains an HMAC-SHA256 signature over the [RFC 8785](https://datatracker.ietf.org/doc/html/rfc8785)-canonicalized JSON body. See [Verifying Webhooks](/guides/verifying-webhooks).
# Create webhook subscription
Source: https://docs.etherfuse.com/v2/api-reference/webhook-subscriptions/create-webhook-subscription
openapi-v2.json POST /webhook_subscription
Subscribes a URL to one v2 event. The response includes the signing `secret`. We return it only here, so store it. See [Verifying Webhooks](/guides/verifying-webhooks).
In production the URL must use `https`.
# Delete webhook subscription
Source: https://docs.etherfuse.com/v2/api-reference/webhook-subscriptions/delete-webhook-subscription
openapi-v2.json DELETE /webhook_subscription/{id}
Deletes one of your organization's webhook subscriptions. Deliveries to it stop.
# Get webhook subscription
Source: https://docs.etherfuse.com/v2/api-reference/webhook-subscriptions/get-webhook-subscription
openapi-v2.json GET /webhook_subscription/{id}
Returns one of your organization's webhook subscriptions. The secret is not included.
# List webhook subscriptions
Source: https://docs.etherfuse.com/v2/api-reference/webhook-subscriptions/list-webhook-subscriptions
openapi-v2.json GET /webhook_subscriptions
Lists all of your organization's webhook subscriptions, oldest first. The list is not paged. Secrets are not included.
# kycUpdated
Source: https://docs.etherfuse.com/v2/api-reference/webhooks/kycupdated
openapi-v2.json webhook kycUpdated
Fired whenever a customer's KYC status changes, and when a write you sent was accepted but could not be applied.
The `status` field is one of `not_started`, `in_progress`, `submitted`, `approved`, `denied` (see the payload schema); `approved: bool` is kept for back-compat. KYC is org-level, so `customerId` is the identifier to key on and `walletPublicKey` is best-effort and may be empty.
On a denial, `updateReason` and `userMessage` explain what needs to be corrected. When `needsWork` is `true`, the customer can resubmit without creating a new KYC request.
`needsWork` also arrives **without** a status change, when one of the verification endpoints answered `202` and the work then failed. `status` is the customer's current status, unchanged, and `userMessage` names what to send again. See [KYC API error handling](/guides/kyc-api#error-handling).
# API Authentication
Source: https://docs.etherfuse.com/v2/authentication
Authenticate requests with your Etherfuse API key: format, scope, and key management.
The Etherfuse API authenticates every request with an **API key** sent in the `Authorization` header. There is no OAuth flow; you send the key itself.
```bash theme={null}
curl https://api.sand.etherfuse.com/organization//payment_accounts \
-H "Authorization: "
```
**Do not use a `Bearer` prefix.** The header value is the raw API key: `Authorization: `, **not** `Authorization: Bearer `. A `Bearer` prefix is the most common cause of a `401 Unauthorized`.
## Key format
An Etherfuse API key has three colon-separated segments:
```
api_{env}:{key_id}:{org_id}
```
| Segment | Meaning |
| - | - |
| `api_` | Marks the value as an API key. |
| `{env}` | `sand` (sandbox) or `prod` (production). The environment is baked into the key, so **sandbox and production keys are not interchangeable**. |
| `{key_id}` | A random identifier unique to this key. |
| `{org_id}` | Your organization's ID. The key is scoped to this org. |
## What a key can do
A single key grants full access to **every v1 and v2 endpoint** for its organization and for any **child organizations** you create under it. There are no per-key roles, scopes, or IP allowlists; a key grants all or nothing for its org. Public `/lookup` endpoints need no key. See [Public endpoints](#public-endpoints).
v2 payment account, quote and order routes take the organization they act for as `{organizationId}`. The key can act for its own organization and for its direct customer organizations; any other id returns `404` `not-found`. Webhook subscriptions and `GET /orders` belong to the organization that owns the key. See [Organizations in the route](/v2/overview#organizations-in-the-route).
Keys are hashed server-side with an organization-specific salt, so a key only works for the org that issued it and can't be reused elsewhere.
## Sandbox vs. production keys
Use the key that matches the base URL you're calling.
| Environment | Base URL | Dashboard | Availability |
| - | - | - | - |
| **Sandbox** | `https://api.sand.etherfuse.com` | [sandbox.etherfuse.com](https://sandbox.etherfuse.com/account/manage) | After KYB, self-approved |
| **Production** | `https://api.etherfuse.com` | [app.etherfuse.com](https://app.etherfuse.com/account/manage) | After KYB approval |
The endpoints and auth scheme are identical across environments; only the base URL and key differ. Build against sandbox, then swap the base URL to go live.
**Production keys require business verification (KYB).** You can't create production keys until your organization completes KYB at [app.etherfuse.com/compliance](https://app.etherfuse.com/compliance). Sandbox requires KYB too, but you can approve it yourself with the **Approve KYB** button on the [KYB page](https://sandbox.etherfuse.com/account/kyb).
## Managing keys
**API keys require a business account.** Keys are only available on **business**-type organizations; a **personal** account can't create them. Use **Add account** on the [Manage Accounts](https://sandbox.etherfuse.com/account/manage) page to create a business organization and select it, then complete KYB. In sandbox, click **Approve KYB** at the bottom of the [KYB page](https://sandbox.etherfuse.com/account/kyb) and key management appears.
Create and manage keys in the dashboard under **Account → Manage Accounts** ([sandbox](https://sandbox.etherfuse.com/account/manage) · [production](https://app.etherfuse.com/account/manage)):
* **Up to 5 active keys** per organization.
* A new key is shown in **full only once**. Copy it immediately and store it securely. Afterward the dashboard shows only the key's **first 16 characters** for reference.
* **Deleting a key revokes it immediately.** There is no grace period.
* **There is no in-place rotation.** To rotate, create a new key, switch your integration over, then delete the old one. Both work during the cutover (within the 5-key limit).
Treat API keys like passwords. Store them in a secrets manager or environment variable, never in source control or client-side code. Call the API from your backend.
## Public endpoints
A few `Lookup` endpoints (exchange rates, stablebonds, token costs, country codes) are **public** and require no key. Every other v2 endpoint requires authentication. See the [API Reference](/v2/api-reference/lookup/list-stablebonds) for which endpoints are public.
To let a customer sign in with your own identity provider, see [JWT authentication](/v2/guides/jwt-authentication).
## Troubleshooting
| Symptom | Likely cause | Fix |
| - | - | - |
| `401 Unauthorized` | `Bearer` prefix on the header | Send the raw key: `Authorization: ` |
| `401 Unauthorized` | Wrong-environment key | Match the key to the base URL (sandbox key ↔ sandbox URL) |
| `401 Unauthorized` | Revoked, deleted, or mistyped key | Re-create the key in the dashboard |
| `401 Unauthorized` ("Invalid API key format") | Sent something other than the full `api_{env}:{key_id}:{org_id}` value | Use the complete key, all three segments |
| Can't see key management / prompted to finish KYB on sandbox | Viewing a **personal** account, or KYB not approved on the business account | Create/select a business org via **Add account**, then click **Approve KYB** on the KYB page |
v2 endpoints return a `401` as a problem details body with `type` ending in [`unauthorized`](/v2/errors#unauthorized). See [Errors](/v2/errors) for the other status codes.
# Errors
Source: https://docs.etherfuse.com/v2/errors
The problem details format v2 errors use, every problem type you can receive, and the error format of the other endpoints.
Every error from a v2 endpoint returns an [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem details body with content type `application/problem+json`. This includes authentication failures, unsupported methods and malformed JSON. A path that matches no endpoint is the one exception: it returns a plain `404` with an empty body.
The `/lookup` and `/auth` endpoints use a different format. See [Other endpoints](#other-endpoints).
```json theme={null}
{
"type": "https://docs.etherfuse.com/ramp/v2/errors/quote-expired",
"title": "Quote expired",
"status": 400,
"detail": "quote 5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b has expired or does not exist",
"instance": "tag:etherfuse.com,2026-08-28:R2-10120/0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d"
}
```
| Field | Meaning |
| - | - |
| `type` | `https://docs.etherfuse.com/ramp/v2/errors/` followed by the problem slug. **Use this value to select your error handler.** The URL opens this page. |
| `title` | Short summary of the problem type. The same for every occurrence of a type. |
| `status` | The HTTP status code. |
| `detail` | Human-readable explanation of this occurrence. Show it or log it; do not parse it. For `internal-error` it is always `An unexpected internal error occurred.` |
| `instance` | A unique identifier for this occurrence. Every error gets a new one. Include the full value when you contact support, so we can find the exact request. |
## Extra fields
Two problem types add fields.
**`validation-error`** adds `errors`, one entry per invalid field. `pointer` is a JSON Pointer into your request.
```json theme={null}
{
"type": "https://docs.etherfuse.com/ramp/v2/errors/validation-error",
"title": "Your request is not valid.",
"status": 400,
"detail": "invalid account details for clabe",
"instance": "tag:etherfuse.com,2026-08-28:R2-10006/6a7b8c9d-0e1f-4a2b-8c3d-4e5f6a7b8c9d",
"errors": [
{ "pointer": "#/accountDetails/clabe", "detail": "This field is required." },
{ "pointer": "#/accountDetails/memo", "detail": "This field is not allowed for this account kind." }
]
}
```
**`registration-info-required`** adds `required` and `missing`. Both are lists of JSON Pointers into your request: `required` is every registration field the route needs, and `missing` is the ones you still have to send.
```json theme={null}
{
"type": "https://docs.etherfuse.com/ramp/v2/errors/registration-info-required",
"title": "Registration information required",
"status": 422,
"detail": "required registration information was not supplied",
"instance": "tag:etherfuse.com,2026-08-28:R2-10022/0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d",
"required": ["#/registrationInfo/accountHolderName", "#/registrationInfo/taxId"],
"missing": ["#/registrationInfo/taxId"]
}
```
## Problem types
Each heading below is the slug at the end of `type`.
### invalid-request
`400` Invalid request. The request is malformed: bad JSON, an unknown enum value, a bad path or query parameter, or a value that fails a check (for example a CLABE check digit, a non-positive amount, or a `pageSize` outside 1 to 100). Fix the request. `detail` names the problem.
### validation-error
`400` Your request is not valid. One or more fields are missing, not allowed, or malformed. Fix each field listed in `errors`.
### quote-expired
`400` Quote expired. The quote passed to `POST /organization/{organizationId}/order` has expired or does not exist. Create a new quote and use its `quoteId`.
### amount-out-of-range
`400` Amount out of range. The amount is below the minimum or above the maximum of a rail. Change the amount. The account's `rails` show `minAmount` and `maxAmount` when a limit is known.
### sandbox-limit-exceeded
`400` Sandbox limit exceeded. The amount exceeds the sandbox limit for onramps (500 MXN per quote). Use a smaller amount in sandbox. See the [Sandbox Reference](/v2/test-environment).
### unauthorized
`401` Unauthorized. The `Authorization` header is missing, not ASCII, or does not hold a valid API key. Send the raw API key with no `Bearer` prefix, for the right environment. See [Authentication](/v2/authentication).
### forbidden
`403` Forbidden. The request is not allowed here, for example the sandbox simulator called in production, member endpoints called for your own organization, or partner statements read with a customer organization's key. Do not retry.
### not-found
`404` Not found. The resource does not exist or does not belong to the organization in the route, or the `organizationId` in the route is an organization your API key cannot act for. A path that matches no endpoint also returns `404`, with an empty body. Your key can act for its own organization and its direct customer organizations. Check the ids and the path.
### method-not-allowed
`405` Method not allowed. The route exists but not for this HTTP method. The `Allow` header lists the methods it accepts. Use a method from `Allow`.
### conflict
`409` Conflict. The request collides with existing state: a webhook subscription for this URL and event already exists, an order for this quote was created at the same time, the account is registered in a different market, an organization `id` is already in use, or a customer's verification is not at a stage that accepts this write. `detail` says which. Read the existing resource instead of creating it again. See [Idempotency](/v2/idempotency).
### organization-not-approved
`409` Organization not approved. The organization in the route is not approved, so it cannot quote. Complete verification, then quote again.
### payment-account-unavailable
`409` Payment account unavailable. Another organization holds this account. Register a different account.
### payment-account-disabled
`409` Payment account disabled. The account is disabled. It cannot be registered or quoted. Use a different account.
### payment-account-not-ready
`409` Payment account not ready. The account is not `ready` in the needed direction on the chosen rail, is not registered on the rail you named, or has no rail that can carry that direction. Check the account's `rails`. Wait for the direction to become `ready`, or name a rail the account is ready on.
### order-not-cancelable
`409` Order not cancelable. The order can no longer be canceled, for example it already finished. Read the order to see its status.
### order-not-claimable
`409` Order not claimable. The order is not `claimable`, so there is nothing to claim. Read the order to see its status.
### authorization-expired
`409` Authorization expired. The `id` you sent is not the order's current authorization, or it expired. Read the order, sign the new `authorization.method.message`, and send the new `id`.
### authorization-not-required
`409` Authorization not required. The order has no pending authorization. Read the order to see its current state.
### invalid-signature
`400` Invalid signature. The signature is not valid hex, or it does not verify over the authorization's `message` with the embedded wallet's `signerPublicKey`. Sign the exact UTF-8 bytes of `message` with ECDSA P-256 and SHA-256, and send it as DER or `r||s` hex.
### payload-too-large
`413` Payload too large. The request body is too large. Send a smaller body.
### rate-limit-exceeded
`429` Rate limit exceeded. You sent too many requests, for example balance reads for one organization. Wait the number of seconds in the `Retry-After` header, then send the request again.
### quote-unavailable
`422` Quote unavailable. We cannot price this route right now. Retry later with backoff.
### registration-info-required
`422` Registration information required. The route requires registration fields we do not hold and you did not send. Send the fields listed in `missing` under `registrationInfo`.
### holder-tax-id-mismatch
`422` Account holder tax ID mismatch. `registrationInfo.taxId` does not match the verified tax ID. Send the verified tax ID, or leave `taxId` out and we use the verified one.
### unsupported
`422` Unsupported. The operation is not supported, for example a `debitCard` account, an identifier no route accepts, a route we cannot quote, or a sandbox deposit simulation on an order whose source does not receive fiat. Do not retry the same request. Change the account or route.
### internal-error
`500` Internal server error. An unexpected error on our side. Retry with backoff. If it persists, contact support with the `instance`.
### not-implemented
`501` Not implemented. The endpoint is documented but not available yet in this environment. Do not retry. Check the [changelog](/changelog) for when it ships.
### service-unavailable
`503` Service unavailable. A dependency is temporarily unavailable. Retry with backoff.
## Handling errors
* Handle errors by `type`. Use `status` as a fallback for unknown types.
* `4xx` other than `quote-unavailable` and `rate-limit-exceeded`: fix the request before retrying. The same request fails the same way.
* `rate-limit-exceeded`: retry after `Retry-After`.
* `quote-unavailable`, `internal-error` and `service-unavailable`: retry with exponential backoff. `POST /organization/{organizationId}/order` is safe to retry with the same `quoteId`; it returns the existing order instead of creating a second one.
* Keep the `instance` of any error you report to support.
## Other endpoints
The `/lookup` and `/auth` endpoints do not return problem details. `/lookup` returns an HTTP status code with a short message, as plain text or as a JSON object `{ "error": "" }`. `/auth/token` returns OAuth 2.0 errors; see [Authentication errors](/v2/guides/authentication-errors). Handle these by HTTP status code, and display or log the message rather than using it as an error code.
The KYC verification endpoints list every missing or invalid field in one `400` `detail`. Fill them in and send the request again; the call is safe to repeat. See [KYC API error handling](/v2/guides/kyc-api#error-handling).
Still stuck? [Book a session with the team](https://cal.com/team/etherfuse/api-overview?overlayCalendar=true).
# Transaction Fees
Source: https://docs.etherfuse.com/v2/fees
What each trade costs: same-currency fees, FX fees by market, and partner fees.
Every trade buys or sells an Etherfuse asset. Entering a stablebond is free, leaving one costs 5 bps, and a trade that crosses currencies pays that market's FX fee. When a trade crosses two markets, the higher of the two FX fees applies. Every quote shows its fees before you create an order.
1 bps (basis point) = 0.01%.
This page summarizes the schedule for API integrations. The full schedule, with a table for every market, is in the support center: [Transaction fees](https://support.etherfuse.com/articles/3367253525-transaction-fees).
## Within one currency
Trades that stay in one currency have flat fees. Each market has a currency, its stablebond and its principal token:
| Market | Currency | Stablebond | Principal token |
| - | - | - | - |
| Mexico | MXN | CETES | MEXe |
| Brazil | BRL | TESOURO | BRAe |
| US dollar | USDC, USDT | USTRY | USAe |
| Trade | Example | Fee |
| - | - | - |
| Currency into its stablebond | MXN to CETES | 0 bps |
| Stablebond back to its currency | CETES to MXN | 5 bps |
| Currency to or from its principal token | MXN to MEXe, MEXe to MXN | 0 bps |
| Principal token into its stablebond | MEXe to CETES | 0 bps |
| Stablebond into its principal token | CETES to MEXe | 5 bps |
US dollar assets are all in US dollars, so no FX fee applies between them. Buying USTRY is free, and selling it back to USDC, USDT or USAe costs 5 bps.
## Across currencies
A trade between currencies, such as MXN to USDC, pays the FX fee of the market it trades in. Your tier comes from your combined 30-day volume across all markets, in USD, and the fee gets lower as you scale:
| 30-day volume (USD) | Mexico | Brazil | Eurozone | UK |
| - | - | - | - | - |
| \$0 to 5M | 20 bps | 40 bps | 10 bps | 12 bps |
| \$5M to 10M | 15 bps | 35 bps | 7 bps | 9 bps |
| \$10M to 50M | 10 bps | 30 bps | 5 bps | 6 bps |
| \$50M to 100M | 8 bps | 25 bps | 4 bps | 5 bps |
| \$100M+ | 5 bps | 20 bps | 3 bps | 4 bps |
For example, converting 1,000,000 MXN to USDC with under \$5M of 30-day volume uses Mexico FX at 20 bps, so the fee is 2,000 MXN.
We apply your current tier automatically. The FX fee also covers price movement during the conversion, so we do not add a separate slippage charge. A cross-currency trade pays only the FX fee, not the 5 bps stablebond fee as well.
### Between markets
When a trade moves between two markets, the higher of the two markets' FX fees applies. CETES to GILTS uses Mexico FX, and CETES to TESOURO uses Brazil FX.
### Eurozone and UK
EUROB (Eurozone) and GILTS (UK) are bought and sold against USDC, USDT or another market's assets. There are no euro or sterling bank transfers.
## Fees on a quote
Quotes list the fee from the schedule above as `transactionFee`. A quote can also carry a partner fee, a wallet setup fee and a bridge fee.
[`POST /organization/{organizationId}/quote`](/v2/api-reference/quotes/create-quote) returns these fee fields:
| Field | Meaning |
| - | - |
| `feeBps` | The total fee in basis points, partner fee included. |
| `feeAmount` | The total fee as a decimal string, in the **source asset**: fiat for an onramp, the source token for an offramp or swap. |
| `fees` | An itemized list of applicable fees. |
`destinationAmount` is already net of every fee. `rateAfterFees` is `destinationAmount / sourceAmount`, and `rateBeforeFees` is the rate before our fee.
On an onramp into a stablebond, we price the bond at the end of the order's funding window, not at the time of the quote. A stablebond gains value every day, so the customer receives slightly fewer bond tokens than today's price would give. In exchange, the amount stays the same however late in the window the customer pays.
`feeAmount` is rounded down to the source asset's decimals, for example two decimals for MXN.
Each line in `fees` has a `type`, an `amount` (decimal string), the `asset` it is charged in, and `bps` (always `null` on a quote):
| `type` | What it is |
| - | - |
| `transactionFee` | The Etherfuse fee from the schedule above: the same-currency fee or the FX fee. |
| `partnerFee` | Your partner fee. See [Partner fees](#partner-fees). |
| `walletSponsor` | A fixed fee for setting up the destination Stellar wallet (creating the account or adding the trustline) when it needs setup. |
| `bridge` | A fixed fee for moving tokens across chains when the order delivers on a different chain from where we issue the asset. |
We include a line only when its amount is greater than zero. A line's `asset` can differ from the quote's source and destination assets. On an MXN to USDC onramp, for example, we convert through an intermediate token, and the `transactionFee` and `partnerFee` lines are charged in that token. Compare lines with each other only when they share an `asset`, and use `feeAmount` for the total in the source asset.
The order you create from the quote returns the same `fees` lines. See [Get order](/v2/api-reference/orders/get-order).
## Partner fees
You can add a **partner fee** of up to 500 bps (5.00%) on top of the platform fee.
* Set your default with [`PATCH /me`](/v2/api-reference/organizations/update-me).
* Override it on one quote with `partnerFeeBpsOverride` in the [quote request](/v2/api-reference/quotes/create-quote). We cap values above 500 at 500.
* We use the per-quote override first, then your organization's default (`partnerFeeDefaultBps`), then 0.
* Read your current default with [`GET /me`](/v2/api-reference/organizations/get-me) (`partnerFeeDefaultBps`).
The partner fee is part of `feeBps` and `feeAmount`, and it has its own `partnerFee` line in `fees`. Read your amount from that line. Do not add your partner fee on top of `feeBps`, and do not deduct it again from `destinationAmount`: both already include it.
## Payouts
We record the partner fee on each order and pay the accumulated fees monthly.
* **Schedule**: paid about **2 weeks after the end of each calendar month**.
* **Currency**: paid out in the underlying currency currently in use (subject to change).
* **Reporting**: the running total and a per-fee breakdown are in your dashboard under [Reports](https://app.etherfuse.com/account/reports), and in your [partner statements](/v2/api-reference/statements/list-partner-statements).
Want market-specific pricing? [Book a call with our team](https://cal.com/team/etherfuse/etherfuse-demo).
# Glossary
Source: https://docs.etherfuse.com/v2/glossary
Key terms used across the Etherfuse API and docs.
## Products & assets
| Term | Meaning |
| - | - |
| **Stablebond** | A tokenized, **yield-bearing** sovereign bond (for example CETES). It earns the yield of the underlying government debt while you hold it on-chain. |
| **Stablecoin** | A token pegged to a fiat currency, such as USDC. |
### Stablebonds
Stablebonds are **yield-bearing**: they accrue the yield of the underlying sovereign debt while you hold them.
### Token addresses
Each stablebond is deployed on multiple chains, each with its own mint (Solana), asset issuer (Stellar), or token contract (EVM). The addresses below are pulled live and link to each chain's block explorer. Use them to verify a token, add a trustline, or import the asset. When you **quote**, always use the token's `asset` from [`GET /supported_assets`](/v2/api-reference/assets/list-supported-assets) and do not hardcode addresses. Sandbox identifiers differ from these production ones.
## Payment accounts
| Term | Meaning |
| - | - |
| **Payment account** | A bank account or a wallet that money moves from or to. You register both with [`POST /organization/{organizationId}/payment_account`](/v2/api-reference/payment-accounts/register-payment-account), and quotes name them by `id`. |
| **Kind** (`kind`) | What sort of account it is: `bank` or `wallet`. The request returns `422` `unsupported` for `debitCard` and `cash`. |
| **Rail** | The network that moves the money: `spei` or `pix` for a bank account, or the chain (`stellar`, `solana`, `base`, `polygon`, `monad`, `avalanche`) for a wallet. |
| **accountDetails** | The identifier that names the account, tagged by its own `kind`: `clabe` (a CLABE), `pixKey` (a Pix key), or `address` (a wallet address plus its `chain`). We do not return it on reads. |
| **registrationInfo** | Account holder details a bank route needs, such as `accountHolderName` and `taxId`. We fill what we can from the verified identity of the organization in the route; a `422` `registration-info-required` lists what you still have to send. |
| **Route readiness** | The account's status on one rail in one direction, under `rails..source` (money leaving the account) and `rails..destination` (money arriving). Each is `pending`, `ready`, `rejected` or `disabled`, and you can quote a direction only when it is `ready`. A direction is `null` when the rail cannot move money that way. |
| **Default rail** (`isDefault`) | The rail a quote uses for a side when the quote does not name one. |
## Transaction types
| Term | Meaning |
| - | - |
| **Onramp** | Buying tokens with fiat: the customer pays from a bank account and receives tokens in a wallet. |
| **Offramp** | Selling tokens for fiat: the customer sends tokens from a wallet and receives fiat in a bank account. |
| **Swap** | Exchanging one token for another on the same chain. |
| **Cross-chain transfer** | Sending a token from a wallet on one supported chain to a wallet on another. |
| **Quote** | Pricing for a transfer between two payment accounts, from [`POST /organization/{organizationId}/quote`](/v2/api-reference/quotes/create-quote). Create the order before `expiresAt`, 2 minutes after the quote is created. |
| **Order** | A transfer created from a quote with [`POST /organization/{organizationId}/order`](/v2/api-reference/orders/create-order). The order `id` equals the `quoteId`, and sending the same `quoteId` again returns the same order. |
| **Order status** | `created`, `funded`, `claimable`, `completed`, `failed`, `canceled` or `refunded`. |
| **Authorization** | The account holder's approval of a transfer we built, such as an offramp from an embedded wallet. While one is pending, the order's `authorization` holds the `message` to sign, and [`POST /organization/{organizationId}/order/{id}/authorization`](/v2/api-reference/orders/submit-authorization) takes the signature. |
| **Claimable** | The order status after we deliver the funds, while the customer still has to accept them. `claimInstructions` holds what they need, and [`POST /organization/{organizationId}/order/{id}/claim`](/v2/api-reference/orders/prepare-claim) prepares the claim. |
| **Funding instructions** (`fundingInstructions`) | How the source funds an order: the `rail`, our `reference`, the `destination` to pay (a CLABE for SPEI, a BR Code for Pix, an address for a wallet), and the `fundingDeadline`. |
## Fiat & banking (Mexico)
| Term | Meaning |
| - | - |
| **MXN** | Mexican peso. |
| **SPEI** | Mexico's real-time interbank electronic transfer system. |
| **CLABE** | The 18-digit standardized Mexican bank account number used for SPEI transfers. Registered as `accountDetails` of kind `clabe`. |
| **RFC** | Mexican taxpayer ID (Registro Federal de Contribuyentes). Sandbox uses placeholder RFCs such as `XAXX010101000`. See the [Test environment](/v2/test-environment). |
## Fiat & banking (Brazil)
| Term | Meaning |
| - | - |
| **BRL** | Brazilian real, the fiat currency for Brazil ramps. |
| **Pix** | Brazil's instant payment system, operated by the Central Bank of Brazil. The Brazilian equivalent of SPEI. |
| **Pix key** (`pixKey`) | The identifier a Pix payment is sent to: a CPF, CNPJ, email, phone number, or random key. Registered as `accountDetails` of kind `pixKey`. |
| **BR Code** (`brCode`) | The Pix payment code in an order's funding instructions when the source pays over Pix. |
| **CPF** | Brazilian individual taxpayer ID (Cadastro de Pessoas Físicas). The Brazilian analog of Mexico's RFC. |
| **CNPJ** | Brazilian business taxpayer ID (Cadastro Nacional da Pessoa Jurídica). |
## Identity & compliance
| Term | Meaning |
| - | - |
| **KYC** | Know Your Customer: identity verification for an individual (personal customer). |
| **KYB** | Know Your Business: verification of a business entity, such as your organization for production access. |
| **Partner fee** | An additional fee (up to 500 bps) a partner can add on top of the platform fee. It appears as a `partnerFee` line in a quote's `fees`. See [Transaction Fees](/v2/fees). |
| **bps** | Basis points. 1 bps = 0.01%; 100 bps = 1%. |
## On-chain
| Term | Meaning |
| - | - |
| **Wallet** | A blockchain address registered as a payment account of kind `wallet`, with `accountDetails` of kind `address` and its `chain`. |
| **Trustline** | *(Stellar)* Authorization a wallet must establish before it can hold a given asset. |
| **XDR** | *(Stellar)* The encoded transaction format a user signs, for example the `xdr` in a claim transaction. |
| **Claimable balance** | *(Stellar)* A pending balance the recipient claims. A `claimable` Stellar order carries one in `claimInstructions` with kind `stellarClaimableBalance`. |
| **Prepared transaction** (`preparedTransaction`) | A transaction we build for the customer to sign and submit when a wallet funds an order. It sits in the funding destination. A Solana or Stellar transaction goes stale after `refreshAfter`; a Stellar or EVM transaction also goes stale when the wallet's sequence number or nonce moves. Refresh the funding instructions to get a new one. |
| **Stellar anchor** | A bridge between the Stellar network and traditional fiat rails, defined by Stellar Ecosystem Proposals (SEPs). Etherfuse acts as an anchor for Stellar offramps. [Learn more ↗](https://developers.stellar.org/docs/learn/fundamentals/anchors) |
## Verification
| Term | Meaning |
| - | - |
| **KYC WebSDK** | The hosted verification flow: you create the customer and launch them, and they complete every step in the widget. See [KYC WebSDK](/v2/guides/kyc-websdk). |
| **KYC API** | Pushing a customer's personal data, documents and questionnaire answers over the API so they aren't asked for them again, then launching them only for what is left. See [KYC API](/v2/guides/kyc-api). |
| **Share token** | A single-use share token created in a partner's own Sumsub account. It hands Etherfuse a verification the customer already passed, so documents *and* liveness transfer. Requires a data-sharing agreement. See [KYC Sumsub Shared Token](/v2/guides/kyc-sumsub-shared-token). |
| **Requirement** | One step in a customer's verification, named by its `type`, with a `status` and a `requiresLaunch` flag that is true when only the customer can satisfy it. Returned by `GET /organization/{organizationId}/kyc?requirements=true`. See [Tracking verification status](/v2/guides/onboarding#tracking-verification-status). |
## Organization & events
| Term | Meaning |
| - | - |
| **Child organization** | A sub-organization you create (and can apply a partner fee to) on behalf of a partner or customer. |
| **organizationId** | The organization a payment account, quote or order call acts for, in the route `/organization/{organizationId}/...`. It is your own organization or one of your direct customer organizations. Any other id returns `404` `not-found`. See [Organizations in the route](/v2/overview#organizations-in-the-route). |
| **Webhook** | An HMAC-signed HTTP callback. Subscribe with [`POST /webhook_subscription`](/v2/api-reference/webhook-subscriptions/create-webhook-subscription) to `orderUpdated`, `paymentAccountUpdated`, `kycUpdated` or `kybUpdated`. See [Webhooks](/v2/webhooks). |
# Authentication errors
Source: https://docs.etherfuse.com/v2/guides/authentication-errors
What each JWT auth and launch error means, and how to fix it.
Authentication failures follow the OAuth 2.0 error format ([RFC 6749 §5.2](https://www.rfc-editor.org/rfc/rfc6749#section-5.2)):
```json theme={null}
{
"error": "invalid_scope",
"error_description": "Scopes not allowed for partner auth: foo. (Valid: kyb)",
"error_uri": "https://docs.etherfuse.com/guides/authentication-errors#scope-not-allowed"
}
```
* **`error`** is the machine-readable category (one of the headings below).
* **`error_description`** is a human-readable detail.
* **`error_uri`** links straight to the matching entry on this page, which opens automatically.
Find your error by its `error` code below.
## invalid\_request
The request was missing a parameter or had a malformed value.
`Missing required parameter: grant_type`
Every request needs a `grant_type` of `urn:ietf:params:oauth:grant-type:jwt-bearer` ([RFC 7523](https://www.rfc-editor.org/rfc/rfc7523)) or `refresh_token` ([RFC 6749 §6](https://www.rfc-editor.org/rfc/rfc6749#section-6)).
`Missing required parameter: assertion`
The `jwt-bearer` grant needs the signed JWT in `assertion`. (A launch may instead send a `refresh_token`.)
`Missing required parameter: refresh_token`
The `refresh_token` grant needs the prior refresh token in `refresh_token`.
`Missing target parameter`
A launch must say where to land. Set `target` to an allowed app path. See [Targets](/v2/guides/user-launch-flows#targets).
The launch `target` is not a relative path, or is not an allowed destination. Pass one of the allowed targets. See [Targets](/v2/guides/user-launch-flows#targets). Absolute URLs and unknown paths are rejected so a launch can't be redirected off-site.
The launch `return_url` is not a valid `http(s)` URL. Pass an absolute `https://` URL, or omit it entirely: `return_url` is optional.
A `postMessage` launch was opened without a parent or opener to request credentials from. Open the launch page in an iframe or a popup so it can ask your page for the JWT, or use the [form POST](/v2/guides/jwt-authentication#launch-the-user-into-the-app) method instead.
`Assertion is not a valid JWT`
The `assertion` couldn't be parsed as a JWT. Check that you're sending the compact-serialized token (three base64url segments separated by dots), not a decoded payload. See [Sign a user JWT](/v2/guides/jwt-authentication#sign-a-user-jwt).
## unsupported\_grant\_type
`Unsupported grant_type: `
Use `urn:ietf:params:oauth:grant-type:jwt-bearer` ([RFC 7523](https://www.rfc-editor.org/rfc/rfc7523), exchange a JWT) or `refresh_token` ([RFC 6749 §6](https://www.rfc-editor.org/rfc/rfc6749#section-6), refresh a session). No other grant types are supported.
## invalid\_scope
`Unsupported scope: `
The JWT's `scope` claim contains a value Etherfuse doesn't recognize. Use a documented scope (see [Scopes](/v2/guides/jwt-authentication#scopes)), or an empty string to provision a user with no abilities.
`Scopes not allowed for partner auth: . (Valid: kyb)`
The scope is recognized but not permitted for partner JWTs. Today the only grantable partner scope is `kyb`.
## invalid\_grant
The JWT or refresh token was rejected.
`JWT has expired`
The token's `exp` is in the past. Sign short-lived tokens and exchange them promptly. Check for clock skew between your signer and real time.
`JWT audience mismatch`
The `aud` claim isn't the token endpoint. Set `aud` to `https://api.etherfuse.com/auth/token` (or the sandbox host `https://api.sand.etherfuse.com/auth/token`).
`JWT nonce or jti has already been used` / `JWT must include a unique nonce or jti claim` / `JWT nonce and jti claims must not differ`
Each JWT must carry a unique replay-protection value in a `jti` (JWT ID) or `nonce` (OIDC) claim, and a given value can be exchanged only once. Generate a fresh one for every token. Send at least one of the two; if you send both, they must be identical.
`JWT missing email claim`
Include an `email` claim in the JWT.
`JWT missing or empty name claim`
Include a non-empty `name` claim in the JWT.
`Subject must not be a business organization id`
The `sub` names a business organization. It must be a customer's UUID, not the id of a business org.
`Invalid or expired refresh token`
The refresh token is unknown or has expired. Sign a fresh JWT and exchange it again.
`Unknown user`
The session's user no longer exists (for example, the account was removed). Sign a fresh JWT and exchange it again to re-establish the user. See [JWT User Authentication](/v2/guides/jwt-authentication).
`Account deactivated`
This customer's Etherfuse account is deactivated and can't authenticate. Contact your Etherfuse representative.
## invalid\_client
`Unknown issuer: `
The JWT's `iss` isn't registered with Etherfuse, so we can't find your JWKS. Confirm `iss` matches exactly what you registered, and contact your Etherfuse representative if it hasn't been set up yet. See [Before you start](/v2/guides/jwt-authentication#before-you-start).
`JWT signature verification failed`
The signature didn't validate against the keys in your JWKS. Confirm the token is signed by a key published in your JWKS and that its `kid` header matches that key. If you rotated keys, make sure the new public key is live at your JWKS URL.
`JWKS endpoint unreachable` / `JWKS bad response`
We couldn't fetch a valid JWKS from **your** endpoint, so the signature can't be verified (returns `502`/`504`). Make sure your JWKS URL is publicly reachable over HTTPS and returns a valid JSON Web Key Set. See [Before you start](/v2/guides/jwt-authentication#before-you-start).
A `postMessage` launch asked your page for credentials and timed out. Respond to `etherfuse:auth:request` promptly, or send `etherfuse:auth:await` first if you need a moment to sign a JWT. See [Launch a user via postMessage](/v2/api-reference/authentication/launch-a-user-via-postmessage).
## server\_error
The auth provider Etherfuse set up for your issuer is incomplete or invalid (a missing or malformed JWKS/issuer URL, or no organization linked). You can't fix this one yourself. Contact your Etherfuse representative so we can correct the configuration.
Something failed on Etherfuse's side while creating the session (for example, issuing the token). These are transient. Retry, and if it persists, contact your Etherfuse representative with the `error_description`.
# Avalanche
Source: https://docs.etherfuse.com/v2/guides/chains/avalanche
EVM asset identifiers, wallet registration, and offramp funding on Avalanche.
We support onramps and offramps on Avalanche C-Chain, including MEXe. Avalanche is an EVM chain, so assets are plain ERC-20 contracts. The end-to-end flows are in the [Onramps](/v2/guides/testing-onramps) and [Offramps](/v2/guides/testing-offramps) guides.
## Supported operations
| Operation | Supported |
| - | - |
| Onramp (fiat → token) | ✓ |
| Offramp (token → fiat) | ✓ |
| Swap (token → token) | ✗ Swaps run on [Stellar](/v2/guides/chains/stellar), [Solana](/v2/guides/chains/solana) and [Monad](/v2/guides/chains/monad) |
## Asset identifiers
Avalanche assets are identified by the **raw token contract address only**.
**Do not prefix with a symbol.** A `SYMBOL:0x...` identifier (the Stellar-style format) fails on an EVM chain. Pass only the `0x...` address, the token's `asset` from [`GET /supported_assets?chain=avalanche`](/v2/api-reference/assets/list-supported-assets).
## Stablebond addresses
Live token-contract addresses for every stablebond on Avalanche, each linked to Snowtrace:
## Register an Avalanche wallet
Register the wallet as a payment account with `accountDetails` of kind `address` and `chain` `avalanche`. Set `$ORGANIZATION_ID` to the organization that owns the wallet: your own organization or a customer organization. See [Organizations in the route](/v2/overview#organizations-in-the-route).
```bash theme={null}
curl -X POST https://api.sand.etherfuse.com/organization/$ORGANIZATION_ID/payment_account \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{
"kind": "wallet",
"label": "Avalanche wallet",
"accountDetails": {
"kind": "address",
"address": "0xf82567432381D1326484E5A9140ABCa4294f82D0",
"chain": "avalanche"
}
}'
```
Use the returned `id` as the `source` or `destination` of a [quote](/v2/api-reference/quotes/create-quote). An EVM address is valid on every EVM chain, so the account's `rails` can list more than one EVM chain. Set `sourceRail` or `destinationRail` to `avalanche` in the quote to pick this chain.
## Offramps
On an Avalanche offramp, the order's `fundingInstructions.rail` is `avalanche`, and `fundingInstructions.destination` gives the `address` to send the token to. The source asset is an ERC-20 token. When `preparedTransaction` is present, its `transaction` is an unsigned EIP-1559 transaction, `0x`-prefixed hex, that calls the token's `transfer` from the customer's wallet. The customer signs it, submits it and pays the gas in AVAX. Otherwise, send the order's `source.amount` of `source.asset` to `address`. The prepared transaction uses the wallet's next nonce, so it goes stale if the wallet sends another transaction first. Then call [`POST /organization/{organizationId}/order/{id}/funding_instructions/refresh`](/v2/api-reference/orders/refresh-funding-instructions) and sign the new one. See [Offramps](/v2/guides/testing-offramps).
## Related
# Base
Source: https://docs.etherfuse.com/v2/guides/chains/base
EVM asset identifiers, wallet registration, and offramp funding on Base.
We support onramps and offramps on Base. Base is an EVM chain, so assets are plain ERC-20 contracts. The end-to-end flows are in the [Onramps](/v2/guides/testing-onramps) and [Offramps](/v2/guides/testing-offramps) guides.
## Supported operations
| Operation | Supported |
| - | - |
| Onramp (fiat → token) | ✓ |
| Offramp (token → fiat) | ✓ |
| Swap (token → token) | ✗ Swaps run on [Stellar](/v2/guides/chains/stellar), [Solana](/v2/guides/chains/solana) and [Monad](/v2/guides/chains/monad) |
We quote stablebonds on Base. Stablecoin (for example USDC) onramps and offramps run on Solana, Stellar and Monad.
## Asset identifiers
Base assets are identified by the **raw token contract address only**, for example `0xcC77c598d42f2f78Beb42C91d12B9d4041a5cE29`.
**Do not prefix with a symbol.** A `SYMBOL:0x...` identifier (the Stellar-style format) fails on an EVM chain. Pass only the `0x...` address, the token's `asset` from [`GET /supported_assets?chain=base`](/v2/api-reference/assets/list-supported-assets).
## Stablebond addresses
Live token-contract addresses for every stablebond on Base, each linked to Basescan:
## Register a Base wallet
Register the wallet as a payment account with `accountDetails` of kind `address` and `chain` `base`. Set `$ORGANIZATION_ID` to the organization that owns the wallet: your own organization or a customer organization. See [Organizations in the route](/v2/overview#organizations-in-the-route).
```bash theme={null}
curl -X POST https://api.sand.etherfuse.com/organization/$ORGANIZATION_ID/payment_account \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{
"kind": "wallet",
"label": "Base wallet",
"accountDetails": {
"kind": "address",
"address": "0xf82567432381D1326484E5A9140ABCa4294f82D0",
"chain": "base"
}
}'
```
Use the returned `id` as the `source` or `destination` of a [quote](/v2/api-reference/quotes/create-quote). An EVM address is valid on every EVM chain, so the account's `rails` can list more than one EVM chain. Set `sourceRail` or `destinationRail` to `base` in the quote to pick this chain.
## Offramps
On a Base offramp, the order's `fundingInstructions.rail` is `base`, and `fundingInstructions.destination` gives the `address` to send the token to. The source asset is an ERC-20 token. When `preparedTransaction` is present, its `transaction` is an unsigned EIP-1559 transaction, `0x`-prefixed hex, that calls the token's `transfer` from the customer's wallet. The customer signs it, submits it and pays the gas in ETH. Otherwise, send the order's `source.amount` of `source.asset` to `address`. The prepared transaction uses the wallet's next nonce, so it goes stale if the wallet sends another transaction first. Then call [`POST /organization/{organizationId}/order/{id}/funding_instructions/refresh`](/v2/api-reference/orders/refresh-funding-instructions) and sign the new one. See [Offramps](/v2/guides/testing-offramps).
## Related
# Monad
Source: https://docs.etherfuse.com/v2/guides/chains/monad
EVM asset identifiers, wallet registration, and supported operations on Monad.
We support onramps, offramps and swaps on Monad. Like Base and Polygon, it is an EVM chain, so assets are plain ERC-20 contracts. The end-to-end flows are in the [Onramps](/v2/guides/testing-onramps), [Offramps](/v2/guides/testing-offramps) and [Swaps](/v2/guides/testing-swaps) guides.
## Supported operations
| Operation | Supported |
| - | - |
| Onramp (fiat → token) | ✓ |
| Offramp (token → fiat) | ✓ |
| Swap (token → token) | ✓ |
We quote both stablebonds and stablecoins (for example USDC) on Monad.
## Asset identifiers
Monad assets are identified by the **raw token contract address only**, for example `0xcC77c598d42f2f78Beb42C91d12B9d4041a5cE29`.
**Do not prefix with a symbol.** A `SYMBOL:0x...` identifier (the Stellar-style format) fails on an EVM chain. Pass only the `0x...` address, the token's `asset` from [`GET /supported_assets?chain=monad`](/v2/api-reference/assets/list-supported-assets).
## Stablebond addresses
Live token-contract addresses for every stablebond on Monad, each linked to the explorer:
## Register a Monad wallet
Register the wallet as a payment account with `accountDetails` of kind `address` and `chain` `monad`. Set `$ORGANIZATION_ID` to the organization that owns the wallet: your own organization or a customer organization. See [Organizations in the route](/v2/overview#organizations-in-the-route).
```bash theme={null}
curl -X POST https://api.sand.etherfuse.com/organization/$ORGANIZATION_ID/payment_account \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{
"kind": "wallet",
"label": "Monad wallet",
"accountDetails": {
"kind": "address",
"address": "0xf82567432381D1326484E5A9140ABCa4294f82D0",
"chain": "monad"
}
}'
```
Use the returned `id` as the `source` or `destination` of a [quote](/v2/api-reference/quotes/create-quote). An EVM address is valid on every EVM chain, so the account's `rails` can list more than one EVM chain. Set `sourceRail` or `destinationRail` to `monad` in the quote to pick this chain.
## Offramps
On a Monad offramp, the order's `fundingInstructions.rail` is `monad`, and `fundingInstructions.destination` gives the `address` to send the token to. The source asset is an ERC-20 token. When `preparedTransaction` is present, its `transaction` is an unsigned EIP-1559 transaction, `0x`-prefixed hex, that calls the token's `transfer` from the customer's wallet. The customer signs it, submits it and pays the gas in MON. Otherwise, send the order's `source.amount` of `source.asset` to `address`. The prepared transaction uses the wallet's next nonce, so it goes stale if the wallet sends another transaction first. Then call [`POST /organization/{organizationId}/order/{id}/funding_instructions/refresh`](/v2/api-reference/orders/refresh-funding-instructions) and sign the new one. See [Offramps](/v2/guides/testing-offramps).
## Related
# Polygon
Source: https://docs.etherfuse.com/v2/guides/chains/polygon
EVM asset identifiers, wallet registration, and offramp funding on Polygon.
We support onramps and offramps on Polygon. Like Base, it is an EVM chain, so assets are plain ERC-20 contracts. The end-to-end flows are in the [Onramps](/v2/guides/testing-onramps) and [Offramps](/v2/guides/testing-offramps) guides.
## Supported operations
| Operation | Supported |
| - | - |
| Onramp (fiat → token) | ✓ |
| Offramp (token → fiat) | ✓ |
| Swap (token → token) | ✗ Swaps run on [Stellar](/v2/guides/chains/stellar), [Solana](/v2/guides/chains/solana) and [Monad](/v2/guides/chains/monad) |
We quote stablebonds on Polygon. Stablecoin (for example USDC) onramps and offramps run on Solana, Stellar and Monad.
## Asset identifiers
Polygon assets are identified by the **raw token contract address only**, for example `0xcC77c598d42f2f78Beb42C91d12B9d4041a5cE29`.
**Do not prefix with a symbol.** A `SYMBOL:0x...` identifier (the Stellar-style format) fails on an EVM chain. Pass only the `0x...` address, the token's `asset` from [`GET /supported_assets?chain=polygon`](/v2/api-reference/assets/list-supported-assets).
## Stablebond addresses
Live token-contract addresses for every stablebond on Polygon, each linked to Polygonscan:
## Register a Polygon wallet
Register the wallet as a payment account with `accountDetails` of kind `address` and `chain` `polygon`. Set `$ORGANIZATION_ID` to the organization that owns the wallet: your own organization or a customer organization. See [Organizations in the route](/v2/overview#organizations-in-the-route).
```bash theme={null}
curl -X POST https://api.sand.etherfuse.com/organization/$ORGANIZATION_ID/payment_account \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{
"kind": "wallet",
"label": "Polygon wallet",
"accountDetails": {
"kind": "address",
"address": "0xf82567432381D1326484E5A9140ABCa4294f82D0",
"chain": "polygon"
}
}'
```
Use the returned `id` as the `source` or `destination` of a [quote](/v2/api-reference/quotes/create-quote). An EVM address is valid on every EVM chain, so the account's `rails` can list more than one EVM chain. Set `sourceRail` or `destinationRail` to `polygon` in the quote to pick this chain.
## Offramps
On a Polygon offramp, the order's `fundingInstructions.rail` is `polygon`, and `fundingInstructions.destination` gives the `address` to send the token to. The source asset is an ERC-20 token. When `preparedTransaction` is present, its `transaction` is an unsigned EIP-1559 transaction, `0x`-prefixed hex, that calls the token's `transfer` from the customer's wallet. The customer signs it, submits it and pays the gas in POL. Otherwise, send the order's `source.amount` of `source.asset` to `address`. The prepared transaction uses the wallet's next nonce, so it goes stale if the wallet sends another transaction first. Then call [`POST /organization/{organizationId}/order/{id}/funding_instructions/refresh`](/v2/api-reference/orders/refresh-funding-instructions) and sign the new one. See [Offramps](/v2/guides/testing-offramps).
## Related
# Solana
Source: https://docs.etherfuse.com/v2/guides/chains/solana
Asset identifiers, wallet registration, gas handling, and swap support on Solana.
We support onramps, offramps and swaps on Solana. Solana needs the least per-transaction setup of the supported networks: there are no trustlines, and network fees are negligible. The end-to-end flows are in the [Onramps](/v2/guides/testing-onramps), [Offramps](/v2/guides/testing-offramps) and [Swaps](/v2/guides/testing-swaps) guides.
## Supported operations
| Operation | Supported |
| - | - |
| Onramp (fiat → token) | ✓ |
| Offramp (token → fiat) | ✓ |
| Swap (token → token) | ✓ |
## Asset identifiers
Solana assets use the **Base58 mint address**, for example `AvvetPGuuB5FD5m86fpw3LtDKyQoUFT1mG9WarNQLW4q`.
Read the mint, the token's `asset`, from [`GET /supported_assets?chain=solana`](/v2/api-reference/assets/list-supported-assets) (or the public [`GET /lookup/stablebonds`](/v2/api-reference/lookup/list-stablebonds)). Do not hardcode it; mints differ between sandbox and production.
## Stablebond addresses
Live mint accounts for every stablebond on Solana, each linked to Solscan:
## Register a Solana wallet
Register the wallet as a payment account with `accountDetails` of kind `address` and `chain` `solana`. Set `$ORGANIZATION_ID` to the organization that owns the wallet: your own organization or a customer organization. See [Organizations in the route](/v2/overview#organizations-in-the-route).
```bash theme={null}
curl -X POST https://api.sand.etherfuse.com/organization/$ORGANIZATION_ID/payment_account \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{
"kind": "wallet",
"label": "Solana wallet",
"accountDetails": {
"kind": "address",
"address": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
"chain": "solana"
}
}'
```
Use the returned `id` as the `source` or `destination` of a [quote](/v2/api-reference/quotes/create-quote).
## Wallet and gas
Solana has no trustline or account-reserve step. Network fees are a tiny fraction of a cent. On an offramp or swap, the customer's wallet pays the fee for the funding transaction. The prepared transaction only transfers the tokens; our receiving wallet's token account already exists.
## Offramps and swaps: fund the order
For an offramp or swap from a Solana wallet, `fundingInstructions.destination.address` is the address to pay. The source asset is an SPL token. Fund the order in one of two ways:
1. **Sign the prepared transaction.** `preparedTransaction.transaction` is an unsigned v0 transaction, base64-encoded in Solana's wire format. The customer's wallet is the fee payer and the only signer. Sign it and submit it.
2. **Send the tokens yourself.** Send the order's `source.amount` of `source.asset` to `address`.
Decode it as a versioned transaction, for example with `VersionedTransaction.deserialize` in `@solana/web3.js`. `Transaction.from` reads only legacy transactions.
The prepared transaction uses a recent blockhash, so it goes stale. After `preparedTransaction.refreshAfter`, about 60 seconds after we build it, call [`POST /organization/{organizationId}/order/{id}/funding_instructions/refresh`](/v2/api-reference/orders/refresh-funding-instructions). It returns the order as it is. The new funding instructions arrive a moment later in an `orderUpdated` webhook, and on [`GET .../order/{id}`](/v2/api-reference/orders/get-order).
Every refresh builds a new transaction. Until the old one's blockhash expires, both can land, so have the customer sign and submit only the newest one.
## Swaps
A swap on Solana is an order between two Solana wallet accounts. Track it like any other order: read [`GET /organization/{organizationId}/order/{id}`](/v2/api-reference/orders/get-order), or subscribe to [`orderUpdated`](/v2/api-reference/webhook-events/orderupdated). The status moves `created` → `funded` → `completed`. See [Swaps](/v2/guides/testing-swaps).
## Related
# Stellar
Source: https://docs.etherfuse.com/v2/guides/chains/stellar
Asset identifiers, trustlines, XLM gas, claimable-balance onramps, and transaction expiry on Stellar.
We support onramps, offramps and swaps on Stellar. Stellar needs more chain-specific setup than any other supported network: assets require **trustlines**, wallets need a small **XLM** balance for reserves and fees, and a prepared transaction goes stale when the wallet's **sequence number** moves. This page covers the Stellar-specific parts. The end-to-end flows are in the [Onramps](/v2/guides/testing-onramps), [Offramps](/v2/guides/testing-offramps) and [Swaps](/v2/guides/testing-swaps) guides.
## Supported operations
| Operation | Supported |
| - | - |
| Onramp (fiat → token) | ✓ |
| Offramp (token → fiat) | ✓ |
| Swap (token → token) | ✓ |
## Asset identifiers
Stellar assets use the **`CODE:ISSUER`** format, for example `CETES:GC3CW7EDYRTWQ635VDIGY6S4ZUF5L6TQ7AA4MWS7LEQDBLUSZXV7UPS4`.
**Always read identifiers from [`GET /supported_assets`](/v2/api-reference/assets/list-supported-assets). Never hardcode the issuer.** The issuer account differs between **sandbox and production** and can change. A hardcoded issuer is the most common cause of "wrong asset" problems. Pass `chain=stellar` and use the token's `asset` as-is for `sourceAsset` or `destinationAsset` in your quote.
**Getting testnet USDC in sandbox.** We have no faucet for sandbox USDC. To get some, **onramp CETES, then swap CETES → USDC**. You need a trustline for any asset you receive; see below.
## Stablebond addresses
Live asset issuers for every stablebond on Stellar, each linked to Stellar Expert:
## Wallet and gas requirements
Holding and receiving Stellar assets requires XLM for the account reserve and for each trustline. These are **Stellar network requirements, not Etherfuse fees**:
* **\~1 XLM** base reserve to create the account on-chain.
* **\~0.5 XLM** additional reserve per trustline (so \~1.5 XLM for an account holding one asset).
* A few **stroops** per transaction for the network fee. About 0.01 XLM on a wallet covers thousands of transactions.
**Self-custody wallets hold their own XLM.** A bring-your-own Stellar wallet pays its own reserves and network fees. The exception is an **onramp**: when the destination wallet has no account or no trustline, we price the account and trustline setup into the quote fee and deliver a claimable balance (see [Onramps: wallets without a trustline](#onramps-wallets-without-a-trustline) below).
**Embedded wallets** work differently: the key holder signs an order `authorization` instead of signing XDR, and we build every transaction. See [Embedded Wallets](/v2/guides/embedded-wallets).
### Trustlines
A wallet **must hold a trustline for any asset it receives.** For onramps, the claim transaction adds the trustline (see below). For **offramps and swaps**, the wallet must already have the trustline and enough XLM before you create the order.
## Register a Stellar wallet
Register the wallet as a payment account with `accountDetails` of kind `address` and `chain` `stellar`. Set `$ORGANIZATION_ID` to the organization that owns the wallet: your own organization or a customer organization. See [Organizations in the route](/v2/overview#organizations-in-the-route).
```bash theme={null}
curl -X POST https://api.sand.etherfuse.com/organization/$ORGANIZATION_ID/payment_account \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{
"kind": "wallet",
"label": "Stellar wallet",
"accountDetails": {
"kind": "address",
"address": "GDUKMGUGD3V6VXTU2RLAUM7A2FABLMHCPWTMDHKP7HHJ6FCZKEY4PVWL",
"chain": "stellar"
}
}'
```
Use the returned `id` as the `source` or `destination` of a [quote](/v2/api-reference/quotes/create-quote). A mis-copied `G...` address fails its checksum and returns `400` `invalid-request`.
## Onramps: wallets without a trustline
You do not need to set up the wallet before an onramp. The wallet is the quote's destination, so we check it on-chain when you quote. If it has no account or no trustline for the asset, the quote fee includes a one-time cost for the XLM, and we deliver the asset as a **claimable balance**.
The order then moves to `claimable`, and `claimInstructions` describes the balance:
```json theme={null}
{
"status": "claimable",
"claimInstructions": {
"kind": "stellarClaimableBalance",
"balanceId": "00000000abc123...",
"asset": "CETES:GC3CW7EDYRTWQ635VDIGY6S4ZUF5L6TQ7AA4MWS7LEQDBLUSZXV7UPS4",
"amount": "100.0000000",
"claimant": "GDUKMGUGD3V6VXTU2RLAUM7A2FABLMHCPWTMDHKP7HHJ6FCZKEY4PVWL",
"expiresAt": null
}
}
```
To claim, call [POST /organization/\{organizationId}/order/\{id}/claim](/v2/api-reference/orders/prepare-claim) with no body:
* A self-held wallet gets `kind` `stellarTransaction` with an `xdr`. The transaction adds the trustline and claims the balance in one step. The customer signs and submits it.
* An embedded wallet gets `kind` `authorization` with an `authorizationId`. The key holder signs the order's `authorization` and you submit it. See [Order authorization](/v2/guides/embedded-wallets#order-authorization).
This applies to **onramps only**. Offramps and swaps need the wallet to exist and be funded first. See [Onramps: Stellar wallets without a trustline](/v2/guides/testing-onramps#stellar-wallets-without-a-trustline) for the full flow.
## Offramps and swaps: fund the wallet first
**The customer's wallet sends the funds.** For offramps and swaps from a self-held wallet, the wallet must exist on-chain, hold enough XLM for the network fee, and hold a trustline for the asset it sends. For a swap, it also needs a trustline for the asset it receives.
Before creating the order, confirm the wallet exists on-chain, holds at least about 1.5 XLM, and has a trustline for the asset being sold. Use [`GET /supported_assets?chain=stellar`](/v2/api-reference/assets/list-supported-assets) to get the issuer for the trustline.
When the source is an [embedded wallet](/v2/guides/embedded-wallets), `fundingInstructions.destination` has no `address`. It carries an `authorizationId` instead, and the key holder approves the order's [authorization](/v2/guides/embedded-wallets#order-authorization) to fund the order.
For a self-held wallet on a Stellar offramp or swap, `fundingInstructions.destination` gives the `address` to pay and a `memo` when one is needed. Fund the order in one of two ways:
1. **Sign the prepared transaction.** When `preparedTransaction` is present, sign `transaction` (an unsigned base64 transaction envelope XDR) and submit it to Horizon. The customer's account is the transaction's source and pays the fee.
2. **Build the payment yourself.** Send a payment of the order's `source.amount` of `source.asset` to `address`, with `memo` when it is present. Wallets that support [SEP-24](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0024.md)-style withdrawals can use this path.
## Stale transactions and refresh
A prepared Stellar transaction stops being valid at the order's funding deadline, so `refreshAfter` is the same time as `fundingDeadline`. It also uses the wallet's sequence number at the time we built it, so it goes stale when the wallet sends another transaction first. Horizon then returns `tx_bad_seq`. Get a new one instead of retrying the stale one:
* **Offramp or swap:** call [`POST /organization/{organizationId}/order/{id}/funding_instructions/refresh`](/v2/api-reference/orders/refresh-funding-instructions). You can call it at any time while the order is `created`. It returns the order as it is. If the old transaction already landed, nothing changes and no webhook arrives, so the customer can't fund the order twice. The new funding instructions arrive a moment later in an `orderUpdated` webhook, and on [`GET .../order/{id}`](/v2/api-reference/orders/get-order).
* **Onramp claim:** the claim transaction uses the wallet's current sequence number, so another transaction from the wallet makes it stale. We build a new claim transaction on every call to [`POST /organization/{organizationId}/order/{id}/claim`](/v2/api-reference/orders/prepare-claim), so call it right before the customer signs.
Get the transaction right before the customer signs it. If Horizon returns `tx_bad_seq`, refresh and have the customer sign the new one.
## Related
# Embedded Wallets
Source: https://docs.etherfuse.com/v2/guides/embedded-wallets
Move on-chain assets for your users through non-custodial embedded wallets, without anyone touching gas, trustlines, or transaction construction.
An embedded wallet lets you and your users hold and move on-chain assets without learning the chain underneath. Nobody constructs a transaction, manages a sequence number, holds gas, or opens a trustline. We build every transaction. The network gas and account reserves are part of the quote fee: they come out of the assets you ramp and show up as fee lines in the quote, so the wallet never holds a balance for fees.
**Integrate server-side.** Run registration, quoting and ordering from your backend with your API key. The signature that approves an order comes from whoever holds the signer key. For a copy-paste sandbox script, see the [sandbox onramp](/v2/guides/embedded-wallets-walkthrough).
An embedded wallet is non-custodial. Each wallet has a single signer key, a P-256 key. When an order needs to move the wallet's funds, we put an **authorization** on the order. The key holder signs it, and only then do we move the funds. We can never sign or move funds on our own, and we can refuse to relay a transaction.
Embedded wallets run on **Stellar**, so the wallet's chain is `stellar`. All endpoints use the raw API key in the `Authorization` header (no `Bearer` prefix); see [Authentication](/v2/authentication). You do not need Stellar's rules (trustlines, reserves, transaction expiry) to use an embedded wallet; we handle them for you.
## Who holds the key
You, the integrating partner, choose who holds the signer key:
* You hold the signer key yourself, on behalf of your program; or
* Your end customer holds and controls the signer key, so only the customer can approve an order and you never hold or see the customer's private key.
Control of the funds follows control of the key. A party that cannot move funds without the keyholder does not control them, so only the holder of the signer key can approve moving the wallet's assets.
Whether holding the key makes you a custodian or money transmitter depends on your own facts, activities, and jurisdiction. This is not legal advice; determine your own obligations with your legal and compliance advisors.
## Register the wallet
We create the embedded wallet when you register it. First, the key holder generates a P-256 key pair and keeps the private key. Then you register the public key with [`POST /organization/{organizationId}/payment_account`](/v2/api-reference/payment-accounts/register-payment-account). Use the id of the organization that owns the wallet: your own organization's id from `GET /me`, or the customer organization's id. The examples below set it as `$ORGANIZATION_ID`, and the quote and order use the same id. See [Organizations in the route](/v2/overview#organizations-in-the-route).
Send `kind` `embeddedWallet`, and `accountDetails` of kind `signerPublicKey` with the `chain` and the `signerPublicKey`. `signerPublicKey` is the hex-encoded P-256 public key in SEC1 form, compressed (33 bytes) or uncompressed (65 bytes). The chain is `stellar` today.
```bash theme={null}
curl -X POST https://api.sand.etherfuse.com/organization/$ORGANIZATION_ID/payment_account \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{
"kind": "embeddedWallet",
"label": "Customer wallet",
"accountDetails": {
"kind": "signerPublicKey",
"chain": "stellar",
"signerPublicKey": "04a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90b1c2d3e4f5061728394a5b6c7d8e9f0a1b2c3d4e5f60718293a4b5c6d7e8f901"
}
}'
```
The response is the payment account. Keep its `id`: you pass it to every quote that moves money into or out of the wallet.
```json theme={null}
{
"id": "8c1d2e3f-4a5b-4c6d-8e7f-901a2b3c4d5e",
"status": "ready",
"label": "Customer wallet",
"rails": {
"stellar": {
"source": { "isDefault": true, "status": "ready" },
"destination": { "isDefault": true, "status": "ready" }
}
},
"createdAt": "2026-09-28T17:04:11.482Z"
}
```
To find a wallet you registered earlier, list the organization's accounts with [`GET /organization/{organizationId}/payment_accounts`](/v2/api-reference/payment-accounts/list-payment-accounts).
## Quoting and ordering
You quote and order an embedded wallet like any other wallet. Pass the wallet's payment account `id` as `destination` on an onramp [quote](/v2/api-reference/quotes/create-quote), or as `source` on an offramp quote. The other side is the bank account's payment account. Both accounts must belong to the organization in the route.
```json theme={null}
{
"source": { "type": "stored", "id": "3f9a2c1e-5b7d-4e8f-9a01-2b3c4d5e6f70" },
"destination": { "type": "stored", "id": "8c1d2e3f-4a5b-4c6d-8e7f-901a2b3c4d5e" },
"sourceAmount": "100",
"sourceAsset": "MXN",
"destinationAsset": "CETES:GC3CW7EDYRTWQ635VDIGY6S4ZUF5L6TQ7AA4MWS7LEQDBLUSZXV7UPS4"
}
```
Take `destinationAsset` from a token's `asset` in [`GET /supported_assets?chain=stellar`](/v2/api-reference/assets/list-supported-assets). The quote prices the account reserve and trustline a new wallet needs into its fee lines, so a new wallet gets its first funds from its first onramp and you never send XLM.
Create the order with [`POST /organization/{organizationId}/order`](/v2/api-reference/orders/create-order) and the `quoteId`. The order's `id` is the `quoteId`. For the rest of each direction, see [Onramps](/v2/guides/testing-onramps) and [Offramps](/v2/guides/testing-offramps).
## Order authorization
When an order needs the key holder to approve moving the wallet's funds, the order's `authorization` field holds the request. Otherwise `authorization` is `null`. Read it from [`GET /organization/{organizationId}/order/{id}`](/v2/api-reference/orders/get-order) or from the `orderUpdated` webhook.
```json theme={null}
{
"id": "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b",
"status": "created",
"authorization": {
"id": "5fac3ce6-b071-4265-8e93-6fcf81a24d55",
"reason": "funding",
"method": {
"kind": "signature",
"algorithm": "es256",
"message": ""
},
"expiresAt": "2026-09-28T17:09:11.482Z"
}
}
```
`reason` tells you why we need the approval:
| `reason` | When |
| - | - |
| `funding` | An offramp or swap whose source is an embedded wallet. We move the funds out of the wallet once the key holder approves. |
| `claim` | An onramp into an embedded wallet that has become `claimable`. See [Claiming funds](#claiming-funds). |
```mermaid theme={null}
sequenceDiagram
participant App as Your app
participant Key as Key holder's device
participant EF as Etherfuse
EF-->>App: orderUpdated with authorization
App->>EF: GET /organization/{organizationId}/order/{id}
EF-->>App: authorization id and method.message
App->>Key: Sign method.message
Key-->>App: P-256 signature, hex
App->>EF: POST .../order/{id}/authorization with id and signature
EF-->>App: The order
EF->>EF: Move the funds
Note over App,EF: If the authorization expires first, we replace it
with a new id and message and send orderUpdated.
Read the order and sign the new one.
```
The order status does not change while the authorization waits. The order stays `created` (for `funding`) or `claimable` (for `claim`). We send `orderUpdated` when `authorization` appears or changes. An order has at most one pending authorization at a time.
An offramp or swap funded from an embedded wallet has no address to send to. Its `fundingInstructions.destination` carries the authorization id:
```json theme={null}
{
"fundingInstructions": {
"destination": {
"expiresAt": null,
"authorizationId": "5fac3ce6-b071-4265-8e93-6fcf81a24d55"
}
}
}
```
Approve that authorization to fund the order.
### Sign the message
The key holder signs `method.message` with the signer key:
* `es256`: ECDSA over P-256 with SHA-256.
* Sign the UTF-8 bytes of `method.message` exactly as we return it.
* Hex-encode the signature. We accept DER, or the 64-byte `r||s` form (128 hex characters) that WebCrypto produces.
In Node.js or a browser, WebCrypto does this directly:
```javascript theme={null}
async function signAuthorization(privateKey, authorization) {
const signature = await crypto.subtle.sign(
{ name: "ECDSA", hash: "SHA-256" },
privateKey,
new TextEncoder().encode(authorization.method.message),
);
return Buffer.from(signature).toString("hex");
}
```
### Submit the approval
Send the authorization `id` and the `signature` to [`POST /organization/{organizationId}/order/{id}/authorization`](/v2/api-reference/orders/submit-authorization):
```bash theme={null}
curl -X POST https://api.sand.etherfuse.com/organization/$ORGANIZATION_ID/order/$ORDER_ID/authorization \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{
"id": "5fac3ce6-b071-4265-8e93-6fcf81a24d55",
"signature": ""
}'
```
The response is the order. We move the funds, and the order continues through its normal statuses.
### Errors and expiry
| Status | `type` slug | What to do |
| - | - | - |
| `409` | `authorization-expired` | The `id` is not the order's current authorization, or it has expired. Read the order and sign the new authorization. |
| `400` | `invalid-signature` | The signature does not verify against the wallet's `signerPublicKey` and `method.message`. Check the key, the message bytes and the encoding, then sign again. |
| `409` | `authorization-not-required` | The order has no pending authorization. Read the order for its current state. |
Each authorization expires at `expiresAt`. When one expires, we replace it with a new authorization that has a new `id` and a new `message`, and we send `orderUpdated`. Always sign the authorization you just read, and never reuse an old signature. See [Errors](/v2/errors) for the error body format.
## Claiming funds
The first time an embedded wallet receives an asset, it has no trustline for it. The asset arrives as a Stellar claimable balance, and the order moves to `claimable`. This comes from how Stellar works, not from Etherfuse. The order's `claimInstructions` describes the balance:
```json theme={null}
{
"status": "claimable",
"claimInstructions": {
"kind": "stellarClaimableBalance",
"balanceId": "00000000da0d57da7d4850e7fc10d2a9d0ebc731f7afb40574c03395b17d49149b91f5be",
"asset": "CETES:GC3CW7EDYRTWQ635VDIGY6S4ZUF5L6TQ7AA4MWS7LEQDBLUSZXV7UPS4",
"amount": "99.61",
"claimant": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
"expiresAt": null
}
}
```
To accept the funds, call [`POST /organization/{organizationId}/order/{id}/claim`](/v2/api-reference/orders/prepare-claim) with no body. For an embedded wallet we return the id of the order's `claim` authorization:
```json theme={null}
{
"kind": "authorization",
"authorizationId": "5fac3ce6-b071-4265-8e93-6fcf81a24d55"
}
```
Read the order, then [sign](#sign-the-message) and [submit](#submit-the-approval) the authorization with that id. Once the key holder approves, we open the trustline and claim the balance in one transaction, and the order moves to `completed`. A self-held wallet gets `kind` `stellarTransaction` with an `xdr` to sign and submit instead.
**Sign the claim authorization soon after you read it.** It expires at `expiresAt`. If it expires, we replace it with a new one with a new `id`, so read the order again and sign the new `message`.
## Tracking the order
Subscribe to [`orderUpdated`](/v2/api-reference/webhook-events/orderupdated) with [`POST /webhook_subscription`](/v2/api-reference/webhook-subscriptions/create-webhook-subscription). Each delivery carries the full order, so you see a new or replaced `authorization`, the move to `claimable`, and then the move to `completed` without polling. [`GET /organization/{organizationId}/order/{id}`](/v2/api-reference/orders/get-order) is the fallback. See [Webhooks](/v2/webhooks).
## Related
Minimal server-side onramp script for sandbox
# Embedded Wallets Sandbox Onramp
Source: https://docs.etherfuse.com/v2/guides/embedded-wallets-walkthrough
A minimal server-side script that runs an embedded-wallet onramp in sandbox, from wallet registration to the signed claim authorization.
This page is optional developer tooling. Read [Embedded Wallets](/v2/guides/embedded-wallets) first for the concepts and the detail of each step.
Embedded wallets run on **Stellar**. Quoting and ordering follow the standard procedure, so this page links to [Onramps](/v2/guides/testing-onramps) and [Offramps](/v2/guides/testing-offramps) for the details.
**Sandbox API keys only.** Run this script server-side. Never put a production API key in client-side code.
## Minimal sandbox onramp
The script generates a P-256 signer key, registers the embedded wallet, quotes MXN into a Stellar asset, creates the order, simulates the fiat deposit, and waits for the order. When the order becomes `claimable`, it builds the claim transaction, signs the order's `authorization`, submits the approval, and waits for `completed`.
The script generates the signer key in memory so it runs on its own. In a real integration, the key holder generates the key once and keeps the private key. Only the public key reaches us.
The script needs Node.js 20 or later for the global `fetch` and WebCrypto. Set these environment variables:
| Variable | Value |
| - | - |
| `ETHERFUSE_API_KEY` | Your sandbox API key. |
| `ORGANIZATION_ID` | The organization the script acts for: your own organization's id from `GET /me`, or a customer organization's id. See [Organizations in the route](/v2/overview#organizations-in-the-route). |
| `BANK_ACCOUNT_ID` | The `id` of a bank payment account on `spei` whose `source` direction is `ready`, registered under `ORGANIZATION_ID`. See [Register payment accounts](/v2/overview#the-flow). |
| `TARGET_ASSET` | A Stellar token's `asset` from [`GET /supported_assets?chain=stellar`](/v2/api-reference/assets/list-supported-assets). |
```javascript theme={null}
const sleep = (s) => new Promise((r) => setTimeout(r, s * 1e3));
const api = async (method, path, body) => {
const r = await fetch(`https://api.sand.etherfuse.com${path}`, {
method,
headers: { Authorization: process.env.ETHERFUSE_API_KEY, "Content-Type": "application/json" },
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
const text = await r.text();
return text ? JSON.parse(text) : {};
};
const org = `/organization/${process.env.ORGANIZATION_ID}`;
const hex = (buf) => Buffer.from(buf).toString("hex");
async function approve(orderId, authorization, privateKey) {
const signature = await crypto.subtle.sign(
{ name: "ECDSA", hash: "SHA-256" },
privateKey,
new TextEncoder().encode(authorization.method.message),
);
// POST /organization/{organizationId}/order/{id}/authorization
await api("POST", `${org}/order/${orderId}/authorization`, {
id: authorization.id,
signature: hex(signature),
});
}
async function embeddedOnramp() {
const { publicKey, privateKey } = await crypto.subtle.generateKey(
{ name: "ECDSA", namedCurve: "P-256" },
true,
["sign", "verify"],
);
const signerPublicKey = hex(await crypto.subtle.exportKey("raw", publicKey));
// POST /organization/{organizationId}/payment_account: we create the embedded wallet
const wallet = await api("POST", `${org}/payment_account`, {
kind: "embeddedWallet",
label: "Embedded wallet",
accountDetails: { kind: "signerPublicKey", chain: "stellar", signerPublicKey },
});
console.log("wallet account", wallet.id);
// POST /organization/{organizationId}/quote: MXN from the bank account into the wallet
const quote = await api("POST", `${org}/quote`, {
source: { type: "stored", id: process.env.BANK_ACCOUNT_ID },
destination: { type: "stored", id: wallet.id },
sourceAmount: "100",
sourceAsset: "MXN",
destinationAsset: process.env.TARGET_ASSET,
});
// POST /organization/{organizationId}/order: the order id is the quoteId
const order = await api("POST", `${org}/order`, { quoteId: quote.quoteId });
// POST /organization/{organizationId}/order/{id}/fiat_received: sandbox only
await api("POST", `${org}/order/${order.id}/fiat_received`, { amount: quote.sourceAmount });
// GET /organization/{organizationId}/order/{id}: in production, subscribe to orderUpdated instead of polling
let approvedId = null;
while (true) {
const current = await api("GET", `${org}/order/${order.id}`);
if (current.status === "completed") {
console.log("completed", current.destination);
return;
}
if (["failed", "canceled", "refunded"].includes(current.status)) {
throw new Error(`order ${current.status}: ${JSON.stringify(current.failure ?? {})}`);
}
if (current.status === "claimable" && current.authorization?.id !== approvedId) {
// POST /organization/{organizationId}/order/{id}/claim: no body
const claim = await api("POST", `${org}/order/${order.id}/claim`);
const { authorization } = await api("GET", `${org}/order/${order.id}`);
if (authorization?.id === claim.authorizationId) {
try {
await approve(order.id, authorization, privateKey);
approvedId = authorization.id;
} catch (e) {
// 409 authorization-expired: we replaced it, so the next pass signs the new one
if (!String(e.message).includes("authorization-expired")) throw e;
}
}
}
await sleep(15);
}
}
embeddedOnramp().catch(console.error);
```
Each commented `api()` call links to the API reference:
| Call | Reference |
| - | - |
| `POST /organization/{organizationId}/payment_account` | [Register payment account](/v2/api-reference/payment-accounts/register-payment-account): we create the wallet |
| `POST /organization/{organizationId}/quote` | [Create quote](/v2/api-reference/quotes/create-quote): the quote expires 2 minutes after it is created |
| `POST /organization/{organizationId}/order` | [Create order](/v2/api-reference/orders/create-order): sending the same `quoteId` again returns the same order |
| `POST /organization/{organizationId}/order/{id}/fiat_received` | [Simulate fiat received](/v2/api-reference/sandbox/simulate-fiat-received): **sandbox only**; in production the customer pays the SPEI `fundingInstructions` |
| `GET /organization/{organizationId}/order/{id}` | [Get order](/v2/api-reference/orders/get-order) |
| `POST /organization/{organizationId}/order/{id}/claim` | [Prepare claim](/v2/api-reference/orders/prepare-claim): an embedded wallet gets `kind` `authorization` with an `authorizationId` |
| `POST /organization/{organizationId}/order/{id}/authorization` | [Submit authorization](/v2/api-reference/orders/submit-authorization): returns `409` `authorization-expired` when the authorization was replaced; read the order and sign the new one |
The order becomes `claimable` only when the wallet has no trustline for the asset yet, which is the case for a wallet's first onramp of that asset. Later onramps of the same asset go straight to `completed`.
## Related
# JWT User Authentication
Source: https://docs.etherfuse.com/v2/guides/jwt-authentication
Authenticate your users to Etherfuse with short-lived JWTs you sign, and launch them straight into the app.
Your users authenticate to Etherfuse with JSON Web Tokens that you sign. You sign a short-lived JWT for a user, and we verify it against a JWKS URL you've registered with us. It's an ordinary OAuth 2.0 JWT bearer exchange ([RFC 7523](https://www.rfc-editor.org/rfc/rfc7523)), so any JWT library works.
The simplest and recommended way to use a JWT is to **launch the user straight into the Etherfuse app**, where they complete flows like KYB. You can also [exchange a JWT server-side](/v2/api-reference/authentication/exchange-a-partner-jwt) to provision a user ahead of time or prefetch a refresh token that speeds up launch.
## Before you start
Create an RSA key pair for `RS256`, the signing algorithm we verify. You sign JWTs with the **private** key and publish the **public** key (next step).
```bash theme={null}
# RSA 2048
openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -pubout -out public.pem
```
Publish the **public** key as a JSON Web Key Set ([RFC 7517](https://www.rfc-editor.org/rfc/rfc7517)) at a stable HTTPS URL, e.g. `https://your-domain.com/.well-known/jwks.json`. Give the key a stable id (`kid`) so we can match it to a token's `kid` header. Most JWT libraries can export a public key to JWK form:
```json Key set theme={null}
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": "your-key-id",
"n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx...",
"e": "AQAB"
}
]
}
```
```javascript Node (jose) theme={null}
import express from "express";
import { exportJWK } from "jose";
import { createPublicKey } from "crypto";
const jwk = await exportJWK(createPublicKey(publicPem));
const jwks = { keys: [{ ...jwk, kid: "your-key-id", use: "sig", alg: "RS256" }] };
express()
.get("/.well-known/jwks.json", (_req, res) => res.json(jwks))
.listen(3000);
```
```python Python (PyJWT) theme={null}
import json
from flask import Flask, jsonify
from jwt.algorithms import RSAAlgorithm
jwk = json.loads(RSAAlgorithm.to_jwk(public_key))
jwk.update({"kid": "your-key-id", "use": "sig", "alg": "RS256"})
jwks = {"keys": [jwk]}
app = Flask(__name__)
@app.get("/.well-known/jwks.json")
def jwks_endpoint():
return jsonify(jwks)
```
The URL must be reachable from Etherfuse's servers over HTTPS and return the key set as JSON. We fetch it fresh on every verification (no caching), so **rotating keys** is straightforward: add the new key as another entry in `keys`, start signing with its `kid`, then remove the old key once no unexpired tokens still reference it.
Send your Etherfuse representative your **issuer** (`iss`) and **JWKS URL**. We link them to your partner organization so JWTs from that issuer resolve to your customers. Register separately for **sandbox** and **production**: each environment keeps its own issuer registration and uses its own `aud` (the sandbox vs production token endpoint). (There is no self-serve endpoint for this *yet*.)
## Sign a user JWT
Sign a JWT with the key behind your JWKS. The claims:
| Claim | Required | Notes |
| - | - | - |
| `iss` | ✓ | Your registered issuer. |
| `sub` | ✓ | The user's id. A UUID is *strongly recommended*; see the note below. |
| `aud` | ✓ | The token endpoint URL: `https://api.etherfuse.com/auth/token` (or `https://api.sand.etherfuse.com/auth/token` in sandbox). |
| `scope` | ✓ | Required. Names the flow the user may complete. See [Scopes](/v2/guides/user-launch-flows#scopes). |
| `jti` / `nonce` | ✓ | A fresh value, unique per token, for replay protection. You can send it as `jti` (the standard JWT ID) or `nonce` (the standard OIDC claim). |
| `email`, `name` | ✓ | The user's email and full name. |
| `exp`, `iat` | ✓ | Keep tokens short-lived (about 5 minutes). There is no enforced maximum lifetime, but verification allows no clock skew, so keep your server clock in sync. |
| `picture` | optional | Profile image URL. |
**Strongly recommended: make `sub` a UUID.** `sub` identifies the person signing in. For an individual customer, that person is the customer: use the same UUID for `sub` and for their organization's `id`, so both resolve to one customer. For a business, each person's `sub` is their `memberId` in the [member endpoints](/v2/api-reference/members/add-organization-member). A non-UUID `sub` still signs in, but can't be addressed through the API.
**A `sub` must name a person, never a business.** Don't put a business organization's id in `sub`: it's rejected with `invalid_grant`. And don't reuse a person's `sub` as a business org id: [`POST /organization`](/v2/api-reference/organizations/create-organization) returns `409`. A business is a separate org with its own id; a real person signs in with their own personal `sub` and completes the business's verification on its behalf.
```javascript Node theme={null}
import jwt from "jsonwebtoken";
import { randomUUID } from "crypto";
// Reuse one UUID per person: their organization id, or their memberId in a business.
const sub = randomUUID();
const token = jwt.sign(
{
scope: "kyb",
jti: randomUUID(),
email: "ana@example.com",
name: "Ana Ejemplo",
},
PRIVATE_KEY, // the key behind your JWKS
{
algorithm: "RS256",
keyid: "your-key-id", // matches a kid in your JWKS
issuer: "https://your-domain.com",
subject: sub,
audience: "https://api.sand.etherfuse.com/auth/token",
expiresIn: "5m",
},
);
```
```python Python theme={null}
import uuid, time, jwt # PyJWT
sub = str(uuid.uuid4()) # their organization id, or their memberId in a business
token = jwt.encode(
{
"iss": "https://your-domain.com",
"sub": sub,
"aud": "https://api.sand.etherfuse.com/auth/token",
"scope": "kyb",
"jti": str(uuid.uuid4()),
"email": "ana@example.com",
"name": "Ana Ejemplo",
"iat": int(time.time()),
"exp": int(time.time()) + 300,
},
PRIVATE_KEY, # the key behind your JWKS
algorithm="RS256",
headers={"kid": "your-key-id"},
)
```
## Launch the user into the app
This is the recommended path. You hand the user's browser to Etherfuse with their JWT, and the app signs them in and takes them where you point them (for example, [KYB](/v2/guides/kyb)).
**Launch lives on the app host**, not the API host: `https://app.etherfuse.com` (`https://sandbox.etherfuse.com` in sandbox). It's a browser page, not a JSON API.
Every launch carries:
* **`grant_type`** and **`assertion`**: either a raw partner JWT (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`) or a `refresh_token` from a prior [server-side exchange](/v2/api-reference/authentication/exchange-a-partner-jwt) (`grant_type=refresh_token`).
* **`target`**: the app path to land on; see [Targets](/v2/guides/user-launch-flows#targets) for the available paths.
* **`return_url`** *(optional)*: where to send the user when they leave or the session can't resume. It is not the success redirect; on success the user lands on `target`.
Have the user's browser POST a form to `/auth/launch`, either as a top-level navigation or into an iframe you've embedded (point the form's `target` at the frame). The app exchanges the JWT, establishes the session, and redirects to `target`. A server-rendered auto-submitting form is the usual way:
```html theme={null}
```
**Rather not POST a form?** Hand the credentials to the launch page over `postMessage` instead: open it in an iframe or popup and message the JWT in. See [Launch a user via postMessage](/v2/api-reference/authentication/launch-a-user-via-postmessage) for the message contract and a working example.
**Embedding.** You can embed the launch page in an iframe or popup on your own site; there's no origin to register with us. With `postMessage`, trust is established by the handshake itself: the page only accepts a reply from the parent or opener that requested it. Flows that use the camera, such as [`/idv`](/v2/guides/user-launch-flows#identity-verification), additionally need `allow="camera *; microphone *"` on the iframe element (the wildcard origin is required because the scan SDK uses the camera from a nested frame; a bare `allow="camera"` only covers the iframe's own origin).
## Scopes and targets
Which [`scope`](/v2/guides/user-launch-flows#scopes) to sign and which [`target`](/v2/guides/user-launch-flows#targets) to launch into depends on the flow you're sending the user into. Every available flow (and what to know about each) is listed in **[User Launch Flows](/v2/guides/user-launch-flows#flows)**.
## When something fails
Auth failures come back as OAuth errors with an `error`, an `error_description`, and an `error_uri` pointing at the matching entry in [Authentication errors](/v2/guides/authentication-errors).
A launch shows these errors on the page and does not return them. If you want to read them programmatically, [exchange the JWT server-side](/v2/api-reference/authentication/exchange-a-partner-jwt) first: `POST /auth/token` returns the same error as JSON, so you can branch on `error` before ever handing the user to a launch.
# KYB Overview
Source: https://docs.etherfuse.com/v2/guides/kyb
What Etherfuse collects to verify a business, how to track where its verification stands, and who has to take part.
Every business must clear **Know Your Business** verification before it can transact or hold API keys. It is completed in the app, through the [KYB WebSDK](/v2/guides/kyb-websdk) launch.
## What verification covers
| Step | What the business provides |
| - | - |
| Company details | Legal name, country, registration number, incorporation date, legal address, tax id, and legal form. |
| Registry documents | Certificate of incorporation, articles of incorporation, certificate of good standing, ownership structure chart, share registry, directors registry or certificate of incumbency, power of attorney for its representatives, and proof of address. |
| Incorporation and compliance detail | The deed and notary record behind the incorporation, any later amendment to the bylaws, tax registration, recent financial statements, and any license, AML policy, or compliance officer the business is required to hold. |
| Corporate information letter | Its business model, website, the jurisdictions it operates in, its economic activity, and a transactional profile: expected annual amount, number and type of operations, channels and assets used, and counterparties. |
| Source of funds | Where the funds the business will use originate, with bank or financial statements behind the declaration. |
| Agreements | Electronic signature, the terms and conditions, and the customer agreement. |
| Owners and representatives | The people it names as its beneficial owners, each with the size of their stake, and as its legal representatives. Each of them completes an [identity check](/v2/guides/onboarding#requirements-list) of their own. |
| Parent companies | A company that owns part of the business is added as a parent company, and supplies its country, legal name, registration number, and the size of its stake, plus its certificate of incorporation, certificate of good standing, ownership structure chart, directors registry or certificate of incumbency, and share registry. Parent companies can have their own parent companies. |
A business is approved once it, its owners, its representatives, and its parent companies have all been reviewed and approved.
Treat the list above as information, not a contract. Etherfuse tunes what is required by jurisdiction and risk, and adding a requirement changes what your customer is asked for, not your integration.
## Tracking verification status
### The overall status
```bash Sandbox theme={null}
curl -H "Authorization: " \
https://api.sand.etherfuse.com/organization//kyb
```
```bash Production theme={null}
curl -H "Authorization: " \
https://api.etherfuse.com/organization//kyb
```
```json theme={null}
{
"organizationId": "00000000-0000-4000-8000-000000000001",
"status": "submitted",
"approvedAt": null
}
```
| Status | Description |
| - | - |
| `notStarted` | No verification started |
| `inProgress` | Started, but the business still has something to submit, including a note Etherfuse has asked it to answer |
| `submitted` | Everything the business can do is done; Etherfuse's turn, including a note the business is waiting on an answer to |
| `approved` | Cleared; the business can transact |
| `denied` | A rejected verdict, or a previously-approved business whose approval was revoked |
Full response codes are on the [`GET /organization/{organizationId}/kyb`](/v2/api-reference/kyb/get-kyb-status) reference page.
### Watching for a change
Subscribe to [`kybUpdated`](/v2/api-reference/webhook-events/kybupdated) with [`POST /webhook_subscription`](/v2/api-reference/webhook-subscriptions/create-webhook-subscription). We send it whenever the business's KYB status changes, with the same `status` values. When it arrives, read `GET /organization/{organizationId}/kyb` to reconcile. A business that Etherfuse asks to send something again moves back to `inProgress`.
The `organizationId` identifies the **business**. A **person** is identified separately by their `memberId`, the `sub` you sign into their JWT. The two are never interchangeable.
When a customer created their own business and you only hold their `sub`, [`GET /member/{memberId}/organizations`](/v2/api-reference/members/list-member-organizations) lists the businesses they belong to, so you can find its `organizationId`.
# KYB WebSDK
Source: https://docs.etherfuse.com/v2/guides/kyb-websdk
The hosted verification flow: launch a business's people into Etherfuse, and they complete every step in the widget.
You launch a person into the business's verification; they and the rest of the business's owners and representatives do everything else in the widget at `/kyb`.
## The flow
Skip this and your customer creates the business themselves on first launch, and the app asks them for a company name, legal name, and country. Pre-create it when you want the organization and its members in place before anyone launches.
Create the business with [`POST /organization`](/v2/api-reference/organizations/create-organization) and `accountType: business`. You may pass your own `id` (a UUID) to match an identifier in your own system; omit it and Etherfuse assigns one and returns it as `id`. Pass `country` to generate the KYB onboarding at creation so the user lands straight in verification, and with it `legalName`, the incorporated name on the company's registry documents. `displayName` stays a friendly label.
```json Request theme={null}
{
"displayName": "Acme Inc.",
"legalName": "Acme Corporation S.A. de C.V.",
"accountType": "business",
"country": "MX"
}
```
```json Response theme={null}
{
"id": "00000000-0000-4000-8000-000000000001",
"displayName": "Acme Inc.",
"accountType": "business",
"approvedAt": null,
"createdAt": "2026-10-07T12:00:00Z",
"updatedAt": "2026-10-07T12:00:00Z"
}
```
Now add the people who will complete KYB with [`POST /organization/00000000-0000-4000-8000-000000000001/member`](/v2/api-reference/members/add-organization-member), two ways:
* **By `email`**: the invite is accepted automatically the first time that email launches via your `kyb` JWT. Pass an optional `emailInviteLink` to send them an invite pointing at your own page.
* **By `memberId`**: add someone directly by their `sub`. No email is sent. A `memberId` exists only after that person has [launched](/v2/guides/jwt-authentication#launch-the-user-into-the-app) or exchanged a JWT via [`POST /auth/token`](/v2/guides/jwt-authentication).
```json By email theme={null}
{ "email": "bob@acme.com", "role": "member" }
```
```json By memberId theme={null}
{ "memberId": "cccccccc-cccc-4ccc-8ccc-cccccccccc01", "role": "admin" }
```
Membership can change at any time: [add a member](/v2/api-reference/members/add-organization-member), [list members](/v2/api-reference/members/list-organization-members), [remove a member](/v2/api-reference/members/remove-organization-member), or [revoke a pending invite](/v2/api-reference/members/revoke-organization-invite).
A [Business KYB](/v2/guides/user-launch-flows#business-kyb) launch, using [JWT User Authentication](/v2/guides/jwt-authentication#sign-a-user-jwt). The `sub` is the **person's** `memberId`, not the business's `organizationId`.
```html theme={null}
```
If that person belongs to more than one organization, append `?org=` to the target to choose which one to verify. Without it the app uses the last organization that person selected, falling back to their first, so pass it whenever the person may belong to more than one.
If you pre-created the business, the user lands directly in its verification, and an emailed invite is accepted at this point. If you did not, the app first prompts them to create the business, then continues.
**Camera access.** The owner and representative identity checks need the camera. If you embed the launch in an **iframe**, the iframe element must delegate camera access with a wildcard origin: `allow="camera *; microphone *"`. The bare form (`allow="camera"`) only grants the camera to the iframe's own `src` origin, but the scan runs from a nested frame, so without the `*` the browser blocks the camera and the scan never starts. A top-level tab or popup needs nothing extra.
Everything in [What verification covers](/v2/guides/kyb#what-verification-covers), in an authenticated session that can be left and resumed. The people the business names as its owners and legal representatives each complete an identity check of their own, so more than one person usually takes part.
Subscribe to [`kybUpdated`](/v2/api-reference/webhook-events/kybupdated), or read [`GET /organization/{organizationId}/kyb`](/v2/api-reference/kyb/get-kyb-status), until `status` reads `approved`. The business can then transact and hold API keys. See [Tracking verification status](/v2/guides/kyb#tracking-verification-status).
## Error handling
Failures specific to launching. Verification outcomes are in [Tracking verification status](/v2/guides/kyb#tracking-verification-status). A business asked to send something again reads `inProgress`, and launching it again is how its people do that. `denied` is a final rejection, which launching again does not reopen.
| What you see | Meaning | What to do |
| - | - | - |
| The launch never reaches `/kyb` | The JWT was rejected: wrong scope, expired, bad signature, or an unrecognized `target`. | See [Authentication errors](/v2/guides/authentication-errors). |
| The user is prompted to create a business they already have | The launch did not resolve to your organization, usually because the person belongs to more than one and no `?org=` was given. | Append `?org=` to the target. |
| The user lands in the app as a new person | The JWT `sub` was not that person's `memberId`. | Re-sign with the correct `sub`. Do not reuse the old session. |
| The identity scan never starts in an iframe | Camera permission. The bare `allow="camera"` grants only the iframe's own origin, but the scan runs from a nested frame. | Set `allow="camera *; microphone *"` on the iframe. A top-level tab or popup needs nothing extra. |
## Sandbox
The verification checks do not run, so any document images and any identity scan will pass. To skip the flow entirely, use **Approve KYB** in the "Ready to approve?" panel on the sandbox [KYB page](https://sandbox.etherfuse.com/account/kyb): it approves the organization without review, which is the fastest way to unlock sandbox API keys. See [Initial Setup](/v2/initial-setup) and [Test environment](/v2/test-environment).
# KYC API
Source: https://docs.etherfuse.com/v2/guides/kyc-api
Collect your customer's identity data and documents in your own UI, push them over the API, and launch the customer only for what is left.
Push identity data and documents you already hold over the API, so the customer isn't asked for them twice. They are still launched into [`/idv`](/v2/guides/user-launch-flows#identity-verification), but arrive with most of the work done.
Normally, the customer must complete the **liveness selfie**, which cannot be submitted directly over the API. If you already verified the customer on your own Sumsub level, import that session and they skip the selfie too: see [Sumsub shared token](/v2/guides/kyc-sumsub-shared-token).
The customer normally accepts the **customer agreement** themselves. If your own terms incorporate ours, you can forward their acceptance to us over the API: see [the customer agreement](#the-customer-agreement).
## The order matters
Verification runs in [stages](/v2/guides/onboarding#requirements-unlock-in-stages), and you can only send what the current stage asks for.
* **Personal data comes first.** A customer's requirements are assigned from the country you send, and that assignment is asynchronous, so documents and answers sent before it lands are refused with a `409`. The [`kycUpdated`](/v2/api-reference/webhooks/kycupdated) webhook fires when it lands; send once [step 2](#2-read-what-is-being-asked-for) returns a `requirements` list.
* **After that, send only what the requirements list shows as `pending`.** A later stage's requirements, such as `customerAgreement`, are not in the list until the customer reaches that stage. If you send one early, it is accepted with a `202` and then dropped.
* **The one exception is a proof of address.** If you hold one, send it with the identity document, before the identity stage is submitted.
[`POST /organization/{organizationId}/verification`](/v2/api-reference/kyc/submit-verification-data)
[`GET /organization/{organizationId}/kyc?requirements=true`](/v2/api-reference/kyc/get-kyc-status), after the `kycUpdated` webhook.
[`POST /organization/{organizationId}/verification/documents`](/v2/api-reference/kyc/upload-verification-documents). Include a proof of address here if you hold one.
[`POST /organization/{organizationId}/verification/questionnaire`](/v2/api-reference/kyc/submit-a-verification-questionnaire)
The liveness selfie, and anything else you did not send. The customer finishes the identity stage in `/idv`.
After each `kycUpdated`, read the requirements list again. Send `proofOfAddress` if it appears and you did not send one, and `customerAgreement` if you are [forwarding the customer's acceptance](#the-customer-agreement).
**These endpoints return `202 Accepted`, not `200 OK`.** A `202` means "accepted and being applied", not "done". Never treat it as proof that a requirement is satisfied: learn that from the [`kycUpdated`](/v2/api-reference/webhooks/kycupdated) webhook or the [requirements breakdown](/v2/guides/onboarding#the-per-requirement-breakdown).
## 1. Submit personal data
Supply `userInfo.email` when [creating the customer](/v2/guides/kyc-websdk#the-flow). An email address is required for the verification record and status notifications.
```bash Sandbox theme={null}
curl -X POST https://api.sand.etherfuse.com/organization//verification \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{
"firstName": "Ana",
"lastName": "Ejemplo",
"dateOfBirth": "1990-01-01",
"taxId": "XAXX010101000",
"country": "MEX",
"address": {
"street": "Av. Reforma 100",
"city": "Ciudad de Mexico",
"region": "CDMX",
"postalCode": "06600",
"country": "MEX"
}
}'
```
```bash Production theme={null}
curl -X POST https://api.etherfuse.com/organization//verification \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{
"firstName": "Ana",
"lastName": "Ejemplo",
"dateOfBirth": "1990-01-01",
"taxId": "XAXX010101000",
"country": "MEX",
"address": {
"street": "Av. Reforma 100",
"city": "Ciudad de Mexico",
"region": "CDMX",
"postalCode": "06600",
"country": "MEX"
}
}'
```
```json 202 Accepted theme={null}
{
"organizationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"status": "inProgress"
}
```
Every field, its format, and whether it is required: [`POST /organization/{organizationId}/verification`](/v2/api-reference/kyc/submit-verification-data).
**Send only an address that matches the identity document you upload.** The two are cross-checked. An address, date of birth, or name that contradicts the document holds the customer up for review instead of approving them.
Re-submitting overwrites what you sent before and is safe. It never moves the customer's review status on its own.
## 2. Read what is being asked for
Read this when the [`kycUpdated`](/v2/api-reference/webhooks/kycupdated) webhook arrives. It arrives when the customer is routed and the requirement list becomes available. Without a `kycUpdated` subscription, poll this endpoint.
```bash theme={null}
curl "https://api.sand.etherfuse.com/organization//kyc?requirements=true" \
-H "Authorization: "
```
```json theme={null}
{
"organizationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"status": "inProgress",
"currentRejectionReason": null,
"selfies": [],
"documents": [],
"approvedAt": null,
"needsWork": false,
"userMessage": null,
"requirements": [
{ "type": "personalData", "status": "satisfied", "requiresLaunch": false },
{ "type": "identityDocument", "status": "pending", "requiresLaunch": false },
{ "type": "occupation", "status": "pending", "requiresLaunch": false },
{ "type": "taxDocument", "status": "pending", "requiresLaunch": false },
{ "type": "selfie", "status": "pending", "requiresLaunch": true }
]
}
```
`requiresLaunch: false` is yours to send; `true` can only be done by the customer. When everything outstanding reads `true`, you have nothing left to send **for this stage**. Full vocabulary: [Tracking verification status](/v2/guides/onboarding#tracking-verification-status).
The list holds the current stage, plus anything already satisfied. `customerAgreement` and, when needed, `proofOfAddress` appear only once the identity stage clears. Read the list again after each `kycUpdated`.
## 3. Upload documents
`multipart/form-data`, with files sent as files. Nothing is base64-wrapped into JSON.
```bash Sandbox theme={null}
curl -X POST https://api.sand.etherfuse.com/organization//verification/documents \
-H "Authorization: " \
-F id_type=ID_CARD \
-F id_front=@ine-front.jpg \
-F id_back=@ine-back.jpg
```
```bash Production theme={null}
curl -X POST https://api.etherfuse.com/organization//verification/documents \
-H "Authorization: " \
-F id_type=ID_CARD \
-F id_front=@ine-front.jpg \
-F id_back=@ine-back.jpg
```
Send `tax_document` here too, where the customer's country requires one. See [Country-specific requirements](/v2/guides/onboarding#country-specific-requirements).
**A proof of address can go in the same request**, even though it has its own later stage. We hold it for that stage, and it is used only if the identity document turns out to carry no address. This works only until the identity stage is submitted. After that, wait for `proofOfAddress` to appear in the requirements list, then send it on its own.
Every part, the accepted document types, and the file limits: [`POST /organization/{organizationId}/verification/documents`](/v2/api-reference/kyc/upload-verification-documents).
## 4. Submit the questionnaire answers (optional)
Questions only. Documents, including a region's tax document, go through [step 3](#3-upload-documents).
Answer them here if you already hold the answers. Anything you leave out, the customer answers in `/idv`.
Each submission names the questionnaire it answers in `type`:
| `type` | Answers | Applies to |
| - | - | - |
| `occupation` | `jobTitle`, `industry` | Every customer |
| `agreement` | `acceptTerms`, `signatureName`, `signatureDate` | Customers who accepted under your terms |
### Occupation
```bash Sandbox theme={null}
curl -X POST https://api.sand.etherfuse.com/organization//verification/questionnaire \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{
"type": "occupation",
"jobTitle": "Software Engineer",
"industry": "4430200"
}'
```
```bash Production theme={null}
curl -X POST https://api.etherfuse.com/organization//verification/questionnaire \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{
"type": "occupation",
"jobTitle": "Software Engineer",
"industry": "4430200"
}'
```
**Send `type`.** If it names a questionnaire that doesn't exist, the request is rejected and the error tells you which names are valid.
**`industry` is a code you have to look up.** [`GET /verification/occupations`](/v2/api-reference/kyc/list-occupation-categories) returns every accepted value as `{ code, label }`. Submit the `code`: the `label` is display copy, can be reworded, and is not accepted. An unrecognized code is refused with a `400` naming it, so a typo fails at the request and does not leave the requirement outstanding.
```json theme={null}
{
"occupations": [
{ "code": "1000000", "label": "NOT APPLICABLE" },
{ "code": "4430200", "label": "INFORMATION TECHNOLOGY - IT PROGRAMMING ENGINEERS OR TECHNICIANS" }
]
}
```
### The customer agreement
Your customer has to accept the Etherfuse customer agreement before they can transact. By default they accept it themselves in `/idv`. Send `type: "agreement"` only if your own terms incorporate ours and you already collected their acceptance.
**Send the agreement only once `customerAgreement` is in the requirements list.** It has its own stage, after the identity stage and any proof of address. Do not send it in the same burst as the occupation answers: at that point the customer is still on the identity stage. An early agreement is accepted with a `202`, then refused in the background, and you get a `kycUpdated` webhook with `needsWork: true`. Wait for the `kycUpdated` that moves the customer back to `inProgress`, read the list, and send it then.
```bash Sandbox theme={null}
curl -X POST https://api.sand.etherfuse.com/organization//verification/questionnaire \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{
"type": "agreement",
"acceptTerms": true,
"signatureName": "Ana Ejemplo",
"signatureDate": "2026-08-24"
}'
```
```bash Production theme={null}
curl -X POST https://api.etherfuse.com/organization//verification/questionnaire \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{
"type": "agreement",
"acceptTerms": true,
"signatureName": "Ana Ejemplo",
"signatureDate": "2026-08-24"
}'
```
**`acceptTerms: true` is a legal declaration.** By setting it you declare that:
* The person named in `signatureName` accepted terms that incorporate the Etherfuse customer agreement, and did so themselves on the date you give.
* They saw the agreement, or terms that include it, before they accepted.
* They consented to signing electronically, agreed to the terms, stated they are not a U.S. Person, and stated they act in their own name and on their own behalf.
* You keep a record of that acceptance and can produce it for a regulator.
`acceptTerms` must be `true`. It is your affirmation of the declaration above.
`signatureName` is the name the customer typed to sign, in their own words. It is printed on their agreement in a cursive face, so it appears as the signature on the executed document. Up to 255 characters.
`signatureDate` is when the customer accepted, `YYYY-MM-DD`. It is printed on their agreement as the date of signature. It should be within one day of today's date.
## 5. Launch the customer
Launch into [`/idv`](/v2/guides/user-launch-flows#identity-verification) exactly as in the [hosted flow](/v2/guides/kyc-websdk#the-flow). The customer is shown only what's left: the liveness selfie and any questionnaire you did not answer in [step 4](#4-submit-the-questionnaire-answers-optional).
Once they finish the identity stage, the later stages follow in the same session: the proof of address if one is needed and was not already sent, then the customer agreement. If you forward the agreement yourself, watch for `customerAgreement` to appear in the requirements list and send it then.
## Error handling
Each endpoint's full response codes are on its API reference page. Errors are [problem details](/v2/errors); the messages below appear in `detail`. Requirement and overall statuses are in [Tracking verification status](/v2/guides/onboarding#tracking-verification-status). What follows is what actually bites in this flow.
| What you see | Meaning | What to do |
| - | - | - |
| `409` `Submit this customer's personal data first` | Step 1's `202` returns before the customer has a verification profile, so a document or questionnaire call in that window has no stage to land on. | Wait for the requirements breakdown to appear, normally well under a second, then send. |
| `409` `has not been routed to their country's requirements yet` | Step 1 landed, but the stages that hold documents and questions are assigned from the country you sent, and that assignment is asynchronous. | Wait for the `kycUpdated` webhook (`notStarted` to `inProgress`), then re-read step 2 and send. With no webhook registered, read step 2 until the requirements list appears. |
| `202`, but the requirement stays `pending`, or never appears | Steps 1, 3 and 4 validate your request, return `202`, then apply it in the background, and that background work failed. The usual cause is a document or answer sent before its stage was reached, most often the `agreement` sent together with `occupation`. | A `kycUpdated` webhook arrives with `needsWork: true` and a `userMessage` naming what to send again, and `status` unchanged. Nothing retries on our side, so send it again. For documents send every part in the same request: one side of a two-sided document is refused on its own. That webhook is the only place the message appears, so without one registered, re-read the requirements list and re-send what is still outstanding. |
| `202`, then the customer stalls in review | The personal data contradicts the document it was cross-checked against. | Re-submit personal data matching the document, or upload the document that matches. |
| `409` `already approved` / `already denied` | Concluded verifications can't be written to. | A denied customer resubmits by being launched into `/idv`. |
## Sandbox
The document checks don't run, so document content isn't scrutinised: a placeholder image is accepted where production would reject it. Everything else is identical, including the submission order and the conditional proof-of-address stage. See [Test environment](/v2/test-environment).
Mexico's RFC registry check still runs in sandbox. The sandbox registry returns a single test person, so send `"firstName": "LETICIA"` and `"lastName": "HERNANDEZ GONZALEZ"` for a Mexican customer. Any other name is rejected.
# KYC Sumsub Shared Token
Source: https://docs.etherfuse.com/v2/guides/kyc-sumsub-shared-token
Reuse a verification your customer already passed on your own Sumsub level: import it with a share token so the customer repeats neither document capture nor liveness.
If you already verified a customer on your own Sumsub level, hand that verification to Etherfuse instead of putting the customer through it again. You create a share token, we import the session, and the customer's identity data, documents **and liveness** come with it. The customer only completes requirements that the shared session or your API submissions did not satisfy.
This page names the underlying verification provider because the shared-token
flow cannot be described without it: you create the share token in your own account
with that provider. Elsewhere the docs keep the provider abstract.
## Prerequisites
Reusable KYC is off by default on both sides. Before any of this works:
1. You verify customers on your own Sumsub level.
2. A one-time **data-sharing agreement** is signed with Sumsub, by both parties.
3. **Etherfuse is added as a sharing partner** in your Sumsub dashboard, and you in ours.
4. The account creating share tokens holds the **"Share applicants data"** permission.
Talk to your Etherfuse contact to start this. Neither side can enable it unilaterally.
## The flow
A `personal` org, as in any other flow. See [KYC WebSDK](/v2/guides/kyc-websdk#the-flow). Send `userInfo.email`: the import uses it.
For the applicant you already verified, targeting Etherfuse's client id. Share tokens are short-lived and single-use.
[`POST /organization/{organizationId}/verification/import`](/v2/api-reference/kyc/import-a-verified-session)
Usually the `occupation` answers, through [`POST /organization/{organizationId}/verification/questionnaire`](/v2/api-reference/kyc/submit-a-verification-questionnaire), and any [region-specific document](/v2/guides/onboarding#country-specific-requirements) through [`POST /organization/{organizationId}/verification/documents`](/v2/api-reference/kyc/upload-verification-documents). Send only these. When the last one lands, the identity stage is submitted for review.
A [`kycUpdated`](/v2/api-reference/webhooks/kycupdated) webhook arrives when the stage is submitted, and again when the review clears and the next stage arrives (`status` back to `inProgress`). Then read [`GET /organization/{organizationId}/kyc?requirements=true`](/v2/api-reference/kyc/get-kyc-status).
`proofOfAddress` appears only if the identity document carried no address and the shared session did not include one. `customerAgreement` appears last; send it only if you are [forwarding the customer's acceptance](/v2/guides/kyc-api#the-customer-agreement). Repeat the previous step after each one.
Any questionnaire you did not answer over the API, plus liveness if the shared session did not cover it. The customer also accepts the customer agreement in `/idv` unless you forwarded it.
**Do not send everything at once after the import.** The import response lists only the stage the customer is on. The customer agreement belongs to a later stage, and it is not in the list yet. An agreement sent together with the occupation answers is accepted with a `202`, then refused in the background. See [Requirements unlock in stages](/v2/guides/onboarding#requirements-unlock-in-stages).
## Importing
```bash Sandbox theme={null}
curl -X POST https://api.sand.etherfuse.com/organization//verification/import \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{ "shareToken": "_act-sbx-jwt-eyJhbGciOi..." }'
```
```bash Production theme={null}
curl -X POST https://api.etherfuse.com/organization//verification/import \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{ "shareToken": "_act-jwt-eyJhbGciOi..." }'
```
```json 200 OK theme={null}
{
"organizationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"status": "inProgress",
"requirements": [
{ "type": "personalData", "status": "awaitingReview", "requiresLaunch": false },
{ "type": "identityDocument", "status": "awaitingReview", "requiresLaunch": false },
{ "type": "occupation", "status": "pending", "requiresLaunch": false },
{ "type": "selfie", "status": "awaitingReview", "requiresLaunch": true }
]
}
```
This response describes the identity stage only. `proofOfAddress` and `customerAgreement` are not listed yet, even when the customer will need them.
## What carries across
| Carries across | Does not carry across |
| - | - |
| Personal data (name, date of birth, tax id, address, country) | The **occupation questionnaire** |
| **Identity documents** | Any **region-specific documents** |
| The **selfie / liveness** check | The **customer agreement** |
| A **proof of address**, if your applicant has one. It must still meet ours, including the three-month issue date. | |
**Nothing is trusted wholesale.** Everything imported is re-checked against Etherfuse's own requirements. A session approved in your account is *not* automatically approved in ours.
## Error handling
The endpoint's full response codes are on its [API reference page](/v2/api-reference/kyc/import-a-verified-session). Errors are [problem details](/v2/errors); the messages below appear in `detail`. Requirement and overall statuses are in [Tracking verification status](/v2/guides/onboarding#tracking-verification-status).
| What you see | Meaning | What to do |
| - | - | - |
| `400`, message from the provider | The share token is expired, already spent, created for someone else, or shares nothing our requirements need. | Create a new share token. A spent one cannot be reused. |
| `400` `This customer's country is not supported for verification` | Nothing was created and the share token was **not** spent. | |
| `400` `This customer has no email on file` | The customer was created without `userInfo.email`. | Supply it, then import. |
| `409` `verification has already started` | A session can only be imported before verification begins. | Import into a fresh customer. Talk to us if you need to move one that has already started. |
| Everything reads `awaitingReview` | Expected. Nothing is reviewed until the stage is submitted, so freshly imported documents carry no verdict. | Nothing. Don't re-send. |
| `202` for the agreement, then a `kycUpdated` with `needsWork: true` | The agreement was sent before the customer reached its stage, so it was refused in the background. | Wait until `customerAgreement` appears in the requirements list, then send it again. |
## Sandbox
Testing this end to end needs a **second sandbox account acting as the donor**, with the same sharing configuration as production. Without one, build the rest of your integration against [KYC API](/v2/guides/kyc-api) and add the import once sharing is in place. See [Test environment](/v2/test-environment).
# KYC WebSDK
Source: https://docs.etherfuse.com/v2/guides/kyc-websdk
The hosted verification flow: create the customer, launch them into Etherfuse, and the customer completes every step in the widget.
The default flow. You create the customer and launch them; the customer does everything else in the widget at `/idv`.
## The flow
Create a `personal` organization with [`POST /organization`](/v2/api-reference/organizations/create-organization). **You generate the `id`.** This UUID is the customer's organization id, the `organizationId` you use everywhere else.
```bash Sandbox theme={null}
curl -X POST https://api.sand.etherfuse.com/organization \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{
"id": "",
"displayName": "Ana Ejemplo",
"accountType": "personal",
"userInfo": {
"email": "ana@example.com",
"displayName": "Ana Ejemplo"
}
}'
```
```bash Production theme={null}
curl -X POST https://api.etherfuse.com/organization \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{
"id": "",
"displayName": "Ana Ejemplo",
"accountType": "personal",
"userInfo": {
"email": "ana@example.com",
"displayName": "Ana Ejemplo"
}
}'
```
**Send `userInfo.email`.** An email address is required for the customer's verification record and status notifications. A customer with no email on file cannot start verification.
An [Identity verification](/v2/guides/user-launch-flows#identity-verification) launch, using [JWT User Authentication](/v2/guides/jwt-authentication#sign-a-user-jwt).
```html theme={null}
```
**The JWT `sub` MUST equal the org `id` from step 1.** Any other `sub` registers a brand-new person instead, and the verification won't attach to your customer.
Append **`&lang=es`** to the target (`/idv?lang=es`) for Spanish. Pass an optional `return_url` on the launch to send the customer back to your app when they finish.
Everything in the [Requirements List](/v2/guides/onboarding#requirements-list), plus any [country-specific](/v2/guides/onboarding#country-specific-requirements) requirement, in one authenticated session. The widget takes them through the [stages](/v2/guides/onboarding#requirements-unlock-in-stages) in order, so you send nothing. The customer can leave and come back; progress is kept.
A [`kycUpdated`](/v2/api-reference/webhooks/kycupdated) webhook arrives with `kyc.approved: true`, and the customer can now get quotes and create orders. Subscribe with [`POST /webhook_subscription`](/v2/api-reference/webhook-subscriptions/create-webhook-subscription) and `eventType: "kycUpdated"`; see [Webhooks](/v2/webhooks). See [Tracking verification status](/v2/guides/onboarding#tracking-verification-status).
## Error handling
Failures specific to launching. Verification outcomes (`denied`, needs-work) are in [Tracking verification status](/v2/guides/onboarding#tracking-verification-status); a rejected customer is resubmitted by launching them again.
| What you see | Meaning | What to do |
| - | - | - |
| The launch never reaches `/idv` | The JWT was rejected: wrong scope, expired, bad signature, or an unrecognized `target`. | See [Authentication errors](/v2/guides/authentication-errors). |
| The customer lands in `/idv` as a new person | The JWT `sub` wasn't the org `id`. | Sign a new JWT with `sub` = the org `id`. Don't reuse the old session. |
| The liveness scan never starts in an iframe | Camera permission. The bare `allow="camera"` grants only the iframe's own origin, but the scan runs from a nested frame. | Set `allow="camera *; microphone *"` on the iframe. A top-level tab or popup needs nothing extra. |
## Sandbox
The verification checks don't run, so any document images and any selfie will pass. The one exception is Mexico's constancia. See [Country-specific requirements](/v2/guides/onboarding#country-specific-requirements) and [Test environment](/v2/test-environment).
## When to reach for something else
You already collect identity data and documents in your own UI.
You already verified the customer in your own Sumsub account.
# KYC Overview
Source: https://docs.etherfuse.com/v2/guides/onboarding
What Etherfuse collects to verify a customer, how to track where their verification stands, and the extra requirements that apply in specific countries.
Every customer must clear identity verification before they can transact.
## Choosing a flow
| Flow | You submit | Customer submits |
| - | - | - |
| [**KYC WebSDK**](/v2/guides/kyc-websdk) (the default) | Nothing | Everything |
| [**KYC API**](/v2/guides/kyc-api) | Personal data, identity document, questionnaire answers, and any [region-specific documents](#country-specific-requirements) | Liveness selfie, and any questionnaire you do not answer over the API |
| [**Sumsub shared token**](/v2/guides/kyc-sumsub-shared-token) | A share token, then questionnaire answers and any [region-specific documents](#country-specific-requirements) | Any questionnaire you do not answer over the API, plus liveness if the shared session did not cover it |
## Requirements List
| Requirement | What satisfies it | How it can be submitted |
| - | - | - |
| `personalData` | Name, date of birth, tax id, country, and residential address. | WebSDK · API · Shared token |
| `identityDocument` | A government-issued photo ID. Any valid one works. | WebSDK · API · Shared token |
| `proofOfAddress` | A utility bill, bank statement, tax bill, employment certificate, property deed, or invoice, issued within the last three months. Requested only when the identity document carried no address. | WebSDK · API · Shared token |
| `occupation` | Job title and an occupation category. | WebSDK · API |
| `selfie` | A live face capture. | WebSDK · Shared token |
| `customerAgreement` | The customer accepting Etherfuse's agreements. | WebSDK · API |
Some countries add specific additional requirements. See [Country-specific requirements](#country-specific-requirements).
Supply `userInfo.email` when [creating the customer](/v2/guides/kyc-websdk#the-flow). Read the overall status with [`GET /organization/{organizationId}/kyc`](/v2/api-reference/kyc/get-kyc-status).
## Requirements unlock in stages
A customer does not owe every requirement at once. Verification runs in stages, and each stage asks for its own requirements. Today a customer moves through these stages in this order:
1. **Routing.** The country in their personal data decides which requirements apply. Nothing else can be sent until this lands.
2. **Identity.** `personalData`, `identityDocument`, `selfie`, `occupation`, and any [country-specific](#country-specific-requirements) requirement.
3. **Proof of address.** Only when the identity document carried no address.
4. **Agreement.** `customerAgreement`.
Drive your integration from the [requirements breakdown](#the-per-requirement-breakdown), not from this list. We add and reorder stages, and some countries may gain stages of their own.
The rules:
* **The breakdown lists only the current stage, plus requirements already satisfied.** A requirement from a later stage is not in the list at all until the customer reaches that stage.
* **Send a requirement only once it is in the list as `pending` or `actionRequired`.** If you send it earlier, the request is accepted with a `202`, then refused in the background. A [`kycUpdated`](/v2/api-reference/webhooks/kycupdated) webhook arrives with `needsWork: true`, and nothing retries it.
* **A stage moves on by itself.** When everything the current stage asks for has landed, it is submitted for review and the overall `status` becomes `submitted`. When the review clears, the next stage's requirements appear in the list and `status` returns to `inProgress`. Both moves send `kycUpdated`. Review is usually a few seconds; a manual review takes longer.
* **Proof of address is the one exception.** You may send it before its stage, as long as the identity stage has not been submitted yet. Send it in the same request as the identity document. It is used only if the proof of address stage is reached.
So the loop for anything you submit over the API is:
```mermaid theme={null}
flowchart TD
R["Read the breakdown
GET .../kyc?requirements=true"] --> P{"Anything pending or
actionRequired with
requiresLaunch false?"}
P -- "yes" --> S["Send those requirements"]
S --> W["Wait for kycUpdated:
stage submitted, then
the next stage arrives"]
W --> R
P -- "no" --> L{"Anything left with
requiresLaunch true?"}
L -- "yes" --> LA["Launch the customer"]
L -- "no" --> A["Wait for status approved"]
```
[`GET /organization/{organizationId}/kyc?requirements=true`](/v2/api-reference/kyc/get-kyc-status)
Everything in the list with `status: "pending"` or `"actionRequired"` and `requiresLaunch: false`.
It arrives when the stage is submitted, and again when the next stage arrives. Without a `kycUpdated` subscription, read the breakdown again after a few seconds.
When everything outstanding reads `requiresLaunch: true`, [launch the customer](/v2/guides/user-launch-flows#identity-verification). When nothing is outstanding, wait for `status: "approved"`.
## Tracking verification status
### The overall status
```bash Sandbox theme={null}
curl -H "Authorization: " \
https://api.sand.etherfuse.com/organization//kyc
```
```bash Production theme={null}
curl -H "Authorization: " \
https://api.etherfuse.com/organization//kyc
```
```json theme={null}
{
"organizationId": "123e4567-e89b-12d3-a456-426614174000",
"status": "submitted",
"currentRejectionReason": null,
"selfies": [],
"documents": [],
"approvedAt": null,
"needsWork": false,
"userMessage": null
}
```
| Status | Description |
| - | - |
| `notStarted` | No verification started |
| `inProgress` | Started, but not everything is submitted. Detail in `needsWork` and `userMessage` |
| `submitted` | Submitted, awaiting review (manual or automated) |
| `approved` | Cleared; the customer can transact |
| `denied` | A rejected verdict, or a previously-approved customer whose approval was revoked. Reason in `currentRejectionReason` |
### The per-requirement breakdown
Add **`?requirements=true`** for a step-by-step account of what the customer still needs. It is opt-in: without it the response leaves `requirements` out.
```bash theme={null}
curl "https://api.sand.etherfuse.com/organization//kyc?requirements=true" \
-H "Authorization: "
```
```json theme={null}
{
"organizationId": "123e4567-e89b-12d3-a456-426614174000",
"status": "inProgress",
"currentRejectionReason": null,
"selfies": [],
"documents": [],
"approvedAt": null,
"needsWork": false,
"userMessage": null,
"requirements": [
{ "type": "personalData", "status": "satisfied", "requiresLaunch": false },
{ "type": "identityDocument", "status": "satisfied", "requiresLaunch": false },
{ "type": "occupation", "status": "pending", "requiresLaunch": false },
{ "type": "selfie", "status": "pending", "requiresLaunch": true }
]
}
```
This customer is on the identity stage. `customerAgreement` is not in the list yet, so it cannot be sent yet. Once the identity stage clears, the same call returns:
```json theme={null}
{
"organizationId": "123e4567-e89b-12d3-a456-426614174000",
"status": "inProgress",
"currentRejectionReason": null,
"selfies": [],
"documents": [],
"approvedAt": null,
"needsWork": false,
"userMessage": null,
"requirements": [
{ "type": "personalData", "status": "satisfied", "requiresLaunch": false },
{ "type": "identityDocument", "status": "satisfied", "requiresLaunch": false },
{ "type": "occupation", "status": "satisfied", "requiresLaunch": false },
{ "type": "selfie", "status": "satisfied", "requiresLaunch": true },
{ "type": "customerAgreement", "status": "pending", "requiresLaunch": false }
]
}
```
Each `type` matches a requirement in the [Requirements List](#requirements-list) or a [country-specific](#country-specific-requirements) one. `requiresLaunch: true` marks the rows only the WebSDK can satisfy.
**`requirements` is absent until the customer has been routed to their country's requirements**, so it is missing immediately after their personal data is submitted. Read it again a moment later. After that it **grows** as the customer moves through the [stages](#requirements-unlock-in-stages): `proofOfAddress` and `customerAgreement` appear only when their stage is reached. Re-read it after every `kycUpdated`; no single response is the full list.
We reserve the right to add new requirements, and will update these docs when we do. Some countries may also gain requirements of their own.
### Requirement statuses
```mermaid theme={null}
stateDiagram-v2
[*] --> pending: the current stage asks for it
pending --> awaitingReview: you provide it
awaitingReview --> satisfied: accepted
awaitingReview --> actionRequired: rejected
actionRequired --> awaitingReview: you send it again, corrected
satisfied --> [*]
```
| `status` | Meaning | What to do |
| - | - | - |
| `satisfied` | Accepted. | Nothing. |
| `pending` | The current stage asks for this, and nothing has been provided yet. | Supply it. |
| `awaitingReview` | Provided, not yet reviewed. | Wait. |
| `actionRequired` | **Reviewed and rejected.** | Send it again, corrected. |
## Country-specific requirements
| Requirement | What satisfies it | How it can be submitted |
| - | - | - |
| `taxDocument` | The **Constancia de Situación Fiscal**, the tax-status document issued by SAT. | WebSDK · API |
It must be legible and belong to the customer being verified. Submit it as the `tax_document` part of [`POST /organization/{organizationId}/verification/documents`](/v2/api-reference/kyc/upload-verification-documents), or let the customer upload it in the widget.
**In sandbox, the constancia is the one document that has to look real.** Any real constancia with any data in it works. Use [this example constancia](https://stablebonds.s3.us-west-2.amazonaws.com/example-constancia-de-situacion-fiscal.pdf).
**In sandbox, the customer's name must be `LETICIA HERNANDEZ GONZALEZ`.** The RFC registry check still runs, and the sandbox registry returns only this test person. Any other name is rejected.
## Reference
After verification is approved, register bank accounts and wallets as payment accounts with [`POST /organization/{organizationId}/payment_account`](/v2/api-reference/payment-accounts/register-payment-account). Payment accounts belong to the organization in the route. Use your own organization's id from [`GET /me`](/v2/api-reference/organizations/get-me), or a customer organization's id from [`POST /organization`](/v2/api-reference/organizations/create-organization) or [`GET /organizations`](/v2/api-reference/organizations/list-organizations). See [Organizations in the route](/v2/overview#organizations-in-the-route). A bank account route needs account holder details under `registrationInfo`, such as `accountHolderName` and `taxId`. We fill each field from that organization's verified identity when we hold it, so you do not send KYC data again. To register a customer's bank account, pass the customer's organization id.
To learn which fields you still need before you register, call [`GET /organization/{organizationId}/payment_account/requirements`](/v2/api-reference/payment-accounts/get-registration-requirements) with the same organization id, `kind`, `country`, `currency` and `accountDetails.kind` you plan to register:
```bash theme={null}
curl -H "Authorization: " \
"https://api.sand.etherfuse.com/organization/$ORGANIZATION_ID/payment_account/requirements?kind=bank&country=MX¤cy=MXN&accountDetailsKind=clabe"
```
```json theme={null}
{
"accountDetails": {
"kind": "clabe",
"required": ["#/accountDetails/clabe"]
},
"registrationInfo": {
"required": ["#/registrationInfo/accountHolderName", "#/registrationInfo/taxId"],
"missing": ["#/registrationInfo/taxId"]
}
}
```
`required` lists every field the route needs. `missing` lists the ones we hold no verified value for. Ask the customer only for the `missing` fields and send them under `registrationInfo`. If you already collect more fields, you can send them all; we ignore the ones this route does not use.
If you register without a field we need, we return `422` `registration-info-required` with the same `required` and `missing` lists. Add the missing fields and send the request again. A `taxId` you send must match the verified one, or we return `422` `holder-tax-id-mismatch`.
A new account reports its readiness per rail and direction in `rails`. You can quote a direction once its `status` is `ready`. Read the account with [`GET /organization/{organizationId}/payment_account/{id}`](/v2/api-reference/payment-accounts/get-payment-account), or subscribe to [`paymentAccountUpdated`](/v2/api-reference/webhook-events/paymentaccountupdated) to receive the account when it changes. See [Overview](/v2/overview) for the full flow.
Subscribe to `kycUpdated` events with [`POST /webhook_subscription`](/v2/api-reference/webhook-subscriptions/create-webhook-subscription) to receive status updates. See [Webhooks](/v2/webhooks) for setup and signature verification, and [`kycUpdated`](/v2/api-reference/webhooks/kycupdated) for the payload.
**Approved:**
```json theme={null}
{
"event": "kycUpdated",
"eventId": "0d6f1a2b-3c4d-4e5f-8a9b-0c1d2e3f4a5b",
"occurredAt": "2026-09-29T19:00:00Z",
"organizationId": "123e4567-e89b-12d3-a456-426614174000",
"kyc": { "status": "approved", "approved": true, "needsWork": false, "userMessage": null }
}
```
**Needs work:**
```json theme={null}
{
"event": "kycUpdated",
"eventId": "7e8f9a0b-1c2d-4e3f-8a4b-5c6d7e8f9a0b",
"occurredAt": "2026-09-29T19:05:00Z",
"organizationId": "123e4567-e89b-12d3-a456-426614174000",
"kyc": {
"status": "inProgress",
"approved": false,
"needsWork": true,
"userMessage": "Please upload a current government-issued ID."
}
}
```
Business customers get `kybUpdated` instead, with `kyb: { status, approvedAt }`. See [`kybUpdated`](/v2/api-reference/webhook-events/kybupdated).
Partners can only access KYC data they submitted:
| Accessor | Data Visibility |
| - | - |
| Partner (via API key) | Only data where `source_organization_id` matches their org |
| Wallet Owner | All data for their wallet |
| Admin | All data |
This prevents partners from accessing PII submitted by other partners or directly by users.
# Offramps
Source: https://docs.etherfuse.com/v2/guides/testing-offramps
How to test offramp flows in the sandbox environment
**Prerequisite:** Your organization must be approved before it can quote, and the wallet must hold the asset it sells. See [KYC Overview](/v2/guides/onboarding) for what verification requires, and [KYC WebSDK](/v2/guides/kyc-websdk) for the hosted flow.
**Redemptions do not depend on how much was onramped.** A customer can sell any amount of a stablebond up to their current balance. We do not pair buys with sells or track lots, so partial redemptions and tokens bought elsewhere both work.
An offramp moves crypto from a wallet to a bank account. In v2 both sides are payment accounts: you register the wallet and the bank account, quote between them, create the order, and send the crypto.
Every call below runs for one organization, set as `$ORGANIZATION_ID` in the examples. Use your own organization's id from `GET /me`, or a customer organization's id when you ramp for that customer. Register the accounts, quote and order under the same id. See [Organizations in the route](/v2/overview#organizations-in-the-route).
```bash theme={null}
export ORGANIZATION_ID=
```
## Offramp flow
Either of these endpoints gives you the asset identifiers for a quote:
* [GET /supported\_assets](/v2/api-reference/assets/list-supported-assets) requires auth. It returns every market and token a quote can use. Pass `paymentAccountId` to see what one of your accounts can use, or `chain` for one chain.
* [GET /lookup/stablebonds](/v2/api-reference/lookup/list-stablebonds) is public. It returns every stablebond on every chain, with pricing and supply data.
Use a token's `asset` from the first, or the `identifier` from the second, as `sourceAsset` in the quote.
**Asset identifier format differs by chain:**
* **Solana:** Base58 mint address (for example `AvvetPGuuB5FD5m86fpw3LtDKyQoUFT1mG9WarNQLW4q`)
* **Stellar:** `CODE:ISSUER` (for example `CETES:GC3CW7...`)
* **EVM (Base, Polygon, Monad, Avalanche):** the raw contract address only (for example `0xcC77c598d42f2f78Beb42C91d12B9d4041a5cE29`)
A `SYMBOL:0x...` identifier on an EVM chain fails.
Register the wallet that sends the crypto and the bank account that receives the payout. Each call returns the account's `id` and its `rails`. See [Register payment account](/v2/api-reference/payment-accounts/register-payment-account) for the full schema.
```bash Wallet theme={null}
curl -X POST https://api.sand.etherfuse.com/organization/$ORGANIZATION_ID/payment_account \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{
"kind": "wallet",
"label": "Treasury wallet",
"accountDetails": {
"kind": "address",
"address": "0xf82567432381D1326484E5A9140ABCa4294f82D0",
"chain": "base"
}
}'
```
```bash Bank account (SPEI) theme={null}
curl -X POST https://api.sand.etherfuse.com/organization/$ORGANIZATION_ID/payment_account \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{
"kind": "bank",
"label": "Operating account",
"country": "MX",
"currency": "MXN",
"accountDetails": { "kind": "clabe", "clabe": "012180000000000002" }
}'
```
For an offramp, the wallet needs `source` ready on its chain rail, and the bank account needs `destination` ready on its fiat rail (`spei`). Read the account again with [GET /organization/\{organizationId}/payment\_account/\{id}](/v2/api-reference/payment-accounts/get-payment-account), or listen for [`paymentAccountUpdated`](/v2/api-reference/webhook-events/paymentaccountupdated).
A bank account route needs holder details such as `accountHolderName` and `taxId`. We fill them from the verified identity of the organization in the route, so pass the customer's organization id to register a customer's bank account. If a field is still missing, we return `422` `registration-info-required` with the fields in `missing`. Send them under `registrationInfo` and retry.
Quote from the wallet to the bank account. Both accounts must belong to the organization in the route. Set `sourceAsset` to the asset identifier and `destinationAsset` to the fiat currency. Quotes expire **2 minutes** after they are created (`expiresAt`). See [Create quote](/v2/api-reference/quotes/create-quote) for the full schema.
```bash Sandbox theme={null}
curl -X POST https://api.sand.etherfuse.com/organization/$ORGANIZATION_ID/quote \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{
"source": { "type": "stored", "id": "" },
"destination": { "type": "stored", "id": "" },
"sourceAmount": "50",
"sourceAsset": "0xcC77c598d42f2f78Beb42C91d12B9d4041a5cE29",
"destinationAsset": "MXN"
}'
```
```bash Production theme={null}
curl -X POST https://api.etherfuse.com/organization/$ORGANIZATION_ID/quote \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{
"source": { "type": "stored", "id": "" },
"destination": { "type": "stored", "id": "" },
"sourceAmount": "50",
"sourceAsset": "0x834df4C1d8f51Be24322E39e4766697BE015512F",
"destinationAsset": "MXN"
}'
```
**Partner fees:** We apply your organization's partner fee automatically. To set it for one quote, add `"partnerFeeBpsOverride": 100`. We cap values above 500. See [Partner fees](/v2/fees#partner-fees).
Send the `quoteId`. The response is `201` with the full order, and the order's `id` equals the `quoteId`. Sending the same `quoteId` again returns the existing order with `200`. See [Create order](/v2/api-reference/orders/create-order) for the full schema.
```bash Sandbox theme={null}
curl -X POST https://api.sand.etherfuse.com/organization/$ORGANIZATION_ID/order \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{ "quoteId": "" }'
```
```bash Production theme={null}
curl -X POST https://api.etherfuse.com/organization/$ORGANIZATION_ID/order \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{ "quoteId": "" }'
```
On an offramp, `fundingInstructions.rail` is the wallet's chain, and `fundingInstructions.destination` says where to send the crypto:
```json theme={null}
{
"fundingInstructions": {
"rail": "stellar",
"reference": "1000043",
"destination": {
"expiresAt": null,
"address": "GABPM7AXX...",
"memo": "",
"preparedTransaction": {
"transaction": "AAAAAgAAAABk...base64_xdr...",
"refreshAfter": "2026-09-28T18:05:02Z"
}
},
"fundingDeadline": "2026-09-28T18:05:02Z"
}
}
```
| Field | Meaning |
| - | - |
| `address` | The address to send the crypto to. |
| `memo` | The memo to include in the transfer. Absent when none is needed. |
| `preparedTransaction` | A transaction we built for the customer to sign and submit. Absent when there is none. |
| `preparedTransaction.refreshAfter` | After this time the transaction can no longer land. On Solana it is about 60 seconds after we build it, when the blockhash expires. On Stellar it is the order's `fundingDeadline`. Null on EVM chains, where the transaction has no time limit. A Stellar or EVM transaction also goes stale when the wallet's sequence number or nonce moves. |
`destination` is absent until it is available. You receive it in an `orderUpdated` event, or you can read the order again.
Offramps need an on-chain action from the customer. Fund the order before `fundingDeadline` in one of two ways:
1. **Sign the prepared transaction.** When `preparedTransaction` is present, deserialize `transaction`, sign it with the customer's wallet, and submit it to the network.
2. **Send a transfer yourself.** Send the order's `source.amount` of `source.asset` to `address`, with `memo` when it is present.
**Prepared transactions go stale.** A Solana transaction goes stale after `refreshAfter`, when its blockhash expires. A Stellar or EVM transaction goes stale when the wallet sends another transaction first, because its sequence number or nonce moves. In either case, call [POST /organization/\{organizationId}/order/\{id}/funding\_instructions/refresh](/v2/api-reference/orders/refresh-funding-instructions). The new funding instructions arrive a moment later in an `orderUpdated` webhook and on GET. See [Stellar](/v2/guides/chains/stellar#stale-transactions-and-refresh).
**Embedded wallets** sign through their owner instead. See [Embedded Wallets](/v2/guides/embedded-wallets).
We detect the transfer on-chain and send the fiat payout. The status moves `created` → `funded` (crypto received) → `completed` (fiat sent). Read the order, or subscribe to [`orderUpdated`](/v2/api-reference/webhook-events/orderupdated) with [POST /webhook\_subscription](/v2/api-reference/webhook-subscriptions/create-webhook-subscription). See [Get order](/v2/api-reference/orders/get-order) for the response schema.
**No fiat simulation.** Offramps have no `fiat_received` step. Sending the crypto on the testnet is enough.
```bash Sandbox theme={null}
curl -H "Authorization: " \
https://api.sand.etherfuse.com/organization/$ORGANIZATION_ID/order/
```
```bash Production theme={null}
curl -H "Authorization: " \
https://api.etherfuse.com/organization/$ORGANIZATION_ID/order/
```
The destination is always the bank account. Only the wallet and `sourceAsset` change.
**Solana:** use the Base58 mint address:
```json theme={null}
{
"source": { "type": "stored", "id": "" },
"destination": { "type": "stored", "id": "" },
"sourceAmount": "50",
"sourceAsset": "AvvetPGuuB5FD5m86fpw3LtDKyQoUFT1mG9WarNQLW4q",
"destinationAsset": "MXN"
}
```
**Stellar:** use `CODE:ISSUER`:
```json theme={null}
{
"source": { "type": "stored", "id": "" },
"destination": { "type": "stored", "id": "" },
"sourceAmount": "50",
"sourceAsset": "CETES:GC3CW7...",
"destinationAsset": "MXN"
}
```
**Base, Polygon, Monad or Avalanche:** use the raw contract address. This example uses Base; get the address for your chain from `GET /supported_assets`:
```json theme={null}
{
"source": { "type": "stored", "id": "" },
"destination": { "type": "stored", "id": "" },
"sourceAmount": "50",
"sourceAsset": "0xcC77c598d42f2f78Beb42C91d12B9d4041a5cE29",
"destinationAsset": "MXN"
}
```
| Source asset | Chains |
| - | - |
| Stablebond (for example CETES) | Solana, Stellar, Base, Polygon, Monad, Avalanche |
| Stablecoin (for example USDC) | Solana, Stellar, Monad |
We redeem stablebonds on Solana, so a stablebond offramp from another chain bridges the asset first and can include a `bridge` fee line.
| Error | Cause | Fix |
| - | - | - |
| `409` `organization-not-approved` | Your organization is not approved | Complete verification, then quote again |
| `409` `payment-account-not-ready` | The wallet is not `ready` as a `source`, or the bank account is not `ready` as a `destination` | Check each account's `rails` and wait for the direction to become `ready` |
| `400` `quote-expired` | The quote is older than 2 minutes, or does not exist | Create a new quote |
| `400` `amount-out-of-range` | The amount is outside a rail's `minAmount` or `maxAmount` | Change the amount |
| `422` `quote-unavailable` | We cannot price the route right now | Retry with backoff |
| Quote rejected for a stablecoin on Base or Polygon | We quote stablecoins only on Solana, Stellar and Monad | Sell a stablebond, or use a wallet on one of those chains |
| `tx_bad_seq` (Stellar), a nonce error (EVM), or an expired blockhash (Solana) | The prepared transaction went stale before submission | Refresh the funding instructions and sign the new transaction |
| Stellar transfer fails | The wallet does not hold enough XLM for the network fee | Fund the wallet with XLM. See [Stellar](/v2/guides/chains/stellar#offramps-and-swaps-fund-the-wallet-first) |
See [Errors](/v2/errors) for every problem type.
# Onramps
Source: https://docs.etherfuse.com/v2/guides/testing-onramps
How to test onramp flows in the sandbox environment
**Prerequisite:** Your organization must be approved before it can quote. See [KYC Overview](/v2/guides/onboarding) for what verification requires, and [KYC WebSDK](/v2/guides/kyc-websdk) for the hosted flow.
An onramp moves fiat from a bank account to a wallet. In v2 both sides are payment accounts: you register the bank account and the wallet, quote between them, create the order, and fund it.
Every call below runs for one organization, set as `$ORGANIZATION_ID` in the examples. Use your own organization's id from `GET /me`, or a customer organization's id when you ramp for that customer. Register the accounts, quote and order under the same id. See [Organizations in the route](/v2/overview#organizations-in-the-route).
```bash theme={null}
export ORGANIZATION_ID=
```
## Onramp flow
Either of these endpoints gives you the asset identifiers for a quote:
* [GET /supported\_assets](/v2/api-reference/assets/list-supported-assets) requires auth. It returns every market and token a quote can use. Pass `paymentAccountId` to see what one of your accounts can use, or `chain` for one chain.
* [GET /lookup/stablebonds](/v2/api-reference/lookup/list-stablebonds) is public. It returns every stablebond on every chain, with pricing and supply data.
Use a token's `asset` from the first, or the `identifier` from the second, as `destinationAsset` in the quote.
**Asset identifier format differs by chain:**
* **Solana:** Base58 mint address (for example `AvvetPGuuB5FD5m86fpw3LtDKyQoUFT1mG9WarNQLW4q`)
* **Stellar:** `CODE:ISSUER` (for example `CETES:GC3CW7...`)
* **EVM (Base, Polygon, Monad, Avalanche):** the raw contract address only (for example `0xcC77c598d42f2f78Beb42C91d12B9d4041a5cE29`)
A `SYMBOL:0x...` identifier on an EVM chain fails.
Each chain has its own setup (trustlines, gas). See the chain guides: [Solana](/v2/guides/chains/solana), [Stellar](/v2/guides/chains/stellar), [Base](/v2/guides/chains/base), [Polygon](/v2/guides/chains/polygon), [Monad](/v2/guides/chains/monad), [Avalanche](/v2/guides/chains/avalanche).
Register the bank account the customer pays from and the wallet that receives the crypto. Each call returns the account's `id` and its `rails`. See [Register payment account](/v2/api-reference/payment-accounts/register-payment-account) for the full schema.
```bash Bank account (SPEI) theme={null}
curl -X POST https://api.sand.etherfuse.com/organization/$ORGANIZATION_ID/payment_account \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{
"kind": "bank",
"label": "Operating account",
"country": "MX",
"currency": "MXN",
"accountDetails": { "kind": "clabe", "clabe": "012180000000000002" }
}'
```
```bash Wallet theme={null}
curl -X POST https://api.sand.etherfuse.com/organization/$ORGANIZATION_ID/payment_account \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{
"kind": "wallet",
"label": "Treasury wallet",
"accountDetails": {
"kind": "address",
"address": "0xf82567432381D1326484E5A9140ABCa4294f82D0",
"chain": "base"
}
}'
```
You can quote a direction only when it is `ready`. For an onramp, the bank account needs `source` ready on its fiat rail (`spei`), and the wallet needs `destination` ready on its chain rail. Read the account again with [GET /organization/\{organizationId}/payment\_account/\{id}](/v2/api-reference/payment-accounts/get-payment-account), or listen for [`paymentAccountUpdated`](/v2/api-reference/webhook-events/paymentaccountupdated).
A bank account route needs holder details such as `accountHolderName` and `taxId`. We fill them from the verified identity of the organization in the route, so pass the customer's organization id to register a customer's bank account. If a field is still missing, we return `422` `registration-info-required` with the fields in `missing`. Send them under `registrationInfo` and retry. Use the dummy RFC `XAXX010101000` in sandbox.
Quote from the bank account to the wallet. Both accounts must belong to the organization in the route. Set `sourceAsset` to the fiat currency (`MXN` or `BRL`) and `destinationAsset` to the asset identifier. Quotes expire **2 minutes** after they are created (`expiresAt`). See [Create quote](/v2/api-reference/quotes/create-quote) for the full schema.
```bash Sandbox theme={null}
curl -X POST https://api.sand.etherfuse.com/organization/$ORGANIZATION_ID/quote \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{
"source": { "type": "stored", "id": "" },
"destination": { "type": "stored", "id": "" },
"sourceAmount": "100",
"sourceAsset": "MXN",
"destinationAsset": "0xcC77c598d42f2f78Beb42C91d12B9d4041a5cE29"
}'
```
```bash Production theme={null}
curl -X POST https://api.etherfuse.com/organization/$ORGANIZATION_ID/quote \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{
"source": { "type": "stored", "id": "" },
"destination": { "type": "stored", "id": "" },
"sourceAmount": "100",
"sourceAsset": "MXN",
"destinationAsset": "0x834df4C1d8f51Be24322E39e4766697BE015512F"
}'
```
The response has the `quoteId`, `sourceAmount`, `destinationAmount`, both rails, `rateBeforeFees`, `rateAfterFees`, the total fee (`feeBps`, `feeAmount`) and the fee lines in `fees[]`.
**Partner fees:** We apply your organization's partner fee automatically. To set it for one quote, add `"partnerFeeBpsOverride": 100`. We cap values above 500. See [Partner fees](/v2/fees#partner-fees).
In sandbox, onramp quotes are limited to a `sourceAmount` of 500. Larger amounts return `400` `sandbox-limit-exceeded`.
Send the `quoteId`. The quote carries the accounts, rails, assets and amounts, so you do not repeat them. The response is `201` with the full order, and the order's `id` equals the `quoteId`. See [Create order](/v2/api-reference/orders/create-order) for the full schema.
```bash Sandbox theme={null}
curl -X POST https://api.sand.etherfuse.com/organization/$ORGANIZATION_ID/order \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{ "quoteId": "", "memo": "Invoice 1042" }'
```
```bash Production theme={null}
curl -X POST https://api.etherfuse.com/organization/$ORGANIZATION_ID/order \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{ "quoteId": "", "memo": "Invoice 1042" }'
```
`memo` is optional, up to 255 characters. Sending the same `quoteId` again returns the existing order with `200`, so a retry never creates a second order. See [Idempotency](/v2/idempotency).
`fundingInstructions` tells the customer how to pay:
```json theme={null}
{
"fundingInstructions": {
"rail": "spei",
"reference": "1000042",
"destination": {
"expiresAt": null,
"clabe": "646180000000000009",
"bankName": "Ejemplo Banco",
"recipientName": "Ejemplo Receptor"
},
"fundingDeadline": "2026-09-29T17:05:02Z"
}
}
```
On `spei`, `destination` gives the CLABE to pay. On `pix`, it gives a `brCode`. `destination` is absent until it is available; you receive it in an `orderUpdated` event, or you can read the order again.
In production, the customer pays the funding destination before `fundingDeadline`. In sandbox, simulate the deposit with [Simulate fiat received](/v2/api-reference/sandbox/simulate-fiat-received). Send the order's `source.amount`.
```bash Sandbox theme={null}
curl -X POST https://api.sand.etherfuse.com/organization/$ORGANIZATION_ID/order//fiat_received \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{ "amount": "100" }'
```
We return `200` with an empty body, and the order continues as it would after a real deposit.
This endpoint works only in sandbox. In production it returns `403` `forbidden`. It returns `422` `unsupported` when the order's source is not a `spei` or `pix` bank account.
**The customer must send the exact order amount.** Pay the `source.amount` from the order to the funding destination.
Read the order, or subscribe to `orderUpdated` with [POST /webhook\_subscription](/v2/api-reference/webhook-subscriptions/create-webhook-subscription). Each [`orderUpdated`](/v2/api-reference/webhook-events/orderupdated) event carries the full order. See [Get order](/v2/api-reference/orders/get-order) for the response schema.
```bash Sandbox theme={null}
curl -H "Authorization: " \
https://api.sand.etherfuse.com/organization/$ORGANIZATION_ID/order/
```
```bash Production theme={null}
curl -H "Authorization: " \
https://api.etherfuse.com/organization/$ORGANIZATION_ID/order/
```
The status moves `created` → `funded` → `completed`. A Stellar onramp can stop at `claimable` first; see [Stellar: wallets without a trustline](#stellar-wallets-without-a-trustline). An order can also end `failed` (see `failure`), `canceled` or `refunded`.
The source is always the bank account. Only the wallet and `destinationAsset` change.
**Solana:** use the Base58 mint address:
```json theme={null}
{
"source": { "type": "stored", "id": "" },
"destination": { "type": "stored", "id": "" },
"sourceAmount": "100",
"sourceAsset": "MXN",
"destinationAsset": "AvvetPGuuB5FD5m86fpw3LtDKyQoUFT1mG9WarNQLW4q"
}
```
**Stellar:** use `CODE:ISSUER`:
```json theme={null}
{
"source": { "type": "stored", "id": "" },
"destination": { "type": "stored", "id": "" },
"sourceAmount": "100",
"sourceAsset": "MXN",
"destinationAsset": "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"
}
```
**Base, Polygon, Monad or Avalanche:** use the raw contract address. This example uses Base; get the address for your chain from `GET /supported_assets`:
```json theme={null}
{
"source": { "type": "stored", "id": "" },
"destination": { "type": "stored", "id": "" },
"sourceAmount": "100",
"sourceAsset": "MXN",
"destinationAsset": "0xcC77c598d42f2f78Beb42C91d12B9d4041a5cE29"
}
```
| Destination asset | Chains |
| - | - |
| Stablebond (for example CETES) | Solana, Stellar, Base, Polygon, Monad, Avalanche |
| Stablecoin (for example USDC) | Solana, Stellar, Monad |
We issue stablebonds on Solana and bridge them to the wallet's chain, so a stablebond onramp to another chain can include a `bridge` fee line. For an `avalanche` wallet, read the available assets from [GET /supported\_assets?chain=avalanche](/v2/api-reference/assets/list-supported-assets).
| Error | Cause | Fix |
| - | - | - |
| `409` `organization-not-approved` | Your organization is not approved | Complete verification, then quote again |
| `409` `payment-account-not-ready` | The bank account is not `ready` as a `source`, or the wallet is not `ready` as a `destination` | Check each account's `rails` and wait for the direction to become `ready` |
| `422` `registration-info-required` | The bank account route needs holder fields we do not hold | Send the fields in `missing` under `registrationInfo` |
| `400` `sandbox-limit-exceeded` | The sandbox onramp amount is above 500 | Use a smaller amount |
| `400` `quote-expired` | The quote is older than 2 minutes, or does not exist | Create a new quote |
| `400` `amount-out-of-range` | The amount is outside a rail's `minAmount` or `maxAmount` | Change the amount |
| `422` `quote-unavailable` | We cannot price the route right now | Retry with backoff |
| Quote rejected for a stablecoin on Base or Polygon | We quote stablecoins only on Solana, Stellar and Monad | Quote a stablebond, or use a wallet on one of those chains |
| `422` `unsupported` | We cannot quote this route | Change the asset or the wallet |
See [Errors](/v2/errors) for every problem type.
***
## Stellar: wallets without a trustline
A Stellar wallet needs an on-chain account and a trustline before it can hold an asset. When the destination wallet has neither, we still deliver: we send the asset as a **claimable balance**, and the customer claims it.
### How it works
1. **Quote as usual.** The wallet is the quote's destination, so we check it on-chain. If it needs setup, the quote's `feeBps` and `feeAmount` include a one-time cost to cover the XLM for the account and the trustline reserve.
2. **Create and fund the order as usual.**
3. **The order becomes `claimable`.** We deliver the claimable balance, and the order's `claimInstructions` describes it:
```json theme={null}
{
"id": "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b",
"status": "claimable",
"claimInstructions": {
"kind": "stellarClaimableBalance",
"balanceId": "00000000abc123...",
"asset": "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
"amount": "54.1200000",
"claimant": "GDUKMGUGD3V6VXTU2RLAUM7A2FABLMHCPWTMDHKP7HHJ6FCZKEY4PVWL",
"expiresAt": null
}
}
```
4. **Prepare the claim.** Call [POST /organization/\{organizationId}/order/\{id}/claim](/v2/api-reference/orders/prepare-claim) with no body. The response depends on the wallet:
* `stellarTransaction`: a self-held wallet signs and submits `xdr`. The transaction adds the trustline and claims the balance in one step.
* `authorization`: an [embedded wallet](/v2/guides/embedded-wallets) gets an `authorizationId`. The order's `authorization` with that id holds the `message` for the key holder to sign.
5. **Sign and submit.** A self-held wallet submits the signed `xdr`. For an embedded wallet, the key holder signs `authorization.method.message` and you send the signature to [POST /organization/\{organizationId}/order/\{id}/authorization](/v2/api-reference/orders/submit-authorization); see [Order authorization](/v2/guides/embedded-wallets#order-authorization). After the claim lands, the wallet holds the trustline and the asset, and the order moves to `completed`.
```javascript theme={null}
// Using the Stellar SDK
const res = await fetch(
`https://api.sand.etherfuse.com/organization/${organizationId}/order/${orderId}/claim`,
{ method: "POST", headers: { Authorization: apiKey } }
);
const claim = await res.json();
if (claim.kind === "stellarTransaction") {
const tx = TransactionBuilder.fromXDR(claim.xdr, networkPassphrase);
tx.sign(userKeypair);
await server.submitTransaction(tx);
}
```
If the wallet already has an account and the trustline, we deliver directly. The order goes from `funded` to `completed`, and `claimInstructions` stays null.
**The claim transaction goes stale.** It uses the wallet's current sequence number, so any other transaction the wallet submits first breaks it. We build a new transaction on every call and never store one, so call the endpoint again right before the customer signs.
# Swaps
Source: https://docs.etherfuse.com/v2/guides/testing-swaps
How to test token-to-token swap flows in the sandbox environment
**Prerequisite:** Your organization must be approved before it can quote. Swaps do not need a bank account. For onboarding your customers, see [KYC Overview](/v2/guides/onboarding).
A swap exchanges one asset for another on the same chain. In v2 a swap is a quote and an order between two wallet payment accounts, the same way an onramp or offramp is. The destination can be the same wallet as the source, or another wallet on the same chain.
Every call below runs for one organization, set as `$ORGANIZATION_ID` in the examples. Use your own organization's id from `GET /me`, or a customer organization's id when you swap for that customer. Register the wallets, quote and order under the same id. See [Organizations in the route](/v2/overview#organizations-in-the-route).
```bash theme={null}
export ORGANIZATION_ID=
```
Swaps run on **Stellar**, **Solana** and **Monad**. The source and destination wallets must be on the **same chain**. To move the same token to a wallet on another chain, quote a [cross-chain transfer](/v2/overview) instead. A quote between different tokens on different chains returns `400` `invalid-request`. Base, Polygon and Avalanche support onramps and offramps, not swaps.
**Stellar swaps need a funded, trustlined wallet.** The wallet must hold XLM for the network fee and a trustline for the asset it receives. See the [Stellar](/v2/guides/chains/stellar#offramps-and-swaps-fund-the-wallet-first), [Solana](/v2/guides/chains/solana) and [Monad](/v2/guides/chains/monad) chain guides for per-chain setup.
## Swap flow
Either of these endpoints gives you the asset identifiers for a quote:
* [GET /supported\_assets](/v2/api-reference/assets/list-supported-assets) requires auth. Pass `paymentAccountId` with a wallet's id to list the tokens on that wallet's chain.
* [GET /lookup/stablebonds](/v2/api-reference/lookup/list-stablebonds) is public. It returns every stablebond on every chain, with pricing and supply data.
Use a token's `asset` from the first, or the `identifier` from the second, as `sourceAsset` and `destinationAsset`.
**Swap rules:**
* At least one side must be a stablebond. The other side can be a stablecoin such as USDC, so `USDC → CETES` and `CETES → USDC` both work.
* Both assets must be stablebonds or supported stablecoins. Non-stable tokens (SOL, XLM) return `400` `invalid-request`.
* Stablecoin-to-stablecoin swaps (`USDC → USDT`) return `400` `invalid-request`.
* The two assets must differ.
**Asset identifier format differs by chain:**
* **Solana:** Base58 mint address (for example `AvvetPGuuB5FD5m86fpw3LtDKyQoUFT1mG9WarNQLW4q`)
* **Stellar:** `CODE:ISSUER` (for example `CETES:GC3CW7...`)
* **Monad:** the raw token contract address only (for example `0xcC77c598d42f2f78Beb42C91d12B9d4041a5cE29`)
**USDC identifiers:**
* **Stellar sandbox:** `USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5`
* **Stellar production:** `USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN`
* **Solana sandbox:** `4D4r1KuS9WxbPCzzAJnWtYUJSSzDEZzrF1CbwajWGVjg`
* **Solana production:** `EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v`
Register the wallet that sends the source asset. To deliver to a different wallet, register that one too. See [Register payment account](/v2/api-reference/payment-accounts/register-payment-account) for the full schema.
```bash Sandbox theme={null}
curl -X POST https://api.sand.etherfuse.com/organization/$ORGANIZATION_ID/payment_account \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{
"kind": "wallet",
"label": "Treasury wallet",
"accountDetails": {
"kind": "address",
"address": "GDUKMGUGD3V6VXTU2RLAUM7A2FABLMHCPWTMDHKP7HHJ6FCZKEY4PVWL",
"chain": "stellar"
}
}'
```
The source wallet needs `source` ready on its chain rail, and the destination wallet needs `destination` ready. Read the account again with [GET /organization/\{organizationId}/payment\_account/\{id}](/v2/api-reference/payment-accounts/get-payment-account) to check.
Quote between the two wallet accounts. Both accounts must belong to the organization in the route. Quotes expire **2 minutes** after they are created (`expiresAt`). The response includes `rateBeforeFees`, `rateAfterFees` and `midMarketRate`. See [Create quote](/v2/api-reference/quotes/create-quote) for the full schema.
```bash Sandbox theme={null}
curl -X POST https://api.sand.etherfuse.com/organization/$ORGANIZATION_ID/quote \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{
"source": { "type": "stored", "id": "" },
"destination": { "type": "stored", "id": "" },
"sourceAmount": "10",
"sourceAsset": "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
"destinationAsset": "CETES:GC3CW7EDYRTWQ635VDIGY6S4ZUF5L6TQ7AA4MWS7LEQDBLUSZXV7UPS4"
}'
```
```bash Production theme={null}
curl -X POST https://api.etherfuse.com/organization/$ORGANIZATION_ID/quote \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{
"source": { "type": "stored", "id": "" },
"destination": { "type": "stored", "id": "" },
"sourceAmount": "10",
"sourceAsset": "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
"destinationAsset": "CETES:GC3CW7EDYRTWQ635VDIGY6S4ZUF5L6TQ7AA4MWS7LEQDBLUSZXV7UPS4"
}'
```
**Partner fees:** We apply your organization's partner fee automatically. To set it for one quote, add `"partnerFeeBpsOverride": 100`. We cap values above 500. See [Partner fees](/v2/fees#partner-fees).
Send the `quoteId`. The response is `201` with the full order, and the order's `id` equals the `quoteId`. See [Create order](/v2/api-reference/orders/create-order) for the full schema.
```bash Sandbox theme={null}
curl -X POST https://api.sand.etherfuse.com/organization/$ORGANIZATION_ID/order \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{ "quoteId": "" }'
```
As on an offramp, `fundingInstructions.rail` is the chain, and `fundingInstructions.destination` gives the `address` to send to, a `memo` when one is needed, and a `preparedTransaction` when we built one.
The swap does not start until the customer's wallet sends the source asset. Fund the order before `fundingDeadline` in one of two ways:
1. **Sign the prepared transaction.** When `preparedTransaction` is present, sign `transaction` with the customer's wallet and submit it to the network.
2. **Send a transfer yourself.** Send the order's `source.amount` of `source.asset` to `address`, with `memo` when it is present.
**Prepared transactions go stale.** A Solana or Stellar transaction goes stale after `refreshAfter`. A Stellar or EVM transaction also goes stale when the wallet sends another transaction first. In either case, call [POST /organization/\{organizationId}/order/\{id}/funding\_instructions/refresh](/v2/api-reference/orders/refresh-funding-instructions). The new funding instructions arrive a moment later in an `orderUpdated` webhook and on GET.
We receive the source asset, run the swap on the chain's venue, and deliver the result to the destination wallet. The status moves `created` → `funded` → `completed`. Read the order with [Get order](/v2/api-reference/orders/get-order), or subscribe to [`orderUpdated`](/v2/api-reference/webhook-events/orderupdated) with [POST /webhook\_subscription](/v2/api-reference/webhook-subscriptions/create-webhook-subscription).
```bash Sandbox theme={null}
curl -H "Authorization: " \
https://api.sand.etherfuse.com/organization/$ORGANIZATION_ID/order/
```
Swaps are orders, so [List orders](/v2/api-reference/orders/list-orders) returns them with your onramps and offramps.
**Stellar:** USDC to CETES:
```json theme={null}
{
"source": { "type": "stored", "id": "" },
"destination": { "type": "stored", "id": "" },
"sourceAmount": "10",
"sourceAsset": "USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
"destinationAsset": "CETES:GC3CW7EDYRTWQ635VDIGY6S4ZUF5L6TQ7AA4MWS7LEQDBLUSZXV7UPS4"
}
```
**Solana:** USDC to CETES:
```json theme={null}
{
"source": { "type": "stored", "id": "" },
"destination": { "type": "stored", "id": "" },
"sourceAmount": "10",
"sourceAsset": "4D4r1KuS9WxbPCzzAJnWtYUJSSzDEZzrF1CbwajWGVjg",
"destinationAsset": "AvvetPGuuB5FD5m86fpw3LtDKyQoUFT1mG9WarNQLW4q"
}
```
| Error | Cause | Fix |
| - | - | - |
| `400` `invalid-request`, no stablebond | Neither side is a stablebond (for example a stablecoin-to-stablecoin swap) | Make one side a stablebond. Use identifiers from `/lookup/stablebonds` |
| `400` `invalid-request`, non-stable asset | A side is a non-stable token (SOL, XLM, ETH) | Use a stablebond or a supported stablecoin on both sides |
| `400` `invalid-request`, cross-chain | The two wallets are on different chains and hold different tokens | Swap on one chain, or send the same token across chains |
| `400` `invalid-request`, same asset | `sourceAsset` equals `destinationAsset` | Pick two different assets |
| `400` `quote-expired` | The quote is older than 2 minutes, or does not exist | Create a new quote |
| `409` `payment-account-not-ready` | A wallet is not `ready` in the needed direction | Check the wallet's `rails` |
| `422` `quote-unavailable` | We cannot quote this path right now, for example thin sandbox liquidity, or the chain has no swap venue | Retry with backoff. Swaps run only on Stellar, Solana and Monad |
See [Errors](/v2/errors) for every problem type.
# User Launch Flows
Source: https://docs.etherfuse.com/v2/guides/user-launch-flows
Every in-app flow you can launch a user into: the scope and target each one uses, and what to know about each.
Some Etherfuse flows run **inside the app**, not over the API: the user completes them in their browser, in a real authenticated session. You send a user into one with a **launch**: sign a short-lived [user JWT](/v2/guides/jwt-authentication#sign-a-user-jwt) carrying the flow's **`scope`**, then have the user's browser hand it to `/auth/launch` with the flow's **`target`**.
The launch *mechanism* is identical for every flow and is documented once in [JWT User Authentication](/v2/guides/jwt-authentication#launch-the-user-into-the-app). Only the `scope` and `target` change between flows; the full list is below.
## Scopes
The **`scope`** claim is required on every partner JWT and names what the user may do. It is strictly enforced: the claim cannot be omitted, and any value other than a scope listed below is rejected with [`invalid_scope`](/v2/guides/authentication-errors#invalid_scope). Ask only for the scope a user needs.
## Targets
The **`target`** is the app path the launch lands the user on. It must be one of the paths listed below, optionally with a query string (for example `?org=`, used by Business KYB). Any other path, or an absolute URL, is rejected with [`invalid_target`](/v2/guides/authentication-errors#invalid-target), so a launch can't be redirected off-site.
## Flows
| Flow | `scope` | `target` | What the user does |
| - | - | - | - |
| [**Identity verification (KYC)**](#identity-verification) | `verification` | `/idv` | Completes KYC (identity document, liveness scan, and the required agreement signatures) in one hosted step. If you pushed data or documents over the API first ([KYC API](/v2/guides/kyc-api)) or imported a [shared session](/v2/guides/kyc-sumsub-shared-token), the customer is shown only what remains. Launch with `sub` = the customer's org id. Append `&lang=es` for Spanish. |
| [**Business KYB**](#business-kyb) | `kyb` | `/kyb` | Completes Know Your Business verification for a business: company details, registry documents, its owners' and representatives' identity checks, and the compliance questionnaires. |
A launch's JWT needs the matching `scope`, and the `target` must be the matching path.
### Identity verification
`scope: verification` · `target: /idv`
This is how a customer completes **KYC**. Launch them into `/idv` with **`sub` = the customer's org id** (the `id` you used when creating their `personal` organization). In one hosted session the customer submits a government-issued ID, completes a liveness face scan, and signs the required agreements. Etherfuse reviews the result and approves; you're notified through a [`kycUpdated`](/v2/api-reference/webhooks/kycupdated) [webhook subscription](/v2/webhooks). See [KYC WebSDK](/v2/guides/kyc-websdk) for the full walkthrough.
Append **`&lang=es`** to render the entire flow in Spanish (all UI text, scan prompts, error messages, and status screens). Omit the parameter or pass `lang=en` for English. Example: `/idv?lang=es`.
**Camera access.** The face scan needs the camera. If you embed the launch in an **iframe**, the iframe element must delegate camera access with a wildcard origin: `allow="camera *; microphone *"`. The bare form (`allow="camera"`) only grants the camera to the iframe's own `src` origin, but the scan SDK uses it from a nested frame, so without the `*` the browser blocks the camera and the scan never starts. A top-level tab or popup needs nothing extra.
### Business KYB
`scope: kyb` · `target: /kyb`
Use to send a user into Know Your Business verification. Append `?org=` to the target to choose which organization to verify; without it, the app uses the last organization that person selected, falling back to their first, and prompts them to create a business if they belong to none. See [KYB WebSDK](/v2/guides/kyb-websdk) for the full walkthrough.
The launch lands the user in a guided flow embedded in the app, which collects the company's details and registry documents, the identity of the people who own and represent it, and Etherfuse's compliance questionnaires. Read the business's verification status with [`GET /organization/{organizationId}/kyb`](/v2/api-reference/kyb/get-kyb-status).
**Camera access.** The owner and representative identity checks need the camera, so an **iframe** embed carries the same requirement as `/idv`: `allow="camera *; microphone *"`, with the wildcard. See the warning under [Identity verification](#identity-verification) for why the bare form is not enough.
# Verifying Webhooks
Source: https://docs.etherfuse.com/v2/guides/verifying-webhooks
How to verify webhook signatures to ensure payloads are authentic
Every webhook delivery includes an `X-Signature` header so you can confirm it came from Etherfuse and nobody changed it.
## How it works
When you [create a webhook subscription](/v2/api-reference/webhook-subscriptions/create-webhook-subscription), the response includes a `secret`: a 32-byte HMAC key encoded as URL-safe base64. **Store it securely; we return it only once.**
We sign each delivery with that secret:
1. We **canonicalize** the JSON body with [RFC 8785 JCS](https://datatracker.ietf.org/doc/html/rfc8785): sorted keys, no extra whitespace.
2. We compute HMAC-SHA256 over the canonicalized string, keyed with your secret decoded from URL-safe base64.
3. We send the result as `X-Signature: sha256={hex}`, with the digest in lowercase hex.
The secret uses the **URL-safe** base64 alphabet (RFC 4648 section 5: `-` and `_` in place of `+` and `/`), with `=` padding. Decode it with a URL-safe decoder. A standard base64 decoder rejects secrets that contain `-` or `_`, or decodes them to the wrong key, and your signatures stop matching.
## Verifying the signature
1. **Parse** the received body as JSON and **canonicalize** it.
2. **Decode** your secret from URL-safe base64.
3. **Compute** HMAC-SHA256 over the canonicalized string.
4. **Compare** `sha256={hex_result}` to the `X-Signature` header with a constant-time comparison.
We compute the signature over the **canonicalized** JSON, not the raw request body. Canonicalize before you compare, or the signature does not match.
### Rust
```rust theme={null}
use base64::{engine::general_purpose::URL_SAFE, Engine};
use hmac::{Hmac, Mac};
use serde_json::Value;
use sha2::Sha256;
use subtle::ConstantTimeEq;
type HmacSha256 = Hmac;
fn verify_webhook(body: &Value, secret: &str, signature_header: &str) -> bool {
// serde_jcs canonicalizes per RFC 8785 (cargo add serde_jcs)
let canonicalized = match serde_jcs::to_string(body) {
Ok(s) => s,
Err(_) => return false,
};
let key = match URL_SAFE.decode(secret) {
Ok(k) => k,
Err(_) => return false,
};
let mut mac = HmacSha256::new_from_slice(&key).expect("HMAC accepts any key length");
mac.update(canonicalized.as_bytes());
let digest = hex::encode(mac.finalize().into_bytes());
let expected = format!("sha256={digest}");
expected.as_bytes().ct_eq(signature_header.as_bytes()).into()
}
```
### Node.js
```javascript theme={null}
import { createHmac, timingSafeEqual } from "crypto";
import canonicalize from "canonicalize"; // npm install canonicalize
function verifyWebhook(body, secret, signatureHeader) {
const canonicalized = canonicalize(body);
const key = Buffer.from(secret, "base64url");
const hmac = createHmac("sha256", key).update(canonicalized).digest("hex");
const expected = `sha256=${hmac}`;
if (typeof signatureHeader !== "string" || expected.length !== signatureHeader.length) {
return false;
}
return timingSafeEqual(Buffer.from(expected), Buffer.from(signatureHeader));
}
// Usage in an Express handler with express.json():
app.post("/webhook", (req, res) => {
const signature = req.headers["x-signature"];
if (!verifyWebhook(req.body, process.env.WEBHOOK_SECRET, signature)) {
return res.status(401).send("Invalid signature");
}
res.sendStatus(200);
// Process the event after responding.
});
```
### Python
```python theme={null}
import base64, hashlib, hmac
import rfc8785 # pip install rfc8785
def verify_webhook(body: dict, secret: str, signature_header: str) -> bool:
canonicalized = rfc8785.dumps(body)
key = base64.urlsafe_b64decode(secret)
digest = hmac.new(key, canonicalized, hashlib.sha256).hexdigest()
expected = f"sha256={digest}"
return hmac.compare_digest(expected, signature_header)
```
Use a library that implements RFC 8785 (JCS). A plain `json.dumps(body, sort_keys=True, separators=(",", ":"))` is close but does not follow every JCS rule.
## Delivery and retries
* Return any `2xx` to acknowledge a delivery.
* We make up to **3 attempts** per delivery, **5 seconds** apart. We try again after a connection error, a timeout, a `5xx`, a `408`, a `429`, or any other non-`2xx` status not in the next item.
* We stop after the first response when your endpoint returns `400`, `401`, `403`, `404`, `405`, `410` or `422`. A handler that returns `401` for a bad signature gets no retry.
* We do not send an abandoned delivery later. Read the object with its `GET` endpoint to recover the current state.
See [Webhooks](/v2/webhooks) for the events and their payloads.
# Idempotency
Source: https://docs.etherfuse.com/v2/idempotency
Which create requests are safe to retry, and what we return when you retry them.
A network timeout leaves you unsure whether a create request landed. Each create request below has a rule for what happens when you send it again, so you can retry without creating a duplicate.
We do not read an `Idempotency-Key` header. Sending one has no effect.
## Orders: keyed on quoteId
[`POST /organization/{organizationId}/order`](/v2/api-reference/orders/create-order) creates at most one order per quote. The order's `id` is the `quoteId`.
| Request | Response |
| - | - |
| First request for a `quoteId` | `201` with the new order. |
| Same `quoteId` again, after the order exists | `200` with the existing order, unchanged. We ignore a different `memo`. |
| Same `quoteId` again, after the quote expired | `200` with the existing order. We check for the order before we check the quote. |
| Two requests for the same `quoteId` at the same moment | One gets `201`. The other gets `409` `conflict`. |
| A `quoteId` that has no order and has expired | `400` `quote-expired`. |
So a timed-out request is safe to retry with the same `quoteId`:
1. Keep the `quoteId` from the quote response.
2. Retry `POST /organization/{organizationId}/order` with the same organization id and `quoteId`.
3. Treat `201` and `200` as success.
4. On `409` `conflict`, read the order with [`GET /organization/{organizationId}/order/{id}`](/v2/api-reference/orders/get-order), using the `quoteId` as the id.
```js theme={null}
async function createOrder(organizationId, quoteId) {
const base = `https://api.sand.etherfuse.com/organization/${organizationId}`;
const res = await fetch(`${base}/order`, {
method: "POST",
headers: { Authorization: apiKey, "Content-Type": "application/json" },
body: JSON.stringify({ quoteId }),
});
if (res.status === 201 || res.status === 200) return res.json();
const problem = await res.json();
if (problem.type === "https://docs.etherfuse.com/ramp/v2/errors/conflict") {
return fetch(`${base}/order/${quoteId}`, {
headers: { Authorization: apiKey },
}).then((r) => r.json());
}
throw new Error(`Create failed: ${problem.type}`);
}
```
## Quotes
[`POST /organization/{organizationId}/quote`](/v2/api-reference/quotes/create-quote) creates a new quote with a new `quoteId` on every call. Retrying is safe: a quote does nothing until you create an order from it, and it expires after 2 minutes.
## Payment accounts
[`POST /organization/{organizationId}/payment_account`](/v2/api-reference/payment-accounts/register-payment-account) returns the account that organization already holds, with `200`, when you register the same account again. We do not change the stored account, except that we retry a `rejected` direction with the `registrationInfo` you send, and it goes back to `pending`. If another organization holds the account, you get `409` `payment-account-unavailable`.
## Webhook subscriptions
[`POST /webhook_subscription`](/v2/api-reference/webhook-subscriptions/create-webhook-subscription) allows one subscription per URL and event. A second request for the same URL and event returns `409` `conflict`. We return the signing `secret` only on the first `201`, so a retry that gets `409` does not see it. If you lost the secret, delete the subscription and create it again.
## Customer organizations: keyed on the id you send
[`POST /organization`](/v2/api-reference/organizations/create-organization) accepts an `id` you generate. If the `id` is already in use, the request returns `409` `conflict`.
1. Generate the UUID **once** and store it before the first attempt.
2. Send the same UUID on every retry.
3. Treat `409` `conflict` as "already created" and read it with [`GET /organization/{organizationId}`](/v2/api-reference/organizations/get-organization).
Generate UUIDs with a standard v4 generator (`crypto.randomUUID()` in Node, a `uuid` library elsewhere).
# Introduction
Source: https://docs.etherfuse.com/v2/index
On/off-ramps and swaps: buy and sell stablecoins and yield-bearing stablebonds with fiat, or swap one asset for another.
**v2 is in beta.** Endpoints and request and response shapes can still change. We announce every change in the [changelog](/changelog) before it ships. The [v1 docs](/introduction) stay available from the version menu.
**Build against the sandbox first.** Every example in these docs works against `https://api.sand.etherfuse.com` with no real money or personal data. Start with the [Quickstart](/v2/quickstart).
## Get started
Register payment accounts, quote, create an order and fund it in sandbox.
Create a sandbox account, generate API keys, and learn the base URLs.
Run KYC with a single hosted launch (`/idv`).
Browse all endpoints with live "Try it" requests and code samples.
## What you can build
Customer pays fiat from a bank account, receives tokens in a wallet.
Customer sends tokens from a wallet, receives fiat in a bank account.
Customer exchanges one token for another on the same chain.
Customer sends a token from a wallet on one supported chain to a wallet on another.
## Core concepts & reference
Payment accounts, rails, quotes, orders and their statuses.
API-key auth, sandbox vs. production keys, and the one header gotcha.
Every problem type the API returns, with causes and fixes.
How to page through payment accounts, orders and subscriptions.
Retry order creation with the same `quoteId` and get one order.
Signed event delivery for orders, payment accounts and KYC.
TypeScript and Rust libraries for the on-chain Stablebond side.
## Build with AI & tools
Connect Claude, ChatGPT, or any MCP client and drive the Etherfuse API in natural language.
A machine-readable index of these docs for LLMs and AI assistants.
Download `openapi-v2.json` to import into Postman, Insomnia, or generate a client.
# Initial Setup
Source: https://docs.etherfuse.com/v2/initial-setup
Create a sandbox business account, get an API key, and make your first call
**Start in the sandbox.** It runs on testnets (Stellar Testnet, Solana Devnet, testnet for every other supported chain), so you can test the full onramp, offramp, and swap flows with fake data and no real money.
## 1. Sign up
Go to [sandbox.etherfuse.com](https://sandbox.etherfuse.com) and sign up with your email and password.
## 2. Create a business account
Sign-up gives you a **personal** account, and personal accounts cannot have API keys.
On [Manage Accounts](https://sandbox.etherfuse.com/account/manage) (sidebar: "Account" then "Manage Accounts"), click **Add account**, create a **business** organization, and select it.
## 3. Approve KYB
Creating the business account lands you on [KYB Compliance](https://sandbox.etherfuse.com/account/kyb). You must complete KYB to get API keys.
In sandbox, skip the documents and questionnaires: click **Approve KYB** in the "Ready to approve?" panel at the bottom of that page. It approves the organization without admin review.
## 4. Create an API key
Back on [Manage Accounts](https://sandbox.etherfuse.com/account/manage), create a sandbox API key. Any admin on the business account can create one.
If the page still prompts for KYB instead of showing keys, you are viewing the personal account. Switch to the business account.
## 5. Call the API
Sandbox base URL:
```
https://api.sand.etherfuse.com
```
Pass the key in the `Authorization` header of every request:
```
Authorization: your-api-key-here
```
**Do not use a `Bearer` prefix.** The header value is the API key by itself.
Check the key with a first call. [`GET /me`](/v2/api-reference/organizations/get-me) returns your organization, and its `id` is the `{organizationId}` you use in payment account, quote and order routes:
```bash theme={null}
curl https://api.sand.etherfuse.com/me \
-H "Authorization: your-api-key-here"
```
Endpoints and authentication are identical to production; only the base URL changes. Continue with the [Quickstart](/v2/quickstart) to register payment accounts, quote, and create your first order.
## Going to Production
* **Account:** [app.etherfuse.com](https://app.etherfuse.com/account/ramping)
* **KYB:** [app.etherfuse.com/account/kyb](https://app.etherfuse.com/account/kyb)
* **API keys:** [app.etherfuse.com/account/manage](https://app.etherfuse.com/account/manage)
* **Base URL:** `https://api.etherfuse.com`
Production keys require real KYB. Complete your organization's verification at [app.etherfuse.com/account/kyb](https://app.etherfuse.com/account/kyb).
For sandbox testing details (testnet tokens, test KYC data, simulated deposits), see the [Sandbox Reference](/v2/test-environment).
# MCP Server
Source: https://docs.etherfuse.com/v2/mcp-overview
Connect your preferred LLM to the Etherfuse API using our MCP server
The Etherfuse API docs are available as a [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server. This lets you connect Claude, ChatGPT, or any MCP-compatible client and ask about the API in natural language.
## Getting Started
Add the Etherfuse MCP server to your client using the following URL:
```
https://docs.etherfuse.com/mcp
```
**MCP is an open standard** that allows AI assistants to connect to external tools and data sources. If your LLM client supports MCP, you can use it with the Etherfuse API.
## What You Can Do
The server searches and reads these docs, including the v2 API reference and the endpoints it shares with the rest of the API. It does not call the API with your API key. Once connected, your assistant can answer questions and write requests for tasks such as:
* Registering bank accounts and wallets as payment accounts, and checking their rails
* Creating quotes and orders for onramps, offramps, and swaps
* Reading order status and funding instructions
* Creating customers and running KYC and KYB
* Creating webhook subscriptions and verifying deliveries
The server can also send feedback to the docs team when a page is wrong or unclear.
# V1 → V2
Source: https://docs.etherfuse.com/v2/migration-guide
Why to switch to v2, and how to migrate from v1: endpoint replacements, request fields, order statuses and webhooks.
v2 combines bank accounts and wallets into payment accounts, and uses the same quote and order endpoints for onramps, offramps and swaps. It also has its own versions of the organization, member, KYC, KYB, asset and statement endpoints, so a v2 integration uses one set of conventions throughout. Every v2 endpoint sits at the root of the API host, with no `/ramp` prefix.
v1 remains available during migration. Your API key works with both versions, so you can migrate one flow at a time.
## Why switch to v2
### Available now
* **Lower fees.** v1 charges 20 bps on a same-currency onramp or offramp, such as MXN to CETES. In v2, entering a stablebond in its own currency is free, and leaving one costs 5 bps. The volume-based FX fee applies only when you convert between currencies. Every quote and order itemizes its fees, partner fee included. See [Transaction Fees](/v2/fees).
* **One way to move money.** Quote any asset to any other asset between two payment accounts. Onramps, offramps, swaps and cross-chain transfers all use the same quote and order endpoints, and if we support the route, the quote works. There is no separate swap API.
* **Cross-chain transfers.** Send a token from a wallet on one supported chain to a wallet on another.
* **Payment accounts.** Register a bank account or wallet once and use it for any transfer. Each account lists the rails it can use (SPEI, PIX, Stellar, Solana, Base, Polygon, Monad and Avalanche), whether each rail can send or receive, and the amount limits we know of. One EVM address covers every EVM chain we support. You pick the rail per quote, or use the account's default.
* **Faster delivery.** v2 orders run on our new execution engine, which starts each step as soon as the previous one confirms.
* **Clear funding.** Each order carries its own funding instructions: where to pay, our reference for the payment, and a funding deadline. Ask for fresh instructions when a prepared transaction goes stale.
* **Safe retries.** Creating an order with a `quoteId` you already used returns the existing order instead of making a second one.
* **Complete webhooks.** Each event carries the full order, payment account, or KYC or KYB status, and one subscription covers your organization and every customer organization.
* **Structured errors.** Every error uses RFC 9457 problem details with a stable `type` to handle in code.
* **One set of conventions.** Organizations, members, KYC, KYB, assets and statements all use camelCase, `organizationId` in the path and the same paging, so one client handles every endpoint.
* **Sandbox deposits.** Simulate the fiat deposit to test an onramp without sending money.
### Coming to v2
These ship in v2 only:
* **More countries.** New fiat rails beyond Mexico, starting with Brazil (BRL) over PIX.
* **Third-party payments.** Pay out to, or receive from, an account that belongs to someone other than your customer.
* **Private accounts.** On some fiat providers, a customer can request a bank account in a country where they do not have one, after extra KYC or KYB steps.
We announce each one in the [changelog](/changelog) when it is available.
## What stays the same
Keep using your existing API key, organizations, customers and KYC approvals. Your organization ids carry over: a v1 `customerId` is the v2 `organizationId`. Your customers do not need to verify again.
These endpoints keep their paths and behavior:
* Authentication: [`POST /auth/token`](/v2/api-reference/authentication/exchange-a-partner-jwt) and `/auth/launch`
* The public `/lookup` endpoints
## 1. Read the v2 conventions
v2 uses camelCase keys and camelCase enum values (`debitCard`, `orderUpdated`), decimal strings for amounts, and `pageSize` and `pageNumber` query parameters for lists. See [Conventions](/v2/overview#conventions).
## 2. Put the organization in the path
Routes that act for one organization start with `/organization/{organizationId}`. Use the organization ID previously sent as `customerId` in v1. For your own organization, get the ID from [`GET /me`](/v2/api-reference/organizations/get-me); for a customer, use that customer's organization ID. See [Organizations in the route](/v2/overview#organizations-in-the-route).
In the tables below, `...` abbreviates `/organization/{organizationId}`.
## 3. Use payment accounts
Etherfuse will migrate your existing v1 bank accounts and wallets to v2 payment accounts. Do not re-register them.
Use the v2 registration endpoint for new bank accounts and wallets:
| v1 | v2 |
| - | - |
| `POST /ramp/bank-account`, `POST /ramp/customer/{customer_id}/bank-account` | [`POST /organization/{organizationId}/payment_account`](/v2/api-reference/payment-accounts/register-payment-account) with `kind: "bank"` |
| `POST /ramp/wallet`, `POST /ramp/customer/{customer_id}/wallet` | The same endpoint with `kind: "wallet"` |
| `GET /ramp/bank-account/{id}`, `GET /ramp/wallet/{id}` | [`GET .../payment_account/{id}`](/v2/api-reference/payment-accounts/get-payment-account) |
| `GET`/`POST /ramp/bank-accounts`, `GET`/`POST /ramp/wallets` and the customer variants | [`GET .../payment_accounts`](/v2/api-reference/payment-accounts/list-payment-accounts) |
| `DELETE /ramp/wallet/{wallet_id}` | No equivalent. v2 has no endpoint to delete a payment account. |
| `bank_account_updated` webhook | `paymentAccountUpdated` webhook |
A new account starts as `pending`. Before quoting, check that the required rail direction (`source` or `destination`) is `ready`. Subscribe to `paymentAccountUpdated` for status changes.
## 4. Move organizations, KYC, assets and statements
These endpoints work the same way in v2, with v2 conventions: camelCase enum values (`notStarted`, `personalData`), `organizationId` in place of `customerId`, problem details errors, and `{ items }` for lists.
| v1 | v2 |
| - | - |
| `GET /ramp`, `GET /ramp/me` | [`GET /me`](/v2/api-reference/organizations/get-me) |
| `PUT /ramp/partner-fee` | [`PATCH /me`](/v2/api-reference/organizations/update-me) with `partnerFeeDefaultBps` |
| `GET`/`POST /ramp/customers` | [`GET /organizations`](/v2/api-reference/organizations/list-organizations), with `pageSize` and `pageNumber` in the query. There is no date filter. |
| `GET /ramp/customer/{customer_id}` | [`GET /organization/{organizationId}`](/v2/api-reference/organizations/get-organization) |
| `POST /ramp/organization` | [`POST /organization`](/v2/api-reference/organizations/create-organization). The same fields, except `wallets`: register wallets as payment accounts. |
| `PUT /ramp/organization/{org_id}/name` | [`PATCH /organization/{organizationId}`](/v2/api-reference/organizations/update-organization) with `displayName` |
| `POST /ramp/organization/{id}/member` | [`POST .../member`](/v2/api-reference/members/add-organization-member), with `memberId` in place of `customerId` |
| `GET /ramp/organization/{id}/member` | [`GET .../members`](/v2/api-reference/members/list-organization-members) |
| `DELETE /ramp/organization/{id}/member/{customer_id}` | [`DELETE .../member/{memberId}`](/v2/api-reference/members/remove-organization-member) |
| `DELETE /ramp/organization/{id}/member/invite/{email}` | [`DELETE .../member_invite/{email}`](/v2/api-reference/members/revoke-organization-invite) |
| `GET /ramp/customer/{customer_id}/organization` | [`GET /member/{memberId}/organizations`](/v2/api-reference/members/list-member-organizations) |
| `GET /ramp/customer/{customer_id}/kyc` | [`GET .../kyc`](/v2/api-reference/kyc/get-kyc-status) |
| `POST /ramp/customer/{customer_id}/verification` and its `/documents`, `/questionnaire` and `/import` | [`POST .../verification`](/v2/api-reference/kyc/submit-verification-data) and the same sub-paths |
| `GET /ramp/verification/occupations` | [`GET /verification/occupations`](/v2/api-reference/kyc/list-occupation-categories) |
| `GET /ramp/organization/{id}/kyb/status` | [`GET .../kyb`](/v2/api-reference/kyb/get-kyb-status) |
| `GET /ramp/assets` | [`GET /supported_assets`](/v2/api-reference/assets/list-supported-assets). It lists markets as well as tokens, and filters by `chain`, `currency`, `paymentAccountId` or `organizationId`. It returns no balances. |
| `GET /ramp/balances` | [`GET .../payment_account/{id}/balances`](/v2/api-reference/assets/get-payment-account-balances) |
| `GET /ramp/partner-statements` and its `/{year}-{month}/csv` and `/pdf` | [`GET /partner_statements`](/v2/api-reference/statements/list-partner-statements), [`GET /partner_statement/{year}-{month}/csv`](/v2/api-reference/statements/download-statement-csv) and [`/pdf`](/v2/api-reference/statements/download-statement-pdf) |
What changes in the responses:
* **Organizations** come back as one shape: `id`, `displayName`, `accountType`, `approvedAt`, `createdAt` and `updatedAt`.
* **KYC and KYB** `status` uses the webhook values: `notStarted`, `inProgress`, `submitted`, `approved`, `denied`. Requirement `type` and `status` are camelCase too (`identityDocument`, `awaitingReview`). The KYC response drops `walletPublicKey` and `onChainMarked`.
* **Members** are named by `memberId`: the person's JWT `sub`, which v1 called `customerId` on members. Members of your own organization are managed in the Etherfuse portal; the v2 member endpoints return `403` for it. They work for your customer business organizations.
## 5. Quote with two payment accounts
[`POST /organization/{organizationId}/quote`](/v2/api-reference/quotes/create-quote) replaces `POST /ramp/quote`:
| v1 request field | v2 replacement |
| - | - |
| `quoteId` | Omit from the request. Use the API-generated `quoteId` from the response. |
| `customerId` | Pass as `organizationId` in the path. |
| `blockchain`, `walletAddress` | Set `source` and `destination` to payment account references: `{ "type": "stored", "id": "" }`. Both accounts must belong to the organization in the path. |
| `quoteAssets` | Move `quoteAssets.sourceAsset` to `sourceAsset` and `quoteAssets.targetAsset` to `destinationAsset`. Omit `quoteAssets.type`. Both take an `asset` from [`GET /supported_assets`](/v2/api-reference/assets/list-supported-assets). |
| `sourceAmount` | Unchanged: a positive decimal string. |
| `partnerFeeBps` | `partnerFeeBpsOverride`. Your default partner fee still applies when you leave it out. |
Set `sourceRail` and `destinationRail` to choose rails, or omit them to use the accounts' defaults. A bank-to-wallet transfer is an onramp, a wallet-to-bank transfer is an offramp, a transfer between wallets on the same chain is a swap, and a transfer of the same token between wallets on different chains is a cross-chain transfer.
The response itemizes fees in `fees`. Quotes expire after 2 minutes.
## 6. Create and follow orders
[`POST /organization/{organizationId}/order`](/v2/api-reference/orders/create-order) accepts `quoteId` and an optional `memo`. Remove the v1 fields `orderId`, `bankAccountId`, `cryptoWalletId`, `publicKey`, `direction`, `feePayer` and `useAnchor`. The order's `id` equals the `quoteId`. Reusing the same quote returns the existing order.
Use `fundingInstructions` in place of the v1 deposit fields and `burnTransaction`. Its `destination` contains a CLABE for SPEI, a BR Code for PIX, or a blockchain address and optional `preparedTransaction`. For embedded wallets, it references the order's `authorization` instead.
| v1 | v2 |
| - | - |
| `POST /ramp/swap` | A quote between two wallets on the same chain, then an order |
| `GET /ramp/order/{id}` | [`GET .../order/{id}`](/v2/api-reference/orders/get-order) |
| `GET`/`POST /ramp/orders`, `/ramp/customer/{customer_id}/orders` | [`GET /orders`](/v2/api-reference/orders/list-orders), covering your organization and every customer organization |
| `POST /ramp/order/{id}/cancel` | [`POST .../order/{id}/cancel`](/v2/api-reference/orders/cancel-order) |
| `POST /ramp/order/{id}/regenerate_tx`, `POST /ramp/swap/{id}/regenerate_tx` | [`POST .../order/{id}/funding_instructions/refresh`](/v2/api-reference/orders/refresh-funding-instructions) |
| `GET`/`POST /ramp/order/{id}/approvals` | The order's `authorization` and [`POST .../order/{id}/authorization`](/v2/api-reference/orders/submit-authorization) |
| `POST /ramp/evm/approve` | No equivalent. An EVM offramp needs no token approval: the customer sends the token to `fundingInstructions.destination.address`, or signs the `preparedTransaction`, and pays the gas. |
Order statuses:
| v1 | v2 |
| - | - |
| `created`, `funded`, `completed`, `failed`, `canceled`, `refunded` | Unchanged. |
| `finalized` | Use `completed`; v2 has no `finalized` status. |
| No equivalent | `claimable`: funds are waiting for the customer to claim. See `claimInstructions` and [`POST .../order/{id}/claim`](/v2/api-reference/orders/prepare-claim). |
## 7. Subscribe to v2 webhooks
Existing subscriptions continue sending v1 payloads. Create separate v2 subscriptions with [`POST /webhook_subscription`](/v2/api-reference/webhook-subscriptions/create-webhook-subscription), one per event type:
| v1 event | v2 event |
| - | - |
| `order_updated`, `swap_updated` | `orderUpdated` |
| `bank_account_updated` | `paymentAccountUpdated`, for bank accounts and wallets |
| `kyc_updated` | `kycUpdated` |
| `kyb_updated` | `kybUpdated` |
| `customer_updated` | No v2 event. Use `kycUpdated` and `kybUpdated`. |
Store the `secret` returned when you create the subscription. Decode v2 secrets with **URL-safe base64**, replacing the standard base64 decoding used for v1. See [Verifying webhooks](/v2/guides/verifying-webhooks).
Replace v1 WebSocket listeners with webhooks. Use `GET .../order/{id}` to check an order's current state.
## 8. Handle problem details
For v2 errors (`application/problem+json`), select your error handler using the `type` field. Use the HTTP status as a fallback for unknown types, and do not parse `detail`. See [Errors](/v2/errors). The `/auth` and `/lookup` endpoints keep their own error format.
## 9. Test in sandbox
Run each flow in sandbox before you switch production traffic. [`POST .../order/{id}/fiat_received`](/v2/api-reference/sandbox/simulate-fiat-received) simulates the SPEI or PIX deposit for an onramp. Follow the [Onramps](/v2/guides/testing-onramps), [Offramps](/v2/guides/testing-offramps) and [Swaps](/v2/guides/testing-swaps) guides.
# How it works
Source: https://docs.etherfuse.com/v2/overview
How an Etherfuse integration fits together: conventions, payment accounts, quotes, orders and webhooks.
The API moves money between fiat and crypto. Your customers can buy tokens with fiat (onramp), sell tokens for fiat (offramp), exchange one token for another on the same chain (swap), or send a token from one supported chain to another (cross-chain transfer). Every transfer runs between two **payment accounts**: a bank account or a wallet.
**Use the sandbox for development.** All examples in these guides work against `https://api.sand.etherfuse.com`. If you have not set up your sandbox account, see [Initial Setup](/v2/initial-setup).
v2 endpoints sit at the root of the API host, with no `/ramp` prefix. Send your API key in the `Authorization` header with no `Bearer` prefix. See [Authentication](/v2/authentication).
Payment accounts, quotes and orders belong to an organization, and their routes start with `/organization/{organizationId}`. Webhook subscriptions belong to the organization that owns the API key. See [Organizations in the route](#organizations-in-the-route).
## Conventions
| Topic | Rule |
| - | - |
| Keys | camelCase, for example `sourceAmount`. |
| Enum values | camelCase, for example `debitCard`, `pixKey`, `orderUpdated`. We reject other spellings, such as `debit_card`. |
| Amounts | Decimal strings, for example `"1000.50"`. Never JSON numbers. Basis-point fields such as `feeBps` are integers. |
| Timestamps | ISO 8601 in UTC, for example `2026-09-28T17:05:02Z`. |
| Unknown fields | Request bodies ignore fields we do not define, so we can add fields without breaking your integration. Two objects are stricter: `accountDetails` rejects fields its `kind` does not declare, and `registrationInfo` rejects names that are not registration fields. |
| Errors | [RFC 9457 problem details](/v2/errors) with content type `application/problem+json`. |
| Lists | `pageSize` and `pageNumber` query parameters and one page envelope. See [Pagination](/v2/pagination). |
### Organizations in the route
Payment account, quote and order routes take the organization the call acts for as `{organizationId}`, for example `POST /organization/{organizationId}/payment_account`.
* Use your own organization's id, from [`GET /me`](/v2/api-reference/organizations/get-me), to act for yourself.
* Use a customer organization's id to act for that customer. You get it when you create the organization with [`POST /organization`](/v2/api-reference/organizations/create-organization), or from [`GET /organizations`](/v2/api-reference/organizations/list-organizations).
* Your API key can act for its own organization and for its direct customer organizations. Any other id returns `404` `not-found`.
Webhook subscriptions and [`GET /orders`](/v2/api-reference/orders/list-orders) stay at the partner level and do not take an organization id. One subscription covers your organization and every customer organization. `GET /orders` lists the orders of your organization and all its customer organizations.
## The flow
```mermaid theme={null}
flowchart LR
A["Register payment accounts
bank account, wallet"] --> B["Check each account's rails"]
B --> C["Create a quote
valid for 2 minutes"]
C --> D["Create the order
from the quoteId"]
D --> E["Customer funds the order
before fundingDeadline"]
E --> F["We deliver to the destination"]
D -. "orderUpdated" .-> W(["Your webhook endpoint"])
E -. "orderUpdated" .-> W
F -. "orderUpdated" .-> W
```
A payment account is a bank account or a wallet that money moves from or to. Register each one with [`POST /organization/{organizationId}/payment_account`](/v2/api-reference/payment-accounts/register-payment-account).
A bank account states its market with `country` and `currency`, and its identifier in `accountDetails`:
```json theme={null}
{
"kind": "bank",
"label": "Operating account",
"country": "MX",
"currency": "MXN",
"accountDetails": { "kind": "clabe", "clabe": "012180000000000002" }
}
```
A wallet sends only `accountDetails` of kind `address`, with its `chain`:
```json theme={null}
{
"kind": "wallet",
"label": "Treasury wallet",
"accountDetails": {
"kind": "address",
"address": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
"chain": "stellar"
}
}
```
The `accountDetails.kind` decides which fields `accountDetails` carries:
| `accountDetails.kind` | Fields |
| - | - |
| `clabe` | `clabe`: an 18-digit CLABE with a valid check digit. |
| `pixKey` | `pixKey`: 1 to 77 characters. |
| `address` | `address` and `chain` (`stellar`, `solana`, `base`, `polygon`, `monad` or `avalanche`). |
A missing field or an extra one returns `400` `validation-error`, with one entry per field in `errors`.
**Registration info.** A bank account route requires holder details such as `accountHolderName` and `taxId`, and some routes need more. We fill each field from the verified identity of the organization in the route when we hold it. To register a customer's bank account, pass the customer's organization id. When a required field is still missing, the request returns `422` `registration-info-required`:
```json theme={null}
{
"type": "https://docs.etherfuse.com/ramp/v2/errors/registration-info-required",
"title": "Registration information required",
"status": 422,
"detail": "required registration information was not supplied",
"instance": "tag:etherfuse.com,2026-08-28:R2-10022/0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d",
"required": ["#/registrationInfo/accountHolderName", "#/registrationInfo/taxId"],
"missing": ["#/registrationInfo/taxId"]
}
```
`required` lists every field the route needs and `missing` lists the ones you still have to send. Both are JSON Pointers into your request. Add the missing fields under `registrationInfo` and send the request again. A `taxId` you send must match the verified one, or you get `422` `holder-tax-id-mismatch`. You can also send every registration field you have: we check each value's format and ignore the fields this route does not use. To see the lists before you register, call [`GET /organization/{organizationId}/payment_account/requirements`](/v2/api-reference/payment-accounts/get-registration-requirements).
A new registration returns `201`. **Registering the same account again** returns the account the organization already holds, with `200`. If another organization holds the account, you get `409` `payment-account-unavailable`.
A new account starts `pending`. We register it with the payment provider, then move each rail direction to `ready` or `rejected`. The account's `status` becomes `ready` when any direction is ready, and `rejected` when every direction is rejected. To retry a `rejected` direction, fix the details (usually `registrationInfo`) and register the same account again. We send it to the provider again, and the direction goes back to `pending`.
The response lists the rails the account can move money on, keyed by rail. Each rail reports the account's readiness per direction: `source` for money leaving the account, `destination` for money arriving.
```json theme={null}
{
"id": "3f9a2c1e-5b7d-4e8f-9a01-2b3c4d5e6f70",
"status": "pending",
"label": "Operating account",
"rails": {
"spei": {
"currency": "MXN",
"source": { "isDefault": true, "status": "ready" },
"destination": { "isDefault": true, "status": "pending" }
}
},
"createdAt": "2026-09-28T17:04:11.482Z"
}
```
* A direction's `status` is `pending`, `ready`, `rejected` or `disabled`. You can quote a direction only when it is `ready`.
* A direction is `null` when the rail cannot carry money that way for this account.
* `minAmount` and `maxAmount` appear when the rail has a known limit.
* `isDefault` marks the rail a quote uses when you do not name one.
Subscribe to [`paymentAccountUpdated`](/v2/webhooks) to hear when a direction becomes `ready`, or read the account again with [`GET /organization/{organizationId}/payment_account/{id}`](/v2/api-reference/payment-accounts/get-payment-account).
Price a transfer between two registered accounts with [`POST /organization/{organizationId}/quote`](/v2/api-reference/quotes/create-quote). Both accounts must belong to the organization in the route. One side must be a wallet: fiat to crypto, crypto to fiat, a same-chain swap, or the same token sent to another chain.
```json theme={null}
{
"source": { "type": "stored", "id": "3f9a2c1e-5b7d-4e8f-9a01-2b3c4d5e6f70" },
"destination": { "type": "stored", "id": "8c1d2e3f-4a5b-4c6d-8e7f-901a2b3c4d5e" },
"sourceAmount": "1000",
"sourceAsset": "MXN",
"destinationAsset": "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"
}
```
Take each side's asset from [`GET /supported_assets`](/v2/api-reference/assets/list-supported-assets): a market's `asset` (its currency code) for a fiat side, a token's `asset` for a crypto side. Pass `paymentAccountId` to list only what one account can send or receive.
The quote returns `sourceAmount`, `destinationAmount`, the rails each side uses, `rateBeforeFees`, `rateAfterFees`, the total fee as `feeBps` and `feeAmount`, and the fee lines in `fees[]`. Each fee line has a `type` (`transactionFee`, `partnerFee`, `walletSponsor` or `bridge`), an `amount` and an `asset`. Quotes that convert between currencies also return `midMarketRate`. The order created from the quote returns the same fee lines.
A quote expires 2 minutes after it is created (`expiresAt`). Your organization must be approved to quote, or you get `409` `organization-not-approved`.
Create the order with [`POST /organization/{organizationId}/order`](/v2/api-reference/orders/create-order) and the `quoteId`. The response is `201` with the full order. The order's `id` is the `quoteId`.
Sending the same `quoteId` again returns the existing order with `200`, even after the quote has expired. Retry a timed-out request with the same `quoteId` and you never create a second order. See [Idempotency](/v2/idempotency).
`fundingInstructions` tells the customer how to fund the order: the `rail`, our `reference` for the order, and a `fundingDeadline`. An order with no funds by the `fundingDeadline` moves to `canceled`. You can also cancel an unfunded order yourself with [`POST /organization/{organizationId}/order/{id}/cancel`](/v2/api-reference/orders/cancel-order). `fundingInstructions.destination` holds where to send the funds (a CLABE on `spei`, a BR Code on `pix`, an address on a chain). It is absent until it is available. Subscribe to [`orderUpdated`](/v2/webhooks) to receive the order when it arrives, or read the order again with [`GET /organization/{organizationId}/order/{id}`](/v2/api-reference/orders/get-order).
If a funding destination expires before the customer pays, call [`POST /organization/{organizationId}/order/{id}/funding_instructions/refresh`](/v2/api-reference/orders/refresh-funding-instructions). To cancel an order before it is funded, call [`POST /organization/{organizationId}/order/{id}/cancel`](/v2/api-reference/orders/cancel-order).
An order's `status` is one of:
| Status | Meaning |
| - | - |
| `created` | Waiting for the source funds. |
| `funded` | Source funds received. |
| `claimable` | Delivered, waiting for the customer to claim the funds. `claimInstructions` holds what they need, and [`POST /organization/{organizationId}/order/{id}/claim`](/v2/api-reference/orders/prepare-claim) prepares the claim. |
| `completed` | Delivered to the destination. |
| `failed` | Could not be completed. `failure` holds a `code` and a `message`. |
| `canceled` | Canceled before completion, either by you or because the funding deadline passed. |
| `refunded` | The source funds were returned. |
```mermaid theme={null}
stateDiagram-v2
[*] --> created: order created
created --> funded: source funds received
created --> canceled: you cancel, or fundingDeadline passes
funded --> completed: delivered
funded --> claimable: delivered, needs a claim
claimable --> completed: customer claims
funded --> failed: could not be completed
failed --> refunded: source funds returned
completed --> [*]
canceled --> [*]
failed --> [*]
refunded --> [*]
```
`orderUpdated` delivers the full order on each status change. See [Webhooks](/v2/webhooks).
**Authorization.** Some transfers need the account holder's approval before money moves, for example an offramp from an [embedded wallet](/v2/guides/embedded-wallets) or the claim into one. The order's `authorization` then holds what to sign, and the status does not change while it waits. Sign `authorization.method.message` and send it with [`POST /organization/{organizationId}/order/{id}/authorization`](/v2/api-reference/orders/submit-authorization). `authorization` is null when nothing is needed.
## Quote types
The two payment accounts decide the type of transfer. You do not send a type. Quote any asset to any other asset between two payment accounts: if we support the route, the quote returns a price, and if we do not, it returns an error that says why.
| Source | Destination | Transfer |
| - | - | - |
| Bank account | Wallet | Onramp: the customer buys tokens with fiat. |
| Wallet | Bank account | Offramp: the customer sells tokens for fiat. |
| Wallet | Wallet on the same chain | Swap: the customer exchanges one token for another. |
| Wallet | Wallet on another chain | Cross-chain transfer: the customer sends a token from one supported chain to another. |
```mermaid theme={null}
flowchart TD
S{"Source account"} -- "bank account" --> SB{"Destination account"}
S -- "wallet" --> SW{"Destination account"}
SB -- "wallet" --> ON["Onramp"]
SB -- "bank account" --> X1["Not supported:
fiat to fiat"]
SW -- "bank account" --> OFF["Offramp"]
SW -- "wallet, same chain" --> SWAP["Swap"]
SW -- "wallet, other chain,
same token" --> CC["Cross-chain transfer"]
SW -- "wallet, other chain,
different token" --> X2["Not supported:
cross-chain swap"]
```
## Rails
A rail is the network that moves the money. A bank account moves on a fiat rail (`spei` for MXN, `pix` for BRL). A wallet moves on its chain (`stellar`, `solana`, `base`, `polygon`, `monad` or `avalanche`). Each account's `rails` map shows which rails it can use and in which direction. Name a rail in the quote with `sourceRail` or `destinationRail`, or leave it out to use the account's default rail.
Each chain has its own asset identifier format and setup. See the chain guides: [Solana](/v2/guides/chains/solana), [Stellar](/v2/guides/chains/stellar), [Base](/v2/guides/chains/base), [Polygon](/v2/guides/chains/polygon), [Monad](/v2/guides/chains/monad), [Avalanche](/v2/guides/chains/avalanche).
**Always discover assets with [`GET /supported_assets`](/v2/api-reference/assets/list-supported-assets)** (or the public [`GET /lookup/stablebonds`](/v2/api-reference/lookup/list-stablebonds)). Do not hardcode identifiers. Identifiers, especially Stellar issuers, differ between sandbox and production and can change.
## KYC status
Your customers complete KYC before they transact. See the [KYC overview](/v2/guides/onboarding) for the flows.
Read a customer's KYC status with [`GET /organization/{organizationId}/kyc`](/v2/api-reference/kyc/get-kyc-status), and a business's KYB status with [`GET /organization/{organizationId}/kyb`](/v2/api-reference/kyb/get-kyb-status). To hear when it changes, subscribe to `kycUpdated` with [`POST /webhook_subscription`](/v2/api-reference/webhook-subscriptions/create-webhook-subscription). See the [`kycUpdated` payload](/v2/api-reference/webhooks/kycupdated).
## Fees
Entering a stablebond is free, and leaving one costs 5 bps. A trade that crosses currencies pays that market's volume-based FX fee, and a trade between two markets pays the higher of their FX fees. Partner, wallet setup and bridge fees may also apply. Every quote and order itemizes fees in `fees[]`. See [Transaction Fees](/v2/fees).
## Sandbox
In sandbox, [`POST /organization/{organizationId}/order/{id}/fiat_received`](/v2/api-reference/sandbox/simulate-fiat-received) simulates the fiat deposit for an order funded from a SPEI or PIX account, so you can test an onramp without sending money. See the [Sandbox Reference](/v2/test-environment).
## Resources
The API manages these resources:
* **Organizations** (`id`): your organization and the customer organizations you create with [`POST /organization`](/v2/api-reference/organizations/create-organization). A personal organization completes KYC; a business completes KYB.
* **Members** (`memberId`): the people who belong to a business organization, each named by the `sub` in their JWT. See [Add organization member](/v2/api-reference/members/add-organization-member).
* **Payment accounts** (`id`): bank accounts and wallets, registered with `POST /organization/{organizationId}/payment_account`.
* **Quotes** (`quoteId`): a priced transfer between two payment accounts. Valid for 2 minutes.
* **Orders** (`id`, equal to the `quoteId`): a transfer in progress.
* **Webhook subscriptions**: the URLs we send events to.
## Migrating from v1
The [V1 → V2 guide](/v2/migration-guide) lists endpoint replacements, request field changes, order statuses and webhook changes.
## Troubleshooting
See [Errors](/v2/errors) for every problem type and what to do about it, and [Authentication](/v2/authentication) for `401` issues.
# Pagination
Source: https://docs.etherfuse.com/v2/pagination
How to page through list endpoints with the pageSize and pageNumber query parameters.
List endpoints return one page at a time, wrapped in a page envelope. They take the paging parameters in the query string.
## v2 endpoints
These endpoints are paginated:
| Endpoint | Order |
| - | - |
| [`GET /organizations`](/v2/api-reference/organizations/list-organizations) | Newest first. Your customer organizations. |
| [`GET /organization/{organizationId}/payment_accounts`](/v2/api-reference/payment-accounts/list-payment-accounts) | Oldest first. |
| [`GET /orders`](/v2/api-reference/orders/list-orders) | Newest first. The orders of your organization and all its customer organizations, or one organization's with `?organizationId=`. |
### Request
| Parameter | Default | Rule |
| - | - | - |
| `pageSize` | `30` | Items per page, 1 to 100. Other values return `400` `invalid-request`. |
| `pageNumber` | `0` | Which page to return. Starts at 0. |
```bash theme={null}
curl "https://api.sand.etherfuse.com/orders?pageSize=50&pageNumber=0" \
-H "Authorization: "
```
### Response
| Field | Type | Description |
| - | - | - |
| `items` | array | The items on this page. |
| `totalItems` | integer | Total items across all pages. |
| `pageSize` | integer | Items per page. |
| `pageNumber` | integer | Zero-based index of this page. |
| `totalPages` | integer | Total number of pages. |
```json theme={null}
{
"items": [],
"totalItems": 42,
"pageSize": 30,
"pageNumber": 0,
"totalPages": 2
}
```
A `pageNumber` past the last page returns an empty `items` array.
### Iterating through pages
Start at `pageNumber=0` and stop when `pageNumber + 1 >= totalPages`:
```js theme={null}
const pageSize = 100;
const all = [];
for (let page = 0; ; page++) {
const res = await fetch(
`https://api.sand.etherfuse.com/orders?pageSize=${pageSize}&pageNumber=${page}`,
{ headers: { Authorization: apiKey } },
);
const { items, totalPages } = await res.json();
all.push(...items);
if (page + 1 >= totalPages) break;
}
```
To track orders as they change, subscribe to [webhooks](/v2/webhooks) instead of polling the list.
# Price Feeds
Source: https://docs.etherfuse.com/v2/price-feeds
Get Etherfuse asset prices and FX rates: on-chain via oracles, or over HTTP via the REST API.
Etherfuse asset prices (stablebonds like CETES, principal tokens like MEXe) and FX rates (USD ↔ MXN/BRL) are available two ways:
* **On-chain, via oracles**: for smart contracts and on-chain consumers.
* **Over HTTP, via the REST API**: for backends and apps.
## On-chain oracles
RedStone publishes **push price feeds** for Etherfuse assets (e.g. CETES) on **Base, Solana, Polygon, and Stellar**. Browse the feeds ↗
## REST API pricing
For server-side pricing and FX rates, the public **`Lookup`** endpoints (no API key required) return Etherfuse's pricing directly:
| Endpoint | Returns |
| - | - |
| [`GET /lookup/exchange_rate`](/v2/api-reference/lookup/list-exchange-rates) | All USD ↔ currency FX rates (e.g. USD/MXN, USD/BRL), with a per-provider `sources` breakdown |
| [`GET /lookup/exchange_rate/usd_to_{currency}`](/v2/api-reference/lookup/get-exchange-rate) | A single USD → currency rate |
| [`GET /lookup/tokens/cost`](/v2/api-reference/lookup/list-token-costs) | Costs for every Etherfuse token in USD and fiat, with per-FX-provider sources for oracle-safety checks |
| [`GET /lookup/tokens/cost/{identifier}`](/v2/api-reference/lookup/get-token-cost) | Cost for one token (by symbol or Solana mint) |
| [`GET /lookup/stablebonds`](/v2/api-reference/lookup/list-stablebonds) | All available stablebonds |
```bash theme={null}
# FX rate (public, no API key needed)
curl https://api.etherfuse.com/lookup/exchange_rate/usd_to_mxn
# Cost of one token, by symbol (case-insensitive) or mint
curl https://api.etherfuse.com/lookup/tokens/cost/mexe
```
### Token costs
`GET /lookup/tokens/cost` covers both kinds of Etherfuse token:
* **Stablebonds** (`token_kind: "bond"`) such as CETES, whose price accrues with the underlying bond's rate.
* **Principal tokens** (`token_kind: "principal_token"`) such as MEXe, which wrap a bond and hold par with its currency, so 1 MEXe is 1 MXN.
Stablebond pricing moved to the `BondPrice` account, a PDA derived under the Solana stablebond program with seeds `["bond_price", mint]`. It supplies the bond's price and rate; wallets use the mint's Token-2022 interest-bearing extension only for display. `BondPrice` gives the value of one whole token in the bond's currency, and quotes use its full price precision. Wallet balance valuations still return six decimal places.
The stablebond program added a required `BondPrice` account to priced instructions (one per mint for the two-mint swap). See the [SDK instruction definitions](https://github.com/etherfuse/stablebond/tree/main/clients/js/src/generated/instructions) for each instruction's required accounts.
The response is keyed by uppercased token symbol. Each entry carries the token's cost in its denomination currency (`token_cost_in_fiat`) and in USD (`token_cost_in_usd`), plus a **`sources`** map giving the exchange rate and derived USD cost from each FX provider, so you can run your own oracle-safety checks across providers:
```json theme={null}
{
"CETES": {
"token_symbol": "CETES",
"token_mint": "CETES7CKqqKQizuSN6iWQwmTeFRjbJR6Vw2XRKfEDR8f",
"token_kind": "bond",
"currency": "MXN",
"token_cost_in_fiat": "1.177336",
"token_cost_in_usd": "0.069404",
"fiat_exchange_rate_with_usd": "16.96343",
"current_basis_points": 554,
"sources": {
"d2ce2919-082f-4d60-8fbb-a15b3b570e57": { "exchange_rate": "16.96343", "token_cost_in_usd": "0.069404", "updated_at": 1787243387 },
"8e1e2967-0283-42dd-bb06-e49193c368d2": { "exchange_rate": "16.96425", "token_cost_in_usd": "0.069401", "updated_at": 1787243417 }
}
},
"MEXE": {
"token_symbol": "MEXe",
"token_mint": "MEXeQE5UjuusUoyS2KxbUXp6o21YbbBJaNvPMMEfr4y",
"token_kind": "principal_token",
"underlying_bond_symbol": "CETES",
"currency": "MXN",
"token_cost_in_fiat": "1.000000",
"token_cost_in_usd": "0.058950",
"fiat_exchange_rate_with_usd": "16.96343",
"current_basis_points": 0,
"sources": {
"d2ce2919-082f-4d60-8fbb-a15b3b570e57": { "exchange_rate": "16.96343", "token_cost_in_usd": "0.058950", "updated_at": 1787243387 },
"8e1e2967-0283-42dd-bb06-e49193c368d2": { "exchange_rate": "16.96425", "token_cost_in_usd": "0.058947", "updated_at": 1787243417 }
}
}
}
```
`sources` keys are Etherfuse FX provider ids, and `updated_at` is a Unix timestamp in seconds. `exchange_rate` is always the raw USD to currency rate as the provider published it, so on a principal token it reads the same as on the bond it wraps.
Two fields differ by token kind. `current_basis_points` is the bond's annualized rate, and it is always `0` on a principal token because the coupon accrues to the bond, not to the principal-token holder. `payment_mint` names the token a bond purchase settles in and is absent on principal tokens.
Use `/lookup/tokens/cost` to price both stablebonds and principal tokens.
Browse these under **Lookup** in the API reference, starting at [List token costs](/v2/api-reference/lookup/list-token-costs).
# Quickstart
Source: https://docs.etherfuse.com/v2/quickstart
From a sandbox API key to your first order: register payment accounts, quote, order, and get webhooks.
Create and fund a sandbox onramp with this guide. First, create an API key under **Account, then Manage Accounts** in the [sandbox dashboard](https://sandbox.etherfuse.com/account/manage). See [Initial Setup](/v2/initial-setup) for account setup and base URLs.
Every request below uses the sandbox base URL `https://api.sand.etherfuse.com` and sends your key in the `Authorization` header, **with no `Bearer` prefix** (see [Authentication](/v2/authentication)).
Get your organization's `id`. Payment account, quote and order routes take it as `{organizationId}`:
```bash theme={null}
curl https://api.sand.etherfuse.com/me \
-H "Authorization: "
```
```json theme={null}
{ "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "displayName": "Acme Inc", "approvedAt": "2026-04-15T12:00:00Z", "partnerFeeDefaultBps": 100 }
```
This guide acts for your own organization. To act for a customer, use the customer organization's id instead. See [Organizations in the route](/v2/overview#organizations-in-the-route).
List the organization's payment accounts. A new organization has none yet:
```bash theme={null}
curl https://api.sand.etherfuse.com/organization//payment_accounts \
-H "Authorization: "
```
```json theme={null}
{ "items": [], "totalItems": 0, "pageSize": 30, "pageNumber": 0, "totalPages": 0 }
```
A `401` with `type` ending in `unauthorized` means the key or the header is wrong. See [Authentication](/v2/authentication#troubleshooting).
Register the URL we send order events to. Store the `secret` from the response; we return it only once, and you need it to [verify each delivery](/v2/guides/verifying-webhooks).
```bash theme={null}
curl -X POST https://api.sand.etherfuse.com/webhook_subscription \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/etherfuse/webhooks", "eventType": "orderUpdated" }'
```
Webhook subscriptions do not take an organization id. One subscription covers your organization and every customer organization. Add a `paymentAccountUpdated` subscription the same way to hear when an account becomes ready. See [Webhooks](/v2/webhooks).
Register the bank account the money comes from:
```bash theme={null}
curl -X POST https://api.sand.etherfuse.com/organization//payment_account \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{
"kind": "bank",
"label": "Operating account",
"country": "MX",
"currency": "MXN",
"accountDetails": { "kind": "clabe", "clabe": "012180000000000002" }
}'
```
Then register the wallet the tokens go to:
```bash theme={null}
curl -X POST https://api.sand.etherfuse.com/organization//payment_account \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{
"kind": "wallet",
"label": "Treasury wallet",
"accountDetails": {
"kind": "address",
"address": "",
"chain": "stellar"
}
}'
```
Each response holds the account `id` and its `rails`. You can quote a direction once its `status` is `ready`. If a bank account returns `422` `registration-info-required`, add the fields listed in `missing` under `registrationInfo` and send it again. See [Register payment accounts](/v2/overview#the-flow).
List the tokens your wallet can receive. Use a token's `asset` from the response in the quote.
```bash theme={null}
curl "https://api.sand.etherfuse.com/supported_assets?paymentAccountId=" \
-H "Authorization: "
```
```json theme={null}
{
"markets": [],
"tokens": [
{
"asset": "CETES:GC3CW7EDYRTWQ635VDIGY6S4ZUF5L6TQ7AA4MWS7LEQDBLUSZXV7UPS4",
"chain": "stellar",
"currency": "MXN",
"symbol": "CETES",
"name": "Etherfuse CETES",
"image": "https://assets.etherfuse.com/cetes.png"
}
]
}
```
The bank account's `asset` is its currency, `MXN`. See [`GET /supported_assets`](/v2/api-reference/assets/list-supported-assets).
Price the transfer between the two accounts. Quotes **expire after 2 minutes**.
```bash theme={null}
curl -X POST https://api.sand.etherfuse.com/organization//quote \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{
"source": { "type": "stored", "id": "" },
"destination": { "type": "stored", "id": "" },
"sourceAmount": "100",
"sourceAsset": "MXN",
"destinationAsset": ""
}'
```
The response holds the `quoteId`, `destinationAmount`, `rateAfterFees`, `feeBps` and the fee lines in `fees[]`. Both accounts must belong to the organization in the route. See [`POST /organization/{organizationId}/quote`](/v2/api-reference/quotes/create-quote).
```bash theme={null}
curl -X POST https://api.sand.etherfuse.com/organization//order \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{ "quoteId": "" }'
```
The response is `201` with the order. Its `id` equals the `quoteId`, and sending the same `quoteId` again returns the same order (see [Idempotency](/v2/idempotency)). `fundingInstructions.destination` says where the source sends the funds once it is available: a CLABE for a bank source, a blockchain address for a wallet source. An `orderUpdated` delivery tells you when it arrives.
How you fund the order depends on the source account.
In sandbox no real money moves. Simulate the customer's SPEI payment:
```bash theme={null}
curl -X POST https://api.sand.etherfuse.com/organization//order//fiat_received \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{ "amount": "100" }'
```
See [`POST /organization/{organizationId}/order/{id}/fiat_received`](/v2/api-reference/sandbox/simulate-fiat-received).
There is nothing to simulate: the wallet sends the tokens on the testnet. `fundingInstructions.rail` is the wallet's chain, and `fundingInstructions.destination` holds the address to send to. Fund the order before `fundingDeadline` in one of two ways:
1. **Sign the prepared transaction.** When `preparedTransaction` is present, deserialize `transaction`, sign it with the wallet, and submit it to the network.
2. **Send a transfer yourself.** Send the order's `source.amount` of `source.asset` to `address`, with `memo` when it is present.
```json theme={null}
{
"fundingInstructions": {
"rail": "stellar",
"reference": "1000042",
"fundingDeadline": "2026-10-07T12:30:00Z",
"destination": {
"address": "GBX3...",
"expiresAt": null,
"preparedTransaction": { "transaction": "AAAAAgAAAA...", "refreshAfter": "2026-10-07T12:30:00Z" }
}
}
}
```
An [embedded wallet](/v2/guides/embedded-wallets) does not send anything itself: its owner approves the order's `authorization`, and we move the funds.
You need testnet tokens to fund from a wallet. Get them by running the bank-source onramp first. See [Testnet tokens](/v2/test-environment#testnet-tokens) and the [Offramps](/v2/guides/testing-offramps) guide.
We detect the funds, then the order moves to `funded` and `completed`. An onramp can move to `claimable` instead, when the wallet has to claim the tokens (for example a Stellar wallet with no trustline yet). Each change sends an `orderUpdated` delivery with the full order.
Sandbox onramps are capped at **500 MXN per quote**. See the [Sandbox Reference](/v2/test-environment).
## Test other transfers
Fiat to tokens. The customer pays MXN and receives tokens.
Tokens to fiat. The customer sends tokens and receives MXN.
Token to token. The customer exchanges one asset for another.
## Next steps
Run KYC for your customers before they transact.
Get signed updates for orders, payment accounts and KYC instead of polling.
Payment accounts, rails, quotes, fees and order statuses.
Test KYC data, simulated deposits, and testnet tokens.
## Migrating from v1
Follow the [V1 → V2 guide](/v2/migration-guide) to update an existing integration. Your API key, organizations, customers and KYC approvals remain valid.
# SDKs
Source: https://docs.etherfuse.com/v2/sdks
Libraries for the on-chain Stablebond side, plus how to call the REST API from any language.
There are two parts to an Etherfuse integration, and they use different tools:
1. **The Etherfuse REST API** (v2, at the API root, plus `/lookup` and `/auth`): call it over HTTP from any language. See [Calling the REST API](#calling-the-rest-api) below.
2. **The on-chain Stablebond layer** (Solana): when a flow needs bond data or on-chain transactions, use the **Stablebond SDKs**.
## Stablebond SDKs
These are open-source clients for the Etherfuse Stablebond program on **Solana**. They help you query available bonds, read bond data, and work with on-chain transactions. The interactive tutorials in these docs use the TypeScript SDK.
The Stablebond SDKs are Solana on-chain clients. They do **not** call the Etherfuse REST API, including the v2 endpoints. Use them alongside the REST API for the on-chain part of a flow.
`@etherfuse/stablebond-sdk`: for JavaScript/TypeScript apps (web and mobile). Retrieve available bonds, read bond data, and perform on-chain transactions.
`stablebond-sdk`: for Rust projects needing direct, typed access to the Stablebond program (querying bonds, building transactions).
```bash npm theme={null}
npm install @etherfuse/stablebond-sdk
```
```bash yarn theme={null}
yarn add @etherfuse/stablebond-sdk
```
```bash cargo theme={null}
cargo add stablebond-sdk
```
## Calling the REST API
The Etherfuse API is a standard HTTP API, so you can call it from any language with an HTTP client. You do not need an SDK. Send your API key in the `Authorization` header (see [Authentication](/v2/authentication)):
```bash theme={null}
curl "https://api.sand.etherfuse.com/organization//payment_accounts?pageSize=30" \
-H "Authorization: "
```
Every endpoint in the API reference, for example [List payment accounts](/v2/api-reference/payment-accounts/list-payment-accounts), has a **"Try it" playground** with code samples in cURL, JavaScript, Python, and more. To generate a typed client or import into Postman or Insomnia, download the OpenAPI specs: [`openapi-v2.json`](https://docs.etherfuse.com/openapi-v2.json) covers the v2 endpoints, and [`openapi.json`](https://docs.etherfuse.com/openapi.json) includes the authentication, organization, customer, KYC, asset, lookup and statement endpoints.
Building with an AI assistant? Connect the [MCP server](/v2/mcp-overview) so Claude, ChatGPT, or any MCP-compatible client can search these docs and the API reference.
# Sandbox Reference
Source: https://docs.etherfuse.com/v2/test-environment
Everything that behaves differently in the Etherfuse sandbox: base URLs, test data, simulated deposits, and testnet tokens.
The sandbox is a full copy of the API for development: same endpoints and auth as production, connected to blockchain testnets, with no real money or personal data. This page lists everything that behaves **differently in sandbox**. If you have not set up your sandbox account yet, start with [Initial Setup](/v2/initial-setup).
## Base URLs & environments
| | Sandbox | Production |
| - | - | - |
| API base URL | `https://api.sand.etherfuse.com` | `https://api.etherfuse.com` |
| JWT access token / launch host (`aud`) | `https://api.sand.etherfuse.com/auth/token` | `https://api.etherfuse.com/auth/token` |
| Dashboard | [sandbox.etherfuse.com](https://sandbox.etherfuse.com) | [app.etherfuse.com](https://app.etherfuse.com) |
* The endpoints and auth scheme are **identical** across environments; only the base URL and API key differ. Build against sandbox, then swap the base URL to go live.
* **API keys are environment-specific and not interchangeable.** The environment is part of the key (`api_sand_...` vs `api_prod_...`).
* Sandbox connects to **testnets**: Stellar **Testnet**, Solana **Devnet**, and testnet for the other supported chains.
* **KYB is required in sandbox too, but you approve it yourself.** API keys need a business account with approved KYB, and your organization must be approved to quote. The sandbox [KYB page](https://sandbox.etherfuse.com/account/kyb) has an **Approve KYB** button that clears it without documents or admin review. Production KYB is a real review.
**Interactive testers use sandbox keys only.** The in-docs playgrounds and any browser-side testing send authenticated requests from the browser. Use a **sandbox** key only; never put a production API key in client-side code. Production integrations must call the API from a server.
## KYC in sandbox
Sandbox accepts fake data for KYC; no real personal information is required.
**KYC auto-approves.** When a `personal` customer completes the [`/idv` launch](/v2/guides/kyc-websdk) (identity documents and agreement signing), we approve them automatically and send a `kycUpdated` delivery, with no manual review. The identity checks do not run, so you can upload **any** document images.
**The one exception is Mexico:** if the customer selects Mexico they must upload a **Constancia de Situación Fiscal** at the tax-document step, or the flow does not pass. Any constancia works as long as it looks valid. Use [this example](https://stablebonds.s3.us-west-2.amazonaws.com/example-constancia-de-situacion-fiscal.pdf), or see [Country-specific requirements](/v2/guides/onboarding#country-specific-requirements).
**Mexican customers must use the name `LETICIA HERNANDEZ GONZALEZ`.** Sandbox still runs the RFC registry check. The sandbox registry returns this one test person for every RFC and compares the name to the customer's personal data. Enter first name `LETICIA` and last name `HERNANDEZ GONZALEZ` at the personal data step. Any other name is rejected.
Production still requires review.
**The [KYC API](/v2/guides/kyc-api) flow behaves the same in sandbox**, with the same one difference: the document checks do not run, so we do not inspect document content. Everything else is real. We validate submissions the same way, store them on the customer's profile the same way, and advance the flow [stage by stage](/v2/guides/onboarding#requirements-unlock-in-stages), including the rule that personal data comes first, that we ask for a proof of address only when the identity document had none, and that the customer agreement can be sent only once it appears in the requirements list. The integration you build against sandbox is the one that works in production.
We still read a Mexican customer's constancia: its RFC comes from the document itself, so a sample belonging to somebody else records the wrong identity. The RFC registry check also runs, so send `"firstName": "LETICIA"` and `"lastName": "HERNANDEZ GONZALEZ"` as a Mexican customer's personal data.
**[Importing a shared Sumsub session](/v2/guides/kyc-sumsub-shared-token) needs a second sandbox account** to act as the donor, configured for sharing exactly as in production. Without one, build the rest of the integration against the KYC API flow and add the import when sharing is in place.
## Payment accounts in sandbox
Register bank accounts and wallets with [`POST /organization/{organizationId}/payment_account`](/v2/api-reference/payment-accounts/register-payment-account), exactly as in production. Use dummy values, for example the CLABE `012180000000000002` and the RFC `XAXX010101000`.
A new account starts `pending`, and sandbox moves it to `ready` within a few seconds. To test a rejection, register a bank account with `registrationInfo.accountHolderName` set to `Rechazo Ejemplo`: its directions become `rejected`. Register it again with a different name to retry. Read the account's `rails` with [`GET /organization/{organizationId}/payment_account/{id}`](/v2/api-reference/payment-accounts/get-payment-account), or subscribe to `paymentAccountUpdated`, and quote the direction once it is `ready`.
Register wallets with testnet addresses on the chain you name in `accountDetails.chain`.
## Simulating orders in sandbox
No real money moves in sandbox; we simulate deposits and payouts.
* **Onramps:** in production we detect the customer's SPEI or PIX deposit. In sandbox, **simulate the deposit** with [`POST /organization/{organizationId}/order/{id}/fiat_received`](/v2/api-reference/sandbox/simulate-fiat-received), sending the `amount` the customer paid:
```bash theme={null}
curl -X POST https://api.sand.etherfuse.com/organization//order//fiat_received \
-H "Authorization: " \
-H "Content-Type: application/json" \
-d '{ "amount": "100" }'
```
The response is `200` with an empty body, and the order continues as it would after a real deposit. The endpoint works only for an order whose source is a bank account on `spei` or `pix`; other orders return `422` `unsupported`. In production it returns `403` `forbidden`.
* **Offramps:** there is no simulation step. The customer sends the tokens as `fundingInstructions` describe, and the sandbox processes the fiat payout once the transaction confirms on-chain.
* **Onramp cap:** sandbox onramps are capped at **500 MXN per quote**. A larger amount returns `400` `sandbox-limit-exceeded`. Production has no such cap.
## Testnet tokens
You need testnet tokens to test offramps and swaps; you do not need to contact Etherfuse. **There is no faucet.** Get tokens by **onramping** an Etherfuse token (for example CETES), then **swapping** it for any other supported token (USDC and others). See [Testing Onramps](/v2/guides/testing-onramps) and [Testing Swaps](/v2/guides/testing-swaps).
**Sandbox asset identifiers differ from production.** Do not hardcode token or issuer addresses. Always read the token's `asset` from [`GET /supported_assets`](/v2/api-reference/assets/list-supported-assets) for the current environment when you quote.
**Stellar: onramps to a wallet without a trustline.** When you onramp to a Stellar wallet that has no trustline for the asset (or does not exist on-chain yet), the order becomes `claimable` after delivery. Prepare the claim with [`POST /organization/{organizationId}/order/{id}/claim`](/v2/api-reference/orders/prepare-claim) and have the customer sign the transaction it returns to add the trustline and claim the tokens. For **offramps and swaps**, the wallet must already have trustlines: use `GET /supported_assets?chain=stellar` to get identifiers in `CODE:ISSUER` format and set up the trustline first. See [Stellar](/v2/guides/chains/stellar).
## Webhooks in sandbox
Webhooks work the same in sandbox as in production. Sandbox also accepts `http` URLs; production requires `https`. Make sure your endpoint is publicly reachable (or use a tunnel such as ngrok for local development) and subscribe with [`POST /webhook_subscription`](/v2/api-reference/webhook-subscriptions/create-webhook-subscription). See [Webhooks](/v2/webhooks).
# Webhooks
Source: https://docs.etherfuse.com/v2/webhooks
Subscribe to order, payment account, KYC and KYB events. Verify signatures and handle retries.
Etherfuse sends a signed `POST` to your URL when an order, payment account, KYC or KYB status changes. Order and payment account events include the full object.
**Webhooks vs. polling.** Webhooks arrive when the state changes. Polling is slower and most requests return no change. Use webhooks for anything time-sensitive, and read the object with its `GET` endpoint when you need to confirm the current state.
## Subscribe
Create one subscription per URL and event with [`POST /webhook_subscription`](/v2/api-reference/webhook-subscriptions/create-webhook-subscription):
```json theme={null}
{
"url": "https://example.com/etherfuse/webhooks",
"eventType": "orderUpdated"
}
```
| `eventType` | Sent when | Payload |
| - | - | - |
| `orderUpdated` | An order's status or funding instructions change. | [`{ event, eventId, occurredAt, order }`](/v2/api-reference/webhook-events/orderupdated) |
| `paymentAccountUpdated` | A payment account's status, or the status of one of its rail directions, changes. | [`{ event, eventId, occurredAt, organizationId, paymentAccount }`](/v2/api-reference/webhook-events/paymentaccountupdated) |
| `kycUpdated` | A customer's KYC status changes. | [`{ event, eventId, occurredAt, organizationId, kyc }`](/v2/api-reference/webhooks/kycupdated) |
| `kybUpdated` | A business customer's KYB status changes. | [`{ event, eventId, occurredAt, organizationId, kyb }`](/v2/api-reference/webhook-events/kybupdated) |
The response is `201` and includes a `secret`:
```json theme={null}
{
"id": "9d0e1f2a-3b4c-4d5e-8f6a-7b8c9d0e1f2a",
"eventType": "orderUpdated",
"url": "https://example.com/etherfuse/webhooks",
"createdAt": "2026-09-28T17:00:00Z",
"updatedAt": "2026-09-28T17:00:00Z",
"secret": ""
}
```
**We return the `secret` only once**, in this response. Store it; you need it to verify every delivery. `GET` and list responses do not include it.
* In production the URL must use `https`. Sandbox also accepts `http`.
* A second subscription for the same URL and event returns `409` `conflict`.
* [`GET /webhook_subscriptions`](/v2/api-reference/webhook-subscriptions/list-webhook-subscriptions) lists your subscriptions. [`GET /webhook_subscription/{id}`](/v2/api-reference/webhook-subscriptions/get-webhook-subscription) reads one. [`DELETE /webhook_subscription/{id}`](/v2/api-reference/webhook-subscriptions/delete-webhook-subscription) removes one.
* Subscriptions belong to your organization and do not take an organization id in the route. One subscription covers your organization and every customer organization, so you do not subscribe per customer. Each payload tells you which organization the event is for.
## Payload
`orderUpdated` and `paymentAccountUpdated` share one envelope:
| Field | Meaning |
| - | - |
| `event` | The event name: `orderUpdated` or `paymentAccountUpdated`. |
| `eventId` | Unique per event. A retried delivery carries the same `eventId`, so use it to drop duplicates. |
| `occurredAt` | When the change happened, ISO 8601. |
| `organizationId` | `paymentAccountUpdated` only. The organization that holds the payment account: yours or one of your customer organizations. |
| `order` or `paymentAccount` | The full object, the same one [`GET /organization/{organizationId}/order/{id}`](/v2/api-reference/orders/get-order) or [`GET /organization/{organizationId}/payment_account/{id}`](/v2/api-reference/payment-accounts/get-payment-account) returns. The order's `organizationId` is the organization the order belongs to. |
```json theme={null}
{
"event": "orderUpdated",
"eventId": "2b3c4d5e-6f7a-4b8c-9d0e-1f2a3b4c5d6e",
"occurredAt": "2026-09-28T17:09:41Z",
"order": {
"id": "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b",
"status": "funded",
"organizationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"...": "the full order"
}
}
```
```json theme={null}
{
"event": "paymentAccountUpdated",
"eventId": "7c8d9e0f-1a2b-4c3d-8e4f-5a6b7c8d9e0f",
"occurredAt": "2026-09-28T17:06:12Z",
"organizationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"paymentAccount": {
"id": "3f9a2c1e-5b7d-4e8f-9a01-2b3c4d5e6f70",
"status": "ready",
"...": "the full payment account"
}
}
```
## When each event is sent
### orderUpdated
* When the order's `status` changes, for example to `funded`, `completed`, `failed` or `canceled`.
* When the order's funding instructions arrive or are replaced. `order.fundingInstructions.destination` then holds where to send the funds.
Creating an order does not send a webhook. The `POST /organization/{organizationId}/order` response already holds the order.
What your application does on each status:
| `status` | What happened | What your application does |
| - | - | - |
| `created` | Waiting for the source funds. | When `fundingInstructions.destination` is present, show the customer where to pay. |
| `funded` | We received the source funds. | Optionally show "payment received". |
| `claimable` | We delivered the funds, and the customer has to claim them. | Prepare the claim with [`POST /organization/{organizationId}/order/{id}/claim`](/v2/api-reference/orders/prepare-claim), then have the customer finish it. |
| `completed` | Delivered to the destination. | **Mark the order fulfilled** and notify the customer. |
| `failed` | We could not complete the order. `failure` holds a `code` and a `message`. | Notify the customer and reconcile. |
| `canceled` | Canceled before completion, either by you or because the funding deadline passed. | Close the order in your system. |
| `refunded` | We returned the source funds. | Reconcile and tell the customer the funds came back. |
### paymentAccountUpdated
Sent when the account's `status` changes, or when the `status` of any rail direction in `rails` changes (for example `destination` on `spei` going from `pending` to `ready`). Quote a direction only once it is `ready`.
### kycUpdated
Sent when a customer's KYC status changes, and when a write you sent was accepted but could not be applied. `kyc.status` is one of `notStarted`, `inProgress`, `submitted`, `approved` or `denied`. Do not let a customer transact until `kyc.approved` is `true`. When `kyc.needsWork` is `true`, `kyc.userMessage` says what the customer has to fix.
To learn what is still outstanding, read [`GET /organization/{organizationId}/kyc?requirements=true`](/v2/api-reference/kyc/get-kyc-status) when the webhook arrives. Do not poll it on a timer. A `kycUpdated` that moves the customer from `submitted` back to `inProgress` means the next stage has arrived and the list holds new requirements. See [Tracking verification status](/v2/guides/onboarding#tracking-verification-status) and [Requirements unlock in stages](/v2/guides/onboarding#requirements-unlock-in-stages).
### kybUpdated
Sent when a business customer's KYB status changes. `kyb.status` uses the same values as KYC, and `kyb.approvedAt` is set once the business is approved.
## Verify the signature
Each delivery has an `X-Signature: sha256={hex}` header. We compute it as HMAC-SHA256 over the [RFC 8785](https://datatracker.ietf.org/doc/html/rfc8785) canonicalized JSON body. The HMAC key is your subscription's `secret`, decoded from URL-safe base64.
The secret uses the URL-safe base64 alphabet (RFC 4648 section 5: `-` and `_` in place of `+` and `/`), with `=` padding. It decodes to 32 bytes. Decode it with a URL-safe decoder, for example `base64.urlsafe_b64decode` in Python or `Buffer.from(secret, "base64url")` in Node.js. A standard base64 decoder fails or returns the wrong key for secrets that contain `-` or `_`.
See [Verifying Webhooks](/v2/guides/verifying-webhooks) for Rust, Node.js and Python code.
## Delivery and retries
* Return any `2xx` to acknowledge a delivery.
* We make up to 3 attempts per delivery, 5 seconds apart. We try again after a connection error, a timeout, a `5xx`, a `408`, a `429`, or any other non-`2xx` status not in the next item.
* We stop after the first response when your endpoint returns `400`, `401`, `403`, `404`, `405`, `410` or `422`.
* We do not send an abandoned delivery later. Read the object with its `GET` endpoint to recover the current state.
## Best practices
* **Queue events before processing.** Save the payload to a queue and return `2xx` promptly to avoid timeouts and retries.
* **Ignore duplicate events.** Use `eventId` to avoid processing a retried delivery twice.
* **Check event timestamps.** Deliveries can arrive out of order. Compare `occurredAt` before updating stored state; use the resource's `GET` endpoint to confirm its current state.
* **Expose a public HTTPS endpoint.** For local development, use a tunnel such as [ngrok](https://ngrok.com) so we can reach your handler.
## Migrating from v1
v1 and v2 subscriptions are separate. A v1 subscription keeps receiving the v1 payload and does not appear in `GET /webhook_subscriptions`. To receive the v2 payloads, create `orderUpdated`, `paymentAccountUpdated`, `kycUpdated` and `kybUpdated` subscriptions. See the [V1 → V2 guide](/v2/migration-guide#7-subscribe-to-v2-webhooks).
# Deprecation policy
Source: https://docs.etherfuse.com/changelog/deprecation-policy
How Etherfuse announces, deprecates, and retires API behavior, and how much notice you get.
This page explains how the Etherfuse API changes over time and what you can rely on. Breaking
changes and deprecations are also announced in the [changelog](/changelog), tagged `Breaking` or
`Deprecated`.
## Lifecycle
Every endpoint, field, or flow moves through four stages:
| Stage | What it means |
| - | - |
| **Active** | Fully supported. Safe to build on. |
| **Deprecated** | Still works, but scheduled for removal. Announced in the changelog with a migration path. Start migrating. |
| **Sunset** | A fixed end date is set. The behavior still works until that date. |
| **Removed** | No longer available after the sunset date. |
## Breaking vs. non-breaking changes
What counts as **non-breaking** (no migration required):
* New endpoints, resources, or optional request parameters.
* New fields in a response or webhook payload.
* New webhook event types or enum values.
What counts as **breaking** (we provide a migration path):
* Removing or renaming an endpoint or field.
* Changing the type or meaning of an existing field.
* Changing an authentication flow.
## How we announce changes
When a breaking change or deprecation is scheduled, we use the loudest path available, because this
is money infrastructure:
1. A [changelog](/changelog) entry tagged `Breaking` or `Deprecated`, with migration steps.
2. A direct email to API-key holders.
3. An in-dashboard banner for breaking changes.
4. The affected endpoint is marked deprecated in the [API reference](/api-reference/introduction).
## What's currently deprecated
The current list of deprecated endpoints and flows (with replacements and status) lives on the
[Deprecations](/changelog/deprecations) page. Each deprecation is also announced in the
[changelog](/changelog) when it's scheduled, per the policy above.
# Deprecations
Source: https://docs.etherfuse.com/changelog/deprecations
Endpoints and flows that are deprecated, their replacements, and sunset dates.
These endpoints and flows are **deprecated**. They keep working until their **sunset date**, after which they're removed; migrate off them before then (see each entry's replacement). An entry with no sunset date keeps working until one is announced. How deprecations are announced and retired is described in the [deprecation policy](/changelog/deprecation-policy).
Customer onboarding's default is a **single hosted flow**: create the customer's `personal` organization, then launch them into `/idv`, which runs identity verification **and** agreement signing in one step. See [KYC WebSDK](/guides/kyc-websdk). The onboarding entries below were deprecated as a result.
**A programmatic path exists again**, on new endpoints under `/ramp/customer/{customer_id}/verification`. It is not a revival of the endpoints below: the shapes and semantics differ: real multipart uploads, an explicit submission order, `202` responses. See [KYC API](/guides/kyc-api), or [KYC Sumsub Shared Token](/guides/kyc-sumsub-shared-token) if your customers are already verified with you. The endpoints below remain deprecated on their stated dates.
## Presigned onboarding URL
`POST /ramp/onboarding-url`
The presigned "hosted UI" onboarding link is replaced by the `/idv` launch. Create the customer's org, then launch them into `/idv`.
* **Status:** Deprecated. **Sunset: August 16, 2026.**
* **Replacement:** [KYC WebSDK](/guides/kyc-websdk): create org, then launch `/idv`.
* **Reference:** [POST /ramp/onboarding-url](/api-reference/deprecated/generate-onboarding-url)
* **Change (Week of August 7, 2026):** `userInfo` is now required. Requests without a non-empty `email` and `displayName` are rejected.
## Presigned bank-account registration
`POST /ramp/onboarding/bank-accounts`
Registering a bank account using the presigned URL for auth is replaced by registering it with your API key.
* **Status:** Deprecated. **Sunset: August 16, 2026.**
* **Replacement:** [POST /ramp/customer/\{customer\_id}/bank-account](/api-reference/bank-accounts/create-bank-account-api) with your API key.
## Programmatic KYC submission
`POST /ramp/customer/{customer_id}/kyc`
Submitting a customer's identity fields over the API is replaced by the customer entering them inside the `/idv` launch.
* **Status:** Deprecated. **Sunset: August 16, 2026.**
* **Replacement:** [`POST /ramp/customer/{customer_id}/verification`](/guides/kyc-api#1-submit-personal-data) to submit identity data over the API, or the [`/idv` launch](/guides/kyc-websdk) to have the customer enter it.
* **Reference:** [POST /ramp/customer/\{customer\_id}/kyc](/api-reference/deprecated/submit-kyc)
## KYC document upload
`POST /ramp/customer/{customer_id}/kyc/documents`
Uploading ID and selfie images over the API is replaced by document capture inside the `/idv` launch.
* **Status:** Deprecated. **No sunset date set yet.**
* **Replacement:** [`POST /ramp/customer/{customer_id}/verification/documents`](/guides/kyc-api#3-upload-documents) to upload documents over the API, or the [`/idv` launch](/guides/kyc-websdk) to have the customer capture them.
* **Reference:** [POST /ramp/customer/\{customer\_id}/kyc/documents](/api-reference/deprecated/upload-kyc-docs)
## `/agreements` launch
`scope: agreements` · `target: /agreements`
Launching the customer into a separate agreements-signing step is replaced by signing inside `/idv`.
* **Status:** Deprecated. **Sunset: August 16, 2026.**
* **Replacement:** agreement signing happens automatically inside the [`/idv` launch](/guides/user-launch-flows#identity-verification).
## Direct agreement-signing endpoints
`POST /ramp/agreements/electronic-signature` · `POST /ramp/agreements/terms-and-conditions` · `POST /ramp/agreements/customer-agreement`
* **Status:** Removed.
* **Replacement:** agreements are signed inside the [`/idv` launch](/guides/kyc-websdk).
## Inline `bankAccount` on `POST /ramp/organization`
`POST /ramp/organization` no longer creates a bank account inline, and the response no
longer includes `bankAccount`. Bank accounts can only be registered once the owning
organization is approved.
* **Status:** Removed.
* **Replacement:** create the organization with [`POST /ramp/organization`](/api-reference/organizations/create-child-org), then register the bank account separately with [`POST /ramp/customer/{customer_id}/bank-account`](/api-reference/bank-accounts/create-bank-account-api) after the organization is approved.
* **Reference:** [Bank account creation](/api-reference/bank-accounts/create-bank-account-api)
## Per-wallet KYC status fields
`Wallet.kycStatus` and `Wallet.kycOnChain` have been removed from wallet list, search, get, and register responses. KYC state is organization-level.
* **Status:** Removed.
* **Replacement:** query [`GET /ramp/customer/{customer_id}/kyc`](/api-reference/kyc/get-kyc-status) or consume the [`kyc_updated`](/guides/verifying-webhooks) webhook.
* **Reference:** [Get KYC status](/api-reference/kyc/get-kyc-status)
* **Change (Week of August 14, 2026):** removed from the `Wallet` object.
## KYB progress endpoint
`GET /ramp/organization/{id}/kyb/progress`
Per-requirement KYB counts are replaced by a single status. The requirement breakdown they
counted belongs to the in-house KYB flow, which business verification no longer uses.
* **Status:** Deprecated. **Sunset: August 16, 2026.**
* **Replacement:** [`GET /ramp/organization/{id}/kyb/status`](/api-reference/organizations/get-kyb-status), which returns the same shape the [`kyb_updated`](/guides/verifying-webhooks) webhook delivers.
* **Reference:** [Get KYB progress](/api-reference/deprecated/get-kyb-progress)
## Requirement counts on `kyb_updated`
The `approved`, `submitted`, `notStarted` and `total` counts have been removed from the
[`kyb_updated`](/guides/verifying-webhooks) webhook payload. It now carries `organizationId`,
`status` and `approvedAt`.
* **Status:** Removed.
* **Replacement:** `status` alone. `submitted` means everything the business can do is done and it is Etherfuse's turn, which is what the counts were read for.
* **Reference:** [kyb\_updated payload](/api-reference/webhooks/kyb_updated)
**Not deprecated:** `GET /ramp/customer/{customer_id}/kyc` (KYC status) and the
[`kyc_updated`](/guides/verifying-webhooks) webhook are unchanged; keep using them to track KYC
state.
## Stablebond cost lookup
`GET /lookup/bonds/cost` · `GET /lookup/bonds/cost/{identifier}`
These price stablebonds only, so they cannot answer for a principal token such as MEXe.
`/lookup/tokens/cost` prices every Etherfuse token, bonds and principal tokens alike, and
renames the `bond_*` fields to `token_*` accordingly.
* **Status:** Deprecated. **No sunset date.** They keep working with their existing fields.
* **Replacement:** [`GET /lookup/tokens/cost`](/api-reference/lookup/list-token-costs) and [`GET /lookup/tokens/cost/{identifier}`](/api-reference/lookup/get-token-cost).
* **Reference:** [List stablebond costs](/api-reference/lookup/list-stablebond-costs) · [Get stablebond cost](/api-reference/lookup/get-stablebond-cost)
Field mapping:
| `/lookup/bonds/cost` | `/lookup/tokens/cost` |
| - | - |
| `bond_symbol` | `token_symbol` |
| `bond_cost_in_fiat` | `token_cost_in_fiat` |
| `bond_cost_in_usd` | `token_cost_in_usd` |
| `sources[].bond_cost_in_usd` | `sources[].token_cost_in_usd` |
| `mint` (the payment token, not the bond) | `payment_mint`, and `token_mint` for the token itself |
| `symbol` (always `"USDC"`) | dropped; use `payment_mint` |
| `bond_cost_in_payment_token` | dropped; it duplicated `bond_cost_in_fiat` |
| n/a | `token_kind`, `underlying_bond_symbol` |
The list endpoint is also keyed differently: `/lookup/bonds/cost` keys by Solana mint address,
`/lookup/tokens/cost` keys by uppercased token symbol.