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

Задача звучит просто: пользователь нажимает «Оплатить», платит картой, заказ переходит в статус «оплачен». На практике очевидная реализация — «создали платёж, получили редирект, поверили фронтенду» — приводит к заказам, помеченным оплаченными без денег на счёте. Деньги приходят асинхронно, а фронтенд обмануть тривиально. Разбираем схему приёма платежей в Django через ЮKassa, где источником истины служит серверный вебхук, а не редирект пользователя.
Речь про Django 5.x и официальную библиотеку yookassa (Python SDK). Углы про подписку, рекуррентные платежи и фискализацию (54-ФЗ) здесь не раскрываем — это отдельные темы. Фокус на разовой оплате заказа и на том, где наивная реализация теряет деньги.
Почему нельзя верить редиректу
Схема оплаты состоит из двух независимых потоков. Первый — пользовательский: он уходит на страницу оплаты и после неё возвращается на ваш return_url. Второй — серверный: платёжный провайдер отправляет на ваш эндпоинт HTTP-уведомление (вебхук) о смене статуса платежа.
Ошибка новичка — считать заказ оплаченным по факту возврата на return_url. Проблема в трёх местах:
- Пользователь может закрыть вкладку сразу после оплаты и не вернуться вовсе — заказ останется неоплаченным при списанных деньгах.
- URL возврата открывается в браузере пользователя и подделывается вручную — открыл нужный адрес, заказ «оплачен» без единого рубля.
- Между нажатием кнопки и реальным списанием проходит время: карта может быть отклонена уже после редиректа.
Единственный надёжный сигнал «деньги пришли» — вебхук со статусом succeeded, дополнительно перепроверенный запросом к API провайдера. Всё остальное — подсказка для интерфейса, не основание менять статус заказа.Что понадобится до старта
- Аккаунт в ЮKassa и созданный магазин:
shopIdи секретный ключ из личного кабинета. - Публично доступный HTTPS-адрес для вебхука. Для локальной разработки — туннель (например, через
ngrok), потому что провайдер должен достучаться до вашего сервера снаружи. - Установленный SDK:
pip install yookassa.
Ключи не храните в коде и не коммитьте в репозиторий — читайте из переменных окружения или из вашего менеджера секретов.
Три подхода к подтверждению оплаты
Прежде чем писать код, выберите модель подтверждения. От неё зависит, сколько вы потеряете при сбое.
| Подход | Что выигрываете | Чем платите |
|---|---|---|
Верить return_url | Ноль кода на сервере | Заказы «оплачены» без денег, тривиальная подделка |
| Только вебхук | Асинхронно, не зависит от пользователя | Пропущенный вебхук = потерянная оплата, нужна защита эндпоинта |
| Вебхук + перепроверка через API | Двойная защита, устойчиво к подделке уведомления | Лишний запрос к API на каждое уведомление |
Рабочий выбор для магазина — третий: обрабатываем вебхук, но перед сменой статуса заказа заново запрашиваем платёж у провайдера по его ID и смотрим статус там. Уведомление считаем лишь триггером «сходи и проверь».
Шаг 1. Создание платежа
Настраиваем SDK и создаём платёж при оформлении заказа. Ключ идемпотентности защищает от двойного списания при повторной отправке запроса (например, из-за таймаута и ретрая).
import uuid
from decimal import Decimal
from django.conf import settings
from yookassa import Configuration, Payment
Configuration.account_id = settings.YOOKASSA_SHOP_ID
Configuration.secret_key = settings.YOOKASSA_SECRET_KEY
def create_payment(order):
# Уникальный ключ на попытку оплаты защищает от двойного списания
idempotence_key = str(uuid.uuid4())
payment = Payment.create(
{
"amount": {
"value": str(order.total.quantize(Decimal("0.01"))),
"currency": "RUB",
},
"confirmation": {
"type": "redirect",
"return_url": f"https://example.com/orders/{order.id}/return/",
},
"capture": True, # сразу списываем, без двухстадийной оплаты
"description": f"Заказ №{order.id}",
"metadata": {"order_id": str(order.id)},
},
idempotence_key,
)
# Сохраняем ID платежа у заказа — по нему потом сверяем статус
order.payment_id = payment.id
order.status = "pending"
order.save(update_fields=["payment_id", "status"])
return payment.confirmation.confirmation_urlПроверка: функция возвращает URL вида https://yoomoney.ru/checkout/payments/.... Перенаправьте на него пользователя. У заказа появился payment_id и статус pending.
Шаг 2. Приём вебхука
Вебхук приходит POST-запросом с JSON. Эндпоинт обязательно освобождаем от CSRF-проверки — запрос идёт от стороннего сервера, токена у него нет. Взамен защищаем эндпоинт двумя способами: фильтром по IP отправителя и обязательной перепроверкой статуса через API.
import json
from django.http import HttpResponse, HttpResponseForbidden
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from yookassa import Payment
from yookassa.domain.common import SecurityHelper
from .models import Order
@csrf_exempt
@require_POST
def yookassa_webhook(request):
# 1. Пускаем только с доверенных IP провайдера.
# SecurityHelper хранит актуальный список сетей ЮKassa.
client_ip = request.META.get("HTTP_X_FORWARDED_FOR", "").split(",")[0].strip()
client_ip = client_ip or request.META.get("REMOTE_ADDR", "")
if not SecurityHelper().is_ip_trusted(client_ip):
return HttpResponseForbidden("untrusted ip")
try:
event = json.loads(request.body)
except json.JSONDecodeError:
return HttpResponse(status=400)
payment_id = event.get("object", {}).get("id")
if not payment_id:
return HttpResponse(status=400)
# 2. Не верим телу уведомления — заново спрашиваем статус у API.
payment = Payment.find_one(payment_id)
if payment.status != "succeeded":
# Другие статусы (canceled, waiting_for_capture) обрабатываем
# отдельно или просто подтверждаем приём.
return HttpResponse(status=200)
order_id = payment.metadata.get("order_id")
order = Order.objects.filter(id=order_id).first()
if order is None:
return HttpResponse(status=200)
# 3. Идемпотентность: не обрабатываем один платёж дважды.
if order.status == "paid":
return HttpResponse(status=200)
order.status = "paid"
order.save(update_fields=["status"])
# тут же — отгрузка, письмо, начисление и т.п.
return HttpResponse(status=200)Проверка: оплатите тестовый заказ, в логах сервера появится POST на эндпоинт вебхука, а заказ перейдёт в paid. Провайдер ждёт от вас ответ 200 в течение нескольких секунд — иначе будет повторять доставку уведомления.
Идемпотентность вебхука
Идемпотентность — свойство операции давать один и тот же результат при повторном выполнении. Вебхук может прийти дважды: из-за ретрая провайдера, сетевого сбоя или медленного ответа. Проверка if order.status == "paid" — минимум. В нагруженной системе два уведомления способны прийти одновременно; тогда одной проверки статуса мало.
Где схема ломается в проде
- Гонка при двойном вебхуке. Два параллельных запроса читают статус
pendingодновременно и оба выполняют отгрузку. Лечится блокировкой строки: оберните смену статуса в транзакцию и возьмитеOrder.objects.select_for_update()по этому заказу. - URL вебхука не настроен или недоступен. Если эндпоинт не зарегистрирован в личном кабинете или закрыт файрволом, уведомления не придут вовсе — заказы застрянут в
pending. Держите фоновую задачу, которая периодически перепроверяет статус зависших платежей черезPayment.find_one. - IP за прокси. За Nginx или облачным балансировщиком в
REMOTE_ADDRлежит адрес прокси, а не провайдера. Настройте передачу реального IP и читайте его корректно, иначе фильтр по IP заблокирует легитимные уведомления или пропустит чужие. - Несовпадение суммы. Сверяйте
payment.amount.valueс суммой заказа перед сменой статуса — цена товара могла измениться между созданием платежа и его подтверждением.
Что настроить после
Базовый приём оплаты работает. Дальше:
- Добавьте
select_for_updateи транзакцию вокруг смены статуса — без этого гонка вебхуков выстрелит на первом же всплеске заказов. - Заведите фоновую сверку зависших
pending-заказов (Celery-задача по расписанию). - Если продаёте физическим лицам — разберитесь с фискализацией по 54-ФЗ: в тело платежа добавляется объект
receipt. Это отдельная большая тема. - Логируйте все входящие вебхуки в отдельную таблицу до обработки — при разборе спорного платежа это единственный способ понять, что и когда пришло.
Актуальный формат объекта платежа, список статусов и точные имена полей сверяйте с официальной документацией ЮKassa для Python SDK — параметры со временем меняются.
Проверка грамотности: Орфография, пунктуация, стиль — с объяснением каждой правки
Спросить за 10 ₽Источники: ЮKassa API — приём платежей (официальная документация), Django — CSRF и представления
Частые вопросы
А если у меня не ЮKassa, а другой провайдер?
Вебхук не приходит, что проверять?
Нужно ли проверять подпись, если я уже фильтрую по IP?
Что будет при двойном вебхуке без блокировки?
Работает ли этот код в старых версиях Django?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.