PDF Security Blog

PDF Tamper Detection in Node.js: Typed fetch Client, Express and Next.js Routes, zod and Dedup

HTPBE Team··30 min read
PDF Tamper Detection in Node.js: Typed fetch Client, Express and Next.js Routes, zod and Dedup

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.

PDF fraud is a backend problem. The forged payslip or the altered invoice does not reach a human reviewer untouched – by the time your route handler has stored the upload and returned 201, the document’s claims are already feeding your business logic. The cheapest place to catch the structural layer of tampering is at ingress, before your service trusts the file.

This guide integrates the PDF tamper detection API into a Node.js service in TypeScript: a small typed client on native fetch with AbortController timeouts, retries that cannot double-charge, verdict routing, a ready-to-paste Express route, and a Vitest suite that runs without spending credits. The client, verdict routing, error mapping and the Next.js handler were type-checked, and the mocked tests were run against the client exactly as printed; the test-key scenarios are written against the documented test-URL contract. Adapt and harden it for your own traffic and threat model. (If you want the conceptual overview first, start with How to Detect PDF Tampering Programmatically. Using another stack? See the Python, Go, Laravel / PHP and Ruby on Rails, C# /.NET and Java / Spring Boot guides.)

TL;DR

  • Two calls, three verdicts: POST /analyze returns a check id, GET /result/{id} returns a flat object whose status is intact, modified or inconclusive.
  • The client is under 150 lines on native fetch: per-request AbortController timeout, exponential backoff with jitter, Retry-After honoured on 429, and one Idempotency-Key reused across every retry of the same POST.
  • Error handling keys off the API’s own lowercase code field (payment_required, invalid_pdf, server_at_capacity and so on), not just the status number.
  • modified goes to a human review queue, never to an automatic rejection – every result carries a usage_caution object that says so.
  • One Express route shows the wiring; the same logic drops into a Next.js handler. Tests use an injected fetch for the retry logic and the free test key for end-to-end scenarios.
  • This is structural PDF tamper detection – not KYC, not OCR, not content verification.

Prerequisites

  • Node.js 22 or newer (native fetch, AbortController and crypto.randomUUID)
  • TypeScript 5 or newer (optional, but the types below are most of the value)
  • An HTPBE? API key: sign up, then create a live key and copy your test key from the dashboard. Every account has one test key alongside its live keys.

Keep both keys in environment variables – HTPBE_API_KEY for live traffic and HTPBE_TEST_API_KEY for tests – and never commit either.

Step 1: Test the API with curl

Confirm the key and the network path before writing code. The flow is two calls: POST /analyze submits a PDF URL and returns a check id, then GET /result/{id} returns the verdict. (For what the checks cover, see how PDF tamper detection works.)

curl -i -X POST https://api.htpbe.tech/v1/analyze \
  -H "Authorization: Bearer $HTPBE_TEST_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://api.htpbe.tech/v1/test/clean.pdf"}'

Expect 201 Created, a Location header pointing at the result, and a body with only the id:

{ "id": "00000000-0000-4000-8000-000000000001" }
curl https://api.htpbe.tech/v1/result/00000000-0000-4000-8000-000000000001 \
  -H "Authorization: Bearer $HTPBE_TEST_API_KEY"

The result shows "status": "intact". A test key only accepts the mock URLs under https://api.htpbe.tech/v1/test/, returns deterministic answers and never consumes credits. A real PDF URL needs a live key.

Step 2: Define the Types

Types first – they document the contract and make the routing code in Step 4 exhaustive:

// htpbe-types.ts
// POST /analyze returns only the check id (201 Created).
export interface HTPBEAnalyzeResponse {
  id: string;
}

export type HTPBEStatus = 'intact' | 'modified' | 'inconclusive';

export type HTPBEStatusReason =
  | 'consumer_software_origin'
  | 'online_editor_origin'
  | 'scanned_document'
  | 'unverifiable_metadata'
  | 'filled_form_origin'
  | 'fill_sign_origin';

export interface HTPBEUsageCaution {
  safe_for_automated_adverse_decision: false;
  recommended_action: 'route_to_human_review' | 'request_from_issuer' | 'no_action';
  message: string;
}

// GET /result/{id} – one flat object. Timestamps are Unix seconds.
export interface HTPBEResult {
  id: string;
  filename: string;
  check_date: number | null; // null for test-key results
  file_size: number;

  algorithm_version: string;
  current_algorithm_version: string;
  outdated_warning?: string;

  status: HTPBEStatus;
  status_reason?: HTPBEStatusReason; // only when status === 'inconclusive'
  modification_confidence: 'certain' | 'high' | 'none' | null;
  modification_markers: string[]; // stable HTPBE_* ids, strongest first

  creation_date: number | null;
  modification_date: number | null;
  creator: string | null;
  producer: string | null;
  pdf_version: string | null;

  date_sequence_valid: boolean;
  metadata_completeness_score: number;

  xref_count: number;
  has_incremental_updates: boolean;
  update_chain_length: number;

  has_digital_signature: boolean;
  signature_count: number;
  signature_removed: boolean;
  modifications_after_signature: boolean;

  page_count: number;
  object_count: number;
  has_javascript: boolean;
  has_embedded_files: boolean;

  usage_caution: HTPBEUsageCaution;
}

