Event-Driven Healthcare: Bridging Medplum Webhooks with NestJS SQS/RabbitMQ Consumers
Technology Blogs

Event-Driven Healthcare: Bridging Medplum Webhooks with NestJS SQS/RabbitMQ Consumers

Shubham Gapat
Full stack developer

The first time a Medplum subscription fires into your application, it feels like magic. A clinician marks a DiagnosticReport as completed, and milliseconds later an HTTP POST lands on your /webhooks/fhir endpoint with the full resource attached. No polling, no cron jobs, no diffing lastUpdated timestamps. The event finds you.

The trouble starts the day that endpoint does real work. You add a step that calls an external payer API, which one afternoon takes nine seconds to respond. Medplum’s delivery times out, marks the attempt failed, and retries. Your handler which had already half-finished the first time, runs again. Now you have two payer submissions for one report, your endpoint is the slowest thing in the system, and a backlog of lab results is queuing up behind it.

This is the central tension of webhook-driven integration: the source system wants a fast acknowledgment, but the work the event represents is often slow, fallible, and must happen exactly once. The resolution is to stop doing the work in the webhook handler at all. Receive the event, prove it’s real, hand it to a durable queue, and acknowledge all in milliseconds. Then let NestJS consumers drain that queue on their own schedule, with their own retry and failure semantics. This post walks through that bridge, end to end.

What Medplum Actually Sends You

A Medplum subscription is a FHIR Subscription resource with a rest-hook channel. You give it a criteria a FHIR search string and an endpoint:

{
"resourceType": "Subscription",
"status": "active",
"reason": "Notify on completed lab reports",
"criteria": "DiagnosticReport?status=completed",
"channel": {
"type": "rest-hook",
"endpoint": "https://api.yourapp.com/webhooks/fhir"
},
"extension": [
{
"url": "https://www.medplum.com/fhir/StructureDefinition/subscription-secret",
"valueString": "a-cryptographically-secure-secret"
}
]
}

Whenever a resource matches the criteria, Medplum POSTs the resource as application/fhir+json to your endpoint. A few details from the platform shape everything downstream.

The subscription-secret extension turns on HMAC signing. Medplum computes an HMAC-SHA256 of the request body using your secret and sends it in the x-signature header. You recompute it on your side and compare; a match proves the request came from Medplum and wasn’t tampered with in transit. Note that rest-hook endpoints now require HTTPS by default, self-hosted deployments can opt out, but you shouldn’t.

Delivery is retried on failure. The subscription-max-attempts extension controls how many times (1, 18, defaulting to a small handful), and subscription-success-codes lets you define which HTTP status codes count as success. The implication is unavoidable: the same event can arrive more than once. At-least-once delivery is the contract, so at-least-once processing is a bug unless you make it idempotent.

Deletes are special. If you opt into delete notifications, the body is an empty {} and the deleted resource is identified by an X-Medplum-Deleted-Resource header instead. Your handler has to branch on that rather than assuming a body is always present.

One sharp edge worth internalizing early: you cannot subscribe to AuditEvent. Every subscription firing creates an AuditEvent, so subscribing to them would trigger an infinite notification spiral.

Why You Don’t Process the Event Inline

It’s tempting to just do the work in the handler the resource is right there in the body. Resist it, for four reasons.

There’s a delivery timeout budget you don’t control. Medplum waits a bounded time for your 2xx; exceed it and the attempt is counted as failed and retried, even if your code eventually succeeded. Slow work and retried work are the same thing from the source’s perspective.

Webhook delivery is effectively serial per subscription, so a slow handler creates head-of-line blocking. The nine-second payer call doesn’t just delay that one report it backs up every event behind it. Your throughput collapses to the speed of your slowest downstream dependency.

Retries on a non-idempotent handler manufacture duplicates, and in clinical contexts duplicates have physical consequences: a second order, a second message to a patient, a second claim. The retry that was meant to improve reliability becomes the thing that causes harm.

And processing inline couples your application’s availability to Medplum’s delivery window. If your downstream is briefly down, you either drop events or wedge the pipeline. A queue absorbs that; an HTTP handler can’t.

The Bridge: Receive, Verify, Enqueue, Acknowledge

The pattern is to make the webhook handler do almost nothing. Its entire job is to confirm the event is authentic and not a duplicate, drop it onto a queue, and return 200 immediately. All the slow, fallible work moves behind the queue.

Medplum ──POST──▶ NestJS webhook receiver ──▶ SQS / RabbitMQ ──▶ NestJS consumer(s)

