Приём платежей в Django: интеграция без дыр и двойных списаний
Разбираем, почему платёж нельзя завершать в редиректе с формы оплаты, зачем нужен вебхук и идемпотентность, и как не получить оплаченный, но не выданный заказ.

Пользователь нажал «Оплатить», деньги списались, но заказ в базе остался в статусе «ожидает оплаты». Или наоборот: заказ помечен оплаченным, а платёж на самом деле отклонён — пользователь просто вернулся на success-страницу по кнопке «Назад». Оба сценария — следствие одной ошибки: попытки завершить платёж на редиректе с формы оплаты, а не по серверному уведомлению от платёжной системы.
Здесь разбираем архитектуру приёма платежей в Django на примере интеграции через redirect + webhook (так работают ЮKassa, Stripe Checkout, CloudPayments и большинство агрегаторов). Угол статьи — надёжность денежного контура: идемпотентность, проверка подписи, гонки статусов. Конкретный API одного провайдера не разбираем — точные имена методов и полей смотрите в документации вашего, они различаются.
Почему редирект — не источник истины
Типовой сценарий выглядит так: вы создаёте платёж на стороне провайдера, отправляете пользователя на его форму, а после оплаты он возвращается на ваш return_url. Соблазн — прямо в обработчике этого URL пометить заказ оплаченным. Так делать нельзя.
- Пользователь может закрыть вкладку до редиректа. Деньги списаны — заказ не обновлён.
- На
return_urlможно зайти вручную, подставивorder_idв адрес. Заказ станет «оплаченным» без единого рубля. - Редирект приходит из браузера пользователя — этим данным нельзя доверять как факту оплаты.
Источник истины о платеже — только серверный вебхук от провайдера, проверенный по подписи. Редирект нужен исключительно для того, чтобы показать человеку «спасибо» или «что-то пошло не так».
Правильная схема
- Создаёте объект
Paymentв своей базе со статусом pending и уникальным ключом. - Запрашиваете у провайдера создание платежа, сохраняете его внешний ID.
- Редиректите пользователя на форму оплаты.
- Провайдер присылает вебхук на ваш эндпоинт — вы проверяете подпись и меняете статус.
return_urlпросто показывает текущий статус заказа из базы, ничего не меняя.
Модель платежа отдельно от заказа
Частая ошибка новичков — хранить статус оплаты полем прямо в Order. Тогда при повторной попытке оплаты, возврате или частичном платеже история теряется. Заведите отдельную модель.
# models.py — Django 5.x
import uuid
from django.db import models
class Payment(models.Model):
class Status(models.TextChoices):
PENDING = "pending", "Ожидает оплаты"
SUCCEEDED = "succeeded", "Оплачен"
CANCELED = "canceled", "Отменён"
# Внутренний идентификатор, который передаём провайдеру
# и получаем обратно в вебхуке — по нему находим платёж
idempotency_key = models.UUIDField(default=uuid.uuid4, unique=True)
# ID платежа на стороне провайдера
external_id = models.CharField(max_length=255, blank=True, db_index=True)
order = models.ForeignKey("shop.Order", on_delete=models.PROTECT)
amount = models.DecimalField(max_digits=12, decimal_places=2)
status = models.CharField(
max_length=20, choices=Status.choices, default=Status.PENDING
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
Сумму храните в DecimalField, а не во Float: с плавающей точкой 0.1 + 0.2 не равно 0.3, и на копейках это вылезет в отчётности.
Идемпотентность: чтобы не списать дважды
Вебхук может прийти дважды. Провайдер повторяет доставку, если ваш сервер не ответил вовремя или отдал не 200. Пользователь может дважды нажать «Оплатить». Без защиты вы создадите два платежа или дважды выдадите товар.
Две линии обороны:
- Идемпотентный ключ при создании платежа. Многие провайдеры принимают заголовок вроде
Idempotence-Key— при повторном запросе с тем же ключом они вернут тот же платёж, а не создадут новый. Точное имя заголовка уточните в документации своего провайдера. - Проверка статуса перед обработкой вебхука. Если платёж уже в статусе succeeded, повторный вебхук просто отдаёт 200 и ничего не делает.
Обработчик вебхука
Ключевой момент — атомарность. Смена статуса платежа и выдача заказа должны быть в одной транзакции, а строка платежа — заблокирована на время обработки, иначе два параллельных вебхука пройдут проверку одновременно.
# views.py — Django 5.x
import json
from django.db import transaction
from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from .models import Payment
from .signature import verify_signature # ваша проверка подписи
@csrf_exempt # вебхук приходит не из вашей формы, CSRF-токена нет
@require_POST
def payment_webhook(request):
# 1. Проверяем подпись до любого разбора данных
if not verify_signature(request):
return HttpResponseBadRequest("bad signature")
payload = json.loads(request.body)
external_id = payload["object"]["id"] # имя поля зависит от провайдера
new_status = payload["object"]["status"]
with transaction.atomic():
# select_for_update блокирует строку до конца транзакции
try:
payment = Payment.objects.select_for_update().get(
external_id=external_id
)
except Payment.DoesNotExist:
# Платёж не наш — отвечаем 200, чтобы провайдер не долбил повторами
return HttpResponse(status=200)
# Уже обработан — идемпотентный выход
if payment.status == Payment.Status.SUCCEEDED:
return HttpResponse(status=200)
if new_status == "succeeded":
payment.status = Payment.Status.SUCCEEDED
payment.save(update_fields=["status", "updated_at"])
_fulfill_order(payment.order) # выдача заказа
return HttpResponse(status=200)
Провайдер считает доставку успешной по коду 200. Если внутри обработчика упало исключение — отдайте не-2xx, чтобы вебхук повторился позже. Но не выдавайте заказ до фиксации транзакции.
Проверка подписи — не опциональна
Эндпоинт вебхука открыт в интернет и не защищён CSRF. Любой может отправить туда POST с телом «платёж успешен». Единственная защита — подпись. Провайдер подписывает тело запроса секретом, известным только вам двоим.
Механизмы различаются: у одних это HMAC-SHA256 от сырого тела в заголовке, у других — проверка IP из белого списка плюс подпись, у третьих — запрос статуса платежа обратно по API. Не берите тело как request.POST — считайте request.body, потому что подпись рассчитывается по байтам, а не по разобранному словарю. Точный алгоритм и имя заголовка — в документации провайдера.
Гонки и граничные случаи
| Ситуация | Что ломается без защиты | Решение |
|---|---|---|
| Вебхук пришёл дважды | Двойная выдача заказа | Проверка статуса + select_for_update |
| Вебхук пришёл раньше редиректа | Пользователь видит «не оплачено» | return_url читает статус из базы, а не решает сам |
| Пользователь ушёл, не оплатив | Платёж навсегда pending | Фоновая задача: опрос статуса или отмена по таймауту |
| Сумма в вебхуке не совпала | Оплачено меньше, заказ выдан целиком | Сверять amount из вебхука с Payment.amount |
| Тестовые вебхуки в проде | Ложные заказы | Разные ключи и эндпоинты для sandbox и прода |
Отдельно про висящие pending. Пользователь может закрыть форму оплаты — вебхук об успехе не придёт никогда, но и об отмене тоже. Такие платежи нужно добивать самому: периодической задачей запрашивать у провайдера актуальный статус или отменять по таймауту. Инвалидация корзины и возвраты — отдельная тема, здесь не разворачиваем.
Что делать дальше
Соберите контур в таком порядке: модель Payment, создание платежа с идемпотентным ключом, вебхук с проверкой подписи и блокировкой строки, return_url только для отображения. После — заведите фоновую задачу (Celery или django-q) на добивание pending-платежей и логируйте каждый вебхук в отдельную таблицу с сырым телом: при разборе спора с провайдером это спасёт. Проверяйте всё в песочнице провайдера до включения боевых ключей, а перед релизом прогоните повторную доставку одного и того же вебхука — именно на ней ломаются интеграции чаще всего.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: Django: Database transactions (transaction.atomic, select_for_update), Django: CSRF protection and exempting views
Частые вопросы
Можно ли обновлять статус заказа прямо на success-странице после оплаты?
У меня другой провайдер, а не тот, что в примерах. Код подойдёт?
Что будет, если вебхук не дойдёт из-за упавшего сервера?
Зачем select_for_update, если есть проверка статуса?
Почему нельзя хранить сумму во FloatField?
Как обрабатывать платежи, которые навсегда остались в pending?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.