Skip to main content
Register handlers on OfflineProtocol before startup. Event names use the type discriminator. The payloads below come from the published v0.31.0 declarations; fields inherited from BaseEvent are shown first.

Delivery outcomes

message_delivered and message_failed settle a send. message_deferred, message_retrying and message_undeliverable keep it pending. A decryption failure describes an attempt, not application rejection. A service response is a separate application result; correlate its request ID and provider.

BaseEvent

Base event interface

message_sent

Message sent event

message_received

Message received event

message_delivered

Message delivered event

message_failed

Message failed event. Terminal: the SDK has stopped retrying the message when this fires, and no message_delivered follows it for the same message_id. Handle it idempotently all the same: a restore that is retried after a failed MLS initialization can report an id it already reported.

message_decryption_failed

Failed to decrypt an inbound encrypted message.

transport_switched

Transport switched event

relay_promoted

Relay promoted event

relay_demoted

Relay demoted event

relay_demoted_battery

Relay role was demoted due to battery constraints.

neighbor_discovered

Neighbor discovered event

neighbor_lost

Neighbor lost event

peer_rssi

A live signal-strength reading for a linked Bluetooth LE neighbor. Delivered only while at least one on('peer_rssi', ...) listener is registered (an 'all' listener does not turn it on), at most four a second per peer. Only a peer that has proved its address on a link is named. rssi is raw dBm and swings several dB between samples; smooth it for a proximity display, for example next = 0.7 * prev + 0.3 * rssi.

identity_ready

This device’s own address is known. Fires once per successful startup, before any message can be sent. The address is derived from the identity key held in this profile’s storage: the app does not choose it. It is this device’s sender on every outbound frame and the string peers must use as recipient to reach it, and it is stable across restarts of the same profile. Also readable at any time after startup via localAddress().

network_metrics

Network metrics event

file_progress

File progress event

file_received

File received event

file_receive_failed

An inbound file transfer was dropped before completion: the receiver hit a resource limit (too many concurrent transfers, per-sender quota, or the buffered-bytes budget), the reassembled file failed its integrity checks, or the transfer went stale (no chunks within the stale timeout). Terminal and fired at most once per transfer: the failed transfer’s remaining in-flight chunks are dropped silently. No file_received will follow for this file_id; the sender must re-send the file (under a fresh file_id) to retry.

media_sent

Media sent event - all chunks ACK-delivered

media_send_failed

An outbound media transfer was aborted before all chunks were delivered (chunk encryption failed, or a chunk failed terminally). No media_sent will follow for this file_id; retry with a new sendMedia call.

ack_evicted

A pending ACK was evicted due to capacity constraints.

fragment_assembly_evicted

A fragment assembly was evicted to make room for new fragments.

diagnostic

Diagnostic event emitted by native modules for debugging purposes

secure_session_established

Secure session established event Emitted when an MLS session is successfully established with a peer

secure_session_failed

Secure session failed event Emitted when an MLS session fails to be established

convergence_diag

Convergence diagnostic event (1:1 MLS convergence instrumentation). Pure receiver-side breadcrumb for the Welcome receive / adopt / confirm path: carries no protocol effect. Surfaces stages the receiver otherwise emits nothing for (welcome reassembled, branch taken, decrypt landed).

welcome_send_attempted

Welcome send attempted event

welcome_send_succeeded

Welcome send succeeded event

welcome_send_failed

Welcome send failed event. Note: welcome_send_succeeded → welcome_send_failed is a LEGAL sequence for the same welcome over the internet transport. The bridge confirms on socket-write success, but the relay stores nothing for offline recipients: its later DeliveryError corrects the earlier success (reason_code PEER_UNREACHABLE, retryable: true). Recovery is automatic: the SDK keeps an escalating reachability probe running on every carrier (a PEER_UNREACHABLE failure therefore always carries next_retry_at), with presence-driven re-send as the faster edge when the platform polls presence. This event may repeat once per probe round while the peer stays offline; treat it as state, not as a terminal verdict.

welcome_send_expired

Welcome send expired event

connection_request_received

Connection request received event

connection_request_undeliverable

An outbound connection request could not be delivered: the transport reported the recipient unreachable (e.g. the relay’s DeliveryError for an offline peer), or the request exhausted its retry budget (a generic message_failed also fires for the same id in that case). Correlate via the message id returned by sendConnectionRequest. This is a status signal, not proof of permanent failure: the original request may still be delivered by the retry machinery if the peer comes back online, so treat a user-initiated resend as potentially duplicating the original on the recipient’s side.

