Delivery guarantees
Delivery guaranteesWhat Flow Framework promises about the messages an agent sends and receives: what a send means, ordering, duplicates, and what your own provider must do.
What Flow Framework promises about an agent’s messages on every channel, and what it does not. Each promise is checked against every channel adapter the framework ships by its conformance suite, before every release.
What a send means
Section titled “What a send means”A channel’s send resolves once the platform has accepted the message, and rejects when the platform refused it, with the platform’s reason. Accepted means the platform took it to deliver:
| Channel | Resolves when |
|---|---|
| Flow Messaging (WhatsApp, SMS, Telegram, iMessage) | Flow reports message.sent: the channel itself took it, not only Flow’s 202, which means queued. waitFor: "delivered" waits for message.delivered instead, where the channel reports it |
whatsapp(provider), sms(provider), customUI(name, provider) | Your provider’s sendMessage (or sendCarousel, sendTemplate) resolves. Write it to resolve when your platform’s API accepted the message, not before |
webChat() | The visitor’s outbox in the agent’s store kept it, under the visitor’s next number: the chat box reads it from there. It rejects when that write fails |
| The playground and the test cloud | The simulator has it |
A send may resolve with how far the message got: sent, delivered, queued (a provider that
does not wait, such as Flow Messaging with waitFor: "queued"), or unconfirmed. unconfirmed
means no signal came within the provider’s wait (30 seconds by default for Flow Messaging): the
message may still arrive and is never sent again. The agent emits message.slow and carries on.
What follows from it:
- A send tool’s result tells the agent
deliveredonly once the platform accepted the message. message.outis emitted, and the message kept in the history, only then.- A rejected send comes back to the agent once, as a “Not delivered” note with the reason
(
message.undelivered). So does a failure Flow Messaging reports after the send resolved. - A carousel the channel sends card by card resolves once the platform accepted its last card.
- An edit or unsend resolves once the platform accepted the call, and rejects when it refuses it.
- Typing indicators, read receipts and status lines are courtesies: their failures never fail a turn. Typing is kept up while the agent works and never outlives the turn.
Ordering
Section titled “Ordering”- One conversation, one message at a time. An agent’s messages to one person go to the channel in the order it wrote them, and each goes only once the one before it was accepted. Messages the framework sends itself (an acknowledgement, a scheduled message, a nudge) wait for the turn in progress and follow the same rule.
- Inbound, per conversation. A conversation runs one turn at a time and takes the person’s messages in the order the channel gave them; double texts fold into the turn in progress. Flow Messaging delivers events in order per conversation, by stream and webhook alike.
- Across conversations, nothing is ordered. Different people’s conversations run side by side.
- Across one person’s channels, each channel is ordered on its own; the agent is told which channel each message came on.
- A person’s other conversations (under another key, sharing a verified phone number: a
WhatsApp chat and a web chat signed in with that number) are shown to each turn as their recent
messages, read from the store when the turn starts: a message the other conversation is taking at
that moment may show only from the next turn, and a turn whose read fails goes on without them
(a warning is logged). Replies go only on the channel of the conversation whose turn it is. Only one agent’s conversations are ever linked, and
forgetPersondeletes all of them (one person, several conversations). - What a platform does after accepting a message is the platform’s. Flow Messaging sends one message at a time per conversation, in the order accepted; Telegram and WhatsApp deliver in that order in practice but promise nothing more.
Inbound: at least once in, taken once
Section titled “Inbound: at least once in, taken once”Platforms deliver at least once: a webhook is retried until it is answered, and Flow Messaging redelivers events it is not sure arrived. The agent takes each message once:
- A message whose id the conversation has already taken is a
message.duplicateand starts no turn. With a database (sqlite(file),postgres(...)) that holds across restarts and processes; with the default in-memory store, until the process stops. - Flow Messaging events already handled are dropped, so a redelivery, or the same event by both the
stream and a webhook, reaches the agent once. A failure to handle an event answers the webhook
500(or does not move the stream’s cursor), so Flow delivers it again. - The web chat’s box sends each message with its own id, the same on every retry, so a retry is a
message.duplicate.POST /messagesanswers202only once the store has the message; a write that failed answers503, and the box sends it again.
Outbound: when exactly once is true, and when it is not
Section titled “Outbound: when exactly once is true, and when it is not”- Messages the business starts (a nudge, a scheduled message, a template) carry an idempotency key made from their id, the same in every process. A provider that honours keys, as Flow Messaging does for 24 hours, sends each once, even when two processes or a restart send it again. A nudge already settled in the store is never sent again.
- Retries of one send (Flow Messaging’s on a
429or5xx) repeat the same key: never a second message. - The agent’s replies are sent once: a send that was accepted is in the history and never sent again, also after a restart. One case is at least once: a process that stops after the platform accepted a reply but before the store recorded it runs the turn again, and the agent may say it again.
- A send that timed out (
unconfirmed) is never sent again: at most once from the framework. The platform may still deliver it. - Flow Messaging’s own resend. When a channel took a message but never answered, Flow resends it
once, so a rare duplicate is possible on Telegram; an outcome still unknown fails with
channel_timeout_unknown_outcome(it may have arrived). Idempotency keys cannot undo that. - One process works on a conversation at a time (a lease in the database), so a deploy’s overlap never answers one message twice.
- Scheduled messages and wake-ups are rows in the store. When one comes due, one process claims it and starts its turn in one transaction, so it runs once, also with several processes or across a restart. A scheduled message found more than 30 minutes late is missed, never sent late; a wake-up found late still runs, and the agent is told when it was due.
The web chat
Section titled “The web chat”The web chat shows each message once, in order.
- What the box shows waits in the visitor’s outbox in the agent’s store, numbered per visitor. Their tabs and devices share it, and their own messages are echoed into it.
- The box asks for everything after the last number it showed, and ignores a number it already showed. A phone that slept, a dropped connection, a restart or a deploy loses nothing and repeats nothing.
- A request waiting on one process wakes when that process keeps an event, and checks the store every 2 seconds for what another process kept (a deploy’s overlap).
- The outbox keeps a week; a box asking for older reads the history again.
- Typing and status lines are live only: one sent while no box is connected is lost.
Writing your own provider
Section titled “Writing your own provider”whatsapp(provider), sms(provider) and customUI(name, provider) keep these guarantees only if
the provider does its part:
- Resolve
sendMessagewhen your platform’s API has accepted the message, and reject with a readable reason when it refused it. Never resolve early to go faster: the next message would overtake it, and the agent would be told it was delivered. - Pass
idempotencyKeyon to your platform when it takes one. - If your platform’s acceptance can take long, wait a bounded time and resolve
{ status: "unconfirmed" }past it, rather than hanging the conversation.
To check an adapter of your own, add it to the framework’s conformance suite
(conformance/, npm run test:conformance).
Thanks, we read every comment.