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 readViews4

A repeated webhook must not create a second order: testing idempotency

A practical analysis of a webhook receiver: a stable event_id, a UNIQUE constraint, a transaction, a retry after a failure, and two simultaneous deliveries. The tutorial example was implemented and verified on Python 3.12 and SQLite 3.53.

Comments 0

One packaged order and several identical shipping envelopes on the desktop
In this article

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.

Two identical events, evt-42, pass through a unique event_id and create a single local order.
Educational model: a PRIMARY KEY on event_id turns a repeat into a separate duplicate result, not a second order.

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.

Transaction boundary between local records and the remote API
A local transaction rolls back the inbox and the order, but not the already completed remote API call. The external action requires separate protection.
  • 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.

  1. First, verify the webhook signature and required fields. An unconfirmed message must not occupy a trusted event_id.

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

  3. Add a unique index to the main database and verify its creation on a project copy with the same table type and encoding.

  4. Register an event, update the order status, and write the processing result within a single local transaction.

  5. Separate external calls using an outbox or use a documented idempotency key for the external API.

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

  1. Send a new event. Verify one inbox record, one local action, and the saved result.

  2. Resend the same bytes with the same event_id. The number of orders, emails, reservations, and external documents must not increase.

  3. Send a different body with the same event_id. The handler must stop the update, report a conflict, and not overwrite previous data.

  4. Launch two deliveries simultaneously. After completion, count the objects rather than limiting yourself to two successful HTTP responses.

  5. Interrupt processing before the local commit. Verify the rollback, then replay the event and obtain exactly one result.

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

Discussion 0

Share your experience and ask questions. Comments without links appear after editorial review.

No comments yet. Start the discussion.