Cookbook

Готовые сценарии

Минимальные рабочие сниппеты для типичных задач: приём оплаты, частичный возврат, polling статуса, realtime через SSE.

Минимальные рабочие примеры для типичных задач. Скопируйте, замените ключи и запускайте. Все сниппеты — Node.js (для других языков логика идентичная).

1. Принять платёж и дождаться webhook'а

Полный flow: создание платежа на бэкенде, редирект покупателя, обработка payment.succeeded. Используется идемпотентность, чтобы повторный клик «оплатить» в магазине не создал дубль.

// 1) При оформлении заказа на бэкенде магазина:
import { randomUUID } from 'node:crypto'

async function startCheckout(order) {
  const res = await fetch('https://api.balancedpay.pro/api/v1/public/payments', {
    method: 'POST',
    headers: {
      'Authorization':   `Bearer ${process.env.FREEFIN_API_KEY}`,
      'Content-Type':    'application/json',
      'Idempotency-Key': order.id, // одна попытка оформления = один ключ
    },
    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, { balancedpay_payment_id: payment.id })
  return payment_url
}

// 2) Webhook-ручка для payment.succeeded:
import crypto from 'node:crypto'
import express from 'express'

const app = express()
app.post('/balancedpay/hook', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = (req.header('X-Freefin-Signature') || '').replace(/^sha256=/, '')
  const expected = crypto.createHmac('sha256', process.env.FREEFIN_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') {
    // Обязательно проверьте, что заказ ещё не обработан (idempotent на нашей стороне).
    db.orders.markPaid(event.data.order_id, event.data.id)
  }
  res.sendStatus(200)
})

2. Частичный возврат с проверкой остатка

Возвращаем часть суммы, проверяем что не превысим amount.GET /payments/{id} отдаёт amount_refunded — остаток вычисляется на клиенте.

async function refundPartially(paymentId, amountToRefund) {
  // 1) Проверяем остаток.
  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) Делаем возврат с idempotency-key, чтобы ретрай не создал второй refund.
  const refundKey = `refund:${paymentId}:${amountToRefund}:${Date.now()}`
  const res = await fetch(
    `https://api.balancedpay.pro/api/v1/public/payments/${paymentId}/refund`,
    {
      method: 'POST',
      headers: {
        'Authorization':   `Bearer ${process.env.FREEFIN_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 статуса (когда webhook недоступен)

Если у вас нет публичного URL для webhook'а (например, запускаете локально), опросите статус через GET /payments/{id} с экспоненциальной задержкой. Альтернатива — открыть SSE-стрим.

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.balancedpay.pro/api/v1/public/payments/${paymentId}`,
      { headers: { Authorization: `Bearer ${process.env.FREEFIN_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 до 15 сек
  }
  throw new Error('payment did not finalize within timeout')
}

4. Realtime через SSE

Альтернатива polling'у: подписываемся на наш SSE-стрим и реагируем на события без задержек. Хорошо для серверного дашборда мерчанта.

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

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

es.addEventListener('payment.status_changed', (msg) => {
  const { payload } = JSON.parse(msg.data)
  console.log(`payment ${payload.id} → ${payload.status}`)
  // обновляем in-memory кэш / WebSocket-клиентов / dashboard-метрики
})

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

es.onerror = () => {
  // EventSource переподключится сам через несколько секунд.
  console.warn('SSE disconnected, reconnecting…')
}
SSE — best-effort. Для бухгалтерии всё равно держите webhook: он с retry до 3.5 дней. Связка «SSE для UI/realtime + webhook для гарантий» работает в большинстве PSP.