Skip to main content
BVNK

Collect payments via Channels

Beta

The Payments v.3 API endpoints are in beta and may change as we enhance the product. See Migrate crypto payments from v.1 for background on this project.

Use channels to add crypto payments to your checkout flow. A channel is a reusable deposit address: create one per customer, and they can pay into it as many times as they like. In this guide, you'll use the BVNK-hosted page, though you can build your own UI using the API responses. We'll also cover the channel payin resource, so you understand what changes on your backend each time a customer pays into a channel.

When a customer tops up with cryptocurrency for the first time, create a channel linked to their customer ID in your system so you can display the correct address whenever they want to send funds.

Create a channel​

A channel has no amount. There's nothing to fix and nothing to quote when you create one: amounts, rates, and fees only exist on the individual channel payins that arrive through it, covered below.

To create a channel, send a POST /payment/v3/channels request. The payload splits across three objects: originator for the paying customer, beneficiary for your wallet, and checkout for the currency to display and any network restrictions.

  • reference: Your own identifier for the channel.
  • originator.currency: The currency the customer pays in, for example "USDT". Omit to let the customer choose on the hosted page.
  • originator.customerId or originator.customerReference: Link the channel to a customer. Send customerId if you already have a BVNK-issued customer ID for them, or customerReference for your own free-text identifier.
  • originator.requesterIpAddress: The customer's IP address, used for compliance checks.
  • originator.entity or originator.contactId: The customer's compliance details, structured the same way as everywhere else in the v.3 API (type, then firstName/lastName/dateOfBirth for an individual or legalName/registrationNumber for a company, plus relationshipType and address). Send entity or contactId, never both.
  • beneficiary.walletId: Your wallet that receives funds paid into this channel.
  • checkout.currency: The currency to display to the customer, for example "ZAR". Can be a currency BVNK doesn't settle in.
  • checkout.currencyOptions[]: Optional. Restricts which currencies and networks the channel accepts, for example limiting USDT to Ethereum and Polygon only. Omit to accept everything available.
Request: POST /payment/v3/channels
{
"reference": "c1b933d5-3354-4f83-a05f-0b53f1be85f2",
"originator": {
"currency": "USDT",
"customerId": "d063635e-0f83-4e47-a1f3-fc9484df1509",
"requesterIpAddress": "172.16.254.1",
"entity": {
"type": "INDIVIDUAL",
"firstName": "John",
"lastName": "Doe",
"dateOfBirth": "1984-06-30",
"relationshipType": "THIRD_PARTY",
"address": {
"country": "GB"
}
}
},
"beneficiary": {
"walletId": "a:24092328494070:G5i4XZ9:1"
},
"checkout": {
"currency": "ZAR",
"currencyOptions": [
{
"currency": "USDT",
"network": ["ETHEREUM", "POLYGON"]
}
]
}
}
tip

Instead of passing entity inline with every channel, you can create a reusable contact and reference it by contactId. See Manage contacts for details. You cannot pass both entity and contactId in the same request; doing so returns an error.

After successfully submitting the request, you receive the following response:

