FP FopPay

FopPay API v1

API і вебхуки

API дозволяє вашій CRM, сайту чи боту створювати платіжні посилання з QR НБУ на рахунок вашого ФОП, а вебхуки — дізнаватися, коли клієнт відкрив посилання. Гроші йдуть напряму на ваш IBAN: FopPay не проводить платіж і не має доступу до рахунку.

Базова адресаhttps://foppay.com.ua/api/v1
ФорматJSON, UTF-8, лише HTTPS
Ключ APIКабінет → «API ключі»
ВебхукиКабінет → «Вебхуки»

Швидкий старт

  1. Створіть ключ у кабінеті → «API ключі». Секрет показується один раз — збережіть його.
  2. Дізнайтеся код рахунку: GET /api/v1/fops → account_code_6.
  3. Створюйте посилання: POST /api/v1/invoices → надсилайте клієнту short_url.
  4. За потреби підключіть вебхук і отримуйте payment_link.opened.

Інтегруєте Telegram-бота? Є покроковий посібник з готовим кодом.

Автентифікація

Кожен запит передає ключ у заголовку X-API-Secret у форматі fp_<prefix>_<body>: 8 малих латинських літер і цифр — публічний префікс, 32 символи — секретна частина. Ми зберігаємо лише хеш секрету, тож відновити його неможливо — тільки перевипустити. Будь-яка проблема з ключем повертає однакову відповідь 401 unauthorized.

Один активний ключ на акаунт. Перевипуск у кабінеті миттєво вимикає попередній ключ. Не передавайте ключ у браузер чи мобільний застосунок — викликайте API лише зі свого сервера.

Створити платіжне посилання

POST https://foppay.com.ua/api/v1/invoices

ПолеОбовʼязковеОпис
bank_account_codeтак 6 цифр — account_code_6 вашого рахунку з GET /api/v1/fops або з кабінету.
titleтак Призначення платежу — саме цей текст бачить клієнт і банк. До 420 символів, але разом з реквізитами має вміститися в QR НБУ (на практиці ≈ 290 символів). Лише символи, які кодуються у Windows-1251 (без емодзі). Додайте сюди номер замовлення — так оплату легше знайти у виписці банку.
amountтак Сума в гривнях рядком із крапкою: "250.00", "99.5", "1200". Більше 0, не більше 999999999.99, до 2 знаків після крапки. Не передавайте float.
invoice_numberні Ваш номер замовлення, 1–32 символи, без керуючих символів. Не мусить бути унікальним. Повертається у відповіді та у вебхуку як order_number.
customer_labelні Внутрішня позначка клієнта для кабінету.
ttl_daysні Скільки днів посилання дійсне: 1–90. Якщо не передати — береться значення з налаштувань акаунта (початково 14).

Заголовки: X-API-Secret, X-Idempotency-Key (UUID, див. нижче), Content-Type: application/json. Параметри в query string ігноруються.

curl
curl -X POST 'https://foppay.com.ua/api/v1/invoices' \
  -H 'X-API-Secret: fp_<prefix>_<body>' \
  -H "X-Idempotency-Key: $(uuidgen)" \
  -H 'Content-Type: application/json' \
  -d '{"bank_account_code":"100500","title":"Оплата замовлення ORD-1024","amount":"250.00","invoice_number":"ORD-1024"}'
Відповідь 201
{
    "invoice_id": 123,
    "invoice_number": "ORD-1024",
    "short_url": "https://fop.one/txu2k8",
    "long_url": "https://foppay.com.ua/pay/…",
    "og_url": "https://foppay.com.ua/pay/…/og.png",
    "link_id": "txu2k8",
    "expires_at": "2026-10-26T09:15:42+00:00"
}
Python (requests)
import os
import uuid

import requests

response = requests.post(
    "https://foppay.com.ua/api/v1/invoices",
    headers={
        "X-API-Secret": os.environ["FOPPAY_API_SECRET"],
        # Новий UUID на кожне нове посилання. Повтор після збою — з тим самим UUID і тим самим тілом.
        "X-Idempotency-Key": str(uuid.uuid4()),
    },
    json={
        "bank_account_code": "100500",
        "title": "Оплата замовлення ORD-1024",
        "amount": "250.00",
        "invoice_number": "ORD-1024",
    },
    timeout=15,
)

data = response.json()
if response.status_code != 201:
    raise RuntimeError(f"{data['code']}: {data['message']}")

print(data["link_id"], data["short_url"])
  • Надсилайте клієнту short_url. Саме відкриття короткого посилання запускає вебхук payment_link.opened; long_url його не запускає.
  • Збережіть link_id — за ним вебхуки зіставляються з вашим замовленням.
  • og_url — картинка-прев'ю посилання для месенджерів.

