A mock server for Twilio SMS and Call APIs, perfect for development and testing without sending real messages or making real calls.
- Twilio-compatible API - Drop-in replacement for Twilio SMS/Call APIs
- Configurable behavior - Control success/failure scenarios via configuration
- Callback simulation - Automatic delivery status callbacks with configurable delays
- Validation - Toggleable authentication, phone format, and parameter validation
- Web UI - Mailbox-style interface to inspect received messages and calls, with user-defined tags supplied via the
X-TagsHTTP header - Docker support - Easy deployment with Docker and Docker Compose
- SDK compatible - Works with official Twilio SDKs (Python, Node.js, PHP, Ruby, Java, C#)
# Pull and run from Docker Hub. All configuration is via env vars.
docker run -d \
-p 8080:8080 \
-e SMS_MOCK_TWILIO_ACCOUNT_SID=ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX \
-e SMS_MOCK_TWILIO_AUTH_TOKEN=your_auth_token_here \
-e SMS_MOCK_TWILIO_SUCCESS_NUMBERS=+15551234567,+15559876543 \
-e SMS_MOCK_TWILIO_FAILURE_NUMBERS=+15559999999 \
-e SMS_MOCK_TWILIO_ALLOWED_FROM_NUMBERS=+15550000001,+15550000002 \
--name sms-mock-server \
notfoundsam/sms-mock-server:latest
# Access the UI
open http://localhost:8080# Using the provided docker-compose.yml
docker compose up -dThe bundled docker-compose.yml contains a commented-out environment: block listing every supported variable — uncomment what you need.
By default the SQLite DB lives at /tmp/mock_server.db inside the container and is wiped on container restart — fine for the typical "fresh state per test run" use case. To persist data across restarts, set SMS_MOCK_DB_PATH to a path inside a mounted volume.
The bundled docker-compose.yml already wires this up using a named Docker volume:
services:
sms-mock-server:
# ...
environment:
- SMS_MOCK_DB_PATH=/data/mock_server.db
volumes:
- sms-mock-data:/data
volumes:
sms-mock-data:The volume survives docker compose down. Remove it with docker compose down -v (or docker volume rm sms-mock-data).
If you'd rather store the DB in a host directory you can inspect directly (e.g. with sqlite3 ./data/mock_server.db), replace the volume mount with a bind mount:
volumes:
- ./data:/dataOn Linux the host directory must be writable by UID 65532 (the distroless nonroot user the container runs as): mkdir -p ./data && sudo chown -R 65532:65532 ./data. Docker Desktop on macOS/Windows handles UID translation automatically.
# Build a static binary into ./bin/sms-mock-server
make build
# Run it (configuration via env vars, e.g. for testing without auth):
SMS_MOCK_TWILIO_REQUIRE_AUTH=false ./bin/sms-mock-server
# Or skip the build step and use `go run`:
make runRequirements: Go 1.25+ (matches go.mod's declared toolchain). The binary is fully static (CGO_ENABLED=0) and embeds all templates and static assets, so the running binary needs nothing besides a writable directory for the SQLite DB. All configuration is supplied via environment variables.
The server is configured entirely via SMS_MOCK_* environment variables. Common (provider-agnostic) settings use the bare SMS_MOCK_ prefix; Twilio-specific settings use SMS_MOCK_TWILIO_.
| Variable | Default | Description |
|---|---|---|
SMS_MOCK_PORT |
8080 |
Listen port. The server always binds to 0.0.0.0; restrict externally via Docker port-forwarding if needed. |
SMS_MOCK_TIMEZONE |
UTC |
Timezone for UI date display (e.g. America/New_York, Asia/Tokyo) |
SMS_MOCK_DB_PATH |
/tmp/mock_server.db |
SQLite DB path. Default is ephemeral; mount a host directory and override to persist. |
SMS_MOCK_PROVIDER |
twilio |
Provider identifier. Only twilio is supported today. |
SMS_MOCK_MAX_MESSAGES |
500 |
Cap on stored messages; oldest are pruned when exceeded. 0 disables. |
SMS_MOCK_MAX_CALLS |
500 |
Cap on stored calls; oldest are pruned when exceeded. 0 disables. |
SMS_MOCK_MAX_AGE |
(empty) | TTL for messages and calls. Format <int>h or <int>d (e.g. 72h, 3d). Empty disables the TTL. |
SMS_MOCK_HIDE_DELETE_ALL_BUTTON |
false |
Hides the "Delete all" button in the sidebar. Backend /clear/* endpoints remain functional. |
When any of SMS_MOCK_MAX_MESSAGES, SMS_MOCK_MAX_CALLS, or SMS_MOCK_MAX_AGE is set, a background pruner runs every 60 seconds and trims the messages and calls tables. An immediate pass runs at startup so an over-capacity DB gets trimmed without waiting a minute. Pruning never touches callback_logs (audit trail) — it only deletes messages, calls, and their related delivery_events.
| Variable | Default | Description |
|---|---|---|
SMS_MOCK_TWILIO_ACCOUNT_SID |
(empty) | Required when REQUIRE_AUTH=true |
SMS_MOCK_TWILIO_AUTH_TOKEN |
(empty) | Required when REQUIRE_AUTH=true |
SMS_MOCK_TWILIO_SUCCESS_NUMBERS |
(empty) | Comma-separated. Numbers go queued → sent → delivered. |
SMS_MOCK_TWILIO_FAILURE_NUMBERS |
(empty) | Comma-separated. Numbers go queued → failed. |
SMS_MOCK_TWILIO_ALLOWED_FROM_NUMBERS |
(empty) | Comma-separated. From-allowlist; only enforced when CHECK_FROM_NUMBERS=true. |
SMS_MOCK_TWILIO_REQUIRE_AUTH |
true |
Validate HTTP Basic credentials |
SMS_MOCK_TWILIO_VALIDATE_PHONE_FORMAT |
true |
Check E.164 phone format |
SMS_MOCK_TWILIO_CHECK_FROM_NUMBERS |
true |
Require From to be in the allowlist |
SMS_MOCK_TWILIO_CALLBACK_DELAY_SECONDS |
2 |
Delay between status transitions |
SMS_MOCK_TWILIO_CALLBACK_RETRY_ATTEMPTS |
3 |
Total attempts for outbound callbacks |
SMS_MOCK_TWILIO_CALLBACK_RETRY_DELAY_SECONDS |
5 |
Delay between retry attempts |
A destination number's outcome is determined by which list it appears in:
- In
SMS_MOCK_TWILIO_FAILURE_NUMBERS→ status flowqueued → failed. IfStatusCallbackis set, callbacks fire. - In
SMS_MOCK_TWILIO_SUCCESS_NUMBERS→ status flowqueued → sent → delivered. IfStatusCallbackis set, callbacks fire. - In neither list → stays
queuedforever. No callbacks fire. This is also how the server runs in "callbacks-effectively-off" mode: leave both lists empty.
Failure list takes precedence if a number appears in both.
Earlier versions of this project used a YAML config file. The mapping to env vars is straightforward:
| Old YAML key | New env var |
|---|---|
server.port |
SMS_MOCK_PORT |
server.timezone |
SMS_MOCK_TIMEZONE |
provider |
SMS_MOCK_PROVIDER |
database.path |
SMS_MOCK_DB_PATH |
twilio.account_sid |
SMS_MOCK_TWILIO_ACCOUNT_SID |
twilio.auth_token |
SMS_MOCK_TWILIO_AUTH_TOKEN |
twilio.registered_numbers |
SMS_MOCK_TWILIO_SUCCESS_NUMBERS (renamed) |
twilio.failure_numbers |
SMS_MOCK_TWILIO_FAILURE_NUMBERS |
twilio.allowed_from_numbers |
SMS_MOCK_TWILIO_ALLOWED_FROM_NUMBERS |
twilio.validation.* |
SMS_MOCK_TWILIO_REQUIRE_AUTH / _VALIDATE_PHONE_FORMAT / _CHECK_FROM_NUMBERS |
twilio.callbacks.delay_seconds |
SMS_MOCK_TWILIO_CALLBACK_DELAY_SECONDS |
twilio.callbacks.retry_attempts |
SMS_MOCK_TWILIO_CALLBACK_RETRY_ATTEMPTS |
twilio.callbacks.retry_delay_seconds |
SMS_MOCK_TWILIO_CALLBACK_RETRY_DELAY_SECONDS |
Removed settings (with no replacement):
twilio.default_behavior— was effectively dead code (the dispatcher short-circuits on unknown numbers, so the field never observably affected outcomes). Numbers either appear in a list and resolve to that list's outcome, or they don't and stay queued.twilio.callbacks.enabled— removed. Callbacks now fire whenever the request supplies aStatusCallbackURL and the destination number is inSUCCESS_NUMBERSorFAILURE_NUMBERS. To suppress all callbacks server-wide, leave both lists empty (no number progresses pastqueued, so no callbacks fire). The old combination "callbacks.enabled=false + populated lists" (status flow visible in the UI but no outbound HTTP) no longer has a server-side switch — the workaround is to pointStatusCallbackat a sink you control (e.g. the built-in/callback-test).
from twilio.rest import Client
from twilio.http.http_client import TwilioHttpClient
account_sid = 'ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
auth_token = 'your_auth_token_here'
# Point to mock server
http_client = TwilioHttpClient()
http_client.api_base_url = 'http://localhost:8080'
client = Client(account_sid, auth_token, http_client=http_client)
# Send SMS
message = client.messages.create(
to='+15551234567',
from_='+15550000001',
body='Hello from mock server!',
status_callback='http://your-app.com/callback'
)
print(f"Message SID: {message.sid}")const twilio = require('twilio');
const accountSid = 'ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX';
const authToken = 'your_auth_token_here';
const client = twilio(accountSid, authToken, {
lazyLoading: true,
accountSid: accountSid,
apiBaseUrl: 'http://localhost:8080'
});
// Send SMS
const message = await client.messages.create({
to: '+15551234567',
from: '+15550000001',
body: 'Hello from mock server!',
statusCallback: 'http://your-app.com/callback'
});
console.log(`Message SID: ${message.sid}`);<?php
require_once 'vendor/autoload.php';
use Twilio\Rest\Client;
$accountSid = 'ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX';
$authToken = 'your_auth_token_here';
$mockServerUrl = 'http://localhost:8080';
$client = new Client($accountSid, $authToken, $accountSid, null, $mockServerUrl);
$message = $client->messages->create(
'+15551234567',
[
'from' => '+15550000001',
'body' => 'Hello from mock server!',
'statusCallback' => 'http://your-app.com/callback'
]
);
echo "Message SID: " . $message->sid . "\n";
?>POST /2010-04-01/Accounts/{AccountSid}/Messages.json
Parameters:
From(required) - Sender phone numberTo(required) - Recipient phone numberBody(required) - Message textStatusCallback(optional) - Callback URL for delivery status
Response: Standard Twilio message resource JSON
POST /2010-04-01/Accounts/{AccountSid}/Calls.json
Parameters:
From(required) - Caller phone numberTo(required) - Callee phone numberUrl(required) - TwiML URLStatusCallback(optional) - Callback URL for call status
Response: Standard Twilio call resource JSON
GET /health
Response:
{
"status": "healthy",
"version": "1.0.0",
"provider": "twilio",
"timestamp": "2024-01-15T10:30:00Z",
"statistics": {
"messages": 42,
"calls": 15,
"callbacks": 84
}
}POST /callback-test
A test endpoint that accepts POST requests (used for testing callbacks locally without external URLs).
Response: {"status": "received", "data": {...}}
POST /clear/messages # Clear all messages
POST /clear/calls # Clear all calls
POST /clear/callbacks # Clear all callback logs
POST /clear/all # Clear all data
Access the web UI at http://localhost:8080. The layout is a mailbox-style
inbox: top bar with search, left sidebar with type nav and tags, list pane
that opens records as full pages.
- Messages (
/) — list of received SMS, newest first. Unread rows are bold with a leading dot. - Calls (
/calls) — list of calls in the same shape (no message body). - Tags — sidebar section that appears only when at least one record of the active type carries a tag. Tags are user-defined and per-type (the messages sidebar shows only tags attached to messages; same for calls). Click a tag to filter; click again or click another tag to switch (single-select). See Tagging Messages below.
- Search — top-bar input. Free text filters across
From,To, andBody(calls:From/Toonly). Inlinetag:foooperators are supported in the same box (e.g.verify tag:authfinds messages containing "verify" AND taggedauth). Live as you type; press Enter for a shareable URL. - Detail view — clicking a row opens
/view/messages/{sid}(or/view/calls/{sid}). Marks the record read on open. Shows a one-line callback delivery summary if any webhook was sent and the record's tags as pill chips. Filter state is preserved through to the Back button. - Delete — per-row delete on hover; "Delete all" button at the bottom of
the sidebar (calls
POST /clear/messagesor/clear/calls). - Real-time — list and sidebar poll every 3 seconds via HTMX, so new records appear without a manual reload.
The dashboard, the Callbacks page, and modal-style detail views are gone.
Callback delivery info now lives inline on the relevant message/call detail
page; raw callback log rows remain queryable via
SELECT * FROM callback_logs; against the SQLite DB.
Clients can attach arbitrary tags to a message or call by sending an X-Tags
HTTP header on the Twilio-shaped POST. The Twilio request body is unchanged —
tags are out-of-band metadata for the mock UI's benefit only.
curl -X POST http://localhost:8080/2010-04-01/Accounts/AC1/Messages.json \
-H 'X-Tags: verification, auth' \
-d 'From=%2B15550000001&To=%2B15551234567&Body=Your+code+is+1234'- Format: comma-separated names. Whitespace around each name is trimmed, empty entries are dropped, names are lowercased on the server, duplicates are folded.
- Multiple tags: a record can carry zero or more tags. The query
tag:verification tag:authfinds records that have both (AND-semantics). - Per-type isolation: a tag attached only to a call appears in the Calls sidebar, not in the Messages sidebar.
When you send an SMS/call with a StatusCallback URL, the mock server will:
- Accept the request and return immediately (status:
queued) - Wait for
delay_seconds(default: 2s) - Send status update callbacks:
- SMS Success: queued → sent → delivered
- SMS Failure: queued → failed
- Call Success: queued → ringing → in-progress → completed
- Call Failure: queued → failed
- Retry failed callbacks up to
retry_attemptstimes
services:
sms-mock-server:
image: notfoundsam/sms-mock-server:latest
ports:
- "8080:8080"
networks:
- app-network
your-app:
build: ./your-app
depends_on:
- sms-mock-server
environment:
- TWILIO_API_BASE_URL=http://sms-mock-server:8080
- TWILIO_ACCOUNT_SID=ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
- TWILIO_AUTH_TOKEN=your_auth_token_here
networks:
- app-network
networks:
app-network:
driver: bridgeThe mock server emulates common Twilio errors:
| Error | HTTP Status | When |
|---|---|---|
| Authentication Failed | 401 | Invalid credentials |
| Missing Parameter | 400 | Required param missing |
| Invalid Phone Number | 400 | Invalid E.164 format |
| Invalid From Number | 400 | From not in allowed list |
# Go build / test
make build # Build static binary into ./bin/sms-mock-server
make run # Run the server directly (go run)
make test # Go unit tests
make test-race # Go unit tests with -race
make lint # golangci-lint run ./... (41 linters, see .golangci.yml)
make tidy # go mod tidy
make version # Print the build version that `make build` would stamp
# Docker / docker compose (uses published image from Docker Hub)
make up # Pull latest image + start container
make stop # Stop container
make restart # Restart container
make clean # Stop + remove volumes
make logs # Tail container logs
make docker-snapshot # Build a local image via goreleaser (no push)
# Data helpers
make seed # Seed sample messages/calls (against running server on :8080)
make help # Show this listThe server is a single Go binary; templates, static assets, and migrations are all embedded at build time. No external runtime tooling is required.
# Run unit tests
go test ./...
go test -race ./... # with the race detector
# Lint (requires golangci-lint installed)
make lint
# Run the server (auto-reloading is not built in; rebuild + restart on changes)
make runTest coverage:
- Per-package unit tests (
app/<pkg>/*_test.go) using stdlibtesting+testify. Includes table-driven validation matrices, fakes for storage / HTTP / clock. - End-to-end smoke test (
app/main_test.go) builds the full stack viahttptest.NewServerand exercises POST Messages → persistence →/health→ messages page → static asset →/clear/all. - All HTTP endpoints (Twilio Messages/Calls,
/health,/clear/*,/callback-test,/favicon.ico, mailbox/detail pages, UI fragments) are covered by handler-level tests inapp/httpapi/andapp/ui/.
sms-mock-server/
├── app/ # all Go source (flat layout, single binary)
│ ├── main.go # entrypoint
│ ├── main_test.go # end-to-end smoke test (httptest.NewServer)
│ ├── embedded.go # //go:embed templates + static
│ ├── config/ # env-var config loader + validation
│ ├── storage/ # SQLite store, embedded migrations
│ ├── provider/ # Provider interface + types (ValidationError, etc.)
│ │ └── twilio/ # Twilio adapter (auth, validation, outcome)
│ ├── template/ # text/template + html/template engine
│ ├── callback/ # Async dispatcher: worker pool + Clock-driven retries
│ ├── prune/ # Background retention sweeper (60s tick)
│ ├── httpapi/ # Twilio API routes, /health, /clear/*, middleware
│ ├── ui/ # Mailbox pages, detail views, HTMX fragment handlers
│ ├── clock/ # Clock interface (real + fake for tests)
│ ├── testutil/ # Shared fakes for unit tests
│ ├── templates/ # JSON response/error + HTML UI templates (embedded)
│ │ ├── responses/twilio/
│ │ ├── errors/twilio/
│ │ └── ui/ # base.html + page templates + fragments/
│ └── static/ # CSS, JS, favicon (embedded)
├── scripts/
│ └── seed_data.sh # Seed sample messages/calls via curl
├── docs/
│ ├── DESIGN.md # Architecture documentation
│ └── plans/ # Implementation plans (history)
├── .github/workflows/ # CI (test + lint + shellcheck) + release (goreleaser)
├── Makefile # Build / test / docker targets
├── Dockerfile # Multi-stage source build (used by docker compose)
├── Dockerfile.release # Single-stage prebuilt-binary copy (used by goreleaser)
├── docker-compose.yml
├── .golangci.yml # Linter config (41 linters)
├── .goreleaser.yml # Release automation (binaries + Docker Hub image)
└── go.mod / go.sum
Authentication errors even with correct credentials:
- Make sure
SMS_MOCK_TWILIO_ACCOUNT_SIDandSMS_MOCK_TWILIO_AUTH_TOKENare set - Or set
SMS_MOCK_TWILIO_REQUIRE_AUTH=falsefor quick testing
Callbacks not being received:
- Verify the
Tonumber is inSMS_MOCK_TWILIO_SUCCESS_NUMBERS(success flow) orSMS_MOCK_TWILIO_FAILURE_NUMBERS(failure flow). Numbers in neither list stay queued forever and produce no callbacks — this is also the way to disable callbacks entirely (leave both lists empty). - Verify your callback URL is accessible from the mock server
- For local testing, use the built-in
/callback-testendpoint:http://localhost:8080/callback-test - Inspect callback delivery on the message/call detail page (
/view/messages/{sid}shows a "Callback delivery" summary if any webhooks were sent for that record). For raw rows, query the SQLite DB directly:sqlite3 /tmp/sms-mock.db 'SELECT * FROM callback_logs;'
Phone number validation errors:
- Use E.164 format:
+15551234567(with+and country code) - Or set
SMS_MOCK_TWILIO_VALIDATE_PHONE_FORMAT=false - Note:
+1555...numbers (NANP fictional-use) are rejected by libphonenumber when phone format validation is on. Use real-looking numbers like+12025550100(DC area code) for testing with strict validation, or disable the format check for permissive testing.
MIT License - See docs/DESIGN.md for architecture details
Contributions welcome! This is a development tool, so focus on:
- Simplicity over features
- Compatibility with Twilio SDKs
- Easy configuration and debugging