// Every API error: { error, code, details? } with a lowercase snake_case code.
export interface HTPBEErrorBody {
  error: string;
  code: string;
  details?: string;
}

Three details are easy to miss:

  • status_reason exists only when status is inconclusive, and it has six values, not one. scanned_document on a handwritten form is unremarkable. consumer_software_origin on something that claims to be a payslip is not: real payroll systems do not distribute documents that way, so that combination deserves a closer look. Branch on the reason, not just on inconclusive.
  • modification_markers holds stable HTPBE_* ids such as HTPBE_SIGNATURE_REMOVED or HTPBE_DATES_DISAGREE, strongest first. Branch on the id and render the human-readable label from the dictionary on htpbe.tech/how. The ids are part of the public contract and are never renamed.
  • usage_caution is always present. safe_for_automated_adverse_decision is literally false: a structural verdict describes the file, not the person who sent it.

Step 3: The Client

One class owns the whole round trip, so callers write await client.verify(url) and get the full result:

// htpbe-client.ts
import { randomUUID } from 'node:crypto';
import type { HTPBEAnalyzeResponse, HTPBEErrorBody, HTPBEResult } from './htpbe-types';

export class HTPBEError extends Error {
  constructor(
    message: string,
    public readonly statusCode: number, // 0 when no HTTP response was received
    public readonly code: string, // server code, or client_timeout / network_error
    public readonly details?: string,
    public readonly retryAfterMs?: number
  ) {
    super(message);
    this.name = 'HTPBEError';
  }

  get isRetryable(): boolean {
    return (
      this.statusCode === 429 ||
      this.statusCode >= 500 ||
      this.code === 'idempotency_key_in_progress' ||
      this.code === 'client_timeout' ||
      this.code === 'network_error'
    );
  }
}

export interface HTPBEClientOptions {
  baseUrl?: string;
  timeoutMs?: number; // per HTTP request
  maxRetries?: number; // retries per call, not counting the first attempt
  fetch?: typeof fetch; // injectable for tests
  sleep?: (ms: number) => Promise<void>; // injectable for tests
}

export interface VerifyOptions {
  originalFilename?: string;
  // Defaults to a fresh UUID per verify() call. A key is bound to one exact url,
  // so a reused key must always travel with the same url.
  idempotencyKey?: string;
}

const defaultSleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));

function backoffMs(attempt: number): number {
  return Math.min(2 ** attempt * 1_000, 30_000) + Math.random() * 1_000;
}

export class HTPBEClient {
  private readonly baseUrl: string;
  private readonly timeoutMs: number;
  private readonly maxRetries: number;
  private readonly fetchImpl: typeof fetch;
  private readonly sleep: (ms: number) => Promise<void>;

  constructor(
    private readonly apiKey: string,
    options: HTPBEClientOptions = {}
  ) {
    if (!apiKey) throw new Error('HTPBE API key is required');
    this.baseUrl = options.baseUrl ?? 'https://api.htpbe.tech/v1';
    this.timeoutMs = options.timeoutMs ?? 60_000;
    this.maxRetries = options.maxRetries ?? 3;
    this.fetchImpl = options.fetch ?? fetch;
    this.sleep = options.sleep ?? defaultSleep;
  }

  async verify(pdfUrl: string, options: VerifyOptions = {}): Promise<HTPBEResult> {
    // One key per logical check, reused by every retry of the POST.
    const idempotencyKey = options.idempotencyKey ?? randomUUID();

    const { id } = await this.withRetry(() =>
      this.request<HTPBEAnalyzeResponse>('POST', '/analyze', {
        body: { url: pdfUrl, original_filename: options.originalFilename },
        idempotencyKey,
      })
    );

    return this.getResult(id);
  }

  getResult(id: string): Promise<HTPBEResult> {
    return this.withRetry(() => this.request<HTPBEResult>('GET', `/result/${id}`));
  }

  private async withRetry<T>(call: () => Promise<T>): Promise<T> {
    for (let attempt = 0; ; attempt++) {
      try {
        return await call();
      } catch (err) {
        if (!(err instanceof HTPBEError) || !err.isRetryable || attempt >= this.maxRetries) {
          throw err;
        }
        await this.sleep(err.retryAfterMs ?? backoffMs(attempt));
      }
    }
  }

  private async request<T>(
    method: 'GET' | 'POST',
    path: string,
    init: { body?: Record<string, unknown>; idempotencyKey?: string } = {}
  ): Promise<T> {
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), this.timeoutMs);

    const headers: Record<string, string> = { Authorization: `Bearer ${this.apiKey}` };
    if (init.body) headers['Content-Type'] = 'application/json';
    if (init.idempotencyKey) headers['Idempotency-Key'] = init.idempotencyKey;

    try {
      const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
        method,
        headers,
        body: init.body ? JSON.stringify(init.body) : undefined,
        signal: controller.signal,
      });

      if (!response.ok) throw await this.toError(response);
      return (await response.json()) as T;
    } catch (err) {
      if (err instanceof HTPBEError) throw err;
      if (err instanceof Error && err.name === 'AbortError') {
        throw new HTPBEError(`Request timed out after ${this.timeoutMs} ms`, 0, 'client_timeout');
      }
      throw new HTPBEError(`Network error: ${(err as Error).message}`, 0, 'network_error');
    } finally {
      clearTimeout(timer);
    }
  }

  private async toError(response: Response): Promise<HTPBEError> {
    let body: Partial<HTPBEErrorBody> = {};
    try {
      body = (await response.json()) as HTPBEErrorBody;
    } catch {
      // Non-JSON body (proxy error page) – fall back to the status line.
    }

    const retryAfter = Number(response.headers.get('Retry-After'));
    return new HTPBEError(
      body.error ?? response.statusText,
      response.status,
      body.code ?? 'unknown_error',
      body.details,
      Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1_000 : undefined
    );
  }
}