connection_accepted

Connection accepted event

connection_rejected

Connection rejected event

connection_request_cancelled

Connection request cancelled event

group_created

Group created event (from relay)

group_message_received

Group message received event (from relay)

group_member_added

Group member added event (from relay)

group_member_removed

Group member removed event (from relay)

group_info

Stable SDK projection of a GroupInfo relay frame. The same frame is also emitted as internet_server_message; use that raw event for application-owned fields such as descriptions, avatars, pending join requests, or future server extensions. Do not apply standard group state from both events, and do not rely on their arrival order.

user_groups

Stable SDK projection of a UserGroups relay frame. The same frame is also emitted as internet_server_message; use that raw event for profile, membership, or future application-owned fields. Do not apply standard group state from both events, and do not rely on their arrival order.

group_error

Group error event (from relay). reason is a fixed code minted locally, not the relay’s wording: not_found (the relay has no such group: invite links and relay fan-out for it are dead, not merely refused), sync_denied (the relay refused to register or sync the group for this caller), or error (any other refusal). Do not parse it as prose or show it to users as-is. The relay’s text is deliberately not carried here: __GROUP_ERROR__ is accepted unsigned on the relay ingest path, so its wording is chosen by whoever put the frame on that socket. Apps that genuinely need the exact wording, invite-link flows correlating by request_id, for instance, should read the raw GroupError frame, which both bridges still dual-emit on the server-message channel. group_id is present when the relay scoped the error to a group.

group_relay_sync_changed

The relay-side registration state of a group changed. synced: true fires only on the relay’s positive registration acknowledgment (including the idempotent re-sync ack after a membership change): the signal that relay-dependent server commands for the group (invite links via sendRawServerCommand, server-side fan-out) can be issued. Await it via ensureGroupRegistered. synced: false fires when that trust is revoked; reason says why: registered (relay ack), error (group-scoped relay error: details arrive separately as group_error / the raw GroupError frame), removed (we were removed from the group), left (local leave), internet_dropped (transport lost; re-registration re-arms on reconnect), ack_timeout (the relay never answered: likely a relay without group support). Not emitted for groups the relay was never asked about.

username_resolved

A username resolution finished, carrying every claim found.

The set is the whole point

Anyone may publish any username claim, so a name resolves to a set of assertions, never to an answer. Even a single-device user is a set of one. This event fires exactly once per resolution and carries the complete set precisely so an app cannot accidentally treat the first arrival as the winner: there is no per-claim event to race and no ordering to mistake for a ranking. Let the user choose. Silently picking one claim converts a non-authoritative directory into an authoritative-looking one, which is worse than not shipping the feature: the user believes they are talking to the name, and the protocol only ever promised them a key. Present the claims, have a human confirm out of band (a QR code, a shared secret, a voice call), and store the address rather than the name. An empty claims list is an ordinary outcome, not an error.

group_message_sent

Group message sent event: a group message was sent to all members via mesh (MLS-encrypted fan-out).

group_message_partial_failure

Group message partial failure: some members could not be reached.

group_message_delivery_report

The relay’s settled per-recipient delivery report for a group message sent via relay broadcast, after the SDK acted on it. Fires once per broadcast whose report arrived, seconds after group_message_sent (the relay reports only when its whole fan-out has settled: correlate by message_id, never by order). Members in delivered took the message over a live relay socket; members in pushed took a device push carrying the ciphertext. Every other MLS roster member has already been re-sent a per-member copy through the ordinary delivery ladder (missed_reissued): no app action is required; this is delivery observability, not a failure signal. Not emitted when the report itself is lost: the SDK then re-broadcasts (bounded) and finally downgrades the message to per-member fan-out.

group_rich_extras_dropped

Rich media metadata was dropped from an outbound group message because the group is not fully rich-capable (not every member advertised sealed rich payload support, or the local kill switch disabled it). The text was still sent; members receive it without the media attachment. Use this to warn the sender that an attachment did not go through.

group_unauthorized_membership_change

