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.
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:
An HTTP controller that receives and HMAC-verifies Sautikit webhooks
A click-to-call button on the res.partner form view, bound to a Python model method
A Python method that logs each call to the partner chatter as a mail.message
An OWL systray softphone that dials and screen-pops the contact
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:
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:
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").
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:
# addons/sautikit_voice/controllers/webhook.pyimport hashlibimport hmacimport jsonimport loggingimport timefrom odoo import httpfrom 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.
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:
# addons/sautikit_voice/models/res_partner.pyimport loggingimport requestsfrom odoo import _, modelsfrom 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, }, }
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:
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:
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:
/** @odoo-module **/// addons/sautikit_voice/static/src/systray/softphone.jsimport { 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 },);
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.
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.
# Drop the module in your addons path, then update the app list and installodoo-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 (the multi-channel CX platform Sautikit is part of) layers on top of the same numbers.
Sautikit pricing (as of 2026-06-30): 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, 5 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.
The webhooks concept page documents all event kinds and the retry schedule. The voice actions reference 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.