我用20美元的安卓手机搭建了一个短信网关 – Jonno.nz
I Built an SMS Gateway with a $20 Android Phone – Jonno.nz

原始链接: https://jonno.nz/posts/built-an-sms-gateway-with-a-20-dollar-android-phone/

## 摆脱短信费用:自制网关 使用短信通知的应用,使用Twilio等服务很快就会产生费用(每条短信约0.05-0.06美元)。本文详细介绍了一种经济高效的替代方案:使用开源应用“SMS Gateway for Android”将廉价的Android手机变成一个完整的短信网关。 该应用提供两种模式:**本地服务器**(通过您的网络直接连接)和**云服务器**(通过SMS Gateway的中继)。两者都提供REST API用于发送短信和webhook用于接收短信。设置需要一个下午的时间,包括安装应用、配置权限,以及可能调整路由器设置(禁用AP隔离)或电池优化。 作者演示了与Next.js应用集成,使用提供者抽象,允许轻松切换SMS Gateway、Twilio,甚至控制台日志记录用于测试。这种方法可以将短信成本降低到您的手机套餐费用——对于无限短信套餐来说,可能为零——与传统提供商相比,节省超过80%。 文章包含详细的设置说明、代码示例以及生产部署的注意事项,例如专用硬件和监控。它是一个强大的解决方案,适用于MVP及更高阶段,提供控制和显著的成本节约。

## 20美元安卓手机作为短信网关 一位开发者(jonno-nz)在Hacker News上分享了一个项目,使用20美元的安卓手机构建短信网关。该设置允许发送短信,但评论者指出了潜在的缺点。 虽然提供了一种低成本的开发解决方案,但用户警告不要依赖“无限”短信套餐——运营商可能会因过度使用而暂停帐户。 也有人建议使用电子邮件到短信网关等替代方案。 讨论还涉及短信网关的可靠性问题,以及被标记为垃圾短信的风险,可能导致SIM/IMEI被列入黑名单。 讨论还涉及了考虑到谷歌不断变化的规则,侧载应用程序的未来可用性,以及从短信转向RCS的潜在好处。 一些人建议使用专用GSM模块(如SIM900)作为比使用完整安卓设备更高效的替代方案。
相关文章

原文

Twilio charges around $0.05–0.06 per SMS round-trip. Doesn't sound like much until you're building an MVP that sends reminders, confirmations, and notifications — suddenly you're looking at $50/month for a thousand messages. For an app that's not making money yet, that's a dumb tax.

Here's what I did instead: grabbed a cheap Android phone, installed an open-source app called SMS Gateway for Android, and turned it into a full SMS gateway with a REST API. My SMS costs dropped to whatever my mobile plan charges — which on plenty of prepaid plans is zero. Unlimited texts.

This post walks through exactly how to wire it into a Next.js app, from first install to receiving webhooks. The whole thing took an afternoon.


What You're Building

By the end of this you'll have:

  • An Android phone acting as your SMS gateway
  • A webhook endpoint receiving inbound SMS in real-time
  • Outbound SMS sent via a simple REST API call
  • A provider abstraction so you can swap between SMS Gateway, Twilio, or console logging

Prerequisites

  • An Android phone (5.0+) with a SIM card
  • A Next.js app (I'm using 15 with App Router, but any backend works)
  • Node.js 18+
  • ngrok for testing with cloud mode

Install SMS Gateway on Android

  1. Install SMS Gateway for Android from the Google Play Store or grab the APK from GitHub Releases

  2. Open the app and grant SMS permissions when prompted

  3. You'll see the main screen with toggles for Local Server and Cloud Server:

SMS Gateway main screen

The app supports two modes — local and cloud. Both work well, and I'll cover each.


Local Server Mode

Local mode runs an HTTP server directly on the phone. Your backend talks to it over your local network. No cloud dependency, no third-party servers — the simplest setup.

Configure It

Local server settings Local server configuration

  1. Toggle "Local Server" on
  2. Go to Settings > Local Server to configure:
    • Port: 1024–65535 (default 8080)
    • Username: minimum 3 characters
    • Password: minimum 8 characters
  3. Tap "Offline" — it changes to "Online"
  4. Note the local IP address displayed (e.g. 192.168.1.50)

Your phone is now running an HTTP server. Verify it:


curl http://192.168.1.50:8080/health


open http://192.168.1.50:8080/docs

Send Your First SMS

curl -X POST http://192.168.1.50:8080/message \
  -u "admin:yourpassword" \
  -H "Content-Type: application/json" \
  -d '{
    "textMessage": { "text": "Hello from my SMS gateway!" },
    "phoneNumbers": ["+15551234567"]
  }'

