---
title: Add click-to-call and call logging to Odoo with a native Sautikit voice addon
description: >-
  Build the sautikit_voice Odoo module: an HTTP controller for signed webhooks,
  a click-to-call button on res.partner, and an OWL systray softphone.
summary: >-
  Ship a self-contained Odoo addon that verifies Sautikit webhooks in Python,
  adds a click-to-call button to the partner form, logs calls to the chatter,
  and screen-pops contacts from an OWL systray.
date: 2026-07-03T00:00:00.000Z
type: blog
---


## Summary

Odoo is widely used by Kenyan SMEs as an open-source ERP covering sales, accounting, inventory, and CRM. This post builds a self-contained Odoo addon, `sautikit_voice`, that lives entirely inside Odoo: an `http.Controller` verifies and ingests Sautikit webhooks, a button on the `res.partner` form places outbound calls through the ORM, completed calls land in the chatter as `mail.message` notes, and an OWL systray widget gives agents a softphone that screen-pops the matching contact.

## Why a Native Addon

Odoo's CRM module tracks leads, opportunities, and customer contacts in a central record (`res.partner`). Sales teams in Kenya typically reach customers by phone, but those calls leave no trace in Odoo unless manually entered. Rather than run a separate Node/Express relay between Sautikit and Odoo, everything below is a normal Odoo module: HTTP routes come from `odoo.http`, calls are placed with the bundled `requests` library, secrets live in `ir.config_parameter`, and the agent UI is an OWL component registered in the `systray`. There is no external service to deploy or keep in sync: you install the addon and it runs in the same worker as the rest of Odoo.

The addon adds four things:

1. An HTTP controller that receives and HMAC-verifies Sautikit webhooks
2. A click-to-call button on the `res.partner` form view, bound to a Python model method
3. A Python method that logs each call to the partner chatter as a `mail.message`
4. An OWL systray softphone that dials and screen-pops the contact

<!-- diagram omitted -->

## Module Structure

```
addons/
  sautikit_voice/
    __init__.py
    __manifest__.py
    controllers/
      __init__.py
      webhook.py
    models/
      __init__.py
      res_partner.py
      res_config_settings.py
    views/
      res_partner_views.xml
      res_config_settings_views.xml
    security/
      ir.model.access.csv
    static/
      src/
        systray/
          softphone.js
          softphone.xml
          softphone.scss
```

The `__manifest__.py` declares dependencies, data files, and the OWL assets bundle. Static assets are added to `web.assets_backend` so the systray widget loads with the web client:

```python
# addons/sautikit_voice/__manifest__.py
{
    "name": "Sautikit Voice",
    "version": "17.0.1.0.0",
    "summary": "Sautikit click-to-call, signed webhooks, and a systray softphone for Odoo CRM",
    "author": "Your Company",
    "license": "LGPL-3",
    "category": "Phone",
    "depends": ["crm", "mail", "phone_validation"],
    "data": [
        "security/ir.model.access.csv",
        "views/res_partner_views.xml",
        "views/res_config_settings_views.xml",
    ],
    "assets": {
        "web.assets_backend": [
            "sautikit_voice/static/src/systray/softphone.js",
            "sautikit_voice/static/src/systray/softphone.xml",
            "sautikit_voice/static/src/systray/softphone.scss",
        ],
    },
    "installable": True,
    "application": False,
}
```

## Storing Credentials in `ir.config_parameter`

Rather than reading environment variables, the addon keeps the API key, outbound number, and webhook signing secret as system parameters. Expose them through **Settings** with a `res.config.settings` extension so an administrator can edit them in the UI:

```python
# addons/sautikit_voice/models/res_config_settings.py

from odoo import fields, models


class ResConfigSettings(models.TransientModel):
    _inherit = "res.config.settings"

    sautikit_api_key = fields.Char(
        string="Sautikit API Key",
        config_parameter="sautikit_voice.api_key",
    )
    sautikit_outbound_number = fields.Char(
        string="Outbound Number (E.164)",
        config_parameter="sautikit_voice.outbound_number",
    )
    sautikit_webhook_secret = fields.Char(
        string="Webhook Signing Secret",
        config_parameter="sautikit_voice.webhook_secret",
    )
```

The `config_parameter` attribute stores each field directly in `ir.config_parameter`, so anywhere in the addon you can read them with `self.env["ir.config_parameter"].sudo().get_param("sautikit_voice.api_key")`.

