GuideOdoo IntegrationMarch 13, 2026

REST API & External Integrations
in Odoo 19

INTRODUCTION

Your Odoo Instance Is Not an Island — But Most Teams Treat It Like One

Every mid-size Odoo deployment eventually hits the same wall: the e-commerce platform needs real-time inventory, the warehouse app needs to push delivery confirmations back, the BI dashboard needs nightly order snapshots, and the payment gateway needs to notify Odoo when a charge settles. Odoo has the data. The external systems need it. The question is how you connect them without building a fragile mess of cron jobs and CSV exports.

Odoo 19 exposes two RPC protocols (JSON-RPC and XML-RPC), supports OAuth2 provider configuration for third-party authentication, and lets you build fully custom REST endpoints through http.Controller. Combined with server actions and automated rules, you can implement inbound webhooks without writing a single line of Python. But the documentation is scattered, the authentication model has subtle traps, and most "Odoo API tutorial" blog posts stop at search_read on res.partner.

This guide covers the complete integration surface of Odoo 19 — from authenticating your first JSON-RPC call through building production-grade REST controllers with OAuth2, implementing outbound and inbound webhooks, handling pagination and batch operations at scale, and avoiding the rate limiting and error handling mistakes that cause silent data loss.

01

JSON-RPC API in Odoo 19: Authentication, CRUD, and the Call Anatomy

JSON-RPC is the primary API protocol for Odoo 19. Every interaction in the Odoo web client — loading a form view, saving a record, running a wizard — goes through JSON-RPC under the hood. When you build an external integration, you're using the same protocol the UI uses. This is both its strength (it covers every model and method) and its weakness (it's chatty, session-based, and not RESTful).

Authentication: Getting a Session ID

Every JSON-RPC session starts with an authentication call to /web/session/authenticate. This returns a session cookie (session_id) that you include in all subsequent requests. The session is stateful and server-side — Odoo stores it in the database or filestore depending on your configuration.

Python — JSON-RPC authentication and CRUD operations
import requests
import json

# ── Configuration ──
ODOO_URL = "https://erp.yourcompany.com"
DB_NAME = "production"
USERNAME = "api-user@yourcompany.com"
PASSWORD = "your-api-key-or-password"

# ── 1. Authenticate ──
session = requests.Session()
auth_response = session.post(f"{{ODOO_URL}}/web/session/authenticate", json={
    "jsonrpc": "2.0",
    "method": "call",
    "params": {
        "db": DB_NAME,
        "login": USERNAME,
        "password": PASSWORD,
    }
})
auth_data = auth_response.json()
uid = auth_data["result"]["uid"]
print(f"Authenticated as UID {{uid}}")

# ── 2. search_read — Fetch records with field selection ──
result = session.post(f"{{ODOO_URL}}/web/dataset/call_kw", json={
    "jsonrpc": "2.0",
    "method": "call",
    "params": {
        "model": "sale.order",
        "method": "search_read",
        "args": [
            [["state", "=", "sale"], ["date_order", ">=", "2026-01-01"]],
        ],
        "kwargs": {
            "fields": ["name", "partner_id", "amount_total", "state"],
            "limit": 100,
            "offset": 0,
            "order": "date_order desc",
        },
    }
})
orders = result.json()["result"]

# ── 3. create — Create a new record ──
new_partner = session.post(f"{{ODOO_URL}}/web/dataset/call_kw", json={
    "jsonrpc": "2.0",
    "method": "call",
    "params": {
        "model": "res.partner",
        "method": "create",
        "args": [{
            "name": "Acme Corporation",
            "email": "contact@acme.com",
            "phone": "+1-555-0123",
            "is_company": True,
            "category_id": [(6, 0, [3, 7])],  # Replace tags
        }],
        "kwargs": {},
    }
})
partner_id = new_partner.json()["result"]

