SMS Web API — Developer Documentation

Turn your Android phone into an HTTP-callable SMS gateway. Your servers and websites send a request, this phone sends the SMS — using your real SIM card and number.

This document is the complete reference. For the in-app version, open the Code Samples screen on the home screen.


Table of contents


What this is

SMS Web API is a personal SMS gateway. You install the app on an Android phone with a working SIM, and that phone exposes two ways for your servers to send SMS:

**Cloud-triggered (recommended).** Your server calls a Firebase Cloud Function (`sendSms` or `requestSmsReply`). The function writes a command to Firestore. The app sees the command, sends the SMS via the phone's modem, and reports back.

**HTTP polling (advanced / legacy).** The phone polls a URL on your server every N seconds, pulls a queue of pending messages, and sends them.

Both paths share the same downstream pipeline: SMS dispatch, optional dual-SIM routing, optional reply capture, optional webhook delivery report.


Architecture


        ┌─────────────────────────┐
        │  Your website / server  │
        └────────────┬────────────┘
                     │ HTTPS POST
                     ▼
       ┌────────────────────────────┐
       │  Cloud Function: sendSms   │
       │  Cloud Function: req…Reply │
       └────────────┬───────────────┘
                    │ writes
                    ▼
       ┌────────────────────────────┐
       │  Firestore                 │
       │  devices/{id}/outbox/{cmd} │
       └────────────┬───────────────┘
                    │ snapshot listener
                    ▼
       ┌────────────────────────────┐       ┌──────────────────┐
       │  Android app (this phone)  │ SMS   │  Mobile network  │
       │  + foreground service      ├──────►│  → recipient     │
       └────────────┬───────────────┘       └──────────────────┘
                    │ webhook (optional)
                    ▼
       ┌────────────────────────────┐
       │  Your webhook endpoint     │
       │  (delivery report / reply) │
       └────────────────────────────┘

For HTTP Polling mode, replace the Cloud Function leg with: phone polls GET your-server.com/queue → parses JSON → sends.


Quickstart

5 minutes from install to first SMS.

**Install the app** on an Android phone with a SIM card.

**Run the setup wizard** that opens on first launch. Pick "Send Only" (you can change later).

**Grant SMS, Phone, and Notification permissions** when prompted.

**Copy the Device ID** from the wizard's last screen (it looks like `9f621518-941b-44fd-a6c7-f02fb1f1b770`).

**Tap "Start Gateway"**. The status pill should turn green ("Active").

**Send your first SMS** from your laptop:

```bash

curl -X POST \

https://us-central1-sms-web-api-aticmatic.cloudfunctions.net/sendSms \

-H "Content-Type: application/json" \

-d '{

"deviceId": "PASTE_YOUR_DEVICE_ID_HERE",

"phone": "+15551234567",

"message": "Hello from my server"

}'

```

The phone should buzz within a second or two and the SMS should leave the device.

You're done. Read the rest of this doc when you need replies, dual-SIM, webhooks, retries, or anything fancier.


Concepts

Modes

The app supports three operating modes. You pick one in the setup wizard; you can switch any time from Settings → Setup.

ModeWhat happensWhen to pick it
**Send Only**Cloud Functions queue commands; phone sends SMS. No reply capture.Most personal use cases: OTPs you generate yourself, transactional alerts, marketing-style notifications.
**Send + Receive**Same as Send Only, plus the phone watches for the matching incoming SMS and POSTs it back to your `callbackUrl`.Shortcode flows: balance checks, third-party OTP verification, command/response.
**HTTP Polling**Phone polls *your* HTTP endpoint on an interval and processes whatever it finds. No Firebase.Legacy setups where you already have a queue table. Or environments where you'd rather avoid Firebase.

Device ID

Each installation generates a UUIDv4 on first launch and stores it on-device. This is the routing key that tells the Cloud Function which phone to deliver the command to. Treat it as semi-secret — if someone has your Device ID and can hit your Cloud Functions, they can use your SIM to send SMS.

