PT
Overview

Building JoBoEco, Part 5: certificates, proceedings, and the first real event

April 6, 2026
7 min read

The problem with academic certificates

Certificates for academic events have a requirement that most certificate generation systems ignore: they need to be verifiable years later.

A participant might need to present their JoBoEco certificate 3 years down the line, in a CV evaluation process. The verifier will want to confirm that certificate is authentic — that that person really attended that event.

The common approaches for this are:

  1. Centralized database: you go to a URL, type a code, and the system queries the database. Works as long as you keep the server and database active. In 5 years, when the domain expires? In 10 years?

  2. QR with URL: the QR on the certificate points to a verification URL. Same problem — depends on active infrastructure.

  3. Asymmetric cryptography: you sign the certificate content with a private key. The verifier uses the public key to confirm. Doesn’t depend on infrastructure, but the UX for the verifier is complex.

The approach I adopted was different: HMAC-SHA256 with database-free verification.

How HMAC works in the certificate

HMAC (Hash-based Message Authentication Code) is a message authentication mechanism using a hash function and a secret key. The idea is simple: given an enrollmentId and a CERT_SECRET (an environment variable on the server), the HMAC is deterministic — it always produces the same code for the same combination.

src/lib/certificate.ts
import { createHmac } from 'crypto'
export function generateCertificateCode(enrollmentId: string): string {
return createHmac('sha256', process.env.CERT_SECRET!)
.update(enrollmentId)
.digest('hex')
.substring(0, 16) // 16 chars: readable and sufficiently unique
.toUpperCase()
}

This code is embedded as a QR Code in the certificate PDF. The QR URL points to joboeco.org/certificate/verify?code=XXXX&enrollment=yyy.

The verification page does not query the database. It recalculates the HMAC:

src/app/certificate/verify/page.tsx
export default async function VerificationPage({ searchParams }) {
const { code, enrollment } = await searchParams;
const expectedCode = generateCertificateCode(enrollment);
const isValid = code === expectedCode;
return isValid ? <ValidCertificate /> : <InvalidCertificate />;
}

If the code matches the recalculated HMAC for that enrollmentId, the certificate is authentic. No database. No external call. Works as long as the verification code exists — and the HMAC algorithm continues to be applied in the same way.

The obvious limitation: if you ever need to revoke a certificate (which rarely happens, but can), you’d need a revocation list — which reintroduces the database dependency. For JoBoEco’s use cases, I accept that tradeoff.

Client-side PDF generation

The PDF generation happens in the browser, using jsPDF. There is no server endpoint that generates certificates.

Why on the client?

  1. Zero processing cost on the server — each download is computation on the user’s device
  2. Zero download latency — the PDF is generated locally and downloaded immediately
  3. No sensitive data in transit — the certificate is assembled with data the frontend already has
src/lib/certificate.ts
import jsPDF from 'jspdf'
export function generateCertificatePDF({
participantName,
eventName,
eventDate,
hoursLoad,
signatories,
certCode,
enrollmentId,
}: CertificateData): Blob {
const doc = new jsPDF({ orientation: 'landscape', format: 'a4' })
// Layout, fonts, text, event logo...
doc.setFont('helvetica', 'bold')
doc.setFontSize(24)
doc.text(participantName, 148.5, 100, { align: 'center' })
// QR Code with verification URL
const verificationUrl = `https://joboeco.org/certificate/verify?code=${certCode}&enrollment=${enrollmentId}`
// adds QR via qrcode.react converted to data URL
return doc.output('blob')
}

The limitation: visual customization of the PDF is tedious with jsPDF. It’s all manual positioning in points. I considered using @react-pdf/renderer (which has a more declarative API), but it doesn’t work in Cloudflare Workers and would have complicated the deploy. jsPDF runs entirely on the client, which solves the problem.

For JoBoEco’s certificates, the layout is institutional and stable — once it looked good, it didn’t need to change often. The jsPDF maintenance cost is acceptable.

QR Code check-in

In-person check-in uses html5-qrcode to read QR Codes via the organizer’s device camera. The flow is:

  1. Participant shows the registration QR Code (available in the participant area)
  2. Organizer points their phone camera at the QR
  3. The system validates the registration and records attendance
  4. Immediate feedback on screen: ✓ confirmed or ✗ not found / already checked in