# ── 4. write — Update existing records ──
session.post(f"{{ODOO_URL}}/web/dataset/call_kw", json={
    "jsonrpc": "2.0",
    "method": "call",
    "params": {
        "model": "res.partner",
        "method": "write",
        "args": [[partner_id], {"phone": "+1-555-9999"}],
        "kwargs": {},
    }
})

# ── 5. unlink — Delete records ──
session.post(f"{{ODOO_URL}}/web/dataset/call_kw", json={
    "jsonrpc": "2.0",
    "method": "call",
    "params": {
        "model": "res.partner",
        "method": "unlink",
        "args": [[partner_id]],
        "kwargs": {},
    }
})

JSON-RPC vs XML-RPC: When to Use Which

FeatureJSON-RPC (/web/dataset/call_kw)XML-RPC (/xmlrpc/2/object)
Auth modelSession cookie (stateful)Credentials per request (stateless)
Payload formatJSONXML
Method accessAny public method on any modelexecute_kw only (CRUD + workflow)
Best forRich integrations, SPA frontends, full ORM accessSimple scripts, legacy systems, stateless batch jobs
Rate limitingPer session (Nginx: /jsonrpc)Per IP (Nginx: /xmlrpc/*)
Odoo 19 statusPrimary protocol, full supportSupported but no new features
Use API Keys, Not Passwords

Odoo 19 supports API keys as password replacements for both JSON-RPC and XML-RPC. Go to Settings → Users → [user] → API Keys tab and generate a key. Use the key in the password field of authentication calls. API keys can be revoked individually without changing the user's login password, and they appear in the security log with a distinct identifier — making audit trails cleaner.

02

OAuth2 Provider Setup and API Key Management in Odoo 19

Session-based authentication works for server-to-server integrations where you control both ends. But when third-party applications need access — a partner portal, a mobile app, a Zapier integration — you need token-based authentication with scoped permissions. Odoo 19 supports OAuth2 as an authentication provider, and you can extend it to act as an OAuth2 authorization server for external consumers.

Configuring OAuth2 Provider (Google / Azure AD / Custom)

Odoo's built-in OAuth2 support lets users authenticate via external identity providers, configured under Settings → General Settings → Integrations → OAuth Authentication. Register Odoo as an OAuth2 client in your identity provider, set the redirect URI to https://erp.yourcompany.com/auth_oauth/signin, add the provider with Client ID/Secret and authorization endpoints, then map the OAuth2 user identifier to an existing Odoo user.

Python — Custom OAuth2 provider record (data XML or script)
# Create a custom OAuth2 provider programmatically
# Useful for automated deployment or multi-tenant setups
env = api.Environment(cr, SUPERUSER_ID, {})

provider = env["auth.oauth.provider"].create({
    "name": "Corporate Azure AD",
    "enabled": True,
    "client_id": "your-azure-app-client-id",
    "auth_endpoint": "https://login.microsoftonline.com/"
                     "your-tenant-id/oauth2/v2.0/authorize",
    "validation_endpoint": "https://login.microsoftonline.com/"
                           "your-tenant-id/oauth2/v2.0/token",
    "scope": "openid email profile",
    "data_endpoint": "https://graph.microsoft.com/oidc/userinfo",
    "css_class": "fa fa-fw fa-windows",
    "body": "Log in with Corporate SSO",
})

# Map the 'email' claim from the token to Odoo's login field
# Odoo matches auth_oauth.provider + user_id claim to res.users

API Key Architecture for External Integrations

For machine-to-machine integrations, API keys are simpler than OAuth2 flows. Odoo 19 stores keys as hashed values in res.users.apikeys, scoped to a specific user and inheriting that user's access rights. Best practices:

  • Create dedicated integration users — never use a real employee's account. Create api-ecommerce@yourcompany.com, api-warehouse@yourcompany.com, etc.
  • Apply minimal access groups — an e-commerce sync user needs Sales / User and Inventory / User, not Administrator.
  • Rotate keys quarterly — generate a new key, update the integration, then revoke the old one.
  • Monitor key usage — check res.users.apikeys for create_date and monitor web server logs for authentication patterns per key.
03

Building Custom REST Controllers in Odoo 19 with http.route

JSON-RPC gives you access to every model, but it's verbose, session-based, and not friendly for third-party consumers who expect standard REST conventions (GET /api/orders/123). For public APIs, partner portals, and webhook endpoints, you need custom HTTP controllers. Odoo 19's http.Controller class with the @http.route decorator gives you full control over URL patterns, HTTP methods, authentication, and response formats.

Python — Custom REST API controller (my_api/controllers/main.py)
import json
import logging
from datetime import datetime
from odoo import http
from odoo.http import request, Response

_logger = logging.getLogger(__name__)


class OrderAPIController(http.Controller):
    """REST API for external order management."""

    # ── Helper: JSON response with proper headers ──
    def _json_response(self, data, status=200):
        return Response(
            json.dumps(data, default=str),
            status=status,
            content_type="application/json",
            headers={"X-Request-Id": request.httprequest.headers.get(
                "X-Request-Id", "unknown"
            )},
        )

    def _error_response(self, message, status=400, code=None):
        return self._json_response({
            "error": {
                "code": code or f"HTTP_{status}",
                "message": message,
            }
        }, status=status)

    # ── GET /api/v1/orders — List orders with pagination ──
    @http.route(
        "/api/v1/orders",
        type="http", auth="user", methods=["GET"], csrf=False,
    )
    def list_orders(self, **kwargs):
        try:
            limit = min(int(kwargs.get("limit", 50)), 200)
            offset = int(kwargs.get("offset", 0))
            state = kwargs.get("state")
            date_from = kwargs.get("date_from")

            domain = []
            if state:
                domain.append(("state", "=", state))
            if date_from:
                domain.append(("date_order", ">=", date_from))

            Order = request.env["sale.order"].sudo()
            total = Order.search_count(domain)
            orders = Order.search(
                domain, limit=limit, offset=offset,
                order="date_order desc",
            )

            return self._json_response({
                "data": [{
                    "id": o.id,
                    "name": o.name,
                    "partner": {
                        "id": o.partner_id.id,
                        "name": o.partner_id.name,
                    },
                    "date_order": o.date_order,
                    "amount_total": o.amount_total,
                    "state": o.state,
                    "line_count": len(o.order_line),
                } for o in orders],
                "pagination": {
                    "total": total,
                    "limit": limit,
                    "offset": offset,
                    "has_more": (offset + limit) < total,
                },
            })
        except Exception as e:
            _logger.exception("Order list API error")
            return self._error_response(str(e), status=500)

    # ── GET /api/v1/orders/<id> — Single order detail ──
    @http.route(
        "/api/v1/orders/<int:order_id>",
        type="http", auth="user", methods=["GET"], csrf=False,
    )
    def get_order(self, order_id, **kwargs):
        order = request.env["sale.order"].sudo().browse(order_id)
        if not order.exists():
            return self._error_response(
                f"Order {{order_id}} not found", status=404
            )

        return self._json_response({
            "data": {
                "id": order.id,
                "name": order.name,
                "partner": {
                    "id": order.partner_id.id,
                    "name": order.partner_id.name,
                    "email": order.partner_id.email,
                },
                "date_order": order.date_order,
                "amount_untaxed": order.amount_untaxed,
                "amount_tax": order.amount_tax,
                "amount_total": order.amount_total,
                "state": order.state,
                "lines": [{
                    "id": l.id,
                    "product": l.product_id.name,
                    "quantity": l.product_uom_qty,
                    "unit_price": l.price_unit,
                    "subtotal": l.price_subtotal,
                } for l in order.order_line],
            }
        })

    # ── POST /api/v1/orders — Create order from external system ──
    @http.route(
        "/api/v1/orders",
        type="http", auth="user", methods=["POST"], csrf=False,
    )
    def create_order(self, **kwargs):
        try:
            body = json.loads(request.httprequest.data)

            # Validate required fields
            if not body.get("partner_id"):
                return self._error_response("partner_id is required")
            if not body.get("lines"):
                return self._error_response(
                    "At least one order line is required"
                )

            order_vals = {
                "partner_id": body["partner_id"],
                "client_order_ref": body.get("external_ref", ""),
                "order_line": [(0, 0, {
                    "product_id": line["product_id"],
                    "product_uom_qty": line.get("quantity", 1),
                    "price_unit": line.get("price"),
                }) for line in body["lines"]],
            }

            order = request.env["sale.order"].sudo().create(order_vals)
            _logger.info("API: Created order %s", order.name)

            return self._json_response(
                {"data": {"id": order.id, "name": order.name}},
                status=201,
            )
        except (json.JSONDecodeError, KeyError) as e:
            return self._error_response(f"Invalid payload: {{e}}")
        except Exception as e:
            _logger.exception("Order creation API error")
            return self._error_response(str(e), status=500)
auth="user" vs auth="public" vs auth="none"

auth="user" requires a valid session (JSON-RPC login or session cookie). Use this for all CRUD operations. auth="public" allows unauthenticated access but still creates a public user context — good for read-only public endpoints. auth="none" bypasses all Odoo authentication — you handle auth yourself. Use auth="none" for inbound webhooks where the caller (Stripe, Shopify) can't authenticate via Odoo sessions but sends a signature you verify manually.

04

Implementing Inbound and Outbound Webhooks in Odoo 19

Webhooks flip the integration model: instead of polling Odoo every 5 minutes asking "did anything change?", Odoo pushes notifications to external systems the moment a record changes. For inbound webhooks, external services (payment gateways, e-commerce platforms, shipping carriers) push events to Odoo. Both patterns eliminate polling latency and reduce API call volume by 90%+.

Outbound Webhooks: Notifying External Systems on Record Changes

The cleanest way to implement outbound webhooks in Odoo 19 is through automated actions (base.automation) combined with a webhook dispatch model. When a record is created, updated, or deleted, the automated action fires and sends an HTTP POST to the registered webhook URL.

Python — Outbound webhook dispatcher (my_webhooks/models/webhook.py)
import hashlib
import hmac
import json
import logging
import requests
from odoo import api, fields, models

_logger = logging.getLogger(__name__)

WEBHOOK_TIMEOUT = 10  # seconds


class WebhookEndpoint(models.Model):
    _name = "webhook.endpoint"
    _description = "Webhook Subscription"

    name = fields.Char(required=True)
    url = fields.Char("Endpoint URL", required=True)
    secret = fields.Char("Signing Secret", required=True)
    model_id = fields.Many2one("ir.model", required=True)
    trigger = fields.Selection([
        ("create", "On Create"),
        ("write", "On Update"),
        ("unlink", "On Delete"),
    ], required=True)
    active = fields.Boolean(default=True)
    last_status = fields.Integer("Last HTTP Status")
    last_error = fields.Text("Last Error")

    def _compute_signature(self, payload_bytes):
        """HMAC-SHA256 signature for payload verification."""
        return hmac.new(
            self.secret.encode(),
            payload_bytes,
            hashlib.sha256,
        ).hexdigest()

    def dispatch(self, event, record_data):
        """Send webhook with retry logic."""
        payload = json.dumps({
            "event": event,
            "model": self.model_id.model,
            "timestamp": fields.Datetime.now().isoformat(),
            "data": record_data,
        }, default=str).encode()

        signature = self._compute_signature(payload)
        headers = {
            "Content-Type": "application/json",
            "X-Odoo-Signature": f"sha256={{signature}}",
            "X-Odoo-Event": event,
        }

        for attempt in range(3):
            try:
                resp = requests.post(
                    self.url,
                    data=payload,
                    headers=headers,
                    timeout=WEBHOOK_TIMEOUT,
                )
                self.sudo().write({
                    "last_status": resp.status_code,
                    "last_error": False,
                })
                if resp.status_code < 300:
                    return True
                _logger.warning(
                    "Webhook %s returned %s (attempt %s)",
                    self.name, resp.status_code, attempt + 1,
                )
            except requests.RequestException as e:
                _logger.error(
                    "Webhook %s failed (attempt %s): %s",
                    self.name, attempt + 1, e,
                )
                self.sudo().write({"last_error": str(e)})
        return False

Inbound Webhooks: Receiving Events from External Services

Inbound webhooks use auth="none" controllers with manual signature verification. The external service (Stripe, Shopify, etc.) signs the payload with a shared secret. Your controller verifies the signature before processing. This is critical — without signature verification, anyone who discovers your webhook URL can inject fake events.

curl — Testing the REST API and webhook flow
# ── 1. Authenticate and get session cookie ──
curl -s -c cookies.txt -X POST \
  https://erp.yourcompany.com/web/session/authenticate \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "method": "call",
    "params": {
      "db": "production",
      "login": "api-user@yourcompany.com",
      "password": "your-api-key"
    }
  }'

# ── 2. List orders via custom REST endpoint ──
curl -s -b cookies.txt \
  "https://erp.yourcompany.com/api/v1/orders?state=sale&limit=10"

# ── 3. Create order via custom REST endpoint ──
curl -s -b cookies.txt -X POST \
  https://erp.yourcompany.com/api/v1/orders \
  -H "Content-Type: application/json" \
  -d '{
    "partner_id": 42,
    "external_ref": "SHOPIFY-9821",
    "lines": [
      {"product_id": 15, "quantity": 2, "price": 49.99},
      {"product_id": 23, "quantity": 1, "price": 149.00}
    ]
  }'

# ── 4. Simulate inbound webhook (Stripe payment) ──
PAYLOAD='{"type":"payment_intent.succeeded","data":{"id":"pi_xxx","amount":24999}}'
SIGNATURE=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "whsec_your_secret" | cut -d' ' -f2)
curl -s -X POST \
  https://erp.yourcompany.com/api/v1/webhooks/stripe \
  -H "Content-Type: application/json" \
  -H "X-Stripe-Signature: sha256=$SIGNATURE" \
  -d "$PAYLOAD"
05

Pagination, Batch Operations, and Rate Limiting for Odoo 19 APIs

The single most common integration mistake is fetching all records in one call. A search_read on sale.order with no limit on a database with 200,000 orders will consume 2GB+ of RAM, lock the ORM for 30+ seconds, and likely OOM-kill the Odoo worker. Every external integration must implement pagination, and high-volume operations must use batch patterns.

Cursor-Based Pagination Pattern

Offset-based pagination (offset=0, limit=100, then offset=100, limit=100) works for small datasets but degrades on large ones — PostgreSQL still scans and discards the first N rows. For high-volume integrations, use cursor-based pagination with an indexed field like id or write_date:

JavaScript — Cursor-based pagination with error handling (Node.js)
const axios = require("axios");

const ODOO_URL = "https://erp.yourcompany.com";
const BATCH_SIZE = 200;

class OdooClient {
  constructor(db, login, password) {
    this.db = db;
    this.login = login;
    this.password = password;
    this.session = axios.create({
      baseURL: ODOO_URL,
      headers: { "Content-Type": "application/json" },
      withCredentials: true,
    });
    this.sessionCookie = null;
  }

  async authenticate() {
    const resp = await this.session.post(
      "/web/session/authenticate",
      {
        jsonrpc: "2.0",
        method: "call",
        params: {
          db: this.db,
          login: this.login,
          password: this.password,
        },
      }
    );
    // Extract session cookie from Set-Cookie header
    const cookies = resp.headers["set-cookie"] || [];
    this.sessionCookie = cookies
      .find((c) => c.startsWith("session_id="));
    this.session.defaults.headers.Cookie = this.sessionCookie;
    return resp.data.result.uid;
  }

  async callKw(model, method, args, kwargs = {}) {
    const resp = await this.session.post(
      "/web/dataset/call_kw",
      {
        jsonrpc: "2.0",
        method: "call",
        params: { model, method, args, kwargs },
      }
    );
    if (resp.data.error) {
      throw new Error(
        `Odoo RPC Error: ${resp.data.error.data.message}`
      );
    }
    return resp.data.result;
  }

  /**
   * Cursor-based pagination using ID as cursor.
   * Much faster than offset for large datasets.
   */
  async *fetchAll(model, domain, fields, batchSize = BATCH_SIZE) {
    let lastId = 0;
    while (true) {
      const pageDomain = [
        ...domain,
        ["id", ">", lastId],
      ];
      const records = await this.callKw(
        model,
        "search_read",
        [pageDomain],
        {
          fields: [...fields, "id"],
          limit: batchSize,
          order: "id asc",
        }
      );
      if (records.length === 0) break;
      yield records;
      lastId = records[records.length - 1].id;
    }
  }
}

