At-least-once delivery is a promise your clearinghouse keeps and your database regrets. The same remittance arrives twice, and suddenly you've posted a payment twice. Here is the pattern that makes EDI processing effectively-once and its side effects exactly-once — with a small open-source TypeScript library you can read.
Here is a bug that has shipped in more revenue-cycle systems than anyone would like to admit. A payer sends an X12 835 remittance. Your integration picks it up, parses it, and posts the payment to the ledger. A few hours later — a retried SFTP poll, a clearinghouse re-transmit, a redelivered queue message — the same 835 arrives again. Your integration picks it up, parses it, and posts the payment again. Now your books say a claim was paid twice, and someone in finance spends a Tuesday figuring out why.
The root cause isn't a parsing bug. It's an assumption. Almost every channel EDI travels over — SFTP directories you poll, message queues, webhook callbacks, a clearinghouse's own retry logic — guarantees at-least-once delivery, not exactly-once. Duplicates aren't an edge case; they're a certainty at scale. The mistake is writing a processor that only works if every message arrives exactly once.
You cannot make delivery exactly-once. Make processing idempotent instead.
Exactly-once delivery is famously close to impossible in a distributed system — the sender can never be sure its acknowledgment arrived, so a correct sender re-sends, and duplicates are the price of never losing a message. The winning move is to stop fighting it: accept that the same message will arrive more than once, and make processing it a second time a safe no-op. That property is idempotency, and for EDI it has three parts.
We built a small, runnable reference of all three as an open-source TypeScript library — an idempotency key, an inbox, and an outbox. The rest of this post is what each one does and why.
1. The idempotency key: let the message identify itself
To recognize a duplicate you need a stable identifier that is the same for the re-send and different for a genuinely new message. You could hash the whole payload, and as a fallback you should — but X12 hands you a better key for free. Every interchange carries an ISA13 interchange control number: the sender's own unique id for that transmission. A re-send of the same interchange carries the same ISA13; a new remittance carries a new one. Combined with the group (GS06) and transaction-set (ST02) control numbers, it's a natural, meaningful dedup key — far better than a content hash, because it survives a byte-level reformat that means nothing and it's exactly what the sender uses to mean "this is the same document."
So step one is to read the envelope and derive the key from ISA13/GS06/ST02, falling back to a SHA-256 of the raw bytes only when the message isn't parseable X12.
2. The inbox: recognize duplicates, process once
The inbox is a table keyed by that idempotency key. Before doing any work, the processor looks the key up:
- Never seen it → insert a
PENDINGrow and run the handler. - Seen it, and it's
COMPLETED→ do not run the handler; return the stored result. This is the duplicate path, and it's a no-op by design. - Seen it, and it's
PENDING→ a concurrent copy is already processing it right now; bow out.
That last case matters more than it looks. At-least-once delivery plus a bit of scale means two copies of the same message can land simultaneously on two workers. If both check "have I seen this?" at the same moment, both see "no," and both process. The inbox has to claim the key atomically — in Postgres that's a unique constraint on the key (or a SELECT … FOR UPDATE), so exactly one copy wins the insert and the rest fall into the duplicate/in-progress path. Get this right and "the same 835 three times" produces exactly one payment; get it wrong and you've just moved the double-post from sequential to concurrent.
3. The outbox: side effects that survive a crash
The inbox makes processing happen once. But processing has effects — post a payment, emit a claim.created event, send a 999 acknowledgment — and those effects reach other systems. What happens if the process dies in the gap between "marked the inbox COMPLETED" and "told the ledger about the payment"? If the effect lived only in memory, it's gone: the inbox now says done, so the message will never be reprocessed, and the payment silently never posts. That's the mirror-image bug of the double-post, and it's worse because it's invisible.
The outbox fixes it by refusing to separate the two. The side effect is written to an outbox table in the same database transaction as the inbox result. Either both commit or neither does — you can never record the result without enqueuing the effect, or enqueue the effect without recording the result.
A separate relay then reads pending rows from the outbox and delivers them, marking each DELIVERED on success. Because the effect is durable in the database rather than in a process's memory, a crash loses nothing: on restart the relay finds the still-PENDING effect and finishes the job.
One honest subtlety, and it's the same one that started this post: the relay's delivery is itself at-least-once. If it publishes an effect and crashes before recording DELIVERED, it will publish again on restart. The outbox guarantees the effect is enqueued exactly once and never lost — not that publish() fires exactly once. So the consumer on the other end must be idempotent too. It's turtles all the way down, which is precisely why the pattern is worth making reusable: you solve idempotency once, as a library, instead of re-deriving it — usually via a production incident — in every service.
See it in code — it's open source
The reference implementation is open source: a small TypeScript library with the ISA13 idempotency key, a transactional inbox, a transactional outbox with a relay, and a pluggable store — plus tests that prove the guarantees (the same 835 processed three times and five-way-concurrently yields exactly one effect; a simulated crash between commit and delivery still delivers exactly once; a failed handler rolls back with no effect enqueued). There's a live playground where you click "Send ×3," watch the inbox dedup, and watch the "payments posted" counter stubbornly stay at 1. Read it or build on it: github.com/Nirmitee-tech/edi-idempotent-processor. The in-memory store is a reference — production plugs Postgres with a unique constraint on the inbox key and FOR UPDATE SKIP LOCKED in the relay, and the README is honest about that.
It pairs naturally with our X12-to-FHIR mapping engine: parse the 837/835 into a canonical model there, process it reliably here. Both are part of a small family of standards-first healthcare building blocks we've open-sourced, alongside a prior-authorization workflow engine and a HIPAA-by-default starter.
Where we come in
We build the integration plumbing that healthcare runs on — the parsers, the reliable pipelines, and the FHIR-native platforms on top of them — the parts that have to be correct at three in the morning when a clearinghouse decides to re-send yesterday's files. If duplicate claims, lost acknowledgments, or a fragile EDI pipeline sound familiar, let's talk.
