Webhooks
Invoice payment notifications to your server: setup, request format, signature verification, retries, and the delivery log.
On this page
A webhook is a POST request from PS Wallet Pay to your server when an invoice is paid. It is more convenient than polling getInvoices: you learn about the payment right away.
Setup
- Open the app dashboard → "Webhooks".
- Enter your server address, for example
https://example.com/pswallet-pay/webhook. - Click "Send test" to check that the server accepts requests.
- Click "Connect".
Server requirements:
- an
https://…address with a valid public certificate. Self-signed certificates are not supported, because the authenticity of a request is confirmed by the signature, not by the certificate; - a public address: local and internal networks (
localhost,127.0.0.1,10.0.0.0/8,192.168.0.0/16, etc.) are not accessible; - no login or password in the address, up to 512 characters long;
- a
2xxresponse within 10 seconds. Only the response code is taken into account; the body is not read. Redirects (3xx) are not followed and are treated as an error.
Respond with 200 right after verifying the signature and saving the event, and do long-running processing (delivering goods, sending emails) in the background. This way your server stays within 10 seconds even under load.
Request format
PS Wallet Pay sends a POST with these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | PSWalletPay/1.0 |
PSWallet-Pay-API-Signature | Signature of the request body (see Signature verification). |
The request body is an Update JSON object:
| Field | Type | Description |
|---|---|---|
update_id | Number | Event ID. For invoice_paid it equals invoice_id, so a repeated delivery of the same event is easy to recognize. For test it is 0. |
update_type | String | invoice_paid — the invoice is paid; test — a test webhook from the dashboard. |
request_date | String | Time this attempt was sent, ISO 8601. |
payload | Object | For invoice_paid — the paid Invoice. For test — { "app_id": …, "name": … }. |
invoice_paid example:
{
"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 example:
{
"update_id": 0,
"update_type": "test",
"request_date": "2026-11-09T12:00:00.000Z",
"payload": { "app_id": 1234, "name": "My Shop" }
}Handling events
- Handle webhooks idempotently. The same event may arrive more than once (for example, if your server received it but did not manage to respond). Check
invoice_idand do not credit an order twice. - Check
update_typeexplicitly. New event types may appear in the future — for an unknown type, respond with200and do nothing. - Verify the signature of every request before trusting its contents.
Signature verification
Every request is signed. The signature is in the PSWallet-Pay-API-Signature header:
signature = hex( HMAC-SHA256( key = SHA256(token), message = тело запроса ) )where token is the app's API token, SHA256(token) is a binary (not hex) digest, and the body is the raw request bytes exactly as they arrived.
Do not rebuild the JSON from a parsed object: the field order and whitespace will change, and the signature will not match. Compute the signature from the raw body and only then parse the JSON.
If the signature does not match, respond with 401 and do nothing. We also recommend rejecting requests whose request_date is more than a few minutes old.
After the token is reissued, webhooks are signed with the new token.
# Sign the body the same way PS Wallet Pay does (OpenSSL) and send it to your server —
# handy for testing your handler without a real payment
TOKEN='1234:AbCdEfGhIjKlMnOpQrStUvWxYz0123456789' # replace with your app's token
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 — the body arrives as a Buffer, without JSON parsing
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;
// find the order by invoice.invoice_id or invoice.payload and mark it as paid (once)
}
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"]
# find the order by invoice["invoice_id"] or invoice["payload"] and mark it as paid (once)
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'];
// find the order by $invoice['invoice_id'] or $invoice['payload'] and mark it as paid (once)
}
http_response_code(200);Test vector
To make sure the signature is computed correctly, run your function on this data:
- token:
1234:AbCdEfGhIjKlMnOpQrStUvWxYz0123456789 - body (a single line, with no spaces and no trailing newline):
{"update_id":0,"update_type":"test","request_date":"2026-11-09T12:00:00.000Z","payload":{"app_id":1234,"name":"My Shop"}}- expected signature:
3ea77c8d68d6677cec798638c61315c4d7dd37193e2c981f9a7d0534319bbb08
Signature does not match — what to check?
- The signature is computed from the raw request body. The framework must not parse the JSON before verification: in Express, use
express.raw, notexpress.json. - The HMAC key is the binary SHA-256 of the token (32 bytes), not a hex string and not the token itself.
- The token is the current one: after reissuing, webhooks are signed with the new token.
- Compare the signature as lowercase hex, as it arrives in the header.
Retries
If the server does not respond with 2xx within 10 seconds, the delivery is retried:
| Attempt | When |
|---|---|
| 1 | Immediately after payment |
| 2 | After 10 s |
| 3 | After 30 s |
| 4 | After 1 min |
| 5 | After 5 min |
| 6 | After 15 min |
| 7 | After 30 min |
| 8 | After 1 h |
| 9 | After 2 h |
| 10 | After 4 h |
| 11–17 | Every 8 h |
The time in the table is the pause after the previous attempt. In total there are 17 attempts over about 2.7 days. Each attempt has a new request_date, and the body and signature match it. If the webhook address is changed, retries go to the new address.
If all attempts fail, the app's webhooks are turned off and the owner receives a message in the bot. Check your server and turn webhooks back on in the dashboard. If you turn webhooks off manually, pending retries are canceled.
Test webhook and delivery log
The "Webhooks" section of the dashboard includes:
- "Send test" — immediately sends a
testevent to the specified address (you can check it even before connecting). One attempt with no retries; the result is the response code and time, or the error text. - Delivery log — the last 30 notifications sent, with their status, your server's response code, and the error. Statuses: "Delivered", "Retrying" (with the attempt number), "Queued", "Not delivered".
- "Retry" — a failed
invoice_paiddelivery from the log can be resent manually. This is a single attempt: if it fails, webhooks are not turned off.
Test webhooks also appear in the log, but they cannot be retried — send a new one.