Last updated 2026-05-28
Ack a Notification
Two distinct acknowledgements live on the client surface:
AckNotification marks a stored notification as
Read; AckDataChange cancels the
at-least-once retry of a data-change hint. Both are idempotent;
both surface as one Connect call.
When you'd use this
AckNotification— user opened the notification, viewed the inbox item, or otherwise consumed it. Decrements unread count, stampsread_at_ms.AckDataChange— the client successfully refetched the upstream state the hint pointed at; tells the server to stop retrying.
AckNotification — marking a row Read
The wire shape
The id field is the store id (the
Notification.id the server assigned), not the
producer's notification_id:
message AckNotificationRequest { string id = 1; // Notification.id (store id)}cURL
curl -sX POST http://notify:8080/elloloop.notify.v1.NotificationClientService/AckNotification \ -H "Authorization: Bearer $JWT" \ -H 'Content-Type: application/json' \ -d '{"id": "8b9f7c5e-4d3a-..."}'Connect-Web (TypeScript)
await client.ackNotification({ id: notification.id });Connect-Go
_, err := client.AckNotification(ctx, connect.NewRequest(¬ifyv1.AckNotificationRequest{ Id: notification.GetId(),}))Result
The store transitions the row to StatusRead and stamps
read_at_ms to the server clock. A follow-up
GetNotifications shows the new status and an
unread_count reduced by one.
// before{ "id": "8b9f...", "status": "DELIVERY_STATUS_DELIVERED", "readAtMs": "0", "unreadCount": 3 }
// after AckNotification{ "id": "8b9f...", "status": "DELIVERY_STATUS_READ", "readAtMs": "1748391033", "unreadCount": 2 }Authorization story
Why this design.Store.UpdateStatustakestenantIDandidonly — notuserID. Without an ownership check, any user in tenant T could mark any other user's notification as Read by guessing the id. The handler does an explicitGetNotification(claims.TenantID, claims.UserID, id)first and maps a miss toCodeNotFound— neverCodePermissionDenied, which would leak the existence of someone else's row.
Idempotency
Calling AckNotification on an already-read row is a
no-op that returns success. The store update is a status-set, not a
counter increment; multiple calls do not multiply the effect.
AckDataChange — cancelling a retry
The wire shape
message AckDataChangeRequest { string idempotency_key = 1; // from DataChangeEvent.idempotencyKey string session_id = 2; // from StreamEvent.sessionId}Connect-Web
for await (const ev of client.streamEvents({ deviceType: "DEVICE_TYPE_BROWSER" })) { const sessionId = ev.sessionId;
if (ev.event.case === "dataChange") { const dc = ev.event.value;
// Refetch the upstream state the hint pointed at. await refetchUpstream(dc.subjectRef);
// Tell the server to stop retrying this hint. await client.ackDataChange({ idempotencyKey: dc.idempotencyKey, sessionId, }); }}Connect-Go
_, err := client.AckDataChange(ctx, connect.NewRequest(¬ifyv1.AckDataChangeRequest{ IdempotencyKey: event.GetIdempotencyKey(), SessionId: event.GetSessionId(),}))What it actually does
The handler calls RetryTracker.Ack(key, sessionID) on
the server's in-memory tracker. The matching retry goroutine
receives a context cancel; the entry is removed from the map. If
the (key, sessionID) pair is not registered (already acked,
process restart, etc.), the call is a no-op and still returns
success.
Live connections disabled?
When NOTIFY_LIVE_CONNECTIONS_ENABLED=false, there is no
retry tracker and no live stream. AckDataChange returns
success without doing anything — clients calling it speculatively
do not need to gate on the subsystem state.
Wiring it into a UI
// React example — mark a notification read when the user clicks it.function NotificationRow({ n, onAcked }: { n: Notification; onAcked: () => void }) { return ( <li onClick={async () => { await client.ackNotification({ id: n.id }); onAcked(); }} className={n.status === "DELIVERY_STATUS_READ" ? "muted" : "unread"} > <strong>{n.title}</strong> <p>{n.body}</p> </li> );}Mark-all-read
v0.1 ships no bulk-ack RPC. The recommended client-side shape is to
page the inbox with unreadOnly: true and ack each row.
A native MarkAllRead RPC is a candidate follow-up; if
you need it before then, file an issue on the repo.
Related
- API Reference
- Subscribe over SSE
- Realtime engine — RetryTracker invariants