Why it is built this way:

  • Timeout covers the body, too. The AbortController timer is cleared in finally, after response.json(), so a stalled response body cannot hang a worker. The default is 60 seconds because the API documents a 30-second download limit and a 20-second analysis limit; typical checks return in two to five seconds.
  • Retries are classified, not blanket. The client retries 429, any 5xx, 409 idempotency_key_in_progress, timeouts and network failures. 400, 401, 402, 403, 404, 413 and 422 fail identically every time, so they surface immediately.
  • Retry-After wins over backoff. On 429 server_at_capacity – server-wide concurrency, not per-key rate limiting – the header says how long to wait. Without it the client falls back to exponential backoff plus up to a second of jitter.
  • One idempotency key per logical check. POST /analyze accepts an Idempotency-Key header (up to 255 characters). Retry with the same value and a request that already succeeded returns 201 with the same check id and spends no second credit. Without the header, a POST that timed out on your side but finished on the server is paid for twice when you retry it.

The key has two rules worth knowing. A request still being processed answers 409 idempotency_key_in_progress – wait and retry with the same key, never a new one. And a key is bound to the exact url it was first used with: the same key with a different URL is refused with 422 idempotency_key_fingerprint_mismatch. That is why verify() generates a fresh key per call by default; it is also why the route handlers below store the check id instead of reusing a key across requests.

Error reference

Every error body is { error, code, details? }. The code values below come from the API reference:

StatuscodeRetry?What to do
400invalid_request, invalid_url_formatNoFix the request; this is a bug on your side.
400download_failedNoThe URL was unreachable or needed auth; a presigned link may have expired.
401missing_api_key, invalid_api_keyNoConfiguration error; alert, do not show to end users.
402payment_requiredNoNo credits left; top up or subscribe, then alert your team.
403inactive_client, account_restrictedNoAccount-level; contact support.
403test_url_requiredNoA test key was used with a real URL.
404not_foundNoWrong id, or the check belongs to a different API client.
409idempotency_key_in_progressYes, same keyAnother request with this key is still running.
413file_too_largeNoThe PDF is over 10 MB.
422invalid_pdfNoNot a PDF, corrupted, encrypted, or the analysis was cut off.
422idempotency_key_fingerprint_mismatchNoSame key, different URL; use a new key per document.
429server_at_capacityYes, after Retry-AfterServer-wide concurrency limit.
500internal_errorYesTransient; retry, then escalate with the URL or check id.

Two of these deserve explicit handling in your own code. 402 means every credit source on the account is exhausted: a subscription’s monthly quota, one-time credit packs and welcome credits all draw from one pool. Treat it as a billing incident, not a user error – retrying fails until the account is topped up on the pricing page. And a 422 invalid_pdf is final for that file; retrying the same bytes will not help.

Step 4: Route the Verdict

Keep the decision in one pure function so it is trivial to test and to change:

// verdict.ts
import type { HTPBEResult, HTPBEStatusReason } from './htpbe-types';

export type Decision =
  | { action: 'proceed'; checkId: string }
  | {
      action: 'human_review';
      checkId: string;
      confidence: HTPBEResult['modification_confidence'];
      markers: string[];
    }
  | {
      action: 'request_from_issuer';
      checkId: string;
      reason: HTPBEStatusReason | 'unknown';
    };

export function routeResult(result: HTPBEResult): Decision {
  switch (result.status) {
    case 'intact':
      // No structural evidence of modification: not proof the content is true.
      return { action: 'proceed', checkId: result.id };

    case 'modified':
      // A verdict about the file, not the person: queue it, never auto-reject.
      return {
        action: 'human_review',
        checkId: result.id,
        confidence: result.modification_confidence,
        markers: result.modification_markers,
      };

    case 'inconclusive':
      return {
        action: 'request_from_issuer',
        checkId: result.id,
        reason: result.status_reason ?? 'unknown',
      };

    default:
      // A status this code has never seen must fail towards review, not acceptance.
      return {
        action: 'human_review',
        checkId: result.id,
        confidence: result.modification_confidence,
        markers: result.modification_markers,
      };
  }
}

The shape follows the usage_caution.recommended_action the API returns with every result: no_action for intact, route_to_human_review for modified, request_from_issuer for inconclusive.

  • intact – no structural evidence of modification. That is not proof the content is true: let the document proceed to your normal downstream checks (amounts, issuer, identity) rather than treating it as verified.
  • modified – queue for a reviewer and carry modification_confidence and the marker ids along so they can prioritise. certain means conclusive structural evidence; high means strong evidence that rare, unusual legitimate workflows can occasionally trigger. Neither is a licence to reject an applicant automatically.
  • inconclusive – the integrity check does not apply to this origin, which is not proof of fraud. Ask the issuer for the original or a fresh copy. For a document that claims institutional origin, consumer_software_origin is itself a reason for that request. See what ‘inconclusive’ really means.
  • Unknown status – falls through to review, never to acceptance. If the API ever adds a value your types do not know, the safe default is a person, not a pass.

