Django и платёжки: рабочие приёмы приёма онлайн-оплаты
Как принять оплату в Django без задвоенных заказов и потерянных платежей: идемпотентность, вебхуки, проверка подписи и статусная модель заказа.

Платёж прошёл, деньги списались, а заказ в базе остался в статусе «ожидает оплаты». Или наоборот: пользователь дважды нажал кнопку, ушли два запроса — и вы отгрузили товар за одну оплату. Оба сценария рождаются из одного корня: приём оплаты в вебе — это асинхронный процесс с ненадёжной сетью, а не синхронный вызов функции. Ниже — приёмы, которые закрывают самые частые дыры в интеграции Django с платёжным провайдером (Stripe, ЮKassa, CloudPayments — механика у всех похожа). Всё проверялось на Django 5.x и PostgreSQL 16.
Почему прямой редирект «создали заказ → отправили на оплату → показали спасибо» ломается
Наивная схема выглядит так: пользователь оформляет заказ, вы создаёте платёж на стороне провайдера, редиректите его на форму оплаты, а после возврата на success-страницу помечаете заказ оплаченным. Проблема в том, что возврат пользователя на success-страницу — не факт оплаты. Пользователь может закрыть вкладку сразу после списания. Может, наоборот, руками открыть URL успеха, не заплатив. Браузер может потерять редирект на 3-D Secure.
Единственный надёжный сигнал об оплате — вебхук1 от провайдера на ваш сервер. Success-страница нужна только для UX: «спасибо, проверяем платёж». Реальный переход заказа в статус «оплачен» происходит по вебхуку.
Правило, экономящее недели отладки: пользователь никогда не сообщает вашему серверу об оплате. Об оплате сообщает только платёжная система, и только на серверный endpoint.
Статусная модель заказа
Прежде чем писать интеграцию, зафиксируйте состояния. Один булев флаг is_paid недостаточен: между «создан» и «оплачен» есть промежуточные и терминальные состояния, которые придётся различать при возвратах и разборе спорных платежей.
# models.py — Django 5.x
from django.db import models
class Order(models.Model):
class Status(models.TextChoices):
CREATED = "created", "Создан"
PENDING = "pending", "Ожидает оплаты"
PAID = "paid", "Оплачен"
FAILED = "failed", "Оплата не прошла"
REFUNDED = "refunded", "Возвращён"
status = models.CharField(
max_length=16, choices=Status.choices, default=Status.CREATED
)
amount = models.DecimalField(max_digits=12, decimal_places=2)
# id платежа на стороне провайдера — по нему сопоставляем вебхук
provider_payment_id = models.CharField(
max_length=128, blank=True, db_index=True
)
created_at = models.DateTimeField(auto_now_add=True)
Сумму храните в DecimalField, а не во FloatField: деньги и плавающая точка несовместимы. Провайдеры почти всегда принимают и возвращают сумму в минимальных единицах (копейки, центы) целым числом — конвертацию делайте в одном месте, чтобы не потерять два нуля.
Приём 1. Идемпотентность на создании платежа
Двойной клик, повторная отправка формы, ретрай на нестабильной сети — всё это порождает несколько запросов на создание платежа для одного заказа. Если каждый создаёт новый платёж у провайдера, вы получите дубли.
Большинство провайдеров поддерживают ключ идемпотентности: вы передаёте уникальный ключ, и повторный запрос с тем же ключом возвращает тот же платёж, а не создаёт новый. У ЮKassa это заголовок Idempotence-Key, у Stripe — параметр idempotency_key в SDK. Точное имя и формат сверьте в документации вашего провайдера — они различаются.
Ключ должен быть детерминированным для одного заказа и одной попытки. Простой рабочий вариант — привязать его к id заказа:
# services.py — псевдоимена метода провайдера намеренно обобщены
import uuid
def build_idempotency_key(order):
# Один заказ — один ключ на попытку оплаты.
# namespace фиксируем, чтобы ключ был воспроизводим.
return str(uuid.uuid5(uuid.NAMESPACE_OID, f"order-payment-{order.pk}"))
Конкретный вызов создания платежа зависит от SDK провайдера — не подставляю имя метода, чтобы вы не скопировали несуществующий. Смотрите раздел «Create a payment» в документации вашей платёжной системы и передавайте туда этот ключ.
Приём 2. Проверка подписи вебхука до всего остального
Endpoint вебхука публичный: его URL знает провайдер, но угадать его может кто угодно. Без проверки подлинности любой сможет отправить вам POST «заказ оплачен» и получить товар бесплатно.
Провайдеры подписывают тело вебхука. Проверять подпись нужно по сырому телу запроса — до парсинга JSON, потому что любая перекодировка меняет байты и ломает подпись. В Django сырое тело лежит в request.body.
# views.py — Django 5.x
import hmac
import hashlib
from django.conf import settings
from django.http import HttpResponse, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
@csrf_exempt # вебхук приходит без CSRF-токена
@require_POST
def payment_webhook(request):
signature = request.headers.get("X-Signature", "")
# ВАЖНО: считаем по сырым байтам request.body, не по распарсенному JSON
expected = hmac.new(
settings.PAYMENT_WEBHOOK_SECRET.encode(),
request.body,
hashlib.sha256,
).hexdigest()
# сравнение, устойчивое к атаке по времени
if not hmac.compare_digest(expected, signature):
return HttpResponseForbidden("bad signature")
# ... разбор события ниже
return HttpResponse(status=200)
Конкретный алгоритм подписи (HMAC-SHA256, имя заголовка, что именно подписывается — тело или тело плюс timestamp) у каждого провайдера свой. Пример выше — типовая схема HMAC; сверьте с разделом про верификацию вебхуков вашей платёжной системы. У Stripe, например, для этого есть готовая функция в SDK — используйте её вместо ручного HMAC, если работаете со Stripe.
Приём 3. Идемпотентность на обработке вебхука
Провайдер гарантирует доставку «хотя бы один раз», а не «ровно один раз». Один и тот же вебхук может прийти дважды: если ваш сервер ответил не 200 из-за таймаута, провайдер повторит. Значит, обработчик должен быть безопасен к повторам.
Два слоя защиты. Первый — таблица обработанных событий по их уникальному id от провайдера. Второй — атомарный переход статуса заказа с блокировкой строки, чтобы два параллельных вебхука не вошли в критическую секцию одновременно.
import json
from django.db import transaction, IntegrityError
from .models import Order, ProcessedWebhook
def handle_payment_succeeded(event_id, provider_payment_id):
try:
# уникальный индекс на event_id отсечёт повтор
with transaction.atomic():
ProcessedWebhook.objects.create(event_id=event_id)
except IntegrityError:
return # это событие уже обработано, выходим тихо
with transaction.atomic():
order = (
Order.objects
.select_for_update() # блокируем строку до конца транзакции
.get(provider_payment_id=provider_payment_id)
)
# переходим в PAID только из ожидания оплаты
if order.status == Order.Status.PENDING:
order.status = Order.Status.PAID
order.save(update_fields=["status"])
# тут же, но лучше через сигнал/задачу: отгрузка, письмо
Проверка if order.status == PENDING — не косметика. Она защищает от гонки, где вебхук «оплачен» и вебхук «возврат» приходят близко по времени: вы не переведёте возвращённый заказ обратно в оплаченный.
Приём 4. Всегда отвечайте провайдеру 200, если событие принято
Если вы уронили обработчик исключением и вернули 500, провайдер будет ретраить вебхук по нарастающему интервалу — иногда сутками. Логика такая: приняли и записали факт события — отвечаем 200 сразу, а тяжёлую работу (генерация чека, письмо, обновление склада) выносим в фоновую задачу. Провайдер не должен ждать вашу отправку email.
- Синхронно в обработчике: проверка подписи, дедупликация, смена статуса заказа. Быстро и в транзакции.
- Асинхронно (Celery, django-q, RQ): чеки, уведомления, интеграции со складом и CRM.
- Ответ 200 отдаём после успешной записи факта, до фоновых задач.
Сравнение способов подтвердить оплату
| Способ | Надёжность | Когда использовать |
|---|---|---|
| Success-редирект пользователя | Низкая: теряется при закрытии вкладки, подделывается руками | Только для UX-сообщения «проверяем платёж» |
| Вебхук с проверкой подписи | Высокая: серверный сигнал от провайдера | Основной механизм смены статуса заказа |
| Периодический опрос статуса (polling) | Средняя: нагружает API, есть задержка | Страховка, если вебхук не пришёл за N минут |
Рабочая связка: вебхук как основной путь плюс отложенная задача-страховка, которая через 15–30 минут опрашивает провайдера по заказам, застрявшим в PENDING. Вебхуки иногда теряются, и polling ловит эти хвосты.
Что ломается на практике
- Подпись не сходится. Почти всегда причина — считаете HMAC по распарсенному и заново сериализованному JSON, а не по
request.body. Либо мешает промежуточный прокси/CDN, меняющий тело. Логируйте несовпадения, но не логируйте секрет. - Задвоенная отгрузка. Нет дедупликации по event_id или нет
select_for_update— два параллельных вебхука проходят проверку статуса одновременно. - Заказ навечно в PENDING. Вебхук не дошёл, а страховочного polling нет. Пользователь заплатил, товар не отгружен.
- Секрет в репозитории.
PAYMENT_WEBHOOK_SECRETдолжен приходить из переменных окружения, а не лежать вsettings.pyв гите. - Тесты и прод перепутаны. Разные ключи и разные endpoint у тестового и боевого окружения провайдера — держите их в отдельных переменных.
Что настроить дальше
Первое — логирование каждого входящего вебхука с его id и результатом обработки: при спорном платеже это единственный способ доказать, что было. Второе — идемпотентные возвраты: логика возврата зеркальна приёму и тоже приходит вебхуком со своим статусом. Третье — мониторинг заказов, застрявших в PENDING дольше порога, с алертом. Возвраты и частичные возвраты — отдельная тема, здесь рассматривали только приём оплаты. Точные имена методов SDK, формат подписи и список типов событий берите из актуальной документации вашего провайдера, а не из статей: провайдеры меняют API чаще, чем выходит Django.
Проверка грамотности: Орфография, пунктуация, стиль — с объяснением каждой правки
Спросить за 10 ₽Источники: Django documentation: Database transactions (select_for_update, atomic), Django documentation: Request and response objects (request.body)
Частые вопросы
А если у меня Django 4.2, а не 5.x — код подойдёт?
Вебхук не приходит на локальную машину — как отлаживать?
Обязательно ли выносить обработку в Celery?
Почему нельзя доверять success-странице, куда вернулся пользователь?
Что если вебхук пришёл, а заказа с таким provider_payment_id ещё нет в базе?
Как не задвоить отгрузку при повторной доставке вебхука?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.