How to Sync Data with Odoo via the External API
A developer's blueprint for syncing data with Odoo over the JSON-RPC External API: external-id mapping, upsert-before-create, change detection, and idempotency.
ODXProxy Team · Jul 9, 2026 · 8 min read

Whether you're pushing a calendar, a contact list, a bank feed, or a whole product catalog, the job is the same: keep two systems agreeing about the same records over time. This guide is the developer's blueprint for how to sync data with Odoo through its JSON-RPC External API — not a specific connector, but the reusable pattern underneath every one of them. Get four things right — an external identifier, an upsert loop, change detection, and idempotency — and the same handful of calls will sync almost anything into or out of Odoo without creating duplicates or losing edits.
A sync is a pattern, not a feature
There is no single "sync" button in the API. A sync is a small state machine you build on top of the nine External API actions, and the first design decision — before any code — is who owns each object. For every kind of record you're syncing, one system is the source of truth and the other is a replica:
| Data | Typical source of truth | Direction |
|---|---|---|
| Contacts / customers | The external app (CRM, storefront) | External → Odoo |
| Products & prices | Odoo | Odoo → external |
| Calendar events | The calendar provider | Provider → Odoo |
| Bank transactions | The bank feed | Feed → Odoo |
| Invoices / accounting | Odoo | Odoo → external |
Pick the owner per object, and each flow becomes a one-directional copy with a clear winner when the two sides disagree. Two-way sync is just two one-directional flows with different owners — never a free-for-all where both sides overwrite each other.
One endpoint, two secrets
Every call below goes to the same route through the proxy:
POST /api/odoo/executeYour sync service talks to that one endpoint and the proxy forwards to the right Odoo instance. Keep the
two credentials straight — conflating them is the usual cause of a stray 401:
x-api-key— the proxy's key, an HTTP header that authenticates your service to the proxy.odoo_instance.api_key— the Odoo user's key, sent per request inside the body, which authenticates the ERP call itself.
If that distinction is new, start with how to authenticate to the Odoo API and the one route you actually call.
The external-id map is the whole trick
The single habit that makes a sync reliable is storing the external system's identifier on the Odoo record. Before you decide whether to create or update, you look the record up by that id. Without it, your only matching key is something fuzzy like a name or email, and every re-run risks a duplicate.
You have two clean options:
- A dedicated field on the model — a custom
x_external_idchar field you add tores.partner,calendar.event, etc. Simple and queryable. - Odoo's own
ir.model.dataexternal IDs (themodule.namexmlid mechanism), if you want Odoo to own the mapping table for you.
The rest of this guide assumes a x_external_id field, because it's the most transparent to query.
The upsert loop
Every sync direction reduces to the same three-step loop per record: look up, then create or update. Start by searching for an existing Odoo record carrying the external id:
{
"id": "sync-lookup-1",
"action": "search_read",
"model_id": "res.partner",
"params": [[["x_external_id", "=", "crm-8841"]]],
"keyword": { "fields": ["id", "write_date"], "limit": 1 },
"odoo_instance": {
"url": "https://erp.example.com",
"db": "prod",
"user_id": 2,
"api_key": "<the Odoo user API key>"
}
}If the result array is empty, create the record and stamp it with the external id:
{
"id": "sync-create-1",
"action": "create",
"model_id": "res.partner",
"params": [{ "name": "Dana Lee", "email": "dana@example.com", "x_external_id": "crm-8841" }],
"odoo_instance": {
"url": "https://erp.example.com",
"db": "prod",
"user_id": 2,
"api_key": "<the Odoo user API key>"
}
}If the lookup returned a row, write to the id it gave you instead:
{
"id": "sync-write-1",
"action": "write",
"model_id": "res.partner",
"params": [[57], { "email": "dana.lee@example.com" }],
"odoo_instance": {
"url": "https://erp.example.com",
"db": "prod",
"user_id": 2,
"api_key": "<the Odoo user API key>"
}
}That branch — read, then create-or-update against a stable external id — is the backbone of every sync, in either direction. The read and write mechanics are covered end to end in Odoo search_read, in depth and create, write, unlink.
Detecting what changed
Re-copying every record on every run works until your dataset grows, then it's wasteful and slow. The
pull-side optimization is to sync only records changed since your last run, using Odoo's built-in
write_date (every model has it) as a high-water mark. Keep the timestamp of your last successful sync
and filter on it:
{
"id": "sync-delta-1",
"action": "search_read",
"model_id": "res.partner",
"params": [[["write_date", ">", "2026-07-08 00:00:00"]]],
"keyword": { "fields": ["id", "name", "email", "x_external_id"], "order": "write_date asc" },
"odoo_instance": {
"url": "https://erp.example.com",
"db": "prod",
"user_id": 2,
"api_key": "<the Odoo user API key>"
}
}Order by write_date asc and advance your cursor to the last row you processed, so a crash mid-batch
resumes cleanly instead of reprocessing from zero. On the push side, track the external system's own
"updated at" field the same way. This is what turns a nightly full copy into an incremental delta sync.
Pull on a schedule, or push on an event
There are two ways to trigger a sync, and most real integrations use both:
- Scheduled pull — a cron/worker runs the delta query above every N minutes. Simple, robust, and self-healing: a missed run just picks up more records next time. Best for feeds you don't control the events of (bank statements, calendars).
- Event-driven push — the source system calls your endpoint the instant something changes. Lower latency, less wasted work. On the Odoo side, that means outgoing webhooks; see the Odoo webhook API for wiring Odoo to fire on record changes.
A common hybrid: webhooks for low-latency updates, plus a slower scheduled pull as a safety net that reconciles anything a dropped webhook missed.
Idempotency: assume every message arrives twice
Networks retry, cron overlaps, and webhooks are delivered at least once — so your handler must be
safe to run twice on the same input. The upsert loop already gives you this for free: because you look up
the external id before creating, a duplicate delivery finds the existing record and turns into a harmless
write (or a no-op) instead of a second row. Idempotency isn't an extra feature you bolt on; it's the
natural result of upsert-before-create.
200 from the proxy does not mean Odoo accepted the write. Odoo logic errors — a missing required field, a blocked state transition, an access-rights denial — come back with HTTP 200 and a populated error object. Check the HTTP status first, then check the body's error field before you advance your sync cursor. Marking a record "synced" on an error you didn't read is how data silently drifts.The full two-layer error model — proxy failures versus Odoo logic errors, and how to retry each — is in Odoo API error handling. It matters more in a sync than anywhere else, because a mis-read error advances your cursor past a record that never actually landed.
Same pattern, different models
Once the loop is in place, "syncing X with Odoo" is mostly a question of which model and fields to map. The mechanics don't change:
| Sync | Odoo model | Match key |
|---|---|---|
| Contacts | res.partner | email or x_external_id |
| Calendar events | calendar.event | provider event id in x_external_id |
| Bank transactions | account.bank.statement.line | bank transaction id |
| Products | product.product | SKU or x_external_id |
For state transitions that aren't plain CRUD — confirming an order, posting an invoice, reconciling a
statement — reach past the eight data actions to call_method with a non-empty fn_name, covered in
calling Odoo model methods.
A realistic scope note
For mainstream systems, check whether Odoo already ships a connector before you build — for a plain
store or a standard calendar, the packaged app may be all you need. Reach for a custom API sync when your
mapping is bespoke, the source system has no connector, or you need control over conflict resolution and
scheduling that a turnkey app doesn't give you. When you do, the External API exposes the whole ERP, and
the four levers here — external-id mapping, upsert-before-create, write_date deltas, and the two-step
error check — are what keep the sync trustworthy over months of running.
Where to go next
- See a concrete instance of this pattern in Building a Shopify–Odoo integration.
- Nail the reads and writes with Odoo search_read, in depth and create, write, unlink.
- Trigger state changes with call_method, and drive event-based syncs with the Odoo webhook API.
- Read the full request and response contract in the API reference, or skip the envelope boilerplate with the Python SDK.