While automating my life, I often came across situations where I need for "forward" SMS messages from my phone to my server. This often came up when attempting to automate workflows that require me to log in using two-factor authentication. Being uncomfortable with turning off 2FA just for the sake of my own automation, I developed a small webhook pipeline for forwarding incoming SMS from my phone to my server.
Overview
Incoming SMS on an Android phone are forwarded as HTTP POST requests to a webhook endpoint on the server. The webhook validates each request, then runs a shell script that writes the message body to a text file.
In essence, there's two major components that work together here:
- The SMS Forwarder on the Android Phone.
- The webhook on the server that receives the hook and does something with it.
More specifically, the end-to-end system looks like this:
Request path:
- Android phone receives SMS.
- Incoming SMS to URL forwarder POSTs JSON to
https://sms.example.com/hooks/sms-webhook?token=..., signed with HMAC-SHA256. - Cloudflare Tunnel -> Traefik ->
webhookcontainer. - adnanh/webhook checks the URL token and the
X-SignatureHMAC. - Hook runs a custom script, which write the message body to a
.txtfile. - Consumers read the directory; a scheduled job empties it daily.
Components
- Phone app: Incoming SMS to URL forwarder (
tech.bogomolov.incomingsmsgateway), installed over F-Droid (Keep Android Open) - Webhook server:
thecatlady/webhookDocker image (packages adnanh/webhook). - Ingress: Cloudflare Tunnel public hostname
sms.example.compointing at Traefik.
Server: webhook container
compose.yaml
services:
webhook:
container_name: webhook
image: thecatlady/webhook
restart: unless-stopped
command: -template -verbose -hooks=hooks.json -hotreload
environment:
- HOOK_TOKEN=${HOOK_TOKEN}
- HOOK_SECRET=${HOOK_SECRET}
volumes:
- /etc/docker/webhook:/config
ports:
- 9000:9000
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:9000/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
Flags:
-template: parseshooks.jsonas a Go template, so secrets can come from environment variables viagetenvinstead of being written into the file.-hotreload: reloadshooks.jsonon change without a container restart.-verbose: logs each request, including the arguments passed to the command.
.env
HOOK_TOKEN=<random string, sent as ?token= in the URL>
HOOK_SECRET=<random string, shared HMAC key with the phone app>
DOMAIN=example.com
Generate values with e.g. openssl rand -hex 32.
/etc/docker/webhook/hooks.json
This is the hooks.json config file required by the webhook app. We configure it here to set the URL path and trigger the script when hooked.
[
{
"id": "sms-webhook",
"execute-command": "sms-webhook.sh",
"command-working-directory": "/config",
"pass-arguments-to-command": [
{ "source": "payload", "name": "from" },
{ "source": "payload", "name": "receivedStamp" },
{ "source": "payload", "name": "text" }
],
"trigger-rule": {
"and": [
{
"match": {
"type": "value",
"value": "{{ getenv "HOOK_TOKEN" | js }}",
"parameter": { "source": "url", "name": "token" }
}
},
{
"match": {
"type": "payload-hmac-sha256",
"secret": "{{ getenv "HOOK_SECRET" | js }}",
"parameter": { "source": "header", "name": "X-Signature" }
}
}
]
}
}
]
- The hook
idsets the URL path:/hooks/sms-webhook. pass-arguments-to-commandmaps JSON fields from the request body to positional arguments of the script, in order.trigger-rulerequires both conditions:- the
tokenquery parameter equalsHOOK_TOKEN; - the
X-Signatureheader equals the hex HMAC-SHA256 of the raw request body, keyed withHOOK_SECRET.
- the
/etc/docker/webhook/sms-webhook.sh
This is the actual script that gets called by webhook (pointed to by hooks.json above).
#!/bin/sh
FROM=$1
RECEIVED=$2
TEXT=$3
echo $TEXT > ./sms/${RECEIVED}_${FROM}.txt
ℹ️ The script must be executable (chmod +x).Phone: Incoming SMS to URL forwarder
Forwarding rule fields:
- Sender:
*forwards every SMS. A specific number/name, or a regex with "Sender is a regular expression" enabled, limits it. - Webhook URL:
https://sms.example.com/hooks/sms-webhook?token=<HOOK_TOKEN> - Payload template: must contain the three fields the hook reads:
{
"from": "%from%",
"text": "%text%",
"sentStamp": "%sentStamp%",
"receivedStamp": "%receivedStamp%",
"sim": "%sim%"
}
- Sign with HMAC-SHA-256: enabled, secret =
HOOK_SECRET. The app sends the hex signature of the payload in theX-Signatureheader.

ℹ️ Only SMS are forwarded. Messages delivered over RCS do not trigger the app; RCS must be turned off in Google Messages for those senders to be forwarded.
ℹ️ Android battery optimization and background permissions likely need to be relaxed for this app to run as a service.
Retention (optional)
To avoid filling up the directory over time, I configured a xyops (formerly Cronicle) event to occassionally rm the .txt files.
#!/bin/sh
ssh host "rm -f /etc/docker/webhook/sms/*"Conclusion
Thanks for reading! Up to this point, I use this for automated logins only, but if you have any other interesting use cases for this, I'd be really interested in hearing about them.
Happy coding!