Save 44% with yearly billing.View Plans
textbee Logotextbee.dev
Indie Hackers: Ship OTP and Notifications Without a Twilio Bill Eating Your MRR

Indie Hackers: Ship OTP and Notifications Without a Twilio Bill Eating Your MRR

Per-message SMS pricing punishes early-stage products. How an Android SMS gateway keeps OTP and alerts flat-cost while you look for product-market fit.

TT

textbee team

9 min read
Share

TL;DR

  • If your SMS volume is small and mostly transactional, run OTP and alerts through an Android SMS gateway on a flat plan instead of a per-message CPaaS. You keep your own number, and the bill stops tracking your signup count.
  • Twilio's US cost is a per-segment rate plus a carrier fee plus number rental plus 10DLC registration. Small volume pays a disproportionate share of the fixed part.
  • A working OTP flow is about 40 lines of Node.js or Python. Generate with a CSPRNG, store a hash with an expiry, never store the plaintext code.
  • Be honest about the ceiling: one phone and one SIM sends hundreds to a low few thousand messages a day, depending on the carrier. If you need global routing, carrier SLAs, or short codes, use a CPaaS.

You shipped sign-ups. Then password resets. Then failure alerts. Then an SMS nudge experiment. Every one of those maps cleanly onto a CPaaS API, and every one quietly takes a slice of the MRR you are still trying to grow.

This post is for indie hackers and solo developers: people who need reliable SMS without turning carrier billing into a second subscription they pay for.

Why does per-message pricing hurt early-stage products?

Per-message pricing is fine at scale and awkward at the start, because the fixed costs (number rental, registration) do not shrink with your volume. At 100 messages a month you pay more for the number than for the messages.

Here are the published US numbers, checked September 2026 on Twilio's US SMS pricing page: $0.0083 per outbound message segment on a long code, plus a per-segment carrier fee of $0.0035 to $0.005 depending on the destination carrier, plus $1.15 per month to lease the number. Call it $0.012 to $0.013 per delivered US segment. US A2P traffic from a rented number also requires 10DLC registration: $4 to $44 for the brand, $15 per campaign vetting and $1.50 to $10 a month per campaign on Twilio. The 10DLC guide itemizes those fees and explains why a SIM in your own phone is outside the program.

textbee's side is a flat plan: Free covers 300 messages a month, Pro is $14.99 a month for up to 5,000 ($8.33 a month billed yearly) and Scale is $29.99 for up to 25,000, on the pricing page and in the pricing docs (checked September 2026). The messages themselves come out of your own mobile plan.

Monthly OTPsTwilio US, messages plus numbertextbee plan
100about $2.33 to $2.48, plus 10DLC feesFree plan
500about $7.05 to $7.80, plus 10DLC feesPro, flat $14.99
2,000about $25 to $28, plus 10DLC feesPro, flat $14.99
10,000about $119 to $134, plus 10DLC feesScale, flat $29.99

At 100 OTPs a month you will not notice either bill. At 2,000, which is still small for a product charging $10 a seat, you are paying real money for the privilege of texting your own users, and the number keeps climbing with signups. The full Twilio cost breakdown has the fees that never appear on the headline rate, and textbee vs Twilio is the direct comparison.

Wanting predictable costs does not make you cheap. Your job is to stay alive long enough to find repeat value, not to fund a telecom middleman on every test user.

What changes with an Android gateway?

textbee turns a phone you probably already own into a programmable SMS gateway: a REST API, webhooks for inbound messages, and your real mobile number as the sender. The economics flip from "every send is a line item" to "I am bounded by my plan and my SIM."

It is the same mental model as self-hosting a database. Fixed overhead you control, marginal usage that does not automatically tax revenue, and an operational responsibility you now own.

A working OTP flow in Node.js

Generate the code with a CSPRNG, store only a keyed hash of it with an expiry, and compare in constant time. If your database leaks, the codes in it are useless.

JavaScript
import crypto from 'crypto';

const API_KEY = process.env.TEXTBEE_API_KEY;
const OTP_SECRET = process.env.OTP_SECRET; // 32+ random bytes, not in source
const TTL_MS = 10 * 60 * 1000;

// Swap this Map for Redis (with TTL) or a table with an expires_at column.
const otpStore = new Map();

const hashCode = (phone, code) =>
  crypto.createHmac('sha256', OTP_SECRET).update(`${phone}:${code}`).digest('hex');