// ── Usage: Sync all confirmed orders ──
(async () => {
  const client = new OdooClient(
    "production",
    "api-sync@yourcompany.com",
    "your-api-key"
  );
  await client.authenticate();

  let totalSynced = 0;
  const domain = [["state", "=", "sale"]];
  const fields = [
    "name", "partner_id", "amount_total", "date_order",
  ];

  for await (const batch of client.fetchAll(
    "sale.order", domain, fields
  )) {
    // Process each batch — insert into warehouse DB, etc.
    console.log(
      `Synced batch: ${batch.length} orders ` +
      `(IDs ${batch[0].id}-${batch[batch.length - 1].id})`
    );
    totalSynced += batch.length;

    // Rate limit: 200ms pause between batches
    await new Promise((r) => setTimeout(r, 200));
  }
  console.log(`Total synced: ${totalSynced} orders`);
})();

Batch Write Operations

When updating many records, never call write in a loop. Each call triggers a separate HTTP round-trip, ORM validation, and database transaction. Instead: use multi-record write (write([[id1, id2, ...], {{"field": "value"}}])) to update all records in one transaction; use batch create (create([{{vals1}}, {{vals2}}, ...])) which Odoo 19 optimizes into a single INSERT; and pass "context": {{"tracking_disable": True}} in kwargs to disable mail tracking during bulk imports.

