Skip to main content
This is the end-to-end Virtual Account integration guide for developers. It walks through the complete API sequence — customer creation, KYC, quoting, order creation, and webhook tracking — that you’ll need to build a production-grade onramp or offramp.

Before you begin

  • You have completed the Virtual Account Quickstart end to end at least once in sandbox
  • You have a Meld API key with virtual-account access (sandbox first, production for go-live)
  • You have a webhook endpoint configured in Developer → Webhooks in the dashboard
  • If you are KYCing end users, you have at least one virtual-account provider (Noah, Due, or Brale) enabled on your account

Notes

  • This flow is supported for both businesses and individuals. Businesses will have to KYB with Meld, which is manual. Users will have to KYC via Meld with the onramp, which can be done via API, and is described below.
  • While both Noah and Due support this flow for both businesses and users, Brale only supports this flow for businesses at this time. Brale also requires an additional redemption step in the onramp flow in order for the business to receive their crypto.

Full Integration Guide

This is the end-to-end guide for executing virtual account transactions (onramp and offramp) through the Meld API.
API compatibility: New fields may appear in responses without a version bump. Use flexible JSON parsing and do not reject unknown properties. Breaking changes ship under a dated Meld-Version.

How it works

  1. Create a customer in the Account API for the individual or business that will transact.
  2. KYB your business with Meld.
  3. If your end users will be making transactions, then KYC each customer with the provider (e.g. Noah, Due). Meld asks the provider to verify the customer; the provider responds asynchronously.
  4. Get a quote from the Payment API for the currency pair and amount.
  5. Create an onramp order (fiat to crypto) or offramp order (crypto to fiat).
  6. The business or individual completes the fiat or crypto leg (bank transfer or wallet send).
  7. Track progress via webhooks and the Transactions API.

1. Create a Customer

API Endpoint: POST /accounts/customers Request:
{
  "externalId": "your-internal-user-id-123",
  "name": {
    "firstName": "John",
    "lastName": "Doe"
  },
  "email": "john@example.com",
  "phone": "+14155551234",
  "dateOfBirth": "1990-03-15",
  "type": "INDIVIDUAL"  // or BUSINESS if you are a business
}
Response (abbreviated):
{
  "id": "WmYYgvN8ukpV62N3m4u3ee",
  "accountId": "W2aRZnYGPwhBWB94iFsZus",
  "externalId": "your-internal-user-id-123",
  "name": {
    "firstName": "John",
    "lastName": "Doe"
  },
  "email": "john@example.com",
  "phone": "+14155551234",
  "dateOfBirth": "1990-03-15",
  "status": "ACTIVE",
  "type": "INDIVIDUAL",
  "serviceProviderCustomers": [],
  "addresses": []
}
Save the returned id — you will pass it as customerId on every subsequent call.
If you plan on using DUENETWORK later for executing transactions, you must also specify the customer’s address by calling POST /accounts/customers/{customerId}/addresses.
Note: Order and transaction requests also accept optional subaccountCustomerId and externalSubaccountCustomerId fields. Use these to tag which sub-account or business grouping a transaction belongs to for reporting and filtering purposes.

2. KYC / KYB

If you are transacting as a business, you will KYB with Meld. If your end users are transacting, each will have to KYC once with each onramp they use. KYC happens via Sumsub. If you have already KYCed your users with Sumsub, you can pass in the KYC token — see You KYC the user. If you’d prefer Meld to KYC the user, that process is described in Meld KYCes the user. To test KYC in the sandbox, follow the steps in KYC testing. Initiate KYC: API endpoints:
  • POST /accounts/customers/{customerId}/kyc/initiate
  • PATCH /accounts/customers/{customerId}/kyc/initiate
