Обработка вебхуков в Go: типичные ошибки и как их избежать
Вебхук приходит один раз, а платёж списывается дважды — потому что обработчик не был идемпотентным. Разбираем ошибки, которые ломают приём вебхуков на Go: от чтения тела до ретраев.

Вебхук — это когда внешний сервис сам стучится к вам в HTTP-эндпоинт при событии: пришёл платёж, сборка упала, пользователь подписался. Проблема в том, что отправитель ничего не знает про ваш сервер: он отправит запрос, дождётся ответа с таймаутом в несколько секунд и, если ответа нет или он не 2xx, повторит доставку. Из этого рождается большинство ошибок обработки вебхуков на Go — не в синтаксисе, а в предположениях о том, как ведёт себя отправитель.
Статья про рабочие приёмы приёма вебхуков в стандартном net/http (Go 1.22+). Каждую ошибку разбираем на уровне: что происходит, почему очевидный код даёт проблему, как чинить. Доставку событий во внутреннюю систему и выбор брокера очереди здесь не трогаем — только сам HTTP-приём и что происходит сразу после него.
Ошибка 1. Тело запроса читают не сразу и не целиком
Классика: проверили подпись по одному способу, потом попытались распарсить JSON из того же r.Body — и получили пустое тело. r.Body — это io.ReadCloser, поток, который читается один раз. После первого io.ReadAll или json.NewDecoder(r.Body).Decode(...) из него больше ничего не вычитается.
Подпись почти всегда считается по сырым байтам тела, а не по распарсенному объекту. Значит, порядок такой: прочитать тело один раз в []byte, проверить подпись по этим байтам, потом парсить из тех же байтов.
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
)
// Ограничиваем размер тела: без лимита большой запрос съест память.
const maxBodyBytes = 1 << 20 // 1 МБ
func webhookHandler(secret []byte) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Читаем тело один раз и целиком, с ограничением сверху.
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "cannot read body", http.StatusBadRequest)
return
}
// Подпись считаем по сырым байтам, а не по объекту.
sig := r.Header.Get("X-Signature")
if !validSignature(body, sig, secret) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
var event struct {
ID string `json:"id"`
Type string `json:"type"`
}
if err := json.Unmarshal(body, &event); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
// ... обработка
w.WriteHeader(http.StatusOK)
}
}
func validSignature(body []byte, sig string, secret []byte) bool {
mac := hmac.New(sha256.New, secret)
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
// hmac.Equal — сравнение за постоянное время, защита от тайминг-атак.
return hmac.Equal([]byte(sig), []byte(expected))
}Два момента, которые часто пропускают. Первый — http.MaxBytesReader: без него отправитель (или атакующий) может прислать тело в гигабайты, и io.ReadAll положит процесс по памяти. Второй — hmac.Equal вместо ==: обычное сравнение строк выходит раньше на первом несовпавшем байте, и по времени ответа можно подбирать подпись. На практике это редко реализуют, но защита стоит одной строки.
Ошибка 2. Проверка подписи, которой на деле нет
Эндпоинт вебхука публичный — его URL знает внешний сервис, а значит, потенциально кто угодно. Если подпись не проверяется, любой может слать вам фейковые «платежи прошли». Типичные полумеры:
- сравнивают подпись обычным
==(тайминг, см. выше); - парсят JSON до проверки подписи — уже обработали потенциально вредный ввод;
- берут секрет из тела запроса, а не из своей конфигурации;
- сверяют подпись по перекодированному JSON, а не по сырым байтам — из-за порядка полей или пробелов хеши не совпадают, и начинают «чинить» отключением проверки.
Подпись вебхука считается по тем байтам, что пришли по проводу. Как только вы распарсили JSON и собрали его обратно, это уже другие байты — и подпись к ним не подходит. Никогда не считайте HMAC по результату re-marshal.
Точный способ подписи зависит от отправителя: у одних это HMAC-SHA256 от тела, у других — от строки вида timestamp.body, у третьих подпись лежит в заголовке с версией схемы. Смотрите документацию конкретного сервиса — не переносите схему одного провайдера на другой вслепую.
Ошибка 3. Обработчик не идемпотентный
Отправитель повторяет доставку при любом сбое: ваш сервер ответил 500, ответил слишком поздно, ответ потерялся в сети. Один и тот же вебхук приходит два, три, пять раз. Если обработчик на каждый вызов создаёт запись о платеже или списывает деньги — получаете дубли.
Лекарство — идемпотентность по идентификатору события. У большинства провайдеров в теле есть уникальный id события. Сохраняйте обработанные id и отбрасывайте повторы. Ключевой приём — не «проверить, потом вставить» (между этими шагами прилетит второй запрос), а вставка с уникальным ограничением на уровне БД.
// PostgreSQL: таблица processed_events с PRIMARY KEY (event_id).
// Пытаемся вставить; если id уже есть — это повтор, тихо выходим.
func markProcessed(ctx context.Context, db *sql.DB, eventID string) (bool, error) {
res, err := db.ExecContext(ctx,
`INSERT INTO processed_events (event_id, processed_at)
VALUES ($1, now())
ON CONFLICT (event_id) DO NOTHING`,
eventID,
)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
// n == 1 — событие новое; n == 0 — уже обрабатывали.
return n == 1, nil
}Вставку записи о событии и саму бизнес-операцию (списание, создание заказа) держите в одной транзакции. Иначе возможен разрыв: событие пометили обработанным, а операция упала — и повтор уже не пройдёт, потому что id считается использованным.
Ошибка 4. Тяжёлую работу делают внутри обработчика
Отправитель ждёт ответа с коротким таймаутом — у многих это 5–10 секунд, но полагаться на конкретное число нельзя. Если внутри обработчика вы ходите в три внешних API, генерируете PDF и шлёте письмо, ответ не успевает — отправитель считает доставку неудачной и шлёт повтор. Дальше по кругу.
Правильная схема: обработчик делает минимум — проверяет подпись, проверяет идемпотентность, кладёт событие в очередь или в таблицу «входящих» и сразу отвечает 2xx. Тяжёлую работу выполняет отдельный воркер.
| Подход | Плюс | Минус |
|---|---|---|
| Вся работа синхронно в обработчике | Просто, нет инфраструктуры | Таймауты и повторы при любой задержке |
| Обработчик → очередь → воркер | Быстрый 2xx, ретраи внутри воркера | Нужен брокер, сложнее отладка |
| Обработчик → строка в БД → фоновый опрос | Без брокера, транзакционно с проверкой id | Задержка на интервал опроса |
Что нельзя делать — запускать go doWork() прямо из обработчика и тут же отвечать 200. Горутина не переживёт рестарт или деплой, паника в ней уронит весь процесс без recover, а вы уже сказали отправителю «принято» — повтора не будет. Событие потеряно. Отвечайте 2xx только после того, как факт получения закреплён в БД или очереди.
Ошибка 5. Неверные коды ответа
Код ответа — это сигнал отправителю: повторять доставку или нет. Путаница здесь либо копит бесконечные повторы, либо молча теряет события.
- 2xx — принято, повторять не нужно. Отвечайте так, когда закрепили событие.
- 4xx — запрос кривой (плохая подпись, битый JSON), повтор не поможет. Многие отправители на 4xx перестают ретраить — это то, что нужно для мусорного запроса.
- 5xx — временный сбой на вашей стороне, отправитель повторит. Отвечайте так, если БД недоступна или упал воркер — тогда повтор оправдан.
Частая ошибка — вернуть 200 при внутренней ошибке, «чтобы отправитель не долбил». Событие при этом теряется навсегда. Если не смогли принять — честный 5xx, пусть повторят.
Что проверить перед выкладкой в прод
- Тело читается один раз в
[]byte, ограниченоhttp.MaxBytesReader. - Подпись сверяется по сырым байтам через
hmac.Equalдо парсинга JSON. - Идемпотентность через уникальное ограничение в БД, а не «select-then-insert».
- Обработчик отвечает 2xx быстро; тяжёлое — в воркер.
- Коды ответа соответствуют смыслу: 4xx на мусор, 5xx на свой сбой.
- На сервере
http.ServerзаданыReadTimeoutиReadHeaderTimeout— иначе висящее соединение держит горутину.
Дальше смотрите на два показателя в проде: долю ответов не-2xx на эндпоинте вебхука и время обработки одного запроса (p95, p99). Рост доли повторов от одного отправителя обычно значит, что вы отвечаете медленно или отдаёте 5xx. Логируйте event_id и результат — по нему разберёте, что за событие пришло дважды и почему.
AI-стилист и фотограф: Замена фона, ретушь, аватарки, фото для резюме.
Спросить за 20 ₽Источники: net/http — пакет стандартной библиотеки Go, crypto/hmac — пакет стандартной библиотеки Go
Частые вопросы
Как проверять подпись, если отправитель включает в неё timestamp?
У меня Go старее 1.22 — что-то из примеров не заработает?
А если обработчик всё равно иногда отвечает медленно?
Нужна ли очередь, или хватит таблицы в БД?
Что делать, если у события нет уникального id в теле?
Стоит ли отвечать 200, чтобы отправитель перестал повторять при ошибке?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.