Background service

The app runs a foreground service so it can keep listening for Firestore commands and incoming SMS even when the screen is off. Android shows a persistent notification while the service runs — that's intentional.

Two webhook destinations

There are two places a "reply" can be POSTed:

**Per-request `callbackUrl`** — passed in the body of `requestSmsReply`. Used when each request needs a different destination.

**Globally configured webhook** — set under Settings → Webhook. Used for delivery receipts (sent / failed events) and as the fallback for replies if no `callbackUrl` was provided.

The two are independent. You can use either, both, or neither.


Endpoint reference

The Cloud Functions base URL for this project is:


https://us-central1-sms-web-api-aticmatic.cloudfunctions.net

Replace sms-web-api-aticmatic with your own Firebase project ID if you fork.

`POST /sendSms`

Queue a one-way SMS. Optionally attach a callback to also capture the first matching reply.

Request


POST /sendSms HTTP/1.1
Host: us-central1-sms-web-api-aticmatic.cloudfunctions.net
Content-Type: application/json

{
  "deviceId": "9f621518-941b-44fd-a6c7-f02fb1f1b770",
  "phone": "+15551234567",
  "message": "Your code is 4821",
  "simSlot": 0,
  "waitForReply": false,
  "replyWebhookUrl": "https://your-server.example/sms/reply",
  "replyTimeoutSeconds": 120
}
FieldTypeRequiredNotes
`deviceId`stringUUID from the app.
`phone`stringE.164 (`+15551234567`) or local format. The phone modem decides what's valid.
`message`stringUTF-8. Multipart auto-handled by the modem.
`simSlot`int`0` or `1`. Defaults to `0` (SIM 1). Override per Settings is also available.
`waitForReply`boolIf `true`, app waits for an incoming SMS from `phone` and POSTs it to `replyWebhookUrl` (or the configured webhook).
`replyWebhookUrl`stringAliases: `callbackUrl`. Implies `waitForReply: true`.
`replyTimeoutSeconds`intAliases: `timeoutSeconds`. Default `120`. Clamped to `[10, 3600]`.

Response (200)


{
  "success": true,
  "id": "lXm0yrJ8K2d4Ae9wJq3z",
  "status": "queued",
  "info": "Command sent to device outbox"
}

id is the Firestore document ID. It's also the same ID that will appear in any subsequent webhook payload (payload.id), so use it to correlate.

Errors

HTTPWhen
`400`Missing `deviceId`, `phone`, or `message`. Or `replyWebhookUrl` is not http(s).
`405`Method other than POST or OPTIONS.
`500`Firestore write failed.

`POST /requestSmsReply`

Send an SMS and require a reply. The phone sends the SMS, then watches incoming SMS from the same number for up to replyTimeoutSeconds. The first match is POSTed to callbackUrl. If nothing arrives in time, a reply_timeout event is POSTed instead.

Request


POST /requestSmsReply HTTP/1.1
Host: us-central1-sms-web-api-aticmatic.cloudfunctions.net
Content-Type: application/json

{
  "deviceId": "9f621518-941b-44fd-a6c7-f02fb1f1b770",
  "phone": "8500",
  "message": "BAL",
  "callbackUrl": "https://your-server.example/sms/reply",
  "simSlot": 0,
  "replyTimeoutSeconds": 120
}
FieldTypeRequiredNotes
`deviceId`stringUUID from the app.
`phone`stringOften a shortcode like `8500`.
`message`stringThe command text the shortcode expects.
`callbackUrl`stringMust be http(s). The reply is POSTed here.
`simSlot`int`0` or `1`.
`replyTimeoutSeconds`intDefault `120`. Clamped `[10, 3600]`.

Response (202)


{
  "success": true,
  "id": "lXm0yrJ8K2d4Ae9wJq3z",
  "status": "queued",
  "reply": "pending",
  "info": "SMS queued. The first matching reply will be POSTed to callbackUrl."
}

