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

Webhooks

Lumo сообщает о событиях POST-запросами на ваш URL: ордер завершился, пришёл депозит, выдана карта, выплата исполнена.

Настройка

Укажите webhookUrl в профиле компании (право settings.write):

curl -X PATCH 'https://b2b.lumowallet.io/auth/company/profile' \
  -H 'X-API-Key: YOUR_API_KEY' \
  -H 'Content-Type: application/json' \
  -d '{
    "webhookUrl": "https://your-server.com/webhooks/lumo"
  }'

Все события компании приходят на этот один адрес — подписки на отдельные события нет, нужные выбирайте по полю event. Пока webhookUrl не задан, события не отправляются и не сохраняются.

Секрет подписи

Каждый вебхук подписан секретом компании. Секрет не создаётся автоматически — задайте его до запуска интеграции, иначе подпись считается с пустым ключом.

Сгенерировать новый секрет (право webhook.manage):

curl -X POST 'https://b2b.lumowallet.io/auth/company/webhook-secret/rotate' \
  -H 'X-API-Key: YOUR_API_KEY'
{ "webhookSecret": "whsec_3f9a1c0b7e2d4a6f8b1c3e5d7f9a0b2c4d6e8f0a1b3c5d7e" }

Новые вебхуки сразу подписываются новым секретом. Повторы доставок, начатых до смены, ещё могут прийти со старой подписью — на время смены принимайте оба секрета.

Посмотреть текущий — POST /auth/company/webhook-secret/reveal: ответ { "webhookSecret": "..." }, а если секрет не задан — "webhookSecret": null. Задать свой (от 8 символов) — полем webhookSecret в PATCH /auth/company/profile. Есть ли секрет, видно по полю hasWebhookSecret в профиле.

Формат запроса

POST /webhooks/lumo HTTP/1.1
Host: your-server.com
Content-Type: application/json
X-Lumo-Signature: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
X-Lumo-Delivery-Id: 550e8400-e29b-41d4-a716-446655440000

{"event":"order.status_changed","orderId":"...", ...}
ЗаголовокОписание
X-Lumo-SignatureHMAC-SHA256 от тела запроса, hex, без префикса
X-Lumo-Delivery-IdID доставки. Одинаков у всех повторов одной доставки
Content-Typeapplication/json

Тело — плоский JSON: поле event и поля события на верхнем уровне, без вложенного объекта data.

Проверка подписи

Подпись считается от сырого тела запроса — ровно тех байтов, что пришли. Не пересобирайте JSON из распарсенного объекта: другой порядок полей или пробелы дадут другую подпись.

const crypto = require('crypto');
const express = require('express');

const app = express();

app.post(
  '/webhooks/lumo',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.get('x-lumo-signature') || '';
    const expected = crypto
      .createHmac('sha256', process.env.LUMO_WEBHOOK_SECRET)
      .update(req.body) // Buffer с сырым телом
      .digest('hex');

    const valid =
      signature.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
    if (!valid) return res.status(401).send('Invalid signature');

    const event = JSON.parse(req.body.toString('utf8'));
    res.status(200).send('OK');
    handleEvent(req.get('x-lumo-delivery-id'), event);
  },
);
import hashlib
import hmac

