A tenure case at Cornell reaches the review committee as one bookmarked PDF. Inside are dozens of letters, evaluations, syllabi and statements, each in a numbered slot the committee specifies exactly. Collecting those documents takes months, and no software changes that. Assembling them into the PDF took hours of downloading, sorting and merging for every candidate, and any revision meant starting over.
They needed a web app behind the university's sign-on where a staff member picks a candidate and gets back a finished, bookmarked PDF.
The documents live in Interfolio, the faculty-information system, which can't export them in the shape the committee needs. Its API signs every request with HMAC-SHA1 over a strict string made of the verb, two blank lines, the timestamp and the path. One wrong byte gets a 401 with no explanation. A neighboring college had a Python desktop tool that already spoke it, so I read theirs and ported the signing to 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')}`
}
Once the files are out, each one needs a slot. Interfolio returns a pile of PDFs with titles people typed by hand, and the count changes from case to case. So a template is the committee's spec written down as data. Each slot has a pattern, matched case-insensitively against the file's section, title, type and filename. The patterns are loose on purpose, so Submit.*Vote.*Justification catches the committee's voting form whatever someone named it. A slot ending in .+ takes every file that matches and gives each one its own numbered subsection:
"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 named "Letter from First Last", "Last, First.pdf" or "Evaluation from First Last". A bookmark should read as a name, so the builder pulls the last name out of whichever format it gets and uses it to title and sort the subsections. The spec stays with the people who own the process. When the committee reorders the hierarchy or a department renames a section, they edit the template and the next build follows it.
The only hard requirement was matching the spec, so each half of the job could use whatever tool fit. The Node PDF libraries I tried could bookmark or merge, but not both. Python's pypdf does both, so the PDF build stayed in Python with a queue between the two languages. The Node API validates a request and queues a build on Redis. A worker downloads each file with the same signed headers and matches it to the template. The Python builder then writes one merged PDF with the committee's hierarchy as its bookmarks. Because that work runs off Node's single thread, several packets can build at once instead of waiting in line. A packet of thirty to sixty files takes about five minutes from click to finished PDF, most of it spent downloading.
The builder
Redrawn from the live tool with sample names. The list is the whole interface.
Opening a candidate brings up the only other screen. It shows what the system already knows (packet type, unit and candidate) and asks for the two documents that live outside it. Staff can attach them or skip them. Skipping builds a preview, and attaching builds the real packet.
The build modal
The preview path exists because staff wanted to see packets before the uploads were final.
Testing with the two staff members who own tenure packets turned up the rest. Some builds reported failure after they had succeeded. Double-clicks queued duplicate jobs. One template filed documents under the wrong bookmark because a variable-count section and a fixed one shared a number. By launch in late November 2025 those two had built all four current templates themselves, and the first weeks live ran without an issue. When the committee changes its mind, nobody has to call me.
-
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. ↩