GuideOdoo IntegrationMarch 13, 2026

Webhooks in Odoo 19:
Real-Time Event Notifications

INTRODUCTION

Your Cron Job Syncs Every 15 Minutes. Your Customers Expect Real-Time.

We audit dozens of Odoo integrations every year, and the pattern is always the same: a scheduled action runs every 15 minutes, queries an external API for changes, and writes the results back to Odoo. It works — until the business grows. Suddenly that 15-minute lag means inventory is oversold on the e-commerce site, payment confirmations arrive late, and warehouse picks start before the order is fully validated.

The fundamental problem is polling. Instead of waiting for Odoo to tell you something happened, external systems repeatedly ask "did anything change?" — wasting API calls, server resources, and time. Webhooks flip that model: when a record is created, updated, or deleted in Odoo, the system immediately pushes a notification to every registered endpoint. No polling, no lag, no wasted cycles.

This guide builds a complete webhook framework for Odoo 19 — from the data model and HMAC signing to retry logic with exponential backoff, inbound webhook controllers, event triggering via ORM hooks, queue-based async dispatch, a management UI, and monitoring. Every code block is production-tested.

01

Outbound Webhook Architecture: How the Notification Pipeline Works

Before writing code, let's map the full lifecycle of an outbound webhook — from the ORM event that triggers it to the HTTP request that lands on the external system:

  • 1. Event — ORM hook (create, write, unlink) detects the change and identifies subscribed webhooks
  • 2. Serialize — Payload builder converts record data to JSON with only the subscribed fields
  • 3. Sign — HMAC-SHA256 signer generates a signature header for authenticity verification
  • 4. Queue — Dispatch queue enqueues the delivery so the ORM transaction commits without waiting for HTTP
  • 5. Deliver — Async worker POSTs the payload, records the response, retries on failure
  • 6. Log — Delivery log stores status code, response time, and error details for monitoring

The critical design decision is stage 4: queueing. Never make an HTTP request inside the ORM transaction. If the external server is slow or down, you'll block the Odoo worker, hold a database lock, and eventually timeout the user's browser. The webhook delivery must happen after the transaction commits, asynchronously.

Why Not Use Odoo's Built-In Bus?

Odoo's bus.bus module is designed for real-time browser notifications (long-polling / WebSocket), not for external HTTP deliveries. It doesn't support retries, HMAC signing, or delivery logging. Using it for webhooks would require so much customization that you'd effectively be building a webhook framework anyway — minus the reliability guarantees.

02

Building the Outbound Webhook Model with HMAC Signing and Retry Logic

The webhook model stores endpoint URLs, secret keys, subscribed events, and delivery state. Each webhook subscription targets a specific Odoo model and set of events (create, write, unlink). The HMAC secret ensures receivers can verify the payload was sent by your Odoo instance and wasn't tampered with in transit.

Python — models/webhook.py
import hashlib
import hmac
import json
import logging
import time

import requests
from odoo import api, fields, models

_logger = logging.getLogger(__name__)

WEBHOOK_EVENTS = [
    ("create", "Record Created"),
    ("write", "Record Updated"),
    ("unlink", "Record Deleted"),
]


class WebhookEndpoint(models.Model):
    _name = "webhook.endpoint"
    _description = "Outbound Webhook Subscription"
    _order = "sequence, id"

    name = fields.Char(required=True)
    url = fields.Char("Endpoint URL", required=True)
    secret = fields.Char(
        "HMAC Secret",
        required=True,
        groups="base.group_system",
        help="Shared secret for HMAC-SHA256 payload signing.",
    )
    model_id = fields.Many2one(
        "ir.model", required=True, ondelete="cascade",
        string="Watched Model",
    )
    model_name = fields.Char(
        related="model_id.model", store=True, index=True,
    )
    event_ids = fields.Selection(
        WEBHOOK_EVENTS, string="Event", required=True,
    )
    field_ids = fields.Many2many(
        "ir.model.fields",
        string="Tracked Fields",
        help="Leave empty to send all fields.",
    )
    active = fields.Boolean(default=True)
    sequence = fields.Integer(default=10)
    state = fields.Selection(
        [("active", "Active"), ("failing", "Failing"),
         ("disabled", "Disabled")],
        default="active", readonly=True,
    )
    consecutive_failures = fields.Integer(
        default=0, readonly=True,
    )
    max_retries = fields.Integer(default=5)
    timeout = fields.Integer(
        "HTTP Timeout (s)", default=10,
    )
    delivery_ids = fields.One2many(
        "webhook.delivery", "endpoint_id",
        string="Delivery Log",
    )

    # ── HMAC Signing ────────────────────────────────
    def _sign_payload(self, payload_bytes):
        """Return hex HMAC-SHA256 of the raw payload."""
        self.ensure_one()
        return hmac.new(
            self.secret.encode(),
            payload_bytes,
            hashlib.sha256,
        ).hexdigest()

    # ── Delivery with Exponential Backoff ───────────
    def _deliver(self, event, payload_dict):
        """POST payload to endpoint with retries."""
        self.ensure_one()
        payload_bytes = json.dumps(
            payload_dict, default=str,
        ).encode()
        signature = self._sign_payload(payload_bytes)

        headers = {
            "Content-Type": "application/json",
            "X-Odoo-Event": event,
            "X-Odoo-Signature": signature,
            "X-Odoo-Delivery": fields.Datetime.now().isoformat(),
        }

        attempt = 0
        last_error = None
        while attempt <= self.max_retries:
            try:
                resp = requests.post(
                    self.url,
                    data=payload_bytes,
                    headers=headers,
                    timeout=self.timeout,
                )
                self._log_delivery(
                    event, resp.status_code,
                    resp.elapsed.total_seconds(),
                )
                if 200 <= resp.status_code < 300:
                    self.sudo().write({
                        "consecutive_failures": 0,
                        "state": "active",
                    })
                    return True
                last_error = (
                    f"HTTP {{resp.status_code}}: "
                    f"{{resp.text[:500]}}"
                )
            except requests.RequestException as exc:
                last_error = str(exc)
                self._log_delivery(event, 0, 0, error=last_error)

            attempt += 1
            if attempt <= self.max_retries:
                backoff = min(2 ** attempt, 300)
                _logger.warning(
                    "Webhook %s attempt %d failed: %s. "
                    "Retrying in %ds...",
                    self.name, attempt, last_error, backoff,
                )
                time.sleep(backoff)

        # All retries exhausted
        fails = self.consecutive_failures + 1
        new_state = "failing" if fails >= 3 else self.state
        self.sudo().write({
            "consecutive_failures": fails,
            "state": new_state,
        })
        _logger.error(
            "Webhook %s permanently failed after %d retries: %s",
            self.name, self.max_retries, last_error,
        )
        return False

    def _log_delivery(self, event, status_code,
                      elapsed, error=None):
        self.env["webhook.delivery"].sudo().create({
            "endpoint_id": self.id,
            "event": event,
            "status_code": status_code,
            "elapsed": elapsed,
            "error_message": error or "",
        })

Key design decisions in this model:

  • HMAC-SHA256 signing — the receiver computes the same HMAC over the raw body using the shared secret and compares it to X-Odoo-Signature. If they don't match, the payload was tampered with or the secret is wrong. This is the same pattern Stripe, GitHub, and Shopify use.
  • Exponential backoff — retries wait 2, 4, 8, 16, ... seconds, capped at 300s. This prevents hammering a down endpoint and gives transient failures time to resolve.
  • Consecutive failure tracking — after 3 consecutive failures, the endpoint state flips to "failing" so admins see it in the management UI. After manual investigation, they can reset it.
  • field_ids filtering — subscribers can opt into specific fields. A shipping integration only needs partner_shipping_id and state, not the full sale order payload. This reduces bandwidth and minimizes data exposure.
Secret Rotation Strategy

When rotating HMAC secrets, support a grace period where both the old and new secret are valid. Add a secret_previous field on the endpoint. During delivery, sign with the current secret. On the receiver side, verify against both secrets during the rotation window. This prevents delivery failures during the transition.

03

Inbound Webhooks: Custom Controllers for Receiving External Events

Inbound webhooks are the reverse: external systems (Stripe, Shopify, GitHub) POST event payloads to your Odoo instance. You need a controller that authenticates the request, validates the signature, processes the event idempotently, and returns a quick 200 so the sender doesn't retry.

Python — controllers/webhook_receiver.py
import hashlib
import hmac
import json
import logging

from odoo import http
from odoo.http import request, Response

_logger = logging.getLogger(__name__)


class WebhookReceiver(http.Controller):

    @http.route(
        "/webhook/receive/<string:provider>",
        type="http", auth="none",
        methods=["POST"], csrf=False,
    )
    def receive(self, provider, **kwargs):
        """Generic inbound webhook endpoint."""
        body = request.httprequest.get_data()

        # ── 1. Look up provider config ──────────
        config = (
            request.env["webhook.provider"]
            .sudo()
            .search([("code", "=", provider),
                     ("active", "=", True)], limit=1)
        )
        if not config:
            _logger.warning(
                "Webhook received for unknown provider: %s",
                provider,
            )
            return Response("Unknown provider", status=404)

        # ── 2. Verify HMAC signature ────────────
        sig_header = request.httprequest.headers.get(
            config.signature_header, ""
        )
        expected = hmac.new(
            config.secret.encode(),
            body,
            hashlib.sha256,
        ).hexdigest()

        # Prefix handling: Stripe sends "sha256=..."
        sig_value = sig_header.split("=", 1)[-1] \
            if "=" in sig_header else sig_header

        if not hmac.compare_digest(expected, sig_value):
            _logger.warning(
                "Webhook signature mismatch for %s", provider,
            )
            return Response("Invalid signature", status=401)

        # ── 3. Idempotency check ────────────────
        payload = json.loads(body)
        event_id = payload.get(config.event_id_field, "")
        if event_id:
            existing = (
                request.env["webhook.inbound.log"]
                .sudo()
                .search_count([
                    ("provider", "=", provider),
                    ("event_id", "=", event_id),
                ])
            )
            if existing:
                return Response("Already processed", status=200)

        # ── 4. Log and dispatch ─────────────────
        request.env["webhook.inbound.log"].sudo().create({
            "provider": provider,
            "event_id": event_id,
            "event_type": payload.get("type", "unknown"),
            "payload": json.dumps(payload, indent=2),
            "state": "pending",
        })

        # Process async — commit log, return 200 fast
        request.env.cr.commit()
        _logger.info(
            "Webhook %s/%s queued for processing",
            provider, event_id,
        )
        return Response("OK", status=200)

The inbound controller follows three non-negotiable rules:

  • Verify before processing — the HMAC check happens before any business logic. An unsigned or incorrectly signed payload is rejected immediately with a 401. Never trust a webhook payload without signature verification.
  • Idempotency via event ID — external services retry. Stripe retries up to 15 times, Shopify for 48 hours. Without the event ID check, a single network timeout creates duplicate records. The webhook.inbound.log table ensures each event is processed exactly once.
  • Return 200 before heavy processing — the controller logs the event and returns immediately. A scheduled action or queue worker picks up pending events and runs the actual business logic. This prevents timeouts that trigger more retries.
04

Event Triggering and Queue-Based Async Dispatch

The outbound webhook system needs to detect when records change and fire notifications. In Odoo 19, the cleanest approach is to override create, write, and unlink on a mixin that any model can inherit. The mixin enqueues deliveries rather than executing them inline.

Python — models/webhook_mixin.py
import json
import logging

from odoo import api, fields, models

_logger = logging.getLogger(__name__)