Persist result.id with the decision. It is your audit trail, and GET /result/{id} returns the same verdict later. Also store algorithm_version: when outdated_warning is present the file was analysed by an older algorithm, and a re-check is worth considering.

Step 5: Give the API a Reachable URL

The API does not accept uploads. It downloads a publicly reachable URL, so the file has to be fetchable for the few seconds the analysis takes. A short-lived presigned URL keeps the bucket private:

// storage.ts
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

const s3 = new S3Client({ region: process.env.AWS_REGION });

export async function presignPdfUrl(key: string): Promise<string> {
  return getSignedUrl(s3, new GetObjectCommand({ Bucket: process.env.UPLOAD_BUCKET!, Key: key }), {
    expiresIn: 300,
  });
}

Pass originalFilename to verify() whenever your storage key is an opaque id; otherwise the result’s filename is the last URL segment, which is useless in an audit trail. The 10 MB limit applies to the downloaded file. For large uploads, bounded memory and getting files to storage without buffering them in Node, see Large PDFs in Node.js Without Unbounded Buffering.

Step 6: An Express Route

The route reads a stored upload, verifies it, records the decision and maps failures to responses. Failure mapping lives in a separate function so the Express and Next.js handlers share it:

// http-errors.ts
import { HTPBEError } from './htpbe-client';

export interface HttpFailure {
  status: number;
  body: { error: string };
  retryAfterSeconds?: number;
  alert: boolean; // true = a problem on your side that someone must fix
}

export function toHttpFailure(err: unknown): HttpFailure {
  if (!(err instanceof HTPBEError)) throw err;

  switch (err.code) {
    case 'invalid_pdf':
      return { status: 422, body: { error: 'The file is not a readable PDF.' }, alert: false };

    case 'file_too_large':
      return { status: 413, body: { error: 'The PDF must be 10 MB or smaller.' }, alert: false };

    case 'download_failed':
    case 'invalid_url_format':
    case 'invalid_request':
      // Your storage URL was unreachable or malformed – a bug or an expired link.
      return { status: 502, body: { error: 'Could not read the stored file.' }, alert: true };

    case 'payment_required':
      return {
        status: 503,
        body: { error: 'Document checks are temporarily unavailable.' },
        alert: true,
      };

    case 'not_found': // the stored check id is unreadable with this key
    case 'idempotency_key_fingerprint_mismatch':
    case 'missing_api_key':
    case 'invalid_api_key':
    case 'inactive_client':
    case 'account_restricted':
    case 'test_url_required':
      return { status: 500, body: { error: 'Document checks are misconfigured.' }, alert: true };

    default:
      // Capacity, in-progress idempotency keys, timeouts and 5xx that outlived the retries.
      return {
        status: 503,
        body: { error: 'Document checks are busy. Try again shortly.' },
        retryAfterSeconds: err.retryAfterMs ? Math.ceil(err.retryAfterMs / 1_000) : 5,
        alert: err.statusCode >= 500 || err.statusCode === 0,
      };
  }
}
// express.ts
import express from 'express';
import { HTPBEClient } from './htpbe-client';
import { routeResult } from './verdict';
import { toHttpFailure } from './http-errors';
import { presignPdfUrl } from './storage';
import { findUpload, saveVerification } from './db'; // your persistence layer

const htpbe = new HTPBEClient(process.env.HTPBE_API_KEY!, { timeoutMs: 30_000, maxRetries: 1 });

export const app = express();

app.post('/api/uploads/:uploadId/verify', async (req, res) => {
  const upload = await findUpload(req.params.uploadId);
  if (!upload) {
    res.status(404).json({ error: 'Upload not found' });
    return;
  }

  try {
    // A check already exists for this upload: read it back instead of paying twice.
    const result = upload.checkId
      ? await htpbe.getResult(upload.checkId)
      : await htpbe.verify(await presignPdfUrl(upload.storageKey), {
          originalFilename: upload.originalName,
        });
    const decision = routeResult(result);
    await saveVerification(upload.id, { checkId: result.id, decision });

    res.status(decision.action === 'proceed' ? 200 : 202).json({ decision: decision.action });
  } catch (err) {
    const failure = toHttpFailure(err);
    if (failure.alert) console.error('HTPBE integration problem', err);
    if (failure.retryAfterSeconds) res.set('Retry-After', String(failure.retryAfterSeconds));
    res.status(failure.status).json(failure.body);
  }
});

Notes on the choices:

  • 202 for anything that does not proceed. The upload is not rejected; it is waiting for a person or for the issuer’s original. Your front end decides what the user sees.
  • The stored check id is the replay guard. The presigned URL changes on every signing, and an idempotency key is bound to one exact URL, so the route does not reuse keys between requests. Instead, if the upload already has a checkId it reads the result back with getResult() and spends nothing. Retries within one verify() call are covered by the per-call key. Two concurrent requests for the same upload, or a crash between verify() and saveVerification(), can still pay twice; guard the upload with an in-progress flag or lock if that can happen in your system.
  • Your problems alert, theirs do not. payment_required, key errors and download_failed are yours to fix, so they log loudly and return a generic message. invalid_pdf and file_too_large are the uploader’s and get a specific answer.
  • Never echo upstream detail to the client. The error and details strings are for your logs.

