FP FopPay

Посібник

Telegram-бот з FopPay

Бот створює платіжне посилання з QR НБУ, надсилає його клієнту, а коли клієнт відкриває посилання, FopPay надсилає вебхук — і бот повідомляє менеджера. Повний довідник — на сторінці API і вебхуки.

  1. 1

    Бот викликає POST /api/v1/invoices і отримує short_url та link_id.

  2. 2

    Бот зберігає звʼязок link_id → замовлення і надсилає клієнту short_url.

  3. 3

    Клієнт відкриває посилання й оплачує у своєму банку. Гроші йдуть напряму на IBAN ФОП.

  4. 4

    FopPay надсилає payment_link.opened на HTTPS-адресу вашого сервера. Бот перевіряє підпис, знаходить замовлення за link_id і пише менеджеру.

Що підготувати

  • Ключ API — кабінет FopPay → «API ключі».
  • Код рахунку account_code_6 — з кабінету або через GET /api/v1/fops.
  • Telegram ID менеджерів, яким дозволено створювати посилання. Ключ API дає право виставляти рахунки від вашого імені, тож команди, що створюють посилання, мають бути доступні лише їм.
  • Публічна HTTPS-адреса на порту 443 для вебхуків. Бот може працювати через long polling, але вебхуки FopPay приходять HTTP-запитом, тож потрібен вебсервер. Найпростіше — невеликий aiohttp-сервер у тому ж процесі, що й бот, за nginx або Caddy з сертифікатом.
  • Приймач у кабінеті → «Вебхуки»: вкажіть адресу (наприклад, https://bot.example.com/foppay/webhook), позначте payment_link.opened і збережіть секрет whsec_….
Приймач підключаєте самостійно. Адреса — лише https:// на порту 443, з дійсним сертифікатом і доменом, що вказує на публічні IP-адреси; перенаправлення не виконуються (усі вимоги). Після збереження натисніть у кабінеті «Надіслати тестову подію»: бот має відповісти 200 на webhook.test — код нижче так і робить для будь-якої події з правильним підписом.

1. Створити посилання

  • amount — рядком із крапкою: "250.00".
  • Додайте номер замовлення в title — це призначення платежу, його видно у виписці банку.
  • Новий X-Idempotency-Key (UUID) на кожне посилання. Якщо запит обірвався — повторіть з тим самим ключем і тим самим тілом, другого посилання не буде.
  • У боті для покупців беріть суму й номер замовлення з вашої БД, а не з тексту повідомлення.
  • Ліміт — 5 запитів на хвилину для одного ключа з однієї IP-адреси. На 429 дочекайтеся Retry-After.

2. Надіслати клієнту

  • Надсилайте short_url: вебхук спрацьовує на перше відкриття саме короткого посилання. Відкриття long_url вебхук не запускає.
  • Прев'ю, яке Telegram будує для посилання в чаті, ми не рахуємо відкриттям — вебхук не прийде лише через те, що бот надіслав повідомлення.
  • Збережіть link_id разом із замовленням — за ним ви знайдете замовлення, коли прийде вебхук. order_number не гарантовано унікальний.

3. Прийняти вебхук

  • Читайте сирі байти тіла (await request.read()) і перевіряйте підпис до json.loads.
  • Підпис — HMAC-SHA256 від {t}.{X-Idempotency-Key}.{сире тіло} секретом whsec_… повністю. Деталі й тестовий вектор — тут.
  • Дедуплікуйте за X-Idempotency-Key: повторна доставка тієї самої події приходить з тим самим ключем. Позначайте ключ обробленим лише після успішної дії, а на паралельний дубль, поки перший запит ще обробляється, відповідайте 409 — FopPay повторить пізніше.
  • Відповідайте 200 протягом 10 с. Якщо не встигли або відповіли помилкою, FopPay повторить не раніше ніж через 30 с, 5 хв, 30 хв, 2 год, 6 год (усього 6 спроб).
  • На події, яких бот не знає (зокрема тестову webhook.test), після перевірки підпису теж відповідайте 200 і нічого не робіть.

Повний код бота

Python 3.10+, aiogram 3 і aiohttp. Команда /pay 250.00 ORD-1024 (лише для користувачів з ALLOWED_USER_IDS) створює посилання, вебхук сповіщає менеджера. Для простоти дані зберігаються в памʼяті — у продакшні використовуйте БД.

bot.py
"""
Telegram-бот + FopPay: менеджер командою /pay створює посилання на оплату й надсилає його в чат,
а вебхук payment_link.opened повідомляє менеджера, що клієнт відкрив посилання.

    pip install aiogram aiohttp

Змінні середовища:
    BOT_TOKEN                 — токен бота від @BotFather
    ALLOWED_USER_IDS          — Telegram ID менеджерів через кому; лише вони можуть викликати /pay
    MANAGER_CHAT_ID           — чат, куди надсилати сповіщення
    FOPPAY_API_SECRET         — ключ з кабінету FopPay → «API ключі»
    FOPPAY_BANK_ACCOUNT_CODE  — account_code_6 рахунку (GET /api/v1/fops)
    FOPPAY_WEBHOOK_SECRET     — секрет whsec_… з кабінету FopPay → «Вебхуки»
"""
import asyncio
import hashlib
import hmac
import json
import os
import re
import time
import uuid

from aiogram import Bot, Dispatcher
from aiogram.filters import Command, CommandObject
from aiogram.types import Message
from aiohttp import ClientSession, ClientTimeout, web

BOT_TOKEN = os.environ["BOT_TOKEN"]
ALLOWED_USER_IDS = {int(user_id) for user_id in os.environ["ALLOWED_USER_IDS"].split(",") if user_id.strip()}
MANAGER_CHAT_ID = int(os.environ["MANAGER_CHAT_ID"])
FOPPAY_API_SECRET = os.environ["FOPPAY_API_SECRET"]
FOPPAY_BANK_ACCOUNT_CODE = os.environ["FOPPAY_BANK_ACCOUNT_CODE"]
FOPPAY_WEBHOOK_SECRET = os.environ["FOPPAY_WEBHOOK_SECRET"]
FOPPAY_INVOICES_URL = "https://foppay.com.ua/api/v1/invoices"

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

bot = Bot(BOT_TOKEN)
dp = Dispatcher()

# Для прикладу — у памʼяті. У продакшні — таблиця в БД
# (link_id → замовлення, і UNIQUE на оброблені X-Idempotency-Key).
orders_by_link_id: dict[str, dict] = {}
processed_keys: set[str] = set()
in_flight_keys: set[str] = set()


async def create_payment_link(amount: str, order_number: str) -> dict:
    payload = {
        "bank_account_code": FOPPAY_BANK_ACCOUNT_CODE,
        # title потрапляє в призначення платежу — номер замовлення в ньому
        # допоможе знайти оплату у виписці банку.
        "title": f"Оплата замовлення {order_number}",
        "amount": amount,  # рядок із крапкою: "250.00"
        "invoice_number": order_number,
        "ttl_days": 3,
    }
    headers = {
        "X-API-Secret": FOPPAY_API_SECRET,
        "X-Idempotency-Key": str(uuid.uuid4()),
    }
    async with ClientSession(timeout=ClientTimeout(total=15)) as session:
        async with session.post(FOPPAY_INVOICES_URL, json=payload, headers=headers) as response:
            data = await response.json()
            if response.status != 201:
                raise RuntimeError(f"{data.get('code')}: {data.get('message')}")
            return data


@dp.message(Command("pay"))
async def pay_command(message: Message, command: CommandObject) -> None:
    # Посилання створюється ключем власника — команда лише для менеджерів.
    # У боті для покупців беріть суму із замовлення у вашій БД, а не з тексту повідомлення.
    if message.from_user is None or message.from_user.id not in ALLOWED_USER_IDS:
        await message.answer("Немає доступу.")
        return

    # /pay 250.00 ORD-1024
    args = (command.args or "").split()
    if len(args) != 2:
        await message.answer("Формат: /pay 250.00 ORD-1024")
        return

    amount, order_number = args
    link = await create_payment_link(amount, order_number)
    orders_by_link_id[link["link_id"]] = {
        "order_number": order_number,
        "chat_id": message.chat.id,
    }
    # Надсилайте саме short_url: вебхук спрацьовує на відкриття короткого посилання.
    await message.answer(f"Посилання на оплату: {link['short_url']}")


def verify_foppay_signature(raw_body: bytes, signature_header: str, idempotency_key: str) -> bool:
    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)

    if len(timestamps) != 1 or not TIMESTAMP_FORMAT.fullmatch(timestamps[0]):
        return False
    timestamp = timestamps[0]
    if abs(time.time() - int(timestamp)) > 300:
        return False

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


