Event-Driven PDF Verification: Build a Webhook Layer Around the HTPBE? API

This article is a snapshot — content was accurate as of August 2026 (code examples tested against the API as of June 2026). The product evolves actively; specific counts, examples, and detection rules may have changed since publication — see the changelog for the current state.
Most document-fraud problems are not detection problems. They are timing problems. The forged bank statement, the doctored invoice, the edited payslip — your system already had everything it needed to flag it at the moment of upload. It just did not look. By the time a human reviewer opens the document, an automated decision has often already fired: a loan was pre-approved, a claim was routed to fast-track, a candidate cleared a right-to-work gate. The check happened too late to matter.
The fix is to make verification a side effect of upload. The instant a PDF lands in your system, an event fires, a worker checks it, and a verdict is pushed into your risk queue — no human pressing a “verify” button, no nightly batch, no polling loop a developer forgot to deploy. This guide shows how to build that event-driven layer around the PDF tamper detection API using a queue, a worker, and your own internal webhook callback.
One thing to be precise about up front, because it shapes the whole architecture: HTPBE? does not push outbound webhooks to you. The public API is request/response. You submit a PDF URL, you get back a verdict. The webhook in this design is yours — the callback your worker fires into your own risk-ops queue once it has the result in hand. We are wrapping a synchronous service inside your event-driven infrastructure, not subscribing to one. Getting that boundary right is the difference between a robust pipeline and a fragile one.
The shape of the HTPBE? API
Before designing anything around it, fix the contract. The API has two relevant calls.
POST /v1/analyze takes a JSON body with a publicly reachable PDF URL and returns a check ID:
curl -X POST https://api.htpbe.tech/v1/analyze \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://api.htpbe.tech/v1/test/clean.pdf"}'{ "id": "3f9c8b7a-2e1d-4c5f-9b8e-7a6d5c4b3a21" }The analysis runs synchronously. The call returns 201 Created only once the verdict has been computed — there is no background job on our side to wait on. GET /v1/result/{id} then returns the flat verdict object:
{
"status": "modified",
"modification_markers": ["HTPBE_DATES_DISAGREE", "HTPBE_EDITING_TOOL_FINGERPRINT"],
"modification_confidence": "certain"
}Three fields matter for routing:
status—intact,modified, orinconclusive.modification_markers— an array of public marker IDs (always prefixedHTPBE_) describing what fired. Empty forintactandinconclusive.modification_confidence—certain,high, ornone. There is no numeric risk score, by design; the verdict plus named markers is the contract.
A note on inconclusive, because teams new to structural forensics misread it. It is not a failure or an error. It means the document was produced by consumer software — exported from a browser, saved from a word processor, run through a generic PDF tool — and therefore carries none of the institutional structure that lets us reason about whether it was modified after creation. A real bank does not generate statements in Word. So if you submitted what should be an institutional document and got inconclusive, that itself is a routing signal worth surfacing, not a result to discard.
Why polling at the call site is the wrong instinct
The naive integration looks tempting because the API is synchronous. Drop a single await verify(url) into your upload handler and you are done, right? It works in the demo and breaks in production, for the same reasons every inline external call breaks.
It blocks the request the user is waiting on. Analysis takes a few seconds for a typical PDF and can take longer for a complex one. Tying an upload response to that means a spinner the user has to watch, a request that can time out at your load balancer, and tail latency that gets worse exactly when traffic spikes.
It has nowhere to retry. If the call returns a transient error, or the server signals it is at capacity, the inline handler has two bad options: fail the user’s upload, or swallow the error and let an unchecked document through. Both are wrong. A verification pipeline that silently drops checks under load is worse than no pipeline, because it creates false confidence.
It couples the wrong things. Your upload path now depends on the availability of an external service. A short outage there becomes a short outage in your core product. Verification and ingest have different failure modes and different latency budgets; binding them at the same call site forces them to share both.
The event-driven pattern decouples all of this. Ingest does one cheap thing — record the document and enqueue a job — and returns instantly. A separate worker owns the slow external call, the retries, the backoff, and the final callback. The two halves fail independently.
The architecture: upload event → queue → worker → callback
Four stages, each with a single responsibility.
- Ingest. Your upload endpoint stores the file (or its URL) and emits an event — in practice, enqueues a job. It does nothing else and returns immediately.
- Queue. A durable job queue holds pending verifications. It survives restarts, controls concurrency, and gives you retries for free.
- Worker. A consumer pulls jobs, calls HTPBE?
analyzethenresult, and turns the verdict into a domain event. - Callback. The worker fires your internal webhook — an HTTP POST to your risk-ops service, a message onto a topic, a row into a review table — delivering the verdict where decisions get made.
The PDF must be reachable by a public URL for the duration of the analyze call, since the server fetches it. If your documents live in private storage, mint a short-lived signed URL (S3, R2, GCS all support this) and pass that. The URL only needs to live long enough for one download.
Stage 1 — ingest emits an event
The upload handler’s job is to be fast and boring. Persist a record, enqueue, respond. Here it is with BullMQ on Redis, but the shape is identical on SQS, Cloud Tasks, or any broker.
// ingest.ts
import { Queue } from 'bullmq';
const verificationQueue = new Queue('pdf-verification', {
connection: { host: process.env.REDIS_HOST, port: 6379 },
});
export async function onDocumentUploaded(doc: {
documentId: string;
signedUrl: string; // short-lived, publicly fetchable
caseId: string; // your domain reference (loan, claim, candidate)
}) {
await verificationQueue.add(
'verify',
{
documentId: doc.documentId,
url: doc.signedUrl,
caseId: doc.caseId,
},
{
// Idempotency: re-uploading the same document never double-enqueues.
jobId: `verify:${doc.documentId}`,
attempts: 5,
backoff: { type: 'exponential', delay: 2000 },
removeOnComplete: 1000,
removeOnFail: false,
}
);
}Two design choices earn their place here. The deterministic jobId makes the enqueue idempotent — a retried upload, a duplicate webhook from your storage provider, or a user double-clicking submit all map to the same job instead of running the same check twice. And attempts plus exponential backoff mean the queue, not your code, owns the retry policy.
Stage 2 — the worker calls HTPBE?
The worker is where the external call lives. It does the two-step analyze then result, handles the capacity signal, and produces a normalized verdict object that the rest of your system can route on without knowing anything about PDFs.
// htpbe.ts — a thin, typed client
const BASE = 'https://api.htpbe.tech/v1';
const KEY = process.env.HTPBE_API_KEY!;
export interface Verdict {
status: 'intact' | 'modified' | 'inconclusive';
modificationMarkers: string[];
confidence: 'certain' | 'high' | 'none';
}
class CapacityError extends Error {
constructor(public retryAfterMs: number) {
super('server at capacity');
}
}
export async function verifyPdf(url: string): Promise<Verdict> {
const submit = await fetch(`${BASE}/analyze`, {
method: 'POST',
headers: {
Authorization: `Bearer ${KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ url }),
});
if (submit.status === 429) {
const retryAfter = Number(submit.headers.get('retry-after') ?? '5');
throw new CapacityError(retryAfter * 1000);
}
if (!submit.ok) {
throw new Error(`analyze failed: ${submit.status}`);
}
const { id } = (await submit.json()) as { id: string };
const result = await fetch(`${BASE}/result/${id}`, {
headers: { Authorization: `Bearer ${KEY}` },
});
if (!result.ok) {
throw new Error(`result fetch failed: ${result.status}`);
}
const r = (await result.json()) as {
status: Verdict['status'];
modification_markers: string[];
modification_confidence: Verdict['confidence'];
};
return {
status: r.status,
modificationMarkers: r.modification_markers ?? [],
confidence: r.modification_confidence ?? 'none',
};
}
export { CapacityError };Now the worker itself. It calls the client, translates a capacity signal into a queue-level delay (so BullMQ reschedules instead of burning a retry attempt), and on success hands off to the callback stage.
// worker.ts
import { Worker, DelayedError } from 'bullmq';
import { verifyPdf, CapacityError } from './htpbe';
import { emitVerdictWebhook } from './callback';
new Worker(
'pdf-verification',
async (job, token) => {
const { documentId, url, caseId } = job.data;
let verdict;
try {
verdict = await verifyPdf(url);
} catch (err) {
if (err instanceof CapacityError) {
// Not a failure — the server is busy. Reschedule without
// consuming an attempt, then yield.
await job.moveToDelayed(Date.now() + err.retryAfterMs, token);
throw new DelayedError();
}
throw err; // genuine error: let BullMQ retry with backoff
}
// Verdict in hand. Fire OUR webhook into the risk queue.
await emitVerdictWebhook({ documentId, caseId, verdict });
return verdict;
},
{
connection: { host: process.env.REDIS_HOST, port: 6379 },
// Keep this modest. The capacity signal is server-WIDE, not
// per-key — a handful of concurrent workers is plenty.
concurrency: 4,
}
);The capacity handling deserves a word. A 429 here is the server telling you it is already running the maximum number of simultaneous analyses across all callers — it is a global concurrency signal, not a per-key rate limit you can pay to raise. The correct response is to back off and reschedule, which is exactly what moveToDelayed does. Keeping worker concurrency modest means you rarely hit it in the first place. The deeper treatment of throughput, backoff, and idempotency at volume lives in the batch verification queue guide; this article reuses those primitives but stays focused on the event-and-callback flow.
Stage 3 — fire your own webhook
This is the “webhook” in event-driven PDF verification, and it is entirely yours. The worker has a verdict; now it delivers that verdict to wherever your team acts on documents. Whether that is an HTTP POST to an internal risk service, a message on a Kafka or SNS topic, or a write into a review table is an implementation detail. The pattern is the same: translate the verdict into a routed domain event.
// callback.ts
import { Verdict } from './htpbe';
interface VerdictEvent {
documentId: string;
caseId: string;
verdict: Verdict;
}
type Lane = 'auto_pass' | 'manual_review' | 'hold';
function routeVerdict(v: Verdict): Lane {
if (v.status === 'modified') return 'hold';
// inconclusive is a signal, not a pass: an institutional document
// that came back inconclusive was built in consumer software.
if (v.status === 'inconclusive') return 'manual_review';
return 'auto_pass'; // intact
}
export async function emitVerdictWebhook(evt: VerdictEvent) {
const lane = routeVerdict(evt.verdict);
// YOUR internal webhook — HTPBE does not call this; your worker does.
await fetch(process.env.RISK_QUEUE_WEBHOOK_URL!, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Sign your own callbacks so the receiver can trust them.
'X-Signature': sign(evt),
},
body: JSON.stringify({
type: 'document.verified',
documentId: evt.documentId,
caseId: evt.caseId,
lane,
status: evt.verdict.status,
markers: evt.verdict.modificationMarkers,
confidence: evt.verdict.confidence,
checkedAt: new Date().toISOString(),
}),
});
}
function sign(_evt: VerdictEvent): string {
// HMAC the payload with a shared secret; verify on the receiver.
return 'sha256=...';
}The routing table is where business policy lives, and it is worth being deliberate. A modified verdict with certain confidence is the clear case: hold the document, block any downstream auto-decision, and escalate. An intact verdict on an institutional document is your green lane. And inconclusive is the one teams think about too little — if you expected a document from a real institution and the structure says it was built in consumer software, that mismatch belongs in front of a human, not on the fast track.
Sign your own callbacks. The receiver should verify an HMAC of the payload against a shared secret before trusting it, the same way any webhook consumer should. This is your internal trust boundary; treat it like one.
What the verdict cannot tell you
A pipeline is only as honest as its limits, so state them plainly to the people who will act on its output.
Structural tamper detection answers one question: was this PDF altered after it was created, and does its internal structure match how the claimed software produces files? It does not read the meaning of the document. It cannot tell you that an intact bank statement was built from scratch by a fraudster using a tool that writes clean, internally consistent PDFs — a born-fake document with a plausible balance is structurally clean because nothing was modified after creation. That is a content-and-issuer problem, a different category of product, and mixing the two up will mislead your reviewers.
Likewise, inconclusive is a statement about provenance, not guilt. Plenty of legitimate documents are consumer-software exports. The verdict tells you the structural layer cannot vouch for the file; your policy decides what that means for a given document type. The value of wiring this in early is not that it catches everything — it is that it catches the entire class of after-the-fact edits automatically, at upload time, before a decision fires, with zero human prompting.
Putting it together
End to end, the flow is four hops and one external dependency:
- A user uploads a PDF. Your ingest handler stores it, mints a short-lived URL, and enqueues a
verifyjob keyed on the document ID. The response returns in milliseconds. - A worker pulls the job, calls
POST /v1/analyze, thenGET /v1/result/{id}, backing off on a capacity signal and retrying genuine errors via the queue. - The worker maps the verdict (
status,modification_markers,modification_confidence) to a routing lane. - The worker fires your signed internal webhook into the risk-ops queue, where
modifieddocuments are held,inconclusiveones get a human, andintactones flow through.
No polling loop. No human pressing verify. No upload request held hostage by an external call. Every PDF that enters your system is checked as a consequence of entering it, and the verdict arrives where decisions are made.
This is the integration most fraud-ops and engineering teams actually want: not a dashboard someone has to remember to open, but a check that is impossible to skip by design. If you are building exactly this, the fintech document workflow guide shows where the verdict lanes plug into a lending decision, and how to detect PDF tampering programmatically covers the forensic reasoning behind each marker.
To build it, you need a key. A free test key (the htpbe_test_* kind) accepts the documented test URLs and returns deterministic verdicts — perfect for wiring up the queue, the worker, and the callback against known intact, modified, and inconclusive responses before you spend a single live credit. When the pipeline is green end to end, swap in a live key to start checking real documents. See the API reference for the full surface and pricing for the credit pools that cover both API calls and web uploads from one balance.