Skip to main content
A business’s backend posts what happened (an order shipped, an order is late, an item is back in stock) to one signed route on the agent, POST /v1/events. The agent turns each event into a WhatsApp message for one person, at the event’s delivery level: some go at once, some wait for the end of quiet hours, some are merged or dropped. Sends go through the agent’s nudge(), so STOP and the no-contact rules apply to every one. flowEvents (from @flow-engineer/flow) is the engine. What your events mean (which types exist, their templates and wording, your own rules) stays in your app.
Use agent.nudge() for a direct follow-up. Use business events when your backend publishes a catalog of events with delivery levels, expiry and topic rules.

The envelope

By default the person is data.user.phone (E.164, required on a notification) and they agreed to messages when data.consent.whatsappUpdates === true; without consent nothing is sent. Change either with addressOf(event) and consented(event). data.wording: "exact" always sends the template, never the agent’s words. To cancel one not yet sent, post notification.cancelled with the id in data.cancels:

Signing

Every POST carries Flow-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256>, the HMAC of t.{t}.{raw body} with the secret you gave the agent (whsec_...). t may be at most 5 minutes off. The header may hold several v1 values, so a secret can be rotated without dropping events. signEvent(secret, body) makes the header; verifyEventSignature(secret, header, body) checks one. From any Node backend, without the package:
Sign the exact bytes you send.

Answers

On a network error, a 429 or a 5xx, retry with the same id: the id dedupes, so a retry never sends twice. An Idempotency-Key header is honoured too.

Delivery levels

  • liveOnly on a type: its button acts on something live, so it is dropped once its topic closes, whatever the level.
  • collapseKey: a newer standard or optional event with the same key replaces the one held.
  • expiresAt: a held one past it is dropped.
  • Quiet hours are 22:00 to 08:00 in timezone (default UTC); quietHours: { from, to } changes them, null turns them off.
A topic (topic(event) on the type, such as order:4821) closes with events.close(topic).

Watches raise a message

When a person asked to be told about a topic (events.watch({ address, topic, title }), from one of your tools), a message about it is raised: an optional one is kept as standard, it is never dropped when the topic closes and it skips the caps. A watch with urgent: true (“tell me as soon as”) makes it critical. A message is never lowered. Watches last watchDays (30) and end a day after close(topic). A tool saves the watch in its own agent’s events side, context.events (there once events.attach(agent) ran), never in a module variable: the same tool object then serves every agent in the process (each flow test run, both sides of a deploy) with that agent’s state, as long as each agent has a flowEvents of its own (see “Mount it”).
The tool returns { saved: "saved" }, or { saved: "already watching" } when the person already watches that topic. context.events.mute(address, topic), notInterested(address) and stopped(address) work the same way. Code outside a turn (a route, beforeSend, intercept) has it as agent.events; tools served over MCP get it from mcpServer({ businessEvents: events }) (or agent.events after attach). With watch fields of your own (flowEvents<MyWatch>(...)), read it as context.events as FlowEvents<MyWatch> | undefined. Requires Flow Framework 0.6.0.

Wording

Outside WhatsApp’s 24-hour window the template goes. Inside it the agent phrases the message to fit the chat; every value and the link are checked by events.phrasing.check in beforeSend. A message that drops a value or changes the link is refused, and the template goes instead; if the template fails, its text goes as a message with a link button (wording: { linkInText: true } puts the link in the text). data.wording: "exact" always uses the template.

Copies

sentBy says the business already sent this message itself (allowed only for the names in the sentBy option). It is never sent. It is kept as the agent’s context, read with events.context(conversationId), so the agent can answer when they reply about it. It counts as already told: an identical message within a day, and a held one with the same collapseKey, are not sent. If the agent has reach(), the person’s conversation is opened, so the copy is there when they write.

Nudges

Messages you start from what people watch (rather than from an event) go through events.schedule({ delivery: "nudge", nudge: "<kind>", eventId, address, topic, message, notBefore: Date.now(), exact: false }). They are held in quiet hours and capped: caps: { perDay: 2, perKeyPerDay: 1, perWeek: 5 } by default (per person; perKeyPerDay per topic). A raised nudge skips the caps. events.notInterested(address) mutes the topic last nudged and ends its watch; events.mute(address, topic) mutes one; events.stopped(address) ends their watches and held nudges.

Test list

Off production, a send the business starts goes only to listed numbers, so a staging copy of real data never reaches a real customer:
  • FLOW_ENVIRONMENT=production: no list, everyone.
  • Otherwise only FLOW_TEST_NUMBERS (comma-separated E.164); none set sends nothing.
  • testing: true also allows the numbers madeUp matches (a test run’s made-up numbers).
A stopped send calls onUnlisted({ event, type, address }), else it is logged. A held send is checked again when it comes due. Replies to people who wrote first are never checked.

App event types

A catalog type with kind: "event" sends nothing itself: it is handled in your onEvent(event, events), in order with the rest. Its validate(event) returns { field, error } for a 400, or null.

What stays in your app

The catalog (each type’s level, message, topic, liveOnly), the templates and wording, and your rules: rules.notification (return "handled" when you sent or dropped it yourself), rules.copy, onEvent and onTick. The engine keeps its state in memory (createEventState()); pass your own state to keep or share it.

Mount it

The agent then answers POST /v1/events through agent.fetch(request), and held sends go out on its tick. Attached, the events side is the agent’s own (agent.events, and context.events in its tools; not createAgent’s events, which are trace sinks). An agent takes one: attaching a second one to it throws, and a side attached to another agent is no longer the first one’s. Where one module builds several agents (buildAgent, called by flow test for each run and by a deploy’s overlap), make the flowEvents(...) inside buildAgent, one per agent, with the run’s options.testing?.eventsSecret and now when given, so each agent’s watches, held sends and caps are its own.