Most requests for an Odoo integration arrive as a sentence about plumbing: connect Odoo to the marketplace, to the warehouse, to the bank. The plumbing is the easy half. Two systems already disagree about what a product is, what a price includes and when an order becomes real, and the connector either settles those arguments or forwards them into your database every night.
Below is the order I go in, and the things I have had to fix afterwards anyway.
The first question is not technical: who owns each field
Before any transport is chosen, one table has to exist. Down the left, every piece of data that crosses the border: product, stock, price, order, customer, invoice. Across the top, two columns — who may change it, and who merely receives it.
Stock is the honest example. If the marketplace can change quantities and so can Odoo, you do not have an integration, you have a race, and the loser is whichever system wrote second. So the table says: quantities are owned by Odoo. The marketplace gets told, and whatever it thinks it knows is overwritten on the next run. That line is worth more than any amount of merge logic and it takes ten minutes to agree on.
Every field where the answer is "both" comes back later. The one I check first is the customer's email, because it is the field most likely to be edited on both sides — once in the webshop by the customer fixing a typo, once in Odoo by whoever answers the phone. Whichever run happens next quietly wins, and nobody finds out until the invoices go to the old address.
Transport is a choice, and in Odoo 19 it changed
Odoo can talk in several ways, and picking the wrong one is cheap today and expensive in month four.
XML-RPC and JSON-RPC are built in and need no code on the Odoo side. Good for a script that lives elsewhere and moves a modest number of records. Bad as the backbone of a busy sync: every execute_kw carries credentials and re-authenticates, so a loop over ten thousand records is ten thousand logins.
Two things to know before building on them. The credential should be an API key from the user's own preferences rather than a password — with two-factor enabled a password will not work at all. And in Odoo 19 the old endpoints are deprecated: the source says it plainly, /xmlrpc, /xmlrpc/2 and /jsonrpc are on the way out, with POST /json/2/<model>/<method> and a bearer token taking over. They still answer today. If you are writing a connector against 19 now, write it against the new one; if you are inheriting one written against /xmlrpc/2, that is a line in the next upgrade estimate rather than a surprise during it.
A controller in your own module is what you want when the other system pushes data and you need to validate it, transform it and answer with something meaningful. It takes three decisions you cannot postpone: auth='user' means a real user and a real session, auth='public' runs with the website user's very small rights, and auth='none' means you are doing authentication yourself in the first five lines of the method. An endpoint another server posts to also needs csrf=False — which is exactly the moment you took responsibility for checking who is calling. In 19 the route type for JSON is now jsonrpc; type='json' still works and logs a deprecation warning.
Webhooks. Odoo has had a built-in receiver since 17: an automation rule with an "on webhook" trigger gives you a URL with a secret in it and drops the body into a variable. Genuinely useful for a low-volume hook. Note what it does not do — there is no signature check anywhere in it, so the security model is "nobody ever pasted this URL into a support ticket", and there is a rotate button for the day somebody did. Anything that moves money or stock I still take through my own controller, where the signature is verified before the body is parsed. A public endpoint that writes whatever it receives is somebody else's write access to your database.
Files over SFTP are still how a lot of suppliers work, and there is nothing wrong with that. A CSV that lands every morning at six is easier to reason about than an API with no changelog and no status page. What files need is a landing directory, an archive of what was received, and a written rule for the row that will not parse.
Most of what I build uses two of these: a push for orders, because those are urgent, and a nightly pull that compares totals and complains if they differ.
Assume every message arrives twice
Networks retry, queues retry, and people press the button again because the first click "did nothing". The question was never whether a message arrives twice.
The answer is an external key stored on the Odoo side — the marketplace's order id on the order, the supplier's line id on the line — with a unique constraint behind it. Then the second delivery updates a record instead of creating its twin.
Two details that have cost people a production week. First, make the key per company if the database has more than one: two companies buying from the same supplier will legitimately see the same external id. Second, watch the log the first time the constraint installs. If the table already holds duplicates, Postgres refuses to create it, Odoo logs a warning and carries on — the module comes up green and the guarantee you thought you bought is not there.
Without that key nothing throws. The warehouse just prints two picking lists for one order, and the first to know is whoever is standing there holding both.
Outbound has the same problem in the mirror, and it has no clean answer: your database transaction cannot contain somebody else's HTTP call. Commit the flag before the call and a failure leaves a record marked as sent that never was. Commit it after and a timeout leaves you sending it again. So the record is marked "in flight" before the call and "sent" after, the receiving side is made idempotent on my reference so that sending twice is boring, and a scheduled sweep picks up anything that has been "in flight" for an hour — because from the outside, that is what a crash looks like.
The mapping table is where the project actually is
The transport takes a day. The mapping takes the project.
Units of measure that exist on one side and not the other. Taxes included in one system and added in the other. A marketplace that treats a variant as a product and a bundle as a variant. Rounding, which in Odoo is not a property of the field: price precision comes from decimal.precision records and currency rounding from the currency itself, so two totals that look equal differ by a cent and the line silently fails to match. Compare money with float_compare, never with ==.
Barcodes are the one I hit most. One side stores a 13-digit EAN, the other drops the leading zero because a spreadsheet touched the file on the way, and half the catalogue fails to match while both systems report success.
None of it is hard. It is just long, and it is where an integration either says out loud what it does with the edge cases or quietly loses two per cent of the rows. That two per cent is what someone finds in March, in the accounts.
Mappings live as data, not as code: a model with an access rule, edited from a list view by the client's own people. I do not want to be the bottleneck for "this supplier calls a pallet PAL and that one calls it PLT".
What happens when the other side is down
I wrote in the piece on how modules get built that "retries twice, then parks the order and emails me" is an architecture. This is what it costs to actually have one.
Runs go through a job queue — in practice OCA's queue_job, because Odoo core has nothing of the kind. A call becomes a job record with its own channel, a retry pattern with growing delays, and a cap after which it stops and waits for a human instead of hammering a service that is clearly unwell. It needs a line in the server config and a worker to run on, which is worth knowing before anyone promises it on cheap shared hosting.
Scheduled work runs as an ir.cron, and here I have to correct something I have said carelessly before: the cron does not need a lock of your own. Odoo locks the row itself, so a job cannot overlap with its own previous run. The real trap is the clock. A cron worker is killed on a time limit, so a long import does not fail — it gets cut in half, and you find out because yesterday's run stopped at supplier F. Long work belongs in the queue, with the cron only as a starter. And on a server with workers, scheduled jobs need a cron thread configured at all; without one nothing runs and nothing complains, which is a very quiet way for an integration to be dead on arrival.
One rule I will not bend: an integration must never fail silently. A stopped job nobody sees is worse than a crash, because the business keeps making decisions on numbers that stopped moving three days ago.
Volume changes the design, not the schedule
Two hundred records forgive everything. At a hundred thousand the same code meets the request timeout, the memory limit and the other side's rate limiter, usually in that order, and none of them were visible in the sample file the client sent.
Write in batches: create() takes a list, and one call with a thousand dictionaries is a different animal from a thousand calls. On a mass import I also turn off the machinery built for humans — tracking, chatter, notifications — because otherwise most of the run is spent writing messages nobody will read.
Delta sync means asking for what changed since the last run, and write_date is the obvious filter and a slightly treacherous one: it moves whenever a stored computed field is recalculated, so one mass recompute makes the entire catalogue "changed". It also says nothing about deletions. I keep my own last-sync stamp on the connector and handle deletions explicitly, because neither system will volunteer that a row stopped existing.
Scale multiplies in ways the design has to know about in advance. A catalogue of a hundred and ten thousand products will not fit a full nightly pull into any sensible window. An instance serving forty storefronts is forty price lists and forty sets of stock in one run.
The first day is its own project
Nothing above describes go-live. Before a nightly sync matters at all, somebody moves the history: open orders, current stock, the customers that exist in both systems under slightly different names.
That is a separate, throwaway import with its own matching report — how many rows matched, how many were created, how many were left for a human to look at — and it runs twice on a copy before it goes anywhere near production. Then the two systems usually run in parallel for a week with a daily comparison of totals. That week is the cheapest chance you will ever get to find out the mapping was wrong.
How it gets tested when the other side is a live supplier
Few partners have a sandbox, and a staging database is a copy of production with real credentials sitting in it. The first untouched rehearsal will confirm real orders to a real supplier and email real customers.
So staging gets the usual neutralisation — outgoing mail off, scheduled jobs off — and on top of that the connector's credentials are swapped for test ones, or pointed at a file on disk. Where there is no test endpoint at all, I record a week of real responses and replay them. That is slower to set up, and it is the only way to test the failures you cannot ask a partner to reproduce on demand.
Credentials, and the temptation of sudo
API keys live in the module's own configuration record or in system parameters, never in the source. Both beat a repository and neither is a vault: system parameters are read through sudo, so any code in the database can fetch the value, and the value travels in every backup — including the copy on staging and the one on somebody's laptop. What keeps a key off screens is restricting the field to the settings group, which removes it from the view rather than merely hiding it. Where the hosting allows it, the safest place is the server config file, because that does not travel with the dump.
Now the one I look for hardest in anyone's code, mine included: sudo(). It drops access rules and record rules together, it is the quickest way to make an integration "work", and in a public controller it is how an endpoint built for one supplier ends up answering anyone who guesses the URL. When a public endpoint genuinely has to touch one model, the honest shape is a dedicated user with exactly those rights and with_user().
Who pays when their API changes
An integration is not bought once, because the other side gets a vote. Marketplaces rename fields, banks change authentication, and Odoo itself retires transports — the deprecated RPC endpoints above are exactly that story, arriving with years of notice for anyone reading release notes.
So it goes in writing: which breakages are mine to fix under warranty, which are chargeable because the other side moved, and roughly what a version bump on either side costs. Then the first breaking change is a scheduled afternoon instead of an argument.
Three questions before you commission one
Which system owns each field, and what happens when both of them change it?
What does the integration do when the other side returns errors for six hours?
How do I check tomorrow morning that last night's run was fine?
You are not grading the answers, you are listening for whether they exist. The first should produce a table, not a sentence. The second should involve a queue somewhere. The third should not involve me — if the only way to know whether last night's import worked is to write to the developer, the job is not finished; it should be a menu item the office manager opens with her coffee.
If you have two systems that should be talking and are not, write to me. Bring the field list, even a rough one, and we will spend the first half hour on who owns what.