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

Типичный сценарий: интеграция с платёжным провайдером (ЮKassa, Stripe, CloudPayments) шлёт вебхук о смене статуса платежа. Ваша вьюха принимает запрос, лезет в базу, дёргает внешний API, отправляет письмо — и отвечает 200 через 8 секунд. Провайдер ждёт ответ обычно 3–10 секунд, при таймауте считает доставку неудачной и повторяет запрос. Так один платёж превращается в три письма клиенту и три записи в логе.
Проблема не в Django, а в том, что обработка вебхука выполняется внутри цикла запрос-ответ. Разберём три подхода: синхронная обработка прямо во вьюхе, вынос в фоновую очередь (Celery) и приём через отдельный лёгкий эндпоинт с буферизацией. Всё на Django 5.x, Python 3.11+.
Что такое вебхук и почему тут есть проблема
Вебхук1 — это HTTP-запрос, который внешний сервис отправляет на ваш URL, когда у него происходит событие. В отличие от опроса (когда вы сами дёргаете чужой API), инициатор здесь чужая сторона, и она диктует правила: сколько ждать ответа и сколько раз повторять при неудаче.
Ключевое правило: вебхук нужно принять максимально быстро и подтвердить статусом 2xx. Вся тяжёлая работа — потом. Отправитель не должен ждать, пока вы разошлёте письма и синхронизируете CRM.
Ответ 200 на вебхук означает «я получил и сохранил событие», а не «я его полностью обработал». Путаница между этими двумя смыслами — источник почти всех дублей.
Подход 1. Синхронно во вьюхе
Самый прямой вариант: принимаем, проверяем подпись, обрабатываем, отвечаем.
# views.py — Django 5.x
import json
from django.http import HttpResponse, HttpResponseBadRequest
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
@csrf_exempt
@require_POST
def payment_webhook(request):
# Проверка подписи должна идти до парсинга бизнес-логики
if not verify_signature(request):
return HttpResponseBadRequest("bad signature")
payload = json.loads(request.body)
# Тяжёлая работа прямо здесь: запись в БД, письма, внешние вызовы
process_payment(payload) # может длиться секунды
return HttpResponse(status=200)
Что выигрываете: ноль инфраструктуры. Никаких брокеров, воркеров, дополнительных процессов. Для проекта с редкими вебхуками, где обработка укладывается в 200–300 мс, этого достаточно.
Чем платите: любой медленный вызов внутри process_payment растягивает ответ. Внешний API затормозил — провайдер получил таймаут и повторил вебхук. Гунакорн-воркер занят на всё время обработки: пачка вебхуков подряд забивает пул воркеров, и обычные пользователи получают 502.
Подход 2. Быстрый приём, обработка в Celery
Вьюха делает минимум: проверяет подпись, сохраняет сырое событие в БД и ставит задачу в очередь. Ответ уходит за десятки миллисекунд.
# views.py
@csrf_exempt
@require_POST
def payment_webhook(request):
if not verify_signature(request):
return HttpResponseBadRequest("bad signature")
event = WebhookEvent.objects.create(
raw_body=request.body.decode(),
source="payment_provider",
)
# Ставим в очередь, передаём id — не сам payload
handle_payment_event.delay(event.id)
return HttpResponse(status=200)
# tasks.py — Celery 5.x
from celery import shared_task
@shared_task(bind=True, max_retries=5)
def handle_payment_event(self, event_id):
event = WebhookEvent.objects.get(id=event_id)
if event.processed: # защита от повторной обработки
return
try:
process_payment(json.loads(event.raw_body))
except ExternalServiceError as exc:
# экспоненциальная задержка между попытками
raise self.retry(exc=exc, countdown=2 ** self.request.retries)
event.processed = True
event.save(update_fields=["processed"])
Что выигрываете: ответ провайдеру мгновенный, таймауты и лишние ретраи с его стороны уходят. Обработку можно повторять своими силами через self.retry, не завися от того, повторит ли отправитель. Всплеск вебхуков не блокирует веб-воркеры — задачи копятся в очереди.
Чем платите: нужен брокер (Redis или RabbitMQ) и запущенные Celery-воркеры — это ещё один процесс, который может упасть и который надо мониторить. Появляется задержка между приёмом и обработкой. И главное — обработка теперь асинхронна, а значит нужна защита от дублей: тот же вебхук провайдер может прислать дважды, и обе задачи прочитают событие как непроцессированное, если не поставить блокировку.
Идемпотентность — не опция, а обязанность
При асинхронной обработке два одинаковых вебхука могут попасть в две параллельные задачи. Флаг processed сам по себе не спасает — между чтением и записью есть окно гонки. Надёжнее опираться на уникальный идентификатор события от провайдера (почти все его присылают) и unique-ограничение в БД:
# models.py
class WebhookEvent(models.Model):
# id события от провайдера, а не наш PK
external_id = models.CharField(max_length=255, unique=True)
raw_body = models.TextField()
processed = models.BooleanField(default=False)
Тогда повторный вебхук с тем же external_id упадёт на IntegrityError при вставке, и вы его просто проигнорируете — задача даже не создастся.
Подход 3. Приём отдельно от Django, обработка на воркерах
Когда вебхуков десятки в секунду, даже лёгкая вьюха на Django с записью в БД становится узким местом. Вариант: принимать вебхуки отдельным лёгким сервисом (или тем же Nginx с записью в лог/очередь), а Django-воркеры разбирают буфер.
Это уже архитектурное решение, а не пара строк кода, и уместно оно редко. Для большинства проектов до сотен событий в минуту подхода 2 хватает с запасом. Здесь только обозначу: смысл в том, чтобы приём (запись в брокер) не зависел от доступности Django и БД вообще.
Сравнение
| Критерий | Синхронно | Celery | Отдельный приём |
|---|---|---|---|
| Инфраструктура | только Django | + брокер, + воркеры | + отдельный сервис |
| Время ответа провайдеру | = времени обработки | десятки мс | единицы мс |
| Свои ретраи обработки | нет | да | да |
| Всплеск вебхуков | забивает веб-воркеры | копится в очереди | копится, Django не при делах |
| Риск дублей | низкий (одна нить) | высокий без идемпотентности | высокий без идемпотентности |
| Когда брать | редкие быстрые события | обычный продакшн | десятки событий/сек |
Когда что выбирать
- Синхронно — если обработка гарантированно укладывается в 200–300 мс и вебхуков единицы в минуту. Пример: уведомление о новом отзыве, которое просто пишет строку в БД.
- Celery — как только в обработке появляется внешний вызов (письмо, чужой API, генерация документа) или вебхуков больше нескольких в секунду. Это дефолт для продакшна.
- Отдельный приём — когда пиковая нагрузка вебхуков сравнима с обычным трафиком сайта и вы не хотите, чтобы падение БД теряло события.
Что ломается на практике
- Проверка подписи после парсинга. Если сначала распарсить JSON, а потом проверять подпись, вы уже потратили ресурсы на чужой мусорный запрос. Подпись — первым делом, до всего.
- Передача payload в задачу целиком. В Celery передавайте
event_id, а не сам объект запроса или большой JSON. Иначе брокер раздувается, а сериализация ломается на нестандартных типах. - CSRF на вебхуке. Внешний сервис не знает про ваш CSRF-токен. Без
@csrf_exemptDjango вернёт 403, а провайдер — уйдёт в ретраи. При этом защита переносится на проверку подписи. - Тихое проглатывание ошибок. Если задача упала и вы вернули 200, а обработка не прошла — событие потеряно навсегда, провайдер повторять не будет. Храните сырое событие в БД до успешной обработки.
Что делать дальше
Начните с подхода 2 и таблицы WebhookEvent с unique по external_id — это закрывает и скорость ответа, и дубли. Настройте в Celery ограничение попыток и алерт на задачи, ушедшие в dead letter. В проде смотрите на две метрики: время ответа вебхук-эндпоинта (должно быть стабильно низким) и долю событий с processed=False старше нескольких минут — это застрявшие или упавшие обработки. Инвалидация и повторный прогон застрявших событий — отдельная тема, здесь её не разворачиваем.
1 Идемпотентность — свойство операции давать один и тот же результат при повторном выполнении. Обработка вебхука идемпотентна, если получение одного события дважды не создаёт двух заказов, писем или списаний.
Видеомейкер: Ролик по описанию или оживление вашего фото. От пяти секунд, со звуком, вертикально или горизонтально.
Спросить за 190 ₽Источники: Celery — Tasks (официальная документация), Django — csrf_exempt и обработка представлений
Частые вопросы
А если у меня нет возможности поднять Celery и Redis?
Провайдер прислал вебхук дважды — это баг на его стороне?
Что будет, если моя вьюха ответит не 200, а упадёт с 500?
Нужно ли проверять IP-адрес отправителя вебхука?
Как локально тестировать вебхуки, если провайдер не достучится до localhost?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.