PS Wallet Справка
RUEN
Открыть кошелёк

Вебхуки

Уведомления об оплате счетов на ваш сервер: подключение, формат запроса, проверка подписи, повторы и журнал доставок.

На этой странице

Вебхук — POST-запрос от PS Wallet Pay на ваш сервер, когда счёт оплачен. Это удобнее, чем опрашивать getInvoices: вы узнаёте об оплате сразу.

Подключение

  1. Откройте кабинет приложения → «Вебхуки».
  2. Укажите адрес сервера, например https://example.com/pswallet-pay/webhook.
  3. Нажмите «Отправить тестовый», чтобы проверить, что сервер принимает запросы.
  4. Нажмите «Подключить».

Требования к серверу:

  • адрес 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-Typeapplication/json
User-AgentPSWalletPay/1.0
PSWallet-Pay-API-SignatureПодпись тела запроса (см. Проверка подписи).

Тело запроса — JSON-объект Update:

ПолеТипОписание
update_idNumberID события. Для invoice_paid равен invoice_id, поэтому повторную доставку того же события легко узнать. Для test — 0.
update_typeStringinvoice_paid — счёт оплачен; test — тестовый вебхук из кабинета.
request_dateStringВремя отправки этой попытки, ISO 8601.
payloadObjectДля invoice_paid — оплаченный Invoice. Для test — { "app_id": …, "name": … }.

Пример invoice_paid:

json
{
  "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:

json
{
  "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 из журнала можно отправить повторно вручную. Это одна попытка: если она не удалась, вебхуки не выключаются.

Тестовые вебхуки тоже попадают в журнал, но повторить их нельзя — отправьте новый.

Обновлено 9 ноября 2026 г.