> ## Documentation Index
> Fetch the complete documentation index at: https://flow.engineer/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Messaging recipes

> Common 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

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:

1. **Open the stream first.** `GET /v0/stream` without `after` starts at the moment it opens: anything earlier is not replayed. Once it is open, nothing later is missed.
2. **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). Skip `simulated: true` ones. Only when there is none, show the join link and wait for `conversation.started`, then for that conversation's first `message.received`: the join itself (`/start` from the link, or the join message without its code), so it is not read as an answer.
3. **Confirm the send.** The `202` only means queued: wait for `message.sent` or `message.failed` with that message's `id` (Telegram never sends `message.delivered`).
4. **Wait for the reply with a timeout.** The next `message.received` in that conversation is the answer; give up after a time you choose and tell the person.

```ts
// 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 `reconnect` frame. Reconnect with `after` set to the last event `id` you 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; `after` is the last event you handled; page while `has_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}/inbound` on a simulated conversation (`POST /v0/test/conversations`): free, and it arrives as the same `message.received`.

### Many Telegram bots (one per customer, brand or language)

* A person creates each bot in Telegram's @BotFather (`/newbot`, a name, a username ending in `bot`); an agent cannot. Ask them: "Create the bot in @BotFather and put its token in `.env` (or your secret store) as `TELEGRAM_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-Key` per 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_..."` on `POST /v0/messages`; replies go into the conversation and leave from its bot. Every event names its `conversation.sender`, so route by sender.

### Serverless (Vercel, Workers, Lambda): webhooks only

* Register `https://<your-app>/api/flow` once with `POST /v0/webhook_endpoints` and store the `whsec_...` secret as `FLOW_MESSAGING_WEBHOOK_SECRET`. Verify `Flow-Signature` on the raw body (in TypeScript `flow.webhooks.handler(...)` does it), dedupe on `event.id`, answer within 10 seconds.
* Fast answer: return `{"reply": ...}` in the webhook answer. Slow agent: answer `200 {}` at once, then reply with `POST /v0/conversations/{conversation_id}/basic` (or another kind's endpoint; on Vercel inside `waitUntil`). Keep conversation state in your database, keyed by `conversation.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/flow` reads the stream and `POST`s each event to your handler, signed like a real delivery with `FLOW_MESSAGING_WEBHOOK_SECRET`.

### 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

* 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 answers `422 file_blocked` and is not stored; if the scan cannot run just then, the upload answers a retryable `500 api_error` with `Retry-After` (retry with the same `Idempotency-Key`). Then send it with `POST /v0/conversations/{conversation_id}/basic` and `{"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 by `url` instead of `file_id`; the channel fetches it (Telegram: PDF and ZIP, up to 20 MB).
* A voice note arrives as `message.received` with `{"kind":"voice","file_id":"file_...","url":"...","duration_seconds":4}`. Download it with `GET /v0/files/{file_id}` (your API key; it answers `302` to a short-lived signed URL, so follow redirects). Send one with `POST /v0/conversations/{conversation_id}/voice` and `{"file_id":"file_..."}` (OGG/Opus on Telegram).

### 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

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

* There is no bulk endpoint: one `POST /v0/messages` per person, each with its own `Idempotency-Key` (for example `campaign-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_limited` with `Retry-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 answers `429 new_contact_limit` with `retry_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_throttled` on 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.sent` or `message.failed`: count those, not the `202`s.

### 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

1. Make a new key: dashboard ([https://api.flow.engineer/admin/keys](https://api.flow.engineer/admin/keys), `?mode=live` for live keys) > **Create live key** (or test). Put it where the old one was and deploy.
2. 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`).
3. Read `GET /v0/events` for 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

1. Sign in to keep the app (`npx @flow-engineer/messaging login`): the app, its data and webhooks stay.
2. Dashboard > Live > **Create live key**; keep it apart from the test key (`FLOW_MESSAGING_LIVE_KEY`).
3. 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).
4. 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

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

* 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 `202` means stored and queued; every send then ends `message.sent` or `message.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 with `channel_timeout_unknown_outcome` (it may have arrived: check before sending again).
* Requests: `Idempotency-Key` on every POST makes your own retries safe for 24 hours; it cannot undo the channel-side resend above.

### Sent but not received: a checklist

1. **Did the send end?** `GET /v0/messages/{message_id}`, or `GET /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`: read `error.type`, `error.channel_code` and `error.hint`.
2. **Common `message.failed` codes**: `permission` with `sandbox_contact_left` (the person joined another app on the shared sandbox bot while the message waited: they send your app's `join_code` message again); `channel_error` with `channel_code` `403` on 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).
3. **Sandbox (test key)**: the person joined *your* app: opened the sandbox bot's `address.link` and tapped Start, or sent the sender's `join_code` message. The join is also the conversation's first `message.received` (`/start`, or the join message without its code), right after `conversation.started`. One person belongs to one app per sandbox bot, so joining another app moves them (sends answer `403 permission`). A key made without an account expires after 7 days and its contact is removed. Allowance refusals are `403 permission` (`sandbox_allowance_used`, `sandbox_contact_limit`).
4. **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_found` with a live one.
5. **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.
6. **Still stuck?** Write to the Flow team at [samyak@flow.engineer](mailto:samyak@flow.engineer) or [prabal@flow.engineer](mailto:prabal@flow.engineer) with the message ID (`msg_...`), conversation ID (`conv_...`), the call's `request_id`, test or live, and the time. Never your API key.

### 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

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

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](mailto:samyak@flow.engineer) or [prabal@flow.engineer](mailto: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](https://flow.engineer/docs/unified-messaging-api/pricing.md). 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.                                                                                                                                     |
