Send an approved Odoo sales-order confirmation through IllyVoIP SMS using a custom outbox and a private integration worker. The example freezes one recipient and message when an order is confirmed, then hands that event to a durable queue.
This is a developer integration for Odoo 19 on Odoo.sh or a self-hosted installation that permits custom modules. It is not a native IllyVoIP connector or a replacement for Odoo's SMS credit service. Standard Odoo Online deployments that cannot install this module need a separately supported integration approach.

1. Decide which order notification is authorized
The worked example sends one short confirmation for one approved company. An administrator must explicitly approve the telephone number for this transactional notification before confirming the order. Do not infer permission from the presence of a contact's mobile number, and do not add advertising to the confirmation template.
In IllyVoIP, manage your API key under Account Settings → API and review the SMS endpoint in the API documentation or API Playground. Confirm that your account may send to the intended destination and that the sender is permitted. A virtual number does not automatically grant SMS capability or sender approval. Check the balance, destination price and customer authorization before the first real message.
Choose a staging Odoo database first. Record the exact company ID, a test order and an authorized recipient. Use separate production credentials and event namespaces when you later move the integration.
2. Understand why this uses an outbox
Odoo's built-in Send Webhook action is not used for credentials here. The reviewed Odoo 19 implementation sends selected fields without a configurable authentication header, uses a short timeout and logs its URL. Putting an API secret in that URL would create an avoidable exposure.
The module below instead creates an outbox record in the order-confirmation transaction. A scheduled action forwards it with a private header. If forwarding times out, the same business-event key is retried; the receiver keeps one immutable payload for that key.
Install the durable SMS worker
Use a private Python 3 application directory and virtual environment with requests and gunicorn. Save the following as sms_core.py. Keep its SQLite database on persistent local storage, owned by the application user with a private parent directory. This single-host example is not a shared network-filesystem or multi-region queue.
import hashlib, json, os, re, sqlite3, sys
import requests
DB = os.environ['SMS_DB']
SENDER = os.environ['ILLYVOIP_SMS_FROM']
API = 'https://api.illyvoip.com/api/v1/sms/messages'
def connect():
c = sqlite3.connect(DB, timeout=10)
c.execute('PRAGMA busy_timeout=10000')
return c
def init():
with connect() as c:
c.execute('''CREATE TABLE IF NOT EXISTS jobs (
event TEXT PRIMARY KEY, payload TEXT NOT NULL,
idem TEXT NOT NULL UNIQUE, state TEXT NOT NULL,
receipt TEXT, created TEXT DEFAULT CURRENT_TIMESTAMP)''')
def enqueue(event, phone, message):
if not isinstance(event, str) or not 1 <= len(event) <= 300:
raise ValueError('Invalid event')
if not isinstance(phone, str) or not re.fullmatch(r'\+[1-9][0-9]{7,14}', phone):
raise ValueError('Use a verified international recipient')
if not isinstance(message, str) or not 1 <= len(message) <= 500:
raise ValueError('This example accepts 1-500 characters')
payload = json.dumps({'from': SENDER, 'to': [phone],
'message': message}, sort_keys=True,
ensure_ascii=False, separators=(',', ':'))
idem = 'sms-' + hashlib.sha256(event.encode()).hexdigest()
with connect() as c:
c.execute('INSERT OR IGNORE INTO jobs(event,payload,idem,state) '
"VALUES(?,?,?,'pending')", (event, payload, idem))
row = c.execute('SELECT payload,state FROM jobs WHERE event=?',
(event,)).fetchone()
if row[0] != payload:
raise ValueError('Event already exists with another payload')
return row[1]
def dispatch(event, post=requests.post):
with connect() as c:
claim = c.execute("UPDATE jobs SET state='sending' "
"WHERE event=? AND state='pending'", (event,))
if claim.rowcount != 1:
return False
payload, idem = c.execute('SELECT payload,idem FROM jobs WHERE event=?',
(event,)).fetchone()
# The durable claim is committed BEFORE a paid request can start.
state, receipt = 'unknown', {'reason': 'No confirmed response'}
try:
r = post(API, headers={'X-Api-Key': os.environ['ILLYVOIP_API_KEY'],
'Idempotency-Key': idem, 'Content-Type': 'application/json'},
data=payload.encode(), timeout=(5, 20), allow_redirects=False)
data = r.json()
receipt = {'http_status': r.status_code, 'response': data}
ids = data.get('queued_message_ids', []) if isinstance(data, dict) else []
if 200 <= r.status_code < 300 and isinstance(ids, list) and len(ids) == 1 and isinstance(ids[0], (str, int)) and str(ids[0]):
state = 'accepted'
elif r.status_code in (400, 401, 403):
state = 'rejected'
except Exception:
pass # Never log credentials, recipients or an exception's request body.
with connect() as c:
c.execute('UPDATE jobs SET state=?,receipt=? WHERE event=?',
(state, json.dumps(receipt), event))
return True
if __name__ == '__main__':
init()
if sys.argv[1:] == ['work']:
with connect() as c:
events = c.execute("SELECT event FROM jobs WHERE state='pending' "
'ORDER BY created LIMIT 20').fetchall()
for (event,) in events:
dispatch(event)
elif sys.argv[1:] != ['init']:
raise SystemExit('Use: python sms_core.py init | work')
Provide SMS_DB as the absolute database path, ILLYVOIP_SMS_FROM as your permitted sender and ILLYVOIP_API_KEY through your server's private secret environment. Initialize with python sms_core.py init. The key belongs on this server, never in a browser, CRM note, webhook URL or public repository.
Run python sms_core.py work through a service timer or job runner after the first controlled test. Each invocation handles at most twenty pending events. A conditional database update claims an event before the network request, so concurrent workers cannot both send it. Do not remove the database when redeploying.
The 500-character limit is a conservative limit for this example, not a claim about the API's maximum. SMS encoding and multipart segments affect cost; check the actual message and destination pricing.
Run the authenticated handoff receiver
Save this as receiver.py beside the worker. Configure a new random SMS_BRIDGE_TOKEN of at least 32 characters, and the event prefix specified below. It is a separate credential from the IllyVoIP API key.
import hmac, json, os
from sms_core import enqueue
assert len(os.environ['SMS_BRIDGE_TOKEN']) >= 32
assert os.environ['SMS_EVENT_PREFIX']
def application(environ, start_response):
status, result = '400 Bad Request', {'error': 'Invalid request'}
try:
if environ.get('REQUEST_METHOD') != 'POST' or environ.get('PATH_INFO') != '/notify':
status = '404 Not Found'
elif not hmac.compare_digest(environ.get('HTTP_X_INTEGRATION_TOKEN', ''),
os.environ['SMS_BRIDGE_TOKEN']):
status = '401 Unauthorized'
else:
size = int(environ.get('CONTENT_LENGTH') or 0)
if not 0 < size <= 16384:
raise ValueError('Invalid size')
data = json.loads(environ['wsgi.input'].read(size))
event = data['event_id']
if not isinstance(event, str) or not event.startswith(os.environ['SMS_EVENT_PREFIX']):
raise ValueError('Invalid namespace')
state = enqueue(event, data['phone'], data['message'])
status, result = '202 Accepted', {'event_id': event, 'state': state}
except (ValueError, KeyError, TypeError):
pass
except Exception:
status, result = '503 Service Unavailable', {'error': 'Queue unavailable'}
body = json.dumps(result).encode()
start_response(status, [('Content-Type', 'application/json'),
('Content-Length', str(len(body)))])
return [body]
Run gunicorn --bind 127.0.0.1:8080 receiver:application from the virtual environment, behind your managed HTTPS reverse proxy. Expose only the intended notification path, limit request bodies and request rates, and keep access logs free of credentials and message content. Configure service restart, private database backups and alerting for failures. The receiver must commit the event before returning 202; the paid send runs in the worker afterward.
Set the receiver's SMS_EVENT_PREFIX to odoo:YOUR_DATABASE_NAME:. Use the same bridge token in Odoo's private server environment under ILLY_SMS_BRIDGE_TOKEN. Set ILLY_SMS_BRIDGE_URL to your HTTPS /notify endpoint and ILLY_SMS_COMPANY_ID to the approved company's numeric ID.
3. Add the Odoo module
Create an addon directory named illy_sms_order. Add a manifest, initialization file, model file and data files as shown. Install through your normal Odoo deployment process in staging first.
# __manifest__.py
{
'name': 'IllyVoIP approved order SMS',
'version': '19.0.1.0.0',
'depends': ['sale_management'],
'data': ['ir.model.access.csv', 'cron.xml', 'view.xml'],
'license': 'LGPL-3',
}
# __init__.py
from . import modelsSave the following as models.py. The message text is built on the server, and the approval fields are restricted to system administrators. The module does not copy an unreviewed contact telephone number automatically.
import os
import requests
from odoo import fields, models
class SaleOrder(models.Model):
_inherit = 'sale.order'
illy_sms_phone = fields.Char(groups='base.group_system', copy=False)
illy_sms_approved = fields.Boolean(groups='base.group_system', copy=False)
def action_confirm(self):
result = super().action_confirm()
approved_company = int(os.environ['ILLY_SMS_COMPANY_ID'])
for order in self.sudo():
if (order.company_id.id != approved_company or
not order.illy_sms_approved or not order.illy_sms_phone):
continue
event = f'odoo:{self.env.cr.dbname}:sale:{order.id}:confirmed:v1'
# A row lock serializes repeated confirmation of this same order.
self.env.cr.execute('SELECT id FROM sale_order WHERE id=%s FOR UPDATE',
(order.id,))
outbox = self.env['illy.sms.outbox'].sudo()
if not outbox.search_count([('event_key', '=', event)]):
outbox.create({'event_key': event, 'phone': order.illy_sms_phone,
'message': f'Your order {order.name} is confirmed.'})
return result
class SmsOutbox(models.Model):
_name = 'illy.sms.outbox'
_description = 'Approved SMS notification outbox'
event_key = fields.Char(required=True, readonly=True)
phone = fields.Char(required=True, readonly=True)
message = fields.Text(required=True, readonly=True)
forwarded = fields.Boolean(default=False, readonly=True)
_event_unique = models.Constraint('unique(event_key)', 'Event must be unique.')
def _cron_forward(self):
# Private server environment, never a URL query parameter.
url = os.environ['ILLY_SMS_BRIDGE_URL']
if not url.startswith('https://'):
raise ValueError('HTTPS required')
for row in self.search([('forwarded', '=', False)], limit=10):
try:
response = requests.post(url, json={'event_id': row.event_key,
'phone': row.phone, 'message': row.message},
headers={'X-Integration-Token': os.environ['ILLY_SMS_BRIDGE_TOKEN']},
timeout=(3, 5), allow_redirects=False)
data = response.json()
if response.status_code == 202 and data.get('event_id') == row.event_key:
row.forwarded = True
except (requests.RequestException, ValueError):
continue # Retry handoff; receiver deduplicates the stable event.
Add ir.model.access.csv. Ordinary sales users cannot edit or create outbox rows; the narrow confirmation hook writes the approved snapshot.
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_illy_sms_outbox,Illy SMS outbox,model_illy_sms_outbox,base.group_system,1,1,1,0Add cron.xml to forward committed events once per minute. Its retries concern the queue handoff, not repeated paid sends.
<odoo>
<data noupdate="1">
<record id="cron_illy_sms_forward" model="ir.cron">
<field name="name">Forward approved Illy SMS events</field>
<field name="model_id" ref="model_illy_sms_outbox"/>
<field name="state">code</field>
<field name="code">model._cron_forward()</field>
<field name="user_id" ref="base.user_admin"/>
<field name="interval_number">1</field>
<field name="interval_type">minutes</field>
<field name="active" eval="False"/>
</record>
</data>
</odoo>Add view.xml for an administrator-only approval area on the sales order.
<odoo>
<record id="sale_order_illy_sms" model="ir.ui.view">
<field name="name">sale.order.illy.sms</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form"/>
<field name="arch" type="xml">
<xpath expr="//sheet" position="inside">
<group string="Approved order SMS" groups="base.group_system">
<field name="illy_sms_phone"/>
<field name="illy_sms_approved"/>
</group>
</xpath>
</field>
</record>
</odoo>
4. Test the workflow before enabling the timer
- Install the module with forwarding still inactive. Confirm an unapproved staging order; no SMS outbox event should be created.
- On a separate test quotation, enter the authorized international phone number and enable the approval field as an administrator.
- Confirm the order. Inspect the resulting outbox event through developer tools or the Odoo shell. Its key includes the database, order and confirmation version.
- Run the scheduled action manually against a local/test receiver first. Verify one queue row and an unchanged snapshot on a repeated handoff.
- Send one authorized real test by running the SMS worker. Inspect its receipt and delivery result.
- Only after those checks, activate the scheduled action and the separate worker timer.
Reconfirming the same order must not create a second confirmation event. Canceling an order after its SMS was accepted does not retract the message. A separate cancellation notice needs its own approved event and template.
Interpret acceptance and delivery separately
The local state pending means the event is durably queued in your integration. accepted means the IllyVoIP response contained the single queued message ID expected by this example. It does not mean the recipient received the SMS. Store the receipt privately and inspect rejected recipients separately.
Use the returned ID with the status endpoint, or configure delivery webhooks in Account Settings → Integrations / CRM & webhooks. Subscribe to the relevant SMS events and correlate data.message.message_id with the saved queued ID. The idempotency key is not included in those webhooks.
GET https://api.illyvoip.com/api/v1/sms/messages/status?message_id=RETURNED_ID
X-Api-Key: YOUR_SERVER_SIDE_KEYunknown, or a job left in sending after a process crash, requires reconciliation. It must not be reset automatically to pending. Inspect IllyVoIP history/status and the private receipt before deciding whether another attempt is appropriate. If an approved retry is needed, keep the same saved payload and idempotency key; never generate a fresh key merely because a timeout occurred.
A rejected request needs its actual validation or permission issue corrected. If the intended recipient, sender or text changes, treat that as a separately approved event version, after resolving any earlier ambiguous send. Keep event identities longer than the source system's replay/retry period, including backups and manual replays.
Maintain and troubleshoot the integration
If no outbox row appears, check company scope, approval fields and the actual order transition. If forwarding repeats, check the bridge URL/token, receiver response and event namespace. If the queue is accepted but delivery fails, investigate the returned message status rather than repeatedly confirming the order.
Keep the Odoo database and worker database backed up together. Monitor unforwarded events and unknown sends. Disable the scheduled action and worker timer to pause dispatch while retaining the audit trail. Test the addon in your exact Odoo deployment before production: the examples have local syntax and queue-behavior checks, not an executed Odoo tenant acceptance test.
References: Odoo 19 action implementation, scheduled actions and IllyVoIP API reference. Reviewed October 11, 2026.