```xml
<!-- addons/sautikit_voice/views/res_config_settings_views.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
  <record id="res_config_settings_view_form_sautikit" model="ir.ui.view">
    <field name="name">res.config.settings.view.form.sautikit</field>
    <field name="model">res.config.settings</field>
    <field name="inherit_id" ref="base.res_config_settings_view_form"/>
    <field name="arch" type="xml">
      <xpath expr="//form" position="inside">
        <app string="Sautikit Voice" name="sautikit_voice">
          <block title="API Credentials">
            <setting string="API Key" help="Bearer token from your Sautikit workspace">
              <field name="sautikit_api_key" password="True"/>
            </setting>
            <setting string="Outbound Number" help="E.164 number used as caller ID">
              <field name="sautikit_outbound_number"/>
            </setting>
            <setting string="Webhook Secret" help="Signing secret used to verify inbound webhooks">
              <field name="sautikit_webhook_secret" password="True"/>
            </setting>
          </block>
        </app>
      </xpath>
    </field>
  </record>
</odoo>
```

## Webhook Controller

The controller receives Sautikit events at `/sautikit/webhook` and handles `call.answered`, `call.completed`, and `recording.ready`. It is a `type="http"` route so the handler can read the exact request bytes; a `type="json"` route parses and re-serializes the body, which would break byte-exact HMAC verification. CSRF is disabled because the caller is Sautikit, not a browser session, and `auth="public"` lets it run without a logged-in user:

```python
# addons/sautikit_voice/controllers/webhook.py

import hashlib
import hmac
import json
import logging
import time

from odoo import http
from odoo.http import request

_logger = logging.getLogger(__name__)


class SautikitWebhookController(http.Controller):

    @http.route(
        "/sautikit/webhook",
        type="http",
        auth="public",
        methods=["POST"],
        csrf=False,
    )
    def sautikit_webhook(self):
        body = request.httprequest.get_data()  # raw bytes, unparsed
        sig_header = request.httprequest.headers.get("X-Sautikit-Signature", "")

        if not self._verify_signature(body, sig_header):
            _logger.warning("Sautikit webhook signature verification failed")
            return request.make_json_response({"error": "unauthorized"}, status=401)

        try:
            payload = json.loads(body)
        except json.JSONDecodeError:
            return request.make_json_response({"error": "bad_request"}, status=400)

        handlers = {
            "call.answered": self._handle_call_answered,
            "call.completed": self._handle_call_completed,
            "recording.ready": self._handle_recording_ready,
        }
        handler = handlers.get(payload.get("event"))
        if handler:
            handler(payload)

        return request.make_json_response({"status": "ok"})

    def _verify_signature(self, body: bytes, signature_header: str) -> bool:
        secret = request.env["ir.config_parameter"].sudo().get_param(
            "sautikit_voice.webhook_secret"
        )
        if not secret or not signature_header:
            return False
        try:
            parts = dict(item.split("=", 1) for item in signature_header.split(","))
            ts = parts["t"]
            v1 = parts["v1"]
        except (ValueError, KeyError):
            return False

        # Reject stale timestamps to blunt replay attacks
        if abs(time.time() - int(ts)) > 300:
            return False

        signed_payload = body + b"." + ts.encode()
        expected = hmac.new(
            secret.encode(), signed_payload, hashlib.sha256
        ).hexdigest()
        return hmac.compare_digest(expected, v1)

    def _handle_call_answered(self, payload: dict):
        partner = self._find_partner_by_phone(payload.get("from"))
        if partner:
            partner.message_post(
                body=(
                    f"<b>Call started</b> ({payload.get('direction', 'inbound')})<br/>"
                    f"Caller: {payload.get('from')}<br/>"
                    f"Call ID: {payload.get('call_id')}"
                ),
                subtype_xmlid="mail.mt_note",
            )

    def _handle_call_completed(self, payload: dict):
        partner = self._find_partner_by_phone(payload.get("from"))
        if partner:
            partner.message_post(
                body=(
                    f"<b>Call ended</b> "
                    f"({payload.get('direction', 'inbound')}, {payload.get('status', 'completed')})<br/>"
                    f"Duration: {payload.get('duration_seconds', 0)}s<br/>"
                    f"Call ID: {payload.get('call_id')}"
                ),
                subtype_xmlid="mail.mt_note",
            )

    def _handle_recording_ready(self, payload: dict):
        partner = self._find_partner_by_phone(payload.get("from"))
        if not partner:
            return

        call_id = payload.get("call_id")
        recording_url = payload.get("recording_url")

        # Store the presigned recording URL as an attachment on the partner
        request.env["ir.attachment"].sudo().create({
            "name": f"Recording: Call {call_id}",
            "type": "url",
            "url": recording_url,
            "res_model": "res.partner",
            "res_id": partner.id,
        })
        partner.message_post(
            body=(
                f"<b>Recording ready</b> for call {call_id}<br/>"
                f'<a href="{recording_url}">Listen to recording</a>'
            ),
            subtype_xmlid="mail.mt_note",
        )

    def _find_partner_by_phone(self, phone: str):
        if not phone:
            return None
        Partner = request.env["res.partner"].sudo()
        return Partner.search(
            ["|", ("phone", "=", phone), ("mobile", "=", phone)],
            limit=1,
        ) or None
```

