Интеграция платежей на 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» потери прекратились, а выдача осталась фоновой — но уже читает задачи из таблицы, а не из памяти.
Чек-лист перед выкладкой
- Порядок middleware.
express.raw()на маршруте колбэка стоит выше глобальногоexpress.json(). - Секреты в окружении. В коде нет ни ключа кассы, ни
signing_secret;.envв.gitignore. - Сравнение подписи. Только
timingSafeEqual, никаких===по строкам — детали в разборе проверки подписи webhook. - Окно по времени. Отклоняйте
X-Timestampстарше пяти минут. - Повтор события.
payment_idс уникальным индексом, чтобыpayment.paidдважды не выдал доступ дважды. - Логи. В каждой строке есть
order_idиpayment_id, тела запросов не пишутся целиком.
Статус продавца, чек и возвраты остаются на вас: платёжный сервис даёт техническое подключение и подпись, а не налоговый режим. Самозанятый выбивает чек в «Мой налог» и держится в пределах 2,4 млн ₽ в год. Если вы ещё выбираете способ подключения, начните с обзора API приёма платежей или сразу с приёма платежей на сайте. Категорию проекта и доступные методы подтверждает модерация.