Skip to Content

Коллбеки

Коллбеки подписываются HMAC-SHA256 и отправляются на callback URL проекта. Формат тела и схема подписи зависят от флага формата проекта — см. «Формат уведомлений».

ПараметрЗначение
Признак успехаответ 2xx. В конверте платформы (JSON по умолчанию) тело ответа читается: success: false — отказ в приёме, доставка повторится. В прежнем конверте JSON и в XML тело не разбирается, можно отвечать 204 без содержимого
Максимум попыток7 — столько ступеней боевого расписания укладывается в минуту, которой песочница ограничивает доставку
Пауза до первой попытки10 сек, настраивается через callback_delay_seconds (см. «Песочные ручки»)
Backoff между попытками2s → 2s → 5s → 5s → 10s → 10s — начало боевого расписания (см. «Вебхуки»); дальше оно идёт 1 мин × 14, 5 мин × 21, 10 мин × 132, но это уже за минутным окном песочницы
HTTP-таймаут60 сек (5 сек для адреса из test_info.callback_url)
Редиректыне выполняются

Формат уведомлений

Формат уведомлений задаётся на проекте, у JSON и XML это два разных флага с разными значениями по умолчанию.

КонтурФлаг проектаПо умолчаниюПрежний конверт
JSON v3json_callback_formatspec — конверт платформы (NotificationEvent), с v2.96.1 и у существующих проектоввключается значением legacy
Merchant XMLxml_callback_formatlegacy — прежнее плоское тело SendPaymentTransactionуже включён; полный конверт спеки — значение spec

Флаг переключают администраторы песочницы в Admin UI — попросите их, сам партнёр флаг не меняет. Смена действует на уведомления, поставленные в очередь после неё: события, которые уже ждут доставки, уходят в прежнем формате.

Чем отличаются конверты JSON:

spec (по умолчанию)legacy
Тело{id, type, created_at, project_id, data}, data вложенная{event_type, event, event_id, occurred_at, data}, data плоская
Имена событийпо фазе: payment.updated, refund.updated, contract.*, receipt.issued, content.deliveredпо исходу: payment.completed, payment.failed, refund.completed, contract.*
Подписьv4 по сырому телу ключом Password2Merchant; если пары ключей у проекта нет — v3 секретом партнёраv3 секретом партнёра, тело подписью не покрыто
Номер попыткиX-Request-Attempt, с 0X-Event-Attempt, с 1
Ответ мерчантачитается: success: false — повтор доставки, data.content — событие content.deliveredне читается, достаточно любого 2xx

Заголовки доставки

JSONXMLЗначение
X-Event-TypeService-Method + X-Event-TypeТип события — дублирует type из тела (в legacy — event_type). В XML Service-Method несёт XML-имя (SendPaymentTransaction)
X-Event-IdRequestID + X-Event-IdИдентификатор события — дублирует id из тела (в legacy — event_id) и не меняется между попытками: используйте его как ключ дедупликации
X-Request-Attempt (в legacy — X-Event-Attempt)RequestAttempt + X-Event-AttemptНомер попытки: в конверте платформы с 0, в legacy с 1
X-Login, X-Timestamp, X-Signature-Version, X-Signature—Подпись. В конверте платформы X-Login — Login2Merchant, версия 4; без пары ключей и в legacy — логин партнёра и версия 3

Виды уведомлений

JSON (spec)JSON (legacy)XML-имяКогда приходит
payment.updatedpayment.completed / payment.failedSendPaymentTransactionПлатёжная транзакция по инвойсу пришла в окончательное состояние. В spec исход читается из invoice.status, invoice.payment_status и transaction.status; в legacy успех и неуспех — разные события; в XML различие — в Status и PaymentResultCode
refund.updatedrefund.completedSendPaymentTransactionВозврат принят в обработку. В spec статус возврата — invoice.refund_status. Отдельного XML-имени нет: то же уведомление, статус возврата — в InvoiceInfo/InvoiceStatus, блока PaymentTransactionInfo нет
receipt.issued—SendPaymentReceiptФискальный чек сформирован. Только оплата картой и СБП, приходит следом за платёжным уведомлением. В песочнице чек эмулируется, ReceiptUrl ведёт на её страницу-заглушку
contract.authorizedcontract.authorizedSendCustomerAuthorizationContractApprovedNotificationКонтракт авторизован. Только JSON-версия
contract.closedcontract.closedCustomerAuthorizationContractCloseNotificationКонтракт закрыт. Только JSON-версия