Request:
{
  "serviceProvider": "SUMSUB",
  "mode": "HOSTED_URL"
}
Alternatively, if you already have a user who has passed KYC on your Sumsub account, you can import it into Meld by sharing the Sumsub applicant token (TOKEN_IMPORT mode). To import the KYC of the user, call POST /accounts/customers/{customerId}/kyc/initiate. The customerId is in the path, and your request body looks like this:
{
  "serviceProvider": "SUMSUB",
  "mode": "TOKEN_IMPORT",
  "serviceProviderDetails": {
    "kycToken": "_act-sbx-jwt-eyJhbGciOiJub25lIn0.eyJqdGkiOiJfYWN0LXNieC1mNGFlNWYwYy1iYjFmLTQwZmUtYTk1YS1jZWM0MmY3OTBjYmIiLCJ1cmwiOiJodHRwczovL2FwaS5zdW1zdWIuY29tIn0.",
    "applicantId": "Sumsub applicant id"
  }
}
Response:
{
  "customerId": "WmYYgvN8ukpV62N3m4u3ee",
  "serviceProvider": "SUMSUB",
  "status": "PENDING",
  "url": "https://kyc-provider.example.com/verify/abc123"
}
Direct the user to the url to complete verification. If you are not passing in a token, Sumsub will ask the user for all required fields, and if the token is passed in, Sumsub will only prompt the user for any missing fields. KYC completes asynchronously — the provider notifies Meld via webhook, and Meld publishes a CUSTOMER_KYC_STATUS_CHANGE webhook to your endpoint. Sample KYC webhook payload:
{
  "eventType": "CUSTOMER_KYC_STATUS_CHANGE",
  "eventId": "BBxyzABC123456defGHIjk",
  "timestamp": "2025-02-24T14:20:00.000000Z",
  "accountId": "WQ5RyhdFzE45qjsomdzQ1u",
  "version": "2025-03-01",
  "payload": {
    "customerId": "WmYYgvN8ukpV62N3m4u3ee",
    "status": "APPROVED"
  }
}
Poll for KYC Status: API Endpoint: GET /accounts/customers/{customerId} Check serviceProviderCustomers[].kyc.status. Values: PENDING, APPROVED, REJECTED, EXPIRED, UNKNOWN. Wait for the kyc.status to be APPROVED before proceeding. The customer must be KYC-approved before any transactions can be created. In both KYC initiation modes (HOSTED_URL and TOKEN_IMPORT), you can optionally specify Virtual Account providers you want to share the user’s KYC with after Sumsub approves submission, via the kycShareProviders field. Alternatively, you can add share providers later by calling PATCH /accounts/customers/{customerId}/kyc/initiate.
{
  "serviceProvider": "SUMSUB",
  "mode": "HOSTED_URL",
  "kycShareProviders": ["NOAH", "DUENETWORK"]
}
{
  "serviceProvider": "SUMSUB",
  "mode": "TOKEN_IMPORT",
  "kycShareProviders": ["NOAH", "DUENETWORK"],
  "serviceProviderDetails": {
    "kycToken": "_act-sbx-jwt-eyJhbGciOiJub25lIn0.eyJqdGkiOiJfYWN0LXNieC1mNGFlNWYwYy1iYjFmLTQwZmUtYTk1YS1jZWM0MmY3OTBjYmIiLCJ1cmwiOiJodHRwczovL2FwaS5zdW1zdWIuY29tIn0.",
    "applicantId": "Sumsub applicant id"
  }
}
After Sumsub verifies the user’s submission, the KYC token will be automatically shared with all specified share Virtual Account providers, effectively onboarding the user on Virtual Account’s platforms. The Virtual Account provider may require additional KYC for users in case the data shared by Sumsub is insufficient. To track this, you can either listen for CUSTOMER_KYC_STATUS_CHANGE webhooks. Supported share providers and their requirements
  • Noah
  • Due Network. Requires customer country to be specified — call POST /accounts/customers/{customerId}/addresses to add an address.
Expected Webhook Response
{
  "eventType": "CUSTOMER_KYC_STATUS_CHANGE",
  "eventId": "customerId",
  "timestamp": "2022-02-24T16:36:41.717262Z",
  "payload": {
    "accountId": "WeP9eoFziQX4yXE5abcfec",
    "customerId": "customerId",
    "serviceProvider": "SUMSUB",
    "status": "APPROVED",
    "kycRecepient": {
      "serviceProvider": "Virtual account provider",
      "status": "PENDING" // PENDING, REJECTED, APPROVED
    },
    "statusUpdatedAt": "2022-02-24T16:36:41.717262Z"
  }
}
After receiving a webhook event, you can call GET /accounts/customers for more details. For example, when the Virtual Account provider requires additional KYC on their platform, you will receive a webhook event with kycRecipient.status=PENDING. Then, when calling GET /accounts/customers, you will receive the following response:
{
  "id": "customerId",
  "accountId": "accountId",
  "serviceProviderCustomers": [
  {
    "serviceProvider": "Virtual Account Provider",
    "id": "provider customer id",
    "kyc": {
      "status": "PENDING",
      "updatedAt": "2026-04-08T20:04:20.457281Z",
      "additionalInfo": {
        "HostedURL": "https://virtual-provider-paltform.com/additionalKycHostedURL" 
				// addtional provider specific kyc fields, like addtional requirements (documents, questionnaire, etc.)
      },
      "onboardingMethod": {
        "kycProvider": "SUMSUB",
        "onboardingType": "KYC_TOKEN_SHARE"
      }
    }
  }
  ],
  "status": "ACTIVE",
  "type": "INDIVIDUAL"
}
You will need to direct your user to HostedURL so they can pass additional KYC on the Virtual Account provider’s platform. After users complete additional KYC and the Virtual Account provider verifies it, you will receive another CUSTOMER_KYC_STATUS_CHANGE event with kycRecipient.status=APPROVED. The new status will also be reflected in GET /accounts/customers responses. Errors:
  • Calling initiate again for the same customer + provider returns 409.

