Руководство
Markdown-версияВебхуки
Уведомление на ваш сервер, когда заказ получает окончательный статус.
Передайте webhookUrl в теле POST /purchase. Когда заказ станет completed, failed или cancelled, мы отправим на этот адрес POST-запрос:
{
"event": "order.status",
"orderId": 1842,
"status": "completed",
"failureReason": null,
"productId": 12,
"price": 1490,
"currency": "RUB",
"idempotencyKey": "order-7731",
"createdAt": "2026-11-13T12:36:53.000Z"
}Ключа товара в вебхуке нет — получите его через GET /orders/{orderId}.
Заголовки
| Заголовок | Значение |
|---|---|
X-Merchant-Timestamp | Время отправки, Unix-секунды. |
X-Merchant-Signature | sha256= + hex HMAC-SHA256 от строки <timestamp>.<тело запроса> на вашем секрете вебхуков. Секрет выдаём вместе с ключом доступа. |
User-Agent | merchant-webhooks/1 |
Проверка подписи
Всегда проверяйте подпись и время: иначе кто угодно сможет прислать вам «оплаченный» заказ.
import crypto from "node:crypto";
// rawBody — тело запроса строкой, ДО JSON.parse: подпись считается по байтам.
export function verifyMerchantWebhook(rawBody, headers, secret) {
const timestamp = headers["x-merchant-timestamp"];
const signature = headers["x-merchant-signature"] ?? "";
// Отбрасываем старые доставки — защита от повторной отправки перехваченного запроса.
if (!timestamp || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected =
"sha256=" + crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express:
// app.post("/hooks/merchant", express.raw({ type: "application/json" }), (req, res) => {
// if (!verifyMerchantWebhook(req.body.toString("utf8"), req.headers, process.env.MERCHANT_WEBHOOK_SECRET))
// return res.sendStatus(401);
// const event = JSON.parse(req.body);
// res.sendStatus(200); // ответьте быстро, обработку — в очередь
// });import hashlib
import hmac
import time
def verify_merchant_webhook(raw_body: bytes, headers, secret: str) -> bool:
timestamp = headers.get("X-Merchant-Timestamp", "")
signature = headers.get("X-Merchant-Signature", "")
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
return False
message = timestamp.encode() + b"." + raw_body
expected = "sha256=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)Доставка и повторы
- Ответ
2xxв течение 5 секунд — доставлено. Отвечайте сразу, а обработку ставьте в очередь. - Ошибка сети, таймаут,
5xx,408,429— повтор через 30 с, 2 мин, 10 мин, 30 мин, 2 ч и 6 ч (всего до 7 попыток). - Редиректы (
3xx) не выполняются, остальные4xxсчитаются окончательным отказом — без повторов. - Итог виден в поле
webhookStatusзаказа:pending,deliveredилиfailed. - Одно событие может прийти больше одного раза. Обрабатывайте идемпотентно — по паре
orderId+status.
Требования к URL
Только https://, до 2048 символов, без логина и пароля в адресе. Нельзя localhost, внутренние и служебные IP-адреса — ни напрямую, ни через DNS-имя, которое на них указывает. Неверный адрес — 400 Invalid webhookUrl (причина).
В песочнице вебхук приходит только для успешной тестовой покупки, содержит "sandbox": true и повторяется до 3 раз.