Перейти к основному содержимому

Колбэки (вебхуки)

RollyPay отправляет на ваш сервер HTTP POST при наступлении событий: оплата, отмена, истечение срока платежа, возврат и т.д. Колбэк приходит и по успешным, и по неуспешным исходам — см. Неуспешные платежи.

Зачем нужна интеграция​

Интеграция по колбэкам — не опциональное удобство, а обязательное условие корректной работы кассы. Платёжный цикл асинхронный: подтверждение оплаты, отказ банка, истечение срока, а также поздние возвраты и отмены приходят не в ответе на ваш запрос, а отдельными событиями во времени. Единственный канал, по которому платформа доводит до вас эти изменения состояния, — колбэк на ваш callback_url.

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

Отдельно обратите внимание на асинхронные банковские исходы — возвраты и отмены. Банк-эмитент может сообщить об отзыве или возврате средств уже после того, как платёж считался завершённым. Такие уведомления платформа принимает и ретранслирует вам колбэком в рамках допустимого окна — ограниченного периода после исходной операции, в течение которого поздний исход ещё относится к этому платежу (см. Поздние банковские исходы). Если ваша сторона не обрабатывает эти события, возврат или отмена, инициированные банком, пройдут мимо вашего учёта.

Настройка​

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Валюта платежа
testtrue для платежей в тестовом режиме (sandbox), иначе false
metadataПередаётся только если метаданные были заданы при создании платежа

Проверка подписи​

В каждом вебхуке передаются два заголовка:

ЗаголовокОписание
X-SignatureHMAC-SHA256 подпись
X-TimestampUnix-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_typerefunded

Неуспешные платежи​

Отдельного события payment.failed не существует. Платёж, по которому деньги не получены, заканчивается одним из двух исходов:

СтатусСобытиеЧто произошло
canceledpayment.canceledПлатёж отменён или отклонён — например, отказ на стороне банка, отмена покупателем
expiredpayment.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
}

Поздние банковские исходы​

Статус платежа не всегда финализируется в момент оплаты. Банк-эмитент может провести отмену авторизации или возврат средств асинхронно — уже после того, как платёж перешёл в paid и по нему был отправлен колбэк payment.paid. Такие исходы инициирует банк, а не покупатель и не ваша интеграция, поэтому предугадать их в момент оплаты нельзя.

Когда приходит подтверждение позднего возврата или отмены, платформа сопоставляет уведомление с исходным платежом и, если оно попадает в допустимое окно, отправляет вам ещё один колбэк с новым статусом по тому же payment_id:

Поздний исходСобытиеstatusЧто означает
Возврат по инициативе банкаpayment.refundedrefundedСредства по ранее оплаченному платежу возвращены плательщику
Отзыв/отмена банкомpayment.chargebackchargebackБанк отозвал оплаченную ранее транзакцию

Допустимое окно​

Допустимое окно — это ограниченный период после исходной операции, в течение которого поздний исход ещё принимается платформой и относится к данному платежу. Границы окна заданы правилами платёжной системы и не настраиваются на стороне кассы. Механика следующая:

  • Уведомление в пределах окна сопоставляется с платежом по идентификатору операции, проходит те же проверки подлинности, что и остальные колбэки, и доставляется вам как обычное событие — с подписью X-Signature и той же политикой повторов.
  • Уведомление, пришедшее за пределами окна или не сопоставленное с существующим платежом, не порождает колбэк: платформа не может достоверно привязать его к операции, поэтому такой случай разбирается в ручном режиме поддержкой, а не автоматической ретрансляцией.
  • Окно отсчитывается от исходной операции по платежу, а не от момента вашей обработки колбэка. Задержка вашего обработчика на окно не влияет.

Требования к обработчику​

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

  • Ориентируйтесь на последнее событие по payment_id. Не полагайтесь на то, что первый терминальный статус (paid, expired) — окончательный. Более позднее событие (refunded, chargeback, а также поздняя оплата paid после expired) отражает актуальное состояние и должно перезаписывать предыдущее.
  • Обеспечьте идемпотентность. По одному платежу вы можете получить несколько колбэков в разное время. Повторная доставка того же события (из-за ретраев) не должна приводить к двойному списанию, повторной выдаче товара или задвоению возврата в вашем учёте.
  • Не закрывайте платёж от изменений преждевременно. Ни один терминальный статус не гарантирует, что событий по платежу больше не будет. По paid-платежу в пределах допустимого окна может прийти refunded или chargeback; по expired-платежу — поздняя оплата paid, если деньги дошли после истечения срока. Держите возможность обработать эти исходы — например, не удаляйте связь заказа с payment_id сразу после выдачи товара или после первого терминального статуса.
  • Сверяйте сумму и валюту. Поздний возврат приходит с теми же amount и currency, что и исходный платёж; используйте их для контроля, если ведёте частичную сверку.

Порядок доставки в общем случае соответствует хронологии исходов, но обработчик не должен закладываться на строгий порядок между независимыми событиями — при расхождении опорным считается более поздний по времени колбэк.

Тестовые колбэки​

Колбэки по тестовым (sandbox) платежам приходят в том же формате и с той же подписью, что и боевые. Отличает их поле "test": true в теле и заголовок X-Test-Mode: true. См. Тестовый режим (Sandbox).

Повторные попытки​

Если ваш endpoint вернул не 2xx или не ответил в течение 10 секунд, RollyPay повторяет запрос с экспоненциальной задержкой:

Неудачная попыткаСледующая попытка через
130 сек
21 мин
32 мин
44 мин
58 мин
616 мин
732 мин
8повторов больше нет, доставка помечается как неуспешная

Формула задержки: 30 сек × 2^(номер неудачной попытки − 1). Всего попыток не более 8, последняя — примерно через час после первой. После успешного 2xx повторная отправка этого события не выполняется.