A tenure case at Cornell reaches the review committee as one bookmarked PDF, dozens of letters, evaluations, syllabi and statements, each in its own numbered slot in a hierarchy the committee spells out exactly. Chasing down the contents takes months and no software shortens that. Assembling the PDF is hours of downloading, classifying and merging, per section, per dossier, per candidate, and a revision starts the whole pass over.
What they needed was a web app behind the university's sign-on where a staff member picks a candidate and gets a finished, bookmarked PDF on the other side.
The documents live in Interfolio, the faculty-information system, which does not export them in the shape the committee needs. Its API authenticates with HMAC-SHA1 request signing, assembling a canonical string from the verb, two blank lines, the timestamp and the path, and the format is strict enough that a wrong byte comes back as a dry 401 with no explanation. A neighboring college had a Python desktop tool that already spoke it, so I read theirs and ported the signing flow into Node1:
function generateHMACHeader(privateKey, publicKey, timestamp, requestString, requestVerb) {
const verbRequestString = `${requestVerb}\n\n\n${timestamp}\n${requestString}`
const signedHash = crypto.createHmac('sha1', privateKey).update(verbRequestString, 'utf8')
return `INTF ${publicKey}:${signedHash.digest().toString('base64')}`
}
Signed requests get the files out, and then every file needs its slot. What comes back from Interfolio is a pile of PDFs with titles people typed, in counts that change from case to case, so a template is the committee's spec written down as data. Each slot carries a pattern matched case-insensitively against everything Interfolio says about a file, its section, title, type and filename, and the patterns are forgiving on purpose, so Submit.*Vote.*Justification catches the committee voting form however it was titled. A slot keyed with a trailing .+ takes however many files match it, one numbered subsection each:
"5.3.+": {
"title": "Letters from External Reviewers Selected by the Department",
"parent": "5.3",
"dynamicSection": true,
"extractLastName": true,
"files": [{ "source": "interfolio", "match": "Letters from External Reviewers Selected by Department", "sort": true }]
}
Letters arrive as "Letter from First Last", "Last, First.pdf", or "Evaluation from First Last", and a bookmark should read as a name, so the builder pulls the last name out of whichever format shows up and titles and sorts the subsections with it. That keeps the spec in the hands of the people who own the process. When the committee reorders the hierarchy or a department rewords a section, they edit the template and the next build picks it up.
Matching the spec was the entire requirement, so each half of the job could use whatever tool fit it. The Node PDF libraries I tried could bookmark or merge but not both and Python's pypdf does both, so the PDF build stayed Python with a queue and a worker sitting between the two languages. The Node API validates the packet and queues a build on Redis, a worker downloads each file with the same signed headers and matches them against the template, and the Python builder writes one merged PDF with the committee's hierarchy as its bookmarks. Keeping that work off Node's single thread also means several packets landing at once build in parallel rather than in line. Click to finished packet runs about five minutes, dominated by downloads for packets in the thirty-to-sixty-file range.
The builder
Redrawn from the live tool with sample names. The list is the whole interface.
Opening a candidate is the only other screen. It shows what the system already knows, the packet type, unit and candidate, and asks for the only two documents that live outside it, which a staff member can attach or skip. Skipping builds a preview and attaching builds the real thing.
The build modal
The preview path exists because staff wanted to see packets before the uploads were final.
The test rollout with the two staff members who own tenure packets surfaced the rest of it. Builds occasionally reported failure after succeeding, double-clicks queued duplicate jobs, and one template put files under the wrong bookmark because a variable-count section and a fixed one collided over the same number. By launch in late November 2025 the two of them had generated all four current templates themselves, and the first weeks live ran without an issue. Nobody has to call me when the committee changes its mind.
-
The signing scheme (verb, two empty lines, timestamp, path, HMAC-SHA1, base64, an
INTF publicKey:digestenvelope) matches the public reference implementation maintained by the University of Pennsylvania: github.com/sas-irad/interfolio-api. ↩