`request.make_json_response` returns a proper JSON body and status code from an `http`-type route, so Sautikit sees a clean `200`/`401`/`400` without the JSON-RPC envelope a `type="json"` route would add.

## Partner Model: Click-to-Call

Outbound calls are a model method on `res.partner`. It reads the API key and caller ID from `ir.config_parameter`, posts to Sautikit's `/v1/calls` endpoint with the bundled `requests` library, and drops a note in the chatter:

```python
# addons/sautikit_voice/models/res_partner.py

import logging

import requests

from odoo import _, models
from odoo.exceptions import UserError

_logger = logging.getLogger(__name__)

SAUTIKIT_BASE = "https://api.sautikit.com"


class ResPartner(models.Model):
    _inherit = "res.partner"

    def action_sautikit_call(self):
        self.ensure_one()
        to_number = self.mobile or self.phone
        if not to_number:
            raise UserError(_("This contact has no phone or mobile number."))

        ICP = self.env["ir.config_parameter"].sudo()
        api_key = ICP.get_param("sautikit_voice.api_key")
        from_number = ICP.get_param("sautikit_voice.outbound_number")
        if not api_key or not from_number:
            raise UserError(_("Configure the Sautikit API key and outbound number in Settings."))

        base_url = ICP.get_param("web.base.url")
        voice_callback_url = f"{base_url}/sautikit/webhook/voice"

        try:
            resp = requests.post(
                f"{SAUTIKIT_BASE}/v1/calls",
                headers={
                    "Authorization": f"Bearer {api_key}",
                    "Content-Type": "application/json",
                },
                json={
                    "from": from_number,
                    "to": [to_number],
                    "voice_callback_url": voice_callback_url,
                    "metadata": {
                        "partner_id": str(self.id),
                        "partner_name": self.name or "",
                    },
                },
                timeout=10,
            )
            resp.raise_for_status()
        except requests.RequestException as exc:
            _logger.error("Sautikit click-to-call failed: %s", exc)
            raise UserError(_("Could not place the call: %s") % exc)

        call = resp.json()
        self.message_post(
            body=(
                f"<b>Click-to-call initiated</b><br/>"
                f"To: {to_number}<br/>"
                f"Call ID: {call.get('id', 'unknown')}"
            ),
            subtype_xmlid="mail.mt_note",
        )
        # Server methods surface a toast by returning a display_notification
        # client action; there is no notify_* helper on res.users.
        return {
            "type": "ir.actions.client",
            "tag": "display_notification",
            "params": {
                "type": "info",
                "message": _("Calling %s…") % to_number,
                "sticky": False,
            },
        }
```

## Partner Form: Call Button

Extend the `res.partner` form view to add a **Call with Sautikit** button that invokes the model method (`type="object"`). It is hidden when the contact has no number:

```xml
<!-- addons/sautikit_voice/views/res_partner_views.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
  <record id="res_partner_view_form_sautikit" model="ir.ui.view">
    <field name="name">res.partner.form.sautikit</field>
    <field name="model">res.partner</field>
    <field name="inherit_id" ref="base.view_partner_form"/>
    <field name="arch" type="xml">
      <xpath expr="//field[@name='phone']" position="after">
        <button
          name="action_sautikit_call"
          type="object"
          string="Call with Sautikit"
          icon="fa-phone"
          class="btn btn-sm btn-secondary"
          invisible="not phone and not mobile"
        />
      </xpath>
    </field>
  </record>
</odoo>
```

## Voice Callback Route for Outbound Calls

When Sautikit dials the destination, it fetches voice actions from the `voice_callback_url` set above. This is a second route on the same controller that returns the `say`/`record` action list Sautikit executes on the call:

```python
    @http.route(
        "/sautikit/webhook/voice",
        type="http",
        auth="public",
        methods=["POST"],
        csrf=False,
    )
    def sautikit_voice_callback(self):
        actions = {
            "actions": [
                {"say": {"text": "Connecting your call. Please wait a moment.",
                         "language": "en-KE"}},
                {"record": {
                    "maxLength": 3600,
                    "action": f"{request.httprequest.host_url}sautikit/webhook",
                }},
            ]
        }
        return request.make_json_response(actions)
```

## OWL Systray Softphone

The agent UI is an OWL component in the systray. It shows a call icon in the navbar; clicking it opens a small dial pad. Placing a call uses the `orm` service to invoke `action_sautikit_call` on the partner, and the `action` service screen-pops the contact's form. Register the component in the `systray` registry:

