Skip to main content
Receive messages, delivery updates and reactions from one ordered event log. Choose signed webhooks, a live stream or polling to fit how your agent runs.
TypeScript (Next.js route handler)

Three ways to receive events

All three read the same log, so you can mix them: for example a webhook in production and GET /v0/events to recover after downtime.

Polling: GET /v0/events parameters

The answer is {"data": [...], "has_more": true}, oldest first: page on with after set to the last id while has_more is true. Events are kept for your plan’s retention (30 days today; see Retention).

The event shape

timing shows Flow’s own overhead: delivered_at minus received_at.

Event types

Status events are high volume. Each webhook endpoint subscribes to the types it lists, so subscribe only to what you use.

Register a webhook endpoint

curl
The answer includes the endpoint’s signing secret (whsec_...), shown only this once. Store it as FLOW_MESSAGING_WEBHOOK_SECRET. Endpoints and their secrets belong to the mode of the key that created them: a test key’s endpoint receives test events, so going live means registering the endpoint again with the live key and storing its new secret. The URL must be public HTTPS. To run your handler on localhost with no tunnel, npx @flow-engineer/messaging listen --forward-to http://localhost:3000/api/flow forwards each event from the live stream to it, signed like a real delivery (Local development).

Verify the signature

Every delivery carries these headers:
v1 is the lowercase hex HMAC-SHA256, keyed with the endpoint secret, of the string t.{t}.{body}: the letter t, a dot, the timestamp from the header, a dot, and the raw request body exactly as received. Compare in constant time and reject timestamps more than 5 minutes from your clock. While a secret is being rotated the header carries one v1 per active secret; accept the request if any matches.

Rotate the signing secret

POST /v0/webhook_endpoints/{webhook_endpoint_id}/rotate_secret makes a new secret and returns it in secret, shown only this once. The old secret keeps signing for overlap_seconds (default 86400, one day; 0 retires it at once), so every delivery during the overlap carries two v1 values and verifies with either secret while you deploy the new one. previous_secret_expires_at says when the old one stops. Rotating again during an overlap retires the older secret at once: at most two are ever active. So send an Idempotency-Key and retry with the same key; a retry without one is a second rotation, which retires the secret you still have deployed. A repeat of a rotation that went through answers 409 idempotency_conflict (the new secret is shown only once).

Reply in the webhook response

