On this page
Document verification
Every permit, licence or certificate the platform issues is a PDF sealed with the issuing ministry's electronic seal, time-stamped, and recorded in the platform's register of issued documents. It carries a QR code and a verification code. Anyone can check it: a citizen on the portal, a police officer at a roadblock, a bank, a border post or a partner system.
This guide explains the ways in, what the answer means, and the rules every verifier must follow. The most important rule is simple: never report a document as more valid than the platform said it is.
What makes a document checkable
| Element | What it is |
|---|---|
| Verification code | A random code printed under the QR (not the permit number, which is sequential and would let anyone walk the register). On a federated installation the code starts with the issuing node's key, for example EXAMPLE-MDA.7KQ2-M9XZ-4A. |
| QR code | The verification address <portal>/verify/<code>. When it fits, it is followed by # and a compact signed payload (a JWS) that can be checked offline. The part after # never reaches a web server: opening the link sends only the address. |
| Electronic seal | A PAdES signature with the ministry's seal certificate, issued by the platform's certificate authority. Issued permits are sealed at the long-term level (B-LTA) and carry their own validation evidence. |
| Time-stamp | An RFC 3161 time-stamp that proves when the seal was applied. A seal time that is only claimed by the signer is reported as such. |
| Issued record | The register entry the document matches, found by the SHA-256 of the file (or of any signed revision of it). It carries the lifecycle: active, expired, suspended, revoked, replaced. |
A document whose sealing failed is not issued as if it were sealed: it waits in the pending-seals queue that administrators monitor in the signing console.
The ways in
For people (public, no account)
| Call | What it does | Limit per address |
|---|---|---|
Portal page /verify/<code> | What the QR opens. Shows the result in plain words, with the offline payload checked too when present. | as the API below |
GET /api/permits/verify/{code} | The issued record for a verification code: status, dates, issuer, holder initials and surname, masked identifier. Codes of another node are relayed to that node. | 30 per 10 minutes |
POST /api/permits/verify-identity | A permit number together with the holder's identity number, in the body. | throttled |
POST /api/verify/qr { "payload": "<scanned text>" } | Checks a scanned QR text or its signed payload. | 30 per 10 minutes |
POST /api/verify/document (body: the PDF, Content-Type: application/pdf) | Validates the file itself: the seal, the time-stamp, changes since sealing, and the record it matches. | 10 per 10 minutes |
POST /api/verify/document/report?format=pdf (or format=json) | The same check, answered as a report to keep: a PDF sealed by the platform, or JSON shaped like ETSI TS 119 102-2. | 10 per 10 minutes |
GET /api/verify/{code}?issuer=<MDA code> | The national verification hub: this platform, its peer nodes and the legacy ministry systems, one answer shape. | throttled |
GET /api/receipts/verify/{ref}?sig=... | A payment receipt: valid only for a paid case's receipt with a valid signature. | 30 per 10 minutes |
GET /api/upstream/VerifyCertificates?PermitNumber=...&DocType=... | The convention other government systems already use with PACRA and MOTA. Answers a bare true or false, nothing else. | 60 per minute |
Uploaded PDFs are read in memory only and never stored. Files over 10 MB or 100 pages, password-protected files and non-PDFs are refused (413, 422). When the validator is busy the answer is 503 with Retry-After.
For partner systems (Partner API)
| Call | Scope |
|---|---|
GET /api/v1/permits/verify/{code} | permits:verify |
GET /api/v1/verify/{documentNumber}?issuer= | permits:verify |
POST /api/v1/verify/documents { "items": [{ "ref", "code" }, { "ref", "pdf": "<base64>" }] } | permits:verify for codes, documents:validate for PDFs (up to 20 items) |
Use the Partner API for any system-to-system checking. The public endpoints are throttled per address for people, and a bank or a border post will hit their limits. See Partner API for tokens.
curl -s "$API/permits/verify/7KQ2-M9XZ-4A" -H "Authorization: Bearer $TOKEN"
# bulk: two codes and one PDF in one call
curl -s -X POST "$API/verify/documents" -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"items":[{"ref":"a1","code":"7KQ2-M9XZ-4A"},{"ref":"a2","code":"EXAMPLE-MDA.3HTR-QQ2P-7C"},
{"ref":"a3","pdf":"JVBERi0xLjcK..."}]}'
The bulk answer is { "results": [{ "ref", "status", "result" | "error" }] }, one entry per item in your order.
Reading the result
Every way in ends in the same result vocabulary:
| Field | Meaning |
|---|---|
outcome | valid, warning, invalid, unknown or not-signed |
headline | One plain-English sentence |
points[] | Each finding with a stable code, a tone (ok, warn, bad, info), parameters and English text. Translate or act on the code. |
seal | Who sealed it, for which ministry, when, and whether that time is proven by a time-stamp (timeSource: time-stamp) or only claimed (claimed); the certificate's serial, expiry and status |
changed | true if the file changed after sealing, false if not, null when there was nothing sealed to compare |
record | The issued record the file matches (no holder data), or that it was not checked, or that it matches nothing |
technical | ETSI indication and sub-indication, signature level, SHA-256 of the file, validation time |
The code check (/permits/verify/{code}) answers with the record: valid (true only when the status is active), status, statusLabel, validFrom, validUntil, validityText, issuer, service, masked holder, supersededBy, suspendedUntil, duplicate, backCaptured and verifiedAt. On a federated installation it may also carry verifiedBy (the node that answered).
Seal validity and document status are separate facts
A seal can be genuine while the document is no longer in force. The platform always reports the two side by side, never one instead of the other:
- "The seal is genuine; the document was revoked on 3 March 2026 because it was issued in error."
- "The seal is genuine; this document has been replaced by EXAMPLE/2026/00042."
- "The seal is genuine; the permit expired on 31 December 2025."
- The seal certificate was revoked after sealing: "withdrawn later, the seal stays valid". Revoked before the
proven signing time: the seal is not valid.
- A historical paper permit captured by an office is marked
backCaptured. Until a second officer validates it, itsstatus is
pending-validation, never valid. - Seals made under the legacy ZamSign roots are shown as "historical".
Rules for every verifier
- Never overstate validity. Show "valid" only when the platform said
valid(and, for a record, the status isactive). Show a warning as a warning. - Show the document status next to the seal result. Do not reduce a revoked or expired document to "signature
OK".
- Do not invent an expiry. When
validUntilis empty, showvalidityTextas given. The platform never claims"no expiry" when the validity is not a period.
- "Could not check" is not "invalid". A
503(for exampleNODE_UNAVAILABLEwhen the owning node is down) meansthe check could not be done now. Say so and offer to try again.
- "Not found" is one answer for everything. Unknown codes, malformed codes and codes of unknown nodes all get the
same answer, so the API reveals nothing about what exists.
- Do not cache results. Answers are
Cache-Control: no-store: a permit can be suspended at any moment. - Show no more personal data than the platform gave you. Answers carry initials, surname and a masked identifier
on purpose. The signed QR payload carries initials only, never an identity number.
- A boolean is not an explanation.
VerifyCertificatesanswers onlytrueorfalse; use it only where a systemalready speaks that convention, and use the full answer everywhere else.
Every check is audited (a government should be able to see who checked which document), but the audit records only whether the check matched and its source, never the holder.
The national trust list
The platform publishes which certificates seal government documents, time-stamp them and answer for their revocation:
GET /.well-known/gsb-trust-list.json: the list, as exact bytes (typegsb-trust-list, version 1).GET /.well-known/gsb-trust-list.json.p7s: a detached CMS signature over those bytes by the platform seal.?sequence=Non either returns an older version, so you can see what was trusted at a given time.GET /api/verify/trustis the people's view;GET /api/verify/trust/certificates/{fingerprint}downloads onecertificate (
.cer, or?format=pem) for Adobe Reader or Windows.
Each entry has a kind (root-ca, issuing-ca, seal, tsa, ocsp), a status (active, retired, superseded, revoked, expired, historical), revokedAt where relevant, the certificate (PEM), CRL and OCSP addresses, and for seals a kid and public jwk used to check QR payloads. Officers' personal certificates are not published.
On a federated deployment the hub publishes the national list; each node's entries are accepted only when they chain to the hub's own configured root. A peer can never add a root.
Verify the list before you trust it. For example, with OpenSSL and the national root obtained out of band:
curl -sO https://platform.example/.well-known/gsb-trust-list.json
curl -sO https://platform.example/.well-known/gsb-trust-list.json.p7s
openssl cms -verify -binary -inform DER -in gsb-trust-list.json.p7s \
-content gsb-trust-list.json -CAfile national-root.pem -out /dev/null
Checking a QR payload offline
A border post or a bank branch without a connection can check the signed QR payload against a copy of the trust list downloaded earlier. The payload is { v: 1, c: code, n: number, t: title, h: holder initials, m: issuer code, f / u: valid from / until, i: issued, k: node }.
// Node 18+, no dependencies. `list` is a verified, recent copy of gsb-trust-list.json (parsed).
const crypto = require('crypto');
const b64url = (s) => Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64');
const HASH = { RS256: 'sha256', ES256: 'sha256', ES384: 'sha384' };
function checkQrPayload(scannedText, list, now = new Date()) {
const jws = scannedText.includes('#') ? scannedText.split('#')[1] : scannedText;
const [h, p, s] = String(jws).split('.');
if (!h || !p || !s) return { ok: false, reason: 'malformed' };
let header, payload;
try { header = JSON.parse(b64url(h)); payload = JSON.parse(b64url(p)); } catch (_) { return { ok: false, reason: 'malformed' }; }
if (!HASH[header.alg] || payload.v !== 1) return { ok: false, reason: 'malformed' };
const entry = (list.entries || []).find((e) => e.kid === header.kid);
if (!entry) return { ok: false, reason: 'unknown-key' };
if (entry.kind !== 'seal') return { ok: false, reason: 'not-a-seal' };
const key = crypto.createPublicKey({ key: entry.jwk, format: 'jwk' });
const ec = header.alg.startsWith('ES');
const ok = crypto.verify(HASH[header.alg], Buffer.from(`${h}.${p}`),
ec ? { key, dsaEncoding: 'ieee-p1363' } : key, b64url(s));
if (!ok) return { ok: false, reason: 'bad-signature' };
// a key revoked on or before the issue date does not vouch for this document
const issued = payload.i ? new Date(`${payload.i}T23:59:59Z`) : null;
if (entry.status === 'revoked' && entry.revokedAt && (!issued || new Date(entry.revokedAt) <= issued)) {
return { ok: false, reason: 'revoked-key' };
}
const until = payload.u ? new Date(`${payload.u}T23:59:59Z`) : null;
return { ok: true, payload, validity: until && until < now ? 'expired' : 'in-date' };
}
An offline check proves the payload was sealed by a listed key. It cannot tell you about a suspension or revocation of the document after your copy of the list was taken, or after the last time you were online. Say so on screen ("checked offline on ..."), refresh the list whenever you can, and check online when the decision matters.
Testing
Use the sandbox the owning MDA gives you. Its documents are sealed by a development certificate authority that production refuses, so a sandbox document never verifies against the production trust list, and the other way round. Never use real people's documents in tests or screenshots.
Related: Partner API, Payments with ZamPay (receipt verification).