# Webhook қолтаңбасы сәйкес келмейді

> Қолтаңба 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` | `sha256=` префиксі бар hex қолтаңба |
| `X-Webhook-Delivery` | Жеткізу идентификаторы, журналға жазуға ыңғайлы |

## Симптом бойынша себеп

| Симптом | Себебі | Шешімі |
|---|---|---|
| Ешқашан сәйкес келмейді | Дене JSON-ға айналдырылып, қайта жолға түрлендірілген | Raw body алыңыз |
| Кейде сәйкес келеді, кейде жоқ | Денеде кириллица немесе эмодзи бар, қайта түрлендіру байтты өзгертеді | Raw body алыңыз |
| Барлық жерде сәйкес, тек продакшенде емес | Құпия орталары бойынша бөлек, `.env` жаңартылмаған | Сол орта үшін жасалған құпияны қойыңыз |
| `sha256=` префиксі артық болып тұр | Салыстыруда префикс есепке алынбаған | Күтілетін мәнге де `sha256=` қосыңыз |
| Timestamp ескі деп қабылданбайды | Сервер сағаты ауытқыған немесе оқиға кезекте ұзақ тұрған | Сағатты NTP-ге қосыңыз |
| Қолтаңба API кілтпен есептелген | Құпия мен API кілт шатастырылған | Webhook құпиясын қолданыңыз |

## Ең жиі қате: денені қайта құрастыру

Көп фреймворк келген 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())` арқылы қосылып тұрса, ол бұл маршрутқа дейін жетіп үлгереді. Webhook маршрутын жалпы `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->all()` емес, дәл `$request->getContent()` қолданыңыз.

**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.POST` немесе `request.data` емес, `request.body` керек. 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_…` |
| Webhook құпиясы | Тек келген webhook қолтаңбасын тексеруде | Бөлек мән, `qp_` деп басталмайды |

Webhook құпиясы кабинетте **бір рет қана** көрсетіледі — webhook адресін қосқан сәтте. Жоғалтып алсаңыз, қайта қарауға болмайды: жаңасын жасап, кодыңызды жаңартасыз.

Егер `X-API-Key` тақырыбымен мәселе болса, ол мүлде басқа әңгіме: [API 401 қайтарады](/kb/api-401).

## Тексеру реті

1. Келген сұраудың дәл байттарын журналға жазыңыз (hex немесе base64 түрінде)
2. Сол байттардан қолтаңбаны қолмен есептеп, тақырыптағы мәнмен салыстырыңыз
3. Сәйкес келсе — мәселе кодыңыздағы raw body алуда
4. Сәйкес келмесе — құпия басқа немесе timestamp дұрыс қосылмаған
5. `timestamp + "." + rawBody` тәртібін тексеріңіз: нүкте бөлгіш ретінде тұр
6. Бәрі дұрыс көрінсе: [Мәселе менде ме, Kaspi-де ме](/kb/is-it-us-or-kaspi)

## Жиі қойылатын сұрақтар

**Қолтаңба сәйкес келмесе, қандай жауап беру керек?** `400`. Сонда оқиға қайталанады — бұл дұрыс: кодыңызды түзетіп үлгерсеңіз, оқиға жоғалмайды.

**Тексермеуге бола ма?** Болмайды. Webhook адресіңіз авторизациясыз ашық тұрады, яғни оған кез келген адам сұрау жібере алады. Қолтаңба — сұраудың бізден келгенінің жалғыз дәлелі.

**Құпияны ауыстырсам, ескі webhook-тар не болады?** Ауыстырған сәттен бастап жаңа құпия қолданылады. Кодты бір уақытта жаңартыңыз.

**Бір адресте бірнеше құпия бола ма?** Әр webhook адресінің өз құпиясы болады. Бірнеше адрес қоссаңыз, әрқайсысын бөлек сақтаңыз.

**Прокси артындағы серверде істей ме?** Иә, бірақ прокси денені өзгертпейтініне көз жеткізіңіз. Кейбір баптаулар JSON-ды қайта пішімдейді — ондайда қолтаңба ешқашан сәйкес келмейді.