That's it. The phone sends the SMS from its own number, using your mobile plan's rates.

Register a Webhook for Inbound SMS

To receive SMS messages as webhooks:

curl -X POST http://192.168.1.50:8080/webhooks \
  -u "admin:yourpassword" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "my-webhook",
    "url": "http://192.168.1.100:4000/api/sms/webhook",
    "event": "sms:received"
  }'

Replace 192.168.1.100 with your dev machine's local IP. Both devices need to be on the same WiFi network.

Local Mode Gotchas

  • AP isolation: Many routers — especially mesh networks and office WiFi — block device-to-device traffic. If you can't reach the phone, check your router settings for "AP isolation" or "client isolation" and disable it. This one caught me out for a good 20 minutes.
  • Battery optimisation: Android will kill the background server to save battery. Disable battery optimisation for SMS Gateway in your phone settings. dontkillmyapp.com has device-specific instructions — genuinely useful site.
  • Keep it plugged in: During development and in production, the phone lives on a charger. It's not going anywhere.

Cloud Server Mode

Cloud mode is easier to set up and works from anywhere — no local network required. The phone connects to SMS Gateway's cloud relay (api.sms-gate.app), and your backend talks to the same cloud API.

Cloud server settings Cloud server configuration

Enable It

  1. Toggle "Cloud Server" on in the app
  2. Tap "Offline" — it connects and registers automatically
  3. A username and password are auto-generated (visible in the Cloud Server section)
  4. Note these credentials — you'll need them for API calls

The cloud uses a hybrid push architecture: Firebase Cloud Messaging as the primary channel, Server-Sent Events as fallback, and 15-minute polling as a last resort. It's well thought through.

Send an SMS via Cloud API

curl -X POST https://api.sms-gate.app/3rdparty/v1/messages \
  -u "YOUR_USERNAME:YOUR_PASSWORD" \
  -H "Content-Type: application/json" \
  -d '{
    "textMessage": { "text": "Hello from the cloud!" },
    "phoneNumbers": ["+15551234567"]
  }'

Register a Webhook (Cloud Mode)

Your webhook URL must be HTTPS in cloud mode. For local development, use ngrok:


ngrok http 4000



curl -X POST https://api.sms-gate.app/3rdparty/v1/webhooks \
  -u "YOUR_USERNAME:YOUR_PASSWORD" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://abc123.ngrok.app/api/sms/webhook",
    "event": "sms:received"
  }'

Manage Webhooks


curl -u "YOUR_USERNAME:YOUR_PASSWORD" \
  https://api.sms-gate.app/3rdparty/v1/webhooks


curl -X DELETE -u "YOUR_USERNAME:YOUR_PASSWORD" \
  https://api.sms-gate.app/3rdparty/v1/webhooks/WEBHOOK_ID

The Code — Next.js Integration

Here's how I integrated SMS Gateway into a Next.js app with a clean provider abstraction. The idea is simple — swap providers without touching business logic.

Provider Interface



export interface InboundSms {
  from: string;
  body: string;
  receivedAt?: Date;
}

export interface SmsProvider {
  send(to: string, body: string): Promise<string>;
  parseWebhook(req: Request): Promise<InboundSms | null>;
  webhookResponse(replyText?: string): Response;
}

export async function getSmsProvider(): Promise<SmsProvider> {
  const provider = process.env.SMS_PROVIDER || "sms-gate";

  switch (provider) {
    case "sms-gate": {
      const { SmsGateProvider } = await import("./sms-gate");
      return new SmsGateProvider();
    }
    case "console": {
      const { ConsoleProvider } = await import("./console");
      return new ConsoleProvider();
    }
    default:
      throw new Error(`Unknown SMS provider: ${provider}`);
  }
}

