📡 Python Hands‑On: Auto‑Receive SMS Verification Codes with Flask & SIM800L

A practical guide by a tinkerer who hates reaching for the phone when signing up for services. We'll turn a $5 GSM module into your personal SMS API.

Why I Built This — The “Stop Looking at Your Phone” Moment

Last month I had to register a dozen test accounts for a side project. Every single one wanted a phone number. I was tired of picking up my personal phone, squinting at verification codes, then typing them into my browser. I thought: “Can't a script do this for me?”

So I dug out a SIM800L module from my junk box, a spare SIM card, and wrote a little Flask API that listens for incoming SMS and serves the latest code on demand. That’s what we’re building today. Strap in — we’ll go from wiring to running code, with every embarrassing mistake I made along the way documented for your entertainment.

Choosing Your Poison: Three Ways to Auto‑Receive SMS

Before we start soldering, let’s be honest about the options:

ApproachHow it worksProsCons
A. Cloud Webhook (Twilio etc.) Buy a virtual number, set a webhook URL, receive SMS via POST. No hardware, scalable Costs per message, limited local numbers, may not receive Chinese OTP codes
B. Android phone + SMS Forwarder app Install an app on an old Android phone that forwards SMS to a URL. Cheap (if you have a spare phone) Phone must stay on and connected; battery bloat risk; app privacy concerns
C. SIM800L / 4G module + Python Use a bare GSM module controlled via AT commands over UART. Total control, one‑time cost (~$10), huge learning value Needs wiring, power supply care, and handling of raw AT commands

I chose Plan C. It’s the most educational and gives you a permanent, independent number that works as long as you have a prepaid SIM card. Let’s build it.

🛠️ Hardware Shopping List & Wiring

What you need

Wiring diagram (text description)

SIM800L USB-TTL Converter ───────── ───────────────── VCC (5V) → VCC (5V pin) GND (GND) → GND TXD → RXD (white/green) RXD → TXD (yellow/orange)

Important: The SIM800L is a 3.3V logic device, but its VCC can take 5V. The USB‑TTL converter must be set to 3.3V mode (look for a jumper) — if you use 5V logic, you might damage the module. I learned that the smelly way.

First power‑up & AT command test

Before writing a single line of Python, we verify the module works manually. On Linux/Mac:

# Install miniterm if needed
pip install pyserial

# Connect to serial port (find yours with ls /dev/tty*)
python -m serial.tools.miniterm /dev/ttyUSB0 115200

Once connected, type AT and hit Enter. You should see OK. Then try:

AT+CMGF=1   # set text mode (easier to parse)
AT+CNMI=2,2,0,0,0  # enable new message indication via UART

If you see garbage characters, your baud rate is probably wrong. The SIM800L defaults to auto‑baud, but many modules ship with 9600 or 115200. Try both. If the module resets when you send a command, your power supply is too weak — the voltage dips during transmission and the board browns out. Use a proper 2A adapter.

💻 Software Setup: Python Environment

We need only a couple of libraries. Create a virtual environment and install:

pip install flask pyserial

The code runs on any machine with a USB port — a Raspberry Pi, an old laptop, or even a desktop. I used a Raspberry Pi 4 for low power consumption.

🧠 The Architecture in One ASCII Graph

SIM Card & Tower → SIM800L → UART (TX/RX) → Python Serial Thread │ thread-safe queue.Queue() │ Flask API (GET /sms/latest) │ Your automation script

Incoming SMS → parsed in a background thread → pushed into a queue → Flask serves to clients.

📜 Core Code Walkthrough

We'll split the project into two files: serial_reader.py (background serial listener) and app.py (Flask API). Both share a global message_queue.

1. serial_reader.py – The Serial Listener Thread

import serial
import time
import re
import threading
import queue

# Global queue to pass messages to Flask
message_queue = queue.Queue(maxsize=100)

class SMSReader(threading.Thread):
    def __init__(self, port, baudrate=115200, timeout=1):
        super().__init__(daemon=True)
        self.ser = serial.Serial(port, baudrate, timeout=timeout)
        self.buffer = ""

    def send_at(self, cmd, wait=0.5):
        self.ser.write((cmd + '\r\n').encode())
        time.sleep(wait)

    def run(self):
        # Initialize module
        self.send_at('AT')
        self.send_at('AT+CMGF=1')      # text mode
        self.send_at('AT+CNMI=2,2,0,0,0')  # auto new message notification
        self.send_at('AT+CMGD=1,4')    # delete all stored SMS to avoid memory full
        while True:
            try:
                if self.ser.in_waiting:
                    chunk = self.ser.read(self.ser.in_waiting).decode('utf-8', errors='ignore')
                    self.buffer += chunk
                    # Process lines
                    while '\n' in self.buffer:
                        line, self.buffer = self.buffer.split('\n', 1)
                        line = line.strip()
                        if line.startswith('+CMT:') or line.startswith('+CMTI:'):
                            # New SMS indication
                            self.handle_new_sms()
            except Exception as e:
                print(f"Serial error: {e}")
                time.sleep(2)

    def handle_new_sms(self):
        # Read the actual message using AT+CMGR=
        # Since we set CNMI=2,2, the message lines follow +CMT header directly
        # In text mode, we get: +CMT: "sender",,"date/time" then next line is body
        self.send_at('AT+CMGR=1')  # read latest (or we can parse index from CMTI)
        time.sleep(0.8)
        # Read response
        if self.ser.in_waiting:
            raw = self.ser.read(self.ser.in_waiting).decode('utf-8', errors='ignore')
            # Extract message body between quotes and content after newline
            match = re.search(r'\+CMGR:.*?"(.*?)".*?\n(.*)', raw, re.DOTALL)
            if match:
                sender = match.group(1)
                body = match.group(2).strip()
                # Try to decode UCS-2 if it looks like hex
                if body and all(c in '0123456789ABCDEFabcdef ' for c in body):
                    try:
                        hex_str = body.replace(' ', '')
                        body = bytes.fromhex(hex_str).decode('utf-16-be')
                    except:
                        pass
                print(f"📩 SMS from {sender}: {body}")
                if not message_queue.full():
                    message_queue.put({'sender': sender, 'text': body, 'time': time.time()})
        # Delete message to keep SIM clean
        self.send_at('AT+CMGD=1')