An MLS membership change was made that its committer was not authorized to make. Read enforced first: it decides what this event means. With group.enforceAdminCommits off (the default), the change has been applied and enforced is false. MLS authenticated the committer as a group member and accepted the commit. The SDK’s admin model is an application-layer overlay on MLS (which has no admin concept), enforced when sending; refusing the change on receipt would mean refusing the MLS merge, permanently forking this member away from everyone who accepted it. So the change stands and is reported here instead. With that flag on, the commit was instead refused before merging and enforced is true: nothing changed locally, and this device is now an epoch behind the group. See the field doc below. This signal can false-positive: “unauthorized” is judged against this device’s local replica of role state, which replicates best-effort and can lag: a legitimate change may be reported, and different members can disagree about the same commit. Treat this as a moderation signal for a human admin: an admin can undo it with meshRemoveFromGroup / meshInviteToGroup. Never reverse a change automatically off a single member’s event: corroborate first. A member added this way can read all subsequent group traffic until removed. Reports are rate-limited per (group, committer, enforced): a repeat of the same outcome within a short window is not re-emitted, but every affected group_member_added / group_member_removed still carries authorized: false. Known limitation: the member removed by an unauthorized Remove does not receive this event: only the remaining members report the removal.

group_epoch_fork_detected

Epoch fork detected: concurrent MLS commits caused members to diverge onto different branches. The deterministic leader will attempt automatic resolution.

group_epoch_fork_resolved

Epoch fork resolved: the leader re-established a canonical epoch. Members in failed_members could not be reached with the resolution commit and may need re-inviting.

group_role_changed

Group role changed: a member’s role was changed (e.g. admin ↔ member).

group_renamed

Group renamed: emitted when a rename is performed or received via meshRenameGroup. Renames observed only as relay-native GroupRenamed frames surface through internet_server_message instead.

service_discovered

A service was discovered on the mesh in response to a discovery query.

service_request_received

A service request was received from another peer.

service_response_received

A response to a service request was received.

presence_updated

Presence updated event: one unified stream for both sources (discriminated by source): a peer-sent __PRESENCE__ update, or the internet relay’s presence answer (driven by the SDK’s automatic watch loop or an explicit checkInternetPresence). Emission is 1:1 with the underlying signal: the SDK never dedupes unchanged statuses, so every relay answer re-emits this event even when nothing changed.

typing_indicator_received

Typing indicator received event

read_receipt_received

Read receipt received event

message_relayed

Message relayed event: this node forwarded a message for another peer

message_deferred

Message deferred event: a message was queued because no transport was available

message_retrying

Message retrying event: a retry was scheduled after a failed attempt (transport send error or ACK timeout). Non-terminal: MessageDelivered or MessageFailed still settles the message.

message_undeliverable

Message undeliverable event: the transport reported the recipient unreachable for an in-flight message (e.g. the internet relay’s DeliveryError). Non-terminal: the message stays in the outbox. A plain DM is parked, its ACK retry budget stops burning while the peer is provably offline, and is re-driven on reachability edges (transport reconnect, peer discovery, presence-online; the SDK adds parked recipients to the presence watchlist). It settles only via MessageDelivered or, at outbox-lifetime expiry, MessageFailed. Media chunks are not parked and keep the normal retry machinery. Fires repeatedly for the same message_id while the recipient remains offline: a parked DM keeps an escalating reachability probe on every carrier (15s doubling to a 600s cap, the ladder shared per recipient), and each probe that reaches the relay while the peer is still offline earns a fresh verdict and re-emits this event. Treat it as a repeatable status signal, never as a terminal one. Note the probes refresh the outbox entry’s last-send timestamp, so a parked DM’s terminal MessageFailed arrives at the absolute outbox cap (4x the configured lifetime, ~28 days on defaults) rather than the sliding ~7-day window: plan pending-message UI accordingly.

media_resend_required

Media resend required event: an outbound media transfer was in flight when the previous process died. The SDK persists only the transfer descriptor (never chunk bytes), so the app must re-supply the file bytes via sendMedia with this file_id; they are checksum-validated against the original transfer.

dors_score_updated

DORS score updated event (per-transport scores).

dors_transport_selected

DORS transport selected event (current choice and reason).

dors_transport_switched

DORS transport switched event (transition with reason).

dors_escalation_triggered

DORS escalation triggered (BLE→Wi‑Fi recommendation or applied).

security_warning