ФОП і рахунки

GET https://foppay.com.ua/api/v1/fops повертає ваші активні ФОП і їхні активні рахунки з account_code_6 — його передають у bank_account_code. X-Idempotency-Key не потрібен.

curl
curl 'https://foppay.com.ua/api/v1/fops' \
  -H 'X-API-Secret: fp_<prefix>_<body>'
Відповідь 200
{
    "data": [
        {
            "full_name": "ФОП Шевченко Тарас Григорович",
            "edrpou": "1636308230",
            "bank_accounts": [
                {
                    "iban": "UA233005284874453278520906617",
                    "bank_name": "АТ \"ОТП Банк\"",
                    "account_code_6": "836037"
                }
            ]
        }
    ]
}

Ідемпотентність і ліміти

  • Для кожного нового посилання генеруйте новий UUID у X-Idempotency-Key.
  • Якщо запит обірвався, повторіть його з тим самим UUID і тим самим тілом — отримаєте збережену відповідь, а не друге посилання. Ключ діє 24 години.
  • Той самий UUID з іншим тілом → 409 idempotency_key_conflict. Після 5xx повторюйте з новим UUID.
  • Ліміт — 5 запитів на хвилину для одного ключа з однієї IP-адреси (і 30 на хвилину з однієї IP-адреси до автентифікації). На 429 чекайте стільки секунд, скільки вказано в Retry-After.

Помилки

Усі помилки мають однаковий формат. Орієнтуйтеся на code, а не на message: текст може змінюватися, коди стабільні. errors — завжди обʼєкт: {} або помилки по полях.

Формат помилки
{
  "message": "Human-readable description",
  "code": "machine_readable_code",
  "errors": {}
}
СтатусcodeКоли
400 invalid_idempotency_key Заголовок X-Idempotency-Key відсутній або не є UUID
401 unauthorized Невірний або відсутній X-API-Secret
403 forbidden Ключ існує, але не має права на цю дію
404 not_found Ресурс не знайдено (наприклад, неправильний bank_account_code)
409 idempotency_key_failed Попередній запит з цим ключем завершився помилкою сервера — повторіть із новим ключем
409 idempotency_key_conflict Той самий ключ вже використано з іншим тілом запиту
409 idempotency_key_processing Попередній запит з тим же ключем ще обробляється — повторіть після Retry-After
422 amount_exceeds_nbu_maximum Сума більша за максимум НБУ для QR-платежу (999999999.99)
422 amount_field_too_long Поле суми довше за 15 байтів, дозволених стандартом НБУ
422 purpose_too_long Призначення платежу (title) не вміщається в QR-код НБУ
422 qr_payload_too_large Реквізити разом із призначенням не вміщаються в QR-код НБУ
422 recipient_account_invalid IBAN звʼязаного рахунку не відповідає формату НБУ (29 символів)
422 recipient_code_invalid ЄДРПОУ / РНОКПП звʼязаного ФОПа довший за 10 символів
422 recipient_too_long Імʼя звʼязаного ФОПа довше за 140 символів
422 unsupported_character Значення містить символ, який не кодується у Windows-1251 (напр. емодзі)
422 request_cannot_be_processed Запит не вдається обробити (бізнес-правило)
422 validation_failed Поля запиту не пройшли валідацію (див. errors)
429 rate_limited Перевищено ліміт запитів — дочекайтеся Retry-After
500 internal_error Внутрішня помилка сервера
503 service_unavailable Сервіс тимчасово недоступний

Вебхуки: підключення

FopPay надсилає HTTP POST на ваш сервер, коли стається подія. Приймач підключаєте самостійно, без узгодження з нами — у кабінеті → «Вебхуки»:

  1. Вкажіть URL приймача, наприклад https://bot.example.com/foppay/webhook (вимоги — нижче).
  2. Позначте події, які хочете отримувати.
  3. Збережіть секрет whsec_… — він показується один раз. Ним ви перевіряєте підпис кожного запиту.
  4. Натисніть «Надіслати тестову подію»: на ваш приймач прийде підписана подія webhook.test, а в журналі доставок — її статус і код відповіді.

