Sample inventory that does not trust the webhook
Keeping a PostgreSQL inventory in step with the lab's electronic notebook, given a vendor webhook that loses messages in production.
The vendor’s webhooks lose events in production. Nothing errors, so the database quietly stops matching the freezer.
How it stops trusting the webhook
The problem
The lab's inventory lives in an electronic lab notebook. Our database needs to reflect it closely enough that a scientist can trust a query instead of walking to a freezer. The vendor offers webhooks, and the obvious design is to receive each event and write it down.
That design fails quietly. In production, a measurable share of webhook deliveries never arrive. Nothing errors. The database simply drifts from reality, and the first symptom is somebody being told a sample exists when it does not. A system that is wrong but confident is worse than one that is visibly down.
What I decided, and why
Treat webhook delivery as a hint, not as the source of truth
The receiver's only job is to accept the payload, put it on a queue, and return immediately. It does no parsing and no business logic, so a malformed or surprising payload cannot cost us the event. Correctness moved out of the request path entirely, which also means the vendor's retry behaviour stops being our problem.
Reconcile on a schedule instead of hoping for completeness
Because delivery cannot be relied on, a scheduled job walks the notebook's API over a look-back window and compares it against what we hold. Anything missing is pulled in. This is the part that turns completeness from a hope into a property: the steady state is correct even if every webhook in a window is lost.
A second scheduled job does a different thing, and keeping them separate matters. Reconciliation asks “is anything missing?”. Change detection asks “has anything I already hold been edited?”. Those questions have different look-back windows and different costs, and a single job doing both ends up doing neither well.
Detect change by hashing fields, not by trusting timestamps
Reconciliation naively implemented would rewrite everything on every pass, which destroys any useful audit history and makes real changes impossible to see. Each record's meaningful fields are hashed, so a pass can tell the difference between “seen again” and “actually changed”, and only genuine changes are written.
Give every event a receipt, and make the receipt the source of safety
Each event gets a row recording where it is: queued, processing, or finished. That row is what makes reprocessing safe, because the same event arriving twice finds its own receipt rather than creating a second sample.
The subtle case is a crash halfway through. The queue makes the message visible again after a timeout, and the receipt is still sitting in processing. Matching that state, rather than only matching untouched work, is what lets the retry resume instead of quietly discarding the message. Treating a half-finished receipt as if it were finished is the bug that would be hardest to notice.
Archive the raw payload before interpreting it
Every payload lands in object storage exactly as received, before any parsing. When a mapping turns out to be wrong months later, the original is still there, and there is an endpoint that feeds archived payloads back through the same processing path to rebuild the history. Parsing logic is the thing most likely to be wrong; the raw event is the thing least likely to be.
What this guarantees
- Inventory converges on the notebook even during a total webhook outage.
- A lost message delays a row. It does not silently lose one.
- Audit history shows real changes, not reconciliation noise.
- A parsing mistake is recoverable, because the raw event was never discarded.
What I would tell someone starting this
The instinct is to make the receiver clever: validate, deduplicate, retry. That is effort spent in the one place that cannot be made reliable, because you do not control the sender. Put the cleverness in reconciliation, keep the receiver boring, and the whole system gets easier to reason about.