A file input is not a required-file checklist
Every product that needs a W-9, a certificate of insurance, or a license starts with <input type="file">. That control is honest: it picks a file. It does not know the file was required. It does not know it expired last Tuesday. It does not tell your backend when the slot is empty again.
That gap is why teams grow a spreadsheet of “who sent what,” a second portal with another login, or a cron that guesses expiry from a filename. The upload worked. The requirement did not.
What the input actually does
A file input — or a dropzone library in front of S3 — answers one question: did bytes land in a bucket? The rest of the job is left to you:
- Which files are required for this person, vendor, or vehicle
- Whether the file is still under review, valid, expiring, or expired
- What happens when it expires and they must upload again
- How your backend refuses the next action until every in-scope slot is currently good
None of that is storage. It is a checklist with state.
Slots, status, then gate
A required-file checklist is a different primitive. You declare the slots. The subject sees which ones are missing. Your team can review. Expiry rules move a slot from valid to expiring to expired without a homegrown date spreadsheet. Signed webhooks tell your backend when that changes. Email and SMS stay in your stack.
Then you gate. Before you activate an account or pay a vendor, you assert that every required file is currently valid — not that something was uploaded once.
That is why embedding the upload in your app matters more than a prettier input. Your users already have an account. Put the checklist on a page they already use. Holectus never asks them to log in again.
The file upload API with review and expiry is the same idea over HTTP: store the file, track the lifecycle, send document.expiring / document.expired / validity.changed, and let assert return 200 or 409. A dumb upload endpoint cannot do that, no matter how fast the PUT is.
const res = await fetch(
`${api}/api/validity/subjects/${id}/assert`,
{ method: 'POST', headers: { Authorization: `Bearer ${token}` } },
);
if (res.status === 409) {
// missing, expired, or pending review — do not proceed
}
Build the input, buy the checklist
If all you need is a byte store, keep the input. If you need required slots, review, expiry, and a gate, you are no longer building an upload widget — you are building a small compliance product inside your product. Build vs buy a file upload checklist is the comparison; the landings above are the jobs people actually search for.
Use the native control for “pick a file.” Use a checklist when the file has to stay valid.