Integrating PDF Verification into Your Node.js Application: A Complete Developer Guide

Code examples verified against the API as of March 2026. If the API has changed since then, check the changelog.
PDF fraud is a backend problem. The fraud happens before your business logic runs — at the moment a user uploads a document your system treats as trustworthy. By the time a human reviewer looks at a bank statement, invoice, or diploma, the data from that document may already have influenced a decision.
This guide walks through integrating the HTPBE PDF verification API into a Node.js application: from the first curl command to a production-ready TypeScript client with retry logic, typed errors, and an Express middleware layer. All code is complete and working — no pseudocode.
Prerequisites
- Node.js 18+ (for native
fetch) - An HTPBE API key (sign up at htpbe.tech → Dashboard → copy key)
- TypeScript 5.0+ (optional but recommended)
Step 1: Test the API with curl
Before writing any code, confirm your key works. The API uses a two-step flow: POST /analyze submits a PDF URL and returns a check ID, then GET /result/ retrieves the full verdict.
Step 1a — submit for analysis:
curl -X POST https://htpbe.tech/api/v1/analyze \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://htpbe.tech/api/v1/test/clean.pdf"}'
You will receive: {"id": "some-uuid-here"}
Step 1b — retrieve the result:
curl https://htpbe.tech/api/v1/result/YOUR_CHECK_ID \
-H "Authorization: Bearer YOUR_API_KEY"
You will receive a flat JSON object with "status": "intact" and all analysis fields. This confirms authentication works and your network can reach the API.
The URL https://htpbe.tech/api/v1/test/clean.pdf is a test mock URL — it returns a predictable response without consuming quota. We will cover all available test URLs in the testing section below.
Step 2: Define Types
Start with the TypeScript interfaces that match the API response structure:
// POST /analyze response — just the check ID
export interface HTPBEAnalyzeResponse {
id: string;
}
// GET /result/{id} response — flat object with all analysis fields
export interface HTPBEResult {
id: string;
filename: string;
check_date: number | null;
file_size: number;
page_count: number;
// Algorithm version tracking
algorithm_version: string;
current_algorithm_version: string;
outdated_warning?: string;
// Primary verdict
status: 'intact' | 'modified' | 'inconclusive';
status_reason?: 'consumer_software_origin';
origin: {
type: 'consumer_software' | 'institutional' | 'unknown';
software: string | null;
};
// Detection results
modification_confidence: 'certain' | 'high' | 'none' | null;
// Metadata
creator: string | null;
producer: string | null;
creation_date: number | null; // Unix timestamp
modification_date: number | null; // Unix timestamp
pdf_version: string | null;
// Metadata analysis
date_sequence_valid: boolean;
metadata_completeness_score: number;
// Structure analysis
xref_count: number;
has_incremental_updates: boolean;
update_chain_length: number;
// Signature analysis
has_digital_signature: boolean;
signature_count: number;
signature_removed: boolean;
modifications_after_signature: boolean;
// Content analysis
object_count: number;
has_javascript: boolean;
has_embedded_files: boolean;
// Findings
modification_markers: string[];
}
// GET /checks response item — summary with fewer fields than full result
export interface HTPBECheckSummary {
id: string;
filename: string;
check_date: number | null;
status: 'intact' | 'modified' | 'inconclusive';
metadata_completeness_score: number;
creator: string | null;
producer: string | null;
file_size: number;
page_count: number;
pdf_version: string | null;
creation_date: number | null;
modification_date: number | null;
has_javascript: boolean;
has_digital_signature: boolean;
has_embedded_files: boolean;
has_incremental_updates: boolean;
update_chain_length: number;
object_count: number;
}
export interface HTPBEErrorResponse {
error: string;
code: string;
}
Step 3: Build the Client Class
Here is a complete HTPBEClient implementation with exponential backoff retry logic, timeout handling, and typed error responses. The client handles the two-step flow internally — callers just call verify() and get back the full result:
export class HTPBEError extends Error {
constructor(
message: string,
public readonly statusCode: number,
public readonly code: string,
) {
super(message);
this.name = 'HTPBEError';
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export class HTPBEClient {
private readonly apiKey: string;
private readonly baseUrl = 'https://htpbe.tech/api/v1';
private readonly timeoutMs: number;
constructor(apiKey: string, options: { timeoutMs?: number } = {}) {
if (!apiKey) throw new Error('HTPBE API key is required');
this.apiKey = apiKey;
this.timeoutMs = options.timeoutMs ?? 30_000;
}
async verify(pdfUrl: string, retries = 3): Promise<HTPBEResult> {
let lastError: HTPBEError | null = null;
for (let attempt = 0; attempt < retries; attempt++) {
if (attempt > 0) {
// Exponential backoff: 1s, 2s, 4s
await sleep(Math.pow(2, attempt - 1) * 1_000);
}
try {
// Step 1: submit the PDF URL, get back a check ID
const { id } = await this.submitAnalysis(pdfUrl);
// Step 2: retrieve the full flat result
return await this.getResult(id);
} catch (err) {
if (!(err instanceof HTPBEError)) throw err;
// Do not retry on client errors — only on 5xx
if (err.statusCode < 500) throw err;
lastError = err;
}
}
throw lastError!;
}
private async submitAnalysis(pdfUrl: string): Promise<HTPBEAnalyzeResponse> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
let response: Response;
try {
response = await fetch(`${this.baseUrl}/analyze`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ url: pdfUrl }),
signal: controller.signal,
});
} catch (err) {
if ((err as Error).name === 'AbortError') {
throw new HTPBEError(
`Request timed out after ${this.timeoutMs}ms`,
408,
'TIMEOUT',
);
}
throw err;
} finally {
clearTimeout(timer);
}
if (!response.ok) {
await this.handleErrorResponse(response);
}
return response.json() as Promise<HTPBEAnalyzeResponse>;
}
private async getResult(id: string): Promise<HTPBEResult> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
let response: Response;
try {
response = await fetch(`${this.baseUrl}/result/${id}`, {
headers: { Authorization: `Bearer ${this.apiKey}` },
signal: controller.signal,
});
} catch (err) {
if ((err as Error).name === 'AbortError') {
throw new HTPBEError(
`Request timed out after ${this.timeoutMs}ms`,
408,
'TIMEOUT',
);
}
throw err;
} finally {
clearTimeout(timer);
}
if (!response.ok) {
await this.handleErrorResponse(response);
}
return response.json() as Promise<HTPBEResult>;
}
private async handleErrorResponse(response: Response): Promise<never> {
let body: HTPBEErrorResponse = { error: response.statusText, code: 'UNKNOWN' };
try {
body = (await response.json()) as HTPBEErrorResponse;
} catch {
// Body is not JSON — use statusText
}
switch (response.status) {
case 400:
throw new HTPBEError(
`Bad request: ${body.error}`,
400,
'BAD_REQUEST',
);
case 401:
throw new HTPBEError(
'Invalid API key. Check your HTPBE_API_KEY environment variable.',
401,
'UNAUTHORIZED',
);
case 402:
throw new HTPBEError(
'Subscription required. Sign up at htpbe.tech to access the API.',
402,
'SUBSCRIPTION_REQUIRED',
);
case 403:
throw new HTPBEError(
'Access forbidden. Your API key may not have permission for this resource.',
403,
'FORBIDDEN',
);
case 404:
throw new HTPBEError(
'Check not found.',
404,
'NOT_FOUND',
);
case 413:
throw new HTPBEError(
'PDF exceeds the 10 MB size limit.',
413,
'FILE_TOO_LARGE',
);
case 422:
throw new HTPBEError(
'The URL did not return a valid PDF file.',
422,
'INVALID_PDF',
);
case 500:
throw new HTPBEError(
'HTPBE server error. The request will be retried.',
500,
'SERVER_ERROR',
);
default:
throw new HTPBEError(
`Unexpected error ${response.status}: ${body.error}`,
response.status,
body.code ?? 'UNKNOWN',
);
}
}
}
The retry logic only fires on 5xx responses. A 401 means your key is wrong — retrying it would not help. Only server-side transient failures warrant a retry.
Step 4: Production Integration Pattern
The typical flow in any backend application that accepts document uploads:
- User uploads PDF to your backend
- You store it in S3, GCS, Vercel Blob, or similar — getting a publicly accessible URL
- You call the HTPBE API with that URL
- You route the request based on the verdict
import { HTPBEClient, HTPBEResult } from './htpbe-client';
// Placeholder — replace with your actual storage upload logic
async function uploadToStorage(file: Buffer, filename: string): Promise<string> {
// Returns a publicly accessible URL, e.g. from S3 presigned URL or Vercel Blob
throw new Error('Implement uploadToStorage for your storage provider');
}
type DocumentAction = 'accept' | 'reject' | 'manual_review';
interface DocumentVerificationResult {
action: DocumentAction;
result: HTPBEResult;
}
async function handleDocumentUpload(
file: Buffer,
filename: string,
): Promise<DocumentVerificationResult> {
const url = await uploadToStorage(file, filename);
const client = new HTPBEClient(process.env.HTPBE_API_KEY!);
const result = await client.verify(url);
switch (result.status) {
case 'intact':
return { action: 'accept', result };
case 'modified':
return { action: 'reject', result };
case 'inconclusive':
// Consumer software origin — flag for manual review
return { action: 'manual_review', result };
}
}
The inconclusive verdict means the document was created with consumer software (Microsoft Word, Excel, LibreOffice, Canva) rather than institutional document management software. For documents that claim institutional origin — bank statements, diplomas, contracts — inconclusive warrants the same treatment as modified: do not accept automatically, route to a human reviewer.
Step 5: Express Middleware
If you are using Express, the cleanest pattern is a middleware that runs before your upload handler and attaches the verification result to the request object:
import { Request, Response, NextFunction } from 'express';
import { HTPBEClient, HTPBEError, HTPBEResult } from './htpbe-client';
// Extend Express Request type
declare global {
namespace Express {
interface Request {
documentVerification?: HTPBEResult;
}
}
}
const htpbeClient = new HTPBEClient(process.env.HTPBE_API_KEY!);
export async function verifyDocument(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
const { documentUrl } = req.body as { documentUrl?: string };
if (!documentUrl) {
res.status(400).json({ error: 'documentUrl is required' });
return;
}
try {
const result = await htpbeClient.verify(documentUrl);
req.documentVerification = result;
next();
} catch (err) {
if (err instanceof HTPBEError) {
if (err.code === 'UNAUTHORIZED') {
// Configuration error — do not expose key details to client
console.error('HTPBE API key is invalid or missing');
res.status(500).json({ error: 'Document verification service misconfigured' });
return;
}
if (err.code === 'INVALID_PDF') {
res.status(422).json({ error: 'The provided URL is not a valid PDF' });
return;
}
if (err.code === 'FILE_TOO_LARGE') {
res.status(413).json({ error: 'PDF must be under 10 MB' });
return;
}
}
next(err);
}
}
// Route handler that uses the middleware
export function documentUploadHandler(req: Request, res: Response): void {
const verification = req.documentVerification!;
if (verification.status === 'modified') {
res.status(422).json({
error: 'Document appears to have been modified after creation',
modification_markers: verification.modification_markers,
});
return;
}
if (verification.status === 'inconclusive') {
// Queue for manual review instead of rejecting outright
res.status(202).json({
message: 'Document accepted for manual review',
reason: verification.modification_markers[0] ?? 'Consumer software origin detected',
});
return;
}
// status === 'intact' — proceed
res.status(200).json({ message: 'Document accepted', id: verification.id });
}
Wire these up in your Express app:
import express from 'express';
import { verifyDocument, documentUploadHandler } from './document-middleware';
const app = express();
app.use(express.json());
app.post(
'/api/documents',
verifyDocument,
documentUploadHandler,
);
The middleware runs the HTPBE check and attaches the result to req.documentVerification. The route handler then reads the verdict. This separation keeps verification logic out of your business layer.
Step 6: Test Mode
All HTPBE plans — including free — include a test API key. Test API keys use mock URLs that return predefined responses, similar to Stripe test cards. They are designed for integration testing without consuming production quota or requiring real documents.
Test URL format: https://htpbe.tech/api/v1/test/{filename}.pdf
The available test fixtures and what they return:
| URL | status | Description |
|---|---|---|
clean.pdf | intact | Clean document, institutional origin |
clean-no-dates.pdf | intact | Clean, unknown origin, no date metadata |
modified-low.pdf | modified | Single incremental update |
modified-medium.pdf | modified | Consumer software origin, multiple updates |
modified-high.pdf | modified | PDFtk producer, 5-link update chain |
modified-critical.pdf | modified | Signature removed, JavaScript, Excel origin |
signature-removed.pdf | modified | Digital signature stripped |
signature-valid.pdf | intact | Valid digital signature, no modifications |
inconclusive.pdf | inconclusive | Excel origin, no structural modifications |
dates-mismatch.pdf | modified | Modification date 14 days after creation |
Use these in your test suite to cover all decision branches:
import { describe, it, expect } from 'vitest';
import { HTPBEClient } from './htpbe-client';
const TEST_BASE = 'https://htpbe.tech/api/v1/test';
const client = new HTPBEClient(process.env.HTPBE_TEST_API_KEY!);
describe('HTPBEClient', () => {
it('returns intact for a clean document', async () => {
const result = await client.verify(`${TEST_BASE}/clean.pdf`);
expect(result.status).toBe('intact');
expect(result.modification_markers).toHaveLength(0);
});
it('returns modified for a high-risk document', async () => {
const result = await client.verify(`${TEST_BASE}/modified-high.pdf`);
expect(result.status).toBe('modified');
});
it('returns inconclusive for consumer software origin', async () => {
const result = await client.verify(`${TEST_BASE}/inconclusive.pdf`);
expect(result.status).toBe('inconclusive');
expect(result.status_reason).toBe('consumer_software_origin');
});
it('handles signature-removed scenario', async () => {
const result = await client.verify(`${TEST_BASE}/signature-removed.pdf`);
expect(result.status).toBe('modified');
expect(result.signature_removed).toBe(true);
});
});
Keep your test API key in a .env.test file (separate from production) and never commit either key to version control.
Step 7: Reviewing Past Checks
The GET /api/v1/checks endpoint returns a paginated list of all analysis results for your API key. Use it to audit past verifications, filter by verdict, or pull a history for a specific date range.
interface HTPBEChecksResponse {
data: HTPBECheckSummary[];
total: number;
limit: number;
offset: number;
has_more: boolean;
}
async function getRecentModified(apiKey: string): Promise<HTPBECheckSummary[]> {
const params = new URLSearchParams({
status: 'modified',
limit: '50',
});
const response = await fetch(
`https://htpbe.tech/api/v1/checks?${params}`,
{ headers: { Authorization: `Bearer ${apiKey}` } },
);
if (!response.ok) {
throw new Error(`Failed to fetch checks: ${response.status}`);
}
const body = (await response.json()) as HTPBEChecksResponse;
return body.data;
}
Monitor your quota consumption via the HTPBE dashboard. Overage requests succeed automatically and are billed at the end of the billing cycle — there is no hard quota cutoff that interrupts your workflow.
Step 8: Knowing When to Upgrade
The practical signals that a plan upgrade is warranted:
Starter ($15/month, 30 checks): Suitable for low-volume workflows — up to one document per day. If you are hitting quota before mid-month, or if your use case processes more than a handful of documents per week, move to Growth.
Growth ($149/month, 350 checks): The right tier for most production applications — handles roughly 12 documents per day. This is the recommended starting point for any application with real users.
Pro ($499/month, 1,500 checks): For platforms processing 40–50 documents per day consistently. The per-check cost drops to $0.33, a 23% saving over Growth.
Enterprise (custom): For 1,500+ checks per month. Per-check pricing drops to $0.10–$0.20 at high volume.
The clearest indicator to watch is your quota consumption measured at the same point each month — visible in the HTPBE dashboard. If you are consistently below 20% remaining with more than a week left in the billing cycle, you are sized for the next tier.
Complete Client File
For reference, the full htpbe-client.ts module in one place:
// POST /analyze response — just the check ID
export interface HTPBEAnalyzeResponse {
id: string;
}
// GET /result/{id} response — flat object with all analysis fields
export interface HTPBEResult {
id: string;
filename: string;
check_date: number | null;
file_size: number;
page_count: number;
// Algorithm version tracking
algorithm_version: string;
current_algorithm_version: string;
outdated_warning?: string;
// Primary verdict
status: 'intact' | 'modified' | 'inconclusive';
status_reason?: 'consumer_software_origin';
origin: {
type: 'consumer_software' | 'institutional' | 'unknown';
software: string | null;
};
// Detection results
modification_confidence: 'certain' | 'high' | 'none' | null;
// Metadata
creator: string | null;
producer: string | null;
creation_date: number | null; // Unix timestamp
modification_date: number | null; // Unix timestamp
pdf_version: string | null;
// Metadata analysis
date_sequence_valid: boolean;
metadata_completeness_score: number;
// Structure analysis
xref_count: number;
has_incremental_updates: boolean;
update_chain_length: number;
// Signature analysis
has_digital_signature: boolean;
signature_count: number;
signature_removed: boolean;
modifications_after_signature: boolean;
// Content analysis
object_count: number;
has_javascript: boolean;
has_embedded_files: boolean;
// Findings
modification_markers: string[];
}
// GET /checks response item — summary with fewer fields than full result
export interface HTPBECheckSummary {
id: string;
filename: string;
check_date: number | null;
status: 'intact' | 'modified' | 'inconclusive';
metadata_completeness_score: number;
creator: string | null;
producer: string | null;
file_size: number;
page_count: number;
pdf_version: string | null;
creation_date: number | null;
modification_date: number | null;
has_javascript: boolean;
has_digital_signature: boolean;
has_embedded_files: boolean;
has_incremental_updates: boolean;
update_chain_length: number;
object_count: number;
}
export interface HTPBEErrorResponse {
error: string;
code: string;
}
export class HTPBEError extends Error {
constructor(
message: string,
public readonly statusCode: number,
public readonly code: string,
) {
super(message);
this.name = 'HTPBEError';
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export class HTPBEClient {
private readonly apiKey: string;
private readonly baseUrl = 'https://htpbe.tech/api/v1';
private readonly timeoutMs: number;
constructor(apiKey: string, options: { timeoutMs?: number } = {}) {
if (!apiKey) throw new Error('HTPBE API key is required');
this.apiKey = apiKey;
this.timeoutMs = options.timeoutMs ?? 30_000;
}
async verify(pdfUrl: string, retries = 3): Promise<HTPBEResult> {
let lastError: HTPBEError | null = null;
for (let attempt = 0; attempt < retries; attempt++) {
if (attempt > 0) {
await sleep(Math.pow(2, attempt - 1) * 1_000);
}
try {
const { id } = await this.submitAnalysis(pdfUrl);
return await this.getResult(id);
} catch (err) {
if (!(err instanceof HTPBEError)) throw err;
if (err.statusCode < 500) throw err;
lastError = err;
}
}
throw lastError!;
}
private async submitAnalysis(pdfUrl: string): Promise<HTPBEAnalyzeResponse> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
let response: Response;
try {
response = await fetch(`${this.baseUrl}/analyze`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ url: pdfUrl }),
signal: controller.signal,
});
} catch (err) {
if ((err as Error).name === 'AbortError') {
throw new HTPBEError(
`Request timed out after ${this.timeoutMs}ms`,
408,
'TIMEOUT',
);
}
throw err;
} finally {
clearTimeout(timer);
}
if (!response.ok) {
await this.handleErrorResponse(response);
}
return response.json() as Promise<HTPBEAnalyzeResponse>;
}
private async getResult(id: string): Promise<HTPBEResult> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
let response: Response;
try {
response = await fetch(`${this.baseUrl}/result/${id}`, {
headers: { Authorization: `Bearer ${this.apiKey}` },
signal: controller.signal,
});
} catch (err) {
if ((err as Error).name === 'AbortError') {
throw new HTPBEError(
`Request timed out after ${this.timeoutMs}ms`,
408,
'TIMEOUT',
);
}
throw err;
} finally {
clearTimeout(timer);
}
if (!response.ok) {
await this.handleErrorResponse(response);
}
return response.json() as Promise<HTPBEResult>;
}
private async handleErrorResponse(response: Response): Promise<never> {
let body: HTPBEErrorResponse = { error: response.statusText, code: 'UNKNOWN' };
try {
body = (await response.json()) as HTPBEErrorResponse;
} catch {
// non-JSON body
}
switch (response.status) {
case 400:
throw new HTPBEError(`Bad request: ${body.error}`, 400, 'BAD_REQUEST');
case 401:
throw new HTPBEError(
'Invalid API key. Check your HTPBE_API_KEY environment variable.',
401,
'UNAUTHORIZED',
);
case 402:
throw new HTPBEError(
'Subscription required. Sign up at htpbe.tech to access the API.',
402,
'SUBSCRIPTION_REQUIRED',
);
case 403:
throw new HTPBEError(
'Access forbidden. Your API key may not have permission for this resource.',
403,
'FORBIDDEN',
);
case 404:
throw new HTPBEError('Check not found.', 404, 'NOT_FOUND');
case 413:
throw new HTPBEError('PDF exceeds the 10 MB size limit.', 413, 'FILE_TOO_LARGE');
case 422:
throw new HTPBEError('The URL did not return a valid PDF file.', 422, 'INVALID_PDF');
case 500:
throw new HTPBEError(
'HTPBE server error. The request will be retried.',
500,
'SERVER_ERROR',
);
default:
throw new HTPBEError(
`Unexpected error ${response.status}: ${body.error}`,
response.status,
body.code ?? 'UNKNOWN',
);
}
}
}
Summary
The integration surface for HTPBE is intentionally small: one POST endpoint, one JSON response, three possible verdicts. The complexity in this guide is all on the integration side — retry logic, timeout handling, middleware architecture, test coverage — because those are the pieces that determine whether a third-party API call holds up in production.
Key decisions to make before you ship:
inconclusiverouting — for documents that claim institutional origin, treatinconclusivethe same asmodified; for user-generated documents (forms, letters), it may be acceptable- Overage policy — requests beyond your monthly quota succeed automatically and are billed at your plan's overage rate; monitor your dashboard to avoid unexpected charges on large batch jobs
- Test key separation — keep test and production keys in separate env files; test keys return synthetic data and should never touch production flows
CTA
Get your API key — test keys free on all plans.
Sign up at htpbe.tech, copy your test key from the dashboard, and run your first verification in under 5 minutes. No enterprise contract, no minimum commitment. The Growth plan ($149/month, 350 checks) covers most production workloads — and test keys let you build and validate the integration before you spend a single production check.