Step 7: The Same Logic in a Next.js Route Handler

The route body is identical; only the framework glue changes. This is the Next.js 15+ form, where params is a promise, with the nodejs runtime pinned because the client imports node:crypto:

// app/api/uploads/[uploadId]/verify/route.ts
import { NextResponse } from 'next/server';
import { HTPBEClient } from '@/lib/htpbe/client';
import { routeResult } from '@/lib/htpbe/verdict';
import { toHttpFailure } from '@/lib/htpbe/http-errors';
import { presignPdfUrl } from '@/lib/storage';
import { findUpload, saveVerification } from '@/lib/db';

export const runtime = 'nodejs';
// Worst case: four 30 s requests (two POST attempts, two GET attempts) plus backoff.
// Checks usually finish in seconds. If your platform caps function time below this,
// verify in a queue worker instead.
export const maxDuration = 130;

const htpbe = new HTPBEClient(process.env.HTPBE_API_KEY!, { timeoutMs: 30_000, maxRetries: 1 });

export async function POST(_req: Request, { params }: { params: Promise<{ uploadId: string }> }) {
  const { uploadId } = await params;
  const upload = await findUpload(uploadId);
  if (!upload) return NextResponse.json({ error: 'Upload not found' }, { status: 404 });

  try {
    // A check already exists for this upload: read it back instead of paying twice.
    const result = upload.checkId
      ? await htpbe.getResult(upload.checkId)
      : await htpbe.verify(await presignPdfUrl(upload.storageKey), {
          originalFilename: upload.originalName,
        });
    const decision = routeResult(result);
    await saveVerification(upload.id, { checkId: result.id, decision });

    return NextResponse.json(
      { decision: decision.action },
      { status: decision.action === 'proceed' ? 200 : 202 }
    );
  } catch (err) {
    const failure = toHttpFailure(err);
    if (failure.alert) console.error('HTPBE integration problem', err);
    return NextResponse.json(failure.body, {
      status: failure.status,
      headers: failure.retryAfterSeconds
        ? { 'Retry-After': String(failure.retryAfterSeconds) }
        : undefined,
    });
  }
}

For the rest of a Next.js integration – browser uploads, presigned storage and the UI around the verdict – see PDF Tamper Detection in Next.js API Routes. If your platform caps function duration below the worst case above, run verification in a queue worker and let the route return 202 immediately. Batch PDF Verification at Scale builds that worker with BullMQ, concurrency limits and Retry-After backoff. The API call itself is plain request/response: POST /analyze blocks until the verdict is computed and the result is retrievable the moment it returns. There is no job to poll and no webhook to register.

Step 8: Test It Without Spending Credits

Two kinds of tests, because they answer different questions. Retry, timeout and idempotency behaviour is tested with an injected fetch, so it is fast and offline. The idempotency paths cannot be reached through test keys at all – a test key returns a synthetic id before any idempotency handling runs. End-to-end scenarios use the free test key against the mock URLs:

// htpbe-client.test.ts
import { describe, it, expect, vi, beforeAll } from 'vitest';
import { HTPBEClient, HTPBEError } from './htpbe-client';
import { routeResult } from './verdict';
import type { HTPBEResult } from './htpbe-types';

const CHECK_ID = '00000000-0000-4000-8000-000000000001';

function json(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
  return new Response(JSON.stringify(body), {
    status,
    headers: { 'Content-Type': 'application/json', ...headers },
  });
}

function makeClient(responses: Array<Response | Error>, overrides = {}) {
  const queue = [...responses];
  const fetchMock = vi.fn(async (_url: string | URL | Request, _init?: RequestInit) => {
    const next = queue.shift();
    if (!next) throw new Error('unexpected extra request');
    if (next instanceof Error) throw next;
    return next;
  });
  const sleep = vi.fn(async (_ms: number) => {});
  const client = new HTPBEClient('htpbe_test_dummy', {
    fetch: fetchMock as unknown as typeof fetch,
    sleep,
    ...overrides,
  });
  return { client, fetchMock, sleep };
}

const sentHeaders = (call: unknown[]) => (call[1] as RequestInit).headers as Record<string, string>;

