# Callback-и

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

## Как это работает { #how-it-works }

<div class="pp-seq" data-lanes="Ваш сервер|API" markdown>

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 секунд

</div>

Главное:

- Callback — **уведомление о событии**. Источник правды — заявка в API:
  `GET /api/v1/payments/{id}`.
- Одно событие может прийти **несколько раз**, события по одной заявке — **не по порядку**.
- По одной заявке может прийти **несколько разных событий**: например, `canceled`, а потом
  `completed` — см. [Смена статуса после закрытия](#status-changes).
- Попыток мало — 3 за полторы минуты. Отвечайте `2xx` быстро и держите
  [сверку](#missing) на случай, если callback не дошёл.

## Когда приходит { #when }

Callback приходит, когда боевая заявка переходит в один из двух статусов:

| Статус | Что случилось | Что делать |
| --- | --- | --- |
| `completed` | Оплата подтверждена | Выдать заказ на сумму `amount` |
| `canceled` | Заявка не оплачена: истёк её срок, её отменили или оплату не удалось подтвердить после выдачи реквизитов | Не выдавать заказ; предложить оплатить заново — новой заявкой |

Причину отмены callback не передаёт: для вас все случаи одинаковы — реквизиты больше не
действуют.

Callback **не приходит**:

- о заявке в статусе `error`. `error` бывает только до выдачи реквизитов, и вы узнаёте о нём
  из ответа на создание или из `GET /api/v1/payments/{id}`. Если реквизиты выданы, заявка
  заканчивается только `completed` или `canceled` — и о любом из них приходит callback;
- о промежуточных статусах `created`, `processing` и `appeal` (оплату перепроверяют —
  callback придёт с результатом проверки);
- о тестовых заявках (`is_test: true`) — см. [Тестовые заявки](#test-payments);
- если адрес callback-а не задан ни в заявке, ни в проекте, ни у мерчанта —
  см. [Куда приходит](#url).

### Смена статуса после закрытия { #status-changes }

`completed` и `canceled` — финальные статусы, но их может изменить проверка оплаты или
решение службы поддержки. Каждое изменение — **новое событие с новым `event_id`**, и о нём
приходит новый callback:

| Было | Пришло | Когда бывает |
| --- | --- | --- |
| `canceled` | `completed` | Плательщик перевёл деньги после срока заявки, или оплату подтвердили позже |
| `completed` | `completed` с другим `amount` | Плательщик перевёл другую сумму, и её подтвердили. `initial_amount` не меняется |
| `completed` | `canceled` | Оплата не подтвердилась при проверке |
| любой | тот же статус ещё раз | Проверка оставила статус прежним, или служба поддержки повторила callback |

Обработчик должен уметь:

- выдать заказ по отменённой заявке, если позже пришёл `completed`;
- пересчитать заказ, если в новом `completed` другой `amount`;
- отозвать или придержать заказ, если после `completed` пришёл `canceled`.

!!! warning "События могут прийти не по порядку"
    Если callback меняет то, что вы уже записали по заявке, перечитайте её через
    `GET /api/v1/payments/{id}` и примените **статус и сумму из ответа API**. Так запоздавшее
    событие не откатит заказ.

## Куда приходит { #url }

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

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` — неудача. Указывайте конечный адрес.

## Тело { #body }

Тело — событие о переходе заявки в статус: основные поля заявки, курс и ставка,
`event_id` и `project_id`. Реквизитов и сумм в USDT в нём нет — полную заявку отдаёт
`GET /api/v1/payments/{id}` ([поля](sbp.md#response)).

```json
{
  "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`](sbp.md#merchant-information); `null`, если реквизиты не выдавались |
| `is_test` | `true` у тестовой заявки проекта |
| `project_id` | Проект, к которому относится заявка |
| остальные поля | Как в [ответе на создание](sbp.md#response): `currency`, `payment_method`, `bank_code`, `is_intrabank` |

- Кода ошибки в callback нет, даже при ручном повторе заявки в `error`: причину смотрите
  `GET`-ом.
- `amount`, `initial_amount`, `rate` и `course` читайте как десятичные числа (decimal),
  а не float.
- Новые поля могут добавляться: неизвестные поля игнорируйте.
- Тело собирается один раз, когда событие ставится в очередь, и во всех повторах доставки
  одно и то же байт в байт. Если сомневаетесь в актуальности, перечитайте заявку `GET`-ом.

## Заголовки { #headers }

| Заголовок | Значение |
| --- | --- |
| `Content-Type` | `application/json` |
| `X-Callback-Event-Id` | `event_id` события |
| `X-Callback-Timestamp` | Unix-время отправки этой попытки, секунды |
| `X-Callback-Signature` | `v1=<hex>`; 24 часа после ротации секрета — `v1=<hex>,v1=<hex>` |

## Проверка подписи { #signature }

```text
signed_payload = X-Callback-Timestamp + "." + сырое_тело_запроса
signature      = hex( HMAC-SHA256( key = секрет проекта целиком, message = signed_payload ) )
```

- Секрет — строка `whsec_…`. Ключ HMAC — **вся строка** в UTF-8, вместе с `whsec_`.
  Декодировать ничего не нужно.
- `hex` — 64 символа в нижнем регистре.
- Считайте подпись по **сырым байтам тела** до разбора JSON. После разбора и повторной
  сериализации байты могут измениться.
- Запрос подлинный, если хотя бы одно значение `v1=` совпало с вашей подписью.
  Сравнивайте за постоянное время. Значения с другой схемой (не `v1`) пропускайте.

### Защита от повтора { #replay }

1. Отклоняйте запрос, если `X-Callback-Timestamp` отличается от ваших часов больше чем
   на 5 минут. Держите часы синхронизированными по NTP. Timestamp входит в подпись,
   подменить его нельзя.
2. Храните обработанные `event_id` не меньше суток. Повтор с тем же `event_id`
   не обрабатывайте второй раз — просто ответьте `2xx`.

`event_id` — идентификатор **события**, а не заявки и не попытки:

| Что произошло | `event_id` | Тело | Timestamp и подпись |
| --- | --- | --- | --- |
| Повтор доставки после ошибки или ответа не `2xx` | тот же | то же байт в байт | новые |
| Новый статус заявки | новый | новое | новые |
| Ручной повтор службой поддержки | новый, даже если статус не изменился | новое | новые |

Итог по заявке определяйте по `id` и `status`, а `event_id` используйте только чтобы не
обработать одну доставку дважды.

### Функции проверки { #verify-functions }

Функции проверяют подпись и свежесть и проверены на примере ниже.

=== "curl"

    Для отладки в терминале. В приложении используйте функцию своего языка:
    в shell нельзя сравнить строки за постоянное время.

    ```bash
    # 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"

    ```php
    <?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;
    }
    
    ```

=== "Python"

    ```python
    """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
    
    ```

=== "Node.js"

    ```javascript
    // 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);
      });
    }
    
    ```

=== "Go"

    ```go
    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` — ровно одна строка без переводов строк и пробелов между полями (тело из примера
выше в том виде, в каком его отправляет API), 362 байта:

```text
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`.

## Как обрабатывать { #handling }

1. Прочитайте **сырое тело** и заголовки.
2. Проверьте подпись и `X-Callback-Timestamp`. Не прошло — ответьте `401` и ничего не
   меняйте.
3. Запишите `event_id` в таблицу с уникальным индексом. Уже есть — ответьте `2xx` и выйдите.
4. Поставьте событие в свою очередь и сразу ответьте `2xx`.
5. В фоне найдите заказ по `merchant_payment_id` или сохранённому `id` и примените
   `status` и `amount` по правилам [смены статуса](#status-changes).

Ответ `2xx` значит «событие принято», а не «заказ выдан». Если обработка упала уже после
ответа, перечитайте заявку `GET`-ом: сам callback больше не придёт.

### Минимальный приёмник { #minimal-receiver }

Проверяет подпись, отбрасывает повторы и сразу отвечает `2xx`. В бою храните `event_id`
в базе с уникальным индексом, а обработку ставьте в очередь.

=== "PHP"

    ```php
    <?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
    
    ```

=== "Python"

    ```python
    """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()
    
    ```

=== "Node.js"

    ```javascript
    // 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);
    
    ```

=== "Go"

    ```go
    // 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 }

- **Успех** — любой ответ `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 секунд | доставка остановлена |

!!! tip "Сомневаетесь — перечитайте заявку"
    Если callback противоречит тому, что вы знаете о заказе, прочитайте актуальное
    состояние через `GET /api/v1/payments/{id}`. Ответ API важнее порядка
    доставки.

## Если callback не пришёл { #missing }

Попыток мало, поэтому не полагайтесь только на callback:

- **Сверка.** Раз в несколько минут выбирайте свои заказы, которые ждут оплаты дольше срока
  заявки проекта (по умолчанию 15 минут), и читайте их через `GET /api/v1/payments/{id}`.
  Закрытую заявку обрабатывайте так же, как пришедший callback. Не опрашивайте чаще раза
  в 5–10 секунд на заявку: у проекта общий [лимит запросов](idempotency.md#rate-limits).
- **Проверьте адрес.** Он задан хотя бы на одном уровне, открывается из интернета,
  отвечает `2xx` без redirect-а, сертификат действителен.
- **Повторная отправка.** Если ваш сервер был недоступен, передайте в поддержку `id` заявок
  или их `merchant_payment_id` — callback-и отправят ещё раз. Каждый придёт с новым
  `event_id`.

### Частые проблемы { #troubleshooting }

| Симптом | Причина |
| --- | --- |
| Подпись не сходится | Подпись считается по разобранному и заново собранному JSON, а не по сырому телу; ключ взят без `whsec_`; секрет от другого проекта или окружения |
| Подпись верная, но запрос отклоняется по времени | Часы сервера расходятся больше чем на 5 минут |
| Callback-и не приходят совсем | Адрес не задан; адрес отвечает `301` или `302`; хост разрешается во внутренний IP; заявки тестовые |
| Заказ обработан дважды | Повторы не отбрасываются по `event_id`, или итог считается по `event_id`, а не по `id` и `status` |
| Заказ «откатился» | Запоздавшее событие применено поверх нового. Перечитывайте заявку `GET`-ом, когда статус меняется |

## Тестовые заявки { #test-payments }

Заявки с `is_test: true` — тестовые заявки проекта и тестовые платежи из кабинета —
callback автоматически не получают. Их итог читайте через `GET /api/v1/payments/{id}`.

Чтобы проверить свой обработчик:

- прогоните функцию проверки на [проверочном примере](#test-vector);
- попросите службу поддержки повторить callback по закрытой тестовой заявке — на ваш адрес
  придёт настоящий подписанный запрос с `is_test: true`.

Подробнее — в разделе [Песочница](sandbox.md).

## Секрет и ротация { #secret }

- Секрет выпускается в кабинете: «Проекты и API» → «Доступ к внешнему API» → «Callback».
  Значение показывается **один раз** — сохраните его в хранилище секретов.
- Секрет у каждого проекта свой; у песочницы и боя — разные.
- Если секрет ещё не выпускали, платформа создаёт его сама при первой доставке, но
  значение не показывает никому. Чтобы проверять подпись, выпустите новый.
- **Ротация без потерь.** После выпуска нового секрета прошлый ещё 24 часа подписывает
  callback-и вторым значением `v1`. Порядок: выпустите новый → добавьте его в проверку
  рядом со старым (или сразу замените — подойдёт любое совпавшее значение) → в течение
  24 часов уберите старый. Повторная ротация в эти 24 часа сразу отключает самый старый
  секрет: одновременно действуют не больше двух.
- Если секрет утёк, выпустите новый и сразу уберите старый из проверки.

## Чек-лист { #checklist }

- [ ] Адрес callback-а задан, открыт из интернета, `https://`, без redirect-а.
- [ ] Подпись проверяется по сырому телу, сравнение за постоянное время.
- [ ] Запросы старше 5 минут отклоняются, часы синхронизированы.
- [ ] `event_id` хранится с уникальным индексом не меньше суток.
- [ ] `2xx` уходит сразу, обработка — в фоне.
- [ ] Заказ выдаётся по `amount`, а не по `initial_amount`.
- [ ] Обработаны `canceled` → `completed`, `completed` → `canceled` и новый `amount`.
- [ ] Есть сверка через `GET /api/v1/payments/{id}` для заказов без callback-а.