← Blog
odoointegrationcalendarcontactssync

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

Sync Odoo Calendar & Contacts with Outlook or Google — ODXProxy blog cover

"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:

ObjectOdoo modelTypical source of truth
Calendar eventscalendar.eventUsually the calendar provider (Outlook/Google)
Contactsres.partnerOften 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/execute

Your 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>"
  }
}
Send 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" }
  }
}
A 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