Errors

Same shape as sendSms. Additionally 400 if callbackUrl is missing.

Reply matching rules

When the phone receives an incoming SMS, it tries to match it against the oldest still-pending reply request whose phone equals the sender. Matching is exact-string first, then digits-only (so +1 555-1234567 matches 15551234567). The matched request is consumed.

If you fire two requestSmsReply calls to the same shortcode quickly, the first reply matches the first request (FIFO) — even if the actual reply was meant for the second one. There's no way around this without correlation IDs in the SMS body itself.

HTTP Polling (advanced / legacy)

Instead of using Cloud Functions, you can have the phone pull from your server. Configure under Settings → HTTP Polling.

The phone makes


GET https://your-server.example/api/get-sms HTTP/1.1
Authorization: Bearer eyJhbGciOi…   ← optional, configured in Settings
Accept: application/json

Your server returns


{
  "messages": [
    { "id": "1", "phone": "+15551234567", "message": "Hello", "sim_slot": 0 },
    { "id": "2", "phone": "+15559876543", "message": "World" }
  ]
}

The list key (messages), and each item key (id, phone, message, sim_slot) are configurable in Settings → HTTP Polling → JSON path mapping. Use dot notation for nested keys (e.g. data.messages). The parser also auto-falls back to common shapes (data or messages at the root) if your configured path returns nothing.

Your server is responsible for not returning the same id twice — the phone has no idempotency cache. Mark items as "sent" the moment you serve them.

You can also include reply-capture fields per-message: wait_for_reply: true, reply_webhook_url, reply_timeout_seconds.


Webhooks

The app can POST delivery reports back to a webhook URL you configure in Settings → Webhook. Webhooks are independent of Cloud Functions — they fire for every SMS the phone sends, regardless of whether it came from sendSms, requestSmsReply, or HTTP polling.

Events

