How to Scale Twilio Mass SMS in Odoo Without Losing Messages

Mass-messaging architecture in Odoo using Twilio showing the outbound queue, throttled worker, status callback webhook and opt-out compliance layer

Sending one SMS from Odoo is easy. Sending 40,000 of them on a Tuesday morning without tripping Twilio queue overflow errors, double-charging a client or texting someone who replied STOP last month is a different engineering problem. I usually get called in after a campaign has already gone out half-delivered.

In this guide I walk through the architecture I use when I deliver Odoo customization services for bulk SMS campaigns: an outbound queue inside Odoo, a throttled worker that respects messages per second (MPS), a signed status callback webhook for delivery receipts, and a compliance layer that keeps opt-outs and Australian sender rules enforced in code rather than in someone’s memory.

Why the Default SMS Path Breaks at Campaign Scale

Odoo’s standard sms.sms model and the native Twilio provider are fine for transactional traffic: an order confirmation, a delivery reminder, a helpdesk update. The trouble starts when a marketer selects 40,000 contacts and presses send. The Programmable Messaging API will happily accept the requests, but every sender has a throughput ceiling. A single Australian or US long code moves roughly one message per second, while short codes and larger sender pools move far more.

Anything above that ceiling sits in Twilio’s queue. Once the queue is full, or a message outlives its validity window, you get error 30001 and the message simply dies. Meanwhile the Odoo side has marked everything as sent, so nobody notices until customers complain. The fix is an architecture that knows its own capacity.

The Reference Architecture: Queue, Worker, Webhook

I split the pipeline into three components, each with one job:

  1. Outbound queue model: every message becomes a row in Odoo with a state, a use case, a retry counter and a next-attempt timestamp.
  2. Throttled worker: an ir.cron scheduled action pulls a batch, checks the phone blacklist, calls Twilio at a controlled rate and applies exponential backoff on HTTP 429 or 5xx responses.
  3. Status callback controller: a public route receives delivery receipts from Twilio, validates the X-Twilio-Signature header and updates the row.

Because each row is committed individually, a crashed worker never loses track of what Twilio already accepted, which prevents most duplicate-send incidents.

Routing Messages Through Twilio Messaging Services

Never send campaign traffic from a raw phone number. Route everything through a Twilio Messaging Service, which gives you a sender pool, sticky sender behaviour, built-in opt-out keyword handling and a single SID to reference from Odoo.

My rule is one Messaging Service per use case. Transactional messages (OTP codes, booking confirmations) get their own service with a short validity period, often a few minutes, because a login code that arrives an hour late is worse than no code at all. Marketing traffic gets a separate service and sender pool with a longer validity window. That way a large promotional blast can never starve your password resets of throughput. In Odoo, I store each service SID as a system parameter keyed by use case, so routing is a data decision rather than a code change.

Respecting Rate Limits Without Losing Messages

The core idea is simple: Odoo should never push faster than the sender pool can drain. If your pool supports 10 MPS, the worker sends at 10 MPS, and a 40,000-message campaign takes a little over an hour. Predictable beats fast.

The Outbound Queue Model and Throttled Cron Worker

Here is a trimmed version of the model and dispatcher I start from on Odoo 19. It assumes numbers are already stored in E.164 format and that credentials live in system parameters.

# twilio_mass_sms/models/twilio_outbound.py
import time
import requests
from odoo import api, fields, models

TWILIO_URL = "https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json"


class TwilioOutbound(models.Model):
    _name = "twilio.outbound"
    _description = "Twilio Outbound Message"

    number = fields.Char(required=True, index=True)
    body = fields.Text(required=True)
    use_case = fields.Selection(
        [("transactional", "Transactional"), ("marketing", "Marketing")],
        default="marketing", required=True)
    state = fields.Selection(
        [("queued", "Queued"), ("sent", "Sent"), ("delivered", "Delivered"),
         ("failed", "Failed"), ("optout", "Opted out")],
        default="queued", index=True)
    attempts = fields.Integer(default=0)
    next_try = fields.Datetime(default=fields.Datetime.now, index=True)
    twilio_sid = fields.Char(index=True, copy=False)
    error_code = fields.Char()

    @api.model
    def _cron_dispatch(self, mps=10, batch=300):
        icp = self.env["ir.config_parameter"].sudo()
        sid = icp.get_param("twilio.account_sid")
        token = icp.get_param("twilio.auth_token")
        callback = icp.get_param("web.base.url") + "/twilio/status"
        todo = self.search([("state", "=", "queued"),
                            ("next_try", "<=", fields.Datetime.now())], limit=batch)
        blocked = set(self.env["phone.blacklist"].sudo().search(
            [("number", "in", todo.mapped("number"))]).mapped("number"))

        for msg in todo:
            if msg.number in blocked:
                msg.state = "optout"
                continue
            resp = requests.post(TWILIO_URL % sid, auth=(sid, token), timeout=10, data={
                "To": msg.number,
                "Body": msg.body,
                "MessagingServiceSid": icp.get_param("twilio.mss_%s" % msg.use_case),
                "StatusCallback": callback,
                "ValidityPeriod": 300 if msg.use_case == "transactional" else 14400,
            })
            msg.attempts += 1
            if resp.status_code == 201:
                msg.write({"state": "sent", "twilio_sid": resp.json()["sid"]})
            elif resp.status_code == 429 or resp.status_code >= 500:
                delay = min(30 * 2 ** msg.attempts, 3600)
                msg.write({
                    "next_try": fields.Datetime.add(fields.Datetime.now(), seconds=delay),
                    "state": "failed" if msg.attempts >= 6 else "queued",
                })
            else:
                code = str(resp.json().get("code"))
                msg.write({"state": "failed", "error_code": code})
                if code == "21610":  # recipient replied STOP
                    self.env["phone.blacklist"].sudo()._add([msg.number])
            self.env.cr.commit()  # never lose a Twilio SID to a rollback
            time.sleep(1.0 / mps)

        if len(todo) == batch:
            self.env.ref("twilio_mass_sms.cron_dispatch")._trigger()

