Перейти к содержанию

Callback-и

Callback — это POST с JSON, который API отправляет на ваш сервер, когда заявка оплачена или отменена. По нему вы узнаёте результат без опроса. Каждый callback подписан секретом проекта: проверяйте подпись, прежде чем верить содержимому.

Как это работает

  1. APIЗаявка закрылась — completed или canceled. Создаётся событие с новым event_id
  2. API → Ваш серверPOST на адрес callback-а с подписью X-Callback-Signature
  3. Ваш серверПроверяет подпись и время, ищет event_id среди обработанных
  4. Ваш сервер ⇢ APIОтвечает 2xx сразу, до обработки заказа
  5. Ваш серверВ фоне обновляет заказ по id и status
  6. 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. Так запоздавшее событие не откатит заказ.

Куда приходит

Адрес выбирается по первому заданному:

  1. callback_url из запроса на создание заявки;
  2. адрес callback-ов проекта (кабинет, «Проекты и API»);
  3. общий адрес 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) пропускайте.

Защита от повтора

  1. Отклоняйте запрос, если X-Callback-Timestamp отличается от ваших часов больше чем на 5 минут. Держите часы синхронизированными по NTP. Timestamp входит в подпись, подменить его нельзя.
  2. Храните обработанные 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.

Как обрабатывать

  1. Прочитайте сырое тело и заголовки.
  2. Проверьте подпись и X-Callback-Timestamp. Не прошло — ответьте 401 и ничего не меняйте.
  3. Запишите event_id в таблицу с уникальным индексом. Уже есть — ответьте 2xx и выйдите.
  4. Поставьте событие в свою очередь и сразу ответьте 2xx.
  5. В фоне найдите заказ по 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-а.