API Payos
Один API для платежей картами, банковскими переводами и мобильными кошельками. Базовый адрес: https://api.payos.pro. Все суммы — целые числа в минимальных единицах валюты.
Подпись запросов
Каждый запрос подписывается секретным ключом магазина. Ключ и ID магазина выдаются в кабинете после модерации. Ключ живёт только на твоём сервере — никогда не отправляй его в браузер или мобильное приложение.
- X-Shop-Id — ID магазина
- X-Timestamp — unix-время в секундах, не дальше 5 минут от нашего
- X-Signature — hex(HMAC-SHA256(ключ, timestamp ⏎ METHOD ⏎ путь?query ⏎ hex(SHA256(тело))))
- Idempotency-Key — обязателен для POST. Повтор с тем же ключом вернёт тот же ответ, а не создаст второй платёж
import { createHash, createHmac } from 'node:crypto';
const SHOP_ID = process.env.PAYOS_SHOP_ID;
const SECRET_KEY = process.env.PAYOS_SECRET_KEY;
export async function payos(method, path, body) {
const raw = body === undefined ? '' : JSON.stringify(body);
const timestamp = String(Math.floor(Date.now() / 1000));
const bodyHash = createHash('sha256').update(raw).digest('hex');
const signature = createHmac('sha256', SECRET_KEY)
.update([timestamp, method, path, bodyHash].join('\n'))
.digest('hex');
const res = await fetch('https://api.payos.pro' + path, {
method,
headers: {
'content-type': 'application/json',
'x-shop-id': SHOP_ID,
'x-timestamp': timestamp,
'x-signature': signature,
// Every POST: a key you generate per operation; retries reuse it.
...(method === 'POST' && { 'idempotency-key': crypto.randomUUID() }),
},
body: raw || undefined,
});
if (!res.ok) throw new Error(`Payos ${res.status}: ${await res.text()}`);
return res.json();
}Создание платежа
Создай платёж и отправь покупателя на checkoutUrl. Там он выберет способ оплаты, доступный для его страны и валюты. После оплаты вернём его на returnUrl с параметром payment_id.
const payment = await payos('POST', '/v1/payments', {
orderId: 'A-1001', // your order id, unique per shop
amount: 10000, // minor units: 10000 = €100.00
currency: 'EUR',
description: 'Order A-1001',
customer: { email: '[email protected]', country: 'DE' },
returnUrl: 'https://shop.example/orders/A-1001',
});
// Send the customer to the payment page
redirect(payment.checkoutUrl);Ответ 201:
{
"id": "pay_3f9c1e0b8a7d6c5b4a392817",
"orderId": "A-1001",
"amount": 10000,
"currency": "EUR",
"status": "created",
"method": null,
"checkoutUrl": "https://pay.payos.com/c/…",
"expiresAt": "2026-10-31T12:30:00.000Z",
…
}У валют без копеек — XOF, XAF, UGX, RWF, BIF, CLP — сумма в целых единицах: 10000 XOF = 10 000 франков. Ссылка на оплату действует 30 минут.
Статусы
- created — создан, покупатель ещё не выбрал способ оплаты
- processing — связываемся с платёжной системой
- requires_action — покупатель подтверждает оплату: на странице банка или в телефоне
- succeeded — оплачен — можно отгружать
- failed — отклонён, причина в failureCode
- expired — истёк срок ссылки
Финальные статусы — succeeded, failed, expired. Редкий случай: платёжная система подтверждает оплату после истечения — тогда expired сменится на succeeded, и мы пришлём вебхук.
Вебхуки
При payment.succeeded, payment.failed и payment.expired отправляем POST на URL вебхуков магазина. Ответь 2xx — иначе повторим через 1 мин, 5 мин, 30 мин, 2, 6, 12 и 24 часа. Журнал доставок и кнопка повторной отправки — в карточке платежа.
import { createHmac, timingSafeEqual } from 'node:crypto';
// Express: keep the raw body — the signature covers exact bytes.
app.post('/payos/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.get('x-payos-timestamp');
const expected = 'v1=' + createHmac('sha256', process.env.PAYOS_WEBHOOK_SECRET)
.update(`${timestamp}.${req.body}`)
.digest('hex');
const given = req.get('x-payos-signature') ?? '';
const valid = given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
if (!valid || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return res.sendStatus(401);
const event = JSON.parse(req.body);
// Deliveries may repeat: process each event id once.
if (alreadyProcessed(event.id)) return res.sendStatus(200);
if (event.type === 'payment.succeeded') markOrderPaid(event.data.orderId, event.data.amount, event.data.currency);
res.sendStatus(200);
});Порядок доставки не гарантирован. Если сомневаешься в статусе — запроси GET /v1/payments/:id: это источник истины.
Другие методы
- GET /v1/payments/:id — платёж по ID
- GET /v1/balance — баланс компании по валютам: pending (в обработке) и available (к выплате)
- GET /v1/shop — проверить, что подпись настроена верно
Ошибки
Тело ошибки всегда { "code": "…", "message": "…" }. Повторять с тем же Idempotency-Key имеет смысл при 429 и 5xx.
- 401 invalid_signature — подпись не совпала: проверь ключ, путь с query и хеш тела
- 401 timestamp_out_of_range — часы сервера отстают или спешат больше чем на 5 минут
- 403 shop_not_active — магазин не одобрен или заблокирован
- 400 idempotency_key_required — у POST нет Idempotency-Key
- 409 order_exists — платёж с таким orderId уже есть
- 422 idempotency_key_reused — тот же Idempotency-Key с другим телом
- 429 rate_limited — больше 600 запросов в минуту; смотри Retry-After