SMS Gateway - Complete Documentation

The SMS Gateway module replaces Odoo IAP for sending SMS messages. Instead of a cloud service, it uses physical Android phones as SMS gateways. Each phone is paired with Odoo via a QR code and automatically sends SMS messages from the queue.

Table of Contents

1. System Architecture

┌────────────────────────┐ REST API ┌────────────────────────┐ │ Odoo Server │ pending / confirm │ SMS Gateway App │ │ │ ◀─────────────────▶ │ (Android phone) │ │ • mailing.mailing │ heartbeat (60s) │ │ │ • sms.sms │ │ • SMS Queue Polling │ ┌──────────────┐ │ • sms.gateway.phone │ │ • DirectSms (native) │ ───▶ │ SMS Recipient│ │ • phone.blacklist │ ◀───────────────── │ • Inbound SMS (STOP) │ │ +420 777... │ │ • link.tracker │ inbound / STOP │ │ └──────┬───────┘ └──────────┬─────────────┘ └─────────────────────────┘ │ │ /r/{code}/s/{sms_id} (UTM tracking + click counting) │ └────────────────────────────── Clicks link ◀──────────────────────────────┘

Components

Component Description
sms_gateway Odoo module - manages phones, queue, API endpoints
sms-gateway-app React Native (Expo) application for Android
sms.gateway.phone Model representing a single registered phone
sms.sms Extended with gateway_phone_id and gateway_state

2. Odoo Module Installation

Prerequisites

Steps

  1. Install the Python dependency:
    pip install qrcode[pil]
  2. Copy the sms_gateway module to the Odoo addons directory:
    cp -r extra/sms/sms_modules/sms_gateway /path/to/odoo/addons/
  3. Restart the Odoo server and update the module list
  4. Install the SMS Gateway module via Applications
Important: The sms_gateway module is mutually exclusive with sms_httpsms. If you are using sms_httpsms, uninstall it before installing sms_gateway.

3. Mobile App Installation

Download APK (recommended)

Download the latest release APK from GitHub Releases. On your phone, open the downloaded .apk file and allow installation from unknown sources.

Note: The app is not on Google Play. It uses the SEND_SMS and READ_SMS permissions, which require a special review. Sideloading the APK from GitHub Releases is the intended distribution method.

Install the latest release via ADB (script)

Connect the phone via USB (with USB debugging enabled). From the sms-gateway module root (extra/sms):

# Latest release
./scripts/install-release.sh

# A specific tag
./scripts/install-release.sh v1.3.0

# Re-download even if cached
./scripts/install-release.sh --force

Downloads app-release.apk from the GitHub release (cached per tag under .release-cache/), installs it with adb install -r and grants the SMS-limit permission. Requires gh (authenticated) and adb — no build toolchain needed.

Prerequisites (build from source)

Build & install (one script)

Connect the phone via USB. From the sms-gateway module root (extra/sms):

./scripts/setup.sh              # full flow: deps, prebuild, build, install
./scripts/setup.sh --clean      # prebuild with --clean (after app.json / native changes)
./scripts/setup.sh --no-install # build only, skip ADB install

The script bootstraps nvm + Node 20 (installs them if missing), initializes the sms-gateway-app submodule, detects the Android SDK, installs dependencies, prebuilds, builds the release APK, installs it via ADB and grants WRITE_SECURE_SETTINGS.

Build manually

git submodule update --init --recursive   # fetch sms-gateway-app sources
cd sms-gateway-app

# Install dependencies
npm install

# Prebuild native project (required for native modules)
npx expo prebuild

# Run on a connected phone
npx expo run:android

# OR build a release APK via EAS
eas build --profile production-apk --platform android

Upload as GitHub Release

