Handling Verdicts in Code: Intact, Modified, Inconclusive

This article is a snapshot – content was accurate as of September 2026 (code examples tested against the API as of August 2026). The product evolves actively; specific counts, examples, and detection rules may have changed since publication – see the changelog for the current state.
The integration is two HTTP calls. The part that goes wrong is the twenty lines after them.
The first branch most teams reach for collapses the verdict into a boolean:
// Don't ship this.
if (result.status !== 'intact') {
return rejectApplication(applicantId);
}That single line has three separate bugs in it. It treats a provenance finding as evidence of fraud. It fires an adverse decision against a person from a structural check on a file. And it discards the one field that tells you what to do next. The HTPBE? API ships a machine-readable guardrail against exactly this branch, and this code steps straight over it.
This is about the other twenty lines: the decision logic you write around intact, modified and inconclusive, what each verdict licenses you to do, which errors are worth a retry, and where the contract itself tells you to route the case. Why an inconclusive document is a finding rather than a failure is covered in a separate explainer — read that for the reasoning, but take the field-level details from here, since the payload has gained fields since it was written.
The shape you are branching on
Start with the contract. POST /v1/analyze runs the analysis synchronously and returns 201 Created with a check ID plus a Location header. There is no job to poll. The example below uses a test key and one of the documented test URLs, so it returns a fixed synthetic ID and costs nothing — with a live key you would pass a publicly reachable URL of your own instead:
curl -X POST https://api.htpbe.tech/v1/analyze \
-H "Authorization: Bearer $HTPBE_TEST_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://api.htpbe.tech/v1/test/inconclusive.pdf"}'{ "id": "00000000-0000-4000-8000-000000000011" }GET /v1/result/{id} returns the flat verdict object. The full payload carries a lot more — metadata, structure, signature and content fields — but only a handful of them drive routing. Type just those, and let the rest stay loose:
type Verdict = 'intact' | 'modified' | 'inconclusive';
type StatusReason =
| 'consumer_software_origin'
| 'online_editor_origin'
| 'scanned_document'
| 'unverifiable_metadata'
| 'filled_form_origin'
| 'fill_sign_origin';
interface CheckResult {
id: string;
status: Verdict;
/** Present only when status === 'inconclusive'. */
status_reason?: StatusReason;
/** Public marker ids. Treat as an unordered set. Empty unless status === 'modified'. */
modification_markers: string[];
/** 'certain' | 'high' | 'none' — nullable on older stored checks. */
modification_confidence: string | null;
usage_caution: {
safe_for_automated_adverse_decision: false;
recommended_action: 'route_to_human_review' | 'request_from_issuer' | 'no_action';
message: string;
};
algorithm_version: string;
current_algorithm_version: string;
}Three details in that interface are load-bearing.
status_reason is optional. It appears when, and only when, the verdict is inconclusive. Writing result.status_reason.startsWith(...) unconditionally will throw on every clean document you ever check.
modification_markers is populated only when the verdict is modified, and empty otherwise. It is the evidence behind that verdict, not a general-purpose signal feed — so there is no such thing as an intact result carrying markers to cross-check. Any branch that inspects markers outside the modified case is dead code.
modification_confidence is typed string | null, not a tidy union, and may be null on older stored checks. Do not write an exhaustive switch over it without a default arm.
Rule 1: route on usage_caution, not on your own opinion
Every result carries a usage_caution object. It is not documentation that leaked into the payload — it is an in-contract assertion you can read at runtime:
{
"safe_for_automated_adverse_decision": false,
"recommended_action": "request_from_issuer",
"message": "The check could not confirm this document’s integrity, which is not proof of fraud. Confirm the content directly with the issuing organisation or route it to human review — do not treat inconclusive as an automatic rejection."
}safe_for_automated_adverse_decision is the constant false on every verdict, including intact. That is the whole point of shipping it: a structural verdict describes the file, not the person who sent it. A modified payslip might be a forgery, or it might be a real payslip that HR re-saved through a PDF tool before forwarding it. The check cannot distinguish motive, and neither can your if statement.
recommended_action is the field you actually branch on, and it maps one-to-one onto the verdict:
status | recommended_action | What it means for your pipeline |
|---|---|---|
modified | route_to_human_review | Hold the document; a person looks before a decision |
inconclusive | request_from_issuer | Get the file from the source, not from the submitter |
intact | no_action | Proceed, subject to your own downstream checks |
Branching on recommended_action rather than on status gives you one useful property: when a lane is added later, the routing table changes, not string comparisons scattered across four services.
const LANES = {
route_to_human_review: 'manual-review',
request_from_issuer: 'issuer-callback',
no_action: 'continue',
} as const;
function lane(result: CheckResult) {
return LANES[result.usage_caution.recommended_action];
}Rule 2: inconclusive has six causes, and they are not interchangeable
Most integrations stop at a single inconclusive lane, which treats a phone scan of a utility bill exactly like a bank statement exported from Excel. Those are not the same problem.
When the verdict is inconclusive, status_reason tells you which structural situation applies:
status_reason | What the file is |
|---|---|
consumer_software_origin | Produced by consumer software or a freely available renderer |
online_editor_origin | Processed through an online editing service; original metadata gone |
scanned_document | A pure raster scan, no text layer |
unverifiable_metadata | Rebuilt by a render engine that flattened the structural history |
filled_form_origin | An interactive form with at least one filled field |
fill_sign_origin | Carries an Adobe Fill & Sign overlay |
The integrity check does not apply in any of the six cases, but for two different reasons. For the first four, there is no institutional baseline to check against: the document belongs to a class anyone can create, reprocess, or scan from scratch, so there is nothing to compare it with. For filled_form_origin and fill_sign_origin, the problem is attribution rather than absence — a form can be filled and refilled, and an overlay can be added at any point, so whatever the file shows cannot be tied to a particular edit.
Either way it is a statement about what can be established, not an accusation.
But the follow-up action differs sharply by document type. consumer_software_origin on a vendor-drafted contract is unremarkable — of course they wrote it in Word. The same reason code on a document that claims institutional origin is a strong signal on its own, because a real payroll or banking system does not distribute documents that way.
So the useful branch combines the reason with what you expected the document to be:
type DocKind = 'issuer_generated' | 'counterparty_authored';
// Document kinds that a bank, payroll provider or registry should have issued.
function handleInconclusive(result: CheckResult, kind: DocKind) {
const reason = result.status_reason;
if (kind === 'counterparty_authored') {
// A contract the other side drafted; consumer software is expected.
return { lane: 'continue', note: `origin not verifiable (${reason ?? 'unspecified'})` };
}
switch (reason) {
case 'consumer_software_origin':
case 'unverifiable_metadata':
// Claims institutional origin but was not produced like one.
return { lane: 'issuer-callback', priority: 'high' };
case 'online_editor_origin':
case 'scanned_document':
case 'filled_form_origin':
case 'fill_sign_origin':
return { lane: 'issuer-callback', priority: 'normal' };
default:
// New reason codes can be added; never fall through to accept.
return { lane: 'issuer-callback', priority: 'normal' };
}
}Note the default arm. The set of reason codes is additive — treating an unrecognised one as a pass is the failure mode that quietly widens over time.
Rule 3: markers are for triage, confidence is for urgency
When the verdict is modified, modification_markers is a non-empty array of stable public IDs, always prefixed HTPBE_ — HTPBE_DATES_DISAGREE, HTPBE_SIGNATURE_REMOVED, HTPBE_PAGES_FROM_MULTIPLE_SOURCES, and so on.
Three things follow for your code.
Treat the array as a set, not a ranking. Markers are returned in no guaranteed order — the entry at index 0 is not necessarily the strongest signal. If you want to know how solid the finding is, read modification_confidence; if you want to know which specific signals fired, read the whole array. Sorting the reviewer UI by modification_markers[0] will quietly mis-rank your queue.
Branch on the ID, render the label from the dictionary. Marker IDs are part of the public contract and are never renamed once shipped, so they are safe to switch on. The human-readable labels are not something you should hardcode — pull them from the published dictionary on htpbe.tech/how so your reviewer UI stays current when wording changes.
Store the whole array. A single marker means one signal fired. Two or three mean independent signals agreed, which is exactly the context a reviewer wants three weeks later when the applicant disputes the outcome.
function summarise(result: CheckResult) {
if (result.status !== 'modified') return null;
return {
markers: [...result.modification_markers].sort(), // stable for display; not a ranking
confidence: result.modification_confidence, // may be null
};
}modification_confidence — certain, high, or none — is a queue-ordering input, not a decision input. certain means the structural evidence is conclusive; high means strong evidence, with a rare chance of false positives in unusual legitimate workflows such as batch processing pipelines. Use it to decide how fast a case reaches a human. Do not use it to decide whether a human is involved: safe_for_automated_adverse_decision is false at both levels.
Rule 4: half your error branches are not errors
Error handling is where verdict code usually goes wrong, because the failure modes are not uniform. Sort the status codes by what the pipeline should actually do:
Retry with backoff. 429 means the server is at analysis capacity. It carries a Retry-After header in seconds — honour it, and fall back to exponential backoff with jitter when it is absent. 500 is a transient worth retrying on the same schedule.
Never retry. 400 (invalid_request, invalid_url_format, download_failed), 413 (file_too_large) and 422 (invalid_pdf) are all statements about your input. The same request will fail identically the next time. download_failed in particular is a 400 by design: the URL you supplied could not be fetched, which is bad input rather than a server problem. If your presigned URLs are short-lived, this is the code that will tell you they expired before the worker got to them.
Not an error at all. 402 (payment_required) means the account is out of credits. That is a billing event, not a request failure. Alert the account owner; do not bury it in a retry loop that will never succeed.
const RETRYABLE = new Set([429, 500]);
class OutOfCreditsError extends Error {}
async function analyze(url: string, maxRetries = 5): Promise<{ id: string }> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const response = await fetch('https://api.htpbe.tech/v1/analyze', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.HTPBE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ url }),
});
if (response.ok) {
return response.json();
}
if (response.status === 402) {
throw new OutOfCreditsError(); // page the account owner, don't retry
}
if (!RETRYABLE.has(response.status) || attempt === maxRetries) {
const body = await response.json();
throw new Error(`${body.code}: ${body.error}`);
}
const retryAfter = Number(response.headers.get('Retry-After'));
const backoff =
Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: Math.min(2 ** attempt * 1000, 30_000) + Math.random() * 1000;
await new Promise((resolve) => setTimeout(resolve, backoff));
}
throw new Error('unreachable');
}Every error body follows the same shape — { error, code, details? } — so log code and branch on it, not on the prose in error, which is written for humans and can be reworded.
Rule 5: a stored verdict has a shelf life
Every result carries both algorithm_version (the version that produced this verdict) and current_algorithm_version (what is running now). When the stored verdict was produced by an older version, an outdated_warning string appears in the payload.
This matters if you cache verdicts or surface historical checks in an audit view. A document checked six months ago was evaluated by an older detector; a re-check today can legitimately return a different verdict. Comparing the two fields is the cheapest way to know whether a stored result is still current:
function isStale(result: CheckResult) {
return result.algorithm_version !== result.current_algorithm_version;
}Do not silently overwrite the old verdict with a new one. In a dispute, the version that was in effect when the decision was made is the record that matters.
Testing the branches before you spend a credit
A test key (the htpbe_test_* kind) accepts the documented test URLs and returns deterministic, synthetic results with fixed check IDs — no file is downloaded and no credit is consumed. That covers the main lanes end to end: intact, several modified variants, and inconclusive with consumer_software_origin, online_editor_origin and a scanned document.
It does not cover every reason code. The remaining status_reason values do not have a corresponding test URL, so wire those branches up as unit tests against hand-built fixtures rather than assuming an integration test will reach them. The same goes for the null case on modification_confidence and for an unrecognised future reason code — both are cheap to assert in a unit test and expensive to discover in production.
The error paths are worth covering too, and two of them have dedicated triggers. test/trigger-402.pdf returns 402 payment_required, which is the only way to reach that branch with a test key — useful, since it is the one status your retry loop must not swallow. test/outdated-version.pdf returns a result stamped with an old algorithm version, so the staleness check above has something to fire on. Pointing a test key at a real URL returns 403 with code test_url_required, which is a convenient way to prove your non-retryable branch actually throws instead of looping.
What the verdict does not tell you
Structural tamper detection answers a narrow question: was this PDF written to after it was created, and does its internal structure match how the claimed software produces files. It does not read the document. It cannot tell you that the balance on an intact statement is real, that a name matches a registry, or that a file built fake from scratch by a competent forger is anything other than structurally clean — nothing was modified after creation, because nothing existed before it.
That boundary is why usage_caution exists, and why the honest shape of the code is a routing decision rather than an accept/reject. modified buys a human reviewer some time. inconclusive tells you which question to ask the issuer. intact removes one class of doubt and leaves the others exactly where they were.
Write the branches that way and the integration ages well: new reason codes land in a default arm instead of a silent pass, new markers extend an array instead of breaking a comparison, and no single verdict ever becomes the sole reason a person got turned down.
The full field reference lives in the API documentation, and if you are wiring this into an upload pipeline rather than a request handler, the event-driven integration guide covers the queue and worker layer that sits underneath this routing logic.