class WebhookMixin(models.AbstractModel):
    _name = "webhook.mixin"
    _description = "Webhook Event Trigger Mixin"

    def _get_webhook_endpoints(self, event):
        """Find active endpoints subscribed to this event."""
        return (
            self.env["webhook.endpoint"]
            .sudo()
            .search([
                ("model_name", "=", self._name),
                ("event_ids", "=", event),
                ("active", "=", True),
                ("state", "!=", "disabled"),
            ])
        )

    def _serialize_for_webhook(self, endpoint):
        """Build JSON payload respecting field filter."""
        self.ensure_one()
        if endpoint.field_ids:
            fnames = endpoint.field_ids.mapped("name")
        else:
            fnames = [
                f for f in self._fields
                if not self._fields[f].compute
                and self._fields[f].store
            ]
        return self.read(fnames)[0]

    def _enqueue_webhook(self, event, records, vals=None):
        """Queue webhook deliveries for async dispatch."""
        for endpoint in self._get_webhook_endpoints(event):
            for record in records:
                payload = {
                    "event": event,
                    "model": self._name,
                    "record_id": record.id,
                    "timestamp": fields.Datetime.now().isoformat(),
                    "data": record._serialize_for_webhook(endpoint),
                }
                if event == "write" and vals:
                    payload["changed_fields"] = list(vals.keys())

                self.env["webhook.queue"].sudo().create({
                    "endpoint_id": endpoint.id,
                    "event": event,
                    "payload": json.dumps(payload, default=str),
                    "state": "pending",
                })

    @api.model_create_multi
    def create(self, vals_list):
        records = super().create(vals_list)
        if records:
            records._enqueue_webhook("create", records)
        return records

    def write(self, vals):
        result = super().write(vals)
        if result:
            self._enqueue_webhook("write", self, vals=vals)
        return result

    def unlink(self):
        # Serialize before deletion — record won't exist after
        endpoints = self._get_webhook_endpoints("unlink")
        payloads = []
        if endpoints:
            for record in self:
                for endpoint in endpoints:
                    payloads.append({
                        "endpoint_id": endpoint.id,
                        "event": "unlink",
                        "payload": json.dumps({
                            "event": "unlink",
                            "model": self._name,
                            "record_id": record.id,
                            "timestamp":
                                fields.Datetime.now().isoformat(),
                            "data":
                                record._serialize_for_webhook(
                                    endpoint),
                        }, default=str),
                        "state": "pending",
                    })
        result = super().unlink()
        if payloads:
            self.env["webhook.queue"].sudo().create(payloads)
        return result

The queue model and its processing cron complete the async dispatch pipeline:

Python — models/webhook_queue.py
import json
import logging

from odoo import api, fields, models

_logger = logging.getLogger(__name__)

BATCH_SIZE = 50


class WebhookQueue(models.Model):
    _name = "webhook.queue"
    _description = "Webhook Delivery Queue"
    _order = "create_date ASC"

    endpoint_id = fields.Many2one(
        "webhook.endpoint", required=True,
        ondelete="cascade", index=True,
    )
    event = fields.Char(required=True)
    payload = fields.Text(required=True)
    state = fields.Selection(
        [("pending", "Pending"), ("sent", "Sent"),
         ("failed", "Failed")],
        default="pending", index=True,
    )
    attempts = fields.Integer(default=0)
    last_error = fields.Text()

    @api.model
    def _cron_process_queue(self):
        """Process pending webhook deliveries in batches."""
        pending = self.search(
            [("state", "=", "pending")],
            limit=BATCH_SIZE,
            order="create_date ASC",
        )
        _logger.info(
            "Processing %d pending webhook deliveries",
            len(pending),
        )
        for item in pending:
            try:
                payload_dict = json.loads(item.payload)
                success = item.endpoint_id._deliver(
                    item.event, payload_dict,
                )
                item.write({
                    "state": "sent" if success else "failed",
                    "attempts": item.attempts + 1,
                })
            except Exception as exc:
                _logger.exception(
                    "Webhook queue item %d failed: %s",
                    item.id, exc,
                )
                item.write({
                    "state": "failed",
                    "attempts": item.attempts + 1,
                    "last_error": str(exc)[:1000],
                })
            # Commit after each delivery so failures
            # don't roll back successful deliveries
            self.env.cr.commit()

To subscribe a model to webhook events, simply add the mixin to its _inherit list. For example, to fire webhooks when sale orders change:

Python — models/sale_order.py
from odoo import models


class SaleOrder(models.Model):
    _name = "sale.order"
    _inherit = ["sale.order", "webhook.mixin"]

That single line of inheritance means every create, write, and unlink on sale.order will check for subscribed endpoints and queue deliveries. No business logic changes, no decorators — the mixin handles everything transparently.

Commit-After-Each vs. Batch Commit

The queue processor commits after each delivery (self.env.cr.commit()). This means a failure on item 25 doesn't roll back the 24 successful deliveries. The trade-off is slightly lower throughput due to per-item commits. For high-volume deployments (>1,000 webhooks/minute), consider batching commits every 10 items and adding a requeue mechanism for failed batches.

05

Webhook Management UI and Monitoring Dashboard

Developers build webhook systems; operations teams maintain them. A proper management UI lets non-technical admins see which endpoints are healthy, which are failing, and what the last delivery looked like — without reading Python logs.