describe('HTPBEClient retry behaviour', () => {
  it('reuses one Idempotency-Key across retries of the POST', async () => {
    const { client, fetchMock } = makeClient([
      json({ error: 'boom', code: 'internal_error' }, 500),
      json({ id: CHECK_ID }, 201),
      json({ id: CHECK_ID, status: 'intact' }),
    ]);

    await client.verify('https://api.htpbe.tech/v1/test/clean.pdf');

    const [first, second, get] = fetchMock.mock.calls;
    expect(sentHeaders(first)['Idempotency-Key']).toBeTruthy();
    expect(sentHeaders(second)['Idempotency-Key']).toBe(sentHeaders(first)['Idempotency-Key']);
    expect(sentHeaders(get)['Idempotency-Key']).toBeUndefined();
  });

  it('waits for Retry-After on 429', async () => {
    const { client, sleep } = makeClient([
      json({ error: 'busy', code: 'server_at_capacity' }, 429, { 'Retry-After': '7' }),
      json({ id: CHECK_ID }, 201),
      json({ id: CHECK_ID, status: 'intact' }),
    ]);

    await client.verify('https://api.htpbe.tech/v1/test/clean.pdf');
    expect(sleep).toHaveBeenCalledWith(7_000);
  });

  it('retries 409 idempotency_key_in_progress with the same key', async () => {
    const { client, fetchMock } = makeClient([
      json({ error: 'in progress', code: 'idempotency_key_in_progress' }, 409),
      json({ id: CHECK_ID }, 201),
      json({ id: CHECK_ID, status: 'intact' }),
    ]);

    await client.verify('https://api.htpbe.tech/v1/test/clean.pdf');
    expect(sentHeaders(fetchMock.mock.calls[1])['Idempotency-Key']).toBe(
      sentHeaders(fetchMock.mock.calls[0])['Idempotency-Key']
    );
  });

  it('does not retry 402 payment_required', async () => {
    const { client, fetchMock } = makeClient([
      json({ error: 'No credits remaining.', code: 'payment_required' }, 402),
    ]);

    await expect(client.verify('https://api.htpbe.tech/v1/test/clean.pdf')).rejects.toMatchObject({
      statusCode: 402,
      code: 'payment_required',
    });
    expect(fetchMock).toHaveBeenCalledTimes(1);
  });

  it('gives up after maxRetries and throws the last error', async () => {
    const { client, fetchMock } = makeClient(
      Array.from({ length: 3 }, () => json({ error: 'boom', code: 'internal_error' }, 500)),
      { maxRetries: 2 }
    );

    await expect(client.verify('https://api.htpbe.tech/v1/test/clean.pdf')).rejects.toBeInstanceOf(
      HTPBEError
    );
    expect(fetchMock).toHaveBeenCalledTimes(3);
  });

  it('aborts a hanging request and reports client_timeout', async () => {
    const hang = vi.fn(
      (_url: string | URL | Request, init?: RequestInit) =>
        new Promise<Response>((_resolve, reject) => {
          init?.signal?.addEventListener('abort', () =>
            reject(new DOMException('aborted', 'AbortError'))
          );
        })
    );
    const client = new HTPBEClient('htpbe_test_dummy', {
      fetch: hang as unknown as typeof fetch,
      timeoutMs: 20,
      maxRetries: 0,
    });

    await expect(client.verify('https://api.htpbe.tech/v1/test/clean.pdf')).rejects.toMatchObject({
      code: 'client_timeout',
    });
  });
});

describe('getResult', () => {
  it('retries a 5xx and returns the stored check', async () => {
    const { client, fetchMock } = makeClient([
      json({ error: 'boom', code: 'internal_error' }, 500),
      json({ id: CHECK_ID, status: 'intact' }),
    ]);

    const result = await client.getResult(CHECK_ID);
    expect(result.status).toBe('intact');
    expect(fetchMock).toHaveBeenCalledTimes(2);
  });
});

describe('routeResult', () => {
  const base = {
    id: CHECK_ID,
    modification_confidence: 'none',
    modification_markers: [],
  } as unknown as HTPBEResult;

  it('lets intact proceed to downstream checks', () => {
    expect(routeResult({ ...base, status: 'intact' })).toEqual({
      action: 'proceed',
      checkId: CHECK_ID,
    });
  });

  it('queues modified for human review and keeps the marker ids', () => {
    const decision = routeResult({
      ...base,
      status: 'modified',
      modification_confidence: 'certain',
      modification_markers: ['HTPBE_SIGNATURE_REMOVED'],
    });
    expect(decision).toMatchObject({
      action: 'human_review',
      markers: ['HTPBE_SIGNATURE_REMOVED'],
    });
  });

  it('routes inconclusive by status_reason', () => {
    expect(
      routeResult({ ...base, status: 'inconclusive', status_reason: 'scanned_document' })
    ).toMatchObject({ action: 'request_from_issuer', reason: 'scanned_document' });
  });

  it('never accepts a status it does not know', () => {
    const decision = routeResult({ ...base, status: 'brand_new' as never });
    expect(decision.action).toBe('human_review');
  });
});

// Live calls against the mock URLs: no quota, deterministic answers.
describe.skipIf(!process.env.HTPBE_TEST_API_KEY)('test-key scenarios', () => {
  let client: HTPBEClient;
  beforeAll(() => {
    client = new HTPBEClient(process.env.HTPBE_TEST_API_KEY!);
  });
  const mock = (name: string) => `https://api.htpbe.tech/v1/test/${name}.pdf`;

  it('clean.pdf is intact with no markers', async () => {
    const result = await client.verify(mock('clean'));
    expect(result.status).toBe('intact');
    expect(result.modification_markers).toEqual([]);
    expect(result.usage_caution.recommended_action).toBe('no_action');
  });

  it('signature-removed.pdf is modified', async () => {
    const result = await client.verify(mock('signature-removed'));
    expect(result.status).toBe('modified');
    expect(result.signature_removed).toBe(true);
    expect(routeResult(result).action).toBe('human_review');
  });

  it('inconclusive.pdf carries a status_reason', async () => {
    const result = await client.verify(mock('inconclusive'));
    expect(result.status).toBe('inconclusive');
    expect(result.status_reason).toBe('consumer_software_origin');
  });

  it('trigger-402.pdf exercises the out-of-credits path', async () => {
    await expect(client.verify(mock('trigger-402'))).rejects.toMatchObject({
      statusCode: 402,
      code: 'payment_required',
    });
  });

  it('rejects a real URL with 403 test_url_required', async () => {
    await expect(client.verify('https://example.com/real.pdf')).rejects.toMatchObject({
      statusCode: 403,
      code: 'test_url_required',
    });
  });
});