A security-relevant anomaly was detected for a peer. SENDER_ADDRESS_MISMATCH has no benign reading: an address is the hash of its owner’s identity key, so a frame signed by anyone else re-derives to a different address. A peer that reinstalls does not re-key an address: it gets a new address, and reaches you as a new contact. Treat this as an impersonation attempt, not as a peer to re-trust. UNSIGNED_CONTROL_REJECTED fires when a control frame arrives without a signature. This is unconditional and not configurable: every peer running this protocol signs its control traffic. SESSION_REKEY_TRIGGERED is rate-based rather than per-event: a genuine epoch fork heals this way occasionally, but the frame that triggers it is not authenticated (see schedule_session_rekey), so a sustained rate for one peer indicates injected frames. Delivery is delayed, never lost. PUSH_KEY_PACKAGE_POOL_EXHAUSTED is also rate-based: each peer normally gets its own single-use MLS init key, and this reports that the pool of unconsumed packages hit its ceiling so one is being shared. Nothing fails, it clears as packages are consumed or expire, but a sustained rate means the device is accumulating advertisements to peers that never establish a session. RELAY_ADDRESS_BINDING_MISMATCH has no benign reading either: the relay acknowledged binding an address that is not this device’s, which only a broken or hostile relay can produce. peer_id is the foreign address it echoed. The connection is not torn down: a relay that controls the socket already controls what a local refusal would protect. RELAY_ADDRESS_DECLARATION_REFUSED is operational, not adversarial: the relay declined the declaration, so this connection stays attributed by account name. Existing conversations keep working; what breaks is establishing new encrypted sessions over the relay, since the key-package and welcome frames are identity-checked. peer_id is this device’s own id and reason is the relay’s text, verbatim and opaque. GROUP_LEAF_IDENTITY_UNPROVEN has no benign reading, and unlike the others it accuses someone the user is already in a room with. Every member of a group carries an identity key whose hash is their address, so an honest member always re-derives their own name; a member that does not was built around someone else’s. Emitted from four sites. Three refuse a claim as it arrives, a declined group invite, a refused membership change, and a declined 1:1 session Welcome, and in each the frame is dropped, so nothing is delivered. The session case can fire while an existing session with that peer stays live, the refusal is non-destructive, so a secure_session_failed alongside it means “this handshake attempt failed”, not “the session ended”. The fourth site needs a different response: a roster read finding such a leaf already seated in local group state. No wire gate can have admitted it, so it was written to this device’s secure store directly, or joined by a build predating those gates. It is kept out of every roster and cannot speak, but it holds live group secrets and reads everything sent to the group: so the remedy is to leave and re-create the group, not to remove a member. peer_id is who the finding concerns, not always who to blame. On the three refusal sites it is the peer that delivered the forgery, the inviter, or the sender of the membership change, and it is proved: those frames are signature-gated against the sender’s own address. On the fourth there is no delivering peer and it is this device’s own id. reason is diagnostic text, must not be parsed, and carries no identifier: the impersonated address stays in the logs of the device that refused it. STALE_CONTROL_FRAME refuses a frame for what its signature says about when: stamped too far in the past, too far ahead of this device’s clock, or carrying the older signing payload from a peer that has proved it can produce the newer one. Check this device’s clock first. The signed timestamp is compared against it, so a device whose own clock is wrong refuses honest peers in bulk, and the event stream then looks like an attack rather than a local fault. Many different peer_ids in a short window is a clock; one peer_id while others are fine is that peer, either replaying captured frames or running a broken build. Nothing is torn down: the frame is dropped, unacknowledged, and a peer whose frame was genuinely just slow re-sends a fresh one. KEY_PACKAGE_OUTSIDE_VALIDITY_WINDOW is the same kind of finding for key packages, and again the first thing to check is a clock. A peer’s key package is valid from an hour before it was made until 30 days after, judged by this device’s clock. A device more than an hour behind a peer cannot start a session with it (the peer still can, and usually does). Past 30 days each side refuses the other’s package, both report this, and no encrypted session forms: messages and connection requests wait until the clocks agree. This is what happens between phones that have not been online to set their time. Many peer_ids is this device’s clock; one is that peer’s. A window that has already closed may instead be an old package a relay held for days; that package is discarded and the next one the peer sends replaces it, and reason names both causes. Reported once per peer, whether the refusal came when the package arrived or on a later send, and peer_id is a claim, since the package has not proved its sender when its window is checked. Show the user a prompt to check the date and time.

user_blocked

A user was blocked. Emitted for local UI notification only.

user_unblocked

A user was unblocked. Emitted for local UI notification only.

data_changed

A replicated document changed and the change reached storage. Emitted after the delta is durable, never before, so a UI that re-renders on this event is rendering state that survives a crash.

data_doc_removed

A replicated document was removed from every replica of its space. Distinct from a document merely disappearing here: this one is a decision that travels. It fires when this device removes a document and when it learns of a removal from a peer, so a UI holding the document open can close it either way. A removal loses to a concurrent edit, so a document may come back after this fires. That arrives as an ordinary data_changed on a document the app thought was gone, which is the honest report: the edit won, and its contents are the contents.