SMS Gate Provider

The provider handles both local and cloud API differences:



import type { SmsProvider, InboundSms } from "./provider";

const SMSGATE_URL = process.env.SMSGATE_URL || "http://localhost:8080";
const SMSGATE_USER = process.env.SMSGATE_USER || "";
const SMSGATE_PASSWORD = process.env.SMSGATE_PASSWORD || "";

export class SmsGateProvider implements SmsProvider {
  private headers(): Record<string, string> {
    const auth = Buffer.from(
      `${SMSGATE_USER}:${SMSGATE_PASSWORD}`
    ).toString("base64");
    return {
      "Content-Type": "application/json",
      Authorization: `Basic ${auth}`,
    };
  }

  async send(to: string, body: string): Promise<string> {
    const isCloud = SMSGATE_URL.includes("api.sms-gate.app");
    const endpoint = isCloud
      ? `${SMSGATE_URL}/3rdparty/v1/messages`
      : `${SMSGATE_URL}/api/3rdparty/v1/message`;
    const payload = isCloud
      ? { textMessage: { text: body }, phoneNumbers: [to] }
      : { phoneNumbers: [to], message: body };

    const res = await fetch(endpoint, {
      method: "POST",
      headers: this.headers(),
      body: JSON.stringify(payload),
    });

    if (!res.ok) {
      const err = await res.text();
      throw new Error(`SMS Gate send failed: ${res.status} ${err}`);
    }

    const data = await res.json();
    return data.id || "sent";
  }

  async parseWebhook(req: Request): Promise<InboundSms | null> {
    try {
      const body = await req.json();

      if (body.event !== "sms:received" || !body.payload) {
        return null;
      }

      const { phoneNumber, message, receivedAt } = body.payload;
      if (!phoneNumber || !message) return null;

      return {
        from: phoneNumber,
        body: message,
        receivedAt: receivedAt ? new Date(receivedAt) : new Date(),
      };
    } catch {
      return null;
    }
  }

  webhookResponse(): Response {
    return new Response(JSON.stringify({ ok: true }), {
      headers: { "Content-Type": "application/json" },
    });
  }
}

Webhook Route

A basic webhook handler that receives inbound SMS and replies:



import { NextRequest } from "next/server";
import { getSmsProvider } from "@/lib/sms/provider";

export async function POST(req: NextRequest) {
  const provider = await getSmsProvider();
  const sms = await provider.parseWebhook(req);

  if (!sms) {
    return new Response("Bad request", { status: 400 });
  }

  const { from, body } = sms;

  
  const user = await findUserByPhone(from);

  if (!user) {
    await provider.send(from, "Hey! Text us back once you've signed up.");
    return provider.webhookResponse();
  }

  
  console.log(`[SMS from ${from}]: ${body}`);
  await provider.send(from, "Got it — we're on it!");
  return provider.webhookResponse();
}

Console Provider (for Testing)

For local development without a phone:



import type { SmsProvider, InboundSms } from "./provider";

export class ConsoleProvider implements SmsProvider {
  async send(to: string, body: string): Promise<string> {
    console.log(`[SMS -> ${to}] ${body}`);
    return `console-${Date.now()}`;
  }

  async parseWebhook(req: Request): Promise<InboundSms | null> {
    const data = await req.json();
    return {
      from: data.from || "+15550000000",
      body: data.body || "",
      receivedAt: new Date(),
    };
  }

  webhookResponse(): Response {
    return new Response(JSON.stringify({ ok: true }), {
      headers: { "Content-Type": "application/json" },
    });
  }
}

Environment Variables




SMS_PROVIDER=sms-gate


SMSGATE_URL=http://192.168.1.50:8080
SMSGATE_USER=admin
SMSGATE_PASSWORD=yourpassword






Webhook Payload Reference

When someone texts your Android phone, SMS Gateway sends a POST to your webhook URL:

