A webhook timeout does not tell the sender whether Odoo committed the transaction. It only tells the sender that a response did not arrive in time. If the sender retries, a naive integration can create duplicate orders, payments, or inventory operations.
During Odoo implementation service overview, this failure often hides behind otherwise successful API calls. We can prevent it by giving every external event a stable identity and enforcing that identity inside PostgreSQL. This article builds that pattern for Odoo 18 and explains where it stops being sufficient.
Prerequisites
- Odoo 18.0 with a custom module.
- Python 3.12 and PostgreSQL 16, subject to verification against your deployed Odoo build.
- A basic understanding of Odoo models, recordsets, and database transactions.
- An external sender that supplies a stable event identifier.
- A development database for testing duplicate and concurrent deliveries.
The code below uses Odoo's ORM and PostgreSQL uniqueness constraints. Treat the snippets as fragments for a custom module, not as a complete installable repository.
Why the obvious approach fails
A common first attempt checks whether an event already exists before creating a record. That works for sequential requests, but it does not protect against concurrent deliveries.
Consider two workers receiving the same event nearly simultaneously:
# Fragment: unsafe duplicate check existing = self.env["integration.event"].search([ ("event_key", "=", event_key), ], limit=1) if not existing: self.env["integration.event"].create({ "event_key": event_key, }) Both transactions can search before either creates the record. Both searches return empty results, and both workers proceed.
This is a time-of-check-to-time-of-use race. Adding another search or a Python lock does not reliably solve it across multiple Odoo workers or application servers.
The database must enforce the invariant.
Step 1: Give every incoming event a stable identity
Choose an identifier that remains unchanged across retries. A provider's event ID is preferable to a timestamp or a newly generated UUID, because the sender must reuse the same identity.
If the provider does not supply a stable ID, derive one from a documented business-event identity. Do not hash an arbitrary payload without considering whether harmless field changes should represent a new event.
Step 2: Enforce uniqueness in the Odoo model
Create a dedicated model that records processed external events. The following fragment uses Odoo 18's model API and a SQL uniqueness constraint.
# Fragment: models/integration_event.py from odoo import fields, models class IntegrationEvent(models.Model): _name = "integration.event" _description = "External Integration Event" event_key = fields.Char(required=True, index=True) state = fields.Selection( [("received", "Received"), ("done", "Done")], required=True, default="received", ) _sql_constraints = [ ( "integration_event_key_unique", "UNIQUE(event_key)", "This external event has already been registered.", ), ] The uniqueness constraint is the important part. PostgreSQL serializes conflicting inserts so that two transactions cannot commit the same non-null key.
Keep the event key required. SQL uniqueness constraints generally allow multiple null values, so a nullable key would weaken this design.
For multi-company systems or multiple external providers, decide the scope of uniqueness explicitly. You may need a composite key such as (provider, company_id, event_key) instead of a globally unique event key.
Step 3: Handle a duplicate without breaking the transaction
A uniqueness violation raises a database error. Catching it without a savepoint can leave the current transaction unusable until rollback.
Use a savepoint around the insert, then retrieve the existing event if the insert conflicts. The following is a fragment for an Odoo model method; it assumes event_key has already been validated.
# Fragment: method on a model with access to env from psycopg2 import IntegrityError def register_event(self, event_key): Event = self.env["integration.event"] try: with self.env.cr.savepoint(): event = Event.create({"event_key": event_key}) return event, True except IntegrityError: event = Event.search( [("event_key", "=", event_key)], limit=1, ) if not event: raise return event, False The boolean indicates whether this call created the event. A conflicting insert is not automatically proof of a duplicate event: another constraint could have failed, so the code re-raises if no matching key exists.
This method is a building block, not a complete webhook handler. Validate the request, authenticate its sender, and confirm the event's business scope before invoking it.
Step 4: Keep event registration and business changes atomic
Idempotency is not complete if Odoo records an event as processed before creating its business record. A crash between those operations could cause a retry to be ignored even though the order was never created.
Register the event, create or update the business record, and mark the event complete in the same database transaction. If the business operation fails, let the transaction roll back.
A simplified orchestration fragment looks like this:
# Fragment: invoked within the request's Odoo transaction event, created = self.register_event(event_key) if not created and event.state == "done": return {"status": "duplicate"} if event.state != "done": self.create_business_record(payload) event.write({"state": "done"}) return {"status": "processed"} Here, create_business_record represents application-specific logic that must be implemented and tested in your module. The example assumes that the event and business record use the same database transaction.
If the process crashes before commit, both changes roll back. If the transaction commits but the HTTP response is lost, a retry finds the completed event instead of repeating the business operation.
Do not call external services inside this transaction and assume their effects will roll back with PostgreSQL. An external payment, email, or shipping request may succeed even when the local transaction fails.
Step 5: Test retries and concurrency
A successful test should cover more than sending the same request twice in sequence. Test the case that caused the original bug, including simultaneous deliveries and failures around commit.
At minimum, verify these scenarios:
- Two sequential requests with the same key create one business record.
- Two concurrent requests with the same key create one business record.
- Two different event keys create two independent records.
- An invalid payload creates no completed event.
- A business-operation failure rolls back event registration.
- A retry after a committed transaction but a lost response returns the existing result.
For a basic database-level check, inspect the stored keys after sending repeated test requests:
SELECT event_key, COUNT(*) FROM integration_event GROUP BY event_key HAVING COUNT(*) > 1; Expected output: zero rows. This query verifies that duplicates do not exist in the inspected table; it does not prove that every business side effect is idempotent.
Use Odoo integration tests and independent transactions for concurrency testing. A single-threaded unit test cannot reproduce the race reliably.
Trade-offs and limitations
Database-backed idempotency adds a table, a uniqueness index, and a retention policy. Large integrations must decide how long to preserve event identities without making duplicate detection expire prematurely.
It also assumes that retries reuse the same event key. If a sender generates a new key for every attempt, the database cannot recognize those requests as the same event.
Finally, a local transaction cannot guarantee exactly-once effects across independent systems. Use an outbox pattern for reliable outbound messages, provider-side idempotency keys where supported, and reconciliation for operations whose outcomes remain uncertain.
These are important boundaries in Odoo implementation services: database correctness, request handling, and external side effects need separate failure strategies.
Real-world application
We implemented this in... OODLES PROJECT REFERENCE NEEDED: identify an anonymized Odoo integration project where external retries or duplicate business events were a real constraint.
The implementation should describe the external system, the event being processed, the original failure mode, and the transaction or constraint change. Add a measured outcome only after validating it against project records.
For broader context on the delivery work around ERP configuration, integrations, and deployment, see the Odoo implementation service overview.
Key takeaways
- A pre-insert search cannot prevent concurrent duplicate creation.
- A stable external event ID gives retries a consistent identity.
- A database uniqueness constraint protects that identity across workers.
- A savepoint lets the application handle an expected conflict without abandoning the surrounding transaction.
- External side effects need their own idempotency, outbox, or reconciliation strategy.
If you have encountered a different failure boundary in an Odoo integration, share the scenario and how you tested recovery. A small reproducible test is often more useful than a generic claim about reliability. ContactUS
FAQ
How do I prevent duplicate webhook records in Odoo?
Use a stable event ID and enforce its uniqueness in PostgreSQL. A pre-insert search alone is vulnerable to concurrent requests, so the database must enforce the invariant.
Should I use an Odoo constraint or a PostgreSQL constraint?
Use a database uniqueness constraint when concurrent transactions must not create duplicate keys. Python-level validation remains useful for clearer business error messages, but it cannot replace database-enforced uniqueness.
What happens if the webhook sender retries after a timeout?
The retry should return the result associated with the existing event when the original transaction committed. If the original transaction rolled back, the retry should be allowed to process the event again.
Does idempotency prevent duplicate payment charges?
Not by itself. It prevents duplicate local processing only when the event identity and transaction boundaries are correct; payment providers may require their own idempotency keys and reconciliation logic.
How should I handle Odoo integration events that fail repeatedly?
Record enough information to diagnose the failure, apply bounded retries where appropriate, and separate permanent validation errors from transient failures. Use a durable queue or outbox when delivery must survive process restarts.


