Callback-и¶
Callback — это POST с JSON, который API отправляет на ваш сервер, когда заявка
оплачена или отменена. По нему вы узнаёте результат без опроса. Каждый callback подписан
секретом проекта: проверяйте подпись, прежде чем верить содержимому.
Как это работает¶
- APIЗаявка закрылась —
completedилиcanceled. Создаётся событие с новымevent_id - API → Ваш сервер
POSTна адрес callback-а с подписьюX-Callback-Signature - Ваш серверПроверяет подпись и время, ищет
event_idсреди обработанных - Ваш сервер ⇢ APIОтвечает
2xxсразу, до обработки заказа - Ваш серверВ фоне обновляет заказ по
idиstatus - API → Ваш серверНет
2xx— то же событие ещё раз через 30 и через 60 секунд
Главное:
- Callback — уведомление о событии. Источник правды — заявка в API:
GET /api/v1/payments/{id}. - Одно событие может прийти несколько раз, события по одной заявке — не по порядку.
- По одной заявке может прийти несколько разных событий: например,
canceled, а потомcompleted— см. Смена статуса после закрытия. - Попыток мало — 3 за полторы минуты. Отвечайте
2xxбыстро и держите сверку на случай, если callback не дошёл.
Когда приходит¶
Callback приходит, когда боевая заявка переходит в один из двух статусов:
| Статус | Что случилось | Что делать |
|---|---|---|
completed |
Оплата подтверждена | Выдать заказ на сумму amount |
canceled |
Заявка не оплачена: истёк её срок, её отменили или оплату не удалось подтвердить после выдачи реквизитов | Не выдавать заказ; предложить оплатить заново — новой заявкой |
Причину отмены callback не передаёт: для вас все случаи одинаковы — реквизиты больше не действуют.
Callback не приходит:
- о заявке в статусе
error.errorбывает только до выдачи реквизитов, и вы узнаёте о нём из ответа на создание или изGET /api/v1/payments/{id}. Если реквизиты выданы, заявка заканчивается толькоcompletedилиcanceled— и о любом из них приходит callback; - о промежуточных статусах
created,processingиappeal(оплату перепроверяют — callback придёт с результатом проверки); - о тестовых заявках (
is_test: true) — см. Тестовые заявки; - если адрес callback-а не задан ни в заявке, ни в проекте, ни у мерчанта — см. Куда приходит.
Смена статуса после закрытия¶
completed и canceled — финальные статусы, но их может изменить проверка оплаты или
решение службы поддержки. Каждое изменение — новое событие с новым event_id, и о нём
приходит новый callback:
| Было | Пришло | Когда бывает |
|---|---|---|
canceled |
completed |
Плательщик перевёл деньги после срока заявки, или оплату подтвердили позже |
completed |
completed с другим amount |
Плательщик перевёл другую сумму, и её подтвердили. initial_amount не меняется |
completed |
canceled |
Оплата не подтвердилась при проверке |
| любой | тот же статус ещё раз | Проверка оставила статус прежним, или служба поддержки повторила callback |
Обработчик должен уметь:
- выдать заказ по отменённой заявке, если позже пришёл
completed; - пересчитать заказ, если в новом
completedдругойamount; - отозвать или придержать заказ, если после
completedпришёлcanceled.
События могут прийти не по порядку
Если callback меняет то, что вы уже записали по заявке, перечитайте её через
GET /api/v1/payments/{id} и примените статус и сумму из ответа API. Так запоздавшее
событие не откатит заказ.
Куда приходит¶
Адрес выбирается по первому заданному:
callback_urlиз запроса на создание заявки;- адрес callback-ов проекта (кабинет, «Проекты и API»);
- общий адрес callback-ов мерчанта (кабинет, настройки мерчанта).
Если адреса нет ни на одном уровне, callback не отправляется: результат узнавайте
через GET /api/v1/payments/{id}.
Требования к адресу:
- абсолютный
https://, без логина и пароля, до 2048 символов, неlocalhost; - хост разрешается только в публичные IP. Если среди адресов хоста есть внутренний
(частные сети, loopback, link-local, CGNAT
100.64.0.0/10), соединение не открывается; - redirect не выполняется: ответ
3xx— неудача. Указывайте конечный адрес.
Тело¶
Тело — событие о переходе заявки в статус: основные поля заявки, курс и ставка,
event_id и project_id. Реквизитов и сумм в USDT в нём нет — полную заявку отдаёт
GET /api/v1/payments/{id} (поля).
{
"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"
}
| Поле | Что значит |
|---|---|
id |
id заявки из ответа на её создание |
event_id |
Идентификатор события. Одинаковый во всех повторах одного события и равен заголовку X-Callback-Event-Id |
status |
Статус события: completed или canceled. При ручном повторе — текущий закрытый статус заявки, в том числе error |
amount |
Сумма, которую засчитали, в основных единицах валюты. После проверки оплаты может отличаться от initial_amount — выдавайте заказ по amount |
initial_amount |
Сумма из запроса на создание, не меняется |
merchant_payment_id |
Ваш номер заказа |
rate, course |
Ваша ставка, % и курс — валюта заявки за 1 USDT. Числа с двумя знаками, те же, что в merchant_information; null, если реквизиты не выдавались |
is_test |
true у тестовой заявки проекта |
project_id |
Проект, к которому относится заявка |
| остальные поля | Как в ответе на создание: currency, payment_method, bank_code, is_intrabank |
- Кода ошибки в callback нет, даже при ручном повторе заявки в
error: причину смотритеGET-ом. amount,initial_amount,rateиcourseчитайте как десятичные числа (decimal), а не float.- Новые поля могут добавляться: неизвестные поля игнорируйте.
- Тело собирается один раз, когда событие ставится в очередь, и во всех повторах доставки
одно и то же байт в байт. Если сомневаетесь в актуальности, перечитайте заявку
GET-ом.
Заголовки¶
| Заголовок | Значение |
|---|---|
Content-Type |
application/json |
X-Callback-Event-Id |
event_id события |
X-Callback-Timestamp |
Unix-время отправки этой попытки, секунды |
X-Callback-Signature |
v1=<hex>; 24 часа после ротации секрета — v1=<hex>,v1=<hex> |
Проверка подписи¶
signed_payload = X-Callback-Timestamp + "." + сырое_тело_запроса
signature = hex( HMAC-SHA256( key = секрет проекта целиком, message = signed_payload ) )
- Секрет — строка
whsec_…. Ключ HMAC — вся строка в UTF-8, вместе сwhsec_. Декодировать ничего не нужно. hex— 64 символа в нижнем регистре.- Считайте подпись по сырым байтам тела до разбора JSON. После разбора и повторной сериализации байты могут измениться.
- Запрос подлинный, если хотя бы одно значение
v1=совпало с вашей подписью. Сравнивайте за постоянное время. Значения с другой схемой (неv1) пропускайте.
Защита от повтора¶
- Отклоняйте запрос, если
X-Callback-Timestampотличается от ваших часов больше чем на 5 минут. Держите часы синхронизированными по NTP. Timestamp входит в подпись, подменить его нельзя. - Храните обработанные
event_idне меньше суток. Повтор с тем жеevent_idне обрабатывайте второй раз — просто ответьте2xx.
event_id — идентификатор события, а не заявки и не попытки:
| Что произошло | event_id |
Тело | Timestamp и подпись |
|---|---|---|---|
Повтор доставки после ошибки или ответа не 2xx |
тот же | то же байт в байт | новые |
| Новый статус заявки | новый | новое | новые |
| Ручной повтор службой поддержки | новый, даже если статус не изменился | новое | новые |
Итог по заявке определяйте по id и status, а event_id используйте только чтобы не
обработать одну доставку дважды.
Функции проверки¶
Функции проверяют подпись и свежесть и проверены на примере ниже.
Для отладки в терминале. В приложении используйте функцию своего языка: в shell нельзя сравнить строки за постоянное время.
# 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
}
Проверочный пример¶
body — ровно одна строка без переводов строк и пробелов между полями (тело из примера
выше в том виде, в каком его отправляет API), 362 байта:
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
С прошлым секретом whsec_previous то же тело даёт
54b9a97acdb1adf27f81bc49bdc9d963b4fe4f3bf5114ea3303581084e99f3b4, и во время
перекрытия заголовок выглядит так:
v1=e3e688ba…90220f,v1=54b9a97a…99f3b4.
Как обрабатывать¶
- Прочитайте сырое тело и заголовки.
- Проверьте подпись и
X-Callback-Timestamp. Не прошло — ответьте401и ничего не меняйте. - Запишите
event_idв таблицу с уникальным индексом. Уже есть — ответьте2xxи выйдите. - Поставьте событие в свою очередь и сразу ответьте
2xx. - В фоне найдите заказ по
merchant_payment_idили сохранённомуidи применитеstatusиamountпо правилам смены статуса.
Ответ 2xx значит «событие принято», а не «заказ выдан». Если обработка упала уже после
ответа, перечитайте заявку GET-ом: сам callback больше не придёт.
Минимальный приёмник¶
Проверяет подпись, отбрасывает повторы и сразу отвечает 2xx. В бою храните event_id
в базе с уникальным индексом, а обработку ставьте в очередь.
<?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))
}
Доставка и повторы¶
- Успех — любой ответ
2xx. Тело ответа не читается дальше 4 КБ. - Неудача — сетевая ошибка, таймаут или любой другой код:
3xx,4xx,5xx. - Таймаут — 10 секунд на запрос. Отвечайте
2xxсразу, обрабатывайте асинхронно. - Повторы. У события 3 попытки: сразу, через 30 секунд и ещё через 60 секунд —
около полутора минут. Все попытки несут тот же
event_idи то же тело. После третьей неудачи доставка этого события останавливается. - Ручной повтор. Служба поддержки может отправить callback по закрытой заявке ещё раз,
в том числе по заявке в
error. Это новое событие с новымevent_idи одной попыткой, без автоматических повторов. - Порядок не гарантирован. Повтор старого события может прийти после нового. Обработчик должен быть идемпотентным.
| Попытка | Когда | Если нет 2xx |
|---|---|---|
| 1 | сразу после события | попытка 2 через 30 секунд |
| 2 | через 30 секунд | попытка 3 через 60 секунд |
| 3 | через 90 секунд | доставка остановлена |
Сомневаетесь — перечитайте заявку
Если callback противоречит тому, что вы знаете о заказе, прочитайте актуальное
состояние через GET /api/v1/payments/{id}. Ответ API важнее порядка
доставки.
Если callback не пришёл¶
Попыток мало, поэтому не полагайтесь только на callback:
- Сверка. Раз в несколько минут выбирайте свои заказы, которые ждут оплаты дольше срока
заявки проекта (по умолчанию 15 минут), и читайте их через
GET /api/v1/payments/{id}. Закрытую заявку обрабатывайте так же, как пришедший callback. Не опрашивайте чаще раза в 5–10 секунд на заявку: у проекта общий лимит запросов. - Проверьте адрес. Он задан хотя бы на одном уровне, открывается из интернета,
отвечает
2xxбез redirect-а, сертификат действителен. - Повторная отправка. Если ваш сервер был недоступен, передайте в поддержку
idзаявок или ихmerchant_payment_id— callback-и отправят ещё раз. Каждый придёт с новымevent_id.
Частые проблемы¶
| Симптом | Причина |
|---|---|
| Подпись не сходится | Подпись считается по разобранному и заново собранному JSON, а не по сырому телу; ключ взят без whsec_; секрет от другого проекта или окружения |
| Подпись верная, но запрос отклоняется по времени | Часы сервера расходятся больше чем на 5 минут |
| Callback-и не приходят совсем | Адрес не задан; адрес отвечает 301 или 302; хост разрешается во внутренний IP; заявки тестовые |
| Заказ обработан дважды | Повторы не отбрасываются по event_id, или итог считается по event_id, а не по id и status |
| Заказ «откатился» | Запоздавшее событие применено поверх нового. Перечитывайте заявку GET-ом, когда статус меняется |
Тестовые заявки¶
Заявки с is_test: true — тестовые заявки проекта и тестовые платежи из кабинета —
callback автоматически не получают. Их итог читайте через GET /api/v1/payments/{id}.
Чтобы проверить свой обработчик:
- прогоните функцию проверки на проверочном примере;
- попросите службу поддержки повторить callback по закрытой тестовой заявке — на ваш адрес
придёт настоящий подписанный запрос с
is_test: true.
Подробнее — в разделе Песочница.
Секрет и ротация¶
- Секрет выпускается в кабинете: «Проекты и API» → «Доступ к внешнему API» → «Callback». Значение показывается один раз — сохраните его в хранилище секретов.
- Секрет у каждого проекта свой; у песочницы и боя — разные.
- Если секрет ещё не выпускали, платформа создаёт его сама при первой доставке, но значение не показывает никому. Чтобы проверять подпись, выпустите новый.
- Ротация без потерь. После выпуска нового секрета прошлый ещё 24 часа подписывает
callback-и вторым значением
v1. Порядок: выпустите новый → добавьте его в проверку рядом со старым (или сразу замените — подойдёт любое совпавшее значение) → в течение 24 часов уберите старый. Повторная ротация в эти 24 часа сразу отключает самый старый секрет: одновременно действуют не больше двух. - Если секрет утёк, выпустите новый и сразу уберите старый из проверки.
Чек-лист¶
- Адрес callback-а задан, открыт из интернета,
https://, без redirect-а. - Подпись проверяется по сырому телу, сравнение за постоянное время.
- Запросы старше 5 минут отклоняются, часы синхронизированы.
-
event_idхранится с уникальным индексом не меньше суток. -
2xxуходит сразу, обработка — в фоне. - Заказ выдаётся по
amount, а не поinitial_amount. - Обработаны
canceled→completed,completed→canceledи новыйamount. - Есть сверка через
GET /api/v1/payments/{id}для заказов без callback-а.