Sync Odoo Calendar & Contacts with Outlook or Google
Build a custom two-way sync of Odoo calendar events and contacts with Outlook or Google, over the JSON-RPC External API — the calendar.event and res.partner half, done right.
ODXProxy Team · Jul 12, 2026 · 8 min read

"Keep our Odoo calendar and contacts in step with Outlook (or Google)" is one of the most common
integration asks — and one where people reach for a custom build the moment the packaged connector
doesn't quite fit: multiple client instances to manage, a one-way-only flow, a bespoke field mapping, or
control over which events sync. This guide covers the Odoo half of that sync — reading and writing
calendar.event and res.partner through the JSON-RPC External API — so that whatever you're
talking to on the Outlook or Google side, the ERP side is idempotent, duplicate-free, and safe to run on a
schedule.
First: do you even need to build this?
Odoo ships native calendar and contact sync with Google (the Google Calendar / Google Contacts integrations) and Microsoft (the Outlook Calendar integration), configured under Settings → General Settings → Integrations. For a single instance and a standard mapping, turn those on before you write a line of code — they handle the OAuth dance and the recurring-event edge cases for you.
Reach for a custom API sync when the turnkey app doesn't fit:
- you manage many Odoo instances (an agency, a multi-tenant product) and want one sync service;
- you need a custom field mapping or to sync only a subset of events/contacts;
- you want a one-directional flow with a clear source of truth;
- you need control over conflict resolution and scheduling the packaged app doesn't expose.
When one of those is true, the External API exposes calendar.event and res.partner just like any other
model, and the sync is the same reusable pattern underneath every connector.
The pattern in one picture
A sync is not a feature you call; it's a small state machine you build on the nine External API actions. The backbone is always the same: for each record, look it up by a stable external id, then create or update. Before any code, decide who owns each object:
| Object | Odoo model | Typical source of truth |
|---|---|---|
| Calendar events | calendar.event | Usually the calendar provider (Outlook/Google) |
| Contacts | res.partner | Often the external CRM/address book — or Odoo |
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. The general version
of this — external-id mapping, upsert-before-create, write_date deltas, idempotency — is laid out in
how to sync data with Odoo; here we apply it to calendars and contacts
specifically.
One endpoint, two secrets
Every call below goes through ODXProxy — a reverse proxy in front of one or more Odoo instances — to a single route:
POST /api/odoo/executeYour sync service talks to that one endpoint and the proxy forwards to the right 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.
Store the provider's id on the Odoo record
The single habit that makes a sync reliable is stamping the provider's identifier onto the Odoo
record — the Outlook/Google event id on calendar.event, the provider's contact id on res.partner.
That's your match key. Without it you're matching on something fuzzy like a title or email, and every
re-run risks a duplicate. Add a custom x_external_id char field to each model (or use Odoo's
ir.model.data external-id mechanism); the examples below assume x_external_id.
Syncing a contact into res.partner
Contacts map cleanly onto res.partner. The upsert is a three-step loop: look up by external id, then
create or update. First, search for an existing partner carrying the provider id:
{
"id": "contact-lookup-1",
"action": "search_read",
"model_id": "res.partner",
"params": [[["x_external_id", "=", "gcontact-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 result is empty, create the contact and stamp it with the external id:
{
"id": "contact-create-1",
"action": "create",
"model_id": "res.partner",
"params": [{ "name": "Dana Lee", "email": "dana@example.com", "phone": "+1-555-0100", "x_external_id": "gcontact-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": "contact-write-1",
"action": "write",
"model_id": "res.partner",
"params": [[57], { "email": "dana.lee@example.com", "phone": "+1-555-0142" }],
"odoo_instance": {
"url": "https://erp.example.com",
"db": "prod",
"user_id": 2,
"api_key": "<the Odoo user API key>"
}
}The read and write mechanics are covered end to end in Odoo search_read, in depth and create, write, unlink.
Syncing a calendar event into calendar.event
Calendar events work the same way against the calendar.event model — only the field mapping differs. The
fields you care about are usually name (the title), start and stop (datetimes, in the format
"YYYY-MM-DD HH:MM:SS", UTC), allday, location, and description. Upsert an event by the provider's
event id:
{
"id": "event-upsert-1",
"action": "search_read",
"model_id": "calendar.event",
"params": [[["x_external_id", "=", "outlook-evt-5521"]]],
"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>"
}
}On an empty result, create it:
{
"id": "event-create-1",
"action": "create",
"model_id": "calendar.event",
"params": [{
"name": "Quarterly review",
"start": "2026-08-03 14:00:00",
"stop": "2026-08-03 15:00:00",
"location": "Meeting Room B",
"x_external_id": "outlook-evt-5521"
}],
"odoo_instance": {
"url": "https://erp.example.com",
"db": "prod",
"user_id": 2,
"api_key": "<the Odoo user API key>"
}
}start/stop in UTC using the YYYY-MM-DD HH:MM:SS string format, and convert to the provider's timezone on the other side. Storing everything in UTC in Odoo and translating at the edges is what keeps a cross-provider calendar sync from drifting by an hour twice a year.Linking an event to attendees or an organizer means resolving those people to res.partner ids first —
which is exactly why you sync contacts before events, so the partner records already exist to point at.
Relating records across models (many2one/one2many) is covered in
relational domain filters.
Sync only what changed
Re-copying every event and contact on every run works until the dataset grows. Pull only records changed
since your last run using Odoo's built-in write_date as a high-water mark — every model, including
calendar.event and res.partner, has it:
{
"id": "event-delta-1",
"action": "search_read",
"model_id": "calendar.event",
"params": [[["write_date", ">", "2026-07-11 00:00:00"]]],
"keyword": { "fields": ["id", "name", "start", "stop", "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 processed, so a crash mid-batch resumes
cleanly. On the provider side, track its own "updated" timestamp the same way. That's what turns a full
nightly copy into an incremental delta sync.
Pull on a schedule, push on an event
Most real calendar/contact syncs use both triggers:
- Scheduled pull — a cron/worker runs the delta query every N minutes. Simple, self-healing: a missed run just picks up more next time. Ideal for provider calendars you don't control the events of.
- Event-driven push — the source calls you the instant something changes, for low latency. On the Odoo side that means outgoing webhooks; see the Odoo webhook API for firing on record changes. On the provider side, Google and Microsoft offer their own push/webhook channels.
A common hybrid: webhooks for latency, plus a slower scheduled pull as a safety net that reconciles anything a dropped webhook missed.
Idempotency and the HTTP 200 trap
Networks retry and webhooks deliver at least once, so your handler must be safe to run twice. The
upsert loop gives you that for free: because you look up the external id before creating, a duplicate
delivery finds the existing record and becomes a harmless write (or a no-op) instead of a second event
on someone's calendar.
The one failure mode that silently corrupts a sync is misreading a response as success. Odoo speaks
JSON-RPC 2.0, and a logic error — a bad datetime, a missing required field, an access-rights denial —
comes back with HTTP 200 and a populated error object:
{
"jsonrpc": "2.0",
"id": "event-create-1",
"error": {
"code": 200,
"message": "Wrong value for calendar.event.stop: '2026-08-03 13:00:00'",
"data": { "name": "odoo.exceptions.ValidationError" }
}
}200 from the proxy does not mean Odoo accepted the write. Check the HTTP status first, then check the body's error field before you advance your sync cursor — marking an event "synced" on an error you didn't read is how a calendar quietly loses updates. Only proxy-layer failures (wrong x-api-key, upstream timeout) use a non-200 status.The full two-layer error model 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 landed.
Where to go next
- Ground this in the general blueprint: how to sync data with Odoo.
- See another concrete instance in building a Shopify–Odoo integration.
- Nail the reads and writes with Odoo search_read, in depth and create, write, unlink.
- Read the full request/response contract in the API reference, or skip the envelope boilerplate with the Python SDK.