async def foppay_webhook(request: web.Request) -> web.Response:
    raw_body = await request.read()  # сирі байти — саме їх підписано
    idempotency_key = request.headers.get("X-Idempotency-Key", "")
    if not verify_foppay_signature(raw_body, request.headers.get("X-Signature", ""), idempotency_key):
        return web.Response(status=401)

    if idempotency_key in processed_keys:
        return web.Response(status=200)  # повтор уже обробленої події
    if idempotency_key in in_flight_keys:
        # Ту саму подію саме зараз обробляє паралельний запит.
        # Не 2xx — FopPay повторить пізніше, коли ключ уже буде оброблено.
        return web.Response(status=409)

    in_flight_keys.add(idempotency_key)
    try:
        event = json.loads(raw_body)
        if event.get("event") == "payment_link.opened":
            order = orders_by_link_id.get(event["link_id"])
            if order is not None:
                # Якщо Telegram не відповість, виняток дасть 500, ключ не позначиться
                # обробленим, і FopPay повторить доставку. Якщо ж Telegram прийняв
                # повідомлення, а процес упав до наступного рядка, повтор надішле його
                # ще раз: «рівно один раз» для зовнішнього повідомлення не гарантується.
                await bot.send_message(
                    MANAGER_CHAT_ID,
                    f"Клієнт відкрив посилання на оплату замовлення {order['order_number']}",
                )
        processed_keys.add(idempotency_key)
    finally:
        in_flight_keys.discard(idempotency_key)

    return web.Response(status=200)


