# Подпись вебхука не сходится

> Подпись считается как HMAC-SHA256(secret, timestamp + "." + rawBody). Самая частая ошибка — разобрать тело в JSON и собрать обратно в строку. Примеры получения raw body для Express, Laravel, Django.

## Коротко

Подпись считается так: `HMAC-SHA256(secret, timestamp + "." + rawBody)`, результат в hex, с префиксом `sha256=`, приходит в заголовке `X-Webhook-Signature`. У несовпадения **почти всегда одна причина**: вы разбираете тело в JSON, а потом собираете обратно в строку. При этом меняются байты — пробелы, порядок полей, экранирование Unicode, — и подпись не сойдётся никогда.

Решение: брать тело **в неизменном байтовом виде** и проверять именно его. А разбор в JSON делать **после** проверки.

## Как считать правильно

```js
import crypto from 'node:crypto';

function verify(secret, timestamp, rawBody, signature) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(timestamp + '.' + rawBody)  // rawBody — Buffer или нетронутая строка
    .digest('hex');

  const a = Buffer.from(expected);
  const b = Buffer.from(signature || '');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

Сравнивайте не через `===`, а через `timingSafeEqual`.

Заголовки:

| Заголовок | Что приходит |
|---|---|
| `X-Webhook-Event` | Имя события, например `invoice.paid` |
| `X-Webhook-Timestamp` | Метка времени, участвующая в подписи |
| `X-Webhook-Signature` | Hex-подпись с префиксом `sha256=` |
| `X-Webhook-Delivery` | Идентификатор доставки, удобно писать в журнал |

## Причина по симптому

| Симптом | Причина | Решение |
|---|---|---|
| Не сходится никогда | Тело разобрано в JSON и собрано обратно | Берите raw body |
| Иногда сходится, иногда нет | В теле кириллица или эмодзи, пересборка меняет байты | Берите raw body |
| Везде сходится, кроме прода | Секреты у сред разные, `.env` не обновлён | Поставьте секрет именно этой среды |
| Лишний префикс `sha256=` | Префикс не учтён при сравнении | Добавьте `sha256=` и к ожидаемому значению |
| Timestamp считается устаревшим | Часы сервера ушли или событие долго лежало в очереди | Включите синхронизацию по NTP |
| Подпись посчитана по API-ключу | Перепутаны секрет и API-ключ | Используйте секрет вебхука |

## Главная ошибка: пересборка тела

Многие фреймворки автоматически разбирают входящий JSON в объект. Если потом собрать этот объект обратно в строку, результат **не совпадёт с оригиналом побайтово**:

```js
// НЕВЕРНО — подпись не сойдётся никогда
const raw = JSON.stringify(req.body);
```

Почему:

- Пробелы и переводы строк теряются или добавляются
- Порядок полей может измениться
- Числа пишутся иначе: `1000.0` → `1000`
- Кириллица и эмодзи экранируются по-другому
- Пустые объекты и `null` выводятся иначе

HMAC — функция от байтов. Изменился один байт — подпись становится совершенно другой.

## Как получить raw body

**Express.** Для маршрута с проверкой подписи вместо `express.json()` используйте `express.raw()`:

```js
app.post('/webhooks/qutpay',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const raw = req.body;                     // Buffer, нетронутый
    const ts  = req.headers['x-webhook-timestamp'];
    const sig = req.headers['x-webhook-signature'];

    if (!verify(process.env.WEBHOOK_SECRET, ts, raw, sig)) {
      return res.sendStatus(400);
    }

    const event = JSON.parse(raw.toString('utf8')); // ПОСЛЕ проверки
    res.sendStatus(200);
  });
```

Важно: если `express.json()` подключён на всё приложение через `app.use(express.json())`, он успеет отработать раньше. Регистрируйте маршрут вебхука до общего `express.json()`.

**Laravel.**

```php
$raw = $request->getContent();  // нетронутое тело
$expected = 'sha256=' . hash_hmac(
    'sha256',
    $request->header('X-Webhook-Timestamp') . '.' . $raw,
    config('services.qutpay.webhook_secret')
);

if (!hash_equals($expected, $request->header('X-Webhook-Signature', ''))) {
    abort(400);
}

$event = json_decode($raw, true);
```

Нужен именно `$request->getContent()`, а не `$request->all()`.

**Django.**

```python
import hmac, hashlib

raw = request.body                      # bytes, нетронутые
ts  = request.headers.get('X-Webhook-Timestamp', '')
sig = request.headers.get('X-Webhook-Signature', '')

expected = 'sha256=' + hmac.new(
    secret.encode(),
    ts.encode() + b'.' + raw,
    hashlib.sha256,
).hexdigest()

if not hmac.compare_digest(expected, sig):
    return HttpResponseBadRequest()
```

Нужен `request.body`, а не `request.POST` и не `request.data`. В DRF обращайтесь к `request.body` до того, как будет прочитан `request.data`.

**Чистый Node.**

```js
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
  const raw = Buffer.concat(chunks);
  // проверка здесь
});
```

## Проверка timestamp

Даже с верной подписью старый запрос принимать не стоит. Значение `X-Webhook-Timestamp` **не должно быть старше 5 минут**:

```js
const age = Math.abs(Date.now() / 1000 - Number(ts));
if (age > 300) return res.sendStatus(400);
```

Это защита от повторной отправки перехваченного запроса. Если проверка срабатывает ложно, проверьте часы сервера: синхронизация по NTP должна быть включена.

## Не путайте секреты

Есть два разных секрета, и они совершенно не похожи:

| Что | Где применяется | Формат |
|---|---|---|
| API-ключ | При отправке запросов, заголовок `X-API-Key` | `qp_live_…` / `qp_test_…` |
| Секрет вебхука | Только при проверке подписи входящего вебхука | Отдельное значение, не начинается на `qp_` |

Секрет вебхука показывается в кабинете **один раз** — в момент добавления адреса. Если он потерян, посмотреть его снова нельзя: создаёте новый и обновляете код.

Если проблема с заголовком `X-API-Key`, это совсем другая история: [API отвечает 401](/kb/ru/api-401).

## Порядок проверки

1. Запишите в журнал точные байты пришедшего запроса (в hex или base64)
2. Посчитайте подпись от этих байтов вручную и сравните с заголовком
3. Сошлось — проблема в том, как ваш код получает raw body
4. Не сошлось — другой секрет или timestamp подставлен неправильно
5. Проверьте порядок: `timestamp + "." + rawBody`, точка как разделитель
6. Если всё выглядит верно: [Проблема у вас или у Kaspi](/kb/ru/is-it-us-or-kaspi)

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

**Что отвечать, если подпись не сошлась?** `400`. Тогда событие будет повторено — и это правильно: успеете починить код, событие не потеряется.

**Можно ли не проверять подпись?** Нельзя. Адрес вебхука открыт без авторизации, то есть запрос на него может отправить кто угодно. Подпись — единственное доказательство, что запрос пришёл от нас.

**Что будет со старыми вебхуками, если сменить секрет?** С момента смены используется новый секрет. Обновляйте код одновременно.

**Может ли у одного адреса быть несколько секретов?** У каждого адреса вебхука свой секрет. Если адресов несколько, храните их по отдельности.

**Работает ли это за прокси?** Да, но убедитесь, что прокси не меняет тело. Некоторые конфигурации переформатируют JSON — тогда подпись не сойдётся никогда.
