Originally published at htpbe.tech. The version on htpbe.tech stays in sync with the latest detection algorithm — refer to it for the canonical text.
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 PDF tamper detection 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. (If you want the conceptual overview first, start with How to Detect PDF Tampering Programmatically. If you are using Python instead of Node.js, see the Python integration guide.)
Prerequisites
- Node.js 18+ (for native
fetch) - An HTPBE API key (sign up → 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/{id} retrieves the full verdict. (For a language-agnostic overview of what the API detects, see how PDF tamper detection works.)
Step 1a — submit for analysis:
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"}'
You will receive: {"id": "some-uuid-here"}
Step 1b — retrieve the result:
curl https://api.htpbe.tech/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://api.htpbe.tech/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';
// Only present when status === 'inconclusive' — tells you WHY
status_reason?:
| 'consumer_software_origin'
| 'online_editor_origin'
| 'scanned_document'
| 'html_renderer_origin';
origin: {
type: 'consumer_software' | 'institutional' | 'unknown' | 'online_editor' | 'scanned';
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 — stable HTPBE_* marker ids, e.g. ["HTPBE_SIGNATURE_REMOVED"]
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;
details?: string;
}
Two fields deserve a closer look. status_reason is only present when status is inconclusive, and it has four possible values — not one. The difference matters: scanned_document and consumer_software_origin are benign for user-generated content, but html_renderer_origin (a tool like wkhtmltopdf) is used by both legitimate payroll systems and forgery operations producing fake payslips from scratch. Branch on the specific reason, not just on inconclusive.
modification_markers returns stable machine-readable ids prefixed HTPBE_ — for example HTPBE_SIGNATURE_REMOVED, HTPBE_DATES_DISAGREE, HTPBE_MULTIPLE_REVISION_LAYERS, HTPBE_POST_SIGNATURE_EDIT. Branch your integration logic on the id; render the human-readable label from the dictionary published on htpbe.tech/how. These ids are part of the public contract and never change once shipped.
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 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://api.htpbe.tech/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;
// Retry on 5xx and on 429 (server at capacity); never on other 4xx.
// A 402/401/422 will fail identically on retry.
if (err.statusCode < 500 && err.statusCode !== 429) 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:
// No credit source: out of monthly quota, no paid batch, no welcome
// credits — or no active plan on a live key. Top up or subscribe.
throw new HTPBEError(
'Payment required. No credits available for this key. See htpbe.tech/pricing.',
402,
'PAYMENT_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 429:
// Server-wide capacity, not per-key rate limiting. Respect Retry-After.
throw new HTPBEError(
'Server is at analysis capacity. Retry after the Retry-After interval.',
429,
'SERVER_AT_CAPACITY',
);
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. Two status codes deserve explicit handling in your own code:
-
402 PAYMENT_REQUIRED— the key has no credit source left. Credits are universal: a subscription’s monthly quota, a one-time top-up batch, and the welcome credits all draw from one pool. A 402 means all three are exhausted (or there is no active plan on a live key). Surface this to your billing logic rather than retrying — retrying will fail identically until the account is topped up at the pricing page. -
429 SERVER_AT_CAPACITY— this is server-wide concurrency, not per-key rate limiting. The response carries aRetry-Afterheader (in seconds). Wait that long, then retry the same request. The retry loop below treats 429 like a transient failure, but readingRetry-Afterinstead of fixed backoff is more polite under sustained load.
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, an online editor, an HTML renderer, or a scanner rather than institutional document management software. For a deeper explanation, see what “inconclusive” really means. 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 4b: Giving the API a Reachable URL
The API does not accept file uploads. It downloads the PDF from a URL you supply, so the file must live somewhere publicly reachable for the duration of the analysis (typically 2–5 seconds). The cleanest pattern is a short-lived presigned URL from your object store. You never expose the bucket, and the link expires minutes later.
Here is the full round-trip with AWS S3: store the upload, mint a presigned GET URL valid for five minutes, hand it to the client, then let the link expire.
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { randomUUID } from 'node:crypto';
import { HTPBEClient, HTPBEResult } from './htpbe-client';
const s3 = new S3Client({ region: process.env.AWS_REGION });
const htpbe = new HTPBEClient(process.env.HTPBE_API_KEY!);
const BUCKET = process.env.UPLOAD_BUCKET!;
export async function verifyUploadedPdf(
file: Buffer,
originalFilename: string,
): Promise<HTPBEResult> {
const key = `incoming/${randomUUID()}.pdf`;
// 1. Store the upload privately — no public ACL needed.
await s3.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: file,
ContentType: 'application/pdf',
}),
);
// 2. Mint a presigned GET URL valid for 5 minutes.
const presignedUrl = await getSignedUrl(
s3,
new GetObjectCommand({ Bucket: BUCKET, Key: key }),
{ expiresIn: 300 },
);
// 3. Hand the temporary URL to the API. The original filename is
// preserved in the result instead of the opaque UUID key.
return htpbe.verify(presignedUrl);
}
The original_filename parameter on POST /analyze is worth setting whenever your storage key is an opaque UUID like the one above — without it, the result’s filename field is derived from the last URL segment (a1b2c3d4.pdf), which is useless in an audit trail. Pass the human-readable name and it is stored and returned instead. To use it, extend the client’s submitAnalysis body to JSON.stringify({ url: pdfUrl, original_filename: originalFilename }).
The same pattern works with Google Cloud Storage (getSignedUrl on a File), Cloudflare R2 (S3-compatible — reuse the snippet above with the R2 endpoint), or Vercel Blob (already public, so skip the presigning step).
A Note on Synchronous Analysis and Webhooks
The API runs analysis synchronously. POST /analyze blocks until the verdict is computed, then returns the check ID; the response also carries a Location header pointing at the full result URL. There is no job queue to poll and no webhook callback to register — by the time analyze returns, the result is already retrievable from GET /result/{id}. The verify() method above chains both calls, so a single await gives you the full verdict.
If your own architecture is event-driven, wrap verify() in your job runner (BullMQ, a Lambda, a queue consumer) and emit your own internal webhook once the verdict lands. The detection call itself is a plain request/response — treat it as one step inside your worker, not as an external async service you wait on.
Step 5: Express Middleware
If you are using Express, the cleanest pattern is a middleware that runs before your upload handler and attaches the detection 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 fraud detection 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://api.htpbe.tech/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 |
Different 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://api.htpbe.tech/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 with a status_reason', async () => {
const result = await client.verify(`${TEST_BASE}/inconclusive.pdf`);
expect(result.status).toBe('inconclusive');
// status_reason explains WHY: consumer_software_origin, online_editor_origin,
// scanned_document, or html_renderer_origin. Always branch on it.
expect(result.status_reason).toBeDefined();
});
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 checks, 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://api.htpbe.tech/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. When you reach your monthly quota, further requests return 402 PAYMENT_REQUIRED until it resets — add a one-time credit pack or move to a higher tier to keep going, and handle the 402 so a quota boundary never silently drops a check.
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'
| 'online_editor_origin'
| 'scanned_document'
| 'html_renderer_origin';
origin: {
type: 'consumer_software' | 'institutional' | 'unknown' | 'online_editor' | 'scanned';
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://api.htpbe.tech/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 && err.statusCode !== 429) 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(
'Payment required. No credits available for this key. See htpbe.tech/pricing.',
402,
'PAYMENT_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 429:
throw new HTPBEError(
'Server is at analysis capacity. Retry after the Retry-After interval.',
429,
'SERVER_AT_CAPACITY',
);
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',
);
}
}
}
Decisions Before You Ship
The integration surface for HTPBE is intentionally small: one POST endpoint, one JSON response, three possible verdicts. The complexity 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.
Three decisions to make before going live:
-
inconclusiverouting — for documents that claim institutional origin, treatinconclusivethe same asmodified; for user-generated documents (forms, letters), it may be acceptable -
Quota policy — when you reach your monthly quota, further requests return
402 PAYMENT_REQUIREDuntil it resets; add a one-time credit pack or upgrade to keep going, and handle the 402 so a quota boundary never silently drops a check - Test key separation — keep test and production keys in separate env files; test keys return synthetic data and should never touch production flows
Top comments (0)