Automate Diploma Fraud Detection with a PDF Checking API

This article is a snapshot – content was accurate as of April 2026 (code examples tested against the API as of October 2026). The product evolves actively; specific counts, examples, and detection rules may have changed since publication – see the changelog for the current state.
A diploma arrives as a PDF attachment in your ATS. Someone on the recruiting team opens it, sees a plausible seal and a plausible name, and moves the candidate on. If you build the hiring tooling, that step is the one you can automate: run the file through a check the moment it is uploaded, and write the result to the candidate record before a human looks at it.
This article is about wiring that check in. It covers the two-step API call, the code that sits between your ATS and the HTPBE? API, what to do with each verdict, and where the check stops. The last part matters most, because a file check tells you something about the PDF and nothing about whether a university actually awarded the degree.
What the check does and does not tell you
HTPBE? reads the structure and metadata of the PDF file and reports whether there is evidence the file was changed after it was created, or whether its origin means an integrity check cannot apply. It describes the file. It does not read the diploma’s content, and it does not look anything up at the institution.
That split decides how you design the flow:
- File check (this article): was this PDF edited after it was generated, and does its origin allow a meaningful answer? Fast, automated, runs on every upload.
- Issuer or registry verification: did this institution award this degree to this person? Requires the issuing university, a national registry or a credential-verification service. Slower, per-candidate, and the only thing that answers the second question.
A file can pass the first and still fail the second. Treat the file check as triage that decides which candidates need the slower step first, never as a substitute for it. For the same reason it is not an identity or credential verification product, and nothing below should be read as one.
The integration in two calls
The API is two-step. POST /v1/analyze downloads the PDF from a URL you provide, analyses it synchronously and returns only a check ID. GET /v1/result/{id} returns the full result. Typical analysis time is a few seconds for ordinary documents.
Three practical constraints shape the ATS side:
- The URL must be reachable without authentication. The API downloads the file itself, so a URL behind your ATS login will fail with
download_failed. Generate a short-lived signed URL for each check instead of exposing the file permanently, and keep it together with its idempotency key until the check completes (see the retry notes below). - 10 MB per file. Larger PDFs return
file_too_large; decide up front whether to reject, compress or route those to manual review. - One credit per check, drawn from your subscription quota or credit packs. When both are exhausted the call returns
402, so your integration needs a path that queues documents rather than silently skipping them.
const API = 'https://api.htpbe.tech/v1';
const headers = {
Authorization: `Bearer ${process.env.HTPBE_API_KEY}`,
'Content-Type': 'application/json',
};
type Route = 'advance' | 'human_review' | 'request_from_issuer' | 'retry_later';
// idempotencyKey belongs to one signedUrl: store the pair together and reuse
// both on retry. A new URL needs a new key.
export async function checkDiploma(signedUrl: string, idempotencyKey: string) {
// Retrying with the same key and the same URL returns the same check id
// instead of spending a second credit.
const analyze = await fetch(`${API}/analyze`, {
method: 'POST',
headers: { ...headers, 'Idempotency-Key': idempotencyKey },
body: JSON.stringify({ url: signedUrl, original_filename: 'diploma.pdf' }),
});
if ([402, 409, 429].includes(analyze.status)) {
return { route: 'retry_later' as Route };
}
if (analyze.status === 413 || analyze.status === 400 || analyze.status === 422) {
return { route: 'human_review' as Route };
}
if (!analyze.ok) {
throw new Error(`analyze failed: ${analyze.status}`);
}
const { id } = await analyze.json();
const res = await fetch(`${API}/result/${id}`, { headers });
if (!res.ok) throw new Error(`result failed: ${res.status}`);
const result = await res.json();
return { route: routeFor(result), checkId: id, result };
}
function routeFor(result: { status: 'intact' | 'modified' | 'inconclusive' }): Route {
switch (result.status) {
case 'modified':
return 'human_review';
case 'inconclusive':
return 'request_from_issuer';
case 'intact':
return 'advance';
}
}Store checkId, status, status_reason, modification_markers and algorithm_version against the candidate document. The check ID stays retrievable through GET /v1/result/{id}, which gives you an audit trail if someone later asks why a candidate was routed the way they were. The full field list is in the public API docs.
Two more details save debugging time. A 429 carries a Retry-After header; honour it. And while you build, use a test key (htpbe_test_…) with the documented test URLs, which return deterministic synthetic results without spending quota and without touching real files. If you would rather not block the upload request on the call, put it behind a queue; the event-driven webhook pattern shows one way, and the Node.js integration guide goes deeper on error handling.
What to do with each verdict
The result carries a status of intact, modified or inconclusive, and every response includes a usage_caution object. Its safe_for_automated_adverse_decision field is always false: the verdict describes a file, not the person who sent it, so it must not be the only reason an application is rejected. Route on it, do not auto-reject on it.
| Verdict | What it means | Route |
|---|---|---|
intact | No structural evidence of modification, and the origin allows the check. | Continue the normal flow. This is not proof the degree exists. |
modified | Structural evidence the file was changed after creation. | Send to a human reviewer with the named markers; ask for an issuer-sent copy. |
inconclusive | The integrity check does not apply to this kind of file. | Ask for the document from the issuer. This is not proof of fraud and not a clean pass. |
modified. The markers come back as stable ids such as HTPBE_DATES_DISAGREE or HTPBE_MULTIPLE_REVISION_LAYERS, strongest first. Branch on the id in code and show your reviewers the label from the dictionary on the how-it-works page. Put the reviewer’s next step in the ticket: ask the candidate to have the institution send the transcript or degree confirmation directly, rather than re-uploading their own copy.
inconclusive. Read status_reason. Scans, files exported from office suites or online editors, filled forms and re-rendered files all land here for different reasons, and none of them is a failure. The check could not confirm integrity, so the document needs to come from the issuer. Word the request to the candidate neutrally: you need an issuer-sent copy for this document type, which is a statement about your process, not an accusation.
intact. Advance the candidate, and keep the issuer step where your process requires it. An intact file means the check found no evidence of editing. It says nothing about who the diploma was issued to or whether the degree was conferred.
Where a file check stops
Be explicit about this limit in your product and in what you tell recruiters, because it is the one people over-read.
The check cannot tell you that:
- the institution exists, is accredited, or actually awarded the degree;
- the person named on the PDF is the candidate in front of you;
- the grades, dates or honours printed on the document are true.
A file can be unmodified and still be the wrong evidence: a genuine document that belongs to someone else, or a document for a degree that was never conferred, produces no structural signal on its own. Confirming those facts takes registry or issuer verification, such as a direct confirmation from the university, a national credential registry, or a service like the National Student Clearinghouse in the US. Neither check replaces the other.
In practice the layered flow looks like this: run the file check on every upload, send modified and inconclusive documents to the issuer step immediately, and reserve the slower, paid verification for candidates who reach the offer stage. The same pattern applies to other document types in HR pipelines, such as payslips, and to regulated onboarding like healthcare credentialing.
Operational notes
- Data handling. You are sending candidate documents to a third party, so decide retention and lawful basis before launch. The GDPR guide covers what to document.
- Re-checks. Results record the algorithm version used. If
outdated_warningappears on an old result, re-run the document rather than relying on the stored verdict. - Cost control. Check each document once and store the check ID instead of re-running. Send an
Idempotency-Keywith every call and retry with the same key and the same URL; a409means the first request is still running, so wait and retry with that same key. A different URL under an old key is rejected with422. Plans and credit packs are on the pricing page.
Next steps
To see a result before writing any code, upload a sample diploma to the HTPBE? web tool; new accounts get 5 free checks. For the integration, create a test key, run the flow above against the test URLs, then switch to a live key. The diploma detection use case describes the document types covered, and the API page has plans and key setup.