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-Signature | HMAC-SHA256 от тела запроса, hex, без префикса |
X-Lumo-Delivery-Id | ID доставки. Одинаков у всех повторов одной доставки |
Content-Type | application/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 |
{
"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.
Приходит на каждую смену статуса выплаты — 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 означает, что списанное уже вернулось на баланс. Ориентируйтесь на это поле, а не на сам факт провала.
Заявка вернулась в success после того, как средства были возвращены, — и сумма списана с баланса повторно. Тело то же, что у payout.status_changed, и приходит это событие вместо него — обрабатывайте оба.
Отдельное событие нужно потому, что из поля status повторное списание не видно: там снова success, как и при обычном исполнении.
Приходит, когда у покупателя меняется 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"
}Приходит на каждую смену статуса заказа, а при зачислении на карту ещё раз, когда пополнение карты запущено. Тело — заказ, как в 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, а когда выпуск пройдёт, придёт ещё одно. См. Выпуск карты за рубли.
{
"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 | — |
| 2 | 1 секунда |
| 3 | 2 секунды |
| 4 | 4 секунды |
После четвёртой неудачной попытки доставка прекращается и автоматически не возобновляется. Повторы не переживают перезапуск нашего сервиса: если он пришёлся на время повторов, оставшиеся попытки не выполняются. Все недоставленные события видны в журнале с 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
}
]| Параметр | Описание |
|---|---|
limit | 1–200, по умолчанию 50 |
before | Курсор: доставки с createdAt < before |
type | order, 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 — чтобы посмотреть запросы или прокинуть их на локальную машину, см. Инструменты.