{
  "id": "Ey6ECgOkVVFjz3CL48B8C",
  "webhookId": "LreFUt-Z3sSq0JufY9uWB",
  "deviceId": "your-device-id",
  "event": "sms:received",
  "payload": {
    "messageId": "abc123",
    "message": "Hello!",
    "sender": "+15551234567",
    "recipient": "+15559876543",
    "simNumber": 1,
    "receivedAt": "2026-04-01T12:41:59.000+00:00"
  }
}

Available Events

Event Description
sms:received Inbound SMS received
sms:sent Outbound SMS sent
sms:delivered Outbound SMS confirmed delivered
sms:failed Outbound SMS failed
system:ping Heartbeat — device still alive

Webhook Security

SMS Gateway signs webhook payloads with HMAC-SHA256. Two headers are included:

  • X-Signature — hex-encoded HMAC-SHA256 signature
  • X-Timestamp — Unix timestamp used in signing
import crypto from "crypto";

function verifyWebhook(
  signingKey: string,
  payload: string,
  timestamp: string,
  signature: string
): boolean {
  const expected = crypto
    .createHmac("sha256", signingKey)
    .update(payload + timestamp)
    .digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(expected, "hex"),
    Buffer.from(signature, "hex")
  );
}

Retry Behaviour

If your server doesn't respond 2xx within 30 seconds, SMS Gateway retries with exponential backoff — starting at 10 seconds, doubling each time, up to 14 attempts (~2 days). Solid default behaviour, you don't need to configure anything.


Testing the Full Flow

1. Start Your Dev Server

npm run dev

2. Expose It (Cloud Mode)

ngrok http 4000

3. Register the Webhook

curl -X POST https://api.sms-gate.app/3rdparty/v1/webhooks \
  -u "USERNAME:PASSWORD" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://abc123.ngrok.app/api/sms/webhook",
    "event": "sms:received"
  }'

4. Send a Text

Text your Android phone from another phone. You should see:

  1. SMS Gateway receives the text
  2. Webhook fires to your ngrok URL
  3. Your Next.js server processes it
  4. A reply SMS is sent back via the API
  5. The sender's phone receives the reply

That moment when the reply lands on your phone — genuinely satisfying.

Test Without a Phone


SMS_PROVIDER=console npm run dev

curl -X POST http://localhost:4000/api/sms/webhook \
  -H "Content-Type: application/json" \
  -d '{"from": "+15551234567", "body": "Hello"}'

Production Considerations

The Phone Setup

  • Dedicated device: Use a cheap Android phone ($100–200) with a prepaid SIM. It sits on a charger plugged into power and WiFi. That's its whole life now.
  • Battery optimisation off: Disable battery optimisation for SMS Gateway or Android will kill it. dontkillmyapp.com for your specific device.
  • Auto-start: Enable "start on boot" in the SMS Gateway app settings.
  • Monitoring: Register a system:ping webhook to alert if the device goes offline.

Local vs Cloud

Local Cloud
Latency Lower (direct) Slightly higher (relay)
Network Same network required Works from anywhere
Privacy Messages never leave your network Messages transit through SMS Gateway's servers
Reliability Depends on your network Adds FCM/SSE redundancy
Cost Free Free (community tier)

I use cloud mode in production because my server's hosted on Railway and can't reach the phone's local network. For development on the same WiFi, local mode is simpler and faster.

Cost Comparison

Provider SMS Cost Monthly (1,000 msgs)
Twilio ~$0.05/msg ~$50
SMS Gateway + Prepaid SIM $0/msg (unlimited plan) ~$8 (plan cost)

That's an 80%+ saving, and it scales linearly — 10,000 messages a month is still just your plan cost.


It's worth knowing this is a whole category now. httpSMS and textbee do similar things. I went with SMS Gateway for Android because the local mode is properly useful for development, the documentation is solid, and it's actively maintained — v1.56.0 dropped in March 2026.

For an MVP, the maths is obvious. A $200 phone and an $8/month plan gets you a programmable SMS gateway that you fully control. No per-message fees, no carrier contracts, no vendor lock-in. If you outgrow it, swap the provider interface to Twilio and you're done — that's why the abstraction exists.

Links:

联系我们 contact @ memedata.com