Rate Limiting Your Own Integration

Even if Nginx isn't rate-limiting your API user, your integration should self-throttle. A sync job that fires 500 search_read calls per second will monopolize Odoo workers and degrade the UI for human users. We recommend 200ms between API calls (5 req/sec) for background syncs and 50ms (20 req/sec) for time-sensitive operations. Implement exponential backoff when you receive 429 or 503 responses.

06

4 API Integration Mistakes That Cause Silent Data Loss in Odoo 19

1

Ignoring the JSON-RPC Error Envelope

JSON-RPC always returns HTTP 200, even when the operation fails. The error is buried inside the response body under result.error or the top-level error key. If your integration checks only the HTTP status code and assumes 200 means success, you'll silently miss access errors, validation failures, and constraint violations. We've seen integrations run for months "successfully" while every write was being rejected by a record rule.

Our Fix

Always parse the JSON-RPC response body. Check for response["error"] before accessing response["result"]. Log the full error payload including error.data.message and error.data.debug — the debug field contains the Python traceback that tells you exactly what went wrong.

2

Using sudo() in Controllers Without Access Validation

The sudo() call in controller code bypasses all access rights — record rules, field-level ACLs, and group restrictions. It's tempting to slap .sudo() on every ORM call to "make it work," but this means any authenticated user can read and write any record through your API, regardless of their Odoo permissions. A sales user could read HR salary data. A portal user could modify accounting entries.