```js
/** @odoo-module **/
// addons/sautikit_voice/static/src/systray/softphone.js

import { Component, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { _t } from "@web/core/l10n/translation";

export class SautikitSoftphone extends Component {
    static template = "sautikit_voice.Softphone";
    static props = {};

    setup() {
        this.orm = useService("orm");
        this.action = useService("action");
        this.notification = useService("notification");
        this.state = useState({ open: false, number: "" });
    }

    toggle() {
        this.state.open = !this.state.open;
    }

    async dial() {
        const number = this.state.number.trim();
        if (!number) {
            return;
        }
        // Resolve the number to a partner so we can screen-pop and log the call
        const [partner] = await this.orm.searchRead(
            "res.partner",
            ["|", ["phone", "=", number], ["mobile", "=", number]],
            ["id", "name"],
            { limit: 1 },
        );
        if (!partner) {
            this.notification.add(_t("No contact matches %s", number), { type: "warning" });
            return;
        }
        await this.orm.call("res.partner", "action_sautikit_call", [[partner.id]]);
        // Screen-pop: open the partner form in the current breadcrumb
        this.action.doAction({
            type: "ir.actions.act_window",
            res_model: "res.partner",
            res_id: partner.id,
            views: [[false, "form"]],
            target: "current",
        });
        this.state.open = false;
    }
}

registry.category("systray").add(
    "sautikit_voice.Softphone",
    { Component: SautikitSoftphone },
    { sequence: 25 },
);
```

The template is a standard OWL `.xml` asset:

```xml
<!-- addons/sautikit_voice/static/src/systray/softphone.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
  <t t-name="sautikit_voice.Softphone">
    <li class="o_sautikit_softphone o_dropdown">
      <button class="btn o_nav_entry" t-on-click="toggle" title="Sautikit softphone">
        <i class="fa fa-phone"/>
      </button>
      <div t-if="state.open" class="o_sautikit_dialpad p-2">
        <input
          type="tel"
          class="form-control form-control-sm mb-2"
          placeholder="+2547…"
          t-model="state.number"
          t-on-keydown="(ev) => ev.key === 'Enter' &amp;&amp; this.dial()"
        />
        <button class="btn btn-primary btn-sm w-100" t-on-click="dial">
          <i class="fa fa-phone me-1"/> Call
        </button>
      </div>
    </li>
  </t>
</templates>
```

A screen pop can also be driven from the inbound webhook: on `call.answered`, notify the assigned salesperson over the bus and have the systray widget call `action.doAction` to open the matching partner: the same `doAction` call used above, triggered by a `bus_service` notification instead of a click.

## Security

Grant the addon's models access in `ir.model.access.csv`. The controller uses `sudo()` for ORM writes triggered by the public webhook route, so the CSV only needs to cover any new models you add; the extensions above reuse existing `res.partner`, `mail.message`, and `ir.attachment` access rules.

```csv
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
```

## Install

```bash
# Drop the module in your addons path, then update the app list and install
odoo-bin -c odoo.conf -u sautikit_voice -d your_database --stop-after-init
```

After install, open **Settings → Sautikit Voice**, paste the API key, outbound number, and webhook signing secret, then point your Sautikit workspace webhook at `https://your-odoo.example.com/sautikit/webhook`.

Sautikit gives you programmable voice inside Odoo. When you need the full agent desk plus SMS, WhatsApp, and USSD channels alongside your CRM, [Helloduty](https://helloduty.com) (the multi-channel CX platform Sautikit is part of) layers on top of the same numbers.

## Cost Reference

Sautikit pricing (as of 2026-07-03): KES 3/min outbound (KES 0.05/sec), inbound free (KES 0/min), KES 0.50/min for recording, KES 100/month (ex. VAT; KES 116 incl. VAT) per local Nairobi number, 1 GB recording storage included. For a 5-agent sales team making 30 outbound calls/day at 4 minutes average: 5 × 30 × 4 × KES 3 = KES 1,800/day in outbound costs. Inbound calls are free. Full pricing at [/pricing](/pricing).

## Get started

1. [Create a Sautikit workspace](/) and claim a phone number.
2. Top up over **M-Pesa**: KES billing, no card.
3. Install the `sautikit_voice` addon, set your credentials in **Settings**, and place your first call from a `res.partner` form or the systray softphone.

**[Start with Sautikit →](/)** &nbsp;·&nbsp; **[See pricing →](/pricing)** &nbsp;·&nbsp; **[Need SMS, WhatsApp & an agent desk? Helloduty →](https://helloduty.com)**

## Next Steps

The [webhooks concept page](/developers/concepts/webhooks) documents all event kinds and the retry schedule. The [voice actions reference](/developers/concepts/voice-actions) covers `say`, `dial`, `record`, and `getDigits`. Once the module is installed and credentials are set in Settings, agents can dial from the partner form or the systray and see full call history in the chatter without switching tools.