The mocked block asserts the properties that cost money when they break: one Idempotency-Key across retries, Retry-After honoured, no retry on 402, a bounded number of attempts, and a clean client_timeout. The describe.skipIf block runs only when HTPBE_TEST_API_KEY is set, so CI without the secret still passes.

The mock URLs worth wiring into your suite:

URL under /v1/test/Result
clean.pdfintact, no markers
signature-valid.pdfintact, signed, no post-signature changes
modified-high.pdfmodified, modification_confidence: "high"
dates-mismatch.pdfmodified, modification_confidence: "certain"
signature-removed.pdfmodified, signature_removed: true
inconclusive.pdfinconclusive, consumer_software_origin
inconclusive-online-editor.pdfinconclusive, online_editor_origin
scanned-document.pdfinconclusive, scanned_document
trigger-402.pdf402 payment_required straight from POST /analyze

Synthetic results have check_date: null, so do not assert on it. The remaining three status_reason values (unverifiable_metadata, filled_form_origin, fill_sign_origin) have no mock URLs, which is another reason routeResult should handle any reason with the same fallback. The full list of mock URLs is in the testing reference of the public docs repository.

Step 9: Review Past Checks

GET /checks returns your check history, paginated and filterable. It is the tool for audit exports and for a periodic look at what went to review:

import type { HTPBEResult } from './htpbe-types';

interface ChecksPage {
  data: Array<Pick<HTPBEResult, 'id' | 'filename' | 'check_date' | 'status'>>;
  total: number;
  limit: number;
  offset: number;
  has_more: boolean;
}

export async function* modifiedChecks(apiKey: string): AsyncGenerator<ChecksPage['data'][number]> {
  const limit = 100;
  for (let offset = 0, hasMore = true; hasMore; offset += limit) {
    const params = new URLSearchParams({ status: 'modified', limit: String(limit), offset: String(offset) });
    const response = await fetch(`https://api.htpbe.tech/v1/checks?${params}`, {
      headers: { Authorization: `Bearer ${apiKey}` },
    });
    if (!response.ok) throw new Error(`GET /checks failed: ${response.status}`);

    const page = (await response.json()) as ChecksPage;
    yield* page.data;
    hasMore = page.has_more;
  }
}

Filters include status, from_date and to_date (Unix seconds), creator, producer and tool; limit goes up to 500. Test keys always get an empty list because synthetic results are never stored.

Step 10: Production Hardening in Node

The client from Step 3 is complete as an HTTP wrapper. Three things stay on the Node side of the boundary and are easy to get wrong: trusting the response shape, logging, and paying twice for bytes you have already checked.

Validate the response at the boundary

The as casts in Step 2 trust the documented shape. A route that moves money or opens a case should not. Parse what the routing function reads with zod, and make a failed parse a review, not a crash:

// verdict-schema.ts
import { z } from 'zod';
import type { HTPBEResult } from './htpbe-types';
import { routeResult, type Decision } from './verdict';

const RoutableResult = z.object({
  id: z.string().min(1),
  status: z.enum(['intact', 'modified', 'inconclusive']),
  status_reason: z.string().optional(),
  modification_confidence: z.enum(['certain', 'high', 'none']).nullable(),
  modification_markers: z.array(z.string()),
  usage_caution: z.object({
    safe_for_automated_adverse_decision: z.literal(false),
    recommended_action: z.enum(['route_to_human_review', 'request_from_issuer', 'no_action']),
  }),
});

export function decide(raw: HTPBEResult): Decision {
  const parsed = RoutableResult.safeParse(raw);
  if (!parsed.success) {
    // A shape we do not recognise is never a pass.
    return {
      action: 'human_review',
      checkId: typeof raw?.id === 'string' ? raw.id : 'unknown',
      confidence: 'none',
      markers: [],
    };
  }
  return routeResult(raw);
}

Two choices here are deliberate. safe_for_automated_adverse_decision is a z.literal(false): if that guarantee ever stopped being true, you want the parse to fail loudly rather than a rejection path to light up silently. And the schema lists only the fields the router reads, so additive changes to the response never break you.

Log the check, never the URL

The URL you send is a credential for as long as it lives: a presigned link is a bearer token for the file. Log the outcome, not the request. With pino (the logger Fastify uses, and a common choice behind Express):

import pino from 'pino';
import { HTPBEError } from './htpbe-client';

export const log = pino({
  redact: { paths: ['req.headers.authorization', '*.url', '*.pdfUrl'], remove: true },
});

