Skip to Content

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”.

ParameterValue
Success criteriona 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 attempts7 — that many steps of the production schedule fit into the one minute the sandbox allows for delivery
Delay before first attempt10 s, configurable via callback_delay_seconds (see “Sandbox knobs”)
Backoff between attempts2s → 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 timeout60 s (5 s for an address from test_info.callback_url)
Redirectsnot followed

Notification format

The notification format is set on the project; JSON and XML have two separate flags with different defaults.

FlowProject flagDefaultLegacy envelope
JSON v3json_callback_formatspec — the platform envelope (NotificationEvent), since v2.96.1 for existing projects tooenabled with the value legacy
Merchant XMLxml_callback_formatlegacy — the historical flat SendPaymentTransaction bodyalready 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 namesby phase: payment.updated, refund.updated, contract.*, receipt.issued, content.deliveredby outcome: payment.completed, payment.failed, refund.completed, contract.*
Signaturev4 over the raw body with the Password2Merchant key; if the project has no key pair — v3 with the partner secretv3 with the partner secret, the body is not covered by the signature
Attempt numberX-Request-Attempt, from 0X-Event-Attempt, from 1
Merchant responseread: success: false means redelivery, data.content means a content.delivered eventnot read, any 2xx is enough

Delivery headers

JSONXMLMeaning
X-Event-TypeService-Method + X-Event-TypeEvent type — mirrors type from the body (event_type in legacy). In XML Service-Method carries the XML name (SendPaymentTransaction)
X-Event-IdRequestID + X-Event-IdEvent 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-AttemptAttempt 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 nameWhen it is sent
payment.updatedpayment.completed / payment.failedSendPaymentTransactionA payment transaction for an invoice reached its final state. In spec the outcome is read from invoice.status, invoice.payment_status and transaction.status; in legacy success and failure are separate events; in XML the difference is in Status and PaymentResultCode
refund.updatedrefund.completedSendPaymentTransactionThe refund was accepted for processing. In spec the refund status is invoice.refund_status. There is no separate XML name: the same notification, the refund status is in InvoiceInfo/InvoiceStatus, and the PaymentTransactionInfo block is absent
receipt.issued—SendPaymentReceiptThe 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.authorizedcontract.authorizedSendCustomerAuthorizationContractApprovedNotificationThe contract was authorized. JSON version only
contract.closedcontract.closedCustomerAuthorizationContractCloseNotificationThe 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.authorized and contract.closed 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 checkPlatform envelope (v4)legacy, XML and a project without a key pair (v3)
X-LoginMatches the project’s Login2MerchantMatches your own login
X-Signature-VersionEquals 4Equals 3
X-TimestampDiffers from the current UTC by no more than your tolerance (the sandbox uses ±20 minutes) — this is what protects against replaying an intercepted requestsame
X-SignatureEquals HMAC-SHA256(Password2Merchant, X-Timestamp + "." + RAW_BODY) — over the raw body, before parsing JSONEquals HMAC(secret, login + HMAC(secret, timestamp))

Compare the signature in constant time (hmac.compare_digest), 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).

  1. The sandbox increments the attempt counter and schedules the next one on the production schedule (2 → 2 → 5 → 5 → 10 → 10 seconds).
  2. After the seventh failed attempt — 34 seconds after the first one — the delivery is marked gave_up and 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.
  3. A missed event is recovered by polling: GET /invoices/{invoice_id} returns the current status and payment_status whether or not the callback arrived. Do not rely on callbacks as the only source of payment state.
  4. 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_last tool (see “MCP server”).
Payment notification (JSON — payment.updated, default)
// 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 } }
The same notification in the legacy envelope (json_callback_format = legacy)
// 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" } }
Refund notification (JSON — refund.updated)
{ "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" } } }
Verifying the platform envelope signature — v4 (Python)
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", ""))
Verifying the signature — v3: legacy, XML, a project without a key pair (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. 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", ""))
Handler: idempotency and the response
# 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