Повторный webhook не должен создать второй заказ: проверяем идемпотентность
Практический разбор получателя webhook: устойчивый event_id, UNIQUE-ограничение, транзакция, повтор после сбоя и две одновременные доставки. Учебный пример выполнен и проверен на Python 3.12 и SQLite 3.53.

В этой статье
- Сначала определим, что считается повтором
- Минимальная схема: inbox и локальное действие
- Обработчик: сначала занять ключ, затем менять данные
- Пять проверок, а не один удачный запрос
- Почему проверка «сначала SELECT» не защищает от гонки
- Сбой до commit: повтор должен завершить работу
- Граница модели: внешний API не откатится вместе с базой
- Как перенести принцип в проект на 1С-Битрикс
- Протокол приёмки
- Критерий готовности
- First, let us define what counts as a repeat
- Minimal schema: inbox and local action
- Handler: lock the key first, then modify data
- Five checks, not just one successful request
- Why a 'SELECT first' check does not protect against race conditions
- Failure before commit: the retry must complete the operation
- Model boundary: the external API will not roll back with the database
- How to apply this principle in a 1C-Bitrix project
- Acceptance Protocol
- Readiness Criteria
Webhook может прийти повторно: отправитель не получил ответ, соединение оборвалось или две доставки стартовали почти одновременно. Если обработчик сначала проверяет «заказа ещё нет», а потом создаёт его, оба процесса могут увидеть пустое место и выполнить действие дважды.
Надёжный ориентир такой: идентификатор бизнес-события должен быть уникальным на уровне базы данных, а регистрация события и локальное изменение состояния — входить в одну транзакцию. Повтор с тем же идентификатором и тем же телом становится штатным результатом, а тот же идентификатор с другим телом — конфликтом, который нельзя молча принять.
Ниже — воспроизводимая учебная модель. Я проверил её на Python 3.12.14 и SQLite 3.53.1. Это не тест модуля 1С-Битрикс и не готовый фрагмент для боевого магазина: модель доказывает инвариант на локальной базе и отдельно показывает границу, за которой нужна идемпотентность внешнего API.
Сначала определим, что считается повтором
У события, заказа и попытки доставки разные идентификаторы. Один заказ может порождать несколько событий: создан, оплачен, отменён. Одно событие может доставляться несколько раз. Поэтому ключ дедупликации должен обозначать именно бизнес-событие у источника, а не сетевое соединение и не время получения.
Что пришло | Решение получателя | HTTP-результат по контракту | Бизнес-действие |
|---|---|---|---|
Новый event_id | Зарегистрировать и обработать | Успех после надёжной фиксации | Выполнить один раз |
Тот же event_id и тот же хеш | Вернуть сохранённый результат | Успех или иной документированный ответ | Не повторять |
Тот же event_id, другой хеш | Зафиксировать конфликт | Ответ по контракту сервиса | Не перезаписывать молча |
Событие ещё обрабатывается | Не запускать вторую копию | Повторить позже или ответить сохранённым статусом | Не выполнять параллельно |
Конкретный HTTP-код задаёт договор с отправителем. Универсальный код здесь опаснее, чем полезнее: один сервис повторяет доставку после любого ответа, кроме 200, другой различает 409 и 5xx, третий требует свой формат тела.
Минимальная схема: inbox и локальное действие
В учебной базе две таблицы. inbox_events хранит внешний event_id, хеш тела и состояние. local_orders изображает локальный бизнес-эффект — созданный заказ. Оба ограничения UNIQUE нужны не для красоты схемы: именно база разрешает гонку между двумя обработчиками.
CREATE TABLE inbox_events (
event_id TEXT PRIMARY KEY,
payload_sha256 TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('processing', 'done'))
);
CREATE TABLE local_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT NOT NULL UNIQUE,
external_order_id TEXT NOT NULL
);
В документации SQLite ограничения PRIMARY KEY и UNIQUE задают уникальность на уровне таблицы. Предварительный SELECT без такого ограничения остаётся только наблюдением: между чтением и INSERT может успеть второй процесс.
Обработчик: сначала занять ключ, затем менять данные
Обработчик вычисляет SHA-256 исходного тела, начинает пишущую транзакцию и пытается вставить event_id. Если ключ уже занят, он читает сохранённый хеш: совпадение означает повтор, различие — конфликт. Новый заказ и отметка done фиксируются одним commit.
def handle_event(db, event_id, payload, fail_before_commit=False):
payload_hash = hashlib.sha256(payload.encode()).hexdigest()
order_id = str(json.loads(payload)["order_id"])
db.execute("BEGIN IMMEDIATE")
try:
db.execute(
"INSERT INTO inbox_events(event_id, payload_sha256, status) "
"VALUES (?, ?, 'processing')",
(event_id, payload_hash),
)
except sqlite3.IntegrityError:
db.rollback()
stored_hash, status = db.execute(
"SELECT payload_sha256, status FROM inbox_events WHERE event_id = ?",
(event_id,),
).fetchone()
if stored_hash != payload_hash:
return "conflict"
return "duplicate" if status == "done" else "in_progress"
try:
db.execute(
"INSERT INTO local_orders(event_id, external_order_id) VALUES (?, ?)",
(event_id, order_id),
)
if fail_before_commit:
raise RuntimeError("failure before commit")
db.execute(
"UPDATE inbox_events SET status = 'done' WHERE event_id = ?",
(event_id,),
)
db.commit()
return "processed"
except Exception:
db.rollback()
raise
В полном стендовом скрипте исключение для учебного сбоя преобразуется в результат failed_rolled_back, чтобы продолжить серию проверок. В боевом обработчике ошибку нужно записать без секретов и вернуть ответ, предусмотренный контрактом webhook.
SQLite допускает только одну одновременную пишущую транзакцию; режим BEGIN IMMEDIATE запрашивает её сразу. Это свойство учебной среды. При переносе на MySQL или PostgreSQL нужно заново проверить изоляцию, тип таблиц и обработку конфликта уникальности.
Пять проверок, а не один удачный запрос
Скрипт запускался дважды в новой временной базе. Оба прогона дали одинаковый вывод. Для параллельного случая два потока открывали отдельные соединения и одновременно доставляли evt-44.
python3 webhook_idempotency_lab.py
first processed (1, 1)
repeat duplicate (1, 1)
changed-payload conflict (1, 1)
failure failed_rolled_back (1, 1)
retry processed (2, 2)
concurrent ['duplicate', 'processed'] (3, 3)
orders [('evt-42', '1001'), ('evt-43', '1002'), ('evt-44', '1003')]
Проверка | Ожидаемый результат | Наблюдение |
|---|---|---|
Первый evt-42 | Одно событие, один заказ | processed; (1, 1) |
Точный повтор evt-42 | Счётчики не меняются | duplicate; (1, 1) |
evt-42 с другим order_id | Конфликт без перезаписи | conflict; (1, 1) |
Сбой evt-43 до commit | Откат обеих вставок | failed_rolled_back; (1, 1) |
Повтор evt-43 | Одна успешная фиксация | processed; (2, 2) |
Два параллельных evt-44 | Один processed, один duplicate | Итог (3, 3) |
Пара чисел в скобках — количество строк в inbox_events и local_orders. После последовательного и параллельного повтора они растут только для нового event_id. Тут сухой вывод полезнее красивого сообщения «всё работает»: проверяется именно число необратимых локальных результатов.

