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

Интеграция приёма платежей в Django-проекте — это не форма с полем «номер карты» и кнопкой «Оплатить». В боевой связке форма живёт на стороне платёжного провайдера (ЮKassa, CloudPayments, Stripe), а ваш сервер обменивается с ним данными по API и принимает уведомления о статусе. Если подготовить окружение заранее, рабочий сценарий «создать платёж — увести клиента на оплату — принять вебхук — отметить заказ оплаченным» собирается за вечер. Если не подготовить — вы упрётесь в отсутствие HTTPS на пятом шаге и будете переделывать половину.
Эта статья — про то, что должно быть готово до первой строки кода интеграции: доступы, инфраструктура, представление платежа в модели и понимание того, где Django вам не помощник, а где мешает. Про сам код виджета конкретного провайдера — отдельный разговор, здесь фиксируем каркас, общий для любого.
Что нужно иметь до начала
Прежде чем ставить SDK или писать вьюху, проверьте, что у вас есть всё из списка. Каждый пункт — источник грабель, если про него вспомнить поздно.
- HTTPS на домене, включая локальную разработку. Ни один платёжный провайдер не пришлёт вебхук на
http://. Для локали понадобится туннель (ngrok, cloudflared) или самоподписанный сертификат. - Аккаунт продавца и тестовые ключи. У большинства провайдеров есть sandbox-режим с отдельной парой ключей. Боевые ключи не трогайте, пока сценарий не отлажен на тестовых.
- Публичный URL для вебхуков. Провайдер должен достучаться до вашего эндпоинта уведомлений извне. На этапе разработки — туннель, в проде — реальный домен.
- Django 4.2 LTS или 5.x и понимание, что часть провайдерских SDK тянет свои зависимости — проверьте совместимость с вашей версией Python.
- Секреты в переменных окружения, а не в
settings.py. Ключ API и секрет вебхука не должны попасть в репозиторий.
Отдельно про требования к безопасности: если вы собираете номер карты у себя на форме и передаёте его дальше, вы попадаете под PCI DSS* в полном объёме — это дорого и долго. Поэтому в 99% случаев форму карты показывает провайдер (redirect или его JS-виджет в iframe), а вы карту не видите вообще. Стройте интеграцию так с самого начала.
* PCI DSS
Payment Card Industry Data Security Standard — набор требований к тем, кто хранит, обрабатывает или передаёт данные банковских карт. Если карту вводят на форме провайдера, а не на вашей, ваши обязательства сводятся к минимальному опроснику SAQ A.
Как выглядит поток платежа
Прежде чем писать код, держите в голове полную цепочку. В ней есть неочевидное место, которое ломает интеграции у новичков.
- Пользователь нажимает «Оплатить». Ваш сервер идёт в API провайдера и создаёт платёж, получая его
idи ссылку на оплату. - Вы сохраняете
idплатежа в свою модель и редиректите пользователя на страницу оплаты провайдера. - Пользователь платит. Провайдер возвращает его на ваш
return_url. - Отдельно и асинхронно провайдер шлёт на ваш вебхук POST с итоговым статусом платежа.
Возврат пользователя на return_url — это НЕ подтверждение оплаты. Пользователь мог закрыть вкладку, платёж мог зависнуть в обработке. Единственный надёжный сигнал «деньги пришли» — это вебхук. Заказ переводите в статус «оплачен» только по нему.
Именно на этом спотыкаются: заказ отмечают оплаченным на return_url, а деньги не пришли. Или наоборот — деньги пришли, вебхук потерялся, а вы про это не узнали. Return_url — для показа пользователю страницы «Спасибо», вебхук — для изменения состояния заказа.
Модель платежа
Не привязывайте статус оплаты булевым флагом прямо к заказу. Заведите отдельную модель — она понадобится для сверки, возвратов и разбора спорных ситуаций. Минимальный вариант для Django 5.x:
# payments/models.py
import uuid
from django.db import models
from django.conf import settings
class Payment(models.Model):
class Status(models.TextChoices):
PENDING = "pending", "Ожидает оплаты"
SUCCEEDED = "succeeded", "Оплачен"
CANCELED = "canceled", "Отменён"
# наш внутренний id, его же можно слать провайдеру как ключ идемпотентности
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
# id платежа на стороне провайдера — приходит после создания
provider_payment_id = models.CharField(max_length=128, blank=True, db_index=True)
order = models.ForeignKey("shop.Order", on_delete=models.PROTECT, related_name="payments")
amount = models.DecimalField(max_digits=12, decimal_places=2)
currency = models.CharField(max_length=3, default="RUB")
status = models.CharField(max_length=16, choices=Status.choices, default=Status.PENDING)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
Сумму держите в DecimalField, а не в FloatField: деньги и плавающая точка несовместимы, копейки будут теряться на округлениях. Провайдеры часто оперируют суммой в минимальных единицах (копейках) целым числом — конвертацию делайте явно в одном месте.
Приём вебхука без CSRF и с проверкой подписи
Вебхук приходит от чужого сервера, без вашей сессии и без CSRF-токена. Django по умолчанию отклонит такой POST. Эндпоинт нужно освободить от CSRF-защиты и вместо неё проверять подлинность запроса средствами провайдера — обычно это подпись в заголовке или проверка по секрету.
# payments/views.py
import json
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
@csrf_exempt
@require_POST
def payment_webhook(request):
# 1. Сначала проверяем подлинность. Конкретный способ зависит от
# провайдера: сверка подписи заголовка либо белый список IP.
# Точный алгоритм смотрите в документации вашего провайдера.
if not verify_signature(request):
return HttpResponseBadRequest("bad signature")
try:
event = json.loads(request.body)
except json.JSONDecodeError:
return HttpResponseBadRequest("invalid json")
provider_id = event.get("object", {}).get("id")
status = event.get("object", {}).get("status")
# 2. Идемпотентность: один и тот же вебхук может прийти дважды.
# Не меняем состояние, если оно уже финальное.
payment = Payment.objects.filter(provider_payment_id=provider_id).first()
if payment is None:
return HttpResponse(status=200) # чужой платёж — просто подтверждаем приём
if payment.status == Payment.Status.SUCCEEDED:
return HttpResponse(status=200) # уже обработали
if status == "succeeded":
payment.status = Payment.Status.SUCCEEDED
payment.save(update_fields=["status", "updated_at"])
# тут — перевод заказа в оплаченный, отгрузка и т.п.
# 3. Отвечаем 200. Иначе провайдер будет повторять доставку.
return HttpResponse(status=200)
Функцию verify_signature здесь оставлена заглушкой намеренно: её реализация целиком зависит от провайдера, и точное имя заголовка и алгоритм подписи нужно брать из его актуальной документации, а не угадывать. Придуманная проверка подписи хуже её отсутствия — она создаёт ложное чувство защищённости.
Три вещи, которые вебхук обязан переживать
- Повторную доставку. Провайдер шлёт вебхук снова, если не получил 200. Обработка должна быть идемпотентной — отсюда проверка «уже succeeded».
- Чужие события. На эндпоинт может прийти уведомление о платеже, которого у вас нет. Отвечайте 200 и не падайте.
- Ошибку своего кода. Если внутри упало исключение, Django вернёт 500, провайдер повторит — но лучше логировать и разбираться, а не молча глотать.
Локальная разработка: как принять вебхук на localhost
Провайдер не достучится до 127.0.0.1. Поднимите туннель, который даст публичный HTTPS-адрес, проброшенный на ваш runserver:
# ngrok пробрасывает публичный https на локальный порт 8000
ngrok http 8000
Полученный адрес вида https://xxxx.ngrok-free.app/payments/webhook/ укажите в настройках вебхука в личном кабинете провайдера (в тестовом режиме). После этого добавьте домен туннеля в ALLOWED_HOSTS и, если нужно, в CSRF_TRUSTED_ORIGINS — иначе Django отклонит запрос ещё до вашей вьюхи.
Если не получилось
- Вебхук не приходит вовсе. Проверьте, что адрес в кабинете провайдера — публичный HTTPS и совпадает с маршрутом в
urls.py. Загляните в веб-интерфейс ngrok (http://127.0.0.1:4040): видно ли там входящий POST. Если POST есть, а вьюха не срабатывает — дело в маршруте или хосте. - Ответ 403 Forbidden на вебхук. Не снят CSRF: добавьте
@csrf_exempt. Либо домен туннеля не вALLOWED_HOSTS— тогда ошибка будетDisallowedHostв логе. - Провайдер шлёт вебхук по кругу. Ваш эндпоинт не отвечает 200 — падает с 500 или отдаёт редирект. Проверьте, что при любом исходе возвращается
HttpResponse(status=200), если запрос подлинный. - Заказ оплачен дважды. Обработчик не идемпотентен: повторный вебхук снова запускает логику. Проверяйте текущий статус платежа до изменения.
- Суммы не сходятся на копейки. Где-то используется
float. Переведите все денежные поля наDecimalи работайте с копейками как с целыми при обмене с API.
Что делать дальше
Каркас готов: модель платежа, создание платежа через API, редирект, приём вебхука с проверкой подписи и идемпотентностью. Следующие шаги, которые стоит закрыть до боевого запуска:
- Настроить логирование всех вебхуков в отдельную таблицу или файл — при спорах с провайдером это единственное доказательство того, что и когда пришло.
- Реализовать ручную сверку статуса: периодическая задача, которая запрашивает у провайдера статус платежей, зависших в
pendingдольше N минут. Вебхук может потеряться, а деньги — прийти. - Изучить в документации провайдера механизм возвратов — это отдельный API-вызов и отдельный статус, здесь мы его не разбирали.
- Проверить точную процедуру верификации подписи вебхука для вашего провайдера и заменить заглушку
verify_signatureна реальную реализацию.
Тренажёр к экзамену: Вопросы, проверка ответов, план подготовки
Спросить за 10 ₽Источники: Django: cross-site request forgery protection, Django: DecimalField reference
Частые вопросы
У меня Django 3.2, приведённый код заработает?
Можно ли принять платёж без вебхука, только на return_url?
Обязательно ли мне HTTPS на этапе разработки?
Почему нельзя хранить сумму во FloatField?
Что будет, если вебхук придёт дважды?
Нужно ли мне проходить сертификацию PCI DSS?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.