Cookbook

Recipes

Minimum working snippets for common tasks: accepting a payment, partial refund, polling for status, realtime via SSE.

Minimum working examples for common tasks. Copy, swap in your keys, run. All snippets are Node.js (logic is the same in other languages).

1. Accept a payment and wait for the webhook

Full flow: create the payment on your backend, redirect the customer, handle payment.succeeded. Uses idempotency so that a double "pay" click in your shop doesn\u2019t create a duplicate.

// 1) On checkout in your shop backend:
import { randomUUID } from 'node:crypto'

async function startCheckout(order) {
  const res = await fetch('https://api.nerezpay.ru/v1/public/payments', {
    method: 'POST',
    headers: {
      'Authorization':   `Bearer ${process.env.PSP_API_KEY}`,
      'Content-Type':    'application/json',
      'Idempotency-Key': order.id, // one checkout attempt = one key
    },
    body: JSON.stringify({
      amount:      order.amount_minor,
      currency:    'RUB',
      method:      'sbp',
      order_id:    order.id,
      customer_id: order.user_id,
      return_url:  `https://shop.example/orders/${order.id}/done`,
      metadata:    { cart_size: String(order.items.length) },
    }),
  })
  if (!res.ok) throw new Error(`payment create failed: ${res.status}`)
  const { payment, payment_url } = await res.json()
  await db.orders.update(order.id, { nerezpay_payment_id: payment.id })
  return payment_url
}

// 2) Webhook handler for payment.succeeded:
import crypto from 'node:crypto'
import express from 'express'

const app = express()
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).digest('hex')
  if (!crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'))) {
    return res.status(401).end()
  }
  const event = JSON.parse(req.body)
  if (event.event === 'payment.succeeded') {
    // Make sure the order isn’t processed twice (idempotent on your side).
    db.orders.markPaid(event.data.order_id, event.data.id)
  }
  res.sendStatus(200)
})

2. Partial refund with remaining-balance check

Refund part of the amount, verify you won\u2019t exceed it.GET /payments/{id} returns amount_refunded — the remainder is computed client-side.

async function refundPartially(paymentId, amountToRefund) {
  // 1) Check the remaining balance.
  const { payment } = await fetchJSON(`/payments/${paymentId}`)
  const remaining = payment.amount - (payment.amount_refunded || 0)
  if (amountToRefund > remaining) {
    throw new Error(`refund ${amountToRefund} exceeds remaining ${remaining}`)
  }

  // 2) Refund with an idempotency-key so retries don’t create a second refund.
  const refundKey = `refund:${paymentId}:${amountToRefund}:${Date.now()}`
  const res = await fetch(
    `https://api.nerezpay.ru/v1/public/payments/${paymentId}/refund`,
    {
      method: 'POST',
      headers: {
        'Authorization':   `Bearer ${process.env.PSP_API_KEY}`,
        'Content-Type':    'application/json',
        'Idempotency-Key': refundKey,
      },
      body: JSON.stringify({ amount: amountToRefund, reason: 'partial refund' }),
    },
  )
  return res.json() // { id, status: 'succeeded', amount, ... }
}

3. Polling for status (when webhooks aren’t reachable)

If you don\u2019t have a public webhook URL (e.g., running locally), poll status via GET /payments/{id} with exponential backoff. Alternative — open an SSE stream.

async function waitForPayment(paymentId, { timeoutMs = 30 * 60 * 1000 } = {}) {
  const finalStatuses = ['succeeded', 'failed', 'cancelled', 'expired', 'refunded']
  const start = Date.now()
  let delay = 1000

  while (Date.now() - start < timeoutMs) {
    const res = await fetch(
      `https://api.nerezpay.ru/v1/public/payments/${paymentId}`,
      { headers: { Authorization: `Bearer ${process.env.PSP_API_KEY}` } },
    )
    if (res.status === 429) {
      const retryAfter = Number(res.headers.get('Retry-After') || '1')
      await sleep(retryAfter * 1000)
      continue
    }
    const payment = await res.json()
    if (finalStatuses.includes(payment.status)) return payment

    await sleep(delay)
    delay = Math.min(delay * 1.5, 15_000) // backoff up to 15 s
  }
  throw new Error('payment did not finalize within timeout')
}

4. Realtime via SSE

Alternative to polling: subscribe to our SSE stream and react to events without lag. Great for a server-side merchant dashboard.

import { EventSource } from 'eventsource' // npm: eventsource

const es = new EventSource('https://api.nerezpay.ru/v1/public/events', {
  headers: { Authorization: `Bearer ${process.env.PSP_API_KEY}` },
})

es.addEventListener('payment.status_changed', (msg) => {
  const { payload } = JSON.parse(msg.data)
  console.log(`payment ${payload.id} → ${payload.status}`)
  // refresh in-memory cache / WebSocket clients / dashboard metrics
})

es.addEventListener('payout.status_changed', (msg) => {
  const { payload } = JSON.parse(msg.data)
  console.log(`payout ${payload.id} → ${payload.status}`)
})

es.onerror = () => {
  // EventSource reconnects on its own after a few seconds.
  console.warn('SSE disconnected, reconnecting…')
}
SSE is best-effort. Keep webhooks for ledgering anyway: retries up to ~3.5 days. The "SSE for UI/realtime + webhook for guarantees" combo works for most PSPs.