Почему проверка «сначала SELECT» не защищает от гонки
Представим два обработчика. Оба выполняют SELECT и не находят evt-44. Оба переходят к INSERT. Если уникального ограничения нет, база честно сохранит две строки. Блокировка в приложении тоже может не помочь, когда обработчики работают в разных процессах или на разных узлах.
UNIQUE переносит решение в общий для всех обработчиков слой. Один INSERT выигрывает, второй получает конфликт и проверяет сохранённый результат. Одного UNIQUE недостаточно: транзакция всё равно нужна, иначе запись inbox может сохраниться, а заказ — нет, или наоборот.
Сбой до commit: повтор должен завершить работу
Учебный флаг fail_before_commit бросает исключение после вставки заказа, но до commit. Rollback удаляет и строку inbox, и локальный заказ. Следующая доставка evt-43 видит свободный ключ и выполняет операцию один раз.
Модуль sqlite3 в Python предоставляет явные commit и rollback. В тесте версия Python зафиксирована, потому что режим управления транзакциями у драйвера меняется между версиями и его нельзя оставлять неявным в инструкции.
Граница модели: внешний API не откатится вместе с базой
Теперь существенный крайний случай. Если внутри транзакции вызвать CRM, 1С или платёжный сервис, внешний запрос может успеть создать объект, а локальный commit затем упадёт. Rollback вернёт только локальную базу. При повторе обработчик снова вызовет внешний сервис — и может получить дубль.

