Интеграции с платежами криптовалют: идемпотентность, подписи и проверка вебхуков
На примере aiosend обсудим, как строить надёжную обработку событий оплаты: статусы, повторные уведомления и валидация входящих данных.
Содержание
Интеграции с платежами криптовалют: идемпотентность, подписи и проверка вебхуков
Платежи криптовалют — это не просто «запросить адрес и дождаться поступления». На практике интеграция превращается в систему обработки событий, которая должна выдерживать задержки сети, повторные уведомления, частичные отказы и, что особенно важно, валидацию источника. В итоге надежность складывается из нескольких инженерных решений: идемпотентности обработчиков, проверок подписи/подлинности, строгой проверки входящих данных, корректной модели статусов и журналирования.
Ниже разберем эти принципы на примере логики, которую часто используют при интеграциях платежных провайдеров. В качестве контекста возьмем aiosend — это удобная платформа/шаблон для отправки запросов и управления платежами, но ключевые идеи остаются общими для любых криптовалютных шлюзов. Если вы хотите вникнуть в практику работы с aiosend, можно начать с курса aiosend – для начинающих! — он помогает собрать базовую картину до того, как вы начнете проектировать обработку вебхуков «по-взрослому».
Как выглядит реальная обработка оплаты криптовалютами
События и статусы: почему нельзя ограничиться “paid=true”
В классических платежах (карты/банки) разработчики часто привыкают к простому пайплайну: «получили webhook → выставили paid=true». С криптовалютами всё сложнее:
- транзакция может быть в процессе подтверждений (например, 0/1/N подтверждений);
- провайдер может прислать несколько уведомлений по одной оплате;
- возможны повторные уведомления из‑за таймаутов или ретраев;
- может быть «отмена»/«возврат»/«неуспех» (в зависимости от провайдера и типа операции);
- клиентская часть может отображать статус раньше, чем он станет финальным.
Поэтому у системы должна быть модель статусов, которая переживает повторяемость и частичность событий. Обычно достаточно набора:
created— платеж создан, ожидает поступления.pending— обнаружено поступление/инициирован процесс, но нет достаточного числа подтверждений.confirmed— достаточно подтверждений, платеж считается финальным.failed/canceled— неуспех (или отмена).refunded— если поддерживается возврат.
Важно: обработчик вебхука не должен «переприсваивать» статус слепо. Он должен применять событие к текущему состоянию по правилам переходов (state machine), иначе вы рискуете получить «откат» или неконсистентность.
Повторяемость уведомлений: почему идемпотентность — базовое требование
Практика такова: вебхук может быть доставлен повторно. Причины банальны:
- провайдер не получил ответ
200 OKвовремя; - у провайдера ретраи при сетевых сбоях;
- вы временно недоступны;
- истек таймаут на вашей стороне, а провайдер повторил запрос.
Если ваш обработчик не идемпотентен, повтор может привести к:
- двойной выдаче товара,
- двойному списанию бонусов,
- нескольким «успешным» записям в журнале,
- росту счетчиков и расхождению сумм.
Отсюда инженерное правило: одинаковые события должны иметь один и тот же эффект.
Идемпотентность: как проектировать обработчик вебхуков
Идентификатор события: где взять ключ идемпотентности
Идемпотентность строят на «ключе события». Он должен быть стабильным и уникальным для конкретного уведомления. В криптоинтеграциях это часто:
webhook_id(если провайдер его присылает),event_id,payment_id+status+block_confirmations(комбинация),- хэш тела запроса (
sha256(body)) — как fallback, если нет явногоevent_id, - либо
invoice_id/order_id/payment_reference+status(но аккуратно: если статусов несколько, комбинация должна быть достаточно детальной).
Критично: если провайдер шлет повторно одно и то же событие, у вас ключ должен совпасть. Если провайдер шлет «эволюцию статуса» (pending → confirmed), то это разные события: их нужно обрабатывать как разные.
Два уровня защиты: база и транзакции
Надежная схема выглядит так:
- Получили webhook.
- Проверили подпись и схему данных.
- Достали ключ идемпотентности из payload.
- Попробовали «зарезервировать обработку» в БД атомарно.
- Если событие уже обработано — выходим без побочных эффектов.
- Если нет — выполняем переход статуса, начисления, выдачу товара и т.д. в транзакции.
Минимальная модель в БД:
- таблица
payments(с текущим статусом, суммой, order_id, etc.); - таблица
webhook_eventsилиidempotency_keys(с ключом идемпотентности, временем обработки и результатом).
Пример SQL-идей (концептуально):
UNIQUE(event_key)в таблицеwebhook_events;- обработка в транзакции.
Пример кода: идемпотентность с “INSERT … ON CONFLICT”
Допустим, мы пишем обработчик на Python с asyncio/fastapi и используем PostgreSQL. Логика такая: сначала создаем запись об обработке события; если она уже существует — событие повторилось.
import hashlib
from datetime import datetime, timezone
def event_key_from_payload(payload: dict) -> str:
# Вариант 1: если провайдер присылает event_id / webhook_id
if "event_id" in payload:
return f"event:{payload['event_id']}"
# Вариант 2: комбинация payment_id + status
if "payment_id" in payload and "status" in payload:
return f"payment:{payload['payment_id']}|status:{payload['status']}"
# Вариант 3: fallback на хэш тела (осторожно: зависит от неизменности порядка полей)
body_bytes = payload.get("_raw_body_bytes")
if body_bytes:
return "sha256:" + hashlib.sha256(body_bytes).hexdigest()
raise ValueError("Cannot build idempotency key")
Далее — обработчик:
from fastapi import Request, HTTPException
async def handle_webhook(request: Request, db):
raw_body = await request.body()
payload = await request.json()
# 1) build event_key
payload["_raw_body_bytes"] = raw_body # чтобы fallback на хэш работал
event_key = event_key_from_payload(payload)
# 2) idempotency insert
# Важно: это должно быть частью транзакции,
# чтобы не было гонок между несколькими инстансами.
async with db.transaction():
inserted = await db.execute_fetchval(
"""
INSERT INTO webhook_events(event_key, received_at)
VALUES($1, $2)
ON CONFLICT (event_key) DO NOTHING
RETURNING event_key
""",
event_key,
datetime.now(timezone.utc),
)
if inserted is None:
# Событие уже обрабатывали — не делаем побочек
return {"ok": True, "replayed": True}
# 3) валидируем payment_id, сумму, статус и делаем переход
payment_id = payload["payment_id"]
new_status = payload["status"]
updated = await db.execute_fetchval(
"""
UPDATE payments
SET status = $2, updated_at = NOW()
WHERE id = $1
AND status_transition_allowed(status, $2)
RETURNING id
""",
payment_id,
new_status,
)
if updated is None:
# Либо неверный payment_id, либо переход не разрешен.
# На практике можно логировать и считать это "ok",
# чтобы не заставлять провайдера ретраить бесконечно.
return {"ok": True, "ignored": True}
# 4) Побочные эффекты (в транзакции!)
await db.execute(
"""
INSERT INTO fulfillment(payment_id, created_at)
VALUES($1, NOW())
ON CONFLICT DO NOTHING
""",
payment_id,
)
return {"ok": True, "replayed": False}
Несколько нюансов:
status_transition_allowed— это идея. Реализовать можно:- через логику на приложении (проверить текущий статус, сравнить с допустимыми переходами);
- или через таблицу переходов в БД.
fulfillmentсON CONFLICTдополнительно защищает от дублей на уровне конкретного эффекта (например, выдача товара по(payment_id, type)).
Подписи и подлинность: как понять, что webhook пришел от провайдера
Почему проверка подписи важнее, чем “мы же только по URL принимаем”
Если обработчик принимает публичный HTTP endpoint, вы не защищены от:
- случайных запросов,
- сканеров,
- атакующего, который попытается отправить фейковый
confirmed.
Даже если у вас есть секретный токен в query-строке, его можно угадать/подсмотреть. Надежный подход: использовать криптографическую подпись и проверять её на стороне сервера.
Обычно провайдер делает так:
- подписывает содержимое (часто raw body или каноническое представление);
- присылает заголовок
X-Signature/X-Signature-Sha256/Authorizationи т.д.; - вы вычисляете HMAC/подпись на своей стороне и сравниваете.
Общая схема проверки HMAC
Псевдологика:
- Берем
raw_body(важно: не “пересериализованный” JSON изjson.loads, а именно байты как пришли). - Берем
secret/ключ провайдера из настроек. - Считаем HMAC по алгоритму (например,
sha256). - Сравниваем с подписью из заголовка постоянным временем (
hmac.compare_digest).
Пример на Python:
import hmac
import hashlib
from fastapi import Request, HTTPException
def verify_signature(raw_body: bytes, signature_header: str, secret: str) -> None:
# Пример: подпись — это hex-код HMAC SHA256
expected = hmac.new(
key=secret.encode("utf-8"),
msg=raw_body,
digestmod=hashlib.sha256
).hexdigest()
# постоянное время сравнения
if not hmac.compare_digest(expected, signature_header):
raise HTTPException(status_code=401, detail="Invalid signature")
async def webhook_endpoint(request: Request):
raw_body = await request.body()
payload = await request.json()
signature = request.headers.get("X-Signature")
if not signature:
raise HTTPException(status_code=400, detail="Missing signature")
verify_signature(raw_body, signature, secret="YOUR_WEBHOOK_SECRET")
# далее — идемпотентность и обновление статуса
return {"ok": True}
Подводные камни:
- Используйте raw_body, иначе вычисление HMAC может не совпасть из‑за различий в представлении JSON (пробелы, порядок полей, отсутствие/наличие
\u-экранирований). - Обязательно делайте сравнение compare_digest (а не
==). - Не допускайте “обхода” подписи в debug-моде, если endpoint доступен извне.
Вариант с подписью по timestamp: защита от replay атак
Некоторые провайдеры добавляют:
- заголовок
X-Timestamp, - подпись включает timestamp,
- вы проверяете, что timestamp не “старый” (например, не старше 5 минут).
Это не заменяет идемпотентность, но добавляет дополнительный уровень защиты.
Если провайдер присылает timestamp, реализуйте:
- проверку формата времени,
- проверку окна (
now - timestamp <= max_skew), - и включение timestamp в подпись так, как указано в документации.
Проверка данных: как валидировать входящий payload без самоуничтожения
Что именно валидировать
Даже при правильной подписи вам нужно защититься от некорректных данных. Минимальный набор:
- обязательные поля присутствуют;
- типы корректны (строка/число);
- суммы и валюты в ожидаемом формате;
payment_id/order_idсуществуют в вашей БД;- статус соответствует допустимому набору;
- поля, зависящие друг от друга (например, confirmed требует confirmations >= N).
Дополнительно стоит проверить:
- что
amountиз webhook совпадает с суммой в вашей записи (или храните “расхождение”); - что
transaction_hashимеет корректный формат (если провайдер присылает).
Строгая схема валидации: Pydantic/JSON Schema
Практический подход — объявить схему входных данных и отклонять всё, что не соответствует.
Пример схемы Pydantic:
from pydantic import BaseModel, Field
from typing import Literal
class CryptoPaymentWebhook(BaseModel):
payment_id: str = Field(min_length=1)
status: Literal["created", "pending", "confirmed", "failed", "canceled"]
amount: str # часто суммы приходят строкой, чтобы не терять точность
currency: str
tx_hash: str | None = None
confirmations: int | None = None
event_id: str | None = None
Дальше в обработчике:
async def parse_and_validate(raw_body: bytes, payload: dict):
try:
model = CryptoPaymentWebhook.model_validate(payload)
return model
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid payload: {e}")
Важно: если сумма приходит строкой — не парсите через float. Для криптоплатежей лучше хранить:
- целочисленно в минимальных единицах (satoshi/wei),
- либо BigDecimal/decimal с фиксированной точностью.
Проверка согласованности: статус, сумма, подтверждения
Типичная ошибка: принять статус confirmed без проверки, что он соответствует вашему ожиданию (например, что заказ еще не закрыт или не было earlier failed).
Правильная схема:
- читаем текущий
payments.statusиз БД; - сверяем допустимость перехода;
- при
confirmedпроверяем, что сумма совпадает (или документируем поведение при расхождениях); - при
pendingдопускаем обновления (pending→pending может приходить несколько раз с разными confirmations).
Модель статусов и “правила переходов”: как не перепутать pending и confirmed
State machine вместо “если статус == confirmed — выдать”
Минимально достаточная логика:
- если текущий статус уже
confirmed, игнорируемpending(иначе будет дергание); - если текущий
failed, игнорируемconfirmed; confirmedимеет более высокий приоритет, чемpending;- у
canceledиfailedобычно есть приоритет выше, чемpending(зависит от провайдера).
Практический способ:
- Определить порядок приоритетов:
created(0),pending(1),confirmed(2),failed/canceled(3) — если считаете, что это финальные “отрицательные” состояния.
- Для входящего статуса обновлять только если приоритет выше или переход допустим.
Пример в коде:
STATUS_PRIORITY = {
"created": 0,
"pending": 1,
"confirmed": 2,
"failed": 3,
"canceled": 3,
}
def can_apply(current: str, incoming: str) -> bool:
# финальные состояния обычно нельзя "улучшать" назад
return STATUS_PRIORITY.get(incoming, -1) >= STATUS_PRIORITY.get(current, -1)
Это проще, чем пытаться описать все переходы вручную — но всё равно важно аккуратно настроить приоритеты под вашу предметную область.
Проверка вебхуков: как отвечать провайдеру и что логировать
HTTP-коды: “200 всегда” может быть плохой идеей
Провайдеры обычно считают:
200/204— webhook обработан успешно;4xx— ошибка данных/подписи, нужно не ретраить или ретраить ограниченно;5xx— временная ошибка, следует ретраить.
Рекомендация:
- Если подпись неверна или payload невалиден — отвечайте
4xxи логируйте причину. - Если идемпотентность сработала (событие повторное) — можно отвечать
200с флагомreplayed. - Если база недоступна — отвечайте
5xx, чтобы провайдер мог повторить.
Но есть нюанс: некоторые провайдеры ретраят агрессивно. Поэтому лучше, чтобы “ошибка консистентности” (например, переход статуса неразрешен) не вызывала бесконечные ретраи. В таких случаях можно возвращать 200 и логировать, либо возвращать специфичный 4xx, если провайдер поддерживает “не ретраить”. Это зависит от их API — ориентируйтесь на документацию.
Комментарии
Пока нет комментариев