Callbacks
Callbacks are signed with HMAC-SHA256 and sent to the project’s callback URL. The body format and the signing scheme depend on the project’s format flag — see “Notification format”.
| Parameter | Value |
|---|---|
| Success criterion | a 2xx response. In the platform envelope (the JSON default) the response body is read: success: false declines the delivery and it is retried. In the legacy JSON envelope and in XML the body is not parsed, 204 with no content is fine |
| Max attempts | 7 — that many steps of the production schedule fit into the one minute the sandbox allows for delivery |
| Delay before first attempt | 10 s, configurable via callback_delay_seconds (see “Sandbox knobs”) |
| Backoff between attempts | 2s → 2s → 5s → 5s → 10s → 10s — the start of the production schedule (see “Webhooks”); it continues with 1 min × 14, 5 min × 21, 10 min × 132, which is already outside the sandbox minute |
| HTTP timeout | 60 s (5 s for an address from test_info.) |
| Redirects | not followed |
Notification format
The notification format is set on the project; JSON and XML have two separate flags with different defaults.
| Flow | Project flag | Default | Legacy envelope |
|---|---|---|---|
| JSON v3 | json_callback_format | spec — the platform envelope (NotificationEvent), since v2.96.1 for existing projects too | enabled with the value legacy |
| Merchant XML | xml_callback_format | legacy — the historical flat SendPaymentTransaction body | already on; the full specification envelope is the value spec |
The flag is switched by the sandbox administrators in the Admin UI — ask them, a partner cannot change it. A change applies to notifications queued after it: events already waiting for delivery go out in the previous format.
How the JSON envelopes differ:
spec (default) | legacy | |
|---|---|---|
| Body | {id, type, created_at, project_id, data}, nested data | {event_type, event, event_id, occurred_at, data}, flat data |
| Event names | by phase: payment., refund., contract.*, receipt., content. | by outcome: payment., payment., refund., contract.* |
| Signature | v4 over the raw body with the Password2Merchant key; if the project has no key pair — v3 with the partner secret | v3 with the partner secret, the body is not covered by the signature |
| Attempt number | X-Request-Attempt, from 0 | X-Event-Attempt, from 1 |
| Merchant response | read: success: false means redelivery, data. means a content. event | not read, any 2xx is enough |
Delivery headers
| JSON | XML | Meaning |
|---|---|---|
X-Event-Type | Service-Method + X-Event-Type | Event type — mirrors type from the body (event_type in legacy). In XML Service-Method carries the XML name (SendPaymentTransaction) |
X-Event-Id | RequestID + X-Event-Id | Event identifier — mirrors id from the body (event_id in legacy) and is stable across retries: use it as the deduplication key |
X-Request-Attempt (X-Event-Attempt in legacy) | RequestAttempt + X-Event-Attempt | Attempt number: from 0 in the platform envelope, from 1 in legacy |
X-Login, X-Timestamp, X-Signature-Version, X-Signature | — | Signature. In the platform envelope X-Login is Login2Merchant, version 4; without a key pair and in legacy — the partner login and version 3 |
Notification types
JSON (spec) | JSON (legacy) | XML name | When it is sent |
|---|---|---|---|
payment. | payment. / payment. | SendPaymentTransaction | A payment transaction for an invoice reached its final state. In spec the outcome is read from invoice., invoice. and transaction.; in legacy success and failure are separate events; in XML the difference is in Status and PaymentResultCode |
refund. | refund. | SendPaymentTransaction | The refund was accepted for processing. In spec the refund status is invoice.. There is no separate XML name: the same notification, the refund status is in InvoiceInfo/InvoiceStatus, and the PaymentTransactionInfo block is absent |
receipt. | — | SendPaymentReceipt | The fiscal receipt was registered. Card and SBP payments only, arrives after the payment notification. The sandbox emulates the receipt, ReceiptUrl points to its own stub page |
contract. | contract. | SendCustomerAuthorizationContractApprovedNotification | The contract was authorized. JSON version only |
contract. | contract. | CustomerAuthorizationContractCloseNotification | The contract was closed. JSON version only |
Contract events go out in the JSON flow only. The XML flow carries SendPaymentTransaction (payment and refund: a refund has its status in InvoiceInfo/InvoiceStatus and no PaymentTransactionInfo block; the sandbox refunds instantly and always successfully, so it sends the single status Refund) and SendPaymentReceipt (the receipt after a card or SBP payment). The sandbox never sends contract. and contract. in XML — their XML names in the table are listed to match the production contract, so you cannot exercise receiving them here.
Verifying the signature of an incoming callback
The signing scheme depends on the envelope. In JSON the signature arrives in headers, in XML in the <Header><Authentication> block inside the body.
| What to check | Platform envelope (v4) | legacy, XML and a project without a key pair (v3) |
|---|---|---|
X-Login | Matches the project’s Login2Merchant | Matches your own login |
X-Signature-Version | Equals 4 | Equals 3 |
X-Timestamp | Differs from the current UTC by no more than your tolerance (the sandbox uses ±20 minutes) — this is what protects against replaying an intercepted request | same |
X-Signature | Equals HMAC-SHA256(Password2Merchant, X-Timestamp + "." + RAW_BODY) — over the raw body, before parsing JSON | Equals HMAC(secret, login + HMAC(secret, timestamp)) |
Compare the signature in constant time (hmac.), not with the == operator.
In v3 only the timestamp is signed, not the body. The signature proves the sender knows your secret and protects against replaying an old request, but it does not guarantee payload integrity. Do not treat it as a checksum of the content: reconcile amounts and statuses against GET /invoices/{invoice_id} before shipping goods. The v4 signature covers the raw body — so verify it before parsing JSON.
Idempotency
The same event may arrive more than once: the network, a timeout on your side, a retry. The deduplication key is id in the body (event_id in legacy), also sent as the X-Event-Id header (in XML — RequestID).
The event identifier does not change between delivery attempts, while the attempt number lives separately in X-Request-Attempt (X-Event-Attempt in legacy). So deduplicate on the event identifier: store it on first processing and answer 200 to a repeat without redoing anything. Do not answer a duplicate with an error — the sandbox will treat the delivery as failed and keep retrying.
If your server did not answer 200
A 2xx counts as success, and in the platform envelope the body must also not carry success: false. Everything else is a failure, including a timeout and a redirect (redirects are not followed).
- The sandbox increments the attempt counter and schedules the next one on the production schedule (2 → 2 → 5 → 5 → 10 → 10 seconds).
- After the seventh failed attempt — 34 seconds after the first one — the delivery is marked
gave_upand is no longer retried automatically: the next step of the schedule (1 min) would push the attempt past the minute the sandbox allows. In production the schedule continues for another day. - A missed event is recovered by polling:
GET /invoices/{invoice_id}returns the currentstatusandpayment_statuswhether or not the callback arrived. Do not rely on callbacks as the only source of payment state. - To see what exactly was sent and with which error, ask the sandbox administrators — the delivery log is kept on the sandbox side. An AI agent gets the same through the
callback_lasttool (see “MCP server”).
JSON API v3
// headers: 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 on failure)
// headers: 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 alias of event_type, removed in 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 aliases, removed in 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": {
// excerpt: the customer block is omitted
"invoice": {
"invoice_id": "4c702dc3-176e-4365-ab4d-8fd22cbcfab1",
"status": "Refund",
"refund_status": "Refund"
}
}
}import hashlib, hmac
PASSWORD2MERCHANT = "..." # the project's notification signing key
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", "")
# check the X-Timestamp tolerance as in v3 below
# the signature covers the RAW body, before 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. the sender is who it claims to be
if headers.get("X-Login") != my_login:
return False
if headers.get("X-Signature-Version") != "3":
return False
# 2. protection against replaying an intercepted request
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. signature: HMAC(secret, login + HMAC(secret, timestamp))
expected = _hmac(SECRET, my_login + _hmac(SECRET, ts))
# constant-time comparison, not ==
return hmac.compare_digest(expected, headers.get("X-Signature", ""))# the event id does not change between delivery attempts —
# that is the deduplication key (body["event_id"] in legacy)
event_id = body["id"]
if seen_before(event_id):
# repeat: acknowledge and do nothing again.
# An error on a duplicate makes the sandbox retry
return 200
mark_seen(event_id)
apply_payment(body["data"])
return 200 # success is a 2xx; in the platform envelope do not answer success: false