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](#what-this-is)
- [Architecture](#architecture)
- [Quickstart](#quickstart)
- [Concepts](#concepts)
- [Endpoint reference](#endpoint-reference)
- [Webhooks](#webhooks)
- [Authentication](#authentication)
- [Advanced options](#advanced-options)
- [Error handling](#error-handling)
- [Code examples](#code-examples)
- [Webhook receivers](#webhook-receivers)
- [Security recommendations](#security-recommendations)
- [FAQ](#faq)
- [Glossary](#glossary)
- HTTP Polling (advanced / legacy)
- Events
- Signature verification (HMAC)
- Send an SMS and capture the reply
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.
| Mode | What happens | When 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
}
| Field | Type | Required | Notes |
|---|---|---|---|
| `deviceId` | string | ✓ | UUID from the app. |
| `phone` | string | ✓ | E.164 (`+15551234567`) or local format. The phone modem decides what's valid. |
| `message` | string | ✓ | UTF-8. Multipart auto-handled by the modem. |
| `simSlot` | int | – | `0` or `1`. Defaults to `0` (SIM 1). Override per Settings is also available. |
| `waitForReply` | bool | – | If `true`, app waits for an incoming SMS from `phone` and POSTs it to `replyWebhookUrl` (or the configured webhook). |
| `replyWebhookUrl` | string | – | Aliases: `callbackUrl`. Implies `waitForReply: true`. |
| `replyTimeoutSeconds` | int | – | Aliases: `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
| HTTP | When |
|---|---|
| `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
}
| Field | Type | Required | Notes |
|---|---|---|---|
| `deviceId` | string | ✓ | UUID from the app. |
| `phone` | string | ✓ | Often a shortcode like `8500`. |
| `message` | string | ✓ | The command text the shortcode expects. |
| `callbackUrl` | string | ✓ | Must be http(s). The reply is POSTed here. |
| `simSlot` | int | – | `0` or `1`. |
| `replyTimeoutSeconds` | int | – | Default `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
| Event | Fires 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
- **Method**: POST by default; PUT also supported.
- **Retries**: configurable, default 3.
- **Backoff**: linear, `attempt × delay` seconds. Default delay is 5s, so attempts fire at 0s, 5s, 10s, 15s.
- **Success**: any 2xx response code stops further retries.
- **Failure**: non-2xx or network error counts as a failure and triggers the next retry.
- **Idempotency**: receivers should treat repeated `id` values as duplicates. Your endpoint may receive the same payload more than once (e.g. if your endpoint timed out *after* writing).
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.
| Type | Header 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:
- Auth header (Bearer / Basic / API key)
- Custom headers
- HMAC signature in `X-Webhook-Signature`
—all on the same request.
Advanced options
These all live under Settings.
Send behaviour
| Option | Effect |
|---|---|
| **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
| Option | Effect |
|---|---|
| `replyTimeoutSeconds` | How long to wait for a matching incoming SMS. 10–3600 seconds. |
| Phone matching | Exact match first; falls back to digits-only comparison so `+1 (555) 123-4567` matches `15551234567`. |
| FIFO conflict | Multiple pending requests for the same number → first reply matches first request. |
HTTP Polling
| Option | Effect |
|---|---|
| **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
| Option | Effect |
|---|---|
| **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
| HTTP | Cause | Action |
|---|---|---|
| `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` substring | Meaning |
|---|---|
| `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
- **Device ID** — UUIDv4 identifying one app installation. Used by Cloud Functions to route commands to the right phone.
- **Cloud Function** — Firebase HTTPS function. Two of them: `sendSms`, `requestSmsReply`.
- **Outbox** — Firestore subcollection at `devices/{id}/outbox` where Cloud Functions write commands and the app deletes them after dispatch.
- **Webhook** — Your HTTP endpoint that receives delivery reports from the phone.
- **Reply Webhook** — Either the per-request `callbackUrl`, or the globally configured webhook URL — wherever the phone POSTs an inbound SMS reply.
- **HMAC** — Hash-based Message Authentication Code. Used to sign webhook payloads with a shared secret so the receiver can prove authenticity.
- **Cool down** — Delay between consecutive outgoing SMS, configured per-device. Lowers carrier-side anti-spam triggers.
- **SIM slot** — Index of the SIM tray to use on dual-SIM phones. `0` = SIM 1, `1` = SIM 2.
- **JSON path mapping** — User-configurable dot-notation paths the polling parser uses to extract messages from your queue endpoint's JSON response.
Last updated: 2026-05-10 · Project: sms-web-api-aticmatic · Region: us-central1