Если внешний API поддерживает ключ идемпотентности, передавайте устойчивый ключ той же логической операции и проверяйте его контракт.
Если операция асинхронная, сохраняйте локальное событие и запись outbox в одной транзакции, а отдельный исполнитель отправляет её наружу и фиксирует подтверждение.
Каждое последующее действие — письмо, отгрузка, документ в 1С — требует собственного устойчивого ключа или явного состояния. Защита создания заказа не делает идемпотентной всю цепочку.
Тут есть подвох: длинная транзакция вокруг сетевого запроса не превращает две системы в одну базу. Она лишь дольше держит блокировки, а удалённый результат всё равно живёт отдельно.
Как перенести принцип в проект на 1С-Битрикс
SQLite в этой статье — измерительный стенд, а не рекомендация для боевой установки 1С-Битрикс. Перед внедрением нужно зафиксировать версию платформы, фактическую СУБД, место входящего обработчика, формат идентификатора события и список вызываемых обработчиков.
Сначала проверить подпись и обязательные поля webhook. Неподтверждённое сообщение не должно занимать доверенный event_id.
Выбрать ключ бизнес-события из контракта отправителя. Если его нет, составной ключ нужно документировать и проверять на коллизии; время получения не подходит.
Добавить уникальный индекс в основную СУБД и проверить его создание на копии проекта с тем же типом таблиц и кодировкой.
В одной локальной транзакции зарегистрировать событие, изменить состояние заказа и записать результат обработки.
Отделить внешние вызовы через outbox или использовать документированный ключ идемпотентности внешнего API.
Проверить точный повтор, изменённое тело с тем же ID, параллельную доставку, исключение до commit и сбой внешней системы.
Правило подписи зависит от сервиса. В отдельном разборе показано, почему для проверки подписи webhook нужны исходные байты тела; нормализованный JSON может дать другой хеш.
Не смешивайте входящую дедупликацию с политикой исходящих повторов. В статье о бюджете повторных запросов API ограничивается число и срок попыток отправителя. Здесь получатель защищает один бизнес-эффект от повторной доставки.
Протокол приёмки
До запуска на реальном магазине подготовьте обезличенное событие и счётчики объектов до каждой фазы. Ожидаемый результат записывается заранее; иначе второй заказ легко назвать «особенностью теста» уже после появления.
Отправить новое событие. Проверить одну запись inbox, одно локальное действие и сохранённый результат.
Повторить те же байты с тем же event_id. Число заказов, писем, резервов и внешних документов не должно увеличиться.
Отправить другое тело с тем же event_id. Обработчик должен остановить изменение и показать конфликт, не перезаписать прежние данные.
Запустить две доставки одновременно. После завершения посчитать объекты, а не ограничиваться двумя успешными HTTP-ответами.
Прервать обработку до локального commit. Убедиться в откате, затем повторить событие и получить ровно один результат.
Отдельно оборвать связь после успешного внешнего действия, но до локального подтверждения. Проверить ключ идемпотентности или outbox на фактическом API.
Остановитесь, если тест затрагивает рабочий платёж, доставку, ERP или реальные персональные данные. Для приёмки нужен изолированный контур либо безопасная заглушка с управляемыми ответами.
Критерий готовности
Получатель webhook готов не тогда, когда два одинаковых запроса вернули зелёный ответ. Он готов, когда команда может повторить и параллельно доставить одно событие, прервать обработку в заранее выбранной точке и доказать счётчиками: одна логическая операция создала один локальный результат, конфликт не был скрыт, а внешний побочный эффект защищён отдельно.
Если такую схему нужно согласовать для 1С-Битрикс, 1С, CRM или другого API, сначала зафиксируйте контракт событий, границы транзакции и критерии приёмки. Это же набор входных данных для проектирования интеграции сайта.
A webhook may arrive repeatedly: the sender did not receive a response, the connection dropped, or two deliveries started almost simultaneously. If the handler first checks that the order does not exist and then creates it, both processes may see an empty slot and execute the action twice.
A reliable guideline is this: the business event identifier must be unique at the database level, and event registration and local state changes must occur within a single transaction. A repeat with the same identifier and the same payload is a normal result, while the same identifier with a different payload is a conflict that must not be silently accepted.
Below is a reproducible tutorial model. I verified it on Python 3.12.14 and SQLite 3.53.1. This is not a 1C-Bitrix module test nor a ready-made snippet for a production store: the model proves an invariant on a local database and separately shows the boundary where an external API requires idempotency.
First, let us define what counts as a repeat
The event, order, and delivery attempt have different identifiers. One order can generate multiple events: created, paid, cancelled. One event can be delivered multiple times. Therefore, the deduplication key must denote the specific business event at the source, not the network connection or the time of receipt.
What Arrived | Receiver Solution | HTTP result per contract | Business action |
|---|---|---|---|
New event_id | Register and process | Success after reliable persistence | Execute once |
Same event_id and same hash | Return the saved result | Success or other documented response | Do not repeat |
Same event_id, different hash | Record a conflict | Response per service contract | Do not silently overwrite |
Event is still being processed | Do not launch a second copy | Retry later or respond with a saved status | Do not execute in parallel |
A specific HTTP code defines the agreement with the sender. A universal code here is more dangerous than useful: one service retries delivery after any response except 200, another distinguishes between 409 and 5xx, and a third requires its own body format.
Minimal schema: inbox and local action
The sample database has two tables. inbox_events stores the external event_id, body hash, and status. local_orders represents the local business effect—a created order. Both UNIQUE constraints are not just for schema aesthetics: only the database prevents a race condition between two handlers.
CREATE TABLE inbox_events (
event_id TEXT PRIMARY KEY,
payload_sha256 TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('processing', 'done'))
);
CREATE TABLE local_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT NOT NULL UNIQUE,
external_order_id TEXT NOT NULL
);
In the SQLite documentation, PRIMARY KEY and UNIQUE constraints enforce uniqueness at the table level. A preliminary SELECT without such a constraint remains merely an observation: a second process may intervene between the read and the INSERT.
Handler: lock the key first, then modify data
The handler computes the SHA-256 of the original body, starts a write transaction, and attempts to insert the event_id. If the key is already locked, it reads the stored hash: a match indicates a duplicate, a mismatch indicates a conflict. The new order and done flag are committed in a single transaction.
def handle_event(db, event_id, payload, fail_before_commit=False):
payload_hash = hashlib.sha256(payload.encode()).hexdigest()
order_id = str(json.loads(payload)["order_id"])
db.execute("BEGIN IMMEDIATE")
try:
db.execute(
"INSERT INTO inbox_events(event_id, payload_sha256, status) "
"VALUES (?, ?, 'processing')",
(event_id, payload_hash),
)
except sqlite3.IntegrityError:
db.rollback()
stored_hash, status = db.execute(
"SELECT payload_sha256, status FROM inbox_events WHERE event_id = ?",
(event_id,),
).fetchone()
if stored_hash != payload_hash:
return "conflict"
return "duplicate" if status == "done" else "in_progress"
try:
db.execute(
"INSERT INTO local_orders(event_id, external_order_id) VALUES (?, ?)",
(event_id, order_id),
)
if fail_before_commit:
raise RuntimeError("failure before commit")
db.execute(
"UPDATE inbox_events SET status = 'done' WHERE event_id = ?",
(event_id,),
)
db.commit()
return "processed"
except Exception:
db.rollback()
raise
In the full test script, the exception for the simulated failure is converted into a failed_rolled_back result to continue the series of checks. In the production handler, the error must be logged without secrets, and the response must match the webhook contract.
SQLite allows only one concurrent write transaction; the BEGIN IMMEDIATE mode requests it immediately. This is a characteristic of the learning environment. When porting to MySQL or PostgreSQL, you must re-verify isolation levels, table types, and unique constraint conflict handling.
Five checks, not just one successful request
The script ran twice in a new temporary database. Both runs produced identical output. In the parallel case, two threads opened separate connections and simultaneously delivered evt-44.
python3 webhook_idempotency_lab.py
first processed (1, 1)
repeat duplicate (1, 1)
changed-payload conflict (1, 1)
failure failed_rolled_back (1, 1)
retry processed (2, 2)
concurrent ['duplicate', 'processed'] (3, 3)
orders [('evt-42', '1001'), ('evt-43', '1002'), ('evt-44', '1003')]
Check | Expected result | Observation |
|---|---|---|
First evt-42 | One event, one order | processed; (1, 1) |
Exact repeat of evt-42 | Counters do not change | duplicate; (1, 1) |
evt-42 with a different order_id | Conflict without overwrite | conflict; (1, 1) |
evt-43 failure before commit | Revert both insertions | failed_rolled_back; (1, 1) |
Retry evt-43 | One successful commit | processed; (2, 2) |
Two parallel evt-44 events | One processed, one duplicate | Result (3, 3) |
The pair of numbers in parentheses represents the row count in inbox_events and local_orders. After sequential and parallel repeats, these counts increase only for the new event_id. Here, dry output is more valuable than a pretty message saying 'everything works': we are verifying the number of irreversible local results.