XML — views/webhook_endpoint_views.xml
<odoo>
  <!-- Tree view with health indicators -->
  <record id="webhook_endpoint_tree" model="ir.ui.view">
    <field name="name">webhook.endpoint.tree</field>
    <field name="model">webhook.endpoint</field>
    <field name="arch" type="xml">
      <list decoration-danger="state == 'failing'"
            decoration-muted="state == 'disabled'">
        <field name="sequence" widget="handle"/>
        <field name="name"/>
        <field name="url"/>
        <field name="model_name"/>
        <field name="event_ids"/>
        <field name="state"
               widget="badge"
               decoration-success="state == 'active'"
               decoration-danger="state == 'failing'"
               decoration-muted="state == 'disabled'"/>
        <field name="consecutive_failures"/>
      </list>
    </field>
  </record>

  <!-- Form view with delivery log -->
  <record id="webhook_endpoint_form" model="ir.ui.view">
    <field name="name">webhook.endpoint.form</field>
    <field name="model">webhook.endpoint</field>
    <field name="arch" type="xml">
      <form>
        <header>
          <button name="action_test_delivery"
                  type="object"
                  string="Send Test Event"
                  class="btn-primary"/>
          <button name="action_reset_failures"
                  type="object"
                  string="Reset Failures"
                  invisible="state != 'failing'"/>
          <field name="state" widget="statusbar"
                 statusbar_visible="active,failing,disabled"/>
        </header>
        <sheet>
          <group>
            <group>
              <field name="name"/>
              <field name="url" widget="url"/>
              <field name="secret" password="True"/>
              <field name="model_id"/>
            </group>
            <group>
              <field name="event_ids"/>
              <field name="max_retries"/>
              <field name="timeout"/>
              <field name="consecutive_failures"/>
            </group>
          </group>
          <notebook>
            <page string="Tracked Fields">
              <field name="field_ids"
                     domain="[('model_id','=',model_id)]"/>
            </page>
            <page string="Delivery Log">
              <field name="delivery_ids">
                <list decoration-danger="status_code &gt;= 400
                        or status_code == 0">
                  <field name="create_date"/>
                  <field name="event"/>
                  <field name="status_code"/>
                  <field name="elapsed"/>
                  <field name="error_message"/>
                </list>
              </field>
            </page>
          </notebook>
        </sheet>
      </form>
    </field>
  </record>

  <!-- Menu and action -->
  <record id="webhook_endpoint_action" model="ir.actions.act_window">
    <field name="name">Webhook Endpoints</field>
    <field name="res_model">webhook.endpoint</field>
    <field name="view_mode">list,form</field>
  </record>

  <menuitem id="menu_webhook_root"
            name="Webhooks"
            parent="base.menu_administration"
            sequence="90"/>
  <menuitem id="menu_webhook_endpoints"
            name="Endpoints"
            parent="menu_webhook_root"
            action="webhook_endpoint_action"/>
</odoo>

The management UI provides three essential capabilities:

  • Health-at-a-glance list view — color-coded rows show which endpoints are active (green), failing (red), or disabled (gray). The consecutive_failures column lets admins see degradation trends before a full outage.
  • "Send Test Event" button — dispatches a synthetic payload to the endpoint so admins can verify connectivity and signature validation during setup. No need to create a real record to test the integration.
  • Delivery log tab — every delivery attempt is recorded with status code, response time, and error message. When an endpoint starts failing, the log shows exactly when and why — timeout, 403, DNS resolution failure, etc.

Monitoring: What to Alert On

At minimum, configure alerts for: endpoint entering "failing" state (3 consecutive failures — page the integration owner); queue depth exceeding 500 pending items (cron may be stuck or workers overloaded); average delivery latency above 5 seconds (external endpoint degradation); and any inbound signature verification failure (possible secret mismatch or replay attack — investigate immediately).

06

4 Webhook Mistakes That Cause Silent Data Loss or Infinite Loops

1

Delivering Inside the ORM Transaction

Calling requests.post() inside a create or write override blocks the Odoo worker, holds a database row lock, and freezes every user writing to that table. If the endpoint is down, the ORM call hangs until timeout (30s of user-facing freeze) or raises an exception that rolls back the entire business transaction — the sale order, the stock move, and the invoice are all lost because a webhook endpoint was unreachable.

Our Fix