EventFires when
`sent`The phone successfully handed off to the modem. (Note: the modem's queue is its own thing — `sent` does not guarantee carrier delivery.)
`failed`The phone couldn't dispatch the SMS. `error` field has details.
`reply`An expected reply arrived. Only fires for messages that had `waitForReply: true`.
`reply_timeout`An expected reply did not arrive in time.

You can toggle sent and failed independently in Settings → Webhook → Event Triggers. reply and reply_timeout always fire when the originating request opted in.

Payload schemas

All payloads share these always-present fields:


{
  "id": "lXm0yrJ8K2d4Ae9wJq3z",
  "status": "sent",
  "timestamp": "2026-05-10T14:32:01.000Z"
}

Additional fields depend on event type and your "Include in payload" toggles in Settings.

sent — optional fields gated on Settings:


{
  "id": "lXm0yrJ8K2d4Ae9wJq3z",
  "status": "sent",
  "timestamp": "2026-05-10T14:32:01.000Z",
  "phone": "+15551234567",
  "message": "Your code is 4821",
  "sim_slot": 0
}

failed:


{
  "id": "lXm0yrJ8K2d4Ae9wJq3z",
  "status": "failed",
  "timestamp": "2026-05-10T14:32:01.000Z",
  "phone": "+15551234567",
  "error": "Failed to send SMS: SmsManager null"
}

reply:


{
  "id": "lXm0yrJ8K2d4Ae9wJq3z",
  "status": "reply",
  "timestamp": "2026-05-10T14:32:08.500Z",
  "phone": "8500",
  "reply": "Your balance is $12.50",
  "reply_from": "8500",
  "received_at": "2026-05-10T14:32:08.421Z"
}

reply_timeout:


{
  "id": "lXm0yrJ8K2d4Ae9wJq3z",
  "status": "reply_timeout",
  "timestamp": "2026-05-10T14:34:01.000Z",
  "phone": "8500",
  "timeout_seconds": 120
}

Signature verification (HMAC)

If you set a webhook secret in Settings → Webhook → Security, every request includes:


X-Webhook-Signature: sha256=<hex digest>

Where the digest is HMAC-SHA256(secret, raw_request_body). Compute the same on your end and compare in constant time. See Verify a webhook signature for sample implementations.

Always verify. Without verification, anyone who knows your webhook URL can forge "delivery reports" and confuse your accounting.

Retry semantics

There is no exponential backoff and no dead-letter queue — if all retries fail, the event is dropped (a row remains in the local SQLite history with webhookStatus set to the last seen code).

Custom headers

Add arbitrary header key/value pairs in Settings → Webhook → Custom Headers. They're appended to every webhook request. Useful for upstream proxies that require X-Tenant-Id, X-Source, etc.


Authentication

Two independent auth tracks: one for the polling URL the phone calls, one for the webhook URL the phone posts to.

Polling auth (Settings → HTTP Polling → Authentication)

Used on the outbound GET to your queue endpoint.

TypeHeader sent
`None`
`Bearer Token``Authorization: Bearer `
`Basic Auth``Authorization: Basic base64(user:pass)`
`API Key (Header)``: `

Webhook auth (Settings → Webhook → Authentication)

Used on the outbound POST/PUT from the phone to your webhook URL.

Same four types as above, configured separately. You can also stack:

—all on the same request.


Advanced options

These all live under Settings.

Send behaviour

OptionEffect
**Override SIM slot**Force every outgoing SMS to use SIM 1 or SIM 2, ignoring the per-request `simSlot`. Useful if one SIM has a better SMS plan.
**Cool down**Seconds to wait between consecutive sends. `0–30s`. Use this to avoid carrier spam-block heuristics.
**Footer message**Appended to every outgoing message body. Carrier compliance friendly.
**Anti-spam mode**Appends a short unique `[ID:xxxxx]` to each message body, so identical content gets unique-ish bodies and bypasses some carrier dedup.
**Connection error sound**Plays a sound on the phone when the gateway loses connectivity.

Reply capture

OptionEffect
`replyTimeoutSeconds`How long to wait for a matching incoming SMS. 10–3600 seconds.
Phone matchingExact match first; falls back to digits-only comparison so `+1 (555) 123-4567` matches `15551234567`.
FIFO conflictMultiple pending requests for the same number → first reply matches first request.

HTTP Polling

OptionEffect
**Poll every**Interval in seconds. `5–60s`.
**API URL**Your endpoint that returns the queue.
**JSON path mapping**Dot-notation paths for the list, and for each item's `id`, `phone`, `message`, `sim_slot` fields.

Webhook

OptionEffect
**HTTP method**POST or PUT.
**Include in payload**Toggle whether `phone`, `message`, `sim_slot` are sent. (Useful for privacy — don't leak the message body if you only need a delivery receipt.)
**Event triggers**Enable/disable `sent` and `failed` events independently. (`reply` / `reply_timeout` always fire when the request opted in.)
**Retry policy**Max retries `0–10`, retry delay `1–30s`. Linear backoff.
**Webhook secret**HMAC-SHA256 signing key.

Error handling

From the Cloud Function

HTTPCauseAction
`400`Bad input (missing field, malformed URL).Fix the request body.
`405`Wrong method.Use POST.
`500`Firestore write failed.Retry with backoff.

From the phone (during dispatch)

These never reach your caller synchronously — they show up in the webhook as event: failed:

`error` substringMeaning
`SmsManager null`The phone couldn't acquire the modem. Often happens if SMS permission was revoked.
`Generic failure`Modem error, retry rarely helps.
`No service`No cellular signal.
`Radio off`Airplane mode is on.

From your webhook receiver

If you respond non-2xx, the phone retries (per Retry policy). If you respond 2xx but slowly (>15s), the phone may consider the request hung and the next retry might fire. Aim to respond fast and queue the actual work.


Code examples

The base URL is https://us-central1-sms-web-api-aticmatic.cloudfunctions.net.

Send an SMS

curl


curl -X POST \
  https://us-central1-sms-web-api-aticmatic.cloudfunctions.net/sendSms \
  -H "Content-Type: application/json" \
  -d '{
    "deviceId": "9f621518-941b-44fd-a6c7-f02fb1f1b770",
    "phone": "+15551234567",
    "message": "Hello from my server"
  }'

PHP


<?php
$ch = curl_init('https://us-central1-sms-web-api-aticmatic.cloudfunctions.net/sendSms');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
    'deviceId' => '9f621518-941b-44fd-a6c7-f02fb1f1b770',
    'phone'    => '+15551234567',
    'message'  => 'Hello from my server',
]));
$response = curl_exec($ch);
$status   = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($status !== 200) {
    throw new Exception("sendSms failed: HTTP $status — $response");
}
$data = json_decode($response, true);
echo "Queued as " . $data['id'];