def start_reader(port):
    reader = SMSReader(port)
    reader.start()
    return reader

2. app.py – The Flask API

from flask import Flask, jsonify, request, render_template_string
import time
from serial_reader import message_queue, start_reader

app = Flask(__name__)

# Simple HTML page for real‑time viewing
HTML_PAGE = '''
<!DOCTYPE html>
<html>
<head><title>SMS Inbox</title></head>
<body>
<h1>📨 Latest SMS</h1>
<pre id="msg">Loading...</pre>
<script>
async function fetchMsg() {
    const res = await fetch('/sms/latest');
    const data = await res.json();
    if (data.success) {
        document.getElementById('msg').textContent = JSON.stringify(data.message, null, 2);
    }
}
setInterval(fetchMsg, 2000);
fetchMsg();
</script>
</body>
</html>
'''

@app.route('/')
def index():
    return HTML_PAGE

@app.route('/sms/latest', methods=['GET'])
def latest_sms():
    msgs = []
    while not message_queue.empty():
        try:
            msgs.append(message_queue.get_nowait())
        except:
            break
    if not msgs:
        return jsonify({'success': False, 'message': None, 'hint': 'No new SMS'})
    latest = msgs[-1]
    # Try to extract verification code (4‑6 digits)
    code_match = re.search(r'\b(\d{4,6})\b', latest['text'])
    latest['code'] = code_match.group(1) if code_match else None
    return jsonify({'success': True, 'message': latest})

@app.route('/sms/clear', methods=['POST'])
def clear_sms():
    while not message_queue.empty():
        try:
            message_queue.get_nowait()
        except:
            break
    return jsonify({'status': 'cleared'})

if __name__ == '__main__':
    import argparse, re
    parser = argparse.ArgumentParser()
    parser.add_argument('--port', default='/dev/ttyUSB0', help='Serial port')
    args = parser.parse_args()
    start_reader(args.port)
    app.run(host='0.0.0.0', port=5000, debug=False)

3. The Verification Code Extractor

Inside the /sms/latest endpoint, we use a simple regex r'\b(\d{4,6})\b' to grab the first 4‑to‑6‑digit number. This works for most OTP messages but be careful: if the message contains other numbers like phone numbers, you might need to tune the pattern (e.g., look for “code”, “验证码”, or a numeric sequence preceded by specific keywords).

🖥️ Running the Whole Thing

Fire up the app:

python app.py --port /dev/ttyUSB0

Now call your SIM800L’s number from another phone, hang up, and then send an SMS to it. Within seconds you should see the message printed in the terminal, and hitting http://localhost:5000/sms/latest returns JSON with the extracted code.

💣 Four Rabbit Holes I Fell Into (So You Don’t Have To)

Pitfall #1: Power Starvation = Spontaneous Reboots

When the SIM800L transmits, it can draw up to 2A in short bursts. A typical Raspberry Pi USB port provides only 1.2A max. Result: the module kept resetting every time it tried to register on the network. The fix was an external 5V 2.5A power adapter with the power wires directly soldered to the module pins. Never power it from a computer USB port for production use.

Pitfall #2: Baud Rate Mismatch & Garbage

I spent an hour staring at ÿÿ¿½¿½ in my terminal. The SIM800L defaults to auto‑baud, but if you send AT at the wrong speed initially, it may lock onto a wrong rate. Always start with 115200; if that fails, try 9600. You can also send AT+IPR=115200 to permanently fix it.

Pitfall #3: Chinese SMS Turned into Hex Gibberish

When receiving a message like “您的验证码是123456”, the SIM800L in text mode sometimes delivers the body as a UCS‑2 hex string like 60A8 7684 9A8C 8BC1.... I had to detect hex and decode with bytes.fromhex(hex_str).decode('utf-16-be'). That snippet is already in the code above — you’re welcome.

Pitfall #4: SMS Flood & Queue Overflow

One time a service bombarded my test number with 20 verification codes in a minute. The serial thread couldn't keep up and the message buffer in the module filled up, causing older messages to be deleted. I added a maxsize=100 queue and a check for queue.full() to avoid blocking. For production, you’d want a persistent store like Redis.

🔧 Going Further: Ideas for Your New SMS API

A final word on ethics: This project is for educational and personal convenience. Using it to bypass service rate limits, commit fraud, or violate Terms of Service can land you in hot water. Be responsible.

🏁 Wrap‑Up

You now have a $10 hardware device that acts as your personal SMS gateway. You’ve wrestled with AT commands, serial buffering, and power circuits — and you have a working Flask API to show for it. The next time a verification code arrives, your script can catch it before you even reach for your phone. That’s the kind of automation that makes tinkering worth it.

Happy building, and may your power supply always be beefy enough.