Our Fix

Use sudo() only for operations that genuinely need elevated privileges (e.g., writing to a log model). For all business data, use request.env["model"] without sudo() — this respects the authenticated user's access rights. If the API user doesn't have access, the ORM raises an AccessError that your controller can catch and return as a 403.

3

Session Expiry Mid-Sync Causes Partial Updates

Odoo sessions expire after a configurable timeout (default: 7 days of inactivity, but some deployments set it to hours). A long-running sync job that authenticates once and then processes records for 4 hours will find its session expired mid-way. The first 2,000 records update successfully; the remaining 3,000 fail silently if the integration doesn't check response codes. You end up with a partially synced dataset and no clear indication of where it stopped.

Our Fix

Implement session refresh logic: before each batch, check if the session is still valid by calling /web/session/get_session_info. If it returns an error or a different UID, re-authenticate. Alternatively, use XML-RPC for long-running batch jobs — it's stateless and sends credentials with every request, so sessions never expire.

4

Webhook Endpoints Without Idempotency Keys

External services retry webhook deliveries when they don't receive a 2xx response within their timeout window. Stripe retries up to 15 times over 3 days. Shopify retries for 48 hours. If your webhook handler creates a record on every call, a single network timeout can result in 15 duplicate invoices or 15 duplicate inventory adjustments. We've seen a Shopify integration create 47 copies of the same order during a 2-hour Odoo maintenance window.