export async function verifyAndLog(documentId: string, pdfUrl: string, filename: string) {
  const startedAt = performance.now();
  try {
    const result = await client.verify(pdfUrl, { originalFilename: filename });
    log.info(
      {
        documentId,
        checkId: result.id,
        status: result.status,
        statusReason: result.status_reason,
        markers: result.modification_markers,
        durationMs: Math.round(performance.now() - startedAt),
      },
      'htpbe check complete'
    );
    return result;
  } catch (err) {
    if (err instanceof HTPBEError) {
      log.error(
        { documentId, code: err.code, statusCode: err.statusCode, retryable: err.isRetryable },
        'htpbe check failed'
      );
    }
    throw err;
  }
}

Log documentId (yours) next to checkId (the API’s) and you can answer both ‘why was this candidate routed to review?’ and ‘which check did we pay for?’ from one line. Alert on code values that mean your configuration is wrong (invalid_api_key, payment_required, test_url_required) and treat invalid_pdf or download_failed as input problems, not incidents.

Do not pay twice for the same bytes

Users re-upload. An email gateway forwards the same attachment to three mailboxes. The API charges per analysis, and a new Idempotency-Key is a new analysis, so deduplicate on your side with a hash of the content, scoped by tenant:

CREATE TABLE htpbe_verdicts (
  tenant_id text NOT NULL,
  sha256 bytea NOT NULL,
  check_id uuid NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (tenant_id, sha256)
);
import { createHash } from 'node:crypto';

const inFlight = new Map<string, Promise<HTPBEResult>>();

export async function verifyOnce(
  tenantId: string,
  bytes: Buffer,
  pdfUrl: string,
  filename: string
): Promise<HTPBEResult> {
  const sha256 = createHash('sha256').update(bytes).digest();
  const key = `${tenantId}:${sha256.toString('hex')}`;

  const known = await db.query<{ check_id: string }>(
    'SELECT check_id FROM htpbe_verdicts WHERE tenant_id = $1 AND sha256 = $2',
    [tenantId, sha256]
  );
  if (known.rows[0]) return client.getResult(known.rows[0].check_id); // reads are free

  let pending = inFlight.get(key);
  if (!pending) {
    pending = client
      .verify(pdfUrl, { originalFilename: filename })
      .then(async (result) => {
        await db.query(
          'INSERT INTO htpbe_verdicts (tenant_id, sha256, check_id) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING',
          [tenantId, sha256, result.id]
        );
        return result;
      })
      .finally(() => inFlight.delete(key));
    inFlight.set(key, pending);
  }
  return pending;
}

Why it looks like this:

  • getResult for a cache hit. Reading a stored result never uses a credit, so the cache holds only the check id. You always return the API’s current record, including any outdated_warning, and you never copy a verdict into a second source of truth.
  • The tenant is part of the key. Without it, a hit would tell one customer that another customer had submitted the same file. Deduplicate inside a tenant, never across.
  • The in-flight map collapses concurrent uploads of the same file in one process into one request. Across several instances, two simultaneous misses can still both pay once; the primary key keeps a single row, and the cost is bounded to that rare overlap.
  • It does not replace Idempotency-Key. The key makes a network retry of one request safe. The hash makes a second upload of the same file free. They solve different problems and you want both.

What Structural Detection Does Not Catch

Encode the limits in your policy rather than discovering them in production:

  • Content created in one pass. If someone types a false figure into Word and exports once, the file was never modified after creation. That is why a payslip from a consumer tool comes back inconclusive rather than intact.
  • Visual changes without structural change. The service looks at file structure and metadata. It does not read or compare page content, and it does not run OCR.
  • Signature validity. It reports that a signature is present, was removed, or was followed by edits – it does not validate the certificate chain.
  • Password-protected files. Remove the password before submitting.

Pair the structural verdict with domain checks – amounts, account lookups, sender authentication, your KYC or OCR provider – as described in PDF Fraud Prevention Best Practices.

Decisions Before You Ship

The surface is small: one POST, one GET, three verdicts. The decisions that matter are on your side:

  • Where verification runs. Inline in the request gives an immediate answer for two to five seconds of latency; a queue worker returns instantly and delivers the decision later. Low-volume onboarding suits inline, high-volume intake suits a queue (see the batch guide). For an event-driven design, read Event-Driven PDF Verification.
  • inconclusive policy. For documents that claim institutional origin – bank statements, diplomas, payslips – treat inconclusive with the same care as modified: request the original from the issuer. For free-form user documents it may be acceptable as is.
  • A billing alert on 402. A quota boundary must never silently drop a check. Subscriptions, one-time credit packs and the pricing details are on the pricing page; new accounts get five checks to try.
  • Key separation. Test and live keys live in separate environment files. Test keys return synthetic data and must never touch a production flow.
  • Runtime validation. The as casts trust the API’s documented shape. If you want a hard boundary, parse the response with a schema library such as zod before it reaches routeResult.

Start with the curl call in Step 1 and your test key; the API reference documents every field, error code and the marker dictionary this client branches on.

Related FAQ: How long does PDF analysis take? · How should I interpret PDF analysis results?

Share This Article

Found this article helpful? Share it with others to spread knowledge about PDF security and fraud detection.

https://htpbe.tech/blog/pdf-verification-nodejs-integration-guide

Secure your workflow

Create your account – check PDFs on the web or with an API key, both ready on signup.
No sales call. Cancel any time.