Response: POST /payment/v3/channels
{
"id": "9d1f67f2-a647-404b-9b02-247c77be81d0",
"reference": "c1b933d5-3354-4f83-a05f-0b53f1be85f2",
"type": "payment:channel",
"method": "CRYPTO",
"status": "OPEN",
"originator": {
"currency": "USDT",
"customerId": "d063635e-0f83-4e47-a1f3-fc9484df1509",
"customerReference": null,
"requesterIpAddress": "172.16.254.1",
"entity": {
"type": "INDIVIDUAL",
"firstName": "John",
"lastName": "Doe",
"dateOfBirth": "1984-06-30",
"relationshipType": "THIRD_PARTY",
"address": {
"addressLine1": null,
"addressLine2": null,
"city": null,
"region": null,
"postCode": null,
"country": "GB"
}
}
},
"beneficiary": {
"currency": "EUR",
"walletId": "a:24092328494070:G5i4XZ9:1"
},
"instructions": [
{
"type": "CRYPTO",
"network": "ETHEREUM",
"currency": "USDT",
"address": "0x225c8e46493f603e4577ab059c9fb38bb70475ba",
"tag": null,
"uri": "ethereum:0x225c8e46493f603e4577ab059c9fb38bb70475ba"
},
{
"type": "CRYPTO",
"network": "POLYGON",
"currency": "USDT",
"address": "0x101bfbc5018b777cb531aa9d68511b96d800b9c6",
"tag": null,
"uri": "polygon:0x101bfbc5018b777cb531aa9d68511b96d800b9c6"
}
],
"checkout": {
"currency": "ZAR",
"displayRate": {
"from": "EUR",
"to": "ZAR",
"rate": "19.678"
},
"currencyOptions": [
{
"currency": "USDT",
"network": ["ETHEREUM", "POLYGON"]
}
],
"redirectUrl": "https://pay.bvnk.com/channel/9d1f67f2-a647-404b-9b02-247c77be81d0"
},
"metadata": {},
"updatedAt": "2026-02-23T10:26:34Z",
"createdAt": "2026-02-23T10:26:34Z"
}

Retrieve a channel at any time with GET /payment/v3/channels/{id}. The response has this same shape. id is always a UUID string, matching every other resource in the API.

Key takeaways include the following:

  • instructions[] is an array with one entry per accepted currency and network combination. Share any entry's address with the customer, or let them choose a network on the hosted page.
  • checkout.redirectUrl is the URL you can redirect customers to each time they want to top up.
  • checkout.displayRate shows the rate between the display currency and the settlement currency.
Avoid assigning the same channel to multiple end-users

You won't be able to determine which customer sent the funds. Unlike fiat transfers, blockchain transactions don't include customer references. Thus, providing each user with a unique channel ensures you can correctly match their top-ups to their account.

tip

Instead of just showing customers the destination address, redirect them to checkout.redirectUrl whenever they want to top up. On that page, they will be able to scan a QR code from their wallet. This will eliminate the chance of mistyped addresses. Customers will also be able to view the exact exchange rate for the transaction.


Provide additional party information​

You may also need to provide additional party information to comply with the Travel Rule. See the Provide additional party information guide for more details.


Channel payins​

Each payment a customer makes into a channel creates its own channel payin: a full payment resource. Retrieve one directly with GET /payment/v3/channel-payins/{id}, or read the identical shape straight off the payment:v3:channel-payin:status-change webhook payload described below.

A channel payin carries:

  • channelId: References the parent channel this payment arrived through, for example the channel created above.
  • originator: The customer who paid: actual holds the currency and amount actually sent, their sending addresses (originator.paymentInstrument.addresses[]), and the same compliance entity shape as the parent channel.
  • beneficiary: actual holds the currency and amount credited to your wallet, plus the walletId that received it. A channel has no requested amount, so required.amount is null on every side.
  • conversion: Present once the incoming currency has been converted into your wallet currency. Always AUTO_CONVERSION for channel payins: there's no quote step to accept, since the customer has already sent funds by the time BVNK prices the conversion.
  • fees: customerFee (your own markup on the payin, if configured) and processingFee, each with estimated and actual.
  • instructions[] and checkout: Echo the parent channel's accepted networks and display currency.
  • trackingDetails[]: The on-chain transaction that funded this payin.

For example, the customer from the channel above pays 100 USDT, converted into your EUR wallet:

