Skip to main content
Everything your agent receives (messages, delivery updates, reactions, sender changes) is an event in your app’s ordered log, and this page covers the three ways to get them and how to answer.
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 /v1/events to recover after downtime.

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 belong to the mode of the key that created them: a test key’s endpoint receives test events.

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 /v1/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, exactly as if you had called POST /v1/conversations/{conversation_id}/messages with the event’s id as the idempotency key. This saves a whole round trip.
reply may also be a list of up to 10 pieces of content, sent in order. Add fallback ("auto" or your own content) to say what to send where the channel cannot show a piece, as on HTTP sends; it applies to every piece:
  • 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 reply that is not content or a list of content, an empty list, more than 10 pieces, an unknown fallback) 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 arrives later as a message.failed event.
Slow agents answer 200 {} at once and send later with POST /v1/conversations/{conversation_id}/messages. 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 /v1/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.
  • 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.

The live stream

GET /v1/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 and start frames with the same bodies as the HTTP send endpoints (ref is your idempotency key, echoed in the ack or error). 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 /v1/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.