data_doc_size_warning

A replicated document is approaching the per-document size cap. Fires while there is still room to act (archive, split, prune). Without it the cap would be a cliff the app meets for the first time as a failed write.

data_attachment_requested

A peer asked for the bytes behind an attachment reference. The SDK never holds blob bytes, so it cannot answer this on its own: answer with DataStore.provideAttachment, or say you cannot with DataStore.declineAttachment. An app that handles neither leaves the asking peer with a reference it can see and never open.

data_attachment_received

Attachment bytes arrived and matched the hash that asked for them. The bytes are handed over rather than stored: blobs never enter protocol state, so where the file lives is the app’s decision and its storage.

data_attachment_unavailable

A fetch ended without bytes: the peer no longer holds the blob, or the transfer failed. Both are ordinary: an attachment reference outlives the bytes it names, because the reference replicates and the bytes do not.

data_doc_unsyncable

A document cannot be replicated in any form this protocol has: too large for a sync frame, and the peer cannot carry it over the media path either. The two replicas will not converge until the document gets smaller.

internet_server_message

A raw relay server frame that apps need outside or in addition to SDK-owned processing: invite-link lifecycle responses (GroupInviteLinkCreated, GroupJoinedViaInvite, GroupInviteJoinPending, …), GroupRoleChanged, GroupDeleted, RateLimited, and any future/unknown relay message types. GroupError, GroupInfo, and UserGroups are dual-emitted with their stable typed events so apps can consume request correlation or application-owned extension fields without losing the SDK projection. json is the verbatim relay frame; parse it and dispatch on its type field. Pair with sendRawServerCommand for request/response flows. Typed and raw events have no cross-channel ordering guarantee. Raw frames can contain sensitive profile data, invite tokens, and key packages; avoid logging them indiscriminately.

internet_status_changed

Internet-transport readiness transition. authenticated: true is the positive gate for sendRawServerCommand: the replacement for app-side relayStatus === 'authenticated' tracking against a separate app-owned socket. connected: true, authenticated: false is the window where the socket is up but the relay has not accepted the auth token yet (raw sends still return false). Emitted only on actual transitions; query the current value with isInternetReady().

internet_session_superseded

The relay displaced this device’s internet connection: a newer registration for the same identity took over the relay slot, and the relay closed this socket with code 4000 (optionally preceded by a SessionSuperseded notice). The SDK does not auto-reconnect after this: a blind reconnect would just re-displace the other socket in a tight loop (the fleet-wide eviction storm the relay-displacement rollout guards against). The transport is left stopped; the app should surface a “connected elsewhere” state and reconnect only on explicit user action (re-enabling the internet transport), or on foreground with long jitter. reason is the relay-supplied close/notice reason when present. Treat this as state, not as an edge. Delivery is at-least-once: nothing else ever restates the fact it reports, so every layer works to make sure it is not lost, and the cost is that it can repeat. Android redelivers a copy held while JS could not take it (on your next subscribe or foreground); iOS re-derives it from the live latch on every app foreground while the transport stays superseded; and on both platforms the SDK holds a copy that arrived before you had a listener for it and replays it to your first on(...). Handlers must therefore be idempotent: setting a “connected elsewhere” flag is fine, pushing a screen or firing a notification per event is not. Repeats stop as soon as the transport is re-enabled, which also drops any held copy. The pull-side counterpart is isInternetSuperseded(), which answers the same question on demand and survives the windows no in-memory delivery can (a late subscribe, a JS reload, a process restart). An app that reconciles against it on foreground needs nothing from this event but the prompt.

mesh_stopped_by_user

The user stopped the mesh from the Android foreground-service notification’s “Stop” action rather than through a stop() call. The SDK has already torn down the transports, the process scheduler, the keep-alive service and the protocol core by the time this arrives: it is a notification, not a request to act. Apps that track mesh state themselves must reconcile it here, or they will keep reporting an active mesh against stopped transports. Treat this as state, not as an edge. Like InternetSessionSupersededEvent, this is one-shot, nothing ever restates it, so it is held for redelivery when it cannot be delivered: natively while JS is unreachable, and by the SDK when it arrives before you have a listener for it (replayed to your first on(...)). Delivery is at-least-once and handlers must be idempotent. A held copy is dropped by start(), which is a mesh coming back up. Android only; iOS has no equivalent notification affordance.