Why a 'SELECT first' check does not protect against race conditions
Imagine two handlers. Both execute a SELECT and find no evt-44. Both proceed to INSERT. If no unique constraint exists, the database will honestly save two rows. Application-level locking may also fail when handlers run in different processes or on different nodes.
A UNIQUE constraint moves the decision to a layer shared by all handlers. One INSERT succeeds; the other receives a conflict and checks the saved result. A single UNIQUE is not enough: a transaction is still required, otherwise the inbox record might be saved while the order is not, or vice versa.
Failure before commit: the retry must complete the operation
The fail_before_commit test flag throws an exception after inserting the order but before the commit. The rollback removes both the inbox row and the local order. The next delivery, evt-43, sees a free key and executes the operation once.
The sqlite3 module in Python provides explicit commit and rollback. The Python version is fixed in the test because the transaction management mode of the driver changes between versions, and it must not be left implicit in the instruction.
Model boundary: the external API will not roll back with the database
Now consider a significant edge case. If a CRM, 1C, or payment service is called within a transaction, the external request may succeed in creating an object before the local commit fails. The rollback will only revert the local database. On retry, the handler will call the external service again, potentially creating a duplicate.

If an external API supports an idempotency key, pass a stable key for the same logical operation and verify its contract.
If the operation is asynchronous, save the local event and outbox record in a single transaction, while a separate worker sends it externally and confirms receipt.
Each subsequent action—sending an email, shipping goods, or creating a document in 1C—requires its own stable key or explicit state. Protecting order creation does not make the entire chain idempotent.
Here is the catch: a long transaction wrapping a network request does not merge two systems into a single database. It only holds locks longer, while the remote result remains independent.
How to apply this principle in a 1C-Bitrix project
SQLite in this article is a test bench, not a recommendation for a production 1C-Bitrix setup. Before implementation, you must record the platform version, the actual DBMS, the inbound handler location, the event identifier format, and the list of called handlers.
First, verify the webhook signature and required fields. An unconfirmed message must not occupy a trusted event_id.
Select the business event key from the sender's contract. If it is missing, a composite key must be documented and checked for collisions; the receipt time is not suitable.
Add a unique index to the main database and verify its creation on a project copy with the same table type and encoding.
Register an event, update the order status, and write the processing result within a single local transaction.
Separate external calls using an outbox or use a documented idempotency key for the external API.
Verify exact duplicates, modified bodies with the same ID, parallel delivery, exceptions before commit, and external system failures.
The signature rule depends on the service. A separate analysis shows why webhook signature verification requires the original body bytes; normalized JSON can produce a different hash.
Do not mix incoming deduplication with outgoing retry policies. The article on API retry budget limits the number and duration of sender attempts. Here, the receiver protects a single business effect from duplicate delivery.
Acceptance Protocol
Before launching on a real store, prepare an anonymized event and object counters for each phase. The expected result must be recorded in advance; otherwise, a second order can easily be dismissed as a 'test peculiarity' only after it appears.
Send a new event. Verify one inbox record, one local action, and the saved result.
Resend the same bytes with the same event_id. The number of orders, emails, reservations, and external documents must not increase.
Send a different body with the same event_id. The handler must stop the update, report a conflict, and not overwrite previous data.
Launch two deliveries simultaneously. After completion, count the objects rather than limiting yourself to two successful HTTP responses.
Interrupt processing before the local commit. Verify the rollback, then replay the event and obtain exactly one result.
Separately terminate the connection after a successful external action but before local confirmation. Check the idempotency key or outbox on the actual API.
Stop if the test affects live payments, shipping, ERP, or real personal data. Acceptance requires an isolated environment or a safe stub with controlled responses.
Readiness Criteria
A webhook receiver is not ready just because two identical requests returned a green response. It is ready when the team can repeat the test, deliver the same event in parallel, interrupt processing at a predetermined point, and prove via counters that one logical operation produced one local result, the conflict was not hidden, and external side effects are protected separately.
If such a scheme needs to be agreed upon for 1C-Bitrix, 1C, CRM, or another API, first fix the event contract, transaction boundaries, and acceptance criteria. This is also the input set for website integration design.




Обсуждение 0
Делись опытом и задавай вопросы. Комментарии без ссылок появляются после проверки редактором.
Пока никто не написал. Начни обсуждение.