To answer a message.received at once, respond 200 with a reply body. Flow sends it into the event’s conversation through the send gate, piece by piece, as if you had called each piece’s kind’s endpoint (POST /v0/conversations/{conversation_id}/basic, /buttons, …) with the event’s id as the idempotency key. This saves a whole round trip.
Each piece is a kind object with kind, the same fields as on its endpoint. A string is a basic message: {"reply": "Hi"} sends {"kind": "basic", "text": "Hi"}. reply may also be a list of up to 11 pieces, sent in order, so a text plus 10 cards sent one by one fits:
  • An empty body, {}, {"reply": null} or a body that is not a JSON object (plain text such as OK) sends nothing, and is not an error.
  • A JSON object with a reply that is not valid, or a body that starts with { but is not valid JSON (a piece that is not a kind object or a string, an unknown kind, an empty list, more than 11 pieces) sends nothing at all, not even the valid pieces. It is recorded on the delivery as an invalid_request error with the reason, which the MCP tool get_webhook_deliveries shows. The delivery counts as delivered and is not retried.
  • A piece the send gate refuses (a carousel on Telegram answers 422 unsupported_content) arrives as a message.failed event. Nothing is converted: send what the channel takes instead.
Slow agents answer 200 {} at once and send later with the kinds’ endpoints (POST /v0/conversations/{conversation_id}/basic, …). Keep your handler under 10 seconds; anything that takes longer belongs after the response.

Delivery rules

  • Answer 2xx within 10 seconds. Anything else, or no answer, is a failed delivery.
  • Retries: failed deliveries are retried with backoff for 3 days. After that the event stays in the log, marked failed, and you can read it again with GET /v0/events.
  • At least once: a delivery can repeat. Deduplicate on the event’s id (also in Flow-Event-Id).
  • Ordered per conversation: events in one conversation arrive in order, one at a time. Later events in that conversation wait behind a failing one, so a slow endpoint holds back only its own conversations. See Order per person.
  • Ordering in the log: events are ordered by commit, not by created_at. Reading forward with after never skips an event.
  • Disabled endpoints keep their place: nothing is lost from the log.

Order per person

Order is kept per conversation, and a conversation is one person on one of your senders. The same person on two channels, or on two of your senders, is two conversations, with no order between them.
  • What you send goes out one message at a time per conversation, in the order Flow accepted it. A send the channel refuses for now is retried in its place, and later messages to that person wait for it. A send that fails (message.failed) holds nothing back: the next one goes at once.
  • As the person sees it: a message you send as several (a text, then one message per card) arrives in the order you sent the calls, across every endpoint, webhook replies and the stream. On WhatsApp, a message with media holds the next one briefly until WhatsApp reports on it, so the person sees them in the order you sent them.
  • What the person sends becomes events in the channel’s order, one at a time per conversation. On Telegram that is Telegram’s own update order: a later message waits briefly for an earlier one still on its way. On WhatsApp it is WhatsApp’s timestamp: each message is held briefly so one whose webhook comes late can still go first. A message that arrives after that wait is delivered when it arrives.
  • On iMessage, inbound messages carry no order from the channel, so they become events in the order Flow receives them. Two messages the person sends within about a second of each other can arrive swapped.
  • A sandbox join gives conversation.started first, then the join itself as the person’s first message.received, then whatever they send next.
  • Webhook retries never reorder a conversation: its next event waits until the one before is delivered, or marked failed after 3 days of retries.
  • The stream and GET /v0/events give your app’s events in log order, so one conversation’s events come in order there too.

The live stream

GET /v0/stream upgrades to a WebSocket. Every frame is one JSON object: the server sends event, ack, error and reconnect frames, and you may send send frames ({"type": "send", "ref": ..., "conversation": "conv_...", "message": <kind object>}, the same as the kind’s endpoint) and start frames ({"type": "start", "ref": ..., "message": <a start>}, as POST /v0/messages). ref is your idempotency key, echoed in the ack or error. Without after the stream starts live, at the moment it opens: earlier events are not replayed (read them with GET /v0/events, or see Ask and wait for a reply). Pass after to resume: the stream first replays everything after that event, then goes live. When the server is about to restart it sends reconnect; reconnect with after set to the last event you received. The TypeScript SDK does all of this for you in flow.events.stream(), and polls GET /v0/events where the runtime has no WebSocket. Authenticate with the Authorization header. Where your WebSocket cannot set headers (a browser’s WebSocket, and Node’s global WebSocket on a server), offer the key as a subprotocol instead: offer both flow and flow.key.<api key>. This is as valid from server-side code as from a browser. The server selects flow and never echoes the key; offering the key protocol without flow is refused with 400 invalid_request, and an Authorization header, when present, takes precedence. Keys are never accepted in the query string, since URLs end up in logs. The TypeScript SDK offers the subprotocol by itself where the WebSocket cannot send headers.
A refused stream (a bad, revoked or expired key, a bad parameter, too many streams for the key) is still upgraded, because most WebSocket clients cannot read an HTTP status: the server sends one error frame with the usual error body, then closes with 4000 plus the HTTP status. Stop reconnecting on 4401 (authentication), 4403 (permission) and 4400 (invalid_request); reconnect after retry_after on 4429 and 4503. An open stream whose key is revoked also gets an authentication error frame and close 4401.
A key used in a browser is visible to whoever uses that page. Do this only for internal tools or with test keys (fk_test_); keep live keys on your server.