Order Saved, but Webhook Not Sent: Checking the Transactional Outbox
We examine the transactional outbox using a safe model: the order and outgoing event are recorded in a single transaction; failures before and after sending do not lose the event, and retries use a stable event_id.

In this article
Если заказ и запись об исходящем webhook сохраняются раздельно, между двумя операциями остаётся окно сбоя: заказ уже принят, а событие для внешней системы потеряно. Transactional outbox закрывает это окно: бизнес-запись и событие попадают в одну транзакцию базы, а отдельный обработчик отправляет накопленные события позже.
Но тут есть подвох: outbox не даёт «ровно одну доставку». Сбой после отправки, но до отметки события как отправленного, приводит к повтору. Поэтому отправитель хранит стабильный event_id, а получатель обрабатывает его идемпотентно.
Ниже — безопасная учебная модель, фактически проверенная 25 октября 2026 года на CPython 3.12.14 и SQLite 3.53.1. Она работает только во временном каталоге, не обращается к сети и автоматически удаляет базу после завершения. Это не схема конкретного проекта на 1С-Битрикс и не результат внедрения у клиента.
Какая проблема решается
Обычная последовательность «сохранить заказ → вызвать внешний API» связывает две разные системы без общей транзакции. Если процесс завершится между шагами, база и получатель расходятся. Обратный порядок не лучше: внешний сервис может принять событие, а локальная запись заказа — откатиться.
В outbox заказ и событие сохраняются в одной локальной транзакции. Документация SQLite по транзакциям указывает, что явная транзакция продолжается до COMMIT или ROLLBACK, а описание atomic commit раскрывает принцип: изменения одной транзакции применяются вместе либо не применяются.
Точка сбоя | Состояние локальной базы | Что делать |
|---|---|---|
Между INSERT заказа и INSERT события | Оба изменения откатываются | Повторить бизнес-операцию по её правилам |
До сетевой отправки | Событие остаётся pending | Обработчик повторяет позже |
После отправки, до UPDATE sent_at | Событие остаётся pending, получатель мог принять его | Повторить с тем же event_id |
После sent_at | Событие помечено отправленным | Не выбирать его снова |
Минимальная модель
Таблица orders хранит заказ, outbox — стабильный event_id, JSON-полезную нагрузку и отметку sent_at. Ограничение UNIQUE на event_id не позволяет создать две локальные записи одного события. Оно не защищает внешний сервис: дедупликация там остаётся отдельной обязанностью.
В Python объект Connection используется как контекстный менеджер: при нормальном выходе транзакция фиксируется, при исключении откатывается. Документация модуля sqlite3 отдельно описывает commit(), rollback() и управление транзакциями.
python3 - <<'PY'
import json
import sqlite3
import tempfile
from pathlib import Path
class FakeEndpoint:
def __init__(self):
self.calls = 0
self.received_ids = set()
def post(self, event_id: str, payload: str) -> str:
self.calls += 1
json.loads(payload)
if event_id in self.received_ids:
return "duplicate"
self.received_ids.add(event_id)
return "accepted"
def counts(con: sqlite3.Connection) -> tuple[int, int, int]:
orders = con.execute("SELECT count(*) FROM orders").fetchone()[0]
pending = con.execute(
"SELECT count(*) FROM outbox WHERE sent_at IS NULL"
).fetchone()[0]
sent = con.execute(
"SELECT count(*) FROM outbox WHERE sent_at IS NOT NULL"
).fetchone()[0]
return orders, pending, sent
def create_order(
con: sqlite3.Connection,
order_id: str,
event_id: str,
*,
fail_between_writes: bool = False,
) -> None:
payload = json.dumps(
{"event": "order.created", "order_id": order_id},
separators=(",", ":"),
)
with con:
con.execute(
"INSERT INTO orders(order_id, status) VALUES (?, ?)",
(order_id, "new"),
)
if fail_between_writes:
raise RuntimeError("simulated_failure_between_writes")
con.execute(
"INSERT INTO outbox(event_id, payload) VALUES (?, ?)",
(event_id, payload),
)
def deliver_one(
con: sqlite3.Connection,
endpoint: FakeEndpoint,
*,
fail_before_send: bool = False,
fail_after_send: bool = False,
) -> str:
row = con.execute(
"""
SELECT event_id, payload
FROM outbox
WHERE sent_at IS NULL
ORDER BY id
LIMIT 1
"""
).fetchone()
if row is None:
return "nothing_to_send"
event_id, payload = row
if fail_before_send:
raise RuntimeError("simulated_network_failure_before_send")
result = endpoint.post(event_id, payload)
if fail_after_send:
raise RuntimeError("simulated_crash_after_send")
with con:
con.execute(
"UPDATE outbox SET sent_at = CURRENT_TIMESTAMP WHERE event_id = ?",
(event_id,),
)
return result
def main() -> None:
with tempfile.TemporaryDirectory(prefix="outbox-lab-") as temp_dir:
db_path = Path(temp_dir) / "lab.sqlite3"
con = sqlite3.connect(db_path)
con.executescript(
"""
CREATE TABLE orders (
order_id TEXT PRIMARY KEY,
status TEXT NOT NULL
);
CREATE TABLE outbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT NOT NULL UNIQUE,
payload TEXT NOT NULL,
sent_at TEXT
);
"""
)
con.commit()
try:
create_order(
con,
"order-broken",
"evt-broken",
fail_between_writes=True,
)
except RuntimeError as error:
assert str(error) == "simulated_failure_between_writes"
assert counts(con) == (0, 0, 0)
print("rollback: orders=0 pending=0 sent=0")
create_order(con, "order-1001", "evt-1001")
assert counts(con) == (1, 1, 0)
print("commit: orders=1 pending=1 sent=0")
endpoint = FakeEndpoint()
try:
deliver_one(con, endpoint, fail_before_send=True)
except RuntimeError as error:
assert str(error) == "simulated_network_failure_before_send"
assert counts(con) == (1, 1, 0)
assert endpoint.calls == 0
print("before_send_failure: pending=1 endpoint_calls=0")
try:
deliver_one(con, endpoint, fail_after_send=True)
except RuntimeError as error:
assert str(error) == "simulated_crash_after_send"
assert counts(con) == (1, 1, 0)
assert endpoint.calls == 1
assert endpoint.received_ids == {"evt-1001"}
print("after_send_crash: pending=1 unique_received=1 endpoint_calls=1")
result = deliver_one(con, endpoint)
assert result == "duplicate"
assert counts(con) == (1, 0, 1)
assert endpoint.calls == 2
assert endpoint.received_ids == {"evt-1001"}
print("retry: duplicate_ack pending=0 sent=1 unique_received=1 endpoint_calls=2")
con.close()
if __name__ == "__main__":
main()
PY
Что получилось
rollback: orders=0 pending=0 sent=0
commit: orders=1 pending=1 sent=0
before_send_failure: pending=1 endpoint_calls=0
after_send_crash: pending=1 unique_received=1 endpoint_calls=1
retry: duplicate_ack pending=0 sent=1 unique_received=1 endpoint_calls=2
Проверка | Ожидаемый результат | Наблюдение |
|---|---|---|
Исключение между двумя INSERT | Нет ни заказа, ни outbox-события | orders=0, pending=0, sent=0 |
Успешный commit | Один заказ и одно pending-событие | orders=1, pending=1 |
Сбой до отправки | Событие остаётся в очереди, вызова нет | pending=1, endpoint_calls=0 |
Сбой после отправки | Событие остаётся pending, получатель уже видел ID | pending=1, unique_received=1 |
Повтор | Тот же ID распознан как дубль и затем помечен отправленным | pending=0, sent=1, два вызова, одно уникальное событие |
Сценарий завершился с кодом 0. Проверка подтверждает атомарность двух локальных записей в этой модели, сохранение pending-события при двух видах сбоя и повтор с тем же event_id. Реальная сеть, СУБД проекта, параллельные обработчики, блокировки и API конкретного получателя здесь не испытывались.
Механизм повтора
Создать event_id один раз вместе с бизнес-операцией; не генерировать новый ID при каждой попытке отправки.
Выбирать только события без sent_at и передавать исходный event_id получателю.
Считать сетевой тайм-аут неоднозначным исходом: запрос мог дойти, даже если ответ потерян.
Повторять отправку по ограниченной политике с паузой, не удаляя pending-событие.
Отмечать sent_at только после допустимого ответа получателя.
Хранить у получателя обработанные event_id либо использовать его штатный ключ идемпотентности.
На маленьком примере видно границу гарантии. До вызова внешнего API outbox предотвращает потерю события. После вызова он обеспечивает повторяемость, а не уникальность доставки. Если получатель не умеет распознавать event_id, повтор после неоднозначного тайм-аута может создать второе действие.
Что добавить в рабочую реализацию
Индекс по состоянию и времени следующей попытки, чтобы обработчик не сканировал всю таблицу.
Счётчик попыток, last_error и next_attempt_at без токенов, персональных данных и полного тела ответа в журнале.
Безопасное распределение событий между несколькими обработчиками средствами выбранной СУБД.
Отдельное состояние для окончательно отклонённых сообщений и процедура разбора, а не бесконечный цикл.
Срок хранения отправленных событий и дедупликационных ключей, согласованный с максимальным окном повторов.
Метрики возраста самого старого pending-события и числа окончательных ошибок.
Конкретный SQL для конкурентного захвата зависит от СУБД и режима изоляции. Переносить учебный SELECT из SQLite в PostgreSQL, MySQL или рабочую базу 1С-Битрикс без проектирования блокировок нельзя.
Граница с 1С-Битрикс
В интеграции магазина бизнес-операция может создавать заказ, менять остаток или формировать задание обмена. Outbox имеет смысл только тогда, когда бизнес-запись и событие действительно фиксируются одной транзакцией в одном хранилище. Если модуль, агент или внешний коннектор открывает отдельное соединение либо выполняет скрытый commit, это условие нужно проверить по версии и коду конкретного решения.
Не стоит объявлять outbox заменой штатного механизма событий 1С-Битрикс. Это шаблон согласования локальной записи и внешней доставки. Точка подключения, границы транзакции, права и влияние повторной обработки требуют отдельной приёмки на изолированном стенде проекта.
Критерий готовности
При исключении между записью заказа и события обе записи отсутствуют либо обе зафиксированы.
Сбой до отправки не удаляет pending-событие.
Сбой после фактической отправки приводит к повтору с тем же event_id.
Получатель подтверждённо не выполняет второе бизнес-действие для уже обработанного event_id.
При работе нескольких обработчиков механизм распределения событий предотвращает одновременный захват одной записи двумя обработчиками.
Есть ограничение повторов, наблюдаемая очередь ошибок и безопасная процедура ручного разбора.
Готовность подтверждает не один успешный webhook, а управляемое поведение в трёх точках: до commit, до сетевой отправки и после отправки до локальной отметки. Именно третья точка показывает, почему transactional outbox и идемпотентный получатель нужны вместе.
If an order and an outgoing webhook record are saved separately, a failure window remains between the two operations: the order is already accepted, but the event for the external system is lost. The transactional outbox closes this window: the business record and the event enter a single database transaction, and a separate handler sends the accumulated events later.
However, there is a catch: an outbox does not guarantee exactly one delivery. A failure after sending but before marking the event as sent leads to a retry. Therefore, the sender stores a stable event_id, and the receiver processes it idempotently.
Below is a safe educational model, actually tested on October 25, 2026, on CPython 3.12.14 and SQLite 3.53.1. It works only in a temporary directory, does not access the network, and automatically deletes the database after completion. This is not a schema for a specific 1C-Bitrix project and not an implementation result for a client.
Problem Addressed
The usual sequence of save order → call external API links two different systems without a shared transaction. If the process ends between steps, the database and the receiver diverge. The reverse order is no better: the external service may accept the event, while the local order record rolls back.
In the outbox pattern, the order and the event are saved within a single local transaction. SQLite documentation on transactions states that an explicit transaction continues until COMMIT or ROLLBACK, while the atomic commit description explains the principle: changes within one transaction are applied together or not at all.
Failure point | Local database state | What to do |
|---|---|---|
Between the order INSERT and the event INSERT | Both changes are rolled back | Repeat the business operation according to its rules |
Before network transmission | Event remains pending | Handler retries later |
After sending, before UPDATE sent_at | Event remains pending; the recipient may have accepted it | Retry with the same event_id |
After sent_at | Event marked as sent | Do not select it again |
Minimal Model
The orders table stores the order, the outbox stores a stable event_id, JSON payload, and the sent_at timestamp. The UNIQUE constraint on event_id prevents creating two local records for the same event. It does not protect the external service: deduplication there remains a separate responsibility.
In Python, the Connection object is used as a context manager: on normal exit, the transaction is committed; on exception, it is rolled back. sqlite3 module documentation separately describes commit(), rollback(), and transaction management.
python3 - <<'PY'
import json
import sqlite3
import tempfile
from pathlib import Path
class FakeEndpoint:
def __init__(self):
self.calls = 0
self.received_ids = set()
def post(self, event_id: str, payload: str) -> str:
self.calls += 1
json.loads(payload)
if event_id in self.received_ids:
return "duplicate"
self.received_ids.add(event_id)
return "accepted"
def counts(con: sqlite3.Connection) -> tuple[int, int, int]:
orders = con.execute("SELECT count(*) FROM orders").fetchone()[0]
pending = con.execute(
"SELECT count(*) FROM outbox WHERE sent_at IS NULL"
).fetchone()[0]
sent = con.execute(
"SELECT count(*) FROM outbox WHERE sent_at IS NOT NULL"
).fetchone()[0]
return orders, pending, sent
def create_order(
con: sqlite3.Connection,
order_id: str,
event_id: str,
*,
fail_between_writes: bool = False,
) -> None:
payload = json.dumps(
{"event": "order.created", "order_id": order_id},
separators=(",", ":"),
)
with con:
con.execute(
"INSERT INTO orders(order_id, status) VALUES (?, ?)",
(order_id, "new"),
)
if fail_between_writes:
raise RuntimeError("simulated_failure_between_writes")
con.execute(
"INSERT INTO outbox(event_id, payload) VALUES (?, ?)",
(event_id, payload),
)
def deliver_one(
con: sqlite3.Connection,
endpoint: FakeEndpoint,
*,
fail_before_send: bool = False,
fail_after_send: bool = False,
) -> str:
row = con.execute(
"""
SELECT event_id, payload
FROM outbox
WHERE sent_at IS NULL
ORDER BY id
LIMIT 1
"""
).fetchone()
if row is None:
return "nothing_to_send"
event_id, payload = row
if fail_before_send:
raise RuntimeError("simulated_network_failure_before_send")
result = endpoint.post(event_id, payload)
if fail_after_send:
raise RuntimeError("simulated_crash_after_send")
with con:
con.execute(
"UPDATE outbox SET sent_at = CURRENT_TIMESTAMP WHERE event_id = ?",
(event_id,),
)
return result
def main() -> None:
with tempfile.TemporaryDirectory(prefix="outbox-lab-") as temp_dir:
db_path = Path(temp_dir) / "lab.sqlite3"
con = sqlite3.connect(db_path)
con.executescript(
"""
CREATE TABLE orders (
order_id TEXT PRIMARY KEY,
status TEXT NOT NULL
);
CREATE TABLE outbox (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT NOT NULL UNIQUE,
payload TEXT NOT NULL,
sent_at TEXT
);
"""
)
con.commit()
try:
create_order(
con,
"order-broken",
"evt-broken",
fail_between_writes=True,
)
except RuntimeError as error:
assert str(error) == "simulated_failure_between_writes"
assert counts(con) == (0, 0, 0)
print("rollback: orders=0 pending=0 sent=0")
create_order(con, "order-1001", "evt-1001")
assert counts(con) == (1, 1, 0)
print("commit: orders=1 pending=1 sent=0")
endpoint = FakeEndpoint()
try:
deliver_one(con, endpoint, fail_before_send=True)
except RuntimeError as error:
assert str(error) == "simulated_network_failure_before_send"
assert counts(con) == (1, 1, 0)
assert endpoint.calls == 0
print("before_send_failure: pending=1 endpoint_calls=0")
try:
deliver_one(con, endpoint, fail_after_send=True)
except RuntimeError as error:
assert str(error) == "simulated_crash_after_send"
assert counts(con) == (1, 1, 0)
assert endpoint.calls == 1
assert endpoint.received_ids == {"evt-1001"}
print("after_send_crash: pending=1 unique_received=1 endpoint_calls=1")
result = deliver_one(con, endpoint)
assert result == "duplicate"
assert counts(con) == (1, 0, 1)
assert endpoint.calls == 2
assert endpoint.received_ids == {"evt-1001"}
print("retry: duplicate_ack pending=0 sent=1 unique_received=1 endpoint_calls=2")
con.close()
if __name__ == "__main__":
main()
PY
Outcome
rollback: orders=0 pending=0 sent=0
commit: orders=1 pending=1 sent=0
before_send_failure: pending=1 endpoint_calls=0
after_send_crash: pending=1 unique_received=1 endpoint_calls=1
retry: duplicate_ack pending=0 sent=1 unique_received=1 endpoint_calls=2
Check | Expected result | Observation |
|---|---|---|
Exception between two INSERTs | No order and no outbox event | orders=0, pending=0, sent=0 |
Successful commit | One order and one pending event | orders=1, pending=1 |
Failure before sending | Event remains in the queue; no call made | pending=1, endpoint_calls=0 |
Failure after sending | Event remains pending; recipient has already seen the ID | pending=1, unique_received=1 |
Retry | The same ID is recognized as a duplicate and then marked as sent | pending=0, sent=1, two calls, one unique event |
The scenario completed with code 0. The check confirms the atomicity of two local records in this model, the preservation of the pending event under two types of failure, and a retry with the same event_id. The real network, the project's DBMS, parallel handlers, locks, and the specific recipient's API were not tested here.
Retry Mechanism
Create the event_id once together with the business operation; do not generate a new ID with each send attempt.
Select only events without sent_at and pass the original event_id to the recipient.
Treat a network timeout as an ambiguous outcome: the request may have arrived even if the response was lost.
Retry sending under a limited policy with a pause, without removing the pending event.
Mark sent_at only after receiving an acceptable response from the recipient.
Store processed event_id with the recipient or use their standard idempotency key.
A small example illustrates the boundary of the guarantee. Before calling the external API, the outbox prevents event loss. After the call, it ensures repeatability, not unique delivery. If the recipient cannot recognize event_id, a retry after an ambiguous timeout may create a second action.
What to Add to a Production Implementation
Index by status and next attempt time so the handler does not scan the entire table.
Attempt counter, last_error, and next_attempt_at without tokens, personal data, or the full response body in the log.
Safely distribute events among multiple handlers using the capabilities of the selected DBMS.
Separate state for permanently rejected messages and a resolution procedure, not an infinite loop.
Retention period for sent events and deduplication keys, agreed upon with the maximum retry window.
Metrics for the age of the oldest pending event and the count of final errors.
Specific SQL for concurrent locking depends on the DBMS and isolation level. Do not port a sample SELECT from SQLite to PostgreSQL, MySQL, or a production 1C-Bitrix database without designing appropriate locking mechanisms.
Boundary with 1C-Bitrix
In a store integration, a business operation may create an order, update stock, or generate an exchange task. An outbox is meaningful only when the business record and the event are committed in a single transaction within the same storage. If a module, agent, or external connector opens a separate connection or performs a hidden commit, this condition must be verified against the version and code of the specific solution.
Do not declare an outbox as a replacement for the native event mechanism in 1C-Bitrix. This is a pattern for reconciling a local record with external delivery. The connection point, transaction boundaries, permissions, and the impact of reprocessing require separate acceptance testing on an isolated project stand.
Readiness Criteria
When an exception occurs between the order record and the event, either both records are absent or both are committed.
A failure before sending does not remove the pending event.
A failure after actual sending triggers a retry with the same event_id.
The recipient has confirmed that it does not perform the second business action for an already processed event_id.
When multiple handlers operate, the event distribution mechanism prevents two handlers from simultaneously locking the same record.
There is a retry limit, an observable error queue, and a safe procedure for manual resolution.
Readiness is confirmed not by a single successful webhook, but by controlled behavior at three points: before commit, before network transmission, and after transmission until local marking. It is precisely the third point that demonstrates why transactional outbox and an idempotent recipient are needed together.




Discussion 0
Share your experience and ask questions. Comments without links appear after editorial review.
No comments yet. Start the discussion.