External Messaging Integration Patterns
These patterns show how to bridge Medplum messaging to external channels (SMS, email, push notifications) using Bots and on-demand $execute. For in-platform automations (Tasks, reminders, scheduling), see Messaging Automations.
Bots are an advanced feature that may need to be enabled for your project.
Outbound: Notify External Systems on New Messages
Create a Bot that sends an external notification for a child Communication (same handler whether you trigger it from your app server or from another Bot). The handler reads event.input as the Communication resource.
// Bot: bridge a new in-app child Communication to SMS, email, push, etc. (Twilio, SendGrid, Firebase, …).
export async function externalNotifyOnMessageHandler(
medplum: MedplumClient,
event: BotEvent<Communication>
): Promise<void> {
const communication = event.input;
const messageText = communication.payload?.[0]?.contentString;
const recipientRef = communication.recipient?.[0]?.reference;
if (!recipientRef) {
return;
}
const recipient = await medplum.readReference({ reference: recipientRef });
let contact: string | undefined;
if (recipient.resourceType === 'Patient') {
const patient = recipient as Patient;
contact =
patient.telecom?.find((t) => t.system === 'phone' && t.value)?.value ??
patient.telecom?.find((t) => t.system === 'email' && t.value)?.value;
}
// Send via Twilio, SendGrid, etc. using `contact` and `messageText`
console.log(`Sending notification to ${contact ?? recipientRef}: ${messageText}`);
}
After you create the in-app message, invoke the Bot with executeBot() so provider API errors (invalid number, throttling, etc.) return to the caller instead of failing asynchronously behind a subscription.
// After persisting the child Communication, call $execute so Twilio/SendGrid errors surface to the caller (see Bot $execute docs).
const outboundNotifyMessageId = 'emr-message-001';
const childMessageForNotify = await medplum.createResourceIfNoneExist(
{
resourceType: 'Communication',
status: 'in-progress',
identifier: [{ system: 'http://example.com/emr-message', value: outboundNotifyMessageId }],
partOf: [{ reference: 'Communication/thread-header-id' }],
recipient: [{ reference: 'Patient/homer-simpson', display: 'Homer Simpson' }],
payload: [{ contentString: 'Your lab results are ready.' }],
sent: new Date().toISOString(),
},
`identifier=http://example.com/emr-message|${outboundNotifyMessageId}`
);
await medplum.executeBot('{your-external-notify-bot-id}', childMessageForNotify, ContentType.FHIR_JSON);
The example uses createResourceIfNoneExist() with a stable application message id so a retry of the same send does not insert duplicate Communication rows. See Bot $execute for authentication options, content types, and async execution.
When you handle live updates (WebSocket Subscription notifications or similar), locate the new Communication by scanning bundle.entry for resourceType === 'Communication' instead of assuming a fixed entry index. See Searching and Querying Threads.
Inbound: Create Messages from External Webhooks
Wire inbound provider webhooks to a Bot using $execute (see Consuming Webhooks). Some partners send OAuth-signed or otherwise authenticated webhooks; others require a public URL and an unauthenticated webhook endpoint—use the pattern that matches your vendor and lock down access (signatures, narrow AccessPolicy, and validation inside the Bot).
When an inbound message arrives from an external channel, the Bot typically solves two problems: sender resolution (who sent this?) and thread matching (which conversation does it belong to?). FHIR conditional references can resolve the sender at write time when the external system provides enough context.
Sender Resolution with Conditional References
Use a conditional reference on sender so the server resolves the patient at create time — no separate search step:
// Inbound webhook: resolve Patient by phone at write time; set partOf using a thread-matching strategy below.
const inboundSmsWebhook = {
fromPhoneNumber: '+15551234567',
messageText: 'Thanks — I will review them',
providerMessageId: 'SMxxxxxxxx',
};
await medplum.createResourceIfNoneExist(
{
resourceType: 'Communication',
status: 'in-progress',
identifier: [{ system: 'http://example.com/sms-webhook-message', value: inboundSmsWebhook.providerMessageId }],
sender: {
reference: `Patient?phone=${inboundSmsWebhook.fromPhoneNumber}`,
},
payload: [{ contentString: inboundSmsWebhook.messageText }],
sent: new Date().toISOString(),
medium: [
{
coding: [
{
system: 'http://terminology.hl7.org/CodeSystem/v3-ParticipationMode',
code: 'SMSWRIT',
display: 'SMS',
},
],
},
],
// partOf — see thread matching strategies below
},
`identifier=http://example.com/sms-webhook-message|${inboundSmsWebhook.providerMessageId}`
);
The snippet above only demonstrates sender resolution. It omits partOf on purpose — you still match or create a thread using one of the strategies below before you treat the example as a complete inbound message.
The conditional reference Patient?phone=<number> resolves to exactly one matching Patient at write time (no separate search in your Bot). For email, use Patient?email=<address>.
POSTIf any conditional reference on the Communication you are creating does not resolve — zero matches or more than one match — the server rejects the entire create. There is no partial write. That applies to sender (for example Patient?phone=...) and, in Strategy 1 below, to partOf (for example Communication?identifier=<system>|<conversationId> matching how you indexed the thread header).
Order this to match your product rules:
- Reject unrecognized inbound senders — Using
Patient?phone=...(orPatient?email=...) without pre-creating the patient is appropriate: the request fails until that phone or email uniquely identifies an existing patient. - Accept messages from new numbers or addresses — Resolve identity first (search, create, or upsert a
Patient), then create theCommunicationwith a normal literal reference such asPatient/{id}onsender, or with a conditional you know will match.
The MedplumClient helper createResourceIfNoneExist() is described in Working with FHIR (see Creating Patient — it performs a search-then-conditional-create style flow). For update-or-create in one step, see Upsert on the same page.
Other fallbacks (such as a display-only reference without a resolved reference) depend on your validation rules — they may still fail if your server requires a resolved sender.
Use Communication.medium to record which channel the message used. That supports channel indicators in your UI and downstream routing back through the same channel.
Thread Matching Strategies
Sender resolution and thread matching are separate. The conditional reference above answers “who sent this” but you still need to decide which thread the message belongs to. Pick a strategy based on what the external system provides.
Strategy 1: External Conversation ID (Recommended When Available)
If the external system has its own conversation identifier (Twilio Conversations, email Message-ID / In-Reply-To, etc.), store that id on the thread header as an identifier when the thread is first created. The system and value must match what you pass in the conditional partOf reference on inbound messages. Set medium on the header if you use round-trip routing (see below).
Upsert the thread header (no partOf, no payload on the header) so setup is idempotent if the same conversation id is seen more than once. upsertResource() matches on the same identifier search you will use for conditional partOf:
// Strategy 1 setup: thread header with external conversation id (must match conditional partOf below).
const exampleTwilioConversationSid = 'CHxxxxxxxx';
const threadHeaderIdentifierQuery = `identifier=https://twilio.com|${exampleTwilioConversationSid}`;
const threadHeaderWithExternalId = await medplum.upsertResource(
{
resourceType: 'Communication',
status: 'in-progress',
identifier: [{ system: 'https://twilio.com', value: exampleTwilioConversationSid }],
topic: { text: 'SMS conversation' },
subject: { reference: 'Patient/homer-simpson', display: 'Homer Simpson' },
sender: { reference: 'Practitioner/doctor-alice-smith', display: 'Dr. Alice Smith' },
recipient: [
{ reference: 'Patient/homer-simpson', display: 'Homer Simpson' },
{ reference: 'Practitioner/doctor-alice-smith', display: 'Dr. Alice Smith' },
],
medium: [
{
coding: [
{
system: 'http://terminology.hl7.org/CodeSystem/v3-ParticipationMode',
code: 'SMSWRIT',
display: 'SMS',
},
],
},
],
},
threadHeaderIdentifierQuery
);
console.log(threadHeaderWithExternalId);
Inbound messages can then use a conditional reference for partOf:
// Strategy 1: match thread via identifier on the header (e.g. Twilio Conversation SID, email thread id).
const inboundSmsWithConversation = {
fromPhoneNumber: '+15551234567',
messageText: 'Thanks — I will review them',
conversationSid: 'CHxxxxxxxx',
providerMessageId: 'SMxxxxxxxx',
};
await medplum.createResourceIfNoneExist(
{
resourceType: 'Communication',
status: 'in-progress',
identifier: [
{ system: 'http://example.com/sms-webhook-message', value: inboundSmsWithConversation.providerMessageId },
],
partOf: [
{
reference: `Communication?identifier=https://twilio.com|${inboundSmsWithConversation.conversationSid}`,
},
],
sender: {
reference: `Patient?phone=${inboundSmsWithConversation.fromPhoneNumber}`,
},
payload: [{ contentString: inboundSmsWithConversation.messageText }],
sent: new Date().toISOString(),
medium: [
{
coding: [
{
system: 'http://terminology.hl7.org/CodeSystem/v3-ParticipationMode',
code: 'SMSWRIT',
display: 'SMS',
},
],
},
],
},
`identifier=http://example.com/sms-webhook-message|${inboundSmsWithConversation.providerMessageId}`
);
Both sender and partOf can resolve via conditional references in a single POST. The partOf reference uses the same identifier search shape you stored on the thread header (for example Communication?identifier=https://twilio.com|<conversationSid> — the system and value must match how you set Communication.identifier). If no header matches, that conditional fails and the whole Communication create fails, same as an unmatched Patient?phone. You must also include the external id on every inbound payload so the server can resolve partOf.
Strategy 2: Patient + Active Thread Lookup (Fallback for Stateless Channels)
If the channel is stateless (basic SMS, email without thread headers), there may be no external conversation id. The Bot resolves the patient, then searches for their most recent open thread header. This example uses the same active-thread filter as other messaging docs (status:not=completed,entered-in-error,stopped,unknown — see Searching and Querying Threads). The webhook payload must include the provider’s stable message id (providerMessageId in the example) for deduplication.
// Strategy 2: stateless SMS — resolve Patient, then attach to most recent open thread or create one.
export async function inboundSmsActiveThreadLookupHandler(medplum: MedplumClient, event: BotEvent): Promise<void> {
const webhookData = event.input as Record<string, string>;
const patient = await medplum.searchOne('Patient', {
phone: webhookData.fromPhoneNumber,
});
if (!patient?.id) {
return;
}
const activeThreads = await medplum.searchResources('Communication', {
'part-of:missing': true,
subject: `Patient/${patient.id}`,
'status:not': 'completed,entered-in-error,stopped,unknown',
_sort: '-_lastUpdated',
_count: '1',
});
let threadId: string | undefined;
if (activeThreads.length > 0) {
threadId = activeThreads[0].id;
} else {
const newThread = await medplum.createResource({
resourceType: 'Communication',
status: 'in-progress',
topic: {
text: `SMS conversation with ${formatHumanName(patient.name?.[0]) || 'Patient'}`,
},
subject: { reference: `Patient/${patient.id}` },
});
threadId = newThread.id;
}
if (!threadId) {
return;
}
await medplum.createResourceIfNoneExist(
{
resourceType: 'Communication',
status: 'in-progress',
identifier: [{ system: 'http://example.com/sms-webhook-message', value: webhookData.providerMessageId }],
partOf: [{ reference: `Communication/${threadId}` }],
sender: { reference: `Patient/${patient.id}` },
payload: [{ contentString: webhookData.messageText }],
sent: new Date().toISOString(),
medium: [
{
coding: [
{
system: 'http://terminology.hl7.org/CodeSystem/v3-ParticipationMode',
code: 'SMSWRIT',
display: 'SMS',
},
],
},
],
},
`identifier=http://example.com/sms-webhook-message|${webhookData.providerMessageId}`
);
}
The handler above returns quietly when no Patient matches the phone search. In production, log the raw number, write a dead-letter or audit record, or page an operator so retries and fraud attempts are visible.
When you create a new thread from inbound SMS, consider setting sender, recipient, and topic consistently with Messaging Data Model so inbox searches and access policies behave as expected.
Strategy 3: New Thread Per Inbound Message
Always create a new thread header for each inbound message. No matching logic. This fits one-off acknowledgments where messages are independent rather than a single ongoing conversation.
| Strategy | Best for | Tradeoff |
|---|---|---|
| External conversation ID (conditional) | Platforms with conversation state (Twilio, email threads) | Cleanest and declarative; requires storing the external id on the header |
| Patient + active thread lookup | Stateless SMS or simple email | Search logic in the Bot; ambiguous if several open threads exist for one patient |
| New thread per message | One-off notifications | Ongoing conversations become fragmented |
Providers such as Twilio and SendGrid often retry webhook delivery. The same inbound event can hit your Bot more than once. Store the provider’s message id (or similar) on the Communication as an identifier and create with createResourceIfNoneExist() or another conditional-create pattern so a retry does not insert a second identical message.
Round-Trip: Routing Replies Through the Original Channel
When the patient reached you on SMS, provider replies composed in your app should often go back out on SMS. After you upsert the thread header with medium indicating SMS (as in Strategy 1), persist the provider’s in-app reply as a child Communication, then $execute the same style of outbound Bot with that resource as input. The Bot inspects the thread header: if medium includes SMS, resolve the patient from subject, read Patient.telecom for a phone number, and call your SMS API.
// Bot: when a provider sends an in-app reply, mirror it to SMS if the thread is tagged for SMS (medium on header).
export async function roundTripReplyBotHandler(medplum: MedplumClient, event: BotEvent<Communication>): Promise<void> {
const message = event.input;
const threadRef = message.partOf?.[0]?.reference;
if (!threadRef) {
return;
}
const threadHeader = await medplum.readReference({ reference: threadRef });
if (threadHeader.resourceType !== 'Communication') {
return;
}
const header = threadHeader as Communication;
const threadUsesSms = header.medium?.some((m) => m.coding?.some((c) => c.code === 'SMSWRIT'));
if (!threadUsesSms) {
return;
}
const patientRef = header.subject?.reference;
if (!patientRef) {
return;
}
const subject = await medplum.readReference({ reference: patientRef });
if (subject.resourceType !== 'Patient') {
return;
}
const patient = subject as Patient;
const phone = patient.telecom?.find((t) => t.system === 'phone' && t.value)?.value;
const body = message.payload?.[0]?.contentString;
// Send via Twilio (or your SMS provider) using `phone` and `body`
console.log(`Round-trip SMS to ${phone ?? patientRef}: ${body}`);
}
Orchestration from your app or API layer:
// Provider flow: persist the in-app reply, then $execute the round-trip bot with that Communication as input.
const roundTripMessageId = 'app-composed-reply-001';
const providerReplyMessage = await medplum.createResourceIfNoneExist(
{
resourceType: 'Communication',
status: 'in-progress',
identifier: [{ system: 'http://example.com/app-composed-message', value: roundTripMessageId }],
partOf: [{ reference: 'Communication/thread-header-with-sms-medium' }],
sender: { reference: 'Practitioner/doctor-alice-smith', display: 'Dr. Alice Smith' },
recipient: [{ reference: 'Patient/homer-simpson', display: 'Homer Simpson' }],
payload: [{ contentString: 'We can schedule a follow-up if you have questions.' }],
sent: new Date().toISOString(),
},
`identifier=http://example.com/app-composed-message|${roundTripMessageId}`
);
await medplum.executeBot('{your-round-trip-bot-id}', providerReplyMessage, ContentType.FHIR_JSON);
Your product may key off the last inbound message’s medium instead of the header if that fits your model better.
See Also
- Communication FHIR resource API
- Messaging Data Model
- Searching and Querying Threads
- Messaging Automations
- Working with FHIR — conditional create,
createResourceIfNoneExist(), upsert - Bots and Bot $execute