Building Clinical Sample Tracking Software: Chain of Custody, Audit Trails and Two-Party Shipments
How we designed clinical sample tracking software for a biosciences company: shipment-owned statuses, two-party confirmation, audit trails and scoped access.
Key Takeaways
- In clinical sample tracking, a sample's transport status should be derived from its shipment, never typed in by hand. That single rule removes most "where is this sample?" ambiguity.
- Shipments need two parties: the lab requests confirmation, the sending site confirms, and any later change to the shipment voids that confirmation automatically.
- Every change is written to an append-only audit trail in the same database transaction as the change itself, and business records are deactivated, never deleted. This matches the audit-trail expectations of regulations such as 21 CFR Part 11.
- Access is scoped per study and per clinical site, decided by pure policy functions with a unit test for every role.
- The integration API calls the same services as the web app, so no client can bypass a business rule.
In October 2026 we delivered a clinical sample tracking platform to a biosciences company. Clinical centers taking part in the client's studies collect samples and ship them to the client, which processes them and makes results available. Before the platform, that flow lived in spreadsheets and email threads.
This article explains the design decisions that mattered most. It describes patterns, not the client's internal processes or configuration, and the same patterns apply to most systems where physical objects move between organisations and every step has to be explainable later.
What does clinical sample tracking software actually have to do?
A sample tracking system has to answer three questions at any moment: where is this sample, who is responsible for it, and how do we know? Everything else (dashboards, notifications, exports) is built on those answers.
For a multi-site study, that translates into concrete requirements:
- Several organisations use the same system: the sponsor's or lab's own staff, study coordinators, the administrators of each clinical site, and sometimes third parties.
- Each of them should see only the studies and sites they are involved in.
- A sample's history must be complete. If a status was corrected, the original value and the reason must still be visible.
- Shipments cross organisational boundaries, so both sides need to agree on what was sent.
- Nothing that touches patient privacy should leak into places it doesn't belong, such as email notifications.
None of these requirements is exotic. What makes them hard is that they must hold on every path into the system: the web interface, spreadsheet imports, and machine integrations.
The data model: why "study center" is the scoping unit
The model has three core entities: a study, a clinical center, and the study center that pairs them. A clinical center can take part in several studies; a study runs at many centers. Samples and shipments always belong to a study center, never directly to a study or a center.
That pairing is the most important modelling decision in the system. It means:
- A site code can be unique within a study without being globally unique.
- A center administrator's access is granted on the pairing, so administering a center in one study gives no access to its samples in another.
- Every list query can be filtered by a set of study-center identifiers, which keeps access scoping simple and fast.
Roles follow the same structure. There are global roles for the lab's own staff, study roles for coordinators and third parties, and center roles for site administrators. A user's access is the union of what those memberships grant, and nothing more.
How should a sample's status change?
The sample lifecycle is linear: collected → shipped → received → under processing → results available. The interesting decision is who is allowed to move it from one status to the next.
Transport statuses belong to the shipment
The first three statuses describe a physical journey, so we made them owned by shipments. Nobody can set a sample to "shipped" directly. A sample becomes shipped when the shipment it belongs to is shipped, and received when that shipment is received. Cancelling a shipment releases its samples back to "collected".
This sounds like a restriction, and it is one on purpose. In spreadsheet-based tracking, the most common inconsistency is a sample marked as shipped that is not on any shipment, or a shipment marked as received with samples still showing "collected". Deriving one from the other makes those states impossible rather than merely discouraged.
Lab statuses are direct, but only for the lab
The last two transitions, received to under processing and under processing to results available, happen inside the lab. Only the lab's own staff (or its internal integrations) can perform them, and only on samples that are no longer on an active shipment.
Corrections are a separate action
Mistakes happen, so an administrator can correct a post-transport status. A correction is not an edit: it is recorded as its own kind of status event, with a reason, alongside the original. The history shows both what happened and what was fixed.
Registration data is immutable
A sample's registration fields (its code, its site, its references, its sampling date) cannot be changed after creation, on the web or through the API. If registration data could be edited freely, the audit trail would record changes but the identity of the sample itself would become negotiable. When genuine registration errors need fixing, that calls for a dedicated, audited correction workflow, not a free-form edit form.
Two-party shipment confirmation
A shipment moves through draft → ready → shipped → received, and can be cancelled while it is still a draft or ready. The step that needed the most thought was between ready and shipped.
The lab knows what it expects to receive; the site knows what it is actually sending. We wanted both to agree before anything left the building, so:
- A lab user marks the shipment ready and requests confirmation from the site.
- The site's administrator receives a notification and confirms or rejects the shipment.
- The shipment can only move to shipped once it is confirmed.
- If anyone changes the shipment after confirmation (adds or removes a sample, edits tracking details) or cancels it, the confirmation becomes stale and an invalidation event is recorded. The site has to confirm again.
Point 4 is what makes the confirmation mean something. A confirmation that survives later edits only proves that the site agreed to some version of the shipment. Invalidating it on every relevant change ties the confirmation to exactly what was sent.
Each shipment can also produce PDF manifests. Rather than storing generated files, each manifest version stores a frozen JSON snapshot of the shipment and its samples at that moment, and the PDF is rendered from the snapshot on download. A manifest generated on Tuesday always shows Tuesday's data, even if the shipment changed on Wednesday.
An audit trail you can't skip
Regulated environments expect audit trails. The US rule on electronic records, 21 CFR 11.10(e), asks for "secure, computer-generated, time-stamped audit trails" of operator entries and actions, and requires that changes not obscure previously recorded information. Validating a system is always the job of the organisation that uses it, but those controls have to exist in the software first, so we designed them in from the start.
Three decisions do most of the work:
- Same transaction. Every business change writes its audit event (actor, source, before and after values, changed fields, request identifier) in the same database transaction as the change. If the change commits, the audit row exists; if it doesn't, neither does. There is no window in which a change happened but wasn't recorded.
- Append-only histories. Audit events, sample status events and shipment confirmation events are only ever inserted. No code path updates or deletes them.
- No hard deletes. Studies, centers, users and samples are deactivated, never deleted. The two link rows that can be removed (a revoked role, a sample taken off a draft shipment) are themselves audited.
We also use optimistic concurrency on every mutable record: each carries a version number, and an update only applies if the version hasn't changed since it was read. Two coordinators editing the same shipment can't silently overwrite each other; the second one gets a conflict and sees the current state.
Access control: one decision, tested for every role
With five roles across three scopes, ad-hoc permission checks would have drifted within weeks. We used the structure described in our article on structuring authorization:
- An access context is loaded once per request: the local user, their global roles, and their active study and center memberships.
- Policies are pure functions. A check such as
canConfirmShipment(context, shipment)returns true or false;getListScope(context)returns "all", "self", "none" or the set of studies and centers the user can see. - Services call the policies and enforce the result. Pages, form actions and API routes never decide access themselves.
Because policies are pure functions, they are cheap to test. Every policy is tested against a shared set of personas covering each role, including users whose access has been deactivated, and the explicit "never" rules, such as nobody can delete a sample, are tested too. At handover the suite had more than 180 unit tests, all of them running in CI.
Invitation-only onboarding
Nobody can sign up and get access. A lab administrator invites a person with the exact roles they should receive; the invitation is linked to that person's account only if the invited email address is verified on it. A stray sign-up with no matching invitation gets an account with no access at all. Deactivating a user in the application blocks them on their next request, even if their login session is still valid.
One set of rules for the web app and the API
The client's internal systems need to register samples and update shipments programmatically, so the platform includes an authenticated machine API with scoped credentials.
The rule we held to: an API write goes through the same service as the equivalent web action. The same validation, the same policy checks, the same audit events and the same notifications. An integration cannot create a sample directly in "shipped", cannot edit registration fields, and cannot skip site confirmation, because the code that would let it do so doesn't exist.
It's tempting to write API handlers that talk to the database directly because it's faster to build. It also creates a second, slightly different implementation of every business rule, and the two drift apart. Our own pre-handover audit caught exactly this kind of divergence in an early version of the sample endpoints; routing them through the shared services fixed it for good.
Notifications without patient data
Coordinators and site administrators receive email notifications at the steps that concern them: a sample registered, a status changed, results available, a shipment awaiting confirmation, confirmed, rejected, shipped or received.
Two choices shaped them:
- Messages carry identifiers, not people. Notifications include sample and shipment codes, study and center names, statuses and tracking details. They never include patient data, and they leave out the free-text sample reference a site might use internally. This follows the GDPR principle that personal data should be "limited to what is necessary" (Article 5(1)(c)).
- A business change never fails because email did. Notifications are written to an outbox table in the same transaction as the change and delivered afterwards, with retries. We describe that pattern in detail in The transactional outbox in practice.
Handover: documentation as a deliverable
The client has its own technical team, and the platform was always meant to be run by them. So the last phase of the project wasn't a launch; it was a handover:
- A self-audit of our own code, with each finding labelled as confirmed, likely or a suggestion, then fixed or explicitly accepted.
- Fourteen documents covering installation, architecture, the data model, security and the role matrix, operations, the API and the third-party services.
- An architecture decision log with 25 recorded decisions, each with its context and consequences.
- A changelog and a prioritised roadmap, so the next developer knows what is planned and why.
This is the part of a project that is easiest to cut and most expensive to miss. Code ownership on paper means little if the people who own it can't change it safely. We cover the contractual side in Who owns the code?.
Frequently asked questions
Is a custom sample tracking platform a replacement for a LIMS?
Usually not. A LIMS manages work inside the lab: instruments, assays, results. A sample tracking platform like this one manages what happens between organisations: which sites send what, who confirmed it, and who should be told. The two often integrate rather than compete.
Can a system like this be 21 CFR Part 11 compliant?
Software can provide the technical controls Part 11 expects, such as audit trails, unique user accounts and access limited to authorised individuals. Compliance itself depends on how the system is validated and used within the organisation's quality system, so no vendor can certify it alone.
What stack did you use?
TypeScript and Next.js for the application, PostgreSQL with Prisma for data, a managed identity provider for sign-in, and a Docker image built by CI. The authorisation model lives in the application's own database, so the identity provider can be replaced without touching access rules.
How long does a project like this take?
It depends on the number of workflows and integrations. A platform of this scope is typically a matter of months with a small senior team, not weeks. A paid discovery phase can turn that into a firm estimate for your project; see how to commission custom software.
If you are tracking samples, specimens or other physical items across sites and the spreadsheet is starting to show its limits, read the full case study, see how we work with life sciences teams, or tell us about your project.