Response: GET /payment/v3/channel-payins/{id}
{
"id": "98c0bb03-567f-11f0-b26e-6b1848874a27",
"reference": "c1b933d5-3354-4f83-a05f-0b53f1be85f2",
"type": "payment:channel-payin",
"direction": "IN",
"status": "COMPLETED",
"method": "CRYPTO",
"channelId": "9d1f67f2-a647-404b-9b02-247c77be81d0",
"fees": {
"customerFee": { "estimated": { "currency": "EUR", "amount": "2.00" }, "actual": { "currency": "EUR", "amount": "2.00" } },
"processingFee": { "estimated": { "currency": "EUR", "amount": "0.46" }, "actual": { "currency": "EUR", "amount": "0.46" } },
"networkFee": null
},
"originator": {
"required": { "currency": "USDT", "amount": null },
"actual": { "currency": "USDT", "amount": "100" },
"customerId": "d063635e-0f83-4e47-a1f3-fc9484df1509",
"customerReference": null,
"entity": {
"type": "INDIVIDUAL",
"firstName": "John",
"lastName": "Doe",
"dateOfBirth": "1984-06-30",
"relationshipType": "THIRD_PARTY",
"address": {
"addressLine1": null,
"addressLine2": null,
"city": null,
"region": null,
"postCode": null,
"country": "GB"
}
},
"paymentInstrument": {
"type": "CRYPTO_ADDRESS",
"network": "ETHEREUM",
"addresses": ["0x3f5ce5fbfe3e9af3971dd833d26ba9b5c936f0be"],
"tag": null
}
},
"beneficiary": {
"required": { "currency": "EUR", "amount": null },
"actual": { "currency": "EUR", "amount": "91.54" },
"walletId": "a:24092328494070:G5i4XZ9:1"
},
"conversion": {
"type": "AUTO_CONVERSION",
"baseRate": "0.92",
"allInRate": "0.9154",
"spread": {
"percentage": "0.005",
"amount": "0.46",
"currency": "EUR"
}
},
"instructions": [
{
"type": "CRYPTO",
"network": "ETHEREUM",
"currency": "USDT",
"address": "0x225c8e46493f603e4577ab059c9fb38bb70475ba",
"tag": null,
"uri": "ethereum:0x225c8e46493f603e4577ab059c9fb38bb70475ba"
},
{
"type": "CRYPTO",
"network": "POLYGON",
"currency": "USDT",
"address": "0x101bfbc5018b777cb531aa9d68511b96d800b9c6",
"tag": null,
"uri": "polygon:0x101bfbc5018b777cb531aa9d68511b96d800b9c6"
}
],
"trackingDetails": [
{
"amount": "100",
"currency": "USDT",
"hash": "0x975ca9788bd6b82e45414e8a7734a39780ed4bf519d41c85eb58f2dd0501df33",
"network": "ETHEREUM",
"createdAt": "2026-05-14T08:50:31Z",
"confirmedAt": "2026-05-14T08:51:43Z"
}
],
"checkout": {
"required": { "currency": "ZAR", "amount": null },
"actual": { "currency": "ZAR", "amount": "1801.32" },
"displayRate": {
"from": "EUR",
"to": "ZAR",
"rate": "19.678"
},
"currencyOptions": [
{
"currency": "USDT",
"network": ["ETHEREUM", "POLYGON"]
}
]
},
"labels": [],
"relatedPayments": [],
"statusHistory": [
{
"status": "PROCESSING",
"timestamp": "2026-05-14T08:50:31Z"
},
{
"status": "COMPLETED",
"timestamp": "2026-05-14T08:51:43Z"
}
],
"metadata": {},
"updatedAt": "2026-05-14T08:51:43Z",
"createdAt": "2026-05-14T08:50:31Z"
}

While the payment is still on-chain and unconfirmed, status is PROCESSING, conversion is null, and beneficiary.actual is null. status moves to HELD if BVNK holds the payment for compliance review and back to PROCESSING once released, or to COMPLETED once it settles, as in the example above.

Channel webhooks​

To get notified when a channel is created or a channel payin changes status, listen to the following webhooks:

  • payment:v3:channel:created: fires once, when the channel is created and its status is OPEN.
  • payment:v3:channel-payin:status-change: fires on every channel payin state change. Read data.status (PROCESSING, HELD, COMPLETED, or FAILED) rather than branching on event name. The payload is the same channel payin resource shown above.
Was this page helpful?