Messaging recipes
Recipes · wait for reply Telegram bot API · ask and wait for user reply · Telegram bot broadcast limits · GDPR delete contact messaging API · serverless webhook Telegram bot · test Telegram bot in CICommon messaging workflows, from waiting for a reply to recovering a failed send.
The same recipes are in the agent quickstart (section 14), which coding agents read at https://api.flow.engineer/docs/quickstart.md.
Ask, wait for the reply, continue
Section titled “Ask, wait for the reply, continue”A program that asks a person one thing on Telegram, waits for the answer and goes on (a CLI, a script, a test). Four rules make it reliable:
- Open the stream first.
GET /v0/streamwithoutafterstarts at the moment it opens: anything earlier is not replayed. Once it is open, nothing later is missed. - Then look for a join that already happened. A person who joined before the program started is already a conversation:
GET /v0/conversations?channel=telegram(newest first). Skipsimulated: trueones. Only when there is none, show the join link and wait forconversation.started, then for that conversation’s firstmessage.received: the join itself (/startfrom the link, or the join message without its code), so it is not read as an answer. - Confirm the send. The
202only means queued: wait formessage.sentormessage.failedwith that message’sid(Telegram never sendsmessage.delivered). - Wait for the reply with a timeout. The next
message.receivedin that conversation is the answer; give up after a time you choose and tell the person.
// ask.mts: Node 22+ (ESM, top-level await), no dependencies. FLOW_MESSAGING_KEY is a test key (or live, with your own bot).const BASE = "https://api.flow.engineer";const KEY = process.env.FLOW_MESSAGING_KEY!;const H = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };type Init = { method?: string; headers?: Record<string, string>; body?: string };const api = async (path: string, init: Init = {}) => { const res = await fetch(BASE + path, { ...init, headers: { ...H, ...init.headers } }); const body = await res.json(); if (!res.ok) throw new Error(`${body.error.type}: ${body.error.hint}`); // hint says what to change return body;};
// 1. Stream first. Events wait in `inbox` until something takes them.const inbox: any[] = [];let wake = () => {};const ws = new WebSocket(`wss://api.flow.engineer/v0/stream?type=conversation.started` + `&type=message.received&type=message.sent&type=message.failed`, ["flow", "flow.key." + KEY]);ws.onmessage = (m) => { const f = JSON.parse(String(m.data)); if (f.type === "event") { inbox.push(f.event); wake(); } else if (f.type === "error") console.error(f.error.type, f.error.hint); // then it closes (4401: bad key)};await new Promise((ok, fail) => { ws.onopen = ok; ws.onerror = fail; });
function next(match: (ev: any) => boolean, ms: number): Promise<any> { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`no event within ${ms / 1000}s`)), ms); wake = () => { const i = inbox.findIndex(match); if (i >= 0) { clearTimeout(timer); resolve(inbox.splice(i, 1)[0]); } }; wake(); });}
// 2. A join from before the program started is already a conversation.const convs = await api("/v0/conversations?channel=telegram&limit=20");let conv: string | undefined = convs.data.find((c: any) => !c.simulated)?.id;if (!conv) { const bots = await api("/v0/senders?channel=telegram"); console.log(`Open ${bots.data[0].address.link} on your phone and tap Start.`); conv = (await next((e) => e.type === "conversation.started" && !e.conversation.simulated, 10 * 60_000)).conversation.id; // The join itself arrives next, as their first message.received ("/start"): take it. await next((e) => e.type === "message.received" && e.conversation.id === conv, 60_000);}
// 3 and 4. Ask, confirm the send, wait for the answer.async function ask(text: string, timeoutMs = 5 * 60_000): Promise<string | null> { inbox.splice(0); // what the person wrote before the question is not its answer const msg = await api(`/v0/conversations/${conv}/basic`, { method: "POST", headers: { "Idempotency-Key": crypto.randomUUID() }, body: JSON.stringify({ text }), }); const outcome = await next((e) => e.data?.message?.id === msg.id && (e.type === "message.sent" || e.type === "message.failed"), 60_000); if (outcome.type === "message.failed") { const err = outcome.data.message.error; throw new Error(`send failed: ${err.type} ${err.channel_code ?? ""}: ${err.hint}`); } try { const ev = await next((e) => e.type === "message.received" && e.conversation.id === conv, timeoutMs); const c = ev.data.message.content; return c.kind === "basic" && c.text ? c.text : c.kind === "button_reply" ? c.label : `[${c.kind}]`; } catch { return null; // timed out: say so, or ask again later }}
const answer = await ask("Which city should I check the weather for?");console.log(answer ?? "No answer in time.");ws.close();- Long waits: the stream ends after 24 hours or on a server restart with a
reconnectframe. Reconnect withafterset to the last eventidyou saw: it replays what you missed, then goes live. - No WebSocket (serverless, cron): poll the event log instead,
GET /v0/events?conversation=conv_...&type=message.received&type=message.sent&type=message.failed&after=evt_...(oldest first;afteris the last event you handled; page whilehas_more). Or register a webhook and keep the question’s state in your store. - In tests without a phone, post the person’s answer with
POST /v0/test/conversations/{conversation_id}/inboundon a simulated conversation (POST /v0/test/conversations): free, and it arrives as the samemessage.received.
Many Telegram bots (one per customer, brand or language)
Section titled “Many Telegram bots (one per customer, brand or language)”- A person creates each bot in Telegram’s @BotFather (
/newbot, a name, a username ending inbot); an agent cannot. Ask them: “Create the bot in @BotFather and put its token in.env(or your secret store) asTELEGRAM_BOT_TOKEN_<NAME>; don’t paste it here.” Never ask for the token in chat. - With a live key,
POST /v0/senders {"channel":"telegram","telegram_bot_token":"..."}once per bot (Idempotency-Keyper bot). One bot is one sender; connecting it again updates that sender; connecting it to another of your apps moves it there. There is no limit on bots per app. - Send from a bot with
"sender": "snd_..."onPOST /v0/messages; replies go into the conversation and leave from its bot. Every event names itsconversation.sender, so route by sender.
Serverless (Vercel, Workers, Lambda): webhooks only
Section titled “Serverless (Vercel, Workers, Lambda): webhooks only”- Register
https://<your-app>/api/flowonce withPOST /v0/webhook_endpointsand store thewhsec_...secret asFLOW_MESSAGING_WEBHOOK_SECRET. VerifyFlow-Signatureon the raw body (in TypeScriptflow.webhooks.handler(...)does it), dedupe onevent.id, answer within 10 seconds. - Fast answer: return
{"reply": ...}in the webhook answer. Slow agent: answer200 {}at once, then reply withPOST /v0/conversations/{conversation_id}/basic(or another kind’s endpoint; on Vercel insidewaitUntil). Keep conversation state in your database, keyed byconversation.id. - Missed deliveries are retried for 3 days; to catch up, read
GET /v0/events?after=evt_...from a cron job. - Previews and local runs: Flow calls only public HTTPS URLs. Locally, no tunnel needed:
npx @flow-engineer/messaging listen --forward-to http://localhost:3000/api/flowreads the stream andPOSTs each event to your handler, signed like a real delivery withFLOW_MESSAGING_WEBHOOK_SECRET.
Add Flow to the IDE (MCP)
Section titled “Add Flow to the IDE (MCP)”The hosted MCP server is an optional development tool. Adding it is the project owner’s decision: show them the command (section 12 of the quickstart, or docs/mcp), never add it yourself. The app itself always uses the REST API.
Send a PDF, receive a voice note
Section titled “Send a PDF, receive a voice note”- Uploads (
POST /v0/files, multipart, up to 100 MB) take images, audio, video, plain text and documents (PDF, Office files, zip and other archives). Documents are malware-scanned before Flow stores them: one that fails answers422 file_blockedand is not stored; if the scan cannot run just then, the upload answers a retryable500 api_errorwithRetry-After(retry with the sameIdempotency-Key). Then send it withPOST /v0/conversations/{conversation_id}/basicand{"media":{"type":"document","file_id":"file_...","filename":"receipt.pdf"}}(Telegram takes documents up to 50 MB; SMS takes none). A PDF you already host can go byurlinstead offile_id; the channel fetches it (Telegram: PDF and ZIP, up to 20 MB). - A voice note arrives as
message.receivedwith{"kind":"voice","file_id":"file_...","url":"...","duration_seconds":4}. Download it withGET /v0/files/{file_id}(your API key; it answers302to a short-lived signed URL, so follow redirects). Send one withPOST /v0/conversations/{conversation_id}/voiceand{"file_id":"file_..."}(OGG/Opus on Telegram).
WhatsApp: a reminder the next day
Section titled “WhatsApp: a reminder the next day”Flow has no scheduled sends: schedule in your own app (cron, queue) and send then. Next day is usually outside WhatsApp’s 24-hour window, so only an approved template goes (POST /v0/templates on your WhatsApp sender with a live key, with a realistic sample for each placeholder in examples, which WhatsApp reviews it with; else 409 outside_window). Test it now on a simulated WhatsApp conversation: templates on the simulated sender are approved at once. Connect your own WhatsApp number with POST /v0/senders (quickstart section 11).
Move a live polling bot to Flow
Section titled “Move a live polling bot to Flow”Connect its token with a live key (POST /v0/senders). Flow sets the bot’s webhook, which ends getUpdates polling: stop the old poller first (Telegram answers its next getUpdates with 409 Conflict). Updates Telegram still holds are delivered to Flow, not dropped. Conversations start fresh in Flow (it has no history from before); people do not need to press Start again. To go back, DELETE /v0/senders/{sender_id} removes the webhook.
Broadcast to many people
Section titled “Broadcast to many people”- There is no bulk endpoint: one
POST /v0/messagesper person, each with its ownIdempotency-Key(for examplecampaign-7-ct_...), from a queue of your own. - Telegram: a bot can message only people who started it, so a broadcast goes to people who wrote to your bot. Each bot sends about 20 messages a second (burst 40); faster answers
429 rate_limitedwithRetry-After. The per-key request limit is 600 a minute on the free plan (RateLimit-*headers), so 5,000 messages take about 9 minutes. - New conversations per sender are budgeted: the sender’s
limits(new_contacts_per_day,new_contacts_per_hour) show today’s numbers; past them a start answers429 new_contact_limitwithretry_after: queue it, do not loop. iMessage lines start no cold conversations; WhatsApp needs a template and Meta’s tier caps new contacts a day; SMS needs consent. - The same first message to 20 new contacts within an hour throttles a dedicated sender for 24 hours (
429 sender_throttledon starts; replies still go). Personalise first messages; templates and media without a caption do not count. Limits and signals: quickstart section 10. - Every send still ends
message.sentormessage.failed: count those, not the202s.
Delete a person’s data (GDPR)
Section titled “Delete a person’s data (GDPR)”DELETE /v0/contacts/{contact_id} (204, repeat-safe) erases the contact’s name and address, all their message content and files, their events’ payloads, pending deliveries and queued sends at once, and emits contact.deleted. IDs and timestamps stay. Find the contact ID on their events (conversation.contact) or GET /v0/contacts; there is no lookup by phone number or username. Content is otherwise kept 30 days.
Erasure removes Flow’s copy only: messages already delivered stay on the person’s phone. To remove those too, unsend them first (DELETE /v0/messages/{message_id}: Telegram within 48 hours, iMessage within 2 minutes; WhatsApp and SMS cannot), wait until each shows status unsent, then erase. After erasure nothing can be unsent.
A leaked API key
Section titled “A leaked API key”- Make a new key: dashboard (https://api.flow.engineer/admin/keys,
?mode=livefor live keys) > Create live key (or test). Put it where the old one was and deploy. - Revoke the old one: Keys > Revoke on the key ending in its last four characters. It stops working at once; open streams close within a minute (
4401). - Read
GET /v0/eventsfor sends you did not make. A leaked webhook secret:POST /v0/webhook_endpoints/{webhook_endpoint_id}/rotate_secret. A leaked bot token: revoke it in @BotFather and connect the new one (POST /v0/senders, same sender).
An app made without an account has no dashboard: get a new sandbox key, or sign in first.
Prototype to production
Section titled “Prototype to production”- Sign in to keep the app (
npx @flow-engineer/messaging login): the app, its data and webhooks stay. - Dashboard > Live > Create live key; keep it apart from the test key (
FLOW_MESSAGING_LIVE_KEY). - Connect your own Telegram bot (
POST /v0/senders, quickstart section 7) or your own WhatsApp number (POST /v0/senders, quickstart section 11); dedicated iMessage lines and SMS numbers are connected by the Flow team. Flow’s shared iMessage line needs no live key: it comes with the free trial a sign-in starts (25 people per channel). - Register the webhook again with the live key (endpoints belong to one mode). Nothing else in the code changes.
Multi-tenant SaaS: one bot per customer
Section titled “Multi-tenant SaaS: one bot per customer”One app holds many senders: connect each customer’s bot with your live key (POST /v0/senders) and keep the mapping customer to snd_... in your database. Events name the sender (conversation.sender) and the conversation, so route by those; replies leave from the conversation’s own bot. Apps cannot be created through the API or the dashboard today (each sign-in has one app; up to 10 sandbox apps can be claimed), so per-customer apps are not self-serve. A key belongs to one app and one mode.
Delivery guarantees
Section titled “Delivery guarantees”- Inbound: every message becomes an event, stored before Flow answers the channel, delivered at least once and in order per conversation (dedupe on
event.id). Webhooks retry for 3 days (5 s up to 12 h apart); the event log keeps everything for 30 days. - Order per person: kept per conversation (one person on one sender; the same person on two channels or senders is two conversations, unordered between them). Inbound follows the channel’s order (Telegram’s update order; WhatsApp’s timestamp, after a short hold for late webhooks). On iMessage the channel gives no order, so inbound goes in the order Flow receives it: two messages sent within about a second can arrive swapped. A webhook retry never reorders a conversation: its next event waits until the one before is delivered or marked failed.
- Outbound: a
202means stored and queued; every send then endsmessage.sentormessage.failed, never silently. One conversation sends one message at a time, in order. Retryable channel errors are retried by Flow in place, and later sends to that person wait; a failed send holds nothing back. When a send’s outcome is unknown (the channel took it but never answered), Flow resends it once, so a rare duplicate is possible on Telegram; if that is unknown too, the message fails withchannel_timeout_unknown_outcome(it may have arrived: check before sending again). - Requests:
Idempotency-Keyon every POST makes your own retries safe for 24 hours; it cannot undo the channel-side resend above.
Sent but not received: a checklist
Section titled “Sent but not received: a checklist”- Did the send end?
GET /v0/messages/{message_id}, orGET /v0/events?conversation=conv_...&type=message.sent&type=message.failed.queued: still waiting (an earlier message in the conversation, the sender’s pace, or a bot whose token Telegram rejected: reconnect it).sent: the channel took it (Telegram reports nothing after that).failed: readerror.type,error.channel_codeanderror.hint. - Common
message.failedcodes:permissionwithsandbox_contact_left(the person joined another app on the shared sandbox bot while the message waited: they send your app’sjoin_codemessage again);channel_errorwithchannel_code403on Telegram (the person blocked the bot, or never started it);outside_window(iMessage: they have not written recently; WhatsApp:window_closed, send a template;queued_too_long);channel_timeout_unknown_outcome(may have arrived). - Sandbox (test key): the person joined your app: opened the sandbox bot’s
address.linkand tapped Start, or sent the sender’sjoin_codemessage. The join is also the conversation’s firstmessage.received(/start, or the join message without its code), right afterconversation.started. One person belongs to one app per sandbox bot, so joining another app moves them (sends answer403 permission). A key made without an account expires after 7 days and its contact is removed. Allowance refusals are403 permission(sandbox_allowance_used,sandbox_contact_limit). - Your own bot (live key): the person pressed Start on that bot (a Telegram bot cannot write first), and you send with the live key: sandbox conversations belong to the test key and answer
404 not_foundwith a live one. - On the phone: the chat with the right bot (the sandbox bot and your own bot are different chats) on the Telegram account that joined.
- Still stuck? Write to the Flow team at samyak@flow.engineer or prabal@flow.engineer with the message ID (
msg_...), conversation ID (conv_...), the call’srequest_id, test or live, and the time. Never your API key.
Tests in CI, without a phone
Section titled “Tests in CI, without a phone”Use a test key and simulated conversations: POST /v0/test/conversations, post the contact’s turns to .../inbound, assert on your agent’s replies with .../transcript. Free, no phone, parallel-safe; at most 500 simulated conversations per app (reuse them) and 120 requests a minute to /v0/test/. Keep the key in your CI’s secret store. In tests, wait for events (stream or GET /v0/events), never fixed sleeps.
Give a cofounder access
Section titled “Give a cofounder access”Not built yet: an account has one member (whoever signed in), and there are no invitations. Today a cofounder can sign in to get their own account, or you can hand them an API key from your Keys page (it acts as the app, not as them; revoke it when no longer needed).
What the Flow team arranges, and how to ask
Section titled “What the Flow team arranges, and how to ask”Some things are set up by the Flow team, not through the API: SMS numbers, dedicated iMessage lines, writing first on iMessage, pricing and support. Flow’s shared iMessage line is self-serve: it comes with the free trial a sign-in at flow.engineer (or npx @flow-engineer/flow setup) starts. You connect your own WhatsApp number yourself (POST /v0/senders with "channel": "whatsapp" and a live key, or the dashboard’s Senders page; quickstart section 11), and the Flow team can still help with it. Write to the Flow team at samyak@flow.engineer or prabal@flow.engineer. For each, what to do today, what to build meanwhile, and what to send.
Never send secrets in chat or email, and never paste them to a coding agent or commit them: API keys, bot tokens, provider auth tokens, access tokens, app secrets. The Flow team tells you how to hand credentials over, and Flow stores them encrypted. A coding agent that finds credentials in .env or a secret manager leaves them where they are and tells the person this.
Build meanwhile on Telegram or simulated conversations. Your code does not change per channel: you reply into conversations and switch on content.kind and error.type; where a kind is not on a channel, the 422 hint says what to send instead. Simulated conversations (POST /v0/test/conversations with "channel": "sms", "whatsapp" or "imessage") keep each channel’s rules (SMS consent and STOP, WhatsApp’s 24-hour window and templates, iMessage replies only), are free and need no number. When the number or line is connected, switch to a live key; nothing else changes.
| You want | Today | Send the Flow team |
|---|---|---|
| US SMS (in pilot), including on your own SMS provider account | POST /v0/senders with "channel": "sms" answers 501; the team connects the number. Build on simulated SMS. | Your app ID (app_..., from GET /v0/app with your live key) and account ID (account there); the number in E.164 (+1 and ten digits), 10DLC or toll-free, and its registration status; whether it is on your own provider account (which provider) or Flow should provide one; what you send and how people opt in. Not the auth token: the team arranges that. |
| Help with WhatsApp on your own WhatsApp Business Account (credentials in your own secret manager is fine) | Self-serve: POST /v0/senders with "channel": "whatsapp" and a live key connects the number at once, and its answer’s setup says what to set in your Meta app. Ask the team if you want help with it. Build on simulated WhatsApp (templates are approved at once there). | Your app and account IDs; the phone number ID and WhatsApp Business Account ID (in WhatsApp Manager); whether the number is on your own Meta app. Not the access token or the app secret: send those only to POST /v0/senders, or ask the team how they reach Flow. Meta bills you directly, so the account needs a payment method in WhatsApp Manager. |
| iMessage | Flow’s shared line is self-serve: sign in at flow.engineer (or run npx @flow-engineer/flow setup) and every app of the account sees it, for up to 25 people per channel in total; it replies only. Dedicated lines are connected by the team; they reply only (the person writes first) unless the line is enabled to start conversations, which is on request. Build on the shared line, simulated iMessage or Telegram. | Your app and account IDs; what the agent does; expected people per day; whether you need to write first (and why the person expects it). |
| Pricing and billing | Confirm current pricing and billing terms for your account before going live. See Pricing. Your own channel provider may bill you separately. | Expected volume per channel and month, if you want to talk about it. |
| Support: “sent but not delivered” | First read the message: GET /v0/messages/{message_id} and its events (GET /v0/events?conversation=conv_...). Every send ends message.sent or message.failed (with error.type, channel_code and hint); Telegram never reports message.delivered, so sent is the last status there. | The message ID (msg_...), conversation ID (conv_...), the request_id of the call (also the Flow-Request-Id header), test or live, the time, and what you expected. Not your API key, and no message content unless asked. |
Thanks, we read every comment.