Уведомления

Вебхуки

NordPay отправляет POST-запрос на ваш адрес, когда меняется статус платежа, подписки или выплаты. Так вы узнаёте об оплате сразу, без опроса API.

Событие payment.succeeded
{
  "id": "evt_7Qw2sK9nXc4M",
  "object": "event",
  "type": "payment.succeeded",
  "created_at": "2026-11-08T12:03:42Z",
  "livemode": false,
  "data": {
    "object": {
      "id": "pay_2Kq8sD1fVxJ7mN4p",
      "object": "payment",
      "status": "succeeded",
      "amount": 499000,
      "amount_refunded": 0,
      "currency": "RUB",
      "fee": 17465,
      "net": 481535,
      "method": "sbp",
      "description": "Заказ №1042",
      "order_id": "1042",
      "metadata": {
        "cart_id": "c_77"
      },
      "customer": {
        "email": "buyer@example.com"
      },
      "confirmation": {
        "type": "redirect",
        "url": "https://api.nordpay.pro/checkout/pay_2Kq8sD1fVxJ7mN4p"
      },
      "sbp": {
        "qr_payload": "https://api.nordpay.pro/checkout/pay_2Kq8sD1fVxJ7mN4p",
        "expires_at": "2026-11-08T12:30:00Z"
      },
      "crypto": null,
      "crypto_network": null,
      "card": null,
      "failure": null,
      "livemode": false,
      "created_at": "2026-11-08T12:00:00Z",
      "paid_at": "2026-11-08T12:03:41Z",
      "expires_at": "2026-11-08T12:30:00Z"
    }
  }
}

Адрес для уведомлений

Добавьте адрес в кабинете, в разделе «Вебхуки и API-ключи», или через API. Укажите, какие события присылать. В ответе — secret для проверки подписи; он показывается один раз.

  • Адрес должен быть доступен из интернета по HTTPS.
  • Для тестового и боевого режимов заводятся разные адреса и секреты. Список — GET /v1/webhook_endpoints, удаление — DELETE /v1/webhook_endpoints/:id.
  • Из кабинета можно отправить тестовое событие и посмотреть журнал доставок: код ответа, время, тело запроса.
POST /v1/webhook_endpoints
curl -X POST https://api.nordpay.pro/v1/webhook_endpoints \
  -H "Authorization: Bearer $NORDPAY_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://shop.example/webhooks/nordpay",
    "events": [
      "payment.succeeded",
      "payment.failed",
      "payment.refunded"
    ]
  }'
Ответ · 201
{
  "id": "we_4Rk8pT1mZs6N",
  "object": "webhook_endpoint",
  "url": "https://shop.example/webhooks/nordpay",
  "events": [
    "payment.succeeded",
    "payment.failed",
    "payment.refunded"
  ],
  "status": "enabled",
  "secret": "9c1f4e…b7a2",
  "created_at": "2026-11-08T12:00:00Z"
}

Формат события

  • id — уникальный идентификатор события с префиксом evt_. Одно и то же событие может прийти повторно — используйте id, чтобы не обработать его дважды.
  • type — тип события, см. таблицу ниже.
  • data.object — объект целиком в состоянии на момент события: платёж, подписка или выплата.
  • livemode — режим, в котором произошло событие.

Порядок доставки не гарантирован: если важна последовательность, сравнивайте статус и время в объекте или запрашивайте актуальное состояние через API.

Запрос к вашему серверу
POST /webhooks/nordpay HTTP/1.1
Host: shop.example
Content-Type: application/json
NordPay-Signature: t=1762603422,v1=5f0c2e…d41a

{"id":"evt_7Qw2sK9nXc4M","object":"event","type":"payment.succeeded",…}

Типы событий

ТипКогда приходит
payment.succeededПлатёж оплачен, деньги зачислены в баланс
payment.failedПлатёж отклонён, причина — в failure
payment.canceledПлатёж отменён до оплаты
payment.refundedПо платежу проведён полный или частичный возврат
subscription.createdПодписка создана
subscription.renewedОчередное списание по подписке прошло успешно
subscription.payment_failedОчередное списание не прошло
subscription.canceledПодписка отменена
payout.paidВыплата отправлена на ваши реквизиты
payout.rejectedВыплата отклонена
Неудачное списание по подписке
{
  "id": "evt_2Bn7rD5kWq8Y",
  "object": "event",
  "type": "subscription.payment_failed",
  "livemode": false,
  "created_at": "2026-12-08T12:00:07Z",
  "data": {
    "object": {
      "id": "sub_3Lm9xR2pVa7Q",
      "object": "subscription",
      "status": "past_due"
    }
  }
}

Подпись

Каждый запрос подписан. В заголовке NordPay-Signature два значения: t — время отправки в секундах Unix и v1 — подпись в hex.

Как считается v1
v1 = HMAC_SHA256(webhook_secret, "<t>.<сырое тело запроса>")
  1. Возьмите тело запроса как есть, до разбора JSON и любых преобразований.
  2. Склейте строку t, точку и тело, посчитайте HMAC-SHA256 секретом адреса.
  3. Сравните с v1 функцией сравнения за постоянное время.
  4. Отклоните запрос, если t отличается от текущего времени больше чем на 5 минут, — это защита от повторной отправки перехваченного запроса.
Посчитать подпись для своего тела события можно прямо в браузере — в песочнице.
Проверка подписи
import crypto from 'node:crypto';

export function verifyNordPay(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(
    String(header || '').split(',').map((kv) => kv.trim().split('=')),
  );
  const t = Number(parts.t);
  if (!t || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.${rawBody}`)
    .digest('hex');

  return expected.length === parts.v1.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

Повторы

Доставка считается успешной, если ваш сервер ответил кодом 2xx в течение 10 секунд. Иначе NordPay повторит отправку по расписанию:

  1. через 1 минута
  2. через 5 минут
  3. через 30 минут
  4. через 2 часа
  5. через 6 часов
  6. через 24 часа

Все попытки с кодом ответа и временем видны в журнале доставок в кабинете. Если ваш сервер долго был недоступен, сверьте статусы платежей через список платежей.

Быстрый ответ, обработка в очереди
app.post('/webhooks/nordpay', express.raw({ type: 'application/json' }), async (req, res) => {
  if (!verifyNordPay(req.body, req.get('NordPay-Signature'), process.env.NORDPAY_WEBHOOK_SECRET)) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(req.body);
  const isNew = await db.events.insertIfAbsent(event.id);
  if (isNew) await queue.push('nordpay-event', event);
  res.sendStatus(200);
});