Connect FlutterFlow to Signal by self-hosting the open-source signal-cli-rest-api Docker bridge on a VPS, securing it behind a token or reverse proxy, then calling its REST endpoints from a FlutterFlow API Group. Signal has no official public API — this entire integration depends on an unofficial bridge you operate yourself. It's an Advanced setup suited to privacy-sensitive internal tools.
| Fact | Value |
|---|---|
| Tool | Signal |
| Category | Communication |
| Method | FlutterFlow API Call |
| Difficulty | Advanced |
| Time required | 2 hours |
| Last updated | July 2026 |
Signal from FlutterFlow: The Self-Hosted Bridge Reality
Signal is the gold standard for encrypted private messaging — but it was built for people, not developers. Signal intentionally has no official public API. There is no Signal Bot API, no REST endpoint you can hit with an API key, no developer portal. If you need to send Signal messages from a FlutterFlow app, the only viable option is a community-maintained open-source project: **signal-cli-rest-api** (github.com/bbernhard/signal-cli-rest-api), which wraps the signal-cli Java command-line tool as an HTTP REST server. You deploy it as a Docker container on a VPS, register a dedicated phone number via Signal's registration flow, and then FlutterFlow calls your container's `/v2/send` endpoint.
Before going further, understand the trade-offs. This is an unofficial integration that depends on reverse-engineered Signal protocols. Signal can change their protocol at any time, breaking the bridge without notice. There is no SLA, no official support, and sending automated messages at scale risks the phone number being rate-limited or banned by Signal for policy violations. This integration is best suited to **privacy-sensitive internal tools** — think a private alert system for a small team that specifically needs Signal's encryption, and where Telegram or Slack would be unacceptable for compliance reasons.
For FlutterFlow specifically: the bridge is unauthenticated by default — anyone who knows your bridge URL can send messages as your phone number. You must secure it. The recommended pattern is the same as other secret-bearing APIs: put the bridge URL and any auth token in a Firebase Cloud Function, and have FlutterFlow call the Cloud Function, not the bridge directly. This keeps the bridge hidden from the compiled app binary. The bridge must run 24/7 on a persistent VPS with a mounted storage volume for the signal-cli session data — if the container restarts without the volume, you lose the registration.
Integration method
FlutterFlow calls your self-hosted signal-cli-rest-api Docker container via a FlutterFlow API Group. The bridge wraps the open-source signal-cli Java tool as an HTTP REST server and exposes endpoints like POST /v2/send. Because Signal has no official API, you are responsible for deploying, securing, and maintaining the bridge — FlutterFlow is just the REST client sitting on top of it. The bridge must run on a persistent VPS with a registered Signal phone number.
Prerequisites
- A Linux VPS (e.g., DigitalOcean Droplet, Hetzner, Linode) with Docker installed — the signal-cli-rest-api container requires a persistent environment, not a serverless function
- A dedicated phone number for your Signal bridge — this can be a physical SIM, a VoIP number that receives SMS, or a Google Voice number; this number will be your Signal 'sender'
- A Firebase project with Cloud Functions on the Blaze plan (to proxy requests to your bridge) OR a Supabase project with Edge Functions
- A FlutterFlow project — API Calls are available on all paid FlutterFlow plans
- Comfort with Docker, VPS administration, and the fact that this is an unofficial, unsupported integration
Step-by-step guide
Deploy the signal-cli-rest-api Docker container on a VPS
SSH into your VPS and install Docker if not already present. Create a directory for your Signal bridge and a persistent data volume directory. The signal-cli-rest-api container needs this volume to persist the Signal session (registration state) across container restarts — without it, you'd have to re-register the phone number every time the container restarts. Pull the image and start the container. The container exposes an HTTP API on port 8080 by default. Set the `MODE` environment variable to `normal` for standard operation. Pass the storage volume mount and set your phone number via environment variable. Here's a minimal Docker run command: ``` docker run -d \ --name signal-api \ -p 8080:8080 \ -v /path/to/signal-data:/home/.local/share/signal-cli \ -e MODE=normal \ bbernhard/signal-cli-rest-api ``` Once running, check that the container is live with `docker logs signal-api`. You should see the HTTP server starting. The container is listening on port 8080, but do NOT expose this port to the public internet yet — it has no authentication by default. In the next steps, you'll register a phone number and then secure the bridge.
1# docker-compose.yml (recommended for easier management)2version: '3'3services:4 signal-api:5 image: bbernhard/signal-cli-rest-api6 container_name: signal-api7 environment:8 - MODE=normal9 ports:10 - "127.0.0.1:8080:8080" # Bind to localhost only — not public11 volumes:12 - ./signal-data:/home/.local/share/signal-cli13 restart: unless-stoppedPro tip: Binding the port to `127.0.0.1:8080` (not `0.0.0.0:8080`) means the bridge is only accessible from the same server — not from the public internet. Your Cloud Function or reverse proxy (Nginx with auth) will forward requests to it. This is the safest default configuration.
Expected result: The signal-cli-rest-api container is running on your VPS, bound to localhost only. Docker logs show the HTTP server started on port 8080. The data directory is mounted and will persist the Signal session.
Register a phone number with the Signal bridge
With the container running, you need to register a phone number as a Signal account linked to this bridge. This is a one-time operation done directly on the server — not from FlutterFlow. Step 1 — Request a verification code: call the bridge's registration endpoint, specifying your phone number in E.164 format (e.g., +14155552671) and how you want to receive the code (sms or voice): ``` curl -X POST http://localhost:8080/v1/register/{PHONE_NUMBER} -H "Content-Type: application/json" -d '{"use_voice": false}' ``` Signal will send a 6-digit SMS verification code to that phone number. Step 2 — Verify the code: ``` curl -X POST http://localhost:8080/v1/register/{PHONE_NUMBER}/verify/{CODE} ``` Replace {CODE} with the 6-digit code you received. If successful, the bridge returns a 201 response and writes the session to your mounted data volume. The phone number is now registered as a Signal account on this bridge. Test it by sending yourself a Signal message: ``` curl -X POST http://localhost:8080/v2/send \ -H "Content-Type: application/json" \ -d '{"number": "+14155552671", "recipients": ["+1YOUR_PHONE"], "message": "Hello from the bridge!"}' ``` Check Signal on your personal phone — you should receive the message from your bridge's registered number.
1# Registration flow (run on the VPS server via curl or wget)2# Step 1: Request SMS code3curl -X POST 'http://localhost:8080/v1/register/+14155552671' \4 -H 'Content-Type: application/json' \5 -d '{"use_voice": false}'67# Step 2: Verify the code (replace 123456 with real code)8curl -X POST 'http://localhost:8080/v1/register/+14155552671/verify/123456'910# Test send11curl -X POST 'http://localhost:8080/v2/send' \12 -H 'Content-Type: application/json' \13 -d '{14 "number": "+14155552671",15 "recipients": ["+1RECIPIENT_NUMBER"],16 "message": "Test from FlutterFlow bridge"17 }'Pro tip: If you want to link an existing Signal number (your personal number linked to Signal on your phone) instead of registering a new one, use the /v1/qrcodelink endpoint — scan the QR code from Signal's linked devices screen. This avoids needing a separate SIM or VoIP number, but ties your bridge to your personal account (not recommended for production).
Expected result: The phone number is registered with Signal via the bridge. You've received and verified the SMS code. A test send from the bridge delivers a message to your personal Signal app.
Secure the bridge and expose it via a Cloud Function proxy
The bridge has no authentication by default. Anyone who can reach port 8080 can send Signal messages as your registered number. You must secure it before calling it from FlutterFlow. Option A (recommended): Put the bridge behind a **Cloud Function proxy**. The Cloud Function runs on Google's servers, can reach your VPS bridge via its internal IP or a private network, and adds an auth layer between FlutterFlow and the bridge. Deploy a Firebase Cloud Function named `sendSignalMessage` that accepts `{recipient, message}` from FlutterFlow, validates an API key header, then forwards the request to your VPS bridge URL. Store your VPS bridge URL and any API token as Firebase Functions config — not in FlutterFlow. Option B (if you can't use a Cloud Function): Secure the bridge with **Nginx + HTTP Basic Auth** on the VPS, making it publicly accessible only over HTTPS with a username/password. Then FlutterFlow calls the bridge directly with Basic Auth. This exposes the bridge URL in the app binary — less ideal, but tolerable if you rotate the password regularly and the bridge URL isn't sensitive. For Option A, your Cloud Function makes a POST to `http://YOUR_VPS_IP:8080/v2/send` (internal/private network) and returns the response to FlutterFlow.
1// index.js (Firebase Cloud Function proxy for Signal bridge)2const functions = require('firebase-functions');3const axios = require('axios');45const BRIDGE_URL = functions.config().signal.bridge_url; // e.g., http://10.0.0.5:80806const SENDER_NUMBER = functions.config().signal.sender_number; // e.g., +141555526717const API_SECRET = functions.config().signal.api_secret; // your own secret token89exports.sendSignalMessage = functions.https.onRequest(async (req, res) => {10 res.set('Access-Control-Allow-Origin', '*');11 if (req.method === 'OPTIONS') {12 res.set('Access-Control-Allow-Methods', 'POST');13 res.set('Access-Control-Allow-Headers', 'Content-Type, x-api-key');14 return res.status(204).send('');15 }1617 // Simple auth: check a custom header against your secret18 if (req.headers['x-api-key'] !== API_SECRET) {19 return res.status(401).json({ error: 'Unauthorized' });20 }2122 const { recipient, message } = req.body;23 if (!recipient || !message) {24 return res.status(400).json({ error: 'recipient and message required' });25 }2627 try {28 const response = await axios.post(`${BRIDGE_URL}/v2/send`, {29 number: SENDER_NUMBER,30 recipients: [recipient],31 message: message32 }, {33 headers: { 'Content-Type': 'application/json' },34 timeout: 1500035 });36 return res.status(200).json({ timestamp: response.data.timestamp });37 } catch (err) {38 const status = err.response?.status || 500;39 return res.status(status).json({ error: err.message });40 }41});Pro tip: The `x-api-key` header approach is a simple API key check. For a more production-grade setup, validate against a hashed secret or integrate with Firebase Auth ID tokens — require FlutterFlow to send the user's Firebase Auth token and verify it in the Cloud Function before forwarding to the bridge.
Expected result: The Cloud Function is deployed and requires the x-api-key header. Calling it without the key returns 401. Calling it with the correct key and valid {recipient, message} successfully delivers a Signal message.
Create the FlutterFlow API Group and wire up a send form
In FlutterFlow, click **API Calls** in the left nav → **+ Add** → **Create API Group**. Name it 'Signal Bridge'. Set the Base URL to your Cloud Function URL (e.g., `https://us-central1-your-project.cloudfunctions.net`). Add a shared header: Key = `x-api-key`, Value = `{{apiKey}}`. Add a group-level variable `apiKey` (String). In the Variables tab, you can set the default value of `apiKey` to your API secret — or better, store it in FlutterFlow's **App State** and pass it when initializing the API group. The API key is a shared secret, not a per-user credential, so storing it in App State with a hardcoded initial value is acceptable for this use case (the secret is in the Cloud Function env config; what's in the app is just a key to access your Cloud Function, not direct Signal access). Add an API Call inside the group: name it 'Send Signal Message'. Method = POST, path = `/sendSignalMessage`. Body type = JSON, body fields: `recipient` (variable, E.164 phone number like +14155552671) and `message` (variable, message text). On your send screen, add a TextField for the recipient's phone number, a multiline TextField for the message, and a Send button. Wire the button's Action Flow to: (1) API Request → 'Signal Bridge / Send Signal Message', passing the TextFields as `recipient` and `message`; (2) show a SnackBar 'Message sent via Signal' on 200; (3) show 'Failed to send' on error; (4) clear the form. If this kind of privacy-first integration is important for your use case and you'd rather not manage the VPS yourself, RapidDev's team can help you architect and deploy this infrastructure — free scoping call at rapidevelopers.com/contact.
1{2 "group": "Signal Bridge",3 "baseUrl": "https://us-central1-YOUR-PROJECT.cloudfunctions.net",4 "headers": {5 "x-api-key": "{{apiKey}}"6 },7 "calls": [8 {9 "name": "Send Signal Message",10 "method": "POST",11 "path": "/sendSignalMessage",12 "bodyType": "JSON",13 "body": {14 "recipient": "{{recipient}}",15 "message": "{{message}}"16 },17 "variables": [18 { "name": "apiKey", "type": "String" },19 { "name": "recipient", "type": "String" },20 { "name": "message", "type": "String" }21 ]22 }23 ]24}Pro tip: Phone numbers must be in E.164 format with the country code and + prefix (e.g., +14155552671, not 4155552671 or (415) 555-2671). Add a validation step in FlutterFlow that checks the recipient field starts with '+' and contains only digits after that — Signal will silently fail to deliver to malformed numbers.
Expected result: The FlutterFlow send form delivers Signal messages via the Cloud Function proxy. The messages arrive on the recipient's Signal app. The Cloud Function URL and bridge URL are not exposed in the compiled app.
Handle inbound messages and keep the bridge running
Receiving inbound Signal replies in FlutterFlow requires polling the bridge's `/v1/receive/{PHONE_NUMBER}` endpoint — Signal doesn't push inbound messages to a webhook in the bridge by default (though the bridge has a `native` mode with WebSocket support for production workloads). For basic inbound polling, add a second API Call in your FlutterFlow Signal Bridge group: name it 'Receive Messages', method = GET, path = `/v1/receive/{PHONE_NUMBER}` (substitute your registered number). Route this through your Cloud Function proxy (add a `/receiveSignalMessages` endpoint to the same function that calls the bridge). Set up a Timer action in FlutterFlow using a Custom Action that triggers every 30 seconds to poll for new messages. For production workloads with real-time inbound messages, switch the Docker container to `native` mode (`MODE=native`) and configure your Cloud Function to listen on the bridge's WebSocket feed. Write inbound messages to Firestore and bind a Firestore real-time stream in FlutterFlow — this is the same pattern used in the Sunshine Conversations integration. **Keeping the bridge running** is critical. signal-cli stores session state in the mounted volume. The container must stay running 24/7 — use `restart: unless-stopped` in docker-compose (included in Step 1's config). Monitor the container with a simple uptime check (UptimeRobot, Betterstack) that hits the `/v1/about` endpoint every 5 minutes. If the number gets banned or rate-limited by Signal, the bridge will start returning errors — monitor your VPS logs. Note: if Signal updates their protocol (which happens without notice to third-party tools), the bridge may stop working until the signal-cli maintainers release an update. Subscribe to the signal-cli GitHub repository for release notifications.
1// Add to index.js — receive endpoint proxy2exports.receiveSignalMessages = functions.https.onRequest(async (req, res) => {3 res.set('Access-Control-Allow-Origin', '*');4 if (req.method === 'OPTIONS') {5 res.set('Access-Control-Allow-Methods', 'GET');6 res.set('Access-Control-Allow-Headers', 'Content-Type, x-api-key');7 return res.status(204).send('');8 }910 if (req.headers['x-api-key'] !== API_SECRET) {11 return res.status(401).json({ error: 'Unauthorized' });12 }1314 try {15 const response = await axios.get(16 `${BRIDGE_URL}/v1/receive/${SENDER_NUMBER}`,17 { timeout: 10000 }18 );19 return res.status(200).json(response.data);20 } catch (err) {21 return res.status(err.response?.status || 500).json({ error: err.message });22 }23});Pro tip: The `/v1/receive` endpoint clears messages from the bridge's queue when called — if you poll from two places simultaneously, one of them will get empty results. In FlutterFlow, make sure only one screen polls at a time, and write received messages to Firestore or a local Page State list immediately so they're not lost if the screen refreshes.
Expected result: The receive endpoint returns new Signal messages as a JSON array. A FlutterFlow Timer action polls every 30 seconds and displays new messages in the chat screen. The Docker container restarts automatically if the VPS reboots.
Common use cases
Privacy-first internal alert system for a security-conscious team
A security or legal team uses a FlutterFlow internal app to send encrypted Signal alerts to team members — critical incident notifications, confidential briefings — where they've explicitly rejected Slack or email for compliance reasons. The team controls the bridge server, and messages are end-to-end encrypted via Signal.
Build an internal app where admins can send encrypted Signal messages to team members about security incidents, with the message disappearing from Signal after 1 week.
Copy this prompt to try it in FlutterFlow
End-to-end encrypted customer notifications for a healthcare app
A FlutterFlow patient-communication app sends Signal messages to patients who prefer Signal over SMS or email for medical privacy. Appointment reminders, test result notifications, and follow-up instructions are sent via the self-hosted bridge. Patients' phone numbers are registered as Signal contacts on their devices.
Send appointment reminder messages to patients via Signal from our practice's dedicated Signal number.
Copy this prompt to try it in FlutterFlow
Encrypted ops alerts for a DevOps FlutterFlow dashboard
A FlutterFlow DevOps dashboard sends on-call engineers a Signal message when a critical alert fires — preferred over email for faster delivery and end-to-end encryption of infrastructure details. The bridge runs on the same VPS as other internal tools.
When a critical alert is triggered in the dashboard, send a Signal message to the on-call engineer's phone number with the alert details.
Copy this prompt to try it in FlutterFlow
Troubleshooting
The signal-cli-rest-api container starts but message sends return 'User is not registered' or 'Account is not registered'
Cause: The phone number registration was not completed successfully, or the session data was lost because the Docker volume was not mounted correctly.
Solution: Check if the data directory on the VPS contains Signal session files (`ls /path/to/signal-data`). If empty, the registration didn't persist. Restart the container with the correct volume mount (`-v /absolute/path/to/signal-data:/home/.local/share/signal-cli`) and re-run the registration flow from Step 2. Always use absolute paths for the volume mount, not relative paths.
Send requests to the bridge return 'Rate limit reached' or messages stop delivering after a period
Cause: Signal has detected automated or high-volume sending from the bridge's registered number and has imposed rate limiting. Repeated violations can result in the number being banned.
Solution: Reduce the frequency of sends. Signal's rate limits are not publicly documented, but the bridge is designed for low-volume internal use — not broadcast messaging. Add delays between sends (a queue in your Cloud Function with rate limiting). If the number is rate-limited temporarily, the bridge will typically recover after 24 hours without excessive activity. If the number is permanently banned, you'll need to register a new phone number.
The Cloud Function can't reach the bridge — times out or returns 'connect ECONNREFUSED'
Cause: The VPS bridge is not reachable from the Cloud Function's network. The bridge may be down, the VPS firewall is blocking connections, or the bridge URL in Firebase config is incorrect.
Solution: First, SSH to the VPS and check `docker ps` — confirm the signal-api container is running. Check `curl http://localhost:8080/v1/about` returns a response. If the bridge is running locally but the Cloud Function can't reach it, the issue is network access. You need either a VPN connection between Google Cloud Functions and your VPS, a public IP with the port open (protected by the x-api-key check in the Cloud Function), or use a private network (Tailscale, WireGuard) to connect them securely.
FlutterFlow API Call returns an error even though the Cloud Function is running
Cause: The x-api-key header value in FlutterFlow doesn't match the API_SECRET in Firebase Functions config, or the Cloud Function wasn't redeployed after updating the config.
Solution: Run `firebase functions:config:get` to see the current config values. Confirm the `signal.api_secret` matches what you're sending from FlutterFlow. If you updated the config, redeploy the function: `firebase deploy --only functions:sendSignalMessage`. Config changes don't take effect until the function is redeployed.
Best practices
- Never expose the bridge URL directly in FlutterFlow API Call headers — anyone who decompiles the app can use it to send Signal messages as your number; always proxy through a Cloud Function with an auth check
- Use a dedicated phone number for the bridge (not your personal number) — if it gets rate-limited or banned by Signal, you don't lose your personal account
- Keep send volumes low and targeted — Signal is designed for human-to-human communication, not broadcast; high-volume automated sending risks the number being banned without appeal
- Mount a persistent Docker volume for the signal-cli session data — if the container restarts without the volume, you lose the registration and must re-register the phone number
- Monitor the bridge container with an uptime checker on the /v1/about endpoint — a crashed container silently drops sends with no FlutterFlow-visible error until the Cloud Function times out
- Subscribe to the signal-cli GitHub releases page — Signal protocol updates are unannounced and break the bridge until signal-cli ships a compatible update; you need to know when to update the Docker image
- Document the self-hosted dependency clearly in your app's architecture notes — this integration has no SLA; a signal-cli update delay can create an extended outage that you cannot fix by calling a vendor
Alternatives
Telegram offers an official, free Bot API with a simple token — no self-hosting, no Docker, instant setup; choose Telegram when Signal's encryption isn't a hard requirement.
WhatsApp has an official Cloud API (via Meta) with proper developer support and no self-hosting — better reach than Signal for most user bases, with end-to-end encryption included.
Vonage SMS offers reliable official-API delivery to any mobile number — choose it when you need broad reach without Signal specifically and an official vendor relationship.
Frequently asked questions
Does Signal allow automated messaging through the signal-cli-rest-api bridge?
Signal's Terms of Service prohibit using their service for bulk automated messaging or spam. Low-volume, targeted use (internal team alerts, individual user notifications at reasonable frequency) is typically tolerated in practice, but Signal can rate-limit or ban numbers without warning. The signal-cli-rest-api is an unofficial, community-maintained tool — not endorsed or supported by Signal. Use it for internal tooling, not as a marketing channel.
Can I use my existing Signal number instead of registering a new one?
Yes — use the `/v1/qrcodelink` endpoint to link the bridge as a 'linked device' to your existing Signal account, similar to Signal Desktop. However, this ties the bridge to your personal number, meaning all messages sent by the bridge appear as coming from you personally. For any production use, register a separate dedicated number so your personal account is unaffected if the bridge is rate-limited or banned.
What happens if signal-cli is not updated when Signal changes their protocol?
The bridge stops working — sends return errors and registration may fail. signal-cli is maintained by volunteers who typically release updates within days to weeks of a Signal protocol change, but there's no guaranteed timeline. You'll see errors in your VPS logs and receive 500 responses from the bridge. Monitor the signal-cli GitHub issues page for known breakages and the fix status. During this window, there's no workaround — Signal's end-to-end encryption makes protocol reverse-engineering genuinely difficult.
Can the Signal bridge send messages to Signal groups, not just individuals?
Yes — the signal-cli-rest-api bridge supports sending to Signal groups. First, you need the group's internal ID (a base64 string), which you get from the `/v1/groups/{PHONE_NUMBER}` endpoint after the bridge has joined the group. Then include `"groupId": "GROUP_ID_BASE64"` in the send body instead of (or in addition to) `recipients`. Joining groups requires the bridge's registered number to be added to the group by an existing member.
Is there any way to receive Signal messages in real-time instead of polling?
Yes — run the bridge container with `MODE=native` instead of `MODE=normal`. In native mode, the bridge exposes a WebSocket connection at `/v1/receive/{PHONE_NUMBER}/ws` that pushes inbound messages in real-time. Your Cloud Function can listen on this WebSocket (or you can use a persistent Node.js process), write each inbound message to Firestore, and FlutterFlow binds a Firestore real-time stream to the chat screen. This eliminates polling and makes the experience feel like a live chat.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation