Колбэки (вебхуки)
RollyPay отправляет на ваш сервер HTTP POST при наступлении событий: оплата, отмена, истечение срока платежа, возврат и т.д. Колбэк приходит и по успешным, и по неуспешным исходам — см. Неуспешные платежи.
Настройка
URL для колбэков задаётся в настройках кассы (callback_url). Его можно задать при создании кассы или через API:
PUT /api/v1/terminals/{terminalID}
{
"callback_url": "https://your-server.com/webhooks/rollypay"
}
Сервер должен отвечать кодом 2xx в течение 10 секунд. Иначе запрос будет повторён по политике retry.
Тело запроса
Каждый колбэк — JSON, например:
{
"event_type": "payment.paid",
"payment_id": "pay_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"order_id": "order_12345",
"status": "paid",
"amount": "1500.00",
"currency": "RUB",
"test": false
}
| Поле | Описание |
|---|---|
event_type | Тип события (см. ниже). Всегда соответствует полю status |
payment_id | Идентификатор платежа в RollyPay |
order_id | Ваш идентификатор заказа, переданный при создании платежа |
status | Текущий статус платежа |
amount | Сумма платежа, строка с двумя знаками после запятой |
currency | Валюта платежа |
test | true для платежей в тестовом режиме (sandbox), иначе false |
metadata | Передаётся только если метаданные были заданы при создании платежа |
Проверка подписи
В каждом вебхуке передаются два заголовка:
| Заголовок | Описание |
|---|---|
X-Signature | HMAC-SHA256 подпись |
X-Timestamp | Unix-timestamp момента отправки |
Подпись вычисляется от строки timestamp + "." + body с ключом signing_secret кассы. Проверять подпись обязательно перед обработкой.
JavaScript (Node)
const crypto = require("crypto");
function verifySignature(body, timestamp, signature, signingSecret) {
const expected = crypto
.createHmac("sha256", signingSecret)
.update(timestamp + "." + body)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}
app.post("/webhooks/rollypay", (req, res) => {
const signature = req.headers["x-signature"];
const timestamp = req.headers["x-timestamp"];
const rawBody = req.rawBody; // нужен именно сырой body, не распарсенный
if (!verifySignature(rawBody, timestamp, signature, process.env.SIGNING_SECRET)) {
return res.status(403).send("Invalid signature");
}
const event = JSON.parse(rawBody);
// Обработка event...
res.status(200).send("OK");
});
Go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
)
func verifySignature(body []byte, timestamp, signature, signingSecret string) bool {
mac := hmac.New(sha256.New, []byte(signingSecret))
mac.Write([]byte(timestamp))
mac.Write([]byte("."))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
Типы событий
| Событие | Когда приходит | status в теле |
|---|---|---|
payment.created | Платёж создан | created |
payment.paid | Оплата успешно подтверждена | paid |
payment.canceled | Платёж отменён или отклонён — деньги не получены | canceled |
payment.expired | Истёк срок жизни платежа, оплата так и не поступила | expired |
payment.chargeback | Чарджбек по оплаченному платежу | chargeback |
payment.refunded | По платежу выполнен возврат | refunded |
payout.completed | Вывод успешно выполнен | — |
refund_request.completed | Заявка на возврат завершена; отправляется вместе с payment.refunded (сначала это событие, затем payment.refunded) — оба колбэка несут тот же платёж и status: refunded, достаточно обработать одно из них, если вы не разделяете логику по event_type | refunded |
Неуспешные платежи
Отдельного события payment.failed не существует. Платёж, по которому деньги не получены, заканчивается одним из двух исходов:
| Статус | Событие | Что произошло |
|---|---|---|
canceled | payment.canceled | Платёж отменён или отклонён — например, отказ на стороне банка или провайдера, отмена покупателем |
expired | payment.expired | Срок жизни платежа вышел, оплата не поступила |
По каждому из этих исходов колбэк отправляется — при условии, что на кассе задан рабочий callback_url. Раньше неоплаченный платёж мог остаться без уведомления; теперь и отмена, и истечение срока прив одят к колбэку. Заказ не нужно держать в состоянии «ждём оплату» до бесконечности: дождитесь одного из финальных событий и закройте его.
Ориентируйтесь на поле status — это состояние платежа, а event_type вычисляется из него. Если вы уже разбираете колбэки по status, новое событие не потребует изменений в коде.
Оба исхода финальные. Единственное исключение — поздняя оплата: если деньги дошли уже после истечения срока, платёж может перейти из expired в paid, и вы получите ещё один колбэк с payment.paid. Поэтому обработчик должен опираться на последнее полученное событие по payment_id, а не только на первое.
Пример: истёкший платёж
{
"event_type": "payment.expired",
"payment_id": "pay_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"order_id": "order_12345",
"status": "expired",
"amount": "1500.00",
"currency": "RUB",
"test": false
}
Тестовые колбэки
Колбэки по тестовым (sandbox) платежам приходят в том же формате и с той же подписью, что и боевые. Отличает их поле "test": true в теле и заголовок X-Test-Mode: true. См. Тестовый режим (Sandbox).
Повторные попытки
Если ваш endpoint вернул не 2xx или не ответил в течение 10 секунд, RollyPay повторяет запрос с экспоненциальной задержкой:
| Неудачная попытка | Следующая попытка через |
|---|---|
| 1 | 30 сек |
| 2 | 1 мин |
| 3 | 2 мин |
| 4 | 4 мин |
| 5 | 8 мин |
| 6 | 16 мин |
| 7 | 32 мин |
| 8 | повторов больше нет, доставка помечается как неуспешная |
Формула задержки: 30 сек × 2^(номер неудачной попытки − 1). Всего попыток не более 8, последняя — примерно через час после первой. После успешного 2xx повторная отправка этого события не выполняется.