Payments are experiencing issues due to temporary restrictions in Russia. If your payment does not go through, please submit a support request.Our support team is available 24/7 — we are always here to help with hosting and server issues.We are now accepting requests for dedicated server rental and colocation services in our data center.Reminder: we recommend enabling backups for additional data protection.A new VPS/VDS lineup with NVMe storage and improved performance is now available.Maintenance work on some servers has been completed. All services are operating normally.
Article10 min readViews0

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.

Comments 0

The package and event token pass together through an industrial transport gateway
In this article

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

  1. Create the event_id once together with the business operation; do not generate a new ID with each send attempt.

  2. Select only events without sent_at and pass the original event_id to the recipient.

  3. Treat a network timeout as an ambiguous outcome: the request may have arrived even if the response was lost.

  4. Retry sending under a limited policy with a pause, without removing the pending event.

  5. Mark sent_at only after receiving an acceptable response from the recipient.

  6. 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.