3. Configure webhooks

In the Meld dashboard (Developer > Webhooks), add your endpoint URL and subscribe to at minimum:
  • TRANSACTION_CRYPTO_PENDING
  • TRANSACTION_CRYPTO_TRANSFERRING
  • TRANSACTION_CRYPTO_COMPLETE
  • TRANSACTION_CRYPTO_FAILED
  • CUSTOMER_KYC_STATUS_CHANGE
You can find all webhook events in Webhook events. Verify webhook signatures per Webhook authentication.

4. Get a Quote

API Endpoint: POST /payments/virtual-account/crypto/quote Note that the provider Brale doesn’t support quotes. All Brale transactions are 1:1, aka for $100 you will receive 100 USDC. Request:
{
  "countryCode": "US",
  "sourceAmount": 100,
  "sourceCurrencyCode": "USD",
  "destinationCurrencyCode": "USDC",
  "paymentMethodType": "ACH",
  "customerId": "WmYYgvN8ukpV62N3m4u3ee"
}
For a sell quote, swap the currency codes: sourceCurrencyCode is the crypto, destinationCurrencyCode is the fiat. Response (abbreviated):
{
  "quotes": [
    {
      "transactionType": "CRYPTO_PURCHASE",
      "sourceAmount": 100,
      "sourceCurrencyCode": "USD",
      "destinationAmount": 99.10,
      "destinationCurrencyCode": "USDC",
      "totalFee": 0.90,
      "networkFee": 0.00,
      "transactionFee": 0.90,
      "exchangeRate": 1.0,
      "paymentMethodType": "ACH",
      "serviceProvider": "NOAH"
    }
  ]
}
Key fields per quote:
FieldMeaning
sourceAmountWhat the user spends (fiat for buy, crypto for sell)
destinationAmountWhat the user receives
totalFeeTotal fees in the fiat currency
serviceProviderProvider backing this quote
transactionTypeCRYPTO_PURCHASE (buy) or CRYPTO_SELL (sell)
Present quotes to the user. Capture the selected serviceProvider for the next step.

5. Create an Order

Buy (onramp) — fiat to crypto

API Endpoint: POST /payments/virtual-account/ramp/onramp/order Use the customerId from Step 1. The serviceProvider comes from the quote the user selected. Request:
{
  "customerId": "WmYYgvN8ukpV62N3m4u3ee",
  "sourceAmount": 100,
  "sourceCurrencyCode": "USD",
  "destinationCurrencyCode": "USDC",
  "destinationWalletAddress": "0x51FB80013111111111111112121111111",
  "paymentMethodType": "ACH",
  "serviceProvider": "NOAH"
}
Response:
{
  "orderId": "WeP9eoFziQX4yXE5abcfec",
  "paymentMethodType": "ACH",
  "receivingBankInformation": {
    "accountNumber": "9876543210",
    "routingNumber": "021000021"
  },
  "serviceProviderDetails": {}
}
The user sends fiat to the returned bank details from their banking app. The provider settles crypto to destinationWalletAddress after receiving the funds.

Sell (offramp) — crypto to fiat

