Вебхуки
Уведомления об оплате счетов на ваш сервер: подключение, формат запроса, проверка подписи, повторы и журнал доставок.
На этой странице
Вебхук — POST-запрос от PS Wallet Pay на ваш сервер, когда счёт оплачен. Это удобнее, чем опрашивать getInvoices: вы узнаёте об оплате сразу.
Подключение
- Откройте кабинет приложения → «Вебхуки».
- Укажите адрес сервера, например
https://example.com/pswallet-pay/webhook. - Нажмите «Отправить тестовый», чтобы проверить, что сервер принимает запросы.
- Нажмите «Подключить».
Требования к серверу:
- адрес
https://…с действующим публичным сертификатом. Самоподписанные сертификаты не поддерживаются, потому что подлинность запроса подтверждает подпись, а не сертификат; - публичный адрес: локальные и внутренние сети (
localhost,127.0.0.1,10.0.0.0/8,192.168.0.0/16и т. п.) недоступны; - в адресе нет логина и пароля, длина — до 512 символов;
- ответ с кодом
2xxв течение 10 секунд. Учитывается только код ответа, тело не читается. Перенаправления (3xx) не выполняются и считаются ошибкой.
Отвечайте 200 сразу после проверки подписи и сохранения события, а долгую обработку (выдачу товара, письма) делайте в фоне. Так сервер уложится в 10 секунд даже под нагрузкой.
Формат запроса
PS Wallet Pay отправляет POST с заголовками:
| Заголовок | Значение |
|---|---|
Content-Type | application/json |
User-Agent | PSWalletPay/1.0 |
PSWallet-Pay-API-Signature | Подпись тела запроса (см. Проверка подписи). |
Тело запроса — JSON-объект Update:
| Поле | Тип | Описание |
|---|---|---|
update_id | Number | ID события. Для invoice_paid равен invoice_id, поэтому повторную доставку того же события легко узнать. Для test — 0. |
update_type | String | invoice_paid — счёт оплачен; test — тестовый вебхук из кабинета. |
request_date | String | Время отправки этой попытки, ISO 8601. |
payload | Object | Для invoice_paid — оплаченный Invoice. Для test — { "app_id": …, "name": … }. |
Пример invoice_paid:
{
"update_id": 528,
"update_type": "invoice_paid",
"request_date": "2026-10-28T15:51:22.104Z",
"payload": {
"invoice_id": 528,
"hash": "IVq3Lm9TzR4wXkPa",
"currency_type": "crypto",
"asset": "USDT",
"amount": "12.5",
"bot_invoice_url": "https://t.me/pswallet?start=IVq3Lm9TzR4wXkPa",
"mini_app_invoice_url": "https://t.me/pswallet?startapp=IVq3Lm9TzR4wXkPa",
"web_app_invoice_url": "https://t.me/pswallet?startapp=IVq3Lm9TzR4wXkPa",
"pay_url": "https://t.me/pswallet?start=IVq3Lm9TzR4wXkPa",
"description": "Подписка на месяц",
"status": "paid",
"created_at": "2026-10-28T15:51:21.000+00:00",
"allow_comments": true,
"allow_anonymous": true,
"payload": "order-1042",
"paid_asset": "USDT-TRC20",
"paid_amount": "12.5",
"paid_usd_rate": "1",
"fee_asset": "USDT-TRC20",
"fee_amount": 0.375,
"paid_at": "2026-10-28T15:51:22.000+00:00",
"paid_anonymously": false
}
}Пример test:
{
"update_id": 0,
"update_type": "test",
"request_date": "2026-11-09T12:00:00.000Z",
"payload": { "app_id": 1234, "name": "My Shop" }
}Обработка событий
- Обрабатывайте вебхук идемпотентно. Одно и то же событие может прийти больше одного раза (например, если ваш сервер принял его, но не успел ответить). Проверяйте
invoice_idи не начисляйте заказ дважды. - Проверяйте
update_typeявно. В будущем могут появиться новые типы событий — на незнакомый тип отвечайте200и ничего не делайте. - Проверяйте подпись каждого запроса, прежде чем доверять его содержимому.
Проверка подписи
Каждый запрос подписан. Подпись лежит в заголовке PSWallet-Pay-API-Signature:
signature = hex( HMAC-SHA256( key = SHA256(token), message = тело запроса ) )где token — API-токен приложения, SHA256(token) — бинарный (не hex) дайджест, а тело — сырые байты запроса в том виде, в котором они пришли.
Не пересобирайте JSON из распарсенного объекта: порядок полей и пробелы изменятся, и подпись не сойдётся. Считайте подпись по сырому телу и только потом разбирайте JSON.
Если подпись не совпала, отвечайте 401 и ничего не делайте. Рекомендуем также отклонять запросы, у которых request_date старше нескольких минут.
После перевыпуска токена вебхуки подписываются новым токеном.
# Подписать тело так же, как PS Wallet Pay (OpenSSL), и отправить на свой сервер —
# удобно проверить обработчик без настоящей оплаты
TOKEN='1234:AbCdEfGhIjKlMnOpQrStUvWxYz0123456789' # замените на токен своего приложения
BODY='{"update_id":0,"update_type":"test","request_date":"2026-11-09T12:00:00.000Z","payload":{"app_id":1234,"name":"My Shop"}}'
KEY=$(printf '%s' "$TOKEN" | openssl dgst -sha256 -binary | xxd -p -c 256)
SIGNATURE=$(printf '%s' "$BODY" | openssl dgst -sha256 -mac HMAC -macopt hexkey:$KEY | awk '{print $NF}')
echo "$SIGNATURE"
curl https://example.com/pswallet-pay/webhook \
-H "Content-Type: application/json" \
-H "PSWallet-Pay-API-Signature: $SIGNATURE" \
--data-binary "$BODY"// Express
const crypto = require("crypto");
const express = require("express");
const TOKEN = process.env.PSWALLET_PAY_TOKEN;
const SECRET = crypto.createHash("sha256").update(TOKEN).digest();
function isValidSignature(rawBody, signature) {
const expected = Buffer.from(
crypto.createHmac("sha256", SECRET).update(rawBody).digest("hex"),
);
const received = Buffer.from(String(signature ?? ""));
return expected.length === received.length && crypto.timingSafeEqual(expected, received);
}
const app = express();
// express.raw — тело приходит как Buffer, без разбора JSON
app.post("/pswallet-pay/webhook", express.raw({ type: "application/json" }), (req, res) => {
if (!isValidSignature(req.body, req.get("PSWallet-Pay-API-Signature"))) {
return res.sendStatus(401);
}
const update = JSON.parse(req.body.toString("utf8"));
if (update.update_type === "invoice_paid") {
const invoice = update.payload;
// найдите заказ по invoice.invoice_id или invoice.payload и отметьте оплату (один раз)
}
res.sendStatus(200);
});
app.listen(3000);# FastAPI
import hashlib
import hmac
import json
import os
from fastapi import FastAPI, Request, Response
TOKEN = os.environ["PSWALLET_PAY_TOKEN"]
SECRET = hashlib.sha256(TOKEN.encode()).digest()
app = FastAPI()
def is_valid_signature(raw_body: bytes, signature: str | None) -> bool:
expected = hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
return signature is not None and hmac.compare_digest(expected, signature)
@app.post("/pswallet-pay/webhook")
async def webhook(request: Request):
raw_body = await request.body()
if not is_valid_signature(raw_body, request.headers.get("PSWallet-Pay-API-Signature")):
return Response(status_code=401)
update = json.loads(raw_body)
if update["update_type"] == "invoice_paid":
invoice = update["payload"]
# найдите заказ по invoice["invoice_id"] или invoice["payload"] и отметьте оплату (один раз)
return Response(status_code=200)<?php
$token = getenv('PSWALLET_PAY_TOKEN');
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_PSWALLET_PAY_API_SIGNATURE'] ?? '';
$secret = hash('sha256', $token, true);
$expected = hash_hmac('sha256', $rawBody, $secret);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit;
}
$update = json_decode($rawBody, true);
if ($update['update_type'] === 'invoice_paid') {
$invoice = $update['payload'];
// найдите заказ по $invoice['invoice_id'] или $invoice['payload'] и отметьте оплату (один раз)
}
http_response_code(200);Проверочный пример
Чтобы убедиться, что подпись считается правильно, прогоните свою функцию на этих данных:
- токен:
1234:AbCdEfGhIjKlMnOpQrStUvWxYz0123456789 - тело (одной строкой, без пробелов и перевода строки в конце):
{"update_id":0,"update_type":"test","request_date":"2026-11-09T12:00:00.000Z","payload":{"app_id":1234,"name":"My Shop"}}- ожидаемая подпись:
3ea77c8d68d6677cec798638c61315c4d7dd37193e2c981f9a7d0534319bbb08
Подпись не сходится — что проверить?
- Подпись считается по сырому телу запроса. Фреймворк не должен разбирать JSON до проверки: в Express используйте
express.raw, а неexpress.json. - Ключ HMAC — бинарный SHA-256 от токена (32 байта), а не hex-строка и не сам токен.
- Токен — текущий: после перевыпуска вебхуки подписываются новым токеном.
- Сравнивайте подпись в нижнем регистре hex, как она приходит в заголовке.
Повторы
Если сервер не ответил 2xx за 10 секунд, доставка повторяется:
| Попытка | Когда |
|---|---|
| 1 | Сразу после оплаты |
| 2 | Через 10 с |
| 3 | Через 30 с |
| 4 | Через 1 мин |
| 5 | Через 5 мин |
| 6 | Через 15 мин |
| 7 | Через 30 мин |
| 8 | Через 1 ч |
| 9 | Через 2 ч |
| 10 | Через 4 ч |
| 11–17 | Каждые 8 ч |
Время в таблице — пауза после предыдущей попытки. Всего 17 попыток примерно за 2,7 суток. В каждой попытке request_date новый, тело и подпись соответствуют ему. Если адрес вебхука поменяли, повторы уходят на новый адрес.
Если все попытки не удались, вебхуки приложения выключаются, а владельцу приходит сообщение в бот. Проверьте сервер и включите вебхуки снова в кабинете. Если выключить вебхуки вручную, ожидающие повторы отменяются.
Тестовый вебхук и журнал доставок
В разделе «Вебхуки» кабинета есть:
- «Отправить тестовый» — сразу отправляет событие
testна указанный адрес (его можно проверить ещё до подключения). Одна попытка без повторов; результат — код ответа и время или текст ошибки. - Журнал доставок — последние 30 отправленных уведомлений со статусом, кодом ответа вашего сервера и ошибкой. Статусы: «Доставлено», «Повтор» (с номером попытки), «В очереди», «Не доставлено».
- «Повторить» — неудачную доставку
invoice_paidиз журнала можно отправить повторно вручную. Это одна попытка: если она не удалась, вебхуки не выключаются.
Тестовые вебхуки тоже попадают в журнал, но повторить их нельзя — отправьте новый.