---
title: "Перевірка підпису · Webhooks: документація AOA API"
description: "Як переконатися, що запит справді від AOA: підпис AOA-Signature, вікно часу й дедуплікація."
canonical: "https://aoa.com.ua/docs/webhooks/security"
---

# Перевірка підпису

Як переконатися, що запит справді від AOA: підпис AOA-Signature, вікно часу й дедуплікація.

Адреса вашого обробника рано чи пізно стане відомою — вона світиться в логах проксі, в історії браузера, у скриншотах. Підпис — єдине, що відрізняє наш запит від будь-якого іншого. Перевіряйте його завжди, ще до розбору тіла.

```http
AOA-Signature: t=1800000000,v1=5f2a9c...64-символьний-hex
```

`t` — час підписання в секундах, `v1` — HMAC-SHA256 від рядка `{t}.{тіло}` із вашим секретом. Схема збігається зі Stripe, тож готові приклади звідти підійдуть майже без змін.

## Три правила

- **Беріть сире тіло.** Підпис рахується від байтів, які прийшли. Якщо фреймворк уже розібрав JSON, а ви серіалізуєте його назад — порядок ключів чи пробіли зміняться, і підпис не зійдеться.
- **Порівнюйте безпечно.** `timingSafeEqual` замість `===`: різниця в часі відповіді дозволяє підбирати підпис символ за символом.
- **Перевіряйте вік запиту.** Без цього перехоплений колись запит можна відтворити будь-коли — підпис у ньому вічно валідний. Розумне вікно — 5 хвилин.

## Node.js

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

const TOLERANCE_SECONDS = 300;

export function verifyAoaSignature(header, rawBody, secret) {
  const parts = header.split(',');
  const timestamp = parts.find((p) => p.startsWith('t='))?.slice(2);
  const signature = parts.find((p) => p.startsWith('v1='))?.slice(3);
  if (!timestamp || !signature) return false;

  // Захист від повторного відтворення старого запиту.
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  const a = Buffer.from(signature, 'hex');
  const b = Buffer.from(expected, 'hex');
  if (a.length !== b.length) return false;

  return crypto.timingSafeEqual(a, b);
}
```

### Express

```javascript
// Тіло потрібне СИРИМ: express.json() змінює рядок, і підпис не зійдеться.
app.post(
  '/hooks/aoa',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const raw = req.body.toString('utf8');

    if (!verifyAoaSignature(req.get('AOA-Signature'), raw, process.env.AOA_WEBHOOK_SECRET)) {
      return res.status(401).end();
    }

    const event = JSON.parse(raw);

    // Відповідаємо одразу — важку роботу робимо після відповіді.
    res.status(200).end();

    void handleEvent(event);
  },
);
```

## Python

```python
import hmac, hashlib, time

TOLERANCE_SECONDS = 300

def verify_aoa_signature(header: str, raw_body: bytes, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    timestamp, signature = parts.get("t"), parts.get("v1")
    if not timestamp or not signature:
        return False

    if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
        return False

    expected = hmac.new(
        secret.encode(),
        f"{timestamp}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(signature, expected)
```

## Дедуплікація

Одна подія може прийти двічі — типовий випадок: ви обробили запит, але відповідь не дійшла вчасно, і ми повторили. Заголовок `AOA-Delivery-Id` при повторі той самий, тож зберігайте оброблені ідентифікатори і пропускайте знайомі.

Тримати їх вічно не треба: ретраї живуть близько девʼяти годин, тож доба зберігання з запасом покриває всі повтори.

## Якщо секрет витік

Видаліть адресу і зареєструйте наново — новий секрет видається при створенні. Старий перестає діяти одразу разом із видаленою адресою.

Ми не ходимо за редиректами

Підписаний запит не переходить за

`3xx`

: інакше зміна редиректу на вашому хості пересилала б підпис і дані покупця на чужий сервер. Вказуйте кінцеву адресу одразу.
