# Проверка подписи вебхуков: HMAC-SHA256 на 4 языках

> Как проверить подпись вебхука CryptoLike: сырое тело, окно времени, сравнение за постоянное время и дедупликация по event_id. Код на TypeScript, Python, Go и PHP.

Source: https://cryptolike.net/ru/guides/verify-crypto-payment-webhooks
Published: 2026-09-24 · Updated: 2026-09-25 · Author: CryptoLike team

CryptoLike подписывает каждый вебхук HMAC-SHA256: заголовок `X-Signature` — это `"sha256="` плюс hex-значение HMAC от строки `X-Timestamp + "." + сырое тело` на секрете вашего эндпоинта. Чтобы проверить доставку, посчитайте то же значение по байтам тела, сравните за постоянное время, отклоните метку времени, которая расходится с вашими часами больше чем на 300 секунд в ту или другую сторону, затем отсейте повтор по `event_id` и ответьте 2xx в течение 10 секунд.

[Вебхук](https://cryptolike.net/ru/glossary/webhook) сообщает вашему серверу, что у платежа, депозита или выплаты сменился статус; [подпись вебхука](https://cryptolike.net/ru/glossary/webhook-signature) доказывает, что сообщение отправил CryptoLike и его не изменили по дороге. Полный контракт — в [документации по вебхукам](https://cryptolike.net/ru/docs/webhooks). Здесь он превращён в рабочий код на TypeScript, Python, Go и PHP; каждый пример проверен на эталонном тестовом векторе и на негативных векторах, которые публикуются вместе с [официальными SDK](https://cryptolike.net/ru/integrations/sdk-typescript).

## Что нужно заранее

Чтобы проверить первую доставку вебхука CryptoLike, нужны аккаунт CryptoLike, эндпоинт вебхука на публичном `https://`-адресе, секрет этого эндпоинта на вашем сервере и сервер, который умеет читать сырое тело запроса. Тестовые ключи (`ck_test_`) и тестовые сети позволяют отладить всё до запуска.

- **Эндпоинт:** Разработчикам → Вебхуки → Добавить эндпоинт или `POST /v1/webhooks/endpoints`; события вроде `invoice.paid` или `*` — все.
- **Секрет:** сгенерированный (64 hex-символа) или свой (16–256 печатных ASCII-символов без пробелов, коды 0x21–0x7e), показывается один раз.
- **Часы:** синхронизированы по NTP — проверка метки времени допускает ±5 минут.

## 1. Сохраните секрет эндпоинта на сервере

CryptoLike создаёт отдельный секрет для каждого эндпоинта вебхуков и показывает его один раз. Сохраните его в переменных окружения сервера или в менеджере секретов, например как `CRYPTOLIKE_WEBHOOK_SECRET`, — но не в браузерном коде, мобильном приложении или репозитории. Если секрет мог утечь, выпустите новый в кабинете и сразу разверните его.

У каждого эндпоинта свой секрет, поэтому тестовый и боевой контуры никогда его не делят. Пустой секрет — например, забытая переменная окружения — должен приводить к отказу на любой запрос; все четыре примера из шага 4 так и делают.

## 2. Прочитайте сырое тело запроса и два заголовка

CryptoLike считает подпись по телу запроса ровно в том виде, в каком оно отправлено, поэтому обработчик должен получить сырые байты до того, как их тронет JSON-парсер. Тело — канонический JSON в том виде, в каком он хранится на платформе: порядок ключей и пробелы могут отличаться от примеров, а повторная сериализация даёт другие байты и неверную подпись.

Нужны два заголовка: `X-Timestamp` (Unix-секунды) и `X-Signature` (`sha256=` и 64 hex-символа в нижнем регистре). Сырое тело по фреймворкам: Express — `express.raw({ type: "application/json" })`; Flask — `request.get_data()`; Go — `io.ReadAll(r.Body)`; PHP — `file_get_contents('php://input')`, а не `$_POST`.

## 3. Отклоняйте устаревшие метки времени

CryptoLike передаёт время отправки в `X-Timestamp` и подписывает его вместе с телом, поэтому перехваченный запрос нельзя повторить позже с новой меткой. Отклоняйте доставку, если ваши часы и `X-Timestamp` расходятся больше чем на 300 секунд, — это окно по умолчанию во всех четырёх официальных SDK. Отклонённая доставка не теряется: повтор придёт с новой меткой и новой подписью.

## 4. Посчитайте HMAC и сравните за постоянное время

CryptoLike формирует подпись как `"sha256=" + hex(HMAC-SHA256(secret, X-Timestamp + "." + raw_body))`. Посчитайте ту же строку у себя и сравните её с `X-Signature` функцией постоянного времени — `timingSafeEqual`, `hmac.compare_digest`, `hmac.Equal` или `hash_equals`, — чтобы время ответа не выдавало, сколько символов совпало. Сравнивайте строку целиком, вместе с префиксом `sha256=`.

### TypeScript (Node.js, Express)

```ts
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const TOLERANCE_S = 300; // reject deliveries signed more than 5 minutes away from our clock

export function verifyCryptoLikeWebhook(
  rawBody: Buffer,
  timestamp: string | undefined,
  signature: string | undefined,
  secret: string,
  nowS = Math.floor(Date.now() / 1000),
): boolean {
  if (!secret || !timestamp || !signature || !/^[0-9]+$/.test(timestamp)) return false;
  if (Math.abs(nowS - Number(timestamp)) > TOLERANCE_S) return false;
  const expected = Buffer.from(
    "sha256=" + createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex"),
  );
  const received = Buffer.from(signature);
  return expected.length === received.length && timingSafeEqual(expected, received);
}

const app = express();
app.post("/hooks/cryptolike", express.raw({ type: "application/json" }), (req, res) => {
  const ok = verifyCryptoLikeWebhook(req.body, req.header("X-Timestamp"), req.header("X-Signature"),
    process.env.CRYPTOLIKE_WEBHOOK_SECRET ?? "");
  if (!ok) return res.sendStatus(401);
  const event = JSON.parse(req.body.toString("utf8"));
  if (!alreadySeen(event.event_id)) saveForProcessing(event); // your storage
  res.sendStatus(200);
});
```

### Python (Flask)

```python
import hashlib, hmac, json, os, time
from typing import Optional
from flask import Flask, abort, request

TOLERANCE_S = 300  # reject deliveries signed more than 5 minutes away from our clock

def verify_cryptolike_webhook(raw_body: bytes, timestamp: Optional[str], signature: Optional[str],
                              secret: str, now: Optional[float] = None) -> bool:
    if not secret or not timestamp or not signature:
        return False
    if not (timestamp.isascii() and timestamp.isdecimal()):
        return False
    if abs((time.time() if now is None else now) - int(timestamp)) > TOLERANCE_S:
        return False
    mac = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256)
    return hmac.compare_digest(("sha256=" + mac.hexdigest()).encode(), signature.encode())

app = Flask(__name__)

@app.post("/hooks/cryptolike")
def cryptolike_webhook():
    raw = request.get_data()  # raw bytes, before any JSON parsing
    if not verify_cryptolike_webhook(raw, request.headers.get("X-Timestamp"),
                                     request.headers.get("X-Signature"),
                                     os.environ.get("CRYPTOLIKE_WEBHOOK_SECRET", "")):
        abort(401)
    event = json.loads(raw)
    if not already_seen(event["event_id"]):
        save_for_processing(event)  # your storage
    return "", 200
```

### Go (net/http)

```go
const tolerance = 300 * time.Second // reject deliveries signed more than 5 minutes away from our clock

func VerifyCryptoLikeWebhook(rawBody []byte, timestamp, signature, secret string, now time.Time) bool {
	sec, err := strconv.ParseUint(timestamp, 10, 63)
	if err != nil || secret == "" || signature == "" {
		return false
	}
	if d := now.Sub(time.Unix(int64(sec), 0)); d > tolerance || d < -tolerance {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(timestamp + "."))
	mac.Write(rawBody)
	expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(expected), []byte(signature))
}

func cryptoLikeWebhook(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	if !VerifyCryptoLikeWebhook(body, r.Header.Get("X-Timestamp"), r.Header.Get("X-Signature"),
		os.Getenv("CRYPTOLIKE_WEBHOOK_SECRET"), time.Now()) {
		w.WriteHeader(http.StatusUnauthorized)
		return
	}
	var event struct {
		EventID string `json:"event_id"`
		Type    string `json:"type"`
	}
	if err := json.Unmarshal(body, &event); err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	if !alreadySeen(event.EventID) {
		saveForProcessing(event.EventID, body) // your storage
	}
	w.WriteHeader(http.StatusOK)
}
```

Импорты: `crypto/hmac`, `crypto/sha256`, `encoding/hex`, `encoding/json`, `io`, `net/http`, `os`, `strconv`, `time`.

### PHP (7.4 и новее)

```php
<?php
const CRYPTOLIKE_TOLERANCE_S = 300; // reject deliveries signed more than 5 minutes away from our clock

function verify_cryptolike_webhook(string $rawBody, ?string $timestamp, ?string $signature, string $secret, ?int $now = null): bool
{
    if ($secret === '' || $timestamp === null || $signature === null || !ctype_digit($timestamp)) {
        return false;
    }
    if (abs(($now ?? time()) - (int) $timestamp) > CRYPTOLIKE_TOLERANCE_S) {
        return false;
    }
    $expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    return hash_equals($expected, $signature);
}

$raw = file_get_contents('php://input'); // raw body, not $_POST
$ok = verify_cryptolike_webhook($raw, $_SERVER['HTTP_X_TIMESTAMP'] ?? null,
    $_SERVER['HTTP_X_SIGNATURE'] ?? null, getenv('CRYPTOLIKE_WEBHOOK_SECRET') ?: '');
if (!$ok) { http_response_code(401); exit; }
$event = json_decode($raw, true);
if (!already_seen($event['event_id'])) { save_for_processing($event); } // your storage
http_response_code(200);
```

## 5. Отсейте повторы по event_id и ответьте 2xx за 10 секунд

CryptoLike доставляет вебхуки по принципу «как минимум один раз», поэтому одно событие может прийти несколько раз — с тем же `event_id`, тем же телом и новой подписью. Сначала ищите `event_id` у себя: уже есть — ответьте 2xx и остановитесь; нет — сохраните, примените событие, ответьте 2xx. Обработчик должен быть быстрым: сохранить и подтвердить за 10 секунд, обработать потом.

Доставка без ответа 2xx повторяется через 1 мин, 5 мин, 15 мин, 1 ч, 6 ч и 24 ч — 7 попыток примерно за 31 час, — после чего помечается как исчерпанная. Отправить её снова можно из кабинета: Разработчикам → Вебхуки → Доставки → Повторить. Доставки независимы, и повторный `invoice.pending` может прийти после `invoice.paid`: упорядочивайте по `created_at` или смотрите `status` в `data` — это полное состояние объекта на момент `created_at`.

`event_id` для входящих событий играет ту же роль, что [ключ идемпотентности](https://cryptolike.net/ru/glossary/idempotency-key) для ваших API-запросов: одна логическая операция применяется один раз. Обе стороны разобраны в статье [ключи идемпотентности в платёжном API](https://cryptolike.net/ru/blog/idempotency-in-payment-apis).

## 6. Проверьте код на тестовом векторе и тестовом событии

CryptoLike публикует тестовый вектор, который обязан принять любой верификатор, — код можно проверить до первой реальной доставки. Вызовите свою функцию со значениями из таблицы, отключив проверку времени или закрепив часы на `1700000000`: результат должен быть true. Измените один байт тела — результат должен стать false.

| Параметр | Значение |
|---|---|
| secret | `whsec_9f2c1e4b7a6d5c3b2a1f0e9d8c7b6a5f` |
| X-Timestamp | `1700000000` |
| body | `{"event_id":"6f1c2c1e-3b2a-4d5e-8f90-1234567890ab","type":"invoice.paid","created_at":"2023-11-14T22:13:20Z","data":{"invoice":{"id":"0e8c9a4b-1111-4222-8333-444455556666","status":"paid"}}}` |
| X-Signature | `sha256=788e60a2141669b4c7616c1c616fefb8ec38df0948fba19c6f4e48c616e46c21` |

Затем проверьте вживую: Разработчикам → Вебхуки → эндпоинт → «Отправить тестовое событие» доставляет подписанный `ping` только на этот эндпоинт. Ответьте 2xx и не обрабатывайте его в бизнес-логике.

## Готовая проверка в SDK

Официальные SDK CryptoLike для TypeScript, Python, Go и PHP содержат готовую функцию проверки, которая делает ровно те же шаги: префикс `sha256=`, сравнение за постоянное время, окно 300 секунд по умолчанию и разобранное событие на выходе. Неудачная проверка выбрасывает ошибку верификации — отвечайте 401. SDK пока в пре-релизе и ставятся из репозитория — см. [документацию по SDK](https://cryptolike.net/ru/docs/sdk).

| Язык | Вызов | Страница |
|---|---|---|
| TypeScript | `verifyWebhook(req.headers, req.body, secret)` | [SDK для TypeScript](https://cryptolike.net/ru/integrations/sdk-typescript) |
| Python | `verify_webhook(request.headers, request.get_data(), secret)` | [SDK для Python](https://cryptolike.net/ru/integrations/sdk-python) |
| Go | `cryptolike.VerifyWebhook(r.Header, body, secret, nil)` | [SDK для Go](https://cryptolike.net/ru/integrations/sdk-go) |
| PHP | `Webhook::verify(getallheaders(), file_get_contents('php://input'), $secret)` | [SDK для PHP](https://cryptolike.net/ru/integrations/sdk-php) |

## Ошибки и решения

Когда проверка вебхука CryptoLike не проходит, причина обычно одна из четырёх ошибок интеграции: разобранное тело вместо сырого, не тот секрет, расхождение часов или медленный обработчик. Таблица связывает то, что вы видите в логах или в журнале «Доставки» в кабинете, с причиной и способом исправить.

| Что видно | Причина | Что делать |
|---|---|---|
| Подпись не совпадает, тестовый вектор проходит | JSON-middleware пересобрал тело | Читайте сырые байты до разбора (шаг 2). |
| Подпись не совпадает, тестовый вектор не проходит | Потерян префикс `sha256=`, hex в верхнем регистре или нет точки после метки времени | Соберите строку точно как в шаге 4. |
| На тесте работает, в бою нет | Секрет другого эндпоинта | У каждого эндпоинта свой секрет; возьмите боевой. |
| Все доставки отклоняются как устаревшие | Часы сервера ушли | Синхронизируйте по NTP; окно ±300 с. |
| Доставки повторяются, хотя код отработал | Ответ дольше 10 с или не 2xx | Сохраните, ответьте 2xx, обработайте потом. |
| Заказ выполнен дважды | Нет проверки `event_id` | Храните `event_id` и пропускайте повторы (шаг 5). |

## Вопросы и ответы

Здесь собраны вопросы, которые разработчики чаще всего задают, когда подключают и проверяют подпись вебхуков CryptoLike: какой секрет подписывает какую доставку, почему тело нельзя разбирать до проверки, что происходит после отказа и как событие `invoice.paid` связано со статусом платежа в API.

### Каким секретом подписаны доставки на webhook_url конкретного платежа?

События `invoice.*` платежа, созданного с `webhook_url`, подписываются секретом вашего самого старого включённого эндпоинта; отдельного секрета для платежа нет. Поэтому `webhook_url` требует включённого эндпоинта: без него `POST /v1/invoices` отвечает `400 validation_error` с полем `webhook_url`. Сначала добавьте эндпоинт в разделе Разработчикам → Вебхуки.

### Почему нельзя проверять уже разобранный JSON?

Подпись считается по байтам, а не по структуре данных. Разбор и повторная сериализация меняют порядок ключей, пробелы и экранирование — HMAC перестаёт совпадать. Сначала проверьте сырое тело, потом разбирайте. При каждой доставке события байты одинаковые.

### Что будет после ответа 401?

Попытка считается неудачной и повторяется по расписанию 1 мин, 5 мин, 15 мин, 1 ч, 6 ч и 24 ч. После последней попытки доставка исчерпана: устраните причину и отправьте её снова через Разработчикам → Вебхуки → Доставки → Повторить.

### Достаточно ли invoice.paid, чтобы отгрузить заказ?

Да: `invoice.paid` означает, что платёж подтверждён и зачислен на ваш баланс. Обрабатывайте и `invoice.reverted` — реорганизация сети откатила подтверждение: остановите выполнение заказа до нового `invoice.paid`. Модель статусов описана в [документации API](https://cryptolike.net/ru/docs#status-model).
