Callbacks¶
A callback is a JSON POST that the API sends to your server when a payment is paid or
canceled. It tells you the result without polling. Every callback is signed with the
project secret: verify the signature before trusting the content.
How it works¶
- APIThe payment is closed —
completedorcanceled. An event with a newevent_idis created - API → Your server
POSTto the callback URL, signed inX-Callback-Signature - Your serverVerifies the signature and time, looks the
event_idup among processed ones - Your server ⇢ APIAnswers
2xxat once, before handling the order - Your serverUpdates the order by
idandstatusin the background - API → Your serverNo
2xx— the same event again after 30 and after 60 seconds
Key points:
- A callback is an event notification. The source of truth is the payment in the API:
GET /api/v1/payments/{id}. - One event may arrive more than once; events of one payment may arrive out of order.
- One payment may get several different events: for example
canceledand thencompleted— see Status changes after closing. - There are few attempts — 3 within a minute and a half. Answer
2xxquickly and keep a reconciliation in case a callback does not get through.
When it arrives¶
A callback arrives when a live payment moves to one of two statuses:
| Status | What happened | What to do |
|---|---|---|
completed |
Payment confirmed | Fulfil the order for amount |
canceled |
Not paid: its lifetime ran out, it was canceled, or the payment could not be confirmed after requisites were issued | Do not fulfil; offer to pay again — with a new payment |
The callback does not carry the cancellation reason: for you every case is the same — the requisites are no longer valid.
A callback does not arrive:
- for a payment in
error.errorhappens only before requisites are issued, and you learn about it from the create response or fromGET /api/v1/payments/{id}. Once requisites are issued, a payment ends only ascompletedorcanceled, and either one brings a callback; - for intermediate statuses
created,processingandappeal(the payment is being re-checked — the callback comes with the outcome); - for test payments (
is_test: true) — see Test payments; - if no callback URL is set on the payment, the project or the merchant — see Where it goes.
Status changes after closing¶
completed and canceled are final, but a payment re-check or a support decision can
change them. Every change is a new event with a new event_id and brings a new
callback:
| Was | Received | When |
|---|---|---|
canceled |
completed |
The payer paid after the payment lifetime, or the payment was confirmed later |
completed |
completed with another amount |
The payer sent a different amount and it was confirmed. initial_amount does not change |
completed |
canceled |
The payment was not confirmed on re-check |
| any | the same status again | The re-check kept the status, or support resent the callback |
Your handler must be able to:
- fulfil an order of a canceled payment if
completedarrives later; - recalculate the order if a new
completedhas anotheramount; - revoke or hold the order if
canceledarrives aftercompleted.
Events may arrive out of order
If a callback changes what you have already stored for the payment, re-read it with
GET /api/v1/payments/{id} and apply the status and amount from the API response.
This way a late event cannot roll the order back.
Where it goes¶
The first URL that is set wins:
callback_urlfrom the create request;- the project callback URL (cabinet, Projects and API);
- the merchant's common callback URL (cabinet, merchant settings).
If no URL is set at any level, no callback is sent: read the result with
GET /api/v1/payments/{id}.
URL requirements:
- absolute
https://, no credentials, up to 2048 characters, notlocalhost; - the host resolves to public IPs only. If any of its addresses is internal (private
networks, loopback, link-local, CGNAT
100.64.0.0/10), no connection is made; - redirects are not followed: a
3xxresponse is a failure. Use the final URL.
Body¶
The body is an event about the payment's transition to a status: the main payment fields,
the rate and fee, event_id and project_id. It has no requisites or USDT amounts — the
full payment is returned by GET /api/v1/payments/{id} (fields).
{
"id": "3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42",
"event_id": "5b0c8f5e-1d2a-4a8e-9f3c-7e6d5c4b3a21",
"amount": 3400,
"initial_amount": 3400,
"status": "completed",
"currency": "RUB",
"payment_method": "sbp",
"bank_code": "sber",
"merchant_payment_id": "order-42",
"rate": 11.00,
"course": 100.00,
"is_intrabank": false,
"is_test": false,
"project_id": "5d7e2c1a-0b9f-4e3d-8c2b-1a0f9e8d7c6b"
}
| Field | Meaning |
|---|---|
id |
Payment id from the create response |
event_id |
Event id. The same in every retry of one event and equal to the X-Callback-Event-Id header |
status |
Event status: completed or canceled. On a manual resend, the payment's current closed status, error included |
amount |
Credited amount in major currency units. After a payment re-check it may differ from initial_amount — fulfil the order by amount |
initial_amount |
Amount from the create request, never changes |
merchant_payment_id |
Your order id |
rate, course |
Your fee, %, and rate — payment currency per 1 USDT. Numbers with two decimals, the same as in merchant_information; null if no requisites were issued |
is_test |
true for a project test payment |
project_id |
Project the payment belongs to |
| other fields | As in the create response: currency, payment_method, bank_code, is_intrabank |
- There is no error code in a callback, even on a manual resend of a payment in
error: read the reason withGET. - Parse
amount,initial_amount,rateandcourseas decimals, not floats. - New fields may be added: ignore unknown fields.
- The body is built once, when the event is queued, and is byte-for-byte the same in every
delivery retry. When in doubt about freshness, re-read the payment with
GET.
Headers¶
| Header | Value |
|---|---|
Content-Type |
application/json |
X-Callback-Event-Id |
The event's event_id |
X-Callback-Timestamp |
Unix time of this attempt, seconds |
X-Callback-Signature |
v1=<hex>; for 24 hours after a secret rotation, v1=<hex>,v1=<hex> |
Signature verification¶
signed_payload = X-Callback-Timestamp + "." + raw_request_body
signature = hex( HMAC-SHA256( key = the whole project secret, message = signed_payload ) )
- The secret is a
whsec_…string. The HMAC key is the whole string as UTF-8,whsec_included. Nothing needs decoding. hexis 64 lower-case characters.- Compute over the raw body bytes before parsing JSON. Parsing and re-serializing may change the bytes.
- The request is authentic if at least one
v1=value matches your signature. Compare in constant time. Skip values with another scheme (notv1).
Replay protection¶
- Reject the request if
X-Callback-Timestampdiffers from your clock by more than 5 minutes. Keep your clock synced with NTP. The timestamp is part of the signature and cannot be forged. - Keep processed
event_idvalues for at least a day. Do not process a repeatedevent_idagain — just answer2xx.
event_id identifies an event, not a payment and not an attempt:
| What happened | event_id |
Body | Timestamp and signature |
|---|---|---|---|
Delivery retry after an error or a non-2xx answer |
same | byte-for-byte same | new |
| New payment status | new | new | new |
| Manual resend by support | new, even if the status did not change | new | new |
Decide the payment outcome by id and status; use event_id only to avoid processing
one delivery twice.
Verification functions¶
The functions check the signature and freshness and are tested on the vector below.
For debugging in a terminal. In your application use your language's function: shell cannot compare strings in constant time.
# Payment API callback signature check (HMAC-SHA256) for debugging in a terminal.
# In your application verify with code that compares in constant time.
# POSIX sh + openssl. Load with: . ./verify_callback.sh
# api_callback_signature SECRET TIMESTAMP BODY_FILE — the expected signature (hex).
api_callback_signature() {
{ printf '%s.' "$2"; cat "$3"; } | openssl dgst -sha256 -hmac "$1" -r | cut -d' ' -f1
}
# api_verify_callback SECRET TIMESTAMP SIGNATURE_HEADER BODY_FILE [NOW]
# Exit code 0: the signature matches and the timestamp is fresh (±300 seconds).
api_verify_callback() {
api_now=${5:-$(date +%s)}
api_skew=$((api_now - $2)); [ "$api_skew" -lt 0 ] && api_skew=$((-api_skew))
[ "$api_skew" -le 300 ] || return 1
api_expected=$(api_callback_signature "$1" "$2" "$4")
for api_part in $(printf '%s' "$3" | tr ',' ' '); do
[ "$api_part" = "v1=$api_expected" ] && return 0
done
return 1
}
<?php
// Payment API callback signature check: HMAC-SHA256. PHP 8.1+, no extensions.
declare(strict_types=1);
const CALLBACK_MAX_SKEW = 300; // reject callbacks more than 5 minutes old or ahead
/**
* true if the callback is authentic and fresh.
* $secret — the whole project secret, including the whsec_ prefix;
* $timestamp — the X-Callback-Timestamp header, $signature — X-Callback-Signature;
* $body — the raw body: file_get_contents('php://input'), before json_decode.
*/
function api_verify_callback(string $secret, string $timestamp, string $signature, string $body, ?int $now = null): bool
{
if (!ctype_digit($timestamp)) {
return false;
}
if (abs(($now ?? time()) - (int) $timestamp) > CALLBACK_MAX_SKEW) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $body, $secret);
foreach (explode(',', $signature) as $part) {
[$scheme, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($scheme === 'v1' && hash_equals($expected, $value)) {
return true;
}
}
return false;
}
"""Payment API callback signature check: HMAC-SHA256. Standard library only."""
import hashlib
import hmac
import time
MAX_SKEW_SECONDS = 300 # reject callbacks more than 5 minutes old or ahead
def verify_callback(secret: str, headers, body: bytes, now: float | None = None) -> bool:
"""True if the callback is authentic and fresh.
secret — the whole project secret, including the whsec_ prefix;
headers — request headers (any object with .get);
body — raw body bytes, before JSON parsing.
"""
timestamp = headers.get("X-Callback-Timestamp", "")
signature = headers.get("X-Callback-Signature", "")
if not timestamp.isdigit():
return False
now = time.time() if now is None else now
if abs(now - int(timestamp)) > MAX_SKEW_SECONDS:
return False
signed = timestamp.encode() + b"." + body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
for part in signature.split(","):
scheme, _, value = part.strip().partition("=")
if scheme == "v1" and hmac.compare_digest(value, expected):
return True
return False
// Payment API callback signature check: HMAC-SHA256. Node.js 20+, no dependencies.
import { createHmac, timingSafeEqual } from 'node:crypto';
const MAX_SKEW_SECONDS = 300; // reject callbacks more than 5 minutes old or ahead
/**
* true if the callback is authentic and fresh.
* secret — the whole project secret, including the whsec_ prefix;
* headers — request headers with lower-case names (as in node:http);
* body — raw body bytes (Buffer), before JSON parsing.
*/
export function verifyCallback(secret, headers, body, now = Date.now() / 1000) {
const timestamp = String(headers['x-callback-timestamp'] ?? '');
const signature = String(headers['x-callback-signature'] ?? '');
if (!/^\d+$/.test(timestamp)) return false;
if (Math.abs(now - Number(timestamp)) > MAX_SKEW_SECONDS) return false;
const expected = Buffer.from(
createHmac('sha256', secret).update(`${timestamp}.`).update(body).digest('hex'),
);
return signature.split(',').some((part) => {
const [scheme, value = ''] = part.trim().split('=', 2);
const candidate = Buffer.from(value);
return scheme === 'v1' && candidate.length === expected.length && timingSafeEqual(candidate, expected);
});
}
package paymentapi
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"net/http"
"strconv"
"strings"
"time"
)
// maxSkew: callbacks more than 5 minutes old or ahead are rejected.
const maxSkew = 5 * time.Minute
// VerifyCallback checks the callback signature and freshness. secret is the whole project
// secret including whsec_; body is the raw request body, before JSON parsing.
func VerifyCallback(secret string, header http.Header, body []byte, now time.Time) bool {
timestamp := header.Get("X-Callback-Timestamp")
sent, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
if skew := now.Sub(time.Unix(sent, 0)); skew > maxSkew || skew < -maxSkew {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(body)
expected := []byte(hex.EncodeToString(mac.Sum(nil)))
for _, part := range strings.Split(header.Get("X-Callback-Signature"), ",") {
scheme, value, ok := strings.Cut(strings.TrimSpace(part), "=")
if ok && scheme == "v1" && hmac.Equal([]byte(value), expected) {
return true
}
}
return false
}
Test vector¶
body is exactly one line with no line breaks and no spaces between fields (the body
from the example above as the API sends it), 362 bytes:
secret = whsec_MfKQ9r2vXz
timestamp = 1700000000
body = {"id":"3f1c2b9e-6a8d-4c4b-9a71-2c7f5d1e0a42","event_id":"5b0c8f5e-1d2a-4a8e-9f3c-7e6d5c4b3a21","amount":3400,"initial_amount":3400,"status":"completed","currency":"RUB","payment_method":"sbp","bank_code":"sber","merchant_payment_id":"order-42","rate":11.00,"course":100.00,"is_intrabank":false,"is_test":false,"project_id":"5d7e2c1a-0b9f-4e3d-8c2b-1a0f9e8d7c6b"}
signature = e3e688bac4039def6f680c16f14701866d8e5e2de66d9b37f9ecc8bccb90220f
With the previous secret whsec_previous the same body gives
54b9a97acdb1adf27f81bc49bdc9d963b4fe4f3bf5114ea3303581084e99f3b4, and during the
overlap the header looks like
v1=e3e688ba…90220f,v1=54b9a97a…99f3b4.
How to handle it¶
- Read the raw body and headers.
- Verify the signature and
X-Callback-Timestamp. If they fail, answer401and change nothing. - Insert
event_idinto a table with a unique index. Already there — answer2xxand stop. - Queue the event in your own queue and answer
2xxat once. - In the background find the order by
merchant_payment_idor the storedidand applystatusandamountfollowing the status change rules.
2xx means "event accepted", not "order fulfilled". If your processing fails after the
answer, re-read the payment with GET: the callback will not come again by itself.
Minimal receiver¶
Verifies the signature, drops duplicates and answers 2xx at once. In production store
event_id in a database with a unique index and queue the processing.
<?php
// Minimal callback receiver: put this file behind your web server (or php -S 0.0.0.0:8080).
// The secret comes from the CALLBACK_SECRET environment variable.
declare(strict_types=1);
require __DIR__ . '/callback.php';
$body = file_get_contents('php://input');
$valid = api_verify_callback(
getenv('CALLBACK_SECRET') ?: '',
$_SERVER['HTTP_X_CALLBACK_TIMESTAMP'] ?? '',
$_SERVER['HTTP_X_CALLBACK_SIGNATURE'] ?? '',
$body,
);
if (!$valid) {
http_response_code(401);
exit;
}
$event = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
// Store event_id with a unique index: never process the same event twice.
// Enqueue the work here: mark the order as paid, etc.
error_log("payment {$event['merchant_payment_id']} -> {$event['status']}");
http_response_code(204); // answer 2xx at once, process asynchronously
"""Minimal callback receiver on the standard library.
export CALLBACK_SECRET=whsec_...
python callback_server.py # listens on :8080, any path
"""
import json
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
from callback import verify_callback
SECRET = os.environ.get("CALLBACK_SECRET", "")
seen_events: set[str] = set() # in production: a database table with a unique event_id
class CallbackHandler(BaseHTTPRequestHandler):
def do_POST(self) -> None:
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
if not verify_callback(SECRET, self.headers, body):
self.send_response(401)
self.end_headers()
return
event = json.loads(body)
if event["event_id"] not in seen_events:
seen_events.add(event["event_id"])
# Enqueue the work here: mark the order as paid, etc.
print("payment", event["merchant_payment_id"], "->", event["status"])
self.send_response(204) # answer 2xx at once, process asynchronously
self.end_headers()
if __name__ == "__main__":
HTTPServer(("", 8080), CallbackHandler).serve_forever()
// Minimal callback receiver on node:http.
//
// export CALLBACK_SECRET=whsec_...
// node callback-server.mjs # listens on :8080, any path
import { createServer } from 'node:http';
import { verifyCallback } from './callback.mjs';
const secret = process.env.CALLBACK_SECRET ?? '';
const seenEvents = new Set(); // in production: a database table with a unique event_id
createServer((req, res) => {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const body = Buffer.concat(chunks);
if (req.method !== 'POST' || !verifyCallback(secret, req.headers, body)) {
res.writeHead(401).end();
return;
}
const event = JSON.parse(body);
if (!seenEvents.has(event.event_id)) {
seenEvents.add(event.event_id);
// Enqueue the work here: mark the order as paid, etc.
console.log('payment', event.merchant_payment_id, '->', event.status);
}
res.writeHead(204).end(); // answer 2xx at once, process asynchronously
});
}).listen(8080);
// Minimal callback receiver on net/http.
//
// export CALLBACK_SECRET=whsec_...
// go run ./cmd/callback-server # listens on :8080, any path
package main
import (
"encoding/json"
"io"
"log"
"net/http"
"os"
"sync"
"time"
"example.com/paymentapi"
)
func main() {
secret := os.Getenv("CALLBACK_SECRET")
var mu sync.Mutex
seen := map[string]bool{} // in production: a database table with a unique event_id
http.HandleFunc("POST /", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil || !paymentapi.VerifyCallback(secret, r.Header, body, time.Now()) {
w.WriteHeader(http.StatusUnauthorized)
return
}
var event struct {
EventID string `json:"event_id"`
MerchantPaymentID string `json:"merchant_payment_id"`
Status string `json:"status"`
}
if err = json.Unmarshal(body, &event); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
mu.Lock()
first := !seen[event.EventID]
seen[event.EventID] = true
mu.Unlock()
if first {
// Enqueue the work here: mark the order as paid, etc.
log.Printf("payment %s -> %s", event.MerchantPaymentID, event.Status)
}
w.WriteHeader(http.StatusNoContent) // answer 2xx at once, process asynchronously
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
Delivery and retries¶
- Success is any
2xx. The response body is not read beyond 4 KB. - Failure is a network error, a timeout or any other code:
3xx,4xx,5xx. - Timeout — 10 seconds per request. Answer
2xxat once and process asynchronously. - Retries. An event gets 3 attempts: at once, after 30 seconds and after another
60 seconds — about a minute and a half. All attempts carry the same
event_idand body. After the third failure delivery of this event stops. - Manual resend. Support can send a callback for a closed payment again, including a
payment in
error. It is a new event with a newevent_idand a single attempt, without automatic retries. - No ordering guarantee. A retry of an old event may arrive after a new one. Make the handler idempotent.
| Attempt | When | If no 2xx |
|---|---|---|
| 1 | right after the event | attempt 2 in 30 seconds |
| 2 | after 30 seconds | attempt 3 in 60 seconds |
| 3 | after 90 seconds | delivery stops |
When in doubt, re-read the payment
If a callback contradicts what you know about the order, read the current state with
GET /api/v1/payments/{id}. The API answer beats delivery order.
If a callback did not arrive¶
There are few attempts, so do not rely on callbacks alone:
- Reconciliation. Every few minutes select your orders that have waited for payment
longer than the project payment lifetime (15 minutes by default) and read them with
GET /api/v1/payments/{id}. Handle a closed payment the same way as a received callback. Do not poll more often than once per 5–10 seconds per payment: the project shares one rate limit. - Check the URL. It is set on at least one level, reachable from the internet, answers
2xxwithout a redirect, and its certificate is valid. - Resend. If your server was down, send support the payment
idvalues or theirmerchant_payment_id— the callbacks will be sent again. Each comes with a newevent_id.
Common problems¶
| Symptom | Cause |
|---|---|
| Signature does not match | Signed over parsed and re-serialized JSON instead of the raw body; key taken without whsec_; secret from another project or environment |
| Signature is right, but the request is rejected by time | Server clock is off by more than 5 minutes |
| No callbacks at all | URL not set; URL answers 301 or 302; host resolves to an internal IP; payments are test ones |
| Order processed twice | Retries are not dropped by event_id, or the outcome is decided by event_id instead of id and status |
| Order "rolled back" | A late event was applied over a newer one. Re-read the payment with GET when the status changes |
Test payments¶
Payments with is_test: true — project test payments and test payments from the cabinet —
do not get callbacks automatically. Read their outcome with GET /api/v1/payments/{id}.
To test your handler:
- run your verification function on the test vector;
- ask support to resend the callback of a closed test payment — a real signed request with
is_test: truewill reach your URL.
More in Sandbox.
Secret and rotation¶
- The secret is issued in the cabinet: Projects and API → External API access → Callback. It is shown once — keep it in a secret store.
- Each project has its own secret; sandbox and production secrets differ.
- If no secret was issued, the platform creates one on first delivery but shows it to nobody. To verify signatures, issue a new one.
- Lossless rotation. After a new secret is issued, the previous one keeps signing for
24 hours as a second
v1value. Steps: issue the new secret → add it next to the old one (or replace at once — any matching value is fine) → remove the old one within 24 hours. Rotating again within those 24 hours drops the oldest secret immediately: at most two are valid at a time. - If the secret leaks, issue a new one and remove the old one from your check right away.
Checklist¶
- The callback URL is set, reachable from the internet,
https://, no redirect. - The signature is checked over the raw body with a constant-time comparison.
- Requests older than 5 minutes are rejected; the clock is synced.
-
event_idis stored with a unique index for at least a day. -
2xxgoes out at once; processing runs in the background. - The order is fulfilled by
amount, notinitial_amount. -
canceled→completed,completed→canceledand a newamountare handled. - Orders without a callback are reconciled with
GET /api/v1/payments/{id}.