Готовые сценарии
Минимальные рабочие сниппеты для типичных задач: приём оплаты, частичный возврат, 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…')
}