Коллбеки
Коллбеки подписываются 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.) |
| Редиректы | не выполняются |
Формат уведомлений
Формат уведомлений задаётся на проекте, у JSON и XML это два разных флага с разными значениями по умолчанию.
| Контур | Флаг проекта | По умолчанию | Прежний конверт |
|---|---|---|---|
| JSON v3 | json_callback_format | spec — конверт платформы (NotificationEvent), с v2.96.1 и у существующих проектов | включается значением legacy |
| Merchant XML | xml_callback_format | legacy — прежнее плоское тело 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., refund., contract.*, receipt., content. | по исходу: payment., payment., refund., contract.* |
| Подпись | v4 по сырому телу ключом Password2Merchant; если пары ключей у проекта нет — v3 секретом партнёра | v3 секретом партнёра, тело подписью не покрыто |
| Номер попытки | X-Request-Attempt, с 0 | X-Event-Attempt, с 1 |
| Ответ мерчанта | читается: success: false — повтор доставки, data. — событие content. | не читается, достаточно любого 2xx |
Заголовки доставки
| JSON | XML | Значение |
|---|---|---|
X-Event-Type | Service-Method + X-Event-Type | Тип события — дублирует type из тела (в legacy — event_type). В XML Service-Method несёт XML-имя (SendPaymentTransaction) |
X-Event-Id | RequestID + 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. | payment. / payment. | SendPaymentTransaction | Платёжная транзакция по инвойсу пришла в окончательное состояние. В spec исход читается из invoice., invoice. и transaction.; в legacy успех и неуспех — разные события; в XML различие — в Status и PaymentResultCode |
refund. | refund. | SendPaymentTransaction | Возврат принят в обработку. В spec статус возврата — invoice.. Отдельного XML-имени нет: то же уведомление, статус возврата — в InvoiceInfo/InvoiceStatus, блока PaymentTransactionInfo нет |
receipt. | — | SendPaymentReceipt | Фискальный чек сформирован. Только оплата картой и СБП, приходит следом за платёжным уведомлением. В песочнице чек эмулируется, ReceiptUrl ведёт на её страницу-заглушку |
contract. | contract. | SendCustomerAuthorizationContractApprovedNotification | Контракт авторизован. Только JSON-версия |
contract. | contract. | CustomerAuthorizationContractCloseNotification | Контракт закрыт. Только JSON-версия |
События контрактов песочница шлёт только в JSON-контуре. В XML-контуре приходят SendPaymentTransaction (платёж и возврат: у возврата статус в InvoiceInfo/InvoiceStatus и нет блока PaymentTransactionInfo; песочница возвращает мгновенно и всегда успешно, поэтому шлёт единственный статус Refund) и SendPaymentReceipt (чек после оплаты картой или СБП). contract. и contract. в 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.), а не оператором ==.
В 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. Всё остальное — неуспех, включая таймаут и редирект (редиректы не выполняются).
- Песочница увеличивает счётчик попыток и планирует следующую по боевому расписанию (2 → 2 → 5 → 5 → 10 → 10 секунд).
- После седьмой неуспешной попытки — на 34-й секунде после первой — доставка получает статус
gave_upи больше не повторяется автоматически: следующая ступень расписания (1 мин) увела бы попытку за минуту, которой песочница ограничивает ретраи. В бою расписание на этом месте продолжается ещё сутки. - Пропущенное событие восстанавливается опросом:
GET /invoices/{invoice_id}отдаёт актуальныеstatusиpayment_statusнезависимо от того, дошёл коллбек или нет. Не полагайтесь на коллбек как на единственный источник состояния платежа. - Если нужно посмотреть, что именно ушло и с какой ошибкой, — попросите администраторов sandbox, журнал доставки хранится на стороне песочницы. ИИ-агенту то же доступно инструментом
callback_last(см. «MCP-сервер»).
JSON API v3
// заголовки: 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
}
}// 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"
}
}{
"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"
}
}
}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", ""))Sandbox
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