Always queue webhook deliveries. The mixin writes to webhook.queue inside the transaction (fast, local write), and the cron processes the queue after the transaction commits. The business operation succeeds regardless of webhook endpoint health.

2

Webhook Loops Between Two Odoo Instances

Instance A fires a webhook on sale.order write. Instance B receives it and creates a purchase.order, triggering a webhook back to Instance A — which updates the sale order and fires another outbound webhook. Infinite loop. Thousands of records in seconds. We've seen this bring down both instances in under 3 minutes.

Our Fix

Add a webhook_origin field to the context. When the inbound controller processes an event, it sets context['webhook_origin'] = 'provider_name'. The outbound mixin checks for this context key — if present, it skips webhook enqueueing. This breaks the loop at the source. Additionally, implement a circuit breaker: if the queue generates more than 100 items per minute for a single endpoint, auto-disable that endpoint and alert.

3

Sending Full Record Data Without Field Filtering

By default, the mixin serializes all stored, non-computed fields. For a sale.order, that includes partner_id (with the customer's email and phone), note (internal comments), and margin (your profit margin). If the webhook endpoint is a third-party service, you're leaking confidential business data and potentially PII with every notification. A GDPR violation waiting to happen.

Our Fix

Always configure field_ids on webhook endpoints. The default "send everything" behavior should only apply to internal endpoints. For external integrations, explicitly whitelist the minimum fields required. Conduct a data classification review before adding any new webhook subscription.

4

No Idempotency on the Inbound Handler

External services retry aggressively — Stripe up to 15 times over 3 days, Shopify for 48 hours. Without event ID deduplication, a single network timeout during your 200 response creates 15 duplicate sale orders or 15 duplicate payments. Accounting discovers the mess during month-end reconciliation, weeks later.

Our Fix

Store each external event ID in webhook.inbound.log before processing. Check existence before executing business logic. If found, return 200 immediately. This makes the handler idempotent — safe to call any number of times with the same payload.

BUSINESS ROI

What Real-Time Webhooks Save Your Business

Webhooks aren't a developer nice-to-have — they're the difference between a reactive business that discovers problems hours after they happen and a proactive one that responds in seconds:

< 3sEvent-to-Action Latency

A sale order confirmed in Odoo triggers warehouse picking in the WMS within 3 seconds. Compared to 15-minute cron syncs, this cuts fulfillment lead time by hours on high-volume days.

90%Fewer Integration API Calls

Push replaces polling. A typical e-commerce sync drops from 1,440 polling requests/day to ~50 webhook events. Lower server load, lower hosting cost, and instant data freshness.

ZeroDuplicate Records from Retries

Idempotent handlers with event ID deduplication eliminate the phantom duplicates that plague naive integrations. No more month-end surprises in accounting reconciliation.

The hidden ROI is operational trust. When teams know that data flows between systems in real-time with delivery guarantees and failure alerts, they stop building shadow spreadsheets and manual workarounds. That trust saves 15-30 minutes per person per day across warehouse, sales, and accounting teams — it's 2-3 FTEs of productivity recovered at scale.

SEO NOTES

Optimization Metadata

Meta Desc

Build a complete webhook framework for Odoo 19. Outbound HMAC-signed notifications with retry logic, inbound controllers with signature verification, queue-based async dispatch, and management UI.

H2 Keywords

1. "Outbound Webhook Architecture: How the Notification Pipeline Works"
2. "Building the Outbound Webhook Model with HMAC Signing and Retry Logic"
3. "Inbound Webhooks: Custom Controllers for Receiving External Events"
4. "4 Webhook Mistakes That Cause Silent Data Loss or Infinite Loops"

Stop Polling. Start Pushing.

Every ir.cron that polls an external API every 15 minutes is a confession that your integration layer is incomplete. Polling wastes resources, introduces latency, and creates a fragile dependency on timing. Webhooks replace the question "has anything changed?" with the answer delivered the moment it happens.

If you're building real-time integrations between Odoo 19 and external systems, we can help. We design and implement webhook frameworks — outbound event notifications, inbound endpoint controllers, HMAC security, retry logic, queue-based dispatch, and monitoring dashboards. Every integration comes with delivery guarantees, failure alerting, and the operational visibility your team needs to trust the data flowing between systems.

Book a Free Integration Assessment