Node.js (fetch)


async function sendSms({ phone, message }) {
  const res = await fetch(
    'https://us-central1-sms-web-api-aticmatic.cloudfunctions.net/sendSms',
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        deviceId: '9f621518-941b-44fd-a6c7-f02fb1f1b770',
        phone,
        message,
      }),
    },
  );
  if (!res.ok) {
    throw new Error(`sendSms failed: ${res.status} ${await res.text()}`);
  }
  return res.json();
}

await sendSms({ phone: '+15551234567', message: 'Hello' });

Python (requests)


import requests

def send_sms(phone: str, message: str) -> dict:
    r = requests.post(
        "https://us-central1-sms-web-api-aticmatic.cloudfunctions.net/sendSms",
        json={
            "deviceId": "9f621518-941b-44fd-a6c7-f02fb1f1b770",
            "phone": phone,
            "message": message,
        },
        timeout=15,
    )
    r.raise_for_status()
    return r.json()

print(send_sms("+15551234567", "Hello"))

Send an SMS and capture the reply

curl


curl -X POST \
  https://us-central1-sms-web-api-aticmatic.cloudfunctions.net/requestSmsReply \
  -H "Content-Type: application/json" \
  -d '{
    "deviceId": "9f621518-941b-44fd-a6c7-f02fb1f1b770",
    "phone": "8500",
    "message": "BAL",
    "callbackUrl": "https://your-server.example/sms/reply",
    "replyTimeoutSeconds": 120
  }'

PHP


<?php
$ch = curl_init('https://us-central1-sms-web-api-aticmatic.cloudfunctions.net/requestSmsReply');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
    'deviceId'             => '9f621518-941b-44fd-a6c7-f02fb1f1b770',
    'phone'                => '8500',
    'message'              => 'BAL',
    'callbackUrl'          => 'https://your-server.example/sms/reply',
    'replyTimeoutSeconds'  => 120,
]));
$response = curl_exec($ch);
$status   = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

if ($status !== 202) {
    throw new Exception("requestSmsReply failed: HTTP $status");
}
echo json_decode($response, true)['id'];

Node.js (fetch)


async function requestSmsReply({ phone, message, callbackUrl, timeoutSeconds = 120 }) {
  const res = await fetch(
    'https://us-central1-sms-web-api-aticmatic.cloudfunctions.net/requestSmsReply',
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        deviceId: '9f621518-941b-44fd-a6c7-f02fb1f1b770',
        phone,
        message,
        callbackUrl,
        replyTimeoutSeconds: timeoutSeconds,
      }),
    },
  );
  if (res.status !== 202) {
    throw new Error(`requestSmsReply failed: ${res.status} ${await res.text()}`);
  }
  return res.json();
}

Python (requests)


import requests