src/app/organizer/checkin/page.tsx
import { Html5QrcodeScanner } from 'html5-qrcode'
// QR Code contains the enrollmentId
function onScanSuccess(enrollmentId: string) {
fetch(`/api/checkin`, {
method: 'POST',
body: JSON.stringify({ enrollmentId }),
})
.then((res) => res.json())
.then((data) => setCheckinResult(data))
}

Simple, but functional. The organizer uses their personal phone, no additional hardware required. All validation logic lives in the /api/checkin endpoint, which checks whether the enrollmentId exists, whether the registration is paid, and whether check-in has already been done (idempotency again).

A detail that makes a difference: the endpoint returns an alreadyCheckedIn field, and the frontend displays a different message for “first time here” vs “already checked in”. This prevents confusion when someone scans the same QR twice by accident.

The organizer panel also has a real-time table of all check-ins and a CSV export for attendance reports. The CSV was surprisingly one of the most-used features.

The academic proceedings

Publishing the proceedings is the module that, for the event’s audience, has the most perceived value — and was technically one of the most interesting to build.

The proceedings portal displays all papers accepted through review, with:

  • Full-text search by title, abstract, and authors
  • Filtering by knowledge area
  • Complete details for each paper (title, authors, abstract, area)
  • PDF download of the full paper (if submitted), served directly from R2
  • BibTeX export for any paper

The search is implemented with LIKE on D1, which for proceedings volume (dozens to a few hundred papers) is fast enough. No full-text index or Algolia needed.

BibTeX was the feature I enjoyed implementing most. The format is familiar to any academic, and lets you import references directly into Zotero, Mendeley, or LaTeX:

src/lib/bibtex.ts
export function generateBibTeX(submission: SubmissionForBib): string {
const key = `${submission.authorLastName.toLowerCase()}${submission.year}${submission.titleWords[0].toLowerCase()}`
return `@inproceedings{${key},
author = {${submission.authorName}},
title = {{${submission.title}}},
booktitle = {Proceedings of the ${submission.eventName}},
year = {${submission.year}},
address = {${submission.eventCity}},
pages = {${submission.pages ?? ''}},
note = {${submission.area}}
}`
}
@inproceedings{silva2025floristica,
author = {Silva, M. A.},
title = {{Vascular epiphyte floristics in Atlantic Forest fragment}},
booktitle = {Proceedings of the Botany and Ecology Conference},
year = {2025},
address = {São Carlos, SP},
note = {Systematic Botany}
}

A small UX detail that made a difference: clicking “copy BibTeX” copies the text to the clipboard and a toast confirms it. It seems trivial, but it eliminates the “select all > copy” step that most systems require.

The first real event

When JoBoEco went live for the first time, at a real university event with a few hundred participants, some things I expected to be problems weren’t — and some things I didn’t expect broke.

What worked better than expected: Pix. The payment completion rate was high and no manual confirmation was needed at all. The polling + webhook system worked perfectly — not a single “orphaned” payment.

What surprised me negatively: device diversity during check-in. html5-qrcode has inconsistent behavior on certain older Android models — the camera wouldn’t focus correctly for QR Code reading. I added an alternative field on the check-in screen to type the code manually, which covered the problematic cases.

What I learned about academic users: the certificate PDFs were downloaded massively right after the event. The BibTeX copy option was used far more than I expected — the target audience really does use reference managers. And nobody reported a problem with offline certificate verification, which is the best kind of feedback for a system that should be invisible.

Closing the series — what I’d leave out if starting over

Building JoBoEco was a constant exercise in prioritization. Some features that didn’t make it into v1 (but are on the backlog):

  • Automatic refunds: today, refunds are manual
  • Push notifications: today, only transactional email via Resend
  • Mobile app: today, the site is responsive but there’s no native app for check-in
  • Full multi-tenancy: today, the system runs one event per D1 database; the architecture supports multiple events but not multiple independent organizations

JoBoEco is a project born from a real frustration, built for a real context, and running in real production. It’s not the most sophisticated system out there, but it solves the problem it promised to solve — and in the end, that’s what matters.

The code is in production at joboeco.org. If you organize academic events and want to talk about what I learned, you can find me on GitHub or by email.