FopPay API v1
API і вебхуки
API дозволяє вашій CRM, сайту чи боту створювати платіжні посилання з QR НБУ на рахунок вашого ФОП, а вебхуки — дізнаватися, коли клієнт відкрив посилання. Гроші йдуть напряму на ваш IBAN: FopPay не проводить платіж і не має доступу до рахунку.
| Базова адреса | https://foppay.com.ua/api/v1 |
|---|---|
| Формат | JSON, UTF-8, лише HTTPS |
| Ключ API | Кабінет → «API ключі» |
| Вебхуки | Кабінет → «Вебхуки» |
Швидкий старт
- Створіть ключ у кабінеті → «API ключі». Секрет показується один раз — збережіть його.
- Дізнайтеся код рахунку:
GET /api/v1/fops→account_code_6. - Створюйте посилання:
POST /api/v1/invoices→ надсилайте клієнтуshort_url. - За потреби підключіть вебхук і отримуйте
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 -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"}'
{
"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"
}
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 'https://foppay.com.ua/api/v1/fops' \
-H 'X-API-Secret: fp_<prefix>_<body>'
{
"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 на ваш сервер, коли стається подія. Приймач підключаєте самостійно, без узгодження з нами — у кабінеті → «Вебхуки»:
- Вкажіть URL приймача, наприклад
https://bot.example.com/foppay/webhook(вимоги — нижче). - Позначте події, які хочете отримувати.
- Збережіть секрет
whsec_…— він показується один раз. Ним ви перевіряєте підпис кожного запиту. - Натисніть «Надіслати тестову подію»: на ваш приймач прийде підписана подія
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 (після перевірки підпису) і нічого не робіть.
Так тестова подія в журналі буде «Доставлено», а нові типи подій, які ми додаватимемо, не повторюватимуться даремно.
payment_link.opened — Перше відкриття посилання клієнтом
Надсилається один раз, коли клієнт уперше відкриває коротке посилання на оплату.
Подія надсилається один раз на посилання — при першому відкритті клієнтом короткого посилання (short_url). Не надсилається, якщо:
- клієнт відкрив
long_urlабо ви самі переглядаєте посилання з кабінету; - посилання «розгорнув» у прев'ю месенджер чи соцмережа (Telegram, Viber, Facebook тощо) — такі запити ми відфільтровуємо;
- клієнт відсканував друкований QR;
- це постійне (багаторазове) посилання.
| Поле | Опис |
|---|---|
event | Завжди «payment_link.opened». |
link_id | Ідентифікатор посилання — той самий link_id, що повертає POST /api/v1/invoices. Зіставляйте рахунок саме за ним. |
opened_at | Час першого відкриття клієнтом, UTC, ISO 8601 (наприклад, 2026-10-12T09:15:42Z). |
short_url | Коротке посилання, яке відкрив клієнт. |
order_number | Ваш invoice_number з запиту на створення. Не гарантовано унікальний — для зіставлення використовуйте link_id. |
{"event":"payment_link.opened","link_id":"txu2k8","opened_at":"2026-10-12T09:15:42Z","short_url":"https://fop.one/txu2k8","order_number":"ORD-1024"}
POST /your/webhook/path HTTP/1.1
Content-Type: application/json
User-Agent: FopPay-Webhook/1
X-Foppay-Event: payment_link.opened
X-Idempotency-Key: 3f1c9a52-7b0e-4d2a-9c41-8e5f0b6a2d17
X-Signature: t=1791796543,v1=<64 hex-символи>
Тіло — компактний JSON (без пробілів, кирилиця й «/» не екрануються). Порядок ключів не гарантований — не покладайтеся на нього.
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.dumps(json.loads(body)) чи JSON.stringify(req.body) змінюють байти, і підпис не зійдеться.
Зверніть увагу: на відміну від «t.body», у підписаний рядок входить ще й X-Idempotency-Key.
Як перевіряти
- Розберіть заголовок на
tіv1. Відхиліть запит, якщоtнемає, їх кілька або це не лише цифри. Значенняv1, що не є рівно 64 символами0-9a-f, пропускайте — не порівнюйте їх (у Pythonhmac.compare_digestна не-ASCII рядку кидає виняток). - Відхиліть запит, якщо
tвідрізняється від поточного часу більше ніж на 5 хвилин (захист від повтору перехопленого запиту). - Обчисліть HMAC-SHA256 від
t + "." + X-Idempotency-Key + "." + сире тіловашим секретом. - Порівняйте з кожним
v1функцією порівняння сталого часу (hmac.compare_digest,hash_equals,timingSafeEqual). - Лише після цього довіряйте тілу. Невірний підпис — відповідайте
401.
Тестовий вектор
Перевірте свою реалізацію на цих даних — вона має отримати точно такий самий v1. Вектор обчислено тим самим кодом, яким ми підписуємо справжні запити. Перевірку часу на ньому вимкніть: t фіксований.
| Секрет | whsec_docsTestVectorOnlyNotARealSecret0123456789ABCDEF |
|---|---|
| t | 1791796543 |
| X-Idempotency-Key | 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"} |
| Підписаний рядок | 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"} |
| Очікуваний v1 | dcada7bfb585658c76c9764c66145cc808963fa7a33ea6cf37c4a996ea965b9d |
| X-Signature | t=1791796543,v1=dcada7bfb585658c76c9764c66145cc808963fa7a33ea6cf37c4a996ea965b9d |
Перевірка підпису: приклади
Сире тіло: у FastAPI — await request.body(), в aiohttp — await request.read(),
у Flask — request.get_data(), у PHP — file_get_contents('php://input'),
в Express — express.raw().
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
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);
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— саме він стає призначенням платежу.