Webhooks
Platform events POST to your endpoint with an HMAC signature. There’s also an SSE channel — low latency, no public URL needed.
On every final event the platform POSTs to your endpoint. The body is JSON, the X-PSP-Signature header carries an HMAC-SHA256 signature signed with the webhook secret. This is a different secret from the shop HMAC — make sure you use the whs_… issued when the endpoint was created.
The header has two formats to ease integrator migration:
| Version | Format | Replay protection |
|---|---|---|
v2 (recommended) | t=<unix_ts>,v1=<hex_v1>,v2=<hex_v2> | Yes: timestamp included in the signature + freshness check on the client (default ±5 minutes). |
v1 (legacy) | sha256=<hex> | No. The signature is valid forever; an intercepted webhook can be replayed. |
All our SDKs\u2019 verifyWebhook(...) support both formats: if t=...,v2=... arrives, we verify v2 + timestamp tolerance; if only sha256=..., we verify v1.
"<timestamp>.<raw_body>" with a dot separator. The receiver checks the timestamp is recent (replay protection), then verifies HMAC. Robust against a webhook intercepted at an intermediate proxy and replayed.We also duplicate the timestamp in a dedicated X-PSP-Timestamp header — handy for logging without parsing the Signature.
Where to configure
In the dashboard: Integration → shop card → Webhooks block → New webhook button. You can register multiple endpoints per shop, each with its own URL and event set. Events from one shop will never reach another shop\u2019s endpoint.
The creation dialog asks for:
- URL. Where to POST. HTTPS only in production.
- Events. A checklist from the list below. If nothing is checked, the endpoint receives all events.
The secret is shown once, right after creation. Save it. From there the URL is editable in place, and the secret can be regenerated with the Rotate button (the old one stops working immediately). Deleting an endpoint halts delivery.
One webhook for a single payment
POST /payments has a webhook_url field. If you pass it, events for that specific payment go to that URL instead of the dashboard-configured ones. The secret still comes from the shop\u2019s endpoint. Handy for one-off integrations, B2B flows or temporary test handlers.
Events
| event | when |
|---|---|
payment.succeeded | bank confirmed the charge, status → succeeded. |
payment.failed | bank declined. No charge happened, but there was an attempt. Status → failed. |
payment.cancelled | merchant or customer explicitly cancelled a pending payment. No attempt to pay. Status → cancelled. |
payment.expired | TTL elapsed: either our link (no one opened it) or the bank session (no final status returned). Status → expired. |
payment.refunded | a refund — full or partial — was created on the payment. One event per refund. |
payout.succeeded | SBP payout went through. |
payout.failed | SBP payout was rejected by the bank. |
payment.succeeded only, payment.failed won\u2019t be delivered. An empty list means "all events".Body format
{
"id": "evt_8f9a2c11", // stable event ID — store for dedup
"event": "payment.succeeded",
"created_at": "2026-05-05T12:43:08Z",
"data": {
// Full payment (or payout) object at the time of the event.
// Same fields as GET /v1/public/payments/{id} returns.
"id": "5a331a39-32bf-4940-afb1-855e2fc6757f",
"merchant_id": "22222222-…",
"shop_id": "33333333-…",
"order_id": "ORDER-1042",
"amount": 150000,
"currency": "RUB",
"method": "sbp",
"status": "succeeded",
// ...
}
}Headers
Content-Type: application/jsonUser-Agent: psp-webhook/1.0X-PSP-Event-Id: <evt_…>— same as in the body; convenient to log on the receiver without parsing JSON.X-PSP-Signature: sha256=<hex>
Signature verification
Algorithm is the same. Take the endpoint secret (whs_…), compute HMAC-SHA256 over the raw request body (bytes, not the parsed JSON), encode it as hex and compare against the value of X-PSP-Signature: sha256=<hex>. The comparison must be constant-time, otherwise you open a timing attack.
import crypto from 'node:crypto'
import express from 'express'
const app = express()
// IMPORTANT: raw body, not the json parser — we need the same bytes
// nerezpay computed HMAC over.
app.post('/nerezpay/hook', express.raw({ type: 'application/json' }), (req, res) => {
const sig = (req.header('X-PSP-Signature') || '').replace(/^sha256=/, '')
const expected = crypto.createHmac('sha256', process.env.PSP_WEBHOOK_SECRET)
.update(req.body) // Buffer
.digest('hex')
const ok = sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'))
if (!ok) return res.status(401).send('bad signature')
const event = JSON.parse(req.body.toString('utf8'))
// ... process event.kind / event.data
res.sendStatus(200)
})Retries and idempotency
Delivery is considered successful if any 2xx returns within 15 seconds. Otherwise an exponential backoff kicks in: 10 seconds, 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 24 hours, 48 hours. After the eighth failed attempt (~3.5 days) delivery stops; the event can be re-sent manually from the dashboard.
The event id is stable across retries — store it and dedup on your side. An event can be delivered more than once if your network was flaky and you couldn\u2019t respond within 15 seconds.
Realtime (SSE)
In parallel with webhook deliveries, events are available via Server-Sent Events. Suitable for backends that need minimum latency (e.g., a server-rendered dashboard) or for those that can’t expose a public webhook endpoint.
SSE stream of status changes
/v1/public/eventscurl -N https://api.nerezpay.ru/v1/public/events \ -H 'Authorization: Bearer sk_test_x9k…'
: connected
event: payment.status_changed
data: {"kind":"payment.status_changed","payload":{"id":"5a331a39-…","status":"succeeded"},"at":"2026-05-05T12:43:11Z"}
event: payment.created
data: {"kind":"payment.created","payload":{"id":"…","amount":150000,"status":"pending"},"at":"…"}
: pingEvents delivered
payment.created,payment.status_changedpayout.created,payout.status_changedrefund.created
These are platform-internal events, distinct from webhook-style payment.succeeded. The sets overlap, but the SSE stream also includes intermediate status transitions not delivered as webhooks.
WebSocket and gRPC
Planned. The pub/sub broker runs on Redis and is transport-agnostic, so adding WS and gRPC is a matter of a separate endpoint. The release date will be in the changelog. SSE is enough for most integrations: supported by every major language and framework without extra dependencies.