Our Fix

Store the external event ID (Stripe's evt_xxx, Shopify's X-Shopify-Webhook-Id) in a dedicated field on the created record or in a webhook log table. Before processing, check if that event ID already exists. If it does, return 200 without processing. This makes your handler idempotent — safe to call multiple times with the same payload.

BUSINESS ROI

What Proper API Integration Saves Your Business

A well-built integration layer turns Odoo from a standalone ERP into the operational hub of your entire tech stack. Here's what changes:

Real-TimeData Sync

Webhooks replace hourly cron syncs. Inventory levels, order statuses, and payment confirmations propagate in seconds, not hours. Customer-facing stock accuracy goes from 85% to 99%+.

90%Fewer API Calls

Webhooks push changes instead of polling. A typical e-commerce sync drops from 1,440 polling calls/day to ~50 webhook events. Lower server load, lower hosting cost, faster response time.

ZeroDuplicate Records

Idempotent webhook handlers and proper error envelopes eliminate the phantom duplicates that plague naive integrations. No more "why do we have 3 copies of this invoice?"

The hidden cost of bad integrations isn't technical — it's operational trust. When the warehouse team doesn't trust inventory numbers, they build shadow spreadsheets. When accounting finds duplicate invoices monthly, they add manual reconciliation. Each workaround adds 10-30 minutes of daily labor per team. Multiply across departments and you're looking at 2-3 FTE-equivalents spent compensating for integration bugs.

