Async

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:

VersionFormatReplay 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.

v2 algorithm: the signature is computed over the string "<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

eventwhen
payment.succeededbank confirmed the charge, status → succeeded.
payment.failedbank declined. No charge happened, but there was an attempt. Status → failed.
payment.cancelledmerchant or customer explicitly cancelled a pending payment. No attempt to pay. Status → cancelled.
payment.expiredTTL elapsed: either our link (no one opened it) or the bank session (no final status returned). Status → expired.
payment.refundeda refund — full or partial — was created on the payment. One event per refund.
payout.succeededSBP payout went through.
payout.failedSBP payout was rejected by the bank.
Event filtering is strict. If the endpoint subscribes to 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/json
  • User-Agent: psp-webhook/1.0
  • X-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

GET/v1/public/events
A long keep-alive channel. Authorization is a Bearer key from the dashboard. One connection per key; on disconnect the client reconnects on its own.
Request
curl -N https://api.nerezpay.ru/v1/public/events \
  -H 'Authorization: Bearer sk_test_x9k…'
Response (SSE stream)
: 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":"…"}

: ping

Events delivered

  • payment.created, payment.status_changed
  • payout.created, payout.status_changed
  • refund.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.

SSE delivers on a best-effort basis: under client back-pressure individual messages may be dropped. For guaranteed delivery use webhooks — retries up to ~3.5 days. Recommended combo: SSE for UI / realtime, webhooks for ledgering.

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.