def request_sms_reply(phone, message, callback_url, timeout_seconds=120):
    r = requests.post(
        "https://us-central1-sms-web-api-aticmatic.cloudfunctions.net/requestSmsReply",
        json={
            "deviceId": "9f621518-941b-44fd-a6c7-f02fb1f1b770",
            "phone": phone,
            "message": message,
            "callbackUrl": callback_url,
            "replyTimeoutSeconds": timeout_seconds,
        },
        timeout=15,
    )
    if r.status_code != 202:
        raise RuntimeError(f"requestSmsReply failed: {r.status_code} {r.text}")
    return r.json()

Verify a webhook signature

The phone sends X-Webhook-Signature: sha256= where is HMAC-SHA256(secret, raw_request_body).

PHP


<?php
function verify_signature(string $rawBody, string $headerValue, string $secret): bool {
    if (!str_starts_with($headerValue, 'sha256=')) return false;
    $expected = substr($headerValue, 7);
    $actual   = hash_hmac('sha256', $rawBody, $secret);
    return hash_equals($expected, $actual);
}

$rawBody = file_get_contents('php://input');
$header  = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
if (!verify_signature($rawBody, $header, getenv('SMS_WEBHOOK_SECRET'))) {
    http_response_code(401);
    exit('Bad signature');
}

$payload = json_decode($rawBody, true);
// ...handle $payload
http_response_code(200);
echo 'ok';

Node.js


const crypto = require('crypto');

function verifySignature(rawBody, headerValue, secret) {
  if (!headerValue?.startsWith('sha256=')) return false;
  const expected = headerValue.slice(7);
  const actual = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
  // Constant-time compare. Throws if lengths differ.
  if (expected.length !== actual.length) return false;
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(actual));
}

// Express example — make sure to use express.raw() or capture rawBody
app.post('/sms/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  const ok = verifySignature(
    req.body, // Buffer of raw bytes
    req.header('X-Webhook-Signature'),
    process.env.SMS_WEBHOOK_SECRET,
  );
  if (!ok) return res.status(401).send('Bad signature');

  const payload = JSON.parse(req.body.toString('utf8'));
  // ...handle payload
  res.status(200).send('ok');
});

Python


import hmac, hashlib
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = "your-secret-here".encode()

def verify_signature(raw_body: bytes, header_value: str) -> bool:
    if not header_value or not header_value.startswith("sha256="):
        return False
    expected = header_value[7:]
    actual = hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, actual)

@app.post("/sms/webhook")
def webhook():
    raw = request.get_data()
    if not verify_signature(raw, request.headers.get("X-Webhook-Signature", "")):
        abort(401)
    payload = request.get_json()
    # ...handle payload
    return "ok", 200

Webhook receivers

A complete-ish receiver in three languages. Each one switches on status to handle the four event types.

PHP (no framework)


<?php
$rawBody = file_get_contents('php://input');

// 1. Verify signature (see "Verify a webhook signature" above).
// ...

$payload = json_decode($rawBody, true);
$id      = $payload['id']      ?? null;
$status  = $payload['status']  ?? null;
$phone   = $payload['phone']   ?? null;

switch ($status) {
    case 'sent':
        // Mark message as delivered to the modem.
        break;
    case 'failed':
        // Log and possibly retry application-side.
        $error = $payload['error'] ?? 'unknown';
        error_log("SMS $id to $phone failed: $error");
        break;
    case 'reply':
        $replyText = $payload['reply'] ?? '';
        // Process the reply (e.g. capture an OTP).
        break;
    case 'reply_timeout':
        $secs = $payload['timeout_seconds'] ?? 0;
        error_log("SMS $id to $phone — no reply in {$secs}s");
        break;
    default:
        // Unknown event — ignore politely.
}

http_response_code(200);
echo 'ok';

Node.js (Express)


const express = require('express');
const app = express();

