Reaction events
Live tapbacks and custom emoji on the stream — not a command tally.
A peer (or you) tapping a reaction writes an event: reaction frame. This is
chat evidence. Your own tapback also settles as event: command
(reaction.tapback / reaction.apply). Those are two meanings. Do not
increment a reaction tally on event: command.
Reaction and poll frames have no per-row externalId. A poll's pollExternalId
names the poll origin. A reaction names the target bubble as targetExternalId.
event: reaction
data: {"conversationId":"a0000000-0000-4000-8000-000000000001","occurredAt":"2026-08-24T18:03:40.000Z","from":"+14155550100","action":"added","reaction":"love","targetExternalId":"guid-1"}A custom emoji adds emoji and sets reaction to "custom". Classic tapbacks
omit emoji.
event: reaction
data: {"conversationId":"a0000000-0000-4000-8000-000000000001","occurredAt":"2026-08-24T18:03:41.000Z","from":"+14155550100","action":"added","reaction":"custom","emoji":"🔥","targetExternalId":"guid-1"}The frame
Prop
Type
There is no externalId on this object. Do not invent one from a hash.
Own tapbacks vs event: command
You send reaction.tapback or
reaction.apply. The command settles with
status and, when the Mac's delivered result carries one, outcome
(applied, removed, already_reacted, not_reacted). That settlement has
no actor, target or reaction name.
If chat.db later shows the tapback, an event: reaction follows. Count the
reaction frame. The command frame is whether your verb finished.
add, remove, add
Live edges are not a state snapshot. Adding love, removing it, and adding it
again is three frames: added, removed, added. Two custom emoji on the
same bubble are two frames with distinct emoji values.
Do not dedupe on (from, targetExternalId, reaction, action). That key
collapses add→remove→add (repeats the first tuple) and collapses distinct
custom emoji (reaction: "custom" for both).
SSE and replay can overlap. A frame already seen live may appear again in
replay. Upsert using
(conversationId, from, targetExternalId, reaction, emoji if custom, action, occurredAt)
as a best-effort coincidence key, not as a cursor. Two adds with identical
occurredAt cannot be distinguished this release (no row id).
Catch-up
includeActivity: true on POST /v1/replay returns the
newest limit reactions for that conversation (default 50, max 200), newest
first then reversed to chronological. sinceExternalId does not bound
activity. A reaction-only outage does not advance the inbound-message
checkpoint. activityTruncated: true means a cap hit; a 201st older row is
not recoverable.
Snapshot batches (reaction-state:…) are not streamed.