Building an open QR verifier for eGovPH and PSA
I reverse engineered the public QR verification flow from eVerify and reconstructed it as a personal project with the goal of providing a free and open verifier for both eGovPH and PSA IDs without relying on third-party vendors like Sumsub. I focused on mapping the public behavior from the source maps of the public web app and API host, then recreated the flow in an open app and confirmed it works end to end.
What I set out to do
- I wanted an open solution that can scan government QR codes and verify records for free.
- I limited the scope to public endpoints and observable client behavior, using only what was available via the bundled front end and source maps.
- The objective was to understand the shape of requests and responses.
- I aimed to keep the UX simple: scan a code, classify it, and, if it is eGovPH, wait for consent and fetch details; if it is PSA, display the decoded identity data if present.
- The open app mirrors the experience of the public site, validating the approach.
How I derived the flow from source maps
- I loaded the minified React bundle with source maps and traced the route that handles
/check, which mounts a verification page with a QR scanner. - I followed the debounced mutation that posts the raw QR string to a public classification endpoint.
- The transformation utilities in the bundle showed how the response is normalized, and how different QR types select different UI templates.
- For eGovPH, I found a Firebase realtime database listener wired to a consent path keyed by tracking number.
- After consent, the client issues a GET request to fetch the full profile. Asset links for face images are signed and short-lived.
- This map was sufficient to rebuild the same flow in my app using pseudo-implementations with the same inputs and outputs.
The open app behavior
- The scanner reads a raw QR string and sends it to a check endpoint.
- The response decides whether to show data directly or to open a consent listener (for eGovPH).
- Once consent arrives, the app fetches details and displays them, including the face image via a signed URL.
- For PSA codes, the normalized payload is rendered immediately when present.
- The app shows a simple Tier I verdict when the result is a basic yes or no.
- Validation was performed by scanning real codes and comparing the experience to the public site, achieving parity and building confidence in the approach.
Pseudo code outline
function onScan(rawQr):
state.loading = true
result = classifyQr(rawQr)
if result.type == "eGovPH":
showConsentWaiting(result.tracking_number)
consent = waitForConsent(result.tracking_number)
if consent.accepted:
profile = fetchEgovProfile(result.tracking_number)
renderProfile(profile)
else:
showConsentTimeout()
else if result.type in ["National ID", "National ID Signed", "ePhilId", "Philsys Card"]:
data = transformIdentity(result)
renderIdentityModal(data)
else if result.type == "Philsys Card Number":
renderPcnOnly(result.pcn)
else:
renderUnknownQr()
state.loading = false
function classifyQr(value):
res = http.post(PUB_API + "/api/pub/qr/check", { value })
if not res.ok:
throw Error("Invalid QR")
payload = res.json()
return {
type: payload.meta.qr_type or "unknown",
...payload.data
}
function waitForConsent(trackingNumber):
ws = openFirebaseSocket(APP_ID, NAMESPACE)
subscribe(ws, "/egov_ph_profile_consent/" + trackingNumber)
startTimer(CONSENT_TIMEOUT_MS)
loop:
msg = ws.read()
if msg.path.endsWith(trackingNumber) and msg.data.is_accepted == true:
return { accepted: true, at: msg.data.accepted_at }
if timerExpired():
return { accepted: false }
function fetchEgovProfile(trackingNumber):
res = http.get(PUB_API + "/api/pub/qr/egov_ph?tracking_number=" + trackingNumber)
if not res.ok:
throw Error("Profile fetch failed")
data = res.json()
return normalizeProfile(data)
function transformIdentity(input):
out = {}
out.first_name = input.first_name
out.middle_name = input.middle_name
out.last_name = input.last_name
out.birth_date = input.birth_date
out.pcn = input.pcn
out.photo = input.face_url or input.base64_image
out.tier = input.tier
return out
function renderProfile(profile):
showName(profile.first_name, profile.last_name)
showDob(profile.birth_date)
if profile.photo:
showImage(profile.photo)
if profile.tier == "Tier I":
showVerdict(profile.verified)
if profile.tier == "Tier II":
showExtended(profile)
function renderUnknownQr():
showMessage("Unknown QR Type")What worked and lessons learned
- The public bundle and source maps were sufficient to reconstruct all client-visible steps.
- The consent step is the only real-time piece and can be mirrored with a WebSocket listener subscribing to the same path.
- Short-lived asset links require immediate fetch and display.
- The minimal contract for an open verifier is straightforward: a single classify call, an optional consent wait, and a profile fetch.
- I kept the design free to use and code light to encourage adoption in communities and agencies that want simple verification without vendor lock-in.
Why an open verifier
- The aim is to let people scan eGovPH and PSA IDs for verification in a transparent way.
- The hope is to reduce costs and simplify integrations for civic projects and small businesses.
- An open implementation makes audits and improvements easier and invites contributions to expand support for new QR formats.
- This empowers the community and avoids dependence on costly verification providers.