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. Повтор с тем же ключом вернёт тот же ответ, а не создаст второй платёж
Node.js
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.

Node.js
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:

JSON
{
  "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 часа. Журнал доставок и кнопка повторной отправки — в карточке платежа.

Node.js · Express
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