Connect FlutterFlow to Zulip by creating a REST API Group that POSTs form-encoded messages to `POST /api/v1/messages` with a bot's email and API key in an HTTP Basic auth header (base64-encoded). Because Zulip uses stream-AND-topic addressing and many teams self-host with custom base URLs, both the API key and the base URL need special handling — proxy the API key through a Firebase Cloud Function.
| Fact | Value |
|---|---|
| Tool | Zulip |
| Category | Communication |
| Method | FlutterFlow API Call |
| Difficulty | Intermediate |
| Time required | 45 minutes |
| Last updated | July 2026 |
Send Messages to Zulip Streams from Your FlutterFlow App
Zulip differs from most messaging platforms in one critical way: every message belongs to both a stream (like a Slack channel) and a topic (a subject line within that stream). When you call `POST /api/v1/messages`, you must supply `type=stream`, `to=<stream-name>`, `topic=<topic-subject>`, and `content=<message-text>` as form parameters. Missing the `topic` field causes the API to reject the message entirely — this trips up developers who copy a Slack tutorial and assume channels and streams are equivalent. Unlike Slack's webhook or Bearer token model, Zulip uses HTTP Basic authentication: the username is the bot's email address and the password is its API key, base64-encoded together as `email:apikey`.
Another distinctive feature is the per-organization base URL. Zulip Cloud organizations live at `https://{organization-name}.zulipchat.com` — and self-hosted deployments use entirely custom domains. This means the base URL cannot be hardcoded into your FlutterFlow API Group; it must be a variable you supply at runtime. For apps supporting multiple Zulip organizations or configurable deployments, this is built into the design from the start. Zulip Cloud offers a Free plan (check current limits on zulipchat.com) and a Standard paid plan at approximately $6.67/user/month; self-hosted is open-source and free.
The security constraint for FlutterFlow is the same as any other tool: the bot API key is a secret that must not appear in compiled Dart. FlutterFlow's API Call headers ship inside the app binary. Route the Zulip call through a Firebase Cloud Function that holds the bot API key in its environment variables, accepting the stream, topic, and content from FlutterFlow and making the Zulip API request server-side. Also note that Zulip's real-time event system (register/longpoll) is impractical from a FlutterFlow client; for showing incoming messages in the app, poll `GET /api/v1/messages` on an interval instead.
Integration method
FlutterFlow calls a Firebase Cloud Function that holds your Zulip bot API key and proxies the request to your Zulip organization's REST API. The API Group base URL must be a variable because every Zulip organization has a different host (e.g., `https://your-org.zulipchat.com/api/v1/` for cloud, or a custom domain for self-hosted). The messages endpoint uses `application/x-www-form-urlencoded` body encoding — not JSON — which is the most common source of broken integrations when following Slack tutorials.
Prerequisites
- A Zulip organization (cloud at zulipchat.com or self-hosted) where you can create bots
- Access to Zulip Settings to create a bot and copy its email address and API key
- A Firebase project with Blaze plan enabled for outbound HTTP Cloud Functions
- A FlutterFlow project with API Calls panel available
- The base URL of your Zulip organization (e.g., `https://your-org.zulipchat.com` or your self-hosted domain)
Step-by-step guide
Create a Zulip bot and collect its credentials
Log in to your Zulip organization and open the Settings page. On Zulip Cloud, click your avatar in the top right corner, then select 'Personal settings'. On the left sidebar, click 'Bots'. Click 'Add a new bot'. In the dialog, choose Bot type 'Generic bot' (suitable for sending messages). Give it a name like 'FlutterFlow Bot' and an email address (e.g., `flutterflow-bot@your-org.zulipchat.com` — Zulip may auto-generate the email). Click 'Create bot'. After creation, the bot appears in your bot list. Click the kebab menu (three dots) next to the bot and select 'Edit'. You'll see the bot's email address and its API key. Copy both values — you will need them in the next step. The API key is a long random string (not a Bearer token format like Slack's `xoxb-` prefix). Also note down your organization's base URL at the top of the browser — this is `https://your-org.zulipchat.com` for Zulip Cloud, or your custom domain for self-hosted. Keep these credentials out of FlutterFlow until you deploy the Cloud Function.
Pro tip: The bot's email domain must match your Zulip organization domain. On self-hosted Zulip, the bot email format is configurable by the server admin. Use the exact email shown in the bot settings panel.
Expected result: You have the bot email, API key, and the organization base URL copied and ready. You have NOT entered them into FlutterFlow yet.
Deploy a Firebase Cloud Function to proxy Zulip API calls
The Zulip bot API key must not live in FlutterFlow because it gives send access to your Zulip organization and will ship in the compiled app binary. Deploy a Firebase Cloud Function that holds the API key in its environment variables and formats the HTTP Basic auth header and the form-encoded body. In your Firebase project, set up Cloud Functions (Blaze plan required for outbound HTTP). In your `index.js` file, create an HTTPS function. The function reads `ZULIP_BOT_EMAIL` and `ZULIP_API_KEY` from `process.env` (set them as environment variables in the Firebase Console under the function's settings, or with `firebase functions:config:set`). It also reads the `zulip_base_url`, `stream`, `topic`, and `content` from the incoming POST body. The function builds the Basic auth header by base64-encoding `email:apikey` and sends a `POST` to `{zulip_base_url}/api/v1/messages` with `Content-Type: application/x-www-form-urlencoded`. The body must be URL-encoded form data with four fields: `type=stream`, `to=<stream>`, `topic=<topic>`, `content=<content>`. This form-encoded requirement is critical — Zulip's messages endpoint does NOT accept JSON and will return a 400 or silent failure if you send JSON. Deploy the function and copy its HTTPS URL.
1// functions/index.js (Firebase Cloud Function — Node.js 18)2const functions = require('firebase-functions');3const fetch = require('node-fetch');45exports.sendZulipMessage = functions.https.onRequest(async (req, res) => {6 res.set('Access-Control-Allow-Origin', '*');7 res.set('Access-Control-Allow-Headers', 'Content-Type');8 if (req.method === 'OPTIONS') { res.status(204).send(''); return; }910 const { zulip_base_url, stream, topic, content } = req.body;11 if (!stream || !topic || !content) {12 res.status(400).json({ result: 'error', msg: 'Missing stream, topic, or content' });13 return;14 }1516 const email = process.env.ZULIP_BOT_EMAIL; // set in Firebase env vars17 const apiKey = process.env.ZULIP_API_KEY; // set in Firebase env vars18 const baseUrl = zulip_base_url || process.env.ZULIP_BASE_URL; // org URL1920 // HTTP Basic auth = base64(email:apikey)21 const authHeader = 'Basic ' + Buffer.from(`${email}:${apiKey}`).toString('base64');2223 // Zulip messages endpoint REQUIRES application/x-www-form-urlencoded, NOT JSON24 const body = new URLSearchParams({25 type: 'stream',26 to: stream,27 topic: topic,28 content: content29 });3031 const response = await fetch(`${baseUrl}/api/v1/messages`, {32 method: 'POST',33 headers: {34 'Authorization': authHeader,35 'Content-Type': 'application/x-www-form-urlencoded'36 },37 body: body.toString()38 });3940 const data = await response.json();41 res.status(200).json(data); // Zulip returns { result: 'success', id: ... } or { result: 'error', msg: ... }42});Pro tip: Set `ZULIP_BASE_URL` as a default in the environment variables if all your users are on the same organization. For multi-org apps, pass the base URL from FlutterFlow as a variable.
Expected result: The Cloud Function is deployed and you can POST to it with a JSON body containing `stream`, `topic`, and `content` and receive `{ result: 'success' }` back.
Create a Zulip API Group in FlutterFlow
Open your FlutterFlow project. In the left navigation panel, click API Calls. Click the + Add button and choose Create API Group. Name it 'Zulip Proxy'. In the Base URL field, enter your Firebase Cloud Function's base HTTPS URL — for example `https://us-central1-your-project.cloudfunctions.net`. No authentication headers are needed at the API Group level because the Cloud Function handles Zulip auth internally. Click Save. Inside the Zulip Proxy group, click + Add → Create API Call. Name it 'Send Message'. Set the Method to POST and the Endpoint to `/sendZulipMessage`. Go to the Body tab and set the Body Type to JSON (you are talking to your Cloud Function here with JSON, not directly to Zulip). Add body fields: `stream` mapped to a Variable, `topic` mapped to a Variable, `content` mapped to a Variable, and optionally `zulip_base_url` mapped to a Variable (for multi-org support). In the Variables tab, create three or four String variables: `stream`, `topic`, `content`, and optionally `zulip_base_url`. Go to the Response & Test tab. In Test Values, enter a real stream name (e.g., `general`), a topic (e.g., `FlutterFlow Test`), and a content string. Run the test. You should see a `{ "result": "success", "id": 12345 }` response. If you see `{ "result": "error", "msg": "Stream 'general' does not exist" }`, verify the stream name — Zulip stream names are case-sensitive.
1// API Call configuration reference2{3 "method": "POST",4 "base_url": "https://us-central1-your-project.cloudfunctions.net",5 "endpoint": "/sendZulipMessage",6 "body_type": "JSON",7 "body": {8 "stream": "{{ stream }}",9 "topic": "{{ topic }}",10 "content": "{{ content }}",11 "zulip_base_url": "{{ zulip_base_url }}"12 },13 "variables": [14 { "name": "stream", "type": "String" },15 { "name": "topic", "type": "String" },16 { "name": "content", "type": "String" },17 { "name": "zulip_base_url", "type": "String" }18 ]19}Pro tip: Zulip stream names are case-sensitive and must match exactly. If your organization uses stream names like 'General' (capitalized), make sure the variable value matches.
Expected result: The API Call test returns `{ "result": "success" }` and the test message appears in the specified Zulip stream under the correct topic.
Wire the API Call to a widget action and parse the response
Go to the screen in FlutterFlow where users will compose and send a Zulip message. Create a layout with at least a Text Field for the message content and a Button labeled 'Post to Stream'. You may also add a Text Field or Dropdown for stream name and a Text Field for the topic, or hardcode those values in the action if they never change. Click the Button widget and open the Actions panel. Click + Add Action → Integrations → API Call. Select the 'Zulip Proxy' group and the 'Send Message' call. In the variable bindings: set `stream` to your stream Text Field's value or a hardcoded string; set `topic` to your topic Text Field's value or a hardcoded string; set `content` to the message Text Field's value; if using multi-org, set `zulip_base_url` from an App State variable. After the API Call action, go to the Response & Test tab and click Generate JSON Paths. You'll see `$.result` (String) and `$.id` (Integer). In the Action Flow, add a Conditional Action checking `$.result == 'success'`. If true, show a SnackBar saying 'Message posted!', clear the Text Field, and optionally navigate away. If false, read `$.msg` and display it in an error widget. Unlike Slack, Zulip correctly uses non-200 status codes for auth failures (401) and bad requests (400) — you can also check the HTTP status code from the API Call response in the conditional.
Pro tip: Add a loading indicator between the button tap and the API Call completion. Use a Boolean App State variable `isPosting` — set true before the call, false after the conditional — and bind it to the button's disabled state.
Expected result: Tapping the Post button sends the Zulip message, shows a success SnackBar, and clears the input fields. An intentionally wrong stream name shows an error message from the `$.msg` path.
Add a read-only message list by polling GET /api/v1/messages
If you want to show Zulip messages inside the FlutterFlow app (not just send them), you can poll the `GET /api/v1/messages` endpoint through a second Cloud Function. Add a new function `getZulipMessages` that accepts a `stream`, `topic`, and optional `num_before`/`num_after` parameters. In the function, build a narrow filter in Zulip's format — `narrow=[{"operator":"stream","operand":"general"},{"operator":"topic","operand":"FlutterFlow Test"}]` — and pass it as a query parameter to `GET /api/v1/messages?anchor=newest&num_before=20&narrow=<encoded>`. The function returns the `messages` array from the response. In FlutterFlow, create a second API Call in the Zulip Proxy group named 'Get Messages', pointing to this function. Set up a Timer widget or a page load trigger to call Get Messages on an interval (e.g., every 30 seconds). Parse the response using the JSON Path `$.messages` and bind the array to a ListView with a Dynamic Widget displaying each message's `sender_full_name` and `content`. Zulip's real-time event queue (the register/poll approach) is too stateful to manage from a FlutterFlow client — polling is the practical solution. If you'd rather skip the custom Cloud Function setup, RapidDev's team builds FlutterFlow integrations like this every week — free scoping call at rapidevelopers.com/contact.
1// Narrow filter example for Cloud Function (URL-encode before sending)2const narrow = JSON.stringify([3 { operator: 'stream', operand: stream },4 { operator: 'topic', operand: topic }5]);6const url = `${baseUrl}/api/v1/messages?anchor=newest&num_before=20&num_after=0&narrow=${encodeURIComponent(narrow)}`;7const response = await fetch(url, {8 headers: { 'Authorization': authHeader }9});10const data = await response.json();11// Returns: { result: 'success', messages: [...], found_newest: true }Pro tip: For self-hosted Zulip behind a firewall: the Cloud Function can reach the server only if it has outbound network access to that host. Zulip's Test tab in FlutterFlow may succeed via a public proxy but the built app and the Cloud Function need a resolvable, publicly reachable host.
Expected result: A FlutterFlow screen shows a list of recent Zulip messages that refreshes every 30 seconds. New messages posted from Zulip clients appear in the app within one polling interval.
Common use cases
Community app that lets members post updates to a Zulip stream
Build a FlutterFlow mobile app for a Zulip-based developer community where users can compose and send messages to specific streams and topics directly from the app. The app displays a stream picker and a topic text field alongside the message composer. On submit, it calls the Cloud Function proxy which formats the form-encoded request and posts to `/api/v1/messages`. Members see the message appear in Zulip seconds later.
Build a 'Post to Stream' screen with a dropdown for stream name, a text field for topic, and a multi-line message field. On Submit, call the Zulip proxy function and show a success snackbar.
Copy this prompt to try it in FlutterFlow
Internal operations app that posts status updates to a self-hosted Zulip instance
A FlutterFlow app for an engineering team running a self-hosted Zulip server on their own domain. The app's settings screen includes a base URL field where admins enter their organization's Zulip host (e.g., `https://zulip.company.internal`). Status updates from field workers are posted to a `#field-ops` stream under a daily topic like `2026-07-09`. The Cloud Function proxy handles the per-org base URL and the Basic auth header.
Create a daily status update form where selecting the date fills the topic field. On Submit, post the update to the 'field-ops' stream on our self-hosted Zulip using the organization URL from app settings.
Copy this prompt to try it in FlutterFlow
Alert dashboard that shows recent Zulip messages in a FlutterFlow ListView
A FlutterFlow admin screen that polls `GET /api/v1/messages` with a narrow filter targeting a specific stream and topic. The response is parsed with JSON Paths and bound to a ListView via a Backend Query that refreshes every 30 seconds. This gives FlutterFlow users a read-only view of a Zulip stream inside the mobile app — useful for monitoring `#incidents` or `#alerts` streams without leaving the app.
Build a read-only message list screen that polls the Zulip API every 30 seconds for messages in the 'incidents' stream, displaying sender name and message content in a card list.
Copy this prompt to try it in FlutterFlow
Troubleshooting
400 Bad Request or silent failure when posting a message — the API Call returns an error about missing fields
Cause: Zulip's `POST /api/v1/messages` endpoint requires `type`, `to`, `topic`, and `content` as `application/x-www-form-urlencoded` form fields — NOT a JSON body. Sending a JSON body causes a 400 or unexpected parsing error. This is the most common mistake when adapting Slack tutorials to Zulip.
Solution: In the Cloud Function, use `URLSearchParams` to build the body and set `Content-Type: application/x-www-form-urlencoded`. Never use `JSON.stringify` for the Zulip messages endpoint. The Cloud Function code example in Step 2 above shows the correct pattern.
1// Correct: form-encoded2const body = new URLSearchParams({ type: 'stream', to: stream, topic: topic, content: content });3fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ... }, body: body.toString() });45// Wrong: JSON body (causes 400 on Zulip messages endpoint)6fetch(url, { method: 'POST', body: JSON.stringify({ type: 'stream', to: stream, topic: topic, content: content }) });401 Unauthorized even though the bot email and API key look correct
Cause: The HTTP Basic auth header must be `Basic <base64(email:apikey)>` — where the colon separates email from key. A common mistake is base64-encoding only the key, or using the wrong separator. Another cause is that the bot was created but not yet subscribed to the target stream.
Solution: In the Cloud Function, verify the base64 encoding: `Buffer.from('bot-email@org.zulipchat.com:apikey123').toString('base64')`. The decoded string must have exactly one colon separating email and key. Also, subscribe the bot to the target stream: in Zulip, go to the stream settings and add the bot as a subscriber.
1// Verify base64 encoding in Node.js (run in Firebase functions shell)2const auth = Buffer.from('your-bot@your-org.zulipchat.com:your-api-key').toString('base64');3console.log('Basic ' + auth); // paste into curl to verifyThe integration works in FlutterFlow's Test tab but fails on a self-hosted Zulip instance
Cause: The FlutterFlow Test tab runs on FlutterFlow's servers, which can reach public Zulip Cloud URLs. A self-hosted Zulip instance behind a company firewall, VPN, or private network is not reachable from either FlutterFlow's servers or a real device on a public network.
Solution: Ensure your self-hosted Zulip instance is publicly accessible (with a valid TLS certificate) and that your Firebase Cloud Function's outbound network can reach it. For VPN-gated instances, the Cloud Function must be deployed in a network that can reach the VPN — usually not practical with standard Firebase Functions; consider Google Cloud VPC or a reverse proxy.
Message is rejected with `{ result: 'error', msg: 'Stream does not exist' }` despite the stream name looking correct
Cause: Zulip stream names are case-sensitive. A stream named 'General' will fail if you pass 'general'. Additionally, the bot must be subscribed to the stream — bots are not automatically subscribed to all streams.
Solution: Copy the stream name exactly from the Zulip web interface (check capitalization). Then open that stream's settings in Zulip and add the bot as a subscriber. After both steps, retry the API call.
Best practices
- Always proxy the Zulip bot API key through a Firebase Cloud Function — never put it in a FlutterFlow API Call header or Dart code, as it will ship in the compiled app and grant send/read access to your Zulip organization.
- Store the Zulip organization base URL as an App State variable or a configurable field rather than hardcoding it — this makes your app work with self-hosted Zulip instances and multiple organizations without code changes.
- Use `application/x-www-form-urlencoded` for the Zulip messages endpoint in your Cloud Function, not JSON — the API will silently fail or 400 if you send a JSON body.
- Always supply both `stream` AND `topic` when posting — Zulip requires both fields unlike Slack which only needs a channel. Missing `topic` causes the message to be rejected.
- Subscribe the bot to each stream it needs to post to before going to production — bots are not auto-subscribed to all streams.
- Use polling (`GET /api/v1/messages` on a Timer) to show incoming messages in FlutterFlow rather than attempting Zulip's real-time event queue, which is too stateful for a client app.
- Handle 429 Too Many Requests by reading the `Retry-After` header in the Cloud Function and queuing retries server-side, rather than letting FlutterFlow retry from the client.
Alternatives
Slack is the dominant enterprise messaging platform with richer third-party integrations and a larger ecosystem; choose Slack if your team already uses it or if you need extensive Block Kit interactive messages.
Discord is a better choice for community-facing apps with free Webhook posting and rich embeds; unlike Zulip it has no topic-threading model but is easier to set up for consumer audiences.
Microsoft Teams is the right alternative when your organization is in the Microsoft 365 ecosystem and needs enterprise compliance features that Zulip's Free/Standard plans don't cover.
Frequently asked questions
Do I need a self-hosted Zulip server to use the API, or does Zulip Cloud work?
Both work. Zulip Cloud organizations (at your-org.zulipchat.com) are fully accessible via the REST API — you just use that URL as your base URL. Self-hosted Zulip instances also expose the same REST API at your custom domain. The only requirement for self-hosted is that the server must be publicly reachable (your Firebase Cloud Function needs outbound HTTP access to it).
Why does Zulip use HTTP Basic auth instead of a Bearer token like most APIs?
Zulip's REST API uses HTTP Basic authentication as its primary scheme, where the bot's email serves as the username and its API key as the password. This is standard for Zulip's auth design and is consistent across all API endpoints. The `Authorization: Basic <base64>` header format encodes `email:apikey` in base64 — different from the `Authorization: Bearer <token>` pattern used by Slack, Stripe, and most modern REST APIs. Both are standard HTTP auth schemes; Zulip's choice reflects its open-source, self-hosted heritage.
Can I receive incoming Zulip messages in real time inside my FlutterFlow app?
Not natively. Zulip's real-time system uses a register/longpoll event queue that requires maintaining a session ID and long-polling for updates — too stateful to manage from a FlutterFlow client app. The practical solution is to poll `GET /api/v1/messages` with a narrow filter every 30-60 seconds using a Timer widget. For true real-time needs, a Firebase Cloud Function can subscribe to Zulip's event queue and write new messages to Firestore, which FlutterFlow reads via a Backend Query in real time.
Why must I specify both a stream AND a topic — can't I just post to a stream like a Slack channel?
Zulip's core design requires every message to live in a specific topic within a stream. Topics are Zulip's equivalent of email subject lines and are how the platform organizes threaded conversations. The API enforces this: the `topic` field is required for stream messages, and omitting it will result in a rejected request. If you don't have a meaningful topic, use a date string like `2026-07-09` or a generic label like `updates`.
Is there an Olark-style embed widget for Zulip chat — can I embed the Zulip UI in a WebView?
Zulip does not offer a drop-in embeddable chat widget like Olark or LivePerson. You can embed the full Zulip web app in a WebView pointing at your organization URL, but this loads the entire Zulip interface, not a lightweight chat bubble. For a more native experience, you need to build a custom chat UI in FlutterFlow and back it with Zulip's REST API calls (send + poll for messages) as described in this guide.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation