Sending leads to Odoo without losing one: a transactional outbox in ASP.NET Core
Saving a lead and then calling Odoo means two writes that can each fail on their own. Anas Amin describes a transactional outbox where the lead and a message describing it commit together, and a Hangfire job delivers the message later. Leased claims, error classification, dead-lettering and idempotent Odoo writes carry the rest of the design.
Key points
- The outbox row commits in the same SaveChangesAsync and SQL transaction as the business row, so a failed Odoo call delays a lead instead of losing it.
- Workers claim messages with a five-minute lease and a guarded ExecuteUpdateAsync; an expired lease returns a crashed worker's message to the queue on its own.
- The job runs every 30 seconds and stops after 20 seconds or 500 messages, which keeps each batch bounded.
- Timeouts, 5xx, 408 and 429 are transient and retry at 1, 5, 15, 60 and 360 minutes with jitter; 401, 403 and other 4xx are dead-lettered right away.
- Idempotency comes from a GUID custom field on the Odoo lead: every delivery searches by it first, then updates or creates.
- Odoo 14-18 speak JSON-RPC with a session while Odoo 19 adds a JSON-2 API with a bearer key, so one interface and a startup factory pick the client.
Saving a lead and then calling Odoo is two writes that can disagree: the call can fail after the save, or the save after the call. A transactional outbox takes the second write out of the request. The enquiry and an OutboxMessage row — type, aggregate id, JSON payload, status, attempt count, next-attempt time — commit in one SaveChangesAsync, so either both exist or neither does.
Every 30 seconds a Hangfire job claims due messages oldest first, for at most 500 messages or 20 seconds, through a guarded ExecuteUpdateAsync: only a due pending row or a processing row with an expired lease can move on. Errors are classified once in the Odoo client — timeouts, 5xx, 408 and 429 retry at 1, 5, 15, 60 and 360 minutes with jitter, while 4xx dead-letters. A GUID custom field keeps delivery repeatable.
Real-world use
- A corporate site that captures enquiries and must hand each one to a sales team living in Odoo, even during CRM maintenance windows.
- A supplier onboarding flow where approvals stay in the app's own database and only approved partners are synced to the ERP.
- Any integration whose dispatcher runs on several instances at once and must never deliver the same message twice.
- Outbound confirmation emails or invoice sends that have to survive an app restart right after the database write.
Why it matters
Two writes across a database and an external API will eventually diverge; an outbox turns an occasional lost record into a visible delay. The lease and dead-letter details are what make scaling the dispatcher to several workers safe.