gh release create v1.x.x ./build/*.apk \
  --repo Varyshop/sms-gateway-app \
  --title "v1.x.x" \
  --notes "Release notes"

Required Android Permissions

Permission Purpose
SEND_SMS Sending SMS without opening the app
RECEIVE_SMS Reading incoming SMS (STOP detection)
READ_PHONE_STATE Reading SIM card numbers
READ_PHONE_NUMBERS Reading the phone number
CAMERA Scanning QR codes

4. Module Settings in Odoo

Creating a gateway phone

  1. Go to Email Marketing > SMS Gateway > Gateway Phones
  2. Click New
  3. Fill in:
    Field Description Example
    Name Phone name Phone 1 - O2
    Phone Number Main number (international format) +420777123456
    Phone Number 2 Second number (dual SIM, optional) +420608987654
    Daily Limit Max SMS per day 500
    SMS per Minute Max SMS per minute (rate limit) 100
    Heartbeat Timeout Minutes without heartbeat before going offline 5
  4. Click Generate API Key - an API key and QR code will be generated
Tip: You can register multiple phones. The system automatically distributes the load (least-loaded algorithm).

5. Phone Pairing

  1. In Odoo, open the gateway phone form and make sure a QR code has been generated
  2. On the Android phone, open the SMS Gateway app
  3. Go to the Settings tab
  4. Click Scan QR Code
  5. Point the camera at the QR code displayed in Odoo
  6. The app will automatically connect to the server and start sending heartbeats

The QR code contains

{
    "type": "sms_gateway",
    "url": "https://vas-odoo-server.cz",
    "api_key": "nahodny_bezpecnostni_token"
}
Verification: After pairing, the phone status in Odoo should change to Online. If not, check the network connection and the heartbeat timeout setting.

6. SMS Campaign Setup

Creating a campaign

  1. Go to Email Marketing > SMS Marketing
  2. Click New
  3. Configure:

Example SMS text

Ahoj {{ object.name }}! Mate 20% slevu na vse.
Nakupte na https://www.example.cz/akce
STOP pro odhlaseni
Automatic replacement: The module automatically replaces all URLs in the SMS with short tracking links (e.g. https://vas-server.cz/r/ABC123/s/42). Original unsubscribe links are replaced with the text STOP pro odhlaseni.

Filtering recipients

Use the Odoo domain filter to select recipients:

Filter example Description
[("category_id", "in", [10])] Contacts with category ID 10
[("country_id.code", "=", "CZ")] Czech contacts
[("customer_rank", ">", 0)] Customers only
[("opt_out", "=", False)] Only those who have not unsubscribed

7. Domain Filtering for Phones

Each gateway phone can have a Partner Domain Filter configured. This filter determines which SMS messages will be assigned to the given phone based on the recipient's partner.

How it works

  1. When sms.sms._send() assigns SMS to the queue, it searches for available phones
  2. For each phone with a configured domain_filter, it verifies whether the recipient's partner matches the filter
  3. A phone without a filter accepts SMS for all partners
  4. A phone with a filter only accepts SMS where the partner matches the domain

Usage examples

Scenario Domain Filter Explanation
Phone for VIP customers [("category_id", "in", [10])] Only customers with category "VIP" (ID 10)
Phone for Prague [("state_id.name", "=", "Praha")] Only customers from Prague
Phone for everyone (empty) Accepts SMS for all partners
Phone for companies [("is_company", "=", True)] Companies only, not individuals
Warning: If no phone matches the filter for a given partner, the SMS is marked as an error (failure_type='sms_server'). We recommend having at least one phone without a filter as a fallback.

8. Sending

Automatic sending (campaign)

  1. Create and configure a campaign (see section 6)
  2. Click Send or Schedule
  3. Odoo creates sms.sms records in the outgoing state
  4. A cron job (SMS: SMS Queue Manager) picks up outgoing SMS
  5. _send() assigns each SMS to a gateway phone and changes the state to pending
  6. The mobile app fetches pending SMS via the API
  7. The app sends the SMS and confirms the state (sent/error)

Manual sending (Force Create SMS Queue)

If SMS records were not created automatically:

  1. Open the campaign
  2. Click Force Create SMS Queue
  3. The system creates SMS records for all remaining recipients

Pausing a campaign

A campaign can be paused without cancelling it:

  1. Open the campaign in Sending mode
  2. Toggle the Paused switch to active
  3. SMS in the queue will not be sent, but will remain assigned
  4. To resume, turn off the Paused switch
outgoing ──▶ pending ──▶ processing ──▶ sending ──┬──▶ sent (successfully sent) ✓ (assigned (phone (phone │ to phone) picked up) sending) └──▶ error (send error) ✗ If the campaign is paused: outgoing ──▶ (skipped, stays outgoing)

9. How Links and Tracking Work

Automatic link conversion

All URLs in the SMS body are automatically converted to short tracking links:

Phase Example
Original text Nakupte na https://www.example.cz/akce
After conversion Nakupte na https://vas-server.cz/r/ABC123
After SMS ID assignment Nakupte na https://vas-server.cz/r/ABC123/s/42

How tracking works

  1. Link creation: Odoo creates a link.tracker record with a short code and UTM parameters
  2. Adding SMS ID: Each link gets /s/{sms_id} appended to identify the specific recipient
  3. Recipient clicks: The recipient clicks the link in the SMS message
  4. Server records:
  5. Redirect: The server redirects to the original URL with UTM parameters:
    https://www.example.cz/akce?utm_campaign=Q1_2025&utm_medium=sms&utm_source=kampan

UTM parameters

Automatically added on redirect:

Parameter Value Source
utm_campaign Campaign name mailing.mailing.campaign_id
utm_medium "sms" mailing.mailing.medium_id
utm_source Source name mailing.mailing.source_id
Google Analytics: Thanks to UTM parameters you can track traffic from SMS campaigns directly in Google Analytics (Acquisition > Campaigns).

10. Unsubscribe and Blacklist (GDPR)

How unsubscribing works

The system supports two ways to unsubscribe from SMS campaigns:

Method 1: STOP message (recommended)

  1. At the end of each SMS there is the text: STOP pro odhlaseni
  2. The recipient replies with an SMS message containing the word STOP
  3. The mobile app detects an incoming SMS containing the word STOP
  4. The app sends the information to the Odoo endpoint /sms-gateway/inbound
  5. Odoo adds the recipient's number to phone.blacklist
  6. All future campaigns automatically skip blacklisted numbers

Method 2: Web link (standard Odoo)

Odoo normally generates an unsubscribe URL in the format /sms/{mailing_id}/{trace_code}. Our module replaces these URLs with the text "STOP pro odhlaseni", because:

GDPR requirement: According to GDPR, every marketing SMS message must include an unsubscribe option. The module automatically adds "STOP pro odhlaseni" at the end of the message. Do not remove this information!

Blacklist model

Odoo uses the phone.blacklist model from the phone_validation module:

Where to find the blacklist

SMS Marketing > Configuration > Phone Blacklist

11. Campaign Statistics and Performance

Tracked metrics

Metric Description How it's measured
Sent Number of successfully sent SMS sms.sms in state sent
Delivered Delivered SMS = sent + opened + replied
Clicked Recipients who clicked a link mailing.trace with links_click_datetime
Click Rate Percentage of clicks (unique clicks / total sent) × 100
Bounced Invalid numbers mailing.trace in state bounce
Failed Send error mailing.trace in state error

Where to find statistics

  1. Campaign overview: Open the campaign - the header shows Sent/Clicked/Bounced counters
  2. Detailed statistics: Click a counter to display the list of records
  3. Link Tracker: Email Marketing > Link Tracker - overview of all tracked links with click counts
  4. Gateway phones: SMS Gateway > Gateway Phones - sent_today, sent_total, pending, error counters

Link Tracker - detailed analysis

Each link in an SMS has its own link.tracker record:

12. Limits and Rate Limiting

Limit types

Limit Where to set Description
Daily Limit sms.gateway.phone Max SMS per day per phone. The sent_today counter resets at midnight.
SMS per Minute sms.gateway.phone Max SMS per minute. The app calculates the delay between SMS: 60000 / rate_limit ms.
Android SMS limit Android OS Android by default limits to ~30 SMS per 30 minutes. This can be changed in settings.

How rate limiting works

  1. The server sets rate_limit on each gateway phone (e.g. 100 SMS/min)
  2. The mobile app gets the rate_limit from the heartbeat response
  3. Between each SMS the app waits 60000 / rate_limit milliseconds
  4. If the phone reaches the daily_limit, no further SMS is assigned to it
Android limitation: On some Android devices there is a hardware limit of ~30 SMS per 30 minutes. For larger volumes we recommend using multiple phones or changing the ADB setting:
adb shell settings put global sms_outgoing_check_max_count 10000

13. Dual SIM Support

The system supports phones with two SIM cards:

Note: Not all Android phones allow programmatic selection of the SIM slot for outgoing SMS. On some devices the default SIM card is used.

14. API Reference

All endpoints require the X-API-Key header with a valid gateway phone API key.

POST /sms-gateway/heartbeat

Send a heartbeat and get the queue status.

// Request
{
    "phone_numbers": ["+420777123456", "+420608987654"],
    "battery_level": 85,
    "signal_strength": -70
}

// Response
{
    "success": true,
    "pending_count": {"+420777123456": 12, "+420608987654": 5},
    "rate_limit": 100
}

POST /sms-gateway/pending

Get SMS waiting to be sent.

// Request
{"phone_numbers": ["+420777123456"], "limit": 20}

// Response
{
    "success": true,
    "sms_list": [
        {
            "id": 123,
            "phone_number": "+420999888777",
            "message": "Text SMS zpravy...",
            "uuid": "abc-123-def",
            "gateway_phone_number": "+420777123456"
        }
    ]
}

POST /sms-gateway/confirm/{sms_id}

Confirm the send status of an SMS.

// Request - uspech
{"status": "sent"}

// Request - chyba
{"status": "error", "error_message": "Permission denied"}

// Request - probihajici
{"status": "sending"}

// Response
{"success": true}

POST /sms-gateway/inbound

Report an incoming SMS (for STOP detection). The message is saved to the sms.gateway.inbound model and if it contains STOP, the number is added to the blacklist.

// Request
{
    "from_number": "+420999888777",
    "message": "STOP",
    "to_number": "+420777123456"
}

// Response
{
    "success": true,
    "blacklisted": true,
    "partner_found": true
}

POST /sms-gateway/inbound-batch

Bulk report of multiple incoming SMS at once. Since v1.2.0 the app sends all received SMS (not just STOP) to the server. The server deduplicates based on the combination from_number + to_number + message + received_at, so resending the same messages (e.g. on inbox rescan) will not create duplicates.

// Request
{
    "messages": [
        {
            "from_number": "+420999888777",
            "message": "STOP",
            "to_number": "+420777123456",
            "received_at": "2026-03-22T10:30:00Z"
        },
        {
            "from_number": "+420111222333",
            "message": "Dekuji za informaci",
            "to_number": "+420777123456",
            "received_at": "2026-03-22T11:15:00Z"
        }
    ]
}

// Response
{
    "success": true,
    "processed": 2,
    "duplicates_skipped": 0
}
Chatter integration: Every incoming SMS is automatically written to the partner's chatter in Odoo as a formatted HTML note (blockquote with the message text). If the message contains STOP, a visual STOP label is added.

Model: sms.gateway.inbound

All incoming SMS are stored in the sms.gateway.inbound model:

Field Type Description
from_number Char Sender number (E.164)
to_number Char Gateway phone number the SMS arrived at
message Text SMS message content
received_at Datetime Time the message was received on the phone
partner_id Many2one Partner found by sender number (may be empty)
is_stop Boolean Whether the message contains the STOP keyword
is_blacklisted Boolean Whether the sender number is on the blacklist
gateway_phone_id Many2one Reference to sms.gateway.phone

POST /sms-gateway/stats

Gateway phone statistics.

// Response
{
    "success": true,
    "phones": [
        {
            "id": 1,
            "name": "Telefon 1 - O2",
            "phone_number": "+420777123456",
            "phone_number_2": null,
            "state": "online",
            "sent_today": 142,
            "daily_limit": 500,
            "sent_total": 4582,
            "pending_count": 23,
            "rate_limit": 100
        }
    ]
}

POST /sms-gateway/register-fcm

Register an FCM token for push notifications. The phone sends its FCM token after pairing or when the token is renewed.

// Request
{
    "fcm_token": "dK3xP9...dlouhy_firebase_token"
}

// Response - uspech
{
    "success": true,
    "message": "FCM token registered"
}

// Response - FCM neni povoleno
{
    "success": true,
    "message": "FCM not enabled, token ignored"
}

POST /sms-gateway/confirm-batch

Bulk confirmation of the status of multiple SMS at once. More efficient than individual /confirm/{sms_id} calls.

// Request
{
    "results": [
        {"sms_id": 123, "status": "sent"},
        {"sms_id": 124, "status": "sent"},
        {"sms_id": 125, "status": "error", "error_message": "Number unreachable"}
    ]
}

// Response
{
    "success": true,
    "processed": 3
}

14b. Marketing Models (Segments and Templates)

Since version v18.0.2.2.0 the module includes models for SMS marketing directly from the mobile app. An admin defines segments and templates in Odoo; an operator uses them from the app without backend access.

Model: sms.marketing.segment

Defines a group of recipients (e.g. "No order in 3 months").

FieldTypeDescription
nameCharSegment name (translatable)
codeChar, uniqueSlug (no_order_3m, one_order_only, new_customers_30d)
domain_filterCharDeclarative Odoo domain, e.g. [("country_id.code", "=", "CZ")]. If set, it is used instead of code-based logic.
sequenceIntegerOrder in the list
activeBooleanActive/inactive
descriptionTextDescription shown in the app

Default segments (data)

CodeNameLogic
no_order_3mNo order in 3 monthsSQL: partners with an order but none in the last 90 days
one_order_onlySingle order onlySQL: partners with exactly 1 confirmed order
new_customers_30dNew customers (30 days)Domain: [("create_date", ">=", today - 30d)]

Key methods

MethodDescription
_get_domain() Returns the basic Odoo domain of the segment. Uses domain_filter or dispatches to _domain_{code}().
_get_full_domain(phone, exclude_contacted_days) Single source of truth — assembles the complete domain: segment + blacklist + phone filter + exclusion of contacted partners.
_get_storable_domain(phone, exclude_contacted_days) Returns a domain suitable for storage in mailing_domain. Declarative segments are stored as a readable domain; SQL segments are pre-resolved to ('id', 'in', [...]).
_resolve_recipient_ids(phone, exclude_days, limit) Resolves the domain to a list of partner IDs. Used for counts and preview.
_get_recipient_count(phone, exclude_days) Counts the number of matching partners.
_get_exclusion_domain(days) Returns a declarative domain leaf ('stats_last_sms_days', '>', days) excluding partners contacted in the last N days. If days <= 0, returns an empty list (filter ignored). Since v18.0.2.3.0 it is fully declarative — no SQL on mailing_trace.
Domain compositing (v18.0.2.3.0+): _get_storable_domain() combines 3 declarative filters into a single stored domain:
  1. segment domain_filter
  2. phone domain_filter from sms.gateway.phone
  3. ('stats_last_sms_days', '>', exclude_contacted_days) from the template
All 3 conditions must be met by the recipient simultaneously. The domain is stored in mailing.mailing.mailing_domain as a pure declarative Odoo domain — Odoo re-evaluates it on every send, without runtime SQL queries. The exclusion leaf uses the computed field res.partner.stats_last_sms_days with its own _search_stats_last_sms_days() handler (v18.0.2.4.0+), which for operators >/>= returns an OR branch: partners with a stats row satisfying the date or partners without a stats row (('stats_id', '=', False)). This ensures that brand-new partners without any SMS history satisfy the condition "not contacted in the last N days". The previous fields.Integer(related='stats_id.last_sms_sent_days') variant did NOT work, because Odoo converted the search to a LEFT JOIN via O2m, which filtered NULL rows out (see changelog v18.0.2.4.0).

Model: sms.marketing.template

SMS template assigned to a gateway phone.

FieldTypeDescription
nameCharTemplate name
bodyTextSMS text (may contain {{object.name}})
phone_idMany2one → sms.gateway.phoneAssigned phone
segment_idsMany2many → sms.marketing.segmentAllowed segments
default_limitInteger (100)Default suggested recipient count
max_limitInteger (500)Hard cap on recipient count
exclude_contacted_daysInteger (0)Exclude partners contacted in the last N days

New fields on mailing.mailing

FieldTypeDescription
gateway_phone_forced_idMany2one → sms.gateway.phoneForced phone for all SMS in the campaign
recipient_limitIntegerMax number of recipients
marketing_template_idMany2one → sms.marketing.templateBack reference to the template
created_from_appBooleanCreated from the mobile app
pausedBooleanPaused campaign (not sending, but SMS are in the queue)

14c. Campaign API (Mobile App)

Endpoints for the mobile app enabling creation and monitoring of SMS campaigns without backend access. All endpoints: POST, auth via X-API-Key, JSON body/response.

POST /sms-gateway/campaign/templates

Returns templates assigned to the phone identified by the API key.

// Response
{
    "success": true,
    "templates": [
        {
            "id": 1,
            "name": "Akce tento tyden",
            "body": "Ahoj {{object.name}}! Mate slevu 20%...",
            "segments": [
                {"id": 1, "name": "Neobjednali 3 mesice", "code": "no_order_3m"},
                {"id": 2, "name": "Jedina objednavka", "code": "one_order_only"}
            ],
            "default_limit": 100,
            "max_limit": 500,
            "exclude_contacted_days": 14
        }
    ]
}

POST /sms-gateway/campaign/filters

For each segment, counts the number of matching recipients (including phone domain filter and exclusion).

// Request
{"template_id": 1}

// Response
{
    "success": true,
    "filters": [
        {
            "id": 1,
            "code": "no_order_3m",
            "name": "Neobjednali 3 mesice",
            "description": "Partneri s objednavkou ale zadnou za 90 dni",
            "recipient_count": 342
        }
    ]
}

POST /sms-gateway/campaign/preview

Campaign preview — recipient count and rendered text with a sample partner.

// Request
{"template_id": 1, "segment_id": 1, "limit": 200}

// Response
{
    "success": true,
    "recipient_count": 200,
    "preview_text": "Ahoj Jan Novak! Mate slevu 20%...",
    "template_name": "Akce tento tyden",
    "segment_name": "Neobjednali 3 mesice"
}

POST /sms-gateway/campaign/create

Creates a mailing.mailing with full tracking. Accepts optional custom_body (text edit from the app) and a send_now switch.

// Request
{
    "template_id": 1,
    "segment_id": 1,
    "limit": 200,
    "custom_body": "Upraveny text SMS...",
    "send_now": true
}

// Response
{
    "success": true,
    "campaign_id": 42,
    "recipient_count": 200,
    "state": "sending"
}
send_now: If send_now=false, the campaign is created in the in_queue state with paused=true. SMS are in the queue but are not sent until the operator presses "Send now" in the app (endpoint assign-sim).
Domain storage: For segments with a declarative domain_filter a readable domain is stored in mailing_domain (e.g. [("country_id.code", "=", "CZ"), ...]). For SQL segments it is pre-resolved to a compact [("id", "in", [1, 2, 3, ...])].

POST /sms-gateway/campaign/assign-sim

Assigns a gateway phone and SIM to the SMS in the campaign. Uses the same logic as the "Send via Gateway" action in Odoo.

// Request — jedina SIM (nebo auto-prirazeni bez SIM cisla)
{
    "campaign_id": 42,
    "mode": "single",
    "sim_number": "+420777123456"
}

// Request — rozdeleni mezi vice SIM
{
    "campaign_id": 42,
    "mode": "split",
    "sim_numbers": ["+420777123456", "+420608987654"]
}

// Response
{
    "success": true,
    "assigned": 200,
    "message": "Assigned 200 SMS to phone"
}
What assign-sim does:

POST /sms-gateway/campaign/list

List of campaigns created from the app for the given phone.

// Response
{
    "success": true,
    "campaigns": [
        {
            "id": 42,
            "name": "Akce tento tyden - 04.04.2026 10:30",
            "state": "done",
            "date_created": "2026-04-04 10:30:00",
            "total": 200,
            "sent": 195,
            "pending": 0,
            "error": 5,
            "clicked": 42,
            "total_clicks": 67,
            "order_count": 8,
            "revenue": 12400.0,
            "optout": 3
        }
    ]
}

POST /sms-gateway/campaign/status/{mailing_id}

Campaign detail with marketing statistics.

// Response
{
    "success": true,
    "id": 42,
    "name": "Akce tento tyden - 04.04.2026 10:30",
    "state": "done",
    "total": 200,
    "sent": 195,
    "pending": 0,
    "error": 5,
    "clicked": 42,
    "total_clicks": 67,
    "order_count": 8,
    "revenue": 12400.0,
    "optout": 3,
    "created_at": "2026-04-04 10:30:00"
}

Statistics explanation

FieldDescriptionSource
clickedUnique recipients who clickedmailing.trace with links_click_datetime
total_clicksTotal click count (including repeated clicks)link.tracker.click via UTM campaign
order_countNumber of orders from the campaignsale.order with campaign_id = UTM campaign
revenueTotal revenue from ordersSum of amount_total from matching orders
optoutNumber of unsubscribes (STOP)mailing.trace in state cancel

15. Troubleshooting

Problem Cause Solution
Phone is still Offline Heartbeat not reaching the server
  • Check the phone's network connection
  • Verify the API key is correct (re-scan the QR code)
  • Check the server URL (must be HTTPS)
  • Increase heartbeat_timeout on the gateway phone
SMS are not being sent App does not have SEND_SMS permission Open Android Settings > Apps > SMS Gateway > Permissions > allow SMS
SMS in queue but no phone is picking them up No phone matches the domain_filter Check domain_filter on gateway phones. At least one should be without a filter.
All SMS have error status No gateway phone is online Make sure the app is running, paired, and sending heartbeats
Daily limit reached too soon sent_today counter Increase daily_limit or add more phones. Reset happens at midnight (cron).
Android blocks SMS after 30 messages Android hardware limit Use ADB command: adb shell settings put global sms_outgoing_check_max_count 10000
STOP does not add number to blacklist App does not have RECEIVE_SMS permission Enable RECEIVE_SMS permission in Android settings
Links in SMS don't work Incorrect web.base.url configuration In Odoo set the correct System Parameters > web.base.url (must be publicly accessible)
Campaign is not marked as Done Some SMS still pending Check that all SMS are in sent or error state. The campaign may be paused.
SMS are not sent when screen is locked Android kills the app due to battery optimization
  • Disable Battery Optimization for SMS Gateway (Settings > Apps > SMS Gateway > Battery > Unrestricted)
  • On MIUI/HarmonyOS: add to Autostart and disable DuraSpeed / Battery saver
  • Verify that AlarmManager has the SCHEDULE_EXACT_ALARM permission
STOP SMS missing from blacklist InboundSmsWorker failed or no network
  • Check that WorkManager has network access (Settings > Data connection)
  • Verify the RECEIVE_SMS permission
  • In the app log, look for InboundSmsWorker errors
  • Check that the endpoint /sms-gateway/inbound is accessible from the phone
  • Use the Re-scan received SMS button in Settings > Service to retroactively send all received SMS from the last 30 days
Incoming SMS not reaching the server InboundSmsWorker used incorrect authentication (Authorization: Bearer instead of X-API-Key)
  • Fix in v1.2.0: InboundSmsWorker now correctly uses the X-API-Key header (same as other endpoints)
  • Update the app to v1.2.0+
  • After updating, use Re-scan received SMS to send missing messages from the past
FCM push not arriving FCM is not correctly configured
  • Verify sms_gateway.fcm_enabled = True in System Parameters
  • Check that sms_gateway.fcm_credentials_json or sms_gateway.fcm_credentials_path is set
  • Verify installation: pip list | grep firebase-admin
  • Check that the phone has a valid FCM token (endpoint /sms-gateway/register-fcm)

Diagnostic commands (curl)

# Test heartbeat
curl -X POST https://vas-server.cz/sms-gateway/heartbeat \
  -H "Content-Type: application/json" \
  -H "X-API-Key: VAS_API_KLIC" \
  -d '{"phone_numbers": ["+420777123456"]}'

# Overeni pending SMS
curl -X POST https://vas-server.cz/sms-gateway/pending \
  -H "Content-Type: application/json" \
  -H "X-API-Key: VAS_API_KLIC" \
  -d '{"phone_numbers": ["+420777123456"], "limit": 5}'

# Test inbound STOP
curl -X POST https://vas-server.cz/sms-gateway/inbound \
  -H "Content-Type: application/json" \
  -H "X-API-Key: VAS_API_KLIC" \
  -d '{"from_number": "+420999888777", "message": "STOP", "to_number": "+420777123456"}'

16. FCM Push Notifications

Since version v18.0.2.0.0 the module supports Firebase Cloud Messaging (FCM) for immediately waking up the phone when new SMS are in the queue. Instead of waiting for the next polling interval, the phone receives a push notification and immediately fetches pending SMS.

Architecture

Odoo Server Google FCM Android App │ │ │ │ 1. New SMS in queue │ │ │──────────────────────────────▶ │ │ send_fcm_wake() │ │ │ (data message) │ 2. Push notification │ │ │───────────────────────────▶│ │ │ │ 3. FcmMessageHandler │ │ │ wakes the app │ 4. GET /sms-gateway/pending │ │ │◀──────────────────────────────────────────────────────────│ │ 5. SMS data │ │ │──────────────────────────────────────────────────────────▶│ │ │ │ 6. Sends SMS

Configuration on the Odoo side

System Parameter Type Description
sms_gateway.fcm_enabled Boolean Enables/disables FCM push notifications. Default: False
sms_gateway.fcm_credentials_json Text (JSON) Inline content of the Firebase service account JSON file. Takes precedence over fcm_credentials_path.
sms_gateway.fcm_credentials_path Text (path) Absolute path to the Firebase service account JSON file on the server. Used if fcm_credentials_json is not set.

Python dependency

pip install firebase-admin

The firebase-admin library is automatically installed when the module is installed (declared in external_dependencies in the manifest). If installation fails (e.g. missing system libraries), the FCM feature silently deactivates and phones continue in polling mode.

Implementation: tools/fcm_service.py

The main function send_fcm_wake(phone) sends an FCM data message to the registered phone. A data message (unlike a notification message) wakes the app in the background without showing a notification to the user.

# Format FCM data message
{
    "type": "sms_pending",
    "phone_id": "7",
    "timestamp": "1710751200"
}
Field Description
type Always "sms_pending" — the app decides the action based on the type
phone_id ID of the sms.gateway.phone record in Odoo
timestamp Unix timestamp of sending — for deduplication on the app side

Backward compatibility

Safe upgrade: Phones without a registered FCM token (the fcm_token field on sms.gateway.phone) continue in polling mode without any behavioral change. FCM is a purely additive enhancement — no existing phone will stop working.

17. Background and Reliability (Android)

Android aggressively restricts background app activity to save battery. This section documents the strategies used in the SMS Gateway app for reliable operation even with a locked screen and in Doze mode.

AlarmManager vs ScheduledExecutorService

For regular polling (heartbeat + fetching SMS) the app uses AlarmManager with exact wakeup instead of ScheduledExecutorService or Handler.postDelayed().

Approach Doze behavior MIUI/HarmonyOS
ScheduledExecutorService Stopped after ~1 min in Doze Aggressively terminated
Handler.postDelayed Stopped after ~1 min in Doze Aggressively terminated
AlarmManager.setExactAndAllowWhileIdle Wakes the device from Doze Works even on MIUI (with battery exception)
Permission: Since Android 12 (API 31) the permission SCHEDULE_EXACT_ALARM or USE_EXACT_ALARM is required. The app requests it automatically on first launch.

WorkManager for incoming SMS

Processing incoming SMS (STOP detection) uses WorkManager (InboundSmsWorker) instead of direct HTTP calls from the BroadcastReceiver. Reasons:

WakeLock strategy

The app uses PowerManager.WakeLock in two places to prevent the CPU from sleeping during critical operations:

Component WakeLock tag Duration Purpose
FcmMessageHandler sms-gateway:fcm-wake Max 60 seconds Keeps CPU awake during poll + SMS sending after FCM wake
SmsBroadcastReceiver sms-gateway:inbound-sms Max 30 seconds Keeps CPU awake during processing of incoming SMS and enqueue into WorkManager

IMPORTANCE_HIGH notification channel

The foreground service notification uses a channel with IMPORTANCE_HIGH. This ensures Android does not kill the service even under low memory conditions. The user sees a persistent notification in the status bar (Android requirement for foreground service).

Battery optimization exception

On first app launch the user is asked to grant a battery optimization exception (REQUEST_IGNORE_BATTERY_OPTIMIZATIONS). Without this exception Android may:

Check: In the app's Settings section there is a battery optimization status indicator. If it shows red, the user must manually add an exception.

Rescan Inbox (native method)

Since v1.2.0 the app includes a native method SmsModule.rescanInbox(days), which reads the SMS inbox for the last N days (default: 30) and sends all found messages to the server via the endpoint /sms-gateway/inbound-batch.

This feature is available to the user via the Re-scan received SMS button in Settings > Service. The server performs deduplication, so it is safe to run the rescan repeatedly.

// React Native volani
import { NativeModules } from 'react-native';
const messages = await NativeModules.SmsModule.rescanInbox(30);
// messages: Array<{from: string, body: string, date: number}>

// Nasledne se zpravy odeslou pres inbound-batch endpoint

WRITE_SECURE_SETTINGS permission

To increase the native Android limit on the number of outgoing SMS, the app needs the WRITE_SECURE_SETTINGS permission. This permission cannot be granted from the phone's GUI — it requires an ADB connection:

# Primo pres ADB
adb shell pm grant com.varyshop.smsgatewayapp android.permission.WRITE_SECURE_SETTINGS

# Nebo pri buildu ze zdrojaku
yarn grant-permission
Without this permission Android may block sending after exceeding the native limit (~30 SMS per 30 minutes). With the permission the app automatically raises the limit.

Heartbeat safety net

Even with FCM there can be situations where a push notification does not arrive (e.g. temporary Google service outage). Therefore the app maintains a heartbeat safety net:

  1. Every heartbeat returns pending_count for each number
  2. If pending_count > 0, the app immediately starts a poll cycle
  3. The heartbeat interval is typically 60 seconds — the maximum delay on FCM failure is therefore 1 minute
  4. This mechanism works independently of FCM and ensures delivery even without push notifications
FCM push (immediate) ──▶ Poll + Send │ │ (if FCM fails) │ Heartbeat (60s) ──▶ pending_count > 0? ──▶ Poll + Send │ └── pending_count == 0 ──▶ No action
For any questions or help with setup please contact info@varyshop.eu or the developer directly info@michalvarys.eu

18. Changelog

v18.0.2.6.0 / App v1.5.0 (2026-04-06)

v18.0.2.5.0 (2026-04-05)

v18.0.2.4.0 (2026-04-05)

v18.0.2.3.0 / App v1.4.0 (2026-04-05)

v18.0.2.2.0 / App v1.3.0 (2026-04-04)

v18.0.2.1.0 / App v1.2.0


SMS Gateway v18.0.2.6.0 • VaryShop • 2026