События контрактов песочница шлёт только в JSON-контуре. В XML-контуре приходят SendPaymentTransaction (платёж и возврат: у возврата статус в InvoiceInfo/InvoiceStatus и нет блока PaymentTransactionInfo; песочница возвращает мгновенно и всегда успешно, поэтому шлёт единственный статус Refund) и SendPaymentReceipt (чек после оплаты картой или СБП). contract.authorized и contract.closed в XML песочница не отправляет — их XML-имена в таблице приведены для соответствия боевому контракту, проверить их приём здесь не получится.

Проверка подписи входящего коллбека

Схема подписи зависит от конверта. В JSON подпись приходит заголовками, в XML — блоком <Header><Authentication> внутри тела.

Что проверитьКонверт платформы (v4)legacy, XML и проект без пары ключей (v3)
X-LoginСовпадает с Login2Merchant проектаСовпадает с вашим логином
X-Signature-VersionРавен 4Равен 3
X-TimestampОтличается от текущего UTC не более чем на ваш допуск (у песочницы ±20 минут) — это защита от повторной отправки перехваченного запросато же
X-SignatureСовпадает с HMAC-SHA256(Password2Merchant, X-Timestamp + "." + RAW_BODY) — по сырому телу, до разбора JSONСовпадает с HMAC(secret, login + HMAC(secret, timestamp))

Сравнивайте подпись константно по времени (hmac.compare_digest), а не оператором ==.

В v3 подписывается только метка времени, не тело. Подпись подтверждает, что отправитель знает ваш секрет, и защищает от повтора старого запроса, но не гарантирует целостность payload. Не считайте её контрольной суммой содержимого: сверяйте суммы и статусы с GET /invoices/{invoice_id}, прежде чем отгружать товар. Подпись v4 покрывает сырое тело — поэтому проверяйте её до разбора JSON.

Идемпотентность

Одно и то же событие может прийти несколько раз: сеть, таймаут на вашей стороне, повторная доставка. Ключ дедупликации — id в теле (в legacy — event_id), он же заголовок X-Event-Id (в XML — RequestID).

Идентификатор события не меняется между попытками доставки, а номер попытки лежит отдельно, в X-Request-Attempt (в legacy — X-Event-Attempt). Значит, дедуплицировать нужно именно по идентификатору события: сохраните его при первой обработке и на повтор отвечайте 200, ничего не делая заново. Отвечать ошибкой на дубль нельзя — песочница сочтёт доставку неуспешной и продолжит повторы.

Если ваш сервер не ответил 200

Успехом считается 2xx, а в конверте платформы — ещё и тело без success: false. Всё остальное — неуспех, включая таймаут и редирект (редиректы не выполняются).

  1. Песочница увеличивает счётчик попыток и планирует следующую по боевому расписанию (2 → 2 → 5 → 5 → 10 → 10 секунд).
  2. После седьмой неуспешной попытки — на 34-й секунде после первой — доставка получает статус gave_up и больше не повторяется автоматически: следующая ступень расписания (1 мин) увела бы попытку за минуту, которой песочница ограничивает ретраи. В бою расписание на этом месте продолжается ещё сутки.
  3. Пропущенное событие восстанавливается опросом: GET /invoices/{invoice_id} отдаёт актуальные status и payment_status независимо от того, дошёл коллбек или нет. Не полагайтесь на коллбек как на единственный источник состояния платежа.
  4. Если нужно посмотреть, что именно ушло и с какой ошибкой, — попросите администраторов sandbox, журнал доставки хранится на стороне песочницы. ИИ-агенту то же доступно инструментом callback_last (см. «MCP-сервер»).
