API и надёжная интеграция

Интеграция платежей на Node.js: запрос и webhook

В Node.js важно сохранить исходный Buffer webhook до body-parser, генерировать новый UUID для X-Nonce и сравнивать подпись через timingSafeEqual.

Интеграция платежей на Node.js — это ровно два куска кода: POST-запрос, который создаёт платёж и возвращает pay_url, и HTTP-эндпоинт, который принимает колбэк о результате. Первый пишется за двадцать минут. Второй ломается у всех одинаково: express.json() успевает распарсить и выбросить сырое тело, HMAC перестаёт сходиться, и вы полдня смотрите на 401 в логах.

Ниже — код под Node 20+ и Express, который можно скопировать, и разбор именно тех мест, где JavaScript подставляет подножку.

Что положить в окружение до первой строки

Ключ кассы X-API-Key и signing_secret — это два разных секрета. Первый доказывает платёжному сервису, что запрос от вас. Второй доказывает вам, что колбэк от сервиса. Оба живут только в переменных окружения: в репозитории их быть не должно даже в тестовом файле.

  • ROLLYPAY_API_KEY — ключ кассы, уходит в заголовке каждого запроса на создание платежа.
  • ROLLYPAY_SIGNING_SECRET — ключ подписи колбэков, никогда не покидает ваш сервер.
  • ROLLYPAY_API_URL — базовый адрес API из документации docs.rollypay.io.

Заведите таблицу orders заранее: id, user_id, amount_kopecks, status со значением pending, payment_id. Без неё колбэку некуда приземляться.

Создание платежа: fetch, X-Nonce и таймаут

Встроенный fetch в Node 20 не имеет таймаута по умолчанию — запрос может висеть, пока не отвалится сокет. Вешайте AbortController на 10 секунд, иначе один медленный ответ съест воркер.

import { randomUUID } from 'node:crypto';

const BASE = process.env.ROLLYPAY_API_URL;
const API_KEY = process.env.ROLLYPAY_API_KEY;

export async function createPayment(order) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 10_000);

  try {
    const res = await fetch(`${BASE}/api/v1/payments`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-API-Key': API_KEY,
        'X-Nonce': randomUUID(),
      },
      body: JSON.stringify({
        amount: (order.amountKopecks / 100).toFixed(2),
        payment_currency: 'RUB',
        order_id: order.id,
        description: order.title,
        success_redirect_url: `${process.env.SITE}/thanks/${order.id}`,
        metadata: { user_id: order.userId },
      }),
      signal: controller.signal,
    });

    const data = await res.json();
    if (!res.ok) throw new Error(`${res.status} ${data.error ?? 'unknown'}`);
    return data; // payment_id, pay_url, expires_at
  } finally {
    clearTimeout(timer);
  }
}

Три детали, которые видно только в проде. amount уходит строкой — храните сумму целым числом копеек и делите на сотню в последний момент, иначе 0.1 + 0.2 однажды даст вам платёж на 1499.99 ₽. X-Nonce обязан быть новым на каждый HTTP-запрос: crypto.randomUUID() генерирует его без внешних зависимостей, а повтор старого значения в течение 10 минут вернёт 401 nonce already used. При ретрае берите новый nonce, но тот же order_id — про это подробнее в тексте про идемпотентность платёжных запросов.

Почему express.json() ломает подпись

Подпись считается от строки X-Timestamp + "." + сырое тело запроса, байт в байт. Если express.json() уже отработал, у вас в руках объект JavaScript, а исходных байтов нет. Повторный JSON.stringify() почти наверняка даст другую строку: другой порядок ключей, другие пробелы, другое представление чисел. HMAC не сойдётся, и вы будете винить платёжный сервис.

Лечится порядком middleware. На маршруте колбэка ставьте express.raw(), а общий JSON-парсер подключайте после — он останется для остальных маршрутов приложения.

import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';

const app = express();

app.post('/webhooks/rollypay',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const ts = req.get('X-Timestamp') ?? '';
    const got = req.get('X-Signature') ?? '';

    const expected = createHmac('sha256', process.env.ROLLYPAY_SIGNING_SECRET)
      .update(ts + '.')
      .update(req.body)          // Buffer, а не объект
      .digest('hex');

    const a = Buffer.from(got, 'utf8');
    const b = Buffer.from(expected, 'utf8');
    if (a.length !== b.length || !timingSafeEqual(a, b)) {
      return res.status(401).end();
    }
    if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
      return res.status(401).end();
    }

    const event = JSON.parse(req.body.toString('utf8'));
    try {
      await handleEvent(event);
      res.status(200).end();
    } catch (err) {
      console.error('webhook failed', event.payment_id, err);
      res.status(500).end();
    }
  });

