Обработка вебхуков в Go: как сделать правильно с первого раза
Вебхук приходит один раз, а обрабатывается плохо: дубли, гонки, потерянные события при рестарте. Разбираем, как принять, проверить подпись и не потерять полезную нагрузку на Go 1.22+.

Платёжный провайдер отправляет вам вебхук об успешной оплате. Ваш обработчик делает запрос в базу, отправляет письмо, дёргает внешний сервис — и всё это внутри HTTP-хендлера. Провайдер ждёт ответа 5 секунд, не дожидается, считает доставку неудачной и присылает тот же вебхук ещё раз. И ещё. Через час у вас три письма клиенту и два списания в отчёте. Разбираем, как принимать вебхуки на Go так, чтобы повторы, медленная обработка и рестарт сервиса не превращались в инциденты. Ориентир — Go 1.22+ со стандартным net/http.
Что не так с наивным обработчиком
Типичный первый вариант выглядит так: читаем тело, парсим JSON, делаем всю бизнес-логику, отвечаем 200. Проблема не в коде, а в контракте вебхуков. Отправитель гарантирует доставку хотя бы один раз (at-least-once), но не ровно один раз. Дубли — это норма, а не сбой на их стороне.
Из этого следуют три требования, которые наивный хендлер не выполняет:
- Быстрый ответ. Отправитель считает доставку успешной по коду ответа. Пока вы ходите в три внешних сервиса, у него тикает таймаут.
- Идемпотентность. Один и тот же вебхук, обработанный дважды, не должен приводить к двум спискам, двум письмам, двум записям.
- Проверка подлинности. Эндпоинт вебхука открыт в интернет. Без проверки подписи любой может прислать вам «оплата прошла».
Правило, которое экономит недели отладки: HTTP-хендлер вебхука должен только принять, проверить и сохранить событие. Вся обработка — асинхронно и отдельно.
Шаг 1. Читаем тело один раз и с лимитом
Подпись почти всегда считается по сырому телу запроса. Если вы сначала распарсите JSON, а потом захотите проверить HMAC — байты уже не те: другой порядок ключей, другие пробелы. Поэтому читаем сырое тело, проверяем подпись по нему, и только потом парсим.
// Go 1.22+, стандартный net/http
const maxWebhookBody = 1 << 20 // 1 МБ — вебхуки не бывают большими
func readBody(w http.ResponseWriter, r *http.Request) ([]byte, error) {
// MaxBytesReader обрывает чтение и возвращает ошибку,
// если тело больше лимита — защита от раздувания памяти
r.Body = http.MaxBytesReader(w, r.Body, maxWebhookBody)
return io.ReadAll(r.Body)
}
Проверка: если прислать тело больше мегабайта, io.ReadAll вернёт ошибку, а не съест память. Отвечайте на неё кодом 413.
Шаг 2. Проверяем подпись в постоянное время
Большинство провайдеров подписывают тело через HMAC-SHA256 с общим секретом и кладут результат в заголовок вида X-Signature (точное имя смотрите в документации конкретного провайдера — у Stripe это Stripe-Signature с меткой времени внутри, у других иначе).
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
)
func validSignature(body []byte, secret, gotHex string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
want := mac.Sum(nil)
got, err := hex.DecodeString(gotHex)
if err != nil {
return false
}
// ConstantTimeCompare, а не ==: защита от timing-атак,
// hmac.Equal делает то же самое и читается яснее
return hmac.Equal(got, want)
}
Ключевой момент — hmac.Equal вместо обычного сравнения строк. Обычное сравнение выходит из цикла на первом несовпавшем байте, и по времени ответа можно подбирать подпись байт за байтом. hmac.Equal сравнивает за постоянное время.
Если провайдер добавляет в подпись метку времени (как Stripe), проверяйте ещё и её: отклоняйте вебхуки старше нескольких минут, иначе перехваченный запрос можно переиграть позже. Формат метки и допуск ищите в документации провайдера — не выдумывайте.
Шаг 3. Записываем событие и отвечаем сразу
Здесь и рождается идемпотентность. У каждого вебхука есть идентификатор события — либо в теле (event.id), либо в заголовке. Используем его как ключ уникальности в таблице входящих событий.
// PostgreSQL 16, database/sql + драйвер pgx через stdlib
// Таблица:
// CREATE TABLE inbound_events (
// event_id TEXT PRIMARY KEY,
// payload JSONB NOT NULL,
// status TEXT NOT NULL DEFAULT 'pending',
// created_at TIMESTAMPTZ NOT NULL DEFAULT now()
// );
func storeEvent(ctx context.Context, db *sql.DB, id string, body []byte) (bool, error) {
// ON CONFLICT DO NOTHING: повторный вебхук с тем же id
// не вставится, RowsAffected будет 0
res, err := db.ExecContext(ctx,
`INSERT INTO inbound_events (event_id, payload)
VALUES ($1, $2) ON CONFLICT (event_id) DO NOTHING`,
id, body)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n == 1, nil // true — новое событие, false — дубль
}
Дальше хендлер собирается целиком. Заметьте: он не вызывает бизнес-логику. Он только кладёт событие в очередь на обработку.
func webhookHandler(db *sql.DB, secret string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := readBody(w, r)
if err != nil {
http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
return
}
if !validSignature(body, secret, r.Header.Get("X-Signature")) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
var evt struct {
ID string `json:"id"`
}
if err := json.Unmarshal(body, &evt); err != nil || evt.ID == "" {
http.Error(w, "bad payload", http.StatusBadRequest)
return
}
// отдельный контекст: если клиент оборвёт соединение,
// запись в базу мы всё равно доведём до конца
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
isNew, err := storeEvent(ctx, db, evt.ID, body)
if err != nil {
http.Error(w, "storage error", http.StatusInternalServerError)
return
}
// и на новое событие, и на дубль отвечаем 200:
// дубль уже принят раньше, повторять доставку не нужно
_ = isNew
w.WriteHeader(http.StatusOK)
}
}
Проверка: пошлите один и тот же вебхук дважды. Первый раз в таблице появится строка, второй раз — нет, но оба ответа будут 200. В логах отправителя доставка успешна в обоих случаях.
Почему не обрабатывать прямо в хендлере
Соблазн велик: событие принято, тут же и обработаем. Но HTTP-хендлер живёт ровно столько, сколько открыто соединение. Клиент отвалился по таймауту — контекст запроса отменён, ваша транзакция посреди обработки откатывается. Отделив приём от обработки, вы получаете свойство «принято — значит, не потеряется»: событие лежит в базе со статусом pending, и его подхватит воркер, даже если сервис перезапустят.
Шаг 4. Обрабатываем событие отдельным воркером
Воркер — отдельная горутина (или отдельный процесс), который выбирает pending-события, обрабатывает и помечает done. Чтобы два воркера не взяли одно событие, используйте FOR UPDATE SKIP LOCKED.
func claimEvent(ctx context.Context, tx *sql.Tx) (string, []byte, error) {
var id string
var payload []byte
// SKIP LOCKED: если строку уже держит другой воркер,
// пропускаем её, а не ждём блокировку
err := tx.QueryRowContext(ctx,
`SELECT event_id, payload FROM inbound_events
WHERE status = 'pending'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 1`).Scan(&id, &payload)
return id, payload, err
}
После успешной обработки — UPDATE ... SET status = 'done' в той же транзакции. Если обработка упала, транзакция откатывается, статус остаётся pending, событие возьмут снова. Для событий, которые падают раз за разом, заведите счётчик попыток и статус failed, чтобы одно ядовитое событие не крутилось в цикле вечно.
Очередь на самой базе годится до сотен событий в секунду. Выше — берите брокер (NATS, RabbitMQ, Kafka), но принцип тот же: хендлер только принимает и кладёт в очередь.
Если не получилось
- Подпись не сходится, хотя секрет верный. Почти всегда — тело изменилось до проверки. Убедитесь, что считаете HMAC по сырым байтам из
io.ReadAll, а не по результатуjson.Marshalраспарсенной структуры. Порядок ключей и пробелы после парсинга не сохраняются. - Ответ
http: superfluous response.WriteHeader call. Вы вызвалиWriteHeaderдважды или послеhttp.Error. Проверьте, что после каждой ветки с ошибкой стоитreturn. - Дубли всё равно проходят. Значит, ключ идемпотентности выбран неверно. Не используйте свой сгенерированный ID или время получения — берите идентификатор события от отправителя. Только он одинаков у оригинала и повтора.
context canceledв записи события. Вы используетеr.Context()для записи в базу. При обрыве соединения он отменяется. Для критичной записи заводите отдельныйcontext.WithTimeout(context.Background(), ...), как в примере.- Отправитель шлёт один вебхук снова и снова. Он не видит вашего 200 — либо вы отвечаете медленно и упираетесь в его таймаут, либо возвращаете код вне диапазона 2xx. Проверьте, что успешный приём — это ровно 200, а не 202 с телом, которое провайдер не ждёт.
Что мерить и настроить дальше
Инвалидация старых событий из таблицы inbound_events — отдельная тема; здесь достаточно знать, что таблица растёт и её надо чистить по created_at. После запуска смотрите на два показателя: время ответа хендлера (должно держаться в десятках миллисекунд, вся тяжесть ушла в воркер) и размер очереди pending — если он растёт, воркеров не хватает. Настройте таймаут на HTTP-сервере через http.Server{ReadTimeout, WriteTimeout} и добавьте на эндпоинт rate limiting, раз он открыт наружу. И заведите алерт на события в статусе failed: это те вебхуки, которые вы приняли, но так и не обработали.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Go package crypto/hmac — официальная документация, Go package net/http — MaxBytesReader и Server
Частые вопросы
А если у меня не PostgreSQL, а MySQL или Redis?
Обязательно ли отделять обработку в воркер, если логика лёгкая?
Что вернуть отправителю, если подпись неверная?
Как отвечать на дубль — 200 или ошибку?
Метка времени в подписи — это обязательно проверять?
Работает ли этот подход на Go 1.21 и старше?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.