Skip to main content

Read Receipts and Message Status

How you track sent, received, and read for messages depends on two things: whether a message can have more than one recipient (group threads), and whether you need to search for unread messages via the API. Use the decision guide below to choose the right model.

Decision Guide

  1. Can a message have more than one recipient (i.e. more than two total participants in the thread)?
  1. Do you need to search for unread messages via the API?

Option A: Simple Model (1:1 Threads Only)

Use this when each message has a single sender and a single recipient (no group threads). Sent, received, and read are represented directly on the Communication resource.

StageRepresentation
sentCommunication.sent is populated
receivedCommunication.received is populated
readCommunication.status is "completed"

Pros: Searchable (e.g. Communication?status=completed or status:not=completed), simple, no extra resources.
Limitation: Does not support group threads; one recipient per message only.

Mark a Message as Read

// Option A (1:1 only): Mark a message as read by setting received and status
await medplum.patchResource('Communication', 'message-id', [
{ op: 'add', path: '/received', value: new Date().toISOString() },
{ op: 'replace', path: '/status', value: 'completed' },
]);

Query Unread Messages

// Option A (1:1 only): Query messages not yet read (status not completed)
await medplum.searchResources('Communication', {
recipient: getReferenceString(profile),
'status:not': 'completed,entered-in-error,stopped,unknown',
'part-of:missing': false,
_sort: '-sent',
});

Option B: Extension on Thread Header

Use this when you have group threads but do not need to search for unread messages via the API. Store per-participant read state in a custom extension on the thread header Communication (the one with no partOf).

Pros: Works for group threads; no extra resources beyond the thread header; simpler than the Task-based model.
Limitation: Not searchable via API — you cannot run a query like "all threads where Practitioner X has unread messages." You can still, for a thread you already have, read the extension and compare to the latest message to show a "user is not up to date" dot and sort the thread list by last message timestamp.

Thread Header With Read-State Extension

// Option B: Thread header with extension for per-participant last-read state (one block per participant)
const threadWithReadState = {
resourceType: 'Communication' as const,
status: 'in-progress' as const,
subject: { reference: 'Patient/homer-simpson', display: 'Homer Simpson' },
topic: { text: 'Lab results - April 10th' },
extension: [
{
url: 'https://medplum.com/fhir/StructureDefinition/thread-read-state',
extension: [
{ url: 'participant', valueReference: { reference: 'Practitioner/doctor-alice-smith' } },
{ url: 'lastRead', valueReference: { reference: 'Communication/latest-message-id' } },
{ url: 'lastReadAt', valueDateTime: '2024-03-15T14:30:00.000Z' },
],
},
],
};

Update Last-Read When a User Views the Thread

Find the participant's read-state block by URL (e.g. with getExtension from @medplum/core), update lastRead and lastReadAt, then save with updateResource (PUT). Avoid patching by extension index (e.g. /extension/0/...), since the order of extensions can vary and other extensions may exist on the resource.

// Option B: Find this participant's read-state block by URL, update lastRead and lastReadAt, then PUT
const threadReadStateUrl = 'https://medplum.com/fhir/StructureDefinition/thread-read-state';
const header = await medplum.readResource('Communication', threadHeader.id);
const currentUserRef = getReferenceString(profile);
const readStateBlock = header.extension?.find((ext) => {
if (ext.url !== threadReadStateUrl || !ext.extension) {
return false;
}
const participantExt = ext.extension.find((e: { url?: string }) => e.url === 'participant');
return (participantExt as { valueReference?: { reference?: string } })?.valueReference?.reference === currentUserRef;
});
if (readStateBlock?.extension) {
const lastReadExt = readStateBlock.extension.find((e: { url?: string }) => e.url === 'lastRead');
const lastReadAtExt = readStateBlock.extension.find((e: { url?: string }) => e.url === 'lastReadAt');
if (lastReadExt) {
(lastReadExt as { valueReference?: { reference: string } }).valueReference = {
reference: `Communication/${latestMessageId}`,
};
} else {
readStateBlock.extension.push({
url: 'lastRead',
valueReference: { reference: `Communication/${latestMessageId}` },
});
}
const now = new Date().toISOString();
if (lastReadAtExt) {
(lastReadAtExt as { valueDateTime?: string }).valueDateTime = now;
} else {
readStateBlock.extension.push({ url: 'lastReadAt', valueDateTime: now });
}
await medplum.updateResource(header);
}

Option C: Task-Based Model (Groups + Searchable)

Use this when you have group threads and need to search for unread messages via the API. Each "message needs to be read by recipient" is represented by a Task with a dedicated code (e.g. read-receipt). Tasks are created when messages are sent and completed when the recipient reads.

Pros: Supports group threads; read receipts are searchable (e.g. unread count, unread per thread).
Con: Most complex data model; requires creating and updating Task resources when messages are sent and when the user marks as read.

Mark as Read

When the user reads the message, complete the Task:

// Option C: Mark read-receipt Task completed when user reads the message
await medplum.patchResource('Task', readReceiptTaskId, [{ op: 'replace', path: '/status', value: 'completed' }]);

Find Unread in a Specific Thread

// Option C: Find unread messages in a specific thread for current user
await medplum.searchResources('Task', {
code: 'https://medplum.com/task-codes|read-receipt',
owner: getReferenceString(profile),
focus: `Communication/${threadHeader.id}`,
status: 'requested',
});

Count Total Unread for the Current User

// Count unread messages by querying read-receipt Tasks still in 'requested' status
// The returned Bundle's `total` field contains the unread count
await medplum.search('Task', {
code: 'https://medplum.com/task-codes|read-receipt',
owner: `Practitioner/${userId}`,
status: 'requested',
_total: 'accurate',
_count: 0,
});

See Also