The receiver is the thin synchronous edge. The consumers are where the actual integration logic lives, free to be slow because nothing upstream is waiting on them.

The Receiver in NestJS

Two things make signature verification correct, and both are easy to get subtly wrong.

First, you must verify against the raw request body, not a parsed-then-re-serialized version. JSON.stringify(req.body) will not reliably reproduce the exact bytes Medplum hashed key ordering and whitespace differ and your HMAC won’t match. Capture the raw buffer. In NestJS, enable it at bootstrap:

const app = await NestFactory.create(AppModule, { rawBody: true });

Second, compare signatures in constant time to avoid leaking information through timing.

@Controller('webhooks')
export class FhirWebhookController {
constructor(private readonly queue: EventQueueService) {}
@Post('fhir')
@HttpCode(200)
async receive(
@Headers('x-signature') signature: string,
@Headers('x-medplum-deleted-resource') deletedRef: string | undefined,
@RawBody() raw: Buffer,
): Promise<{ accepted: boolean }> {
// 1. Authenticate the sender against the RAW body.
const expected = createHmac('sha256', process.env.MEDPLUM_WEBHOOK_SECRET!)
.update(raw)
.digest('hex');
const ok =
signature?.length === expected.length &&
timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!ok) throw new UnauthorizedException('Invalid signature');
// 2. Build a stable envelope. Deletes carry no body.
const resource = deletedRef ? null : JSON.parse(raw.toString('utf8'));
const envelope: EventEnvelope = deletedRef
? { kind: 'delete', reference: deletedRef }
: {
kind: 'change',
resourceType: resource.resourceType,
id: resource.id,
versionId: resource.meta?.versionId,
resource,
};
// 3. Hand off to the queue and return immediately.
await this.queue.publish(envelope);
return { accepted: true };
}
}

Notice what isn’t here: no payer calls, no database writes beyond the enqueue, no business logic. By the time the resource needs real work, the HTTP response has already gone back to Medplum. The handler stays well inside the delivery timeout no matter how slow your integrations are.

Need Reliable Event Processing for Fhir Data? Talk to Our Team

SQS or RabbitMQ?

NestJS speaks to both through its microservice transport layer, so the choice is about operational fit, not framework support.

Reach for SQS when you’re on AWS and want the queue to be someone else’s problem. It’s fully managed, scales without capacity planning, and gives you dead-letter queues and visibility timeouts as configuration rather than infrastructure. Its FIFO queues offer ordering and exactly-once processing within a message group, which maps neatly onto “process all events for one patient in order.” The cost is less routing flexibility fan-out means pairing it with SNS.

Reach for RabbitMQ when you need richer routing or you’re not on AWS. Topic and direct exchanges let you route one event to many queues by pattern, consumer acknowledgments and per-message TTLs are first-class, and you can run it anywhere. The cost is that you operate it clustering, disk, and memory watermarks are now your concern.

A reasonable default: SQS if you’re already on AWS and your routing needs are simple, RabbitMQ if routing topology is central to your design. Both support the pattern equally well; the bridge doesn’t care which is on the other side.

The Consumer: Where the Real Work Lives, Done Safely

The consumer pulls envelopes off the queue and does the slow integration work. This is where idempotency, ordering, and failure handling earn their keep.

@Injectable()
export class DiagnosticReportConsumer {
constructor(
private readonly dedupe: IdempotencyStore,
private readonly payer: PayerClient,
) {}
@SqsMessageHandler('fhir-events', false)
async handle(message: AWS.SQS.Message): Promise {
const evt: EventEnvelope = JSON.parse(message.Body!);
if (evt.kind !== 'change') return; // handle deletes separately
// Idempotency: a (resource, version) pair processes at most once.
// A redelivered duplicate short-circuits before any side effect.
const key = `${evt.resourceType}/${evt.id}@${evt.versionId}`;
if (await this.dedupe.seen(key)) return;
// Ordering: discard events we've superseded. If we've already
// processed a newer version of this resource, this one is stale.
if (await this.dedupe.hasNewerThan(evt.resourceType, evt.id, evt.versionId)) {
return;
}
// The slow, fallible work safe to take its time here.
await this.payer.submit(evt.resource);
await this.dedupe.record(key, evt.versionId);
}
}

Three defenses are doing the work. The idempotency key combines resource identity with meta.versionId, so the exact same version processed twice is a no-op this neutralizes Medplum’s at-least-once retries. The ordering guard handles out-of-order delivery: queues don’t guarantee global order, and a quick edit can produce two events whose arrival order is reversed, so you compare versions and drop anything you’ve already moved past. And because the side effect sits between the dedupe check and the record, a crash mid-processing simply leaves the event un-recorded, and a redelivery reprocesses it cleanly.

