Пошаговое руководство для продуктов, которым нужно выдавать пользователям виртуальные VISA-карты.
Вы — криптокошелёк, необанк или любой потребительский сервис. Ваши пользователи хотят платить виртуальной картой за подписки и онлайн- покупки, а вы не хотите строить выпуск карт с нуля.
С Lumo B2B ваш пользователь получает активную VISA с полным номером и CVC через ~2 секунды после нажатия кнопки.
- Мгновенно. Карта выдаётся из нашего стока — один HTTP-запрос.
- 3DS OTP через API. Код подтверждения покупки приходит вебхуком и доступен по запросу. SIM-карта не нужна.
- Пополнение с USDT. Списываем с баланса компании в USDT — пользователь вообще не касается блокчейна.
- Полный PAN и CVC. Не только маска — реальные данные для ввода в интернет-магазине.
Пользователь ──► Ваш продукт ──► Lumo B2B API ──► Виртуальная VISA
│ │
│ Интернет-магазин
│ │
└────── USDT баланс ◄────── оплата / 3DS-кодДеньги списываются с USDT-баланса вашей компании в Lumo. Вы берёте с пользователя (фиат или крипта) столько, сколько хотите — разница ваша маржа.
Перед первым вызовом убедитесь, что у компании в Lumo включена программа карт. Проверить можно через GET /auth/company/profile:
{
"cards": {
"enabled": true,
"openingFeeUsdt": 5,
"commissionBps": 80,
"minInitialDepositUsdt": 10,
"minRechargeUsdt": null,
"testMode": false
}
}Ключу интеграции понадобятся права cards.read, cards.create, cards.recharge и — для показа реквизитов и 3DS-кодов — cards.sensitive. См. Команда и права.
Если enabled: false — напишите в Telegram, мы активируем.
// issueId — ваш идентификатор этой выдачи: создаётся один раз и
// переиспользуется при повторах. Он же идёт в Idempotency-Key и в externalId,
// чтобы результат выдачи всегда можно было найти через GET /cards?externalId=.
async function issueCard(userId, issueId) {
const res = await fetch('https://b2b.lumowallet.io/cards/request', {
method: 'POST',
headers: {
'X-API-Key': process.env.LUMO_API_KEY,
'Idempotency-Key': issueId,
'Content-Type': 'application/json',
},
body: JSON.stringify({ externalId: issueId }),
});
const body = await res.json();
// Повтор той же выдачи: externalId уже занят нашей же картой или заявкой
if (res.status === 409 && body.errorCode === 'CARD_EXTERNAL_ID_TAKEN') {
return body.details.cardId
? { status: 'assigned', cardId: body.details.cardId }
: { status: 'queued', lumoRequestId: body.details.requestId };
}
if (!res.ok) {
throw new Error(`${body.errorCode}: ${body.message}`);
}
if (body.status === 'assigned') {
await db.cards.upsert({
userId,
lumoCardId: body.card.id,
maskedPan: body.card.maskedPan,
});
return body.card;
}
// status === 'pending' — карт в стоке нет, ждём webhook card.assigned
await db.cardRequests.upsert({
userId,
lumoRequestId: body.request.id,
});
return { status: 'queued' };
}Вебхук card.assigned придёт и на мгновенную выдачу — с requestId: null. Сохраняйте карту по cardId идемпотентно, чтобы не завести её дважды.
При status: 'assigned' карта уже работает. Комиссия за выпуск (openingFeeUsdt) списана с баланса компании автоматически.
Когда пользователь хочет вбить данные в онлайн-магазине, запросите полный номер и CVC через отдельный endpoint:
async function revealCard(lumoCardId) {
const res = await fetch(
`https://b2b.lumowallet.io/cards/${lumoCardId}/sensitive`,
{ headers: { 'X-API-Key': process.env.LUMO_API_KEY } },
);
return res.json();
}Ответ:
{
"cardId": "8c7f1823-...",
"cardNumberFull": "4466123412341234",
"cardCvv": "123",
"expiry": "03/29"
}⚠️ Безопасность. На вашей стороне никогда не храните
cardNumberFullиcardCvvв БД. Запрашивайте в момент показа, держите в памяти на время сессии, очищайте при закрытии экрана. Каждый показ реквизитов логируется на нашей стороне.
Рекомендуем auto-hide через 30 секунд:
function CardDetails({ cardId }) {
const [data, setData] = useState(null);
const [seconds, setSeconds] = useState(0);
const show = async () => {
setData(await revealCard(cardId));
setSeconds(30);
};
useEffect(() => {
if (seconds === 0) { setData(null); return; }
const t = setTimeout(() => setSeconds(s => s - 1), 1000);
return () => clearTimeout(t);
}, [seconds]);
if (!data) return <button onClick={show}>Показать карту</button>;
return (
<div>
<div>{data.cardNumberFull.replace(/(.{4})/g, '$1 ').trim()}</div>
<div>CVC: {data.cardCvv}</div>
<div>Скрыто через {seconds}s</div>
</div>
);
}Когда пользователь делает покупку, эквайер присылает одноразовый код подтверждения. SIM-карта не нужна — мы получаем код из почты карты и отдаём через API и вебхук. Код действует 5 минут.
Вариант A — поллинг (если не хотите webhook):
async function waitForOtp(lumoCardId, timeoutMs = 90_000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const res = await fetch(
`https://b2b.lumowallet.io/cards/${lumoCardId}/otp/latest`,
{ headers: { 'X-API-Key': process.env.LUMO_API_KEY } },
);
const text = await res.text(); // нет кода — пустое тело, а не null
const otp = text ? JSON.parse(text) : null;
if (otp && new Date(otp.expiresAt) > new Date()) return otp;
await sleep(3000);
}
throw new Error('OTP not received');
}Вариант B — webhook (рекомендуется):
Событие card.otp_received приходит на webhookUrl компании вместе со всеми остальными событиями — отдельно подписываться не нужно:
{
"event": "card.otp_received",
"cardId": "8c7f1823-...",
"code": "582130",
"maskedCard": "1234",
"receivedAt": "2026-04-24T12:00:10.000Z",
"expiresAt": "2026-04-24T12:05:10.000Z",
"createdAt": "2026-04-24T12:00:11.000Z"
}Моментально проталкивайте код пользователю в интерфейс, пуш-уведомлением или Telegram-ботом.
Если пользователю нужен не только выпуск, но и возможность пополнять карту USDT — используйте двухфазный recharge:
// idempotencyKey создаётся один раз на пополнение (например, при создании
// записи о пополнении у вас) и переиспользуется при всех повторах запроса.
async function rechargeCard(lumoCardId, usdAmount, idempotencyKey) {
// 1. Посчитать сколько USDT будет списано
const quote = await fetch('https://b2b.lumowallet.io/cards/recharge/quote', {
method: 'POST',
headers: {
'X-API-Key': process.env.LUMO_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ cardId: lumoCardId, targetAmount: usdAmount }),
}).then(r => r.json());
console.log(`Будет списано ${quote.totalDebitUsdt} USDT (включая ${quote.commissionUsdt} наша комиссия)`);
// 2. Подтвердить
const recharge = await fetch('https://b2b.lumowallet.io/cards/recharge', {
method: 'POST',
headers: {
'X-API-Key': process.env.LUMO_API_KEY,
'Idempotency-Key': idempotencyKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({ cardId: lumoCardId, targetAmount: usdAmount }),
}).then(r => r.json());
return recharge;
}Сумма пополнения — от 10 USD. Финальный статус (succeeded / failed / refunded) приходит webhook'ом card.recharge.* и виден в GET /cards/{id}/recharges. Если ответ на запрос не пришёл, повторите его с тем же Idempotency-Key — второго списания не будет.
Пользователь хочет видеть свои покупки — отдавайте через:
async function listUserTransactions(lumoCardId, before) {
const params = new URLSearchParams({ limit: '50' });
if (before) params.set('before', before);
const res = await fetch(
`https://b2b.lumowallet.io/cards/${lumoCardId}/transactions?${params}`,
{ headers: { 'X-API-Key': process.env.LUMO_API_KEY } },
);
return res.json();
}Для real-time используйте вебхуки card.transaction.created и card.transaction.settled. Авторизацию и её проведение связывайте по upayParentId, а строки пополнений (typeName: Recharge/Collateral) пропускайте — пополнения уже видны по card.recharge.*.
| Параметр | Значение (пример) |
|---|---|
openingFeeUsdt | 5 USDT |
minInitialDepositUsdt | 10 USDT |
commissionBps на recharge | 80 (0.8%) |
Это ваши расходы в Lumo: за выпуск карты списывается openingFeeUsdt, с каждого пополнения — commissionBps от суммы, при закрытии карты из остатка удерживается cardClosureFeeUsdt. Закладывайте их в цену для пользователя и добавляйте свою маржу сверху — стандартная практика в таких продуктах 1–3%.
- Обзор программы карт — полные параметры и жизненный цикл.
- Webhooks по картам — все события и формат.
- Тестируйте с
cardsTestMode = true— операции с картами возвращают тестовые данные без расхода реального стока, но движения по балансу проходят по-настоящему (запросите включение у поддержки).