Уведомления
Уведомление — это POST-запрос от AvantPay на ваш сервер. Он приходит, когда платёж оплачен или отменён. Так вы узнаёте о результате без постоянных запросов к API. Тело, события и заголовки уведомления — в справочнике: Объект уведомления.
Как включить
- Войдите в личный кабинет.
- Откройте Ещё → Разработчикам → HTTP-уведомления (открыть сразу). На телефоне вместо «Ещё» — «Меню».
- Нажмите Изменить настройки.
- Укажите адрес, на который присылать уведомления, например
https://example.com/avantpay/webhook. - Отметьте события «Платёж оплачен» и «Платёж отменён». Нажмите Сохранить.
Адрес уведомлений задаётся только в личном кабинете: через API адрес и события не настраиваются. Адрес запоминается в платеже при создании. Поэтому включите уведомления до первого платежа: платежи, созданные, пока адрес не был указан, уведомлений не получат никогда — их результат узнавайте запросом GET /v3/payments/{payment_id}. Если поменять адрес в личном кабинете, уведомления о платежах, созданных раньше, придут на прежний адрес.
Требования к адресу
- Только
https://. - Доменное имя, не IP-адрес и не
localhost. - Порт 443 или 8443.
- Действующий сертификат от общепризнанного центра сертификации (например, Let's Encrypt), TLS 1.2 или новее. С самоподписанным или просроченным сертификатом уведомление не доставится.
Какие уведомления приходят
Для приёма платежей нужны два события: payment.succeeded (платёж оплачен) и payment.canceled (платёж отменён). Подробнее — в справочнике: События.
В личном кабинете есть и другие события. Для приёма платежей они не нужны: включайте только эти два. Если пришло уведомление с другим event, ответьте 200 и ничего не делайте.
Уведомления приходят и о счетах, которые выставлены в личном кабинете, — даже если события выключены. У таких платежей в metadata.order_id — номер счёта вида BI…, а ещё в metadata есть служебные ключи AvantPay. Поэтому заказ ищите только по id платежа. Если платёж из уведомления не связан ни с одним вашим заказом или запрос платежа вернул 404, ответьте 200 и ничего не делайте. Не отвечайте 500: тогда AvantPay будет повторять уведомление сутки.
Как обработать уведомление
- Прочитайте тело запроса как есть, байтами, до разбора JSON. Это нужно для проверки подписи.
- Проверьте подпись и время из заголовков
SignatureиSignature-Timestamp(как). Не сошлось — ответьте 401 и ничего не делайте. - Разберите JSON. Если
eventнеpayment.succeededи неpayment.canceled, ответьте 200 и ничего не делайте. - Запросите платёж:
GET https://api.avantpay.io/v3/payments/{object.id}. Дальше решайте по ответу этого запроса, а не по телу уведомления. Ответ 404 — такого платежа у магазина нет: ответьте 200 и ничего не делайте. - Найдите заказ по
idплатежа. Заказа нет (например, это счёт из личного кабинета) — ответьте 200 и ничего не делайте. - Если
statusравенsucceeded, сверьтеamount.valueиamount.currencyс суммой заказа. Совпали — отметьте заказ оплаченным атомарно и выполните его. Не совпали — заказ не выполняйте, деньги за этот платёж верните в личном кабинете (почему). - Если
statusравенcanceled— не выполняйте заказ. - Ответьте кодом 200. Тело ответа не важно.
Правила:
- Отвечайте не позже чем через 10 секунд. Если выполнение заказа долгое, поставьте его в очередь и сразу ответьте 200.
- Одно и то же уведомление может прийти несколько раз. Повторная обработка не должна ничего менять.
- Уведомления могут прийти не по порядку. Поэтому статус берите из запроса платежа.
- Отмечайте заказ оплаченным атомарно: условным
UPDATE … WHERE paid_payment_id IS NULLили с блокировкой строки. Уведомление и возврат покупателя наreturn_urlприходят одновременно, и проверка «не выполнен — выполнить» без атомарности выполнит заказ дважды. - Если не смогли обработать уведомление (например, API не ответил), ответьте 500. AvantPay пришлёт уведомление ещё раз.
Ответ и повторы
Уведомление доставлено, если ваш сервер ответил кодом 200 (или любым другим кодом 2xx) не позже чем через 10 секунд.
Если ответ другой, ответа нет или соединение не установилось, AvantPay повторяет уведомление примерно через 1, 5, 15 и 30 минут, через 1, 2, 4, 8 и 16 часов и в последний раз через 24 часа после первой попытки. Всего 11 попыток. В повторе — то же тело: объект платежа на момент события. Подпись и Signature-Timestamp у каждой попытки свои. Текущее состояние платежа узнавайте запросом GET /v3/payments/{payment_id}.
Если уведомление не пришло
Уведомление может не дойти: истёк сертификат, сервер лежал дольше 24 часов, адрес не был указан, когда создавали платёж. Тогда оплаченный заказ сам не выполнится. Поэтому сверяйтесь с API по расписанию:
- Раз в несколько минут запрашивайте оплаченные платежи:
GET /v3/payments?status=succeeded&captured_at.gte=<время прошлой сверки минус 10 минут>, все страницы (список платежей). Запас в 10 минут — чтобы не пропустить платёж на границе. - Каждый платёж обрабатывайте тем же кодом, что и уведомление: найти заказ, сверить сумму, атомарно отметить оплаченным, выполнить. Уже выполненные заказы код пропустит.
- Для отдельного заказа, у которого платёж давно
pendingи прошёлexpires_at, можно запросить платёж напрямую:GET /v3/payments/{payment_id}.
Так вы не пропустите и позднюю оплату: у неё тоже есть captured_at.
Проверка подлинности
Уведомление может подделать кто угодно, кто знает ваш адрес. Поэтому не верьте телу уведомления на слово.
Главный способ: запросите платёж
Запросите платёж по object.id с вашими ключами: GET /v3/payments/{payment_id}. Ответ приходит от API AvantPay, поэтому ему можно доверять. Если такого платежа у вашего магазина нет, API ответит 404 — тогда уведомление поддельное: ответьте на него 200 и ничего не делайте. Этого способа достаточно.
Дополнительно: подпись
В каждом уведомлении есть заголовки:
Signature-Timestamp— время отправки, Unix-время в секундах.Signature— подпись:v1=и HMAC-SHA256 в шестнадцатеричном виде. Подписывается строка<Signature-Timestamp>.<тело запроса>: время, точка и тело запроса как оно пришло (байты, без разбора и повторной сборки JSON). Ключ HMAC — ваш секретный ключ API.
Как проверить:
- Склейте значение
Signature-Timestamp, точку и тело запроса. - Посчитайте HMAC-SHA256 этой строки секретным ключом и добавьте впереди
v1=. - Сравните с
Signatureфункцией сравнения за постоянное время. - Отклоните уведомление, если
Signature-Timestampотличается от текущего времени больше чем на 5 минут (300 секунд). Так чужой не сможет прислать вам старое уведомление повторно.
Подписывается текущим секретным ключом магазина. После перевыпуска ключа — новым ключом, в том числе в повторах старых уведомлений. Если ключа у магазина нет, заголовков подписи нет.
Функция проверки:
- Python
- Node.js
- PHP
import hashlib
import hmac
import time
from typing import Optional
TOLERANCE_SECONDS = 300
def verify_signature(raw_body: bytes, signature: Optional[str], timestamp: Optional[str], secret_key: str) -> bool:
if not signature or not timestamp or not (timestamp.isascii() and timestamp.isdigit()):
return False
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
signed = timestamp.encode() + b"." + raw_body
expected = "v1=" + hmac.new(secret_key.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature.encode(), expected.encode())
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300;
function verifySignature(rawBody, signature, timestamp, secretKey) {
if (typeof signature !== "string" || typeof timestamp !== "string" || !/^\d+$/.test(timestamp)) {
return false;
}
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const expected =
"v1=" +
crypto
.createHmac("sha256", secretKey)
.update(`${timestamp}.`)
.update(rawBody)
.digest("hex");
return (
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
);
}
<?php
function verifySignature(string $rawBody, ?string $signature, ?string $timestamp, string $secretKey): bool
{
if ($signature === null || $timestamp === null || !ctype_digit($timestamp)) {
return false;
}
if (abs(time() - (int) $timestamp) > 300) {
return false;
}
$expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secretKey);
return hash_equals($expected, $signature);
}
Сравнивайте подпись функцией сравнения за постоянное время, как в примерах: hmac.compare_digest, timingSafeEqual, hash_equals.
Проверьте свою функцию на этом примере. Тело запроса:
{"type":"notification","event":"payment.succeeded","object":{"id":"2f9b6c1e-4d7a-4c3b-9e8f-1a2b3c4d5e6f","status":"succeeded","paid":true,"amount":{"value":"1000.00","currency":"RUB"}}}
Signature-Timestamp равен 1792324800. С ключом test_secret_key подпись равна v1=07181d18b6de6cfe0c882fd02f7e40c8c0e735f19dc67062c39f57c8d75c995b. Время в примере старое, поэтому проверка времени отклонит его: для самопроверки временно подставьте вместо текущего времени 1792324800.
Дополнительно: IP-адреса AvantPay
AvantPay отправляет уведомления только с этих IP-адресов:
79.141.79.16731.129.44.152
Пример обработчика
Готовые обработчики: проверяют подпись, запрашивают платёж и выполняют заказ один раз. Замените fulfill_order (fulfillOrder) своей функцией: она находит заказ, сверяет сумму и атомарно отмечает заказ оплаченным. На 404 обработчики отвечают 200: такого платежа у магазина нет. Python — на Flask, Node.js — на Express, PHP — без фреймворка.
- Python
- Node.js
- PHP
import hashlib
import hmac
import os
import time
from typing import Optional
import requests
from flask import Flask, abort, request
SHOP_ID = os.environ["AVANTPAY_SHOP_ID"]
SECRET_KEY = os.environ["AVANTPAY_SECRET_KEY"]
app = Flask(__name__)
TOLERANCE_SECONDS = 300
def verify_signature(raw_body: bytes, signature: Optional[str], timestamp: Optional[str], secret_key: str) -> bool:
if not signature or not timestamp or not (timestamp.isascii() and timestamp.isdigit()):
return False
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
return False
signed = timestamp.encode() + b"." + raw_body
expected = "v1=" + hmac.new(secret_key.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature.encode(), expected.encode())
def get_payment(payment_id: str) -> Optional[dict]:
"""Платёж из API; None — у магазина нет такого платежа (404)"""
response = requests.get(
f"https://api.avantpay.io/v3/payments/{payment_id}",
auth=(SHOP_ID, SECRET_KEY),
timeout=8,
)
if response.status_code == 404:
return None
response.raise_for_status()
return response.json()
def fulfill_order(payment: dict) -> None:
# 1. Найдите заказ по payment["id"]. Заказа нет (например, счёт из личного кабинета) — ничего не делайте.
# 2. Сверьте payment["amount"]["value"] и payment["amount"]["currency"] с суммой заказа. Не совпали — не выполняйте заказ.
# 3. Отметьте заказ оплаченным атомарно, например:
# UPDATE orders SET paid_payment_id = %s WHERE id = %s AND paid_payment_id IS NULL
# Изменилась строка — выполните заказ. Не изменилась — заказ уже выполнен, ничего не делайте.
pass
@app.post("/avantpay/webhook")
def avantpay_webhook():
# Тело — «сырыми» байтами: подпись считается по нему как есть
raw_body = request.get_data()
signature = request.headers.get("Signature")
timestamp = request.headers.get("Signature-Timestamp")
if not verify_signature(raw_body, signature, timestamp, SECRET_KEY):
abort(401)
notification = request.get_json(force=True)
if notification.get("event") not in ("payment.succeeded", "payment.canceled"):
return "", 200
try:
payment = get_payment(notification["object"]["id"])
except requests.RequestException:
return "", 500 # AvantPay пришлёт уведомление ещё раз
# None — платежа нет у магазина (404): ответ 200, ничего не делаем
if payment is not None and payment["status"] == "succeeded":
fulfill_order(payment)
return "", 200
import crypto from "node:crypto";
import express from "express";
const SHOP_ID = process.env.AVANTPAY_SHOP_ID;
const SECRET_KEY = process.env.AVANTPAY_SECRET_KEY;
const AUTH = "Basic " + Buffer.from(`${SHOP_ID}:${SECRET_KEY}`).toString("base64");
const TOLERANCE_SECONDS = 300;
function verifySignature(rawBody, signature, timestamp, secretKey) {
if (typeof signature !== "string" || typeof timestamp !== "string" || !/^\d+$/.test(timestamp)) {
return false;
}
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;
const expected =
"v1=" +
crypto
.createHmac("sha256", secretKey)
.update(`${timestamp}.`)
.update(rawBody)
.digest("hex");
return (
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
);
}
// Платёж из API; null — у магазина нет такого платежа (404)
async function getPayment(paymentId) {
const response = await fetch(
`https://api.avantpay.io/v3/payments/${encodeURIComponent(paymentId)}`,
{ headers: { Authorization: AUTH }, signal: AbortSignal.timeout(8000) },
);
if (response.status === 404) return null;
if (!response.ok) throw new Error(`AvantPay API: HTTP ${response.status}`);
return response.json();
}
async function fulfillOrder(payment) {
// 1. Найдите заказ по payment.id. Заказа нет (например, счёт из личного кабинета) — ничего не делайте.
// 2. Сверьте payment.amount.value и payment.amount.currency с суммой заказа. Не совпали — не выполняйте заказ.
// 3. Отметьте заказ оплаченным атомарно, например:
// UPDATE orders SET paid_payment_id = $1 WHERE id = $2 AND paid_payment_id IS NULL
// Изменилась строка — выполните заказ. Не изменилась — заказ уже выполнен, ничего не делайте.
}
const app = express();
// Тело — «сырыми» байтами: подпись считается по нему как есть
app.post(
"/avantpay/webhook",
express.raw({ type: "application/json" }),
async (req, res) => {
if (!verifySignature(req.body, req.get("Signature"), req.get("Signature-Timestamp"), SECRET_KEY)) {
return res.sendStatus(401);
}
const notification = JSON.parse(req.body.toString("utf8"));
if (!["payment.succeeded", "payment.canceled"].includes(notification.event)) {
return res.sendStatus(200);
}
try {
const payment = await getPayment(notification.object.id);
// payment === null — платежа нет у магазина (404): ответ 200, ничего не делаем
if (payment?.status === "succeeded") await fulfillOrder(payment);
res.sendStatus(200);
} catch (error) {
console.error(error);
res.sendStatus(500); // AvantPay пришлёт уведомление ещё раз
}
},
);
app.listen(3000);
<?php
$shopId = getenv('AVANTPAY_SHOP_ID');
$secretKey = getenv('AVANTPAY_SECRET_KEY');
function verifySignature(string $rawBody, ?string $signature, ?string $timestamp, string $secretKey): bool
{
if ($signature === null || $timestamp === null || !ctype_digit($timestamp)) {
return false;
}
if (abs(time() - (int) $timestamp) > 300) {
return false;
}
$expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secretKey);
return hash_equals($expected, $signature);
}
function fulfillOrder(array $payment): void
{
// 1. Найдите заказ по $payment['id']. Заказа нет (например, счёт из личного кабинета) — ничего не делайте.
// 2. Сверьте $payment['amount']['value'] и $payment['amount']['currency'] с суммой заказа. Не совпали — не выполняйте заказ.
// 3. Отметьте заказ оплаченным атомарно, например:
// UPDATE orders SET paid_payment_id = ? WHERE id = ? AND paid_payment_id IS NULL
// Изменилась строка — выполните заказ. Не изменилась — заказ уже выполнен, ничего не делайте.
}
// Тело — как пришло: подпись считается по нему до json_decode
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_SIGNATURE'] ?? null;
$timestamp = $_SERVER['HTTP_SIGNATURE_TIMESTAMP'] ?? null;
if (!verifySignature($rawBody, $signature, $timestamp, $secretKey)) {
http_response_code(401);
exit;
}
$notification = json_decode($rawBody, true);
$event = $notification['event'] ?? '';
if ($event !== 'payment.succeeded' && $event !== 'payment.canceled') {
http_response_code(200);
exit;
}
$ch = curl_init('https://api.avantpay.io/v3/payments/' . rawurlencode($notification['object']['id']));
curl_setopt_array($ch, [
CURLOPT_USERPWD => $shopId . ':' . $secretKey,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 8,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 404) {
http_response_code(200); // платежа нет у магазина: ничего не делаем
exit;
}
if ($response === false || $httpCode !== 200) {
http_response_code(500); // AvantPay пришлёт уведомление ещё раз
exit;
}
$payment = json_decode($response, true);
if ($payment['status'] === 'succeeded') {
fulfillOrder($payment);
}
http_response_code(200);