Almost every Twilio integration I get asked to rescue began life as a single method that posts to the Twilio Messages API and assumes the response will always be a 201. That holds up in a demo database. It falls apart the first time a carrier filters a campaign, a res.partner record carries a number without a country code, or Twilio answers a bulk run with 429 Too Many Requests. The weak point is rarely the API. It is the missing layer inside Odoo that owns state, retries and evidence.
This walkthrough covers the Twilio endpoints worth building against, how to model them in Odoo, and the error-handling strategies that keep a messaging pipeline honest under load. I ship this pattern for clients through my Odoo Performance optimization services, and it is provider agnostic, so you can swap Twilio for another gateway later without touching your business logic.
Why a Deliberate Integration Layer Beats a Quick Script
Odoo already ships an SMS stack built around the sms.sms model and the IAP gateway, and overriding that transport is often the fastest route for pure marketing sends. But Programmable Messaging gives you far more than fire and forget: message SIDs, asynchronous delivery receipts, per-carrier failure reasons and opt-out signals. None of that has a natural home in a thin override.
So I keep a dedicated queue model. Business code creates a record and returns immediately, a scheduled job pushes it to Twilio, and a public controller receives the StatusCallback webhook and moves the record forward. A slow HTTP call never blocks a user transaction, and a failed send never rolls back the sale order that triggered it.
The Twilio Endpoints That Actually Matter
Creating and Tracking a Message
The endpoint you will spend most of your time with is the Message resource:
POST https://api.twilio.com/2010-04-01/Accounts/{AccountSid}/Messages.jsonAuthentication is HTTP Basic using your Account SID and Auth Token, so requests.post with an auth tuple is enough. The payload needs a recipient in E.164 phone number format, a sender and content. Prefer a Messaging Service SID over a raw From number, because it gives you sender pools, sticky sender behaviour and opt-out handling for free. Add a StatusCallback URL and Twilio reports progress back rather than making you poll.
The response returns a message SID and an initial status. Store that SID, because it is your only durable join key between an Odoo record and Twilio’s own logs. A GET on the same resource path plus the SID lets you reconcile anything the webhook missed, which matters when a deployment window swallows an hour of callbacks.
Receiving Delivery Status Callbacks
Twilio posts status transitions to your StatusCallback URL as form encoded parameters. The values worth modelling are queued, sending, sent, delivered, undelivered and failed. The error_code field is populated only when a message ends in failed or undelivered, and Twilio warns against branching on error_message text, so treat the numeric code as the contract.
Wiring Twilio into the Odoo Data Model
Here is the core of the queue model. The state selection mirrors Twilio’s status vocabulary exactly, which makes the webhook write trivial and removes a whole class of mapping bugs.
import logging
from datetime import timedelta
import requests
from requests.exceptions import RequestException
from odoo import fields, models
_logger = logging.getLogger(__name__)
RETRYABLE_HTTP = {429, 500, 502, 503, 504}
class TwilioMessage(models.Model):
_name = "twilio.message"
_description = "Twilio Outbound Message"
to_number = fields.Char(required=True)
body = fields.Text(required=True)
message_sid = fields.Char(index=True, copy=False)
state = fields.Selection(
[("draft", "Draft"), ("queued", "Queued"), ("sending", "Sending"),
("sent", "Sent"), ("delivered", "Delivered"),
("undelivered", "Undelivered"), ("failed", "Failed")],
default="draft", index=True,
)
error_code = fields.Char()
attempt = fields.Integer(default=0)
next_attempt_at = fields.Datetime()
def _send(self):
icp = self.env["ir.config_parameter"].sudo()
sid = icp.get_param("twilio.account_sid")
token = icp.get_param("twilio.auth_token")
url = f"https://api.twilio.com/2010-04-01/Accounts/{sid}/Messages.json"
base = icp.get_param("web.base.url")
for record in self:
if record.message_sid:
continue # already accepted by Twilio, never send twice
payload = {
"To": record.to_number,
"MessagingServiceSid": icp.get_param("twilio.messaging_service_sid"),
"Body": record.body,
"StatusCallback": f"{base}/twilio/status",
}
try:
res = requests.post(url, data=payload, auth=(sid, token), timeout=15)
except RequestException as exc:
_logger.warning("Twilio transport failure on %s: %s", record.id, exc)
record._defer()
continue
if res.status_code in RETRYABLE_HTTP:
record._defer()
elif res.ok:
record.write({"message_sid": res.json()["sid"],
"state": "queued", "error_code": False})
else:
record.write({"state": "failed",
"error_code": str(res.json().get("code"))})
self.env.cr.commit()
def _defer(self):
self.attempt += 1
delay = min(2 ** self.attempt, 900)
self.next_attempt_at = fields.Datetime.now() + timedelta(seconds=delay)The commit inside the loop is intentional. If message forty of a hundred raises, you do not want the SIDs already collected to vanish with the rollback while Twilio still bills you for them.
Validating the Inbound Webhook in an Odoo Controller
A public Odoo controller route is an open door unless you verify the caller. Twilio signs every webhook with an X-Twilio-Signature header, an HMAC-SHA1 of the callback URL and the sorted request parameters keyed on your auth token. Use the validator from the Twilio Python helper library rather than writing your own.
from twilio.request_validator import RequestValidator
from odoo import http
from odoo.http import request
class TwilioWebhook(http.Controller):
@http.route("/twilio/status", type="http", auth="public",
methods=["POST"], csrf=False, save_session=False)
def status_callback(self, **params):
icp = request.env["ir.config_parameter"].sudo()
validator = RequestValidator(icp.get_param("twilio.auth_token"))
signature = request.httprequest.headers.get("X-Twilio-Signature", "")
url = icp.get_param("web.base.url") + "/twilio/status"
if not validator.validate(url, params, signature):
return request.make_response("", status=403)
message = request.env["twilio.message"].sudo().search(
[("message_sid", "=", params.get("MessageSid"))], limit=1)
if message:
message.write({
"state": params.get("MessageStatus"),
"error_code": params.get("ErrorCode") or False,
})
return request.make_response("", status=204)Two details are easy to miss. The URL you validate against must match the one configured in Twilio byte for byte, so a proxy that rewrites the host breaks validation silently. And returning a 2xx quickly matters, because Twilio treats slow endpoints as failures and logs 11200 HTTP retrieval failure against your account.
Error-Handling Strategies That Survive Production
Classify the Failure Before You Retry
- Transport and throttling failures: timeouts, connection resets, 429 Too Many Requests and 5xx responses. Safe to retry with exponential backoff, because the request never produced a billable message.
- Validation failures: a 400 carrying codes such as 21211 for an invalid To number or 21606 for a From number you do not own. Retrying is pointless. Fail the record, surface it to a user and fix the source data.
- Asynchronous delivery failures: 30003 unreachable handset, 30007 carrier filtering, 30008 unknown error. These arrive by webhook long after a successful API call, and filtering repeats on retry, so escalate to a human or another channel.
Backoff, Queueing and Idempotency
Run the sender from an ir.cron scheduled action that selects only records whose next_attempt_at has passed, oldest first, with a batch limit. Exponential backoff capped at fifteen minutes gives Twilio room to recover. Cap total attempts too, then mark the record failed with a reason a support agent can read.
Idempotency is the piece people skip. The message_sid guard in the send loop stops a cron worker that crashed after the HTTP call but before the commit from sending twice on the next pass. Pair it with an index on message_sid so webhook lookups stay fast at scale.
Rate Limits, Logging and Compliance
Twilio applies concurrency and queue limits per account, and error 21611 tells you the message queue is full rather than that your credentials are wrong. Throttle at your end with a sensible batch size instead of finding the ceiling mid-campaign. Keep the Account SID and Auth Token in ir.config_parameter, never in source.
For observability, log the message SID, the HTTP status and the Twilio error code on every transition, and expose the queue as a normal list view filtered on failed and undelivered states. Australian senders also need consent and a working opt-out path under the Spam Act, so honour STOP replies by writing back to the partner record, not just inside Twilio.
Get the Integration Reviewed Before It Goes Live
Most messaging integrations look fine until the first thousand-message day. If you want a second pair of eyes on your queue design, webhook security and retry policy before you switch it on for real customers, Book a Consultation and we can walk through your module and your Twilio logs together.
Conclusion
A dependable Twilio and Odoo integration comes down to four decisions: build against the Message resource and its status callbacks, mirror Twilio’s status vocabulary in your own model, validate the X-Twilio-Signature header on every request, and classify failures before retrying them. Get those right and the integration becomes boring, which is what you want from infrastructure that talks to customers.
Frequently Asked Questions
Should I override Odoo's sms.sms model or build a separate Twilio queue?
Override sms.sms when you only need outbound marketing sends through the existing SMS Marketing screens. Build a separate queue model when you need message SIDs, delivery receipts, retry state or per-message audit history, because the IAP-shaped interface has nowhere sensible to keep them.
Why does my StatusCallback webhook return a 403 even though Twilio is calling it?
Signature validation almost always fails on a URL mismatch. The validator hashes the exact URL Twilio requested, so an http versus https difference, a missing trailing slash or a rewritten host produces a valid signature that does not match. Log the URL you validate against and compare it with the Twilio console.
How should I handle Twilio error 30007 message filtered?
Treat it as a content or sender reputation problem, not a transient fault. Retrying the same body to the same carrier will usually be filtered again. Register your sender properly, remove URL shorteners and aggressive wording from the template, and route repeat failures to a human or an email fallback.
Do I need the Twilio Python helper library, or can I use requests directly?
Plain requests is fine for outbound calls and keeps your dependency footprint small in an Odoo addon. The one place I do install the helper library is signature validation, because RequestValidator handles parameter sorting and the JSON body variant correctly.
What is the safest way to test this without messaging real customers?
Use Twilio test credentials and the magic numbers that force specific error codes, so you can exercise the 21211 and 30003 paths deliberately. For webhooks, point the StatusCallback at a staging Odoo instance over a tunnel and confirm an unsigned POST is rejected with 403 before you trust the endpoint.