async def main() -> None:
    app = web.Application()
    app.router.add_post("/foppay/webhook", foppay_webhook)
    runner = web.AppRunner(app)
    await runner.setup()
    # Слухаємо локально; назовні — nginx/Caddy з HTTPS на порту 443.
    await web.TCPSite(runner, "127.0.0.1", 8080).start()
    try:
        await dp.start_polling(bot)
    finally:
        await runner.cleanup()


if __name__ == "__main__":
    asyncio.run(main())

Приклад nginx перед ботом:

nginx
location /foppay/webhook {
    proxy_pass http://127.0.0.1:8080;
    proxy_set_header Host $host;
}

Чек-лист перед запуском

  • Ваша функція перевірки дає правильний v1 на тестовому векторі.
  • Підпис перевіряється по сирих байтах, а не по перезібраному JSON.
  • Невірний підпис або застарілий t → 401.
  • Повтор з тим самим X-Idempotency-Key, зокрема одночасний, не дублює повідомлення.
  • Підпис неправильного формату (не 64 hex-символи, кілька t) → 401, а не помилка 500.
  • Команди, що створюють посилання, доступні лише дозволеним користувачам.
  • Клієнту йде short_url, link_id збережено разом із замовленням.
  • Секрети (FOPPAY_API_SECRET, whsec_…) — у змінних середовища, не в коді й не в репозиторії.
  • «Надіслати тестову подію» в кабінеті → «Вебхуки» дає в журналі доставок статус «Доставлено».

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

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

Щоб бот бачив оплату вже зараз, підключіть API свого банку (monobank — особистий API з вебхуком на рух коштів, ПриватБанк — «Автоклієнт») і зіставляйте надходження за сумою та номером замовлення в призначенні платежу (title).