Уведомление о платеже (JSON — payment.updated, по умолчанию)
// заголовки: X-Event-Type / X-Event-Id / X-Request-Attempt / // X-Login / X-Timestamp / X-Signature-Version: 4 / X-Signature { "id": "19e14b58-...", "type": "payment.updated", "created_at": "2026-08-03T09:29:46Z", "project_id": 1001, "data": { "customer": { "type": "mobile_phone", "msisdn": "79600000000" }, "invoice": { "invoice_id": "1aa2b436-...", "merchant_order_id": "ORDER-001", "status": "Processed", "payment_status": "PAID", "price": { "amount": "99.00", "currency": "RUB", "vat_included": true } }, "transaction": { "transaction_id": "B4DA05E2-...", "sequence_number": 1, "status": "SUCCESS", "result_code": 0, "result_description": "OK", "paid_at": "2026-08-03T09:29:46Z", "price": { "amount": "99.00", "currency": "RUB", "vat_included": true } }, "content_required": false } }
То же уведомление в прежнем конверте (json_callback_format = legacy)
// payment.completed (при неуспехе — payment.failed) // заголовки: X-Event-Type / X-Event-Id / X-Event-Attempt / // X-Login / X-Timestamp / X-Signature-Version: 3 / X-Signature { "event_type": "payment.completed", "event": "payment.completed", // deprecated-алиас event_type, будет удалён в 3.0.0 "event_id": "19e14b58-...", "occurred_at": "2026-08-03T09:29:46Z", "data": { "invoice_id": "1aa2b436-...", "merchant_order_id": "ORDER-001", "status": "Processed", "payment_status": "PAID", "price": { "amount": "99.00", "currency": "RUB", "vat_included": true }, "billing_result": "Success", "result_code": 0, "result_description": "OK", // deprecated-алиасы, будут удалены в 3.0.0 "amount": "99.00", "currency": "RUB" } }
Уведомление о возврате (JSON — refund.updated)
{ "id": "c63f380e-0588-4a37-a979-eac526249455", "type": "refund.updated", "created_at": "2026-08-06T18:51:32Z", "project_id": 1001, "data": { // фрагмент: блок customer опущен "invoice": { "invoice_id": "4c702dc3-176e-4365-ab4d-8fd22cbcfab1", "status": "Refund", "refund_status": "Refund" } } }
Проверка подписи конверта платформы — v4 (Python)
import hashlib, hmac PASSWORD2MERCHANT = "..." # ключ подписи уведомлений проекта def notification_is_authentic(headers, raw_body: bytes, login2merchant: str) -> bool: if headers.get("X-Login") != login2merchant: return False if headers.get("X-Signature-Version") != "4": return False ts = headers.get("X-Timestamp", "") # проверку допуска X-Timestamp — как в v3 ниже # подпись по СЫРОМУ телу, до json.loads expected = hmac.new( PASSWORD2MERCHANT.encode(), ts.encode() + b"." + raw_body, hashlib.sha256, ).hexdigest() return hmac.compare_digest(expected, headers.get("X-Signature", ""))
Проверка подписи — v3: legacy, XML, проект без пары ключей (Python)
import hashlib, hmac from datetime import datetime, timezone SECRET = "demo_password" DRIFT_SECONDS = 20 * 60 def _hmac(key: str, msg: str) -> str: return hmac.new(key.encode(), msg.encode(), hashlib.sha256).hexdigest() def callback_is_authentic(headers, my_login: str) -> bool: # 1. отправитель — тот, за кого себя выдаёт if headers.get("X-Login") != my_login: return False if headers.get("X-Signature-Version") != "3": return False # 2. защита от повтора перехваченного запроса ts = headers.get("X-Timestamp", "") try: sent = datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) except ValueError: return False if abs((datetime.now(timezone.utc) - sent).total_seconds()) > DRIFT_SECONDS: return False # 3. подпись: HMAC(secret, login + HMAC(secret, timestamp)) expected = _hmac(SECRET, my_login + _hmac(SECRET, ts)) # сравнение константное по времени, не == return hmac.compare_digest(expected, headers.get("X-Signature", ""))
Обработчик: идемпотентность и ответ
# id события не меняется между попытками доставки — # это и есть ключ дедупликации (в legacy — body["event_id"]) event_id = body["id"] if seen_before(event_id): # повтор: подтверждаем и ничего не делаем заново. # Ошибка на дубль заставит песочницу повторять снова return 200 mark_seen(event_id) apply_payment(body["data"]) return 200 # успех — 2xx; в конверте платформы не отвечайте success: false