app.post('/sms/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    // 1. Verify signature here (see above).
    // ...

    const payload = JSON.parse(req.body.toString('utf8'));
    const { id, status, phone } = payload;

    switch (status) {
      case 'sent':
        // ...
        break;
      case 'failed':
        console.error(`SMS ${id} to ${phone} failed:`, payload.error);
        break;
      case 'reply':
        console.log(`Reply from ${phone}:`, payload.reply);
        break;
      case 'reply_timeout':
        console.warn(`SMS ${id} to ${phone} timed out after ${payload.timeout_seconds}s`);
        break;
    }

    res.status(200).send('ok');
  },
);

app.listen(8080);

Python (Flask)


from flask import Flask, request

app = Flask(__name__)

@app.post("/sms/webhook")
def webhook():
    # 1. Verify signature here (see above).
    # ...

    payload = request.get_json(silent=True) or {}
    msg_id  = payload.get("id")
    status  = payload.get("status")
    phone   = payload.get("phone")

    if status == "sent":
        pass  # mark delivered
    elif status == "failed":
        app.logger.error("SMS %s to %s failed: %s", msg_id, phone, payload.get("error"))
    elif status == "reply":
        app.logger.info("Reply from %s: %s", phone, payload.get("reply"))
    elif status == "reply_timeout":
        app.logger.warning("SMS %s timeout after %ss", msg_id, payload.get("timeout_seconds"))

    return "ok", 200

Security recommendations

**Always set a webhook secret and verify the signature.** Without it, anyone hitting your endpoint can fake delivery reports.

**Always serve webhooks over HTTPS.** The signature is good, but raw payloads with phone numbers and message bodies still shouldn't go over plaintext.

**Treat the Device ID as semi-secret.** It's a routing key. Anyone with the Device ID + reachable Cloud Functions can send SMS via your phone.

**Rate-limit your Cloud Functions.** Firebase has reasonable defaults but the free tier can be exhausted. Consider Firebase App Check or a simple shared-secret header to gate `sendSms` and `requestSmsReply`.

**Don't return the same polling `id` twice.** The phone has no idempotency cache. If your queue endpoint serves an `id` it already served, the SMS will go out a second time.

**Use the "exclude from payload" toggles** if your webhook receiver is on shared infra. The phone can omit `message` and `phone` from delivery reports — just enough info to correlate by `id`.

**Rotate the webhook secret** periodically. Settings → Webhook → Security.


FAQ

Q. Will this work with iOS?

No. The reply-capture and dual-SIM features require Android system APIs that iOS doesn't expose to apps. The codebase has iOS hooks but the native plugin is Android-only.

Q. What happens when the phone is offline?

Outbound SMS still works as long as cellular is up. Cloud Function commands queue in Firestore and are processed when the phone reconnects — the snapshot listener catches up. HTTP Polling pauses cleanly and resumes.

Q. What happens when the phone is off?

Cloud Function commands accumulate in Firestore. When the phone boots and the app starts, it processes them in order. There's no expiry — if the phone is off for a week, you'll get a week of queued SMS at once. Be careful with time-sensitive commands.

Q. Can two phones share one Device ID?

No. Each install generates its own UUID. If you reset the app, a new ID is generated and your servers must be updated.

Q. Can one server send to multiple phones?

Yes. Pass a different deviceId in each request. Each phone has its own devices/{deviceId}/outbox queue.

Q. Why is my reply not being matched?

Check that the sender number matches what you passed in phone. The matcher tries exact-string, then digits-only. +15551234567 matches 15551234567 and (555) 123-4567, but country-code differences fail (+44 555… vs 555…).

Q. Why am I getting duplicate webhook calls?

The phone retries up to your configured webhookRetryCount if your endpoint replies non-2xx or times out. Make your endpoint idempotent on id.

Q. What's the SMS character limit?

The phone modem handles multipart segmentation. There's no app-level limit, but each segment is 160 GSM-7 chars (or 70 UCS-2 chars if your message contains non-Latin characters). Long messages cost more SMS credits.


Glossary


Last updated: 2026-05-10 · Project: sms-web-api-aticmatic · Region: us-central1