Перейти к содержимому
Последнее обновление

Виртуальные карты VISA

Пошаговое руководство для продуктов, которым нужно выдавать пользователям виртуальные VISA-карты.

Сценарий

Вы — криптокошелёк, необанк или любой потребительский сервис. Ваши пользователи хотят платить виртуальной картой за подписки и онлайн- покупки, а вы не хотите строить выпуск карт с нуля.

С Lumo B2B ваш пользователь получает активную VISA с полным номером и CVC через ~2 секунды после нажатия кнопки.

Преимущества

  • Мгновенно. Карта выдаётся из нашего стока — один HTTP-запрос.
  • 3DS OTP через API. Код подтверждения покупки приходит вебхуком и доступен по запросу. SIM-карта не нужна.
  • Пополнение с USDT. Списываем с баланса компании в USDT — пользователь вообще не касается блокчейна.
  • Полный PAN и CVC. Не только маска — реальные данные для ввода в интернет-магазине.

Архитектура

Пользователь ──► Ваш продукт ──► Lumo B2B API ──► Виртуальная VISA
                     │                                    │
                     │                            Интернет-магазин
                     │                                    │
                     └────── USDT баланс ◄────── оплата / 3DS-код

Деньги списываются с USDT-баланса вашей компании в Lumo. Вы берёте с пользователя (фиат или крипта) столько, сколько хотите — разница ваша маржа.

Шаг 1: Подключение

Перед первым вызовом убедитесь, что у компании в 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, мы активируем.

Шаг 2: Выпуск карты

// 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) списана с баланса компании автоматически.

Шаг 3: Показ PAN и CVC пользователю

Когда пользователь хочет вбить данные в онлайн-магазине, запросите полный номер и 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>
  );
}

Шаг 4: 3DS OTP

Когда пользователь делает покупку, эквайер присылает одноразовый код подтверждения. 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-ботом.

Шаг 5: Пополнение карты

Если пользователю нужен не только выпуск, но и возможность пополнять карту 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 — второго списания не будет.

Шаг 6: История транзакций

Пользователь хочет видеть свои покупки — отдавайте через:

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.*.

Юнит-экономика

ПараметрЗначение (пример)
openingFeeUsdt5 USDT
minInitialDepositUsdt10 USDT
commissionBps на recharge80 (0.8%)

Это ваши расходы в Lumo: за выпуск карты списывается openingFeeUsdt, с каждого пополнения — commissionBps от суммы, при закрытии карты из остатка удерживается cardClosureFeeUsdt. Закладывайте их в цену для пользователя и добавляйте свою маржу сверху — стандартная практика в таких продуктах 1–3%.

Что дальше

  • Обзор программы карт — полные параметры и жизненный цикл.
  • Webhooks по картам — все события и формат.
  • Тестируйте с cardsTestMode = true — операции с картами возвращают тестовые данные без расхода реального стока, но движения по балансу проходят по-настоящему (запросите включение у поддержки).