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
Example message.received payload
Example message.received payload
timing shows Flow’s own overhead: delivered_at minus received_at.Event types
Event types and when to subscribe
Event types and when to subscribe
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
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 amessage.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.
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 asOK) sends nothing, and is not an error. - A JSON object with a
replythat 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 unknownkind, an empty list, more than 11 pieces) sends nothing at all, not even the valid pieces. It is recorded on the delivery as aninvalid_requesterror with the reason, which the MCP toolget_webhook_deliveriesshows. The delivery counts as delivered and is not retried. - A piece the send gate refuses (a
carouselon Telegram answers422 unsupported_content) arrives as amessage.failedevent. Nothing is converted: send what the channel takes instead.
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
2xxwithin 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 inFlow-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 withafternever 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.startedfirst, then the join itself as the person’s firstmessage.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/eventsgive 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.
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.
Related
- Local development: receive events on your laptop through the live stream, with no public URL.
- API: The webhook event, Create a webhook endpoint, List events, Open the live stream.