def verify_signature(raw_body: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(signature, expected)

События

СобытиеКогда приходит
order.status_changedОрдер стал success, failed или disputed
deposit.createdДепозит на адрес компании замечен в сети
deposit.fee_chargedДепозит на адрес компании принят в обработку
deposit.aml_pendingДепозит на адрес компании ушёл на AML-проверку
deposit.completedДепозит завершён
deposit.aml_failedДепозит на кошелёк пользователя не прошёл AML-проверку
deposit.failedДепозит не удалось обработать
payout.status_changedСменился статус выплаты
payout.rechargedВыплата вернулась в success после возврата и списана повторно — вместо payout.status_changed
sbp.customer.kyc_updatedСменился статус верификации покупателя
sbp.order.status_changedСменился статус заказа на покупку USDT или пополнения карты по нему
card.*События по картам — см. Webhooks по картам
webhook.testТестовая отправка из POST /webhooks/test

order.status_changed

{
  "event": "order.status_changed",
  "orderId": "550e8400-e29b-41d4-a716-446655440000",
  "walletOrderId": "660e8400-e29b-41d4-a716-446655440001",
  "status": "success",
  "amountUsdt": 52.63,
  "amountRub": 5000,
  "completedAt": "2026-03-10T12:00:45.000Z",
  "failureReason": null,
  "createdAt": "2026-03-10T12:00:00.000Z"
}
  • Приходит при переходе в success, failed и disputed. На created и in_progress вебхука нет. Когда спор открываете вы сами, вебхук может как прийти, так и нет — результат в любом случае приходит в ответе на запрос.
  • createdAt — время создания ордера, а не отправки события.
  • externalOrderId и paymentType в событие не входят — при необходимости берите их из GET /orders/{id}.

Подробнее о переходах — в разделе Жизненный цикл ордера.

События депозитов на адрес компании

{
  "event": "deposit.completed",
  "depositId": "770e8400-e29b-41d4-a716-446655440002",
  "txHash": "a3f1c9...e07b",
  "walletAddress": "TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE",
  "fromAddress": "TXyz4b8XqQ1wR9mJ2vL5kN7pC3dF6gH8jK",
  "amount": 100,
  "tokenSymbol": "USDT",
  "status": "completed",
  "amlCheckId": null,
  "amlResult": null,
  "sweepTxHash": "b81e0d...44c2",
  "error": null,
  "blockTimestamp": "2026-03-10T12:00:00.000Z",
  "createdAt": "2026-03-10T12:00:05.000Z",
  "updatedAt": "2026-03-10T12:00:41.000Z"
}

На один депозит приходит цепочка событий: deposit.created → deposit.fee_charged → deposit.aml_pending → deposit.completed. Вместо completed может прийти deposit.failed — в том числе если AML-проверка не пройдена и после повторов. Если AML-проверка депозитов компании выключена, deposit.aml_pending не приходит, а amlResult начинается со skipped. Поле status в событии совпадает с его названием, кроме deposit.created — у него status: new. createdAt и updatedAt относятся к депозиту.

deposit.completed уходит чуть раньше, чем средства зачисляются на баланс: зачисление идёт сразу следующим шагом. Сверяйтесь с историей баланса, а не с моментом события.

Подробнее — в разделе Депозиты компании.

События депозитов на кошельки пользователей

Для кошельков пользователей приходят только три события: deposit.completed, deposit.aml_failed и deposit.failed. deposit.created, deposit.fee_charged и deposit.aml_pending по ним не приходят.

{
  "event": "deposit.completed",
  "depositId": "880e8400-e29b-41d4-a716-446655440003",
  "externalId": "user-12345",
  "txHash": "c4d2e8...19fa",
  "walletAddress": "TUserWalletAddr9Y2khEsLJW1ChVWFMSM",
  "amount": 50,
  "status": "completed",
  "amlCheckId": null,
  "amlResult": null,
  "sweepTxHash": "d9a0b1...77e3",
  "error": null,
  "blockTimestamp": null,
  "createdAt": "2026-03-10T12:05:00.000Z",
  "updatedAt": "2026-03-10T12:05:00.000Z"
}

Отличия от событий депозитов компании:

  • есть externalId — идентификатор пользователя, которому принадлежит кошелёк. По наличию этого поля события двух видов и различают;
  • нет fromAddress и tokenSymbol;
  • amlResult заполнен у deposit.aml_failed — это показатель риска из AML-проверки; amlCheckId и blockTimestamp всегда null;
  • createdAt и updatedAt — время отправки события, а не депозита;
  • deposit.failed не всегда окончательный: при некоторых ошибках пересылка повторяется, и позже по тому же depositId может прийти deposit.completed.

payout.status_changed

Приходит на каждую смену статуса выплаты — waiting_trader, in_progress, success, fail. Исключение — возврат в success после возврата средств: на него приходит payout.recharged, а payout.status_changed — нет.

{
  "event": "payout.status_changed",
  "payoutId": "0d6c0d28-02e3-4366-bab8-3347f7f4f799",
  "externalId": "payout-12345",
  "status": "success",
  "amountRub": 12000,
  "chargedUsdt": 147.71,
  "rate": 81.24,
  "requisiteType": "phone",
  "requisite": "+79000000000",
  "bankId": "bank100000000111",
  "bank": "Сбербанк",
  "failReason": null,
  "comment": null,
  "refunded": false,
  "disputeStatus": null,
  "disputeResolution": null,
  "completedAt": "2026-08-25T13:33:32.058Z",
  "createdAt": "2026-08-25T13:28:26.887Z",
  "updatedAt": "2026-08-25T13:33:32.100Z"
}

refunded: true при status: fail означает, что списанное уже вернулось на баланс. Ориентируйтесь на это поле, а не на сам факт провала.

payout.recharged

Заявка вернулась в success после того, как средства были возвращены, — и сумма списана с баланса повторно. Тело то же, что у payout.status_changed, и приходит это событие вместо него — обрабатывайте оба.

Отдельное событие нужно потому, что из поля status повторное списание не видно: там снова success, как и при обычном исполнении.

sbp.customer.kyc_updated

Приходит, когда у покупателя меняется kycStatus или kycReason. Тело — клиент целиком, как в GET /sbp/customers/{customerId}.

{
  "event": "sbp.customer.kyc_updated",
  "customerId": "cust-8841",
  "email": "[email protected]",
  "phone": "+79991234567",
  "kycStatus": "verified",
  "kycReason": null,
  "kycTerminal": false,
  "canBuy": true,
  "verifiedAt": "2026-10-24T10:14:51.230Z",
  "createdAt": "2026-10-24T10:12:40.512Z",
  "sentAt": "2026-10-24T10:14:51.402Z"
}

См. Клиенты и верификация.

sbp.order.status_changed

Приходит на каждую смену статуса заказа, а при зачислении на карту ещё раз, когда пополнение карты запущено. Тело — заказ, как в GET /sbp/orders/{id}, без картинки QR, плюс orderId (то же, что id).

{
  "event": "sbp.order.status_changed",
  "orderId": "e4d2c1b0-7a6f-4e5d-8c3b-2a1f0e9d8c7b",
  "id": "e4d2c1b0-7a6f-4e5d-8c3b-2a1f0e9d8c7b",
  "externalId": "buy-5531",
  "customerId": "cust-8841",
  "quoteId": "b7a3c1e2-9d4f-4a8b-8e6c-1f2d3a4b5c6d",
  "status": "completed",
  "target": "card",
  "cardId": "3f1d2a4b-8c7e-4f61-9a0b-5d2e1c3b4a59",
  "amountRub": "10000.00",
  "amountUsdt": "117.42",
  "partnerFeeUsdt": "0.00000000",
  "rate": "85.164367",
  "cardRechargeId": "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
  "cardRechargeStatus": "pending",
  "qrUrl": null,
  "payerPhoneMasked": null,
  "failureReason": null,
  "expiresAt": "2026-10-24T11:15:10.000Z",
  "completedAt": "2026-10-24T10:21:37.000Z",
  "createdAt": "2026-10-24T10:15:10.402Z",
  "sentAt": "2026-10-24T10:21:38.115Z"
}

Для одного заказа может прийти два события с status: completed: сначала о зачислении, потом о запуске пополнения карты. Различайте их по cardRechargeStatus. Дальнейший ход пополнения приходит событиями card.recharge.* с тем же cardRechargeId. См. Котировка и заказ.

У заказа на выпуск карты (purpose: card_issue) событие с status: completed обычно одно, и исход выпуска уже в нём: cardIssueStatus, issuedCardId или cardRequestId. Если при оплате выпуск не удалось запустить сразу, событие придёт с cardIssueStatus: null, а когда выпуск пройдёт, придёт ещё одно. См. Выпуск карты за рубли.

webhook.test

{
  "event": "webhook.test",
  "message": "Это тестовый webhook от Lumo B2B. Проверьте обработку и подпись X-Lumo-Signature.",
  "sentAt": "2026-10-03T12:00:00.000Z",
  "companyId": "660e8400-e29b-41d4-a716-446655440001"
}

Повторные доставки

Доставка считается успешной, если ваш сервер ответил любым кодом 2xx. Любой другой ответ — в том числе 4xx — или отсутствие ответа за 10 секунд запускает повтор:

ПопыткаПауза перед ней
1—
21 секунда
32 секунды
44 секунды

После четвёртой неудачной попытки доставка прекращается и автоматически не возобновляется. Повторы не переживают перезапуск нашего сервиса: если он пришёлся на время повторов, оставшиеся попытки не выполняются. Все недоставленные события видны в журнале с delivered: false — по ним можно досверить состояние через API.

Укажите webhookUrl, который отвечает сразу, без редиректов: при ответе 301 / 302 запрос повторяется по новому адресу уже как GET без тела, и если там ответят 2xx, доставка будет считаться успешной.

Журнал доставок

curl 'https://b2b.lumowallet.io/webhooks/deliveries?limit=50' \
  -H 'X-API-Key: YOUR_API_KEY'
[
  {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "deliveryType": "order",
    "event": "order.status_changed",
    "targetUrl": "https://your-server.com/webhooks/lumo",
    "attempts": 4,
    "maxAttempts": 4,
    "delivered": false,
    "lastStatusCode": 500,
    "lastError": "Error: HTTP 500",
    "lastResponseBody": "Internal Server Error",
    "createdAt": "2026-10-03T12:00:00.000Z",
    "deliveredAt": null
  }
]
ПараметрОписание
limit1–200, по умолчанию 50
beforeКурсор: доставки с createdAt < before
typeorder, deposit, card, payout, sbp или test

id доставки — тот же, что приходит в X-Lumo-Delivery-Id. Подробности одной доставки вместе с отправленным телом — GET /webhooks/deliveries/{id}. Оба эндпоинта требуют право webhook.manage.

Тестовая отправка

curl -X POST 'https://b2b.lumowallet.io/webhooks/test' \
  -H 'X-API-Key: YOUR_API_KEY'

Отправляет событие webhook.test на текущий webhookUrl — один раз, без повторов — и возвращает результат:

{
  "deliveryId": "7a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
  "ok": true,
  "statusCode": 200,
  "durationMs": 184,
  "error": null,
  "responseBody": "OK",
  "payload": {
    "event": "webhook.test",
    "message": "Это тестовый webhook от Lumo B2B. Проверьте обработку и подпись X-Lumo-Signature.",
    "sentAt": "2026-10-03T12:00:00.000Z",
    "companyId": "660e8400-e29b-41d4-a716-446655440001"
  },
  "signature": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
  "targetUrl": "https://your-server.com/webhooks/lumo"
}

Отвечает 200, даже если ваш сервер вернул ошибку, — смотрите поля ok и statusCode. Если webhookUrl не задан, придёт 400. Нужно право webhook.manage.

Рекомендации

Отвечайте быстро

Отвечайте 200 OK сразу, а событие обрабатывайте в фоне: дольше 10 секунд мы ответа не ждём.

Дедупликация

Повторы одной доставки приходят с тем же X-Lumo-Delivery-Id — по нему отбрасывайте дубли:

async function handleEvent(deliveryId, event) {
  const isNew = await db.processedDeliveries.insertIfAbsent(deliveryId);
  if (!isNew) return; // уже обработано
  // ...
}

Действия с деньгами дополнительно делайте идемпотентными по бизнес-ключу (orderId + status, depositId, payoutId + status): одно и то же событие в редких случаях может быть отправлено отдельной доставкой с новым X-Lumo-Delivery-Id.

Порядок не гарантирован

События отправляются независимо друг от друга и могут прийти не в том порядке, в каком произошли. Если вебхук противоречит тому, что вы уже знаете, запросите актуальное состояние через API. Запрос, сделанный сразу после вебхука о заказе, в редких случаях может ещё вернуть прежний статус — повторите его через секунду.

Логирование

console.log({
  deliveryId: req.get('x-lumo-delivery-id'),
  event: event.event,
  receivedAt: new Date().toISOString(),
});

Тестирование

  • POST /webhooks/test — проверка доставки и подписи на ваш реальный URL.
  • webhook.site или ngrok — чтобы посмотреть запросы или прокинуть их на локальную машину, см. Инструменты.