API Endpoint: POST /payments/virtual-account/ramp/offramp/order Request:
{
  "customerId": "WmYYgvN8ukpV62N3m4u3ee",
  "sourceAmount": 100,
  "sourceCurrencyCode": "USDC",
  "destinationCurrencyCode": "USD",
  "sourceWalletAddress": "0x51FB80013111111111111112121111111",
  "paymentMethod": {
    "type": "ACH",
    "owner": "John Doe",
    "details": {
      "accountType": "CHECKING",
      "routingNumber": "1111111111",
      "accountNumber": "22222222",
      "bankName": "Chase Bank",
      "beneficiaryAddress": {
        "street_line_1": "123 Market St.",
        "street_line_2": null,
        "city": "San Francisco",
        "state": "CA",
        "postalCode": "94115",
        "country": "US"
      },
      "bankAddress": {
        "street_line_1": "270 Park Avenue",
        "street_line_2": null,
        "city": "New York",
        "state": "NY",
        "postalCode": "10017",
        "country": "US"
      }
    }
  },
  "serviceProvider": "NOAH"
}
Offramp paymentMethod.type options:
TypeDetails schema
ACH / LOCAL_BANK_TRANSFERaccountType, routingNumber, accountNumber, bankName, beneficiaryAddress, bankAddress
SEPAiban
Response:
{
  "orderId": "WfR7abcDEF12345xyz9876",
  "paymentMethodType": "ACH",
  "walletAddress": "0xABCDEF1234567890ABCDEF1234567890ABCDEF12"
}
The user sends crypto to walletAddress from their wallet. The provider pays out fiat to the bank details after receiving the crypto. Make sure the user sends the correct amount of crypto as in the Create Sell Order API call.

6. Track the Transaction

Webhooks

Meld sends webhooks as the transaction progresses. Correlate to your order using virtualAccountRampOrderId in the payload (matches the orderId from the order response). Find more information on the various webhook event types in Webhook events.

Sample webhook payload (buy)

{
  "eventType": "TRANSACTION_CRYPTO_PENDING",
  "eventId": "AAsuLXHXD3mS1cjNBuHHzv",
  "timestamp": "2025-02-24T16:36:41.717262Z",
  "accountId": "WQ5RyhdFzE45qjsomdzQ1u",
  "profileId": "W9ka8vLE4ufBkSg3BEciZb",
  "version": "2025-03-01",
  "payload": {
    "virtualAccountRampOrderId": "WeP9eoFziQX4yXE5abcfec",
    "paymentTransactionId": "WePZCYJW7cdXR7SxUMp8mE",
    "customerId": "WmYYgvN8ukpV62N3m4u3ee",
    "externalCustomerId": "your-internal-user-id-123",
    "paymentTransactionStatus": "PENDING",
    "transactionType": "CRYPTO_PURCHASE",
    "sessionId": "WePjVaT4iBHPpqW49F419x"
  }
}
For sell transactions, transactionType is CRYPTO_SELL. The paymentTransactionStatus field shows the current status of the transaction. Find the list of transaction statuses and their meanings in Transaction statuses.

Fetch the full transaction

Extract paymentTransactionId from the webhook and call: API Endpoint: GET /payments/transactions/{paymentTransactionId} Sample response:
{
  "id": "WePZCYJW7cdXR7SxUMp8mE",
  "accountId": "W2aRZnYGPwhBWB94iFsZus",
  "transactionType": "CRYPTO_PURCHASE",
  "status": "SETTLED",
  "sourceAmount": 100.00,
  "sourceCurrencyCode": "USD",
  "destinationAmount": 99.10,
  "destinationCurrencyCode": "USDC",
  "paymentMethodType": "ACH",
  "serviceProvider": "NOAH",
  "serviceTransactionId": "noah-tx-abc123",
  "orderId": "WeP9eoFziQX4yXE5abcfec",
  "customer": {
    "id": "WmYYgvN8ukpV62N3m4u3ee",
    "externalId": "your-internal-user-id-123"
  },
  "countryCode": "US",
  "sessionId": "WePjVaT4iBHPpqW49F419x",
  "externalSessionId": null,
  "createdAt": "2025-02-24T16:36:41.000000Z",
  "updatedAt": "2025-02-24T17:05:12.000000Z"
}
The orderId on the transaction matches virtualAccountRampOrderId in the webhook and the orderId from your order response, tying all three together.

Sequence diagram

Virtual Account Integration Flow

Testing

Provider sandboxes often cannot complete full settlement — you may only reach virtual bank account creation. See Virtual account flow testing credentials for provider-specific sandbox values (Noah, Due, Brale).

Next steps

Testing your implementation

Advanced features

Production deployment


Support & resources