SEO NOTES

Optimization Metadata

Meta Desc

Complete guide to Odoo 19 API integration. JSON-RPC authentication and CRUD, OAuth2 setup, custom REST controllers, webhooks, pagination, and batch operations with production code examples.

H2 Keywords

1. "JSON-RPC API in Odoo 19: Authentication, CRUD, and the Call Anatomy"
2. "Building Custom REST Controllers in Odoo 19 with http.route"
3. "Implementing Inbound and Outbound Webhooks in Odoo 19"
4. "4 API Integration Mistakes That Cause Silent Data Loss in Odoo 19"

Your ERP Shouldn't Be a Data Silo

Every CSV export, every manual re-entry, every "I'll check the other system" is a symptom of missing integration. Odoo 19 has the API surface to connect to anything — e-commerce platforms, payment gateways, warehouse management systems, BI tools, CRMs, and custom applications. The gap isn't capability; it's implementation quality.

If you're building integrations between Odoo and external systems, we can help. We design and implement API integration layers — from simple one-way syncs to complex multi-system orchestrations with webhooks, error recovery, and monitoring. We've connected Odoo to Shopify, Stripe, WooCommerce, Amazon, SAP, Salesforce, and dozens of custom platforms. Every integration comes with documentation, error handling, and monitoring dashboards.

Book a Free Integration Assessment