async function sendOtp(phoneNumber) {
  const code = crypto.randomInt(0, 1_000_000).toString().padStart(6, '0');

  otpStore.set(phoneNumber, {
    hash: hashCode(phoneNumber, code),
    expiresAt: Date.now() + TTL_MS,
    attempts: 0,
  });

  const res = await fetch('https://api.textbee.dev/api/v1/gateway/send-sms', {
    method: 'POST',
    headers: { 'x-api-key': API_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({
      recipients: [phoneNumber],
      message: `Your verification code is ${code}. It expires in 10 minutes. Do not share it.`,
    }),
  });

  if (!res.ok) throw new Error(`SMS send failed: HTTP ${res.status}`);
  return res.json(); // { data: { success, smsBatchId, recipientCount, ... } }
}

function verifyOtp(phoneNumber, submittedCode) {
  const record = otpStore.get(phoneNumber);
  if (!record) return { valid: false, reason: 'no_code_sent' };

  if (Date.now() > record.expiresAt || record.attempts >= 5) {
    otpStore.delete(phoneNumber);
    return { valid: false, reason: 'expired' };
  }

  record.attempts += 1;

  const submitted = Buffer.from(hashCode(phoneNumber, submittedCode));
  const expected = Buffer.from(record.hash);
  if (!crypto.timingSafeEqual(submitted, expected)) {
    return { valid: false, reason: 'wrong_code' };
  }

  otpStore.delete(phoneNumber); // single use
  return { valid: true };
}

Production notes:

  • Replace the Map with Redis, where the TTL does the expiry for you, or a row with expires_at and a cleanup job.
  • Rate limit per phone number and per IP. Three OTP requests per 15 minutes is a reasonable ceiling and stops SMS bombing.
  • Log send attempts and failures, never the code or the hash.
  • Cap verification attempts, as above, so a six digit code cannot be brute forced inside its window.

The same flow in Python

Python
import hashlib
import hmac
import os
import secrets
import time

import requests

API_KEY = os.environ["TEXTBEE_API_KEY"]
OTP_SECRET = os.environ["OTP_SECRET"].encode()
TTL_S = 600

otp_store = {}  # replace with Redis or a database table


def hash_code(phone: str, code: str) -> str:
    return hmac.new(OTP_SECRET, f"{phone}:{code}".encode(), hashlib.sha256).hexdigest()


def send_otp(phone_number: str) -> dict:
    code = f"{secrets.randbelow(1_000_000):06d}"
    otp_store[phone_number] = {
        "hash": hash_code(phone_number, code),
        "expires_at": time.time() + TTL_S,
        "attempts": 0,
    }

    response = requests.post(
        "https://api.textbee.dev/api/v1/gateway/send-sms",
        headers={"x-api-key": API_KEY, "Content-Type": "application/json"},
        json={
            "recipients": [phone_number],
            "message": f"Your verification code is {code}. It expires in 10 minutes. Do not share it.",
        },
        timeout=10,
    )
    response.raise_for_status()
    return response.json()


def verify_otp(phone_number: str, submitted_code: str) -> dict:
    record = otp_store.get(phone_number)
    if not record:
        return {"valid": False, "reason": "no_code_sent"}

    if time.time() > record["expires_at"] or record["attempts"] >= 5:
        del otp_store[phone_number]
        return {"valid": False, "reason": "expired"}

    record["attempts"] += 1
    if not hmac.compare_digest(record["hash"], hash_code(phone_number, submitted_code)):
        return {"valid": False, "reason": "wrong_code"}

    del otp_store[phone_number]
    return {"valid": True}

Both versions post to the account-level send endpoint, so you do not have to look up or hard-code a device id. If you run more than one phone, add an optional deviceId to pin the send. For a step-by-step build with the dashboard included, see set up 2FA with textbee.

Where this fits your stack

  • Login OTP and passwordless auth for products not yet at "hire a compliance team" scale.
  • Password reset codes. Same pattern, different copy.
  • Transactional alerts: payment failures, failed cron jobs, deploy notifications, on-call pings.
  • Light customer messaging, where a normal mobile number reads better than a rented long code nobody recognizes.

You still own consent, content, and rate limits. You are choosing a phone and a SIM as the transport instead of a metered API.

When you should still use a CPaaS

Be honest with yourself. If any of these are true, Twilio or a peer is the right tool:

  • You need global reach with predictable per-country rates. An Android gateway sends through one carrier's rules.
  • You need a carrier SLA with credits when delivery fails.
  • Your volume is past what one or two phones sustain in a day.
  • You need fraud-hardened verification with SIM swap signals, like Twilio Verify or Vonage Verify.
  • You are in a regulated industry that requires auditable delivery logs from an enterprise vendor.

Frequently asked questions

Is an Android gateway production-grade for OTP?

For indie and early-stage products at low to mid volume, yes. It is the same HTTP plus SIM model commercial gateways use, running on hardware you control. The honest caveat is that uptime depends on your phone, your power, and your carrier rather than a cloud SLA. Keep a fallback path, such as email OTP, for the days that does not hold.

What happens if the phone is offline when a user requests a code?

The message waits server-side and dispatches when the device reconnects, which is usually seconds for a network blip. For a longer outage the user simply does not receive the code, so build the fallback into the UI: if no code arrives within about 60 seconds, offer a resend or an alternative channel. Do not silently retry forever.

Can I use this for password resets?

Yes, and the flow is identical. Generate a short-lived secret, store only its hash, send it, verify once, then delete it. Reset tokens are often a URL-safe string rather than six digits, which is fine because the send endpoint takes any message body. Keep the expiry short and make the token single use.

Does it work internationally?

It works wherever your SIM can send, which means your carrier's international SMS rates and policies apply, and roaming changes both cost and reliability. Sending to many countries from one SIM is usually a bad trade. For genuinely global OTP, a CPaaS routes through local carriers for you.

What about SIM swap attacks?

SIM swap is a real weakness of SMS as an authentication factor, and it applies equally to an Android gateway and to a CPaaS, because both deliver an SMS to whoever currently holds the number. For high-value accounts, prefer TOTP or a passkey and keep SMS as a backup factor. The OTP channel comparison covers the trade-offs in detail.

Ship the boring way

  1. Create an account and generate an API key.
  2. Download the Android app and link your phone.
  3. Follow the quickstart and send your first message from curl.
  4. Drop the OTP code above into your auth flow.

Keep building the product people pay for. Let the gateway be the boring part.

Get more textbee in Google Search

Add textbee.dev as a preferred source. Our posts show up more often in your Google results.

Add as a preferred source