# Webhooks

> Invoice payment notifications to your server: setup, request format, signature verification, retries, and the delivery log.

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

1. Open the app dashboard → "Webhooks".
2. Enter your server address, for example `https://example.com/pswallet-pay/webhook`.
3. Click "Send test" to check that the server accepts requests.
4. 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 `2xx` response 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.

> [!TIP]
> 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](#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](https://help.pswallet.org/en/pay-api/types#invoice). For `test` — `{ "app_id": …, "name": … }`. |

`invoice_paid` example:

```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` example:

```json
{
  "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_id` and do not credit an order twice.
- **Check `update_type` explicitly.** New event types may appear in the future — for an unknown type, respond with `200` and 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.

> [!WARNING]
> 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.

```bash title="cURL"
# 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"
```

```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 — 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);
```

```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"]
        # find the order by invoice["invoice_id"] or invoice["payload"] and mark it as paid (once)
    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'];
    // 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`

:::details 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`, not `express.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 `test` event 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_paid` delivery 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.

---
Source: https://help.pswallet.org/en/pay-api/webhooks