The per-message commit persists Twilio’s SID the moment it exists. Backoff only applies to retryable responses, so an invalid number fails once. The cron re-triggers itself when a batch was full, so large campaigns drain without an oversized transaction. Run it on a dedicated cron worker so the sleep never blocks users.

Delivery Logs and Status Callbacks

A message marked “sent” only means Twilio accepted it. The real answer arrives later through the status callback webhook as queued, sent, delivered, undelivered or failed, together with an ErrorCode. These delivery receipts are what turn your queue into a usable log.

The controller must be public, so validating the X-Twilio-Signature header is not optional. Twilio signs the full callback URL plus the sorted POST parameters with your auth token using HMAC-SHA1:

# twilio_mass_sms/controllers/status.py
import base64, hashlib, hmac
from odoo import http
from odoo.http import request

FINAL = {"delivered": "delivered", "undelivered": "failed", "failed": "failed"}


def _signature_ok(url, params, signature, token):
    payload = url + "".join(k + params[k] for k in sorted(params))
    digest = hmac.new(token.encode(), payload.encode(), hashlib.sha1).digest()
    return hmac.compare_digest(base64.b64encode(digest).decode(), signature or "")


class TwilioStatus(http.Controller):

    @http.route("/twilio/status", type="http", auth="public",
                methods=["POST"], csrf=False)
    def status(self, **post):
        icp = request.env["ir.config_parameter"].sudo()
        url = icp.get_param("web.base.url") + "/twilio/status"
        sig = request.httprequest.headers.get("X-Twilio-Signature")
        if not _signature_ok(url, post, sig, icp.get_param("twilio.auth_token")):
            return request.make_response("forbidden", status=403)
        state = FINAL.get(post.get("MessageStatus"))
        msg = request.env["twilio.outbound"].sudo().search(
            [("twilio_sid", "=", post.get("MessageSid"))], limit=1)
        if msg and state:
            msg.write({"state": state, "error_code": post.get("ErrorCode")})
        return request.make_response("", status=204)

If Odoo sits behind a reverse proxy, make sure web.base.url matches the exact public URL Twilio calls, including https, or every signature check will fail.

For the broader patterns behind secure inbound endpoints, my guide on integrating external systems with Odoo’s API covers authentication and payload design in more depth.

Compliance: Opt-Outs, Consent and Australian Sender IDs

Compliance belongs in the architecture, not in a policy document. Under Australia’s Spam Act 2003, every commercial message needs consent, clear sender identification and a functional unsubscribe option. My implementation enforces this in three places:

  1. Before queueing: only partners with a recorded consent source are eligible for marketing campaigns.
  2. Before sending: the worker checks the phone blacklist on every batch, so an opt-out recorded five minutes ago is honoured.
  3. After sending: Twilio error 21610 (recipient has replied STOP) writes straight back to the blacklist, keeping Odoo and Twilio in agreement.

For Australian traffic there is one more change. Since 1 July 2026 the ACMA SMS Sender ID Register applies, and messages from an unregistered alphanumeric sender ID are labelled “Unverified” on the recipient’s phone. If your campaigns use a brand name as the sender, register it and confirm your Messaging Service uses the approved ID. In the US, the equivalent concern is A2P 10DLC registration for long codes.

If you are planning a high-volume SMS rollout and want the throughput, logging and compliance design reviewed before the first campaign goes out, Book a Consultation and we can map your sender pool capacity against your real sending windows.

Conclusion

Mass messaging in Odoo works well once you stop treating it as a button and start treating it as a pipeline. Route traffic through purpose-specific Messaging Services, throttle to your real MPS, persist every Twilio SID immediately, validate every callback and enforce opt-outs in code. Do that, and your delivery reports become something you can defend to any client or regulator.

Frequently Asked Questions

Can Odoo 19 send bulk SMS through Twilio without a custom module?

Yes, Odoo 19 supports Twilio as an SMS provider, which covers transactional use and modest campaigns. For large volumes with throttling, routing and detailed logs, a small custom queue module gives you more control.

What causes Twilio error 30001 in bulk campaigns?

Error 30001 is a queue overflow. It happens when Odoo submits messages faster than your sender pool’s MPS can drain them within the validity period. Throttling the Odoo worker to your actual throughput prevents it.

How should I handle HTTP 429 responses from the Twilio API?

Treat 429 as retryable. Reschedule with exponential backoff, cap delay and attempts, and never retry permanent errors.

Do I need to validate the X-Twilio-Signature header?

Yes. The route is public, so without validation anyone could post fake delivery receipts and corrupt your logs.

How do opt-outs stay in sync between Twilio and Odoo?

Write Twilio error 21610 and inbound STOP replies back to Odoo’s phone blacklist, and check that blacklist on every dispatch batch. That keeps both systems enforcing the same opt-out list.

Reach Out for Support

Facing a problem? Contact us and receive expert help and fast solutions.