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, stamps read_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(&notifyv1.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.UpdateStatus takes tenantID and id only — not userID. 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 explicit GetNotification(claims.TenantID, claims.UserID, id) first and maps a miss to CodeNotFound — never CodePermissionDenied, 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(&notifyv1.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