When the work genuinely fails the payer is down, the data is malformed you let the message return to the queue and retry. After a configured number of attempts it lands in a dead-letter queue, where it waits for a human or an automated replay instead of either vanishing or blocking the pipeline behind it. The DLQ is the difference between “we lost a lab result” and “we have a lab result to look at.”

Letting Two Retry Systems Coexist

A subtle design question: Medplum has its own retry budget (subscription-max-attempts), and now so does your queue. If both retry the same failure, you get a multiplicative mess.

The clean rule is that acknowledgment transfers ownership. The moment the receiver enqueues an event and returns 200, Medplum considers delivery successful and stops retrying its job is done. From that point, all retry responsibility belongs to your queue and consumer. Medplum’s retries only ever cover the narrow window before the event reaches your queue: a network blip, a receiver restart. Everything after enqueue is yours. This keeps the two systems from fighting and makes failure behavior easy to reason about there’s exactly one retry authority for any given stage.

It also means your receiver should be paranoid about one thing only: never returning 200 for an event it failed to enqueue. If the publish fails, throw, return a non-success code, and let Medplum retry delivery. That’s the one case where you want its retries.

Fan-out: One Event, Many Reactions

A single Patient update might need to reach billing, the care-coordination service, and an analytics pipeline. You don’t want three subscriptions hammering three endpoints with three signature checks you want one event multiplied after it’s safely inside your system.

On AWS, the receiver publishes to an SNS topic; multiple SQS queues subscribe, each feeding its own NestJS consumer group. On RabbitMQ, the receiver publishes to a topic exchange and each consumer binds a queue with a routing pattern like patient.* or diagnosticreport.completed. Either way the webhook receiver stays a single thin entry point, and adding a fourth consumer later is a config change rather than a new Medplum subscription and a new public endpoint to secure.

The Shape of the Whole Thing

Step back and the architecture is simple to describe. Medplum watches FHIR resources and fires signed webhooks. A thin NestJS receiver authenticates each one against the raw body, wraps it in a versioned envelope, drops it on a queue, and acknowledges in milliseconds. Durable consumers drain the queue at their own pace, deduplicating by resource version, discarding stale events, doing the slow integration work, and routing genuine failures to a dead-letter queue. Fan-out happens inside your boundary, not by multiplying subscriptions.

The payoff is that each part fails independently and visibly. A slow payer no longer backs up your lab results. A brief outage no longer drops events they wait in the queue. A duplicate delivery no longer creates a duplicate claim. And when something does go wrong, it lands in a dead-letter queue you can inspect rather than disappearing into a retry loop you can’t see. That’s what event-driven healthcare looks like when it’s built to survive a Tuesday afternoon: not just events finding your code, but events your code can be trusted to handle exactly once, even when everything downstream is having a bad day.

Conclusion

Webhooks fail quietly when a slow handler blocks everything behind it. The fix isn’t a faster handler, it’s separating “acknowledge fast” from “process safely.” A thin receiver that verifies, dedupes, and enqueues in milliseconds removes the head-of-line problem entirely, and once Medplum gets its 200, retry pressure moves off your webhook endpoint and onto infrastructure built to absorb it.

That queue-first approach doesn’t just fix throughput, it fixes correctness. Version-based idempotency turns duplicate deliveries into no-ops instead of duplicate records. Stale-event checks keep out-of-order retries from overwriting newer state. And failures land in a dead-letter queue where you can see and replay them, not in a retry loop that silently drops events. Same event, twice or ten times, same outcome.

Shubham Gapat

Shubham Gapat

Full stack developer

Connect Now

Shubham is a Full stack developer with 3.5+ years of experience. He has experience in technologies like ReactJS, Redux, Python, Django and UI Frameworks. His expertise in building interactive and responsive web applications, writing efficient, optimized and DRY code. He enjoys learning about new technologies.

Share This Blog

Read More Similar Blogs

Let’s #Transform Healthcare,# Together.

Partner with us to design, build, and scale digital solutions that drive better outcomes.

Location

Global Tech Teams LLC, 525 Washington Blvd, Industrious at Newport Tower, Jersey City, NJ 07310, United States.

Contact

+1 408 786 5974
contact@mindbowser.com
BOOK A QUICK CONSULTATION

Have a Healthcare Project in Mind?

Let’s discuss your goals, workflows, and next steps in a focused consultation call.

Calendar icon Schedule a Call

Contact form