Webhooks
Як переконатися, що запит справді від AOA: підпис AOA-Signature, вікно часу й дедуплікація.
Адреса вашого обробника рано чи пізно стане відомою — вона світиться в логах проксі, в історії браузера, у скриншотах. Підпис — єдине, що відрізняє наш запит від будь-якого іншого. Перевіряйте його завжди, ще до розбору тіла.
AOA-Signature: t=1800000000,v1=5f2a9c...64-символьний-hext — час підписання в секундах, v1 — HMAC-SHA256 від рядка {t}.{тіло} із вашим секретом. Схема збігається зі Stripe, тож готові приклади звідти підійдуть майже без змін.
timingSafeEqual замість ===: різниця в часі відповіді дозволяє підбирати підпис символ за символом.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.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);
},
);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: інакше зміна редиректу на вашому хості пересилала б підпис і дані покупця на чужий сервер. Вказуйте кінцеву адресу одразу.