app.use(express.json());

timingSafeEqual бросает исключение, если буферы разной длины, — поэтому длина сравнивается отдельной проверкой до вызова.

Async-обработчик и молчаливый reject

В Express 4 отклонённый промис внутри async-обработчика никуда не уходит: клиент висит до таймаута, в логах пусто, вы видите только «вебхуки не доходят». В Express 5 отказ уезжает в error-middleware автоматически — но и там надо явно ответить статусом, а не полагаться на удачу.

Правило простое: любой await внутри обработчика обёрнут в try/catch, а в catch вы отдаёте 500 и пишете в лог payment_id. Тогда сервис пришлёт колбэк заново, а вы найдёте инцидент по идентификатору за минуту.

2xx отдавайте после записи, а не до

Соблазн ответить res.status(200).end() первой строкой и обработать событие в фоне понятен: так эндпоинт всегда быстрый. Цена — потерянные оплаты. Как только вы вернули 2xx, платёжный сервис считает доставку успешной и повторов не будет. Упал процесс, отвалилась база, воркер уехал в деплой — событие исчезло вместе с ним.

Бот-магазин пресетов так потерял 11 оплат за вечер релиза: обработчик отвечал 200 сразу, а выдачу ставил в очередь в памяти. Перезапуск во время деплоя очередь стёр. После переноса ответа на строку «после INSERT в таблицу payment_events» потери прекратились, а выдача осталась фоновой — но уже читает задачи из таблицы, а не из памяти.

Чек-лист перед выкладкой

  1. Порядок middleware. express.raw() на маршруте колбэка стоит выше глобального express.json().
  2. Секреты в окружении. В коде нет ни ключа кассы, ни signing_secret; .env в .gitignore.
  3. Сравнение подписи. Только timingSafeEqual, никаких === по строкам — детали в разборе проверки подписи webhook.
  4. Окно по времени. Отклоняйте X-Timestamp старше пяти минут.
  5. Повтор события. payment_id с уникальным индексом, чтобы payment.paid дважды не выдал доступ дважды.
  6. Логи. В каждой строке есть order_id и payment_id, тела запросов не пишутся целиком.

Статус продавца, чек и возвраты остаются на вас: платёжный сервис даёт техническое подключение и подпись, а не налоговый режим. Самозанятый выбивает чек в «Мой налог» и держится в пределах 2,4 млн ₽ в год. Если вы ещё выбираете способ подключения, начните с обзора API приёма платежей или сразу с приёма платежей на сайте. Категорию проекта и доступные методы подтверждает модерация.

Частые вопросы

Почему подпись не сходится, хотя signing_secret правильный?

Почти всегда потому, что до вашего обработчика отработал express.json() и сырые байты потеряны. Повторный JSON.stringify() даёт другой порядок ключей и другие пробелы, а значит другой HMAC. Поставьте express.raw({ type: 'application/json' }) на маршрут колбэка выше глобального парсера.

Можно ли использовать один X-Nonce для нескольких запросов?

Нет, значение живёт 10 минут и повторно не принимается: второй запрос вернёт 401 nonce already used. Генерируйте crypto.randomUUID() прямо в момент отправки. При повторной попытке создать тот же заказ берите новый nonce, но прежний order_id.

Чем timingSafeEqual лучше обычного сравнения строк?

Обычное сравнение выходит на первом несовпавшем символе, и время ответа зависит от того, сколько символов подписи угадано. По этой разнице подпись можно подобрать байт за байтом. timingSafeEqual тратит одинаковое время на любые входные данные, но требует буферов одинаковой длины, поэтому длину проверяйте отдельно.

Что вернуть, если обработка события упала?

Отдайте 500 и запишите в лог payment_id — сервис пришлёт колбэк повторно. Если ответить 200, повтора не будет и событие потеряется навсегда. Именно поэтому 2xx отдают после успешной записи в базу, а не первой строкой обработчика.

Хранить сумму в числе с плавающей точкой можно?

Лучше не стоит: в JavaScript 0.1 + 0.2 не равно 0.3, и на копейках это вылезет. Держите amount целым числом копеек, делите на 100 и вызывайте toFixed(2) только при формировании тела запроса. В API сумма уходит строкой вида «1500.00».

Источники и документация