← Blog
odooxeroaccountingintegrationjson-rpc

Odoo Xero Integration: Sync Accounting via the API

Build a custom Odoo–Xero integration with the External API: which account models to touch, the JSON-RPC calls, webhooks, and idempotency that keep the books in sync.

ODXProxy Team · Jul 14, 2026 · 9 min read

Odoo Xero Integration: Sync Accounting via the API — ODXProxy blog cover

Plenty of businesses run Odoo for operations and Xero for the books, and eventually someone asks for the two to talk: contacts in both, invoices raised in one appearing in the other, payments reconciled without double entry. There are packaged connectors, but accounting is exactly where "close enough" isn't — tax codes, account mapping, and payment matching have to be right. When a packaged tool doesn't fit your chart of accounts or your workflow, you build the Odoo Xero integration yourself against the API. This guide is the developer's version: which Odoo accounting models to touch, the exact External API calls, how to drive it from Xero webhooks, and the idempotency patterns that keep a finance sync from double-posting.

Two APIs, one integration

A custom Odoo–Xero integration is a small service that speaks to both sides and keeps a mapping between their record ids:

  • Xero Accounting API — a REST API at https://api.xero.com/api.xro/2.0/, authenticated with OAuth 2.0 (you register an app, obtain access/refresh tokens, and pass the tenant id per call). It exposes Contacts, Invoices, Payments, Accounts, and more.
  • Odoo External API — the ERP side, reached here through the ODXProxy JSON-RPC endpoint.

Your service sits in the middle: it reads from one, writes to the other, and remembers which record maps to which. The Xero half is standard OAuth REST work; the interesting engineering is on the Odoo side, which is where this guide spends its time.

Decide the source of truth for each object first

"Sync Odoo with Xero" is really several flows, and each one needs a single owner. Deciding who is authoritative for each object is the most important design step — get it wrong and the two systems overwrite each other. A common split when Odoo runs operations and Xero is the ledger:

DataSource of truthDirection
Customers & suppliersOdooOdoo → Xero
Customer invoices (AR)OdooOdoo → Xero
Vendor bills (AP)OdooOdoo → Xero
Payments & reconciliationXeroXero → Odoo
Chart of accounts / tax codesXeroreference map, rarely synced

The rest of this guide follows the two highest-value flows: pushing an Odoo customer invoice out to Xero, and pulling a payment in from Xero.

The Odoo models you'll touch

An accounting integration maps onto a small set of Odoo models. Knowing them upfront saves a lot of fields_get spelunking:

  • res.partner — customers and suppliers, the counterpart to a Xero Contact.
  • account.move — the accounting document. move_type distinguishes a customer invoice (out_invoice), a vendor bill (in_invoice), and credit notes.
  • account.move.line — the invoice lines, created through the invoice_line_ids one2many.
  • account.payment — a registered payment against an invoice.
  • account.account / account.tax — the chart of accounts and tax codes you map Xero's onto.
  • product.product — the products or services an invoice line references.

You reach all of them the same way, through the nine actions of the External API. If these read/write mechanics are new, Odoo search_read, in depth and create, write, unlink cover what's used below.

One endpoint, two secrets

Every Odoo call in this guide goes to the same route through the proxy:

POST /api/odoo/execute

Your service talks to that one endpoint; the proxy forwards to the right Odoo instance. Keep the two secrets straight — conflating them is the usual cause of a stray 401:

  • x-api-key — the proxy's key, an HTTP header authenticating your integration to the proxy.
  • odoo_instance.api_key — the Odoo user's key, sent per request in the body, authenticating the ERP call itself.

Neither of these is the Xero OAuth token — that lives entirely on the Xero side. The full credential setup for the proxy is in how to authenticate to the Odoo API.

Flow 1 — match a Xero contact to its Odoo partner

Whichever way a record syncs, you constantly need to answer "does this Xero contact already exist in Odoo?" The robust way is an external identifier: store the Xero ContactID on the Odoo partner (a dedicated field like x_xero_id) and look it up before you decide to create or update. That single habit is what makes the sync idempotent.

Look up first with search_read:

{
  "id": "find-partner-1",
  "action": "search_read",
  "model_id": "res.partner",
  "params": [[["x_xero_id", "=", "e1b2c3d4-0000-0000-0000-000000000000"]]],
  "keyword": { "fields": ["id", "name"], "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 partner and store the Xero id on it; if it has a row, write to the returned id. That branch — read, then create-or-update — is the backbone of every sync direction. The domain syntax in that filter is explained in Odoo domain filters explained.

Flow 2 — push an Odoo customer invoice to Xero

When Odoo posts a customer invoice, your integration mirrors it into Xero. Read the invoice and its lines from Odoo first — an account.move of type out_invoice, with the fields Xero needs:

{
  "id": "read-invoice-1",
  "action": "search_read",
  "model_id": "account.move",
  "params": [[["move_type", "=", "out_invoice"], ["state", "=", "posted"], ["x_xero_id", "=", false]]],
  "keyword": {
    "fields": ["name", "partner_id", "invoice_date", "amount_total", "invoice_line_ids"],
    "limit": 50
  },
  "odoo_instance": {
    "url": "https://erp.example.com",
    "db": "prod",
    "user_id": 2,
    "api_key": "<the Odoo user API key>"
  }
}

The domain ["x_xero_id", "=", false] selects only invoices you haven't pushed yet — a simple, reliable "unsynced" queue. Build the Xero Invoice payload from each row, POST it to Xero, then write the returned Xero InvoiceID back onto the Odoo record so it's never sent twice:

{
  "id": "tag-invoice-1",
  "action": "write",
  "model_id": "account.move",
  "params": [[142], { "x_xero_id": "a9f8e7d6-0000-0000-0000-000000000000" }],
  "odoo_instance": {
    "url": "https://erp.example.com",
    "db": "prod",
    "user_id": 2,
    "api_key": "<the Odoo user API key>"
  }
}

That write-back is the whole idempotency story for outbound invoices: the next run's x_xero_id = false filter simply won't see it again.

Flow 3 — pull a Xero payment into Odoo

Payments are usually reconciled in Xero, so they flow the other way. When a payment lands in Xero, create the matching account.payment in Odoo against the invoice you already mapped. First find the Odoo invoice by its Xero id, then create the payment. Registering a payment is a business operation, not plain CRUD, so it goes through call_method — Odoo's account.payment.register wizard is the supported path, but a straightforward create on account.payment works when you set the fields explicitly:

{
  "id": "create-payment-1",
  "action": "create",
  "model_id": "account.payment",
  "params": [{
    "payment_type": "inbound",
    "partner_type": "customer",
    "partner_id": 57,
    "amount": 480.00,
    "x_xero_id": "b7c6d5e4-0000-0000-0000-000000000000"
  }],
  "odoo_instance": {
    "url": "https://erp.example.com",
    "db": "prod",
    "user_id": 2,
    "api_key": "<the Odoo user API key>"
  }
}

Then post it — confirming a draft payment is a model method, reached through call_method with a non-empty fn_name:

{
  "id": "post-payment-1",
  "action": "call_method",
  "model_id": "account.payment",
  "fn_name": "action_post",
  "params": [[318]],
  "keyword": {},
  "odoo_instance": {
    "url": "https://erp.example.com",
    "db": "prod",
    "user_id": 2,
    "api_key": "<the Odoo user API key>"
  }
}

call_method is the escape hatch that makes the whole finance workflow reachable — anything Odoo's own buttons do, you can trigger. Its mechanics are covered in Calling Odoo model methods with call_method.

Driving it from Xero webhooks

Polling Xero for changes is slow and burns your API rate limit; the right trigger is Xero's own webhooks. You subscribe to events like Invoice and Contact changes, and Xero POSTs a notification to your endpoint. Two rules keep this reliable:

  • Verify the signature on every delivery. Xero signs the raw request body with HMAC-SHA256 using your webhook signing key and sends the base64 result in the x-xero-signature header. Recompute it over the exact raw bytes and compare before you trust the payload — and be ready to answer Xero's initial "intent to receive" validation, which uses the same signature check.
  • Treat webhooks as at-least-once. Xero retries deliveries, and its notifications tell you what changed, not the full record — so your handler fetches the current record from Xero, then upserts into Odoo. Because every flow above looks up an external id before it writes, a duplicate delivery becomes a harmless no-op.
A 200 from the proxy does not mean Odoo accepted the write. Odoo logic errors — an unmapped tax code, a closed accounting period, a missing account — come back with HTTP 200 and a populated error object. Check the HTTP status first, then the body's error field, before you mark a payment or invoice as synced.

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 finance sync than anywhere else, because a mis-read error is a transaction silently on only one side of the books.

Map your accounts and taxes once

The detail that sinks naive accounting syncs is the chart of accounts and tax codes. Xero and Odoo have different account codes and tax rates, so before any invoice flows you build a static map: Xero account code → Odoo account.account id, and Xero tax type → Odoo account.tax id. Read the Odoo side once with search_read on account.account and account.tax, cache it, and translate every line through it. Never guess an account — an invoice posted to the wrong account is worse than one that didn't sync at all.

A realistic scope note

This is a build-it-yourself pattern, not a turnkey app. For standard bookkeeping, evaluate a packaged Odoo–Xero connector first. Reach for a custom API integration when your requirements outgrow it: a non-standard chart of accounts, multi-entity consolidation, custom tax handling, or syncing alongside systems the packaged connector doesn't touch. When you do, the External API gives you the full accounting surface, and the patterns above — external-id idempotency, upsert-before-create, call_method for posting, a fixed account/tax map, and the two-step error check — are what keep the books trustworthy on both sides.

Where to go next