# Вебхуки

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

Вебхук — 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`) не выполняются и считаются ошибкой.

> [!TIP]
> Отвечайте `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](https://help.pswallet.org/ru/pay-api/types#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) дайджест, а тело — **сырые байты запроса** в том виде, в котором они пришли.

> [!WARNING]
> Не пересобирайте JSON из распарсенного объекта: порядок полей и пробелы изменятся, и подпись не сойдётся. Считайте подпись по сырому телу и только потом разбирайте JSON.

Если подпись не совпала, отвечайте `401` и ничего не делайте. Рекомендуем также отклонять запросы, у которых `request_date` старше нескольких минут.

После перевыпуска токена вебхуки подписываются новым токеном.

```bash title="cURL"
# Подписать тело так же, как 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"
```

```js title="Node.js"
// 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);
```

```python title="Python"
# 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 title="PHP"
<?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`

:::details Подпись не сходится — что проверить?
- Подпись считается по сырому телу запроса. Фреймворк не должен разбирать 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` из журнала можно отправить повторно вручную. Это одна попытка: если она не удалась, вебхуки не выключаются.

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

---
Источник: https://help.pswallet.org/ru/pay-api/webhooks