Вимоги до адреси

  • Лише https:// і лише стандартний порт 443. Сертифікат має бути дійсним для цього домену — самопідписані не приймаються.
  • Доменне імʼя, а не IP-адреса. Без логіна й пароля в адресі (user:pass@) і без фрагмента (#…). Кириличні домени можна — ми збережемо їх у форматі punycode.
  • Усі IP-адреси домену (записи A і AAAA) мають бути публічними. Якщо хоч одна з них приватна, локальна, службова чи зарезервована (наприклад, 10.0.0.0/8, 192.168.0.0/16, 127.0.0.0/8, fc00::/7), запит не надсилається.
  • Перенаправлення (3xx) не виконуються — вказуйте кінцеву адресу. Проксі не використовуються.
  • Адресу перевіряємо, коли ви її зберігаєте, і заново перед кожною спробою доставки. Якщо перевірка не пройшла (наприклад, DNS тимчасово не відповів або домен почав вказувати на приватну адресу), спроба вважається невдалою, причина видна в журналі, а доставка повториться за звичайним розкладом.
  • Один приймач на акаунт. Адресу можна змінити, приймач — вимкнути й увімкнути знову (з тим самим секретом).
  • Секрет можна перевипустити в кабінеті. Усі наступні спроби, включно з повторами, підписуються вже новим секретом.
  • У кабінеті видно журнал доставок: статус, HTTP-код відповіді, помилку. Невдалу доставку можна надіслати ще раз вручну.
  • Події, що сталися до підключення приймача, заднім числом не надсилаються.

Події

На події, яких ваш обробник не знає, відповідайте 2xx (після перевірки підпису) і нічого не робіть. Так тестова подія в журналі буде «Доставлено», а нові типи подій, які ми додаватимемо, не повторюватимуться даремно.

webhook.test — Тестова подія

Надсилається лише вручну, кнопкою «Надіслати тестову подію» в кабінеті, щоб перевірити приймач.

  • Підписуватися на неї не потрібно: вона надходить на ваш приймач, навіть якщо в ньому не позначено жодної події. Вимкнений приймач її не отримує.
  • Підпис, заголовки, вимоги до адреси й таймаути — такі самі, як у справжніх подій. X-Idempotency-Key — новий при кожному натисканні.
  • Одна спроба, без автоматичних повторів: за кілька секунд результат зʼявиться в журналі з позначкою «тест» (оновіть сторінку). Повторити її можна кнопкою в журналі або новим натисканням.
  • Ліміт — 5 на хвилину на акаунт, спільний для нових тестових подій і повторів тестових доставок із журналу. Повтори справжніх доставок він не обмежує.
  • На «Останній успіх» і «Остання помилка» приймача в кабінеті тестова подія не впливає.
ПолеОпис
testЗавжди true.
eventЗавжди «webhook.test».
sent_atЧас, коли ви натиснули кнопку, UTC, ISO 8601.
Тіло запиту
{"test":true,"event":"webhook.test","sent_at":"2026-10-12T09:15:42Z"}
Заголовки
POST /your/webhook/path HTTP/1.1
Content-Type: application/json
User-Agent: FopPay-Webhook/1
X-Foppay-Event: webhook.test
X-Idempotency-Key: 3f1c9a52-7b0e-4d2a-9c41-8e5f0b6a2d17
X-Signature: t=1791796543,v1=<64 hex-символи>

Тіло — компактний JSON (без пробілів, кирилиця й «/» не екрануються). Порядок ключів не гарантований — не покладайтеся на нього.

Підпис X-Signature

Заголовок має вигляд t=<timestamp>,v1=<signature>.

  • t — Unix-час у секундах (UTC) на момент відправки цієї спроби. Генерується на нашому боці при кожній спробі, тому в повторі t новий, а X-Idempotency-Key — той самий.
  • v1 — HMAC-SHA256 у нижньому hex (64 символи) від рядка {t}.{X-Idempotency-Key}.{сире тіло}.
  • Зараз v1 один, але закладіть, що їх може бути кілька через кому (наприклад, під час ротації секрету — старим і новим): приймайте запит, якщо збігся хоча б один. t завжди рівно один.
  • Ключ HMAC — ваш секрет повністю, разом із префіксом whsec_, як звичайний рядок (не base64).
Підписуються сирі байти тіла. Не парсіть і не серіалізуйте JSON перед перевіркою: json.dumps(json.loads(body)) чи JSON.stringify(req.body) змінюють байти, і підпис не зійдеться. Зверніть увагу: на відміну від «t.body», у підписаний рядок входить ще й X-Idempotency-Key.

Як перевіряти

  1. Розберіть заголовок на t і v1. Відхиліть запит, якщо t немає, їх кілька або це не лише цифри. Значення v1, що не є рівно 64 символами 0-9a-f, пропускайте — не порівнюйте їх (у Python hmac.compare_digest на не-ASCII рядку кидає виняток).
  2. Відхиліть запит, якщо t відрізняється від поточного часу більше ніж на 5 хвилин (захист від повтору перехопленого запиту).
  3. Обчисліть HMAC-SHA256 від t + "." + X-Idempotency-Key + "." + сире тіло вашим секретом.
  4. Порівняйте з кожним v1 функцією порівняння сталого часу (hmac.compare_digest, hash_equals, timingSafeEqual).
  5. Лише після цього довіряйте тілу. Невірний підпис — відповідайте 401.

Тестовий вектор

Перевірте свою реалізацію на цих даних — вона має отримати точно такий самий v1. Вектор обчислено тим самим кодом, яким ми підписуємо справжні запити. Перевірку часу на ньому вимкніть: t фіксований.

Секретwhsec_docsTestVectorOnlyNotARealSecret0123456789ABCDEF
t1791796543
X-Idempotency-Key3f1c9a52-7b0e-4d2a-9c41-8e5f0b6a2d17
Тіло{"event":"payment_link.opened","link_id":"txu2k8","opened_at":"2026-10-12T09:15:42Z","short_url":"https://fop.one/txu2k8","order_number":"ORD-1024"}
Підписаний рядок1791796543.3f1c9a52-7b0e-4d2a-9c41-8e5f0b6a2d17.{"event":"payment_link.opened","link_id":"txu2k8","opened_at":"2026-10-12T09:15:42Z","short_url":"https://fop.one/txu2k8","order_number":"ORD-1024"}
Очікуваний v1dcada7bfb585658c76c9764c66145cc808963fa7a33ea6cf37c4a996ea965b9d
X-Signaturet=1791796543,v1=dcada7bfb585658c76c9764c66145cc808963fa7a33ea6cf37c4a996ea965b9d

Перевірка підпису: приклади

Сире тіло: у FastAPI — await request.body(), в aiohttp — await request.read(), у Flask — request.get_data(), у PHP — file_get_contents('php://input'), в Express — express.raw().

Python
import hashlib
import hmac
import re
import time

TOLERANCE_SECONDS = 300
TIMESTAMP_FORMAT = re.compile(r"[0-9]{1,10}")
SIGNATURE_FORMAT = re.compile(r"[0-9a-f]{64}")


def verify_foppay_signature(raw_body: bytes, signature_header: str,
                            idempotency_key: str, secret: str,
                            tolerance: int = TOLERANCE_SECONDS) -> bool:
    """raw_body — сирі байти тіла запиту, без json.loads()/json.dumps()."""
    timestamps, signatures = [], []
    for item in signature_header.split(","):
        key, _, value = item.strip().partition("=")
        if key == "t":
            timestamps.append(value)
        elif key == "v1":
            signatures.append(value)

    # Рівно один t із цифр. v1 може бути кілька (під час ротації секрету).
    if len(timestamps) != 1 or not TIMESTAMP_FORMAT.fullmatch(timestamps[0]):
        return False
    timestamp = timestamps[0]
    if tolerance and abs(time.time() - int(timestamp)) > tolerance:
        return False

    signed = timestamp.encode() + b"." + idempotency_key.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return any(
        SIGNATURE_FORMAT.fullmatch(signature) and hmac.compare_digest(expected, signature)
        for signature in signatures
    )
PHP
<?php

function verifyFoppaySignature(
    string $rawBody,
    string $signatureHeader,
    string $idempotencyKey,
    string $secret,
    int $tolerance = 300,
): bool {
    $timestamps = [];
    $signatures = [];
    foreach (explode(',', $signatureHeader) as $item) {
        [$key, $value] = array_pad(explode('=', trim($item), 2), 2, '');
        if ($key === 't') {
            $timestamps[] = $value;
        } elseif ($key === 'v1') {
            $signatures[] = $value;
        }
    }

    // Рівно один t із цифр. v1 може бути кілька (під час ротації секрету).
    if (count($timestamps) !== 1 || ! preg_match('/^[0-9]{1,10}$/D', $timestamps[0])) {
        return false;
    }

    if ($tolerance > 0 && abs(time() - (int) $timestamps[0]) > $tolerance) {
        return false;
    }

    $expected = hash_hmac('sha256', $timestamps[0].'.'.$idempotencyKey.'.'.$rawBody, $secret);

    foreach ($signatures as $signature) {
        if (preg_match('/^[0-9a-f]{64}$/D', $signature) && hash_equals($expected, $signature)) {
            return true;
        }
    }

    return false;
}

// Сире тіло — саме php://input, а не json_encode(json_decode(...)).
$rawBody = file_get_contents('php://input');

$valid = verifyFoppaySignature(
    $rawBody,
    $_SERVER['HTTP_X_SIGNATURE'] ?? '',
    $_SERVER['HTTP_X_IDEMPOTENCY_KEY'] ?? '',
    (string) getenv('FOPPAY_WEBHOOK_SECRET'),
);

if (! $valid) {
    http_response_code(401);
    exit;
}

$event = json_decode($rawBody, true);
http_response_code(200);
Node.js
import crypto from 'node:crypto';

// rawBody — Buffer із сирим тілом запиту (не JSON.stringify(req.body)).
export function verifyFoppaySignature(rawBody, signatureHeader, idempotencyKey, secret, toleranceSeconds = 300) {
  const timestamps = [];
  const signatures = [];
  for (const item of String(signatureHeader).split(',')) {
    const [key, ...rest] = item.trim().split('=');
    if (key === 't') {
      timestamps.push(rest.join('='));
    } else if (key === 'v1') {
      signatures.push(rest.join('='));
    }
  }

  // Рівно один t із цифр. v1 може бути кілька (під час ротації секрету).
  if (timestamps.length !== 1 || !/^[0-9]{1,10}$/.test(timestamps[0])) {
    return false;
  }
  const t = timestamps[0];
  if (toleranceSeconds > 0 && Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) {
    return false;
  }

  const expected = Buffer.from(
    crypto.createHmac('sha256', secret).update(`${t}.${idempotencyKey}.`).update(rawBody).digest('hex'),
  );
  return signatures.some(
    (signature) => /^[0-9a-f]{64}$/.test(signature) && crypto.timingSafeEqual(expected, Buffer.from(signature)),
  );
}

// Express: express.raw() залишає тіло сирим Buffer.
// app.post('/foppay/webhook', express.raw({ type: 'application/json' }), (req, res) => {
//   const ok = verifyFoppaySignature(req.body, req.get('X-Signature'), req.get('X-Idempotency-Key'), process.env.FOPPAY_WEBHOOK_SECRET);
//   if (!ok) return res.sendStatus(401);
//   const event = JSON.parse(req.body.toString('utf8'));
//   res.sendStatus(200);
// });

Доставка і повтори

  • Успіх — будь-яка відповідь 2xx протягом 10 с (зʼєднання — до 3 с). Успіх визначає лише статус, але перші 4096 символів тіла відповіді ми зберігаємо в журналі доставок для діагностики (двійкову чи стиснуту відповідь — лише її розмір) — відповідайте порожнім тілом і не повертайте в ньому жодних даних.
  • Розмір відповіді — до 64 КБ. Довшу відповідь ми обриваємо, і спроба вважається невдалою. Стиснуті відповіді (Content-Encoding: gzip тощо) не розпаковуються.
  • Повтори. Інакше (таймаут, помилка мережі чи TLS, адреса не пройшла перевірку, будь-який інший статус, включно з 3xx) повторюємо не раніше ніж через 30 с, 5 хв, 30 хв, 2 год, 6 год: усього 6 спроб упродовж ≈ 8,6 год або трохи довше (повтори запускає планувальник раз на хвилину). Потім доставка позначається як вичерпана — її можна повторити вручну з кабінету.
  • Дублікати можливі (доставка at-least-once). Дедуплікуйте за X-Idempotency-Key: він однаковий для всіх спроб однієї події, зокрема для ручного повтору, і входить у підпис. Обробили ключ — на наступні запити з ним відповідайте 200 і нічого не робіть. Якщо цей ключ саме зараз обробляє паралельний запит, відповідайте не-2xx (наприклад, 409) — ми повторимо пізніше.
  • Відповідайте швидко. Довгу роботу (повідомлення в Telegram, запис у CRM) робіть у фоні або встигайте за кілька секунд.
  • Порядок доставки різних подій не гарантований.
  • Невідомі події (зокрема webhook.test) приймайте з відповіддю 2xx: список подій поповнюватиметься.

Підтвердження оплати

Зараз FopPay не підтверджує факт надходження коштів: оплата йде напряму з банку клієнта на ваш IBAN, і ми не маємо доступу до вашого рахунку. Подія payment_link.opened означає, що клієнт відкрив посилання, а не що він заплатив.

Як перевіряти оплату вже зараз:

  • Підключіть API свого банку до вашої системи: у monobank — особистий API з вебхуком на рух коштів, у ПриватБанку — «Автоклієнт» для бізнес-рахунків.
  • Зіставляйте надходження за сумою та номером замовлення в призначенні платежу. Для цього додавайте номер замовлення в title — саме він стає призначенням платежу.