PDF Security Blog

Test Keys & Deterministic Scenarios for PDF Tamper Detection

HTPBE Team··12 min read
Test Keys & Deterministic Scenarios for PDF Tamper Detection

This article is a snapshot – content was accurate as of October 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.

An HTPBE? test key (prefix htpbe_test_) accepts only the documented test URLs, https://api.htpbe.tech/v1/test/{name}.pdf. It downloads nothing: each name maps to a built-in, deterministic result with a fixed check ID, and no credits are spent. That makes the whole flow – POST /v1/analyze, then GET /v1/result/{id} – stable enough to run in CI on every pull request.

HTPBE? is PDF tamper detection: it reports whether a file’s structure shows evidence of modification after it was created. It does not verify identity, issuer records or what the document says, and nothing about test keys changes that.

This article is about the test suite around your integration: which scenarios to use for which code path, what to assert, which error paths you can and cannot reach, and how to split test and live keys in CI. How to route intact, modified and inconclusive once you have them is covered in Handling Verdicts in Code; this piece links there rather than repeating it.

What a test key does, and what it doesn’t

If you have used Stripe test cards, the model is the same. A test key:

  • accepts only test URLs; anything else returns 403 with code test_url_required;
  • never fetches the URL – the filename selects a built-in result;
  • returns 201 Created with { "id": "..." } and a Location header, exactly like a live call;
  • serves the result from GET /v1/result/{id} in the same response contract as a real check – the same fields, including status_reason, usage_caution and, where relevant, outdated_warning;
  • returns the same verdict, markers and flags for the same URL regardless of account or how often you call it;
  • spends no credits, stores no check, and adds nothing to your check history. GET /v1/checks with a test key always returns an empty list.

Two things it does not do:

It does not test detection. The results are synthetic shapes built to exercise your code. They say nothing about what the analysis would return on any real file, and the marker on a scenario is not an explanation of how detection works. A green suite proves your integration handles every documented state – not that HTPBE? is right about your documents. For that, run a live key against representative files from your own pipeline. Signup includes 5 welcome credits for exactly that.

It never reads real data. A test key cannot fetch a real check by ID, not even one your own account created with a live key – that returns 404 not_found.

The scenario matrix

At the time of writing (algorithm version 2.46.5) there are 21 result scenarios and one error trigger. You do not need all of them. Pick one per code path:

Code pathTest URL (/v1/test/...)statusmodification_confidencemodification_markersAlso assert
Intactclean.pdfintactnone[]–
Null metadata fieldsclean-no-dates.pdfintactnone[]creation_date, modification_date, creator, producer all null
Signature fieldssignature-valid.pdfintactnone[]has_digital_signature: true
Modified, highmodified-high.pdfmodifiedhighHTPBE_EDITING_TOOL_FINGERPRINT–
Modified, certaindates-mismatch.pdfmodifiedcertainHTPBE_DATES_DISAGREE–
Signature removedsignature-removed.pdfmodifiedcertainHTPBE_SIGNATURE_REMOVEDsignature_removed: true
Edited after signingmodified-after-sign.pdfmodifiedcertainHTPBE_POST_SIGNATURE_EDITmodifications_after_signature: true
Content flagsboth-threats.pdfmodifiedcertainHTPBE_SIGNATURE_REMOVEDhas_javascript: true, has_embedded_files: true
Invalid date sequencedate-sequence-invalid.pdfmodifiedcertainHTPBE_DATES_DISAGREEdate_sequence_valid: false
Inconclusive, consumerinconclusive.pdfinconclusivenone[]status_reason: consumer_software_origin
Inconclusive, online editorinconclusive-online-editor.pdfinconclusivenone[]status_reason: online_editor_origin
Inconclusive, scanscanned-document.pdfinconclusivenone[]status_reason: scanned_document
Stale resultoutdated-version.pdfintactnone[]algorithm_version: "1.0.0", outdated_warning present
Out of creditstrigger-402.pdf–––402, code payment_required, no ID

The full list, including modified-low, modified-medium, multiple-xref, javascript, embedded-files, modified-critical and dates-same, is in the public testing guide.

One trap in that list: scenario names are labels, not confidence levels. modified-low.pdf returns modification_confidence: "high"; modified-critical.pdf returns "certain". The API has no severity field, and the ‘low / medium / high / critical’ wording in the filenames is not something your code can read off a live response. Assert the confidence the result actually returns.

What to assert, in general: status, status_reason, modification_confidence, the markers as a set, and the boolean flags your code branches on. What not to assert: check_date, which is always null on synthetic results; algorithm_version and current_algorithm_version, which follow the current release (except on outdated-version.pdf); and the exact text of outdated_warning, which is written for people. Rows are chosen for the fields they exercise; no row describes what makes a real file land in that verdict.

Hardcode the IDs

Synthetic IDs follow one pattern: all zeros, with a last segment that counts up in hexadecimal (...009, ...00a, and so on up to ...00f, then ...010). clean.pdf is 00000000-0000-4000-8000-000000000001, inconclusive.pdf is ...000000000011, scanned-document.pdf is ...000000000015. Because they never vary, you can test the analyse step and the result step separately:

import { describe, it, expect } from 'vitest';

const API = 'https://api.htpbe.tech/v1';
const testUrl = (name: string) => `${API}/test/${name}.pdf`;
const headers = {
  Authorization: `Bearer ${process.env.HTPBE_TEST_KEY}`,
  'Content-Type': 'application/json',
};

const analyze = (url: string) =>
  fetch(`${API}/analyze`, { method: 'POST', headers, body: JSON.stringify({ url }) });
const getResult = (id: string) => fetch(`${API}/result/${id}`, { headers });

describe('analyze', () => {
  it('returns the fixed ID and Location for a scenario', async () => {
    const res = await analyze(testUrl('inconclusive'));
    expect(res.status).toBe(201);
    expect(await res.json()).toEqual({ id: '00000000-0000-4000-8000-000000000011' });
    expect(res.headers.get('location')).toBe(
      'https://api.htpbe.tech/v1/result/00000000-0000-4000-8000-000000000011'
    );
  });
});

Fixed IDs are also useful outside the HTTP layer. If your system stores the check ID next to an application record, a fixture row with ...000000000005 can drive your reviewer UI, audit export or reprocessing job against GET /v1/result/{id} without a fresh POST each time.

The pairing has to match. A synthetic ID requested with a live key returns 404 not_found, so a staging environment that quietly swaps keys will fail in a way that looks like a missing record.

Contract tests on the response

The point of a contract test is to catch the day your adapter and the API stop agreeing. Assert invariants, not values:

const IDS = {
  intact: '00000000-0000-4000-8000-000000000001',
  modified: '00000000-0000-4000-8000-000000000007',
  inconclusive: '00000000-0000-4000-8000-000000000011',
  stale: '00000000-0000-4000-8000-000000000013',
};

const ACTION = {
  intact: 'no_action',
  modified: 'route_to_human_review',
  inconclusive: 'request_from_issuer',
} as const;

describe.each(Object.entries(IDS))('result contract: %s', (_name, id) => {
  it('holds the invariants our code relies on', async () => {
    const res = await getResult(id);
    expect(res.status).toBe(200);
    const r = await res.json();

    expect(['intact', 'modified', 'inconclusive']).toContain(r.status);
    expect(Array.isArray(r.modification_markers)).toBe(true);
    expect('status_reason' in r).toBe(r.status === 'inconclusive');
    expect(r.usage_caution.safe_for_automated_adverse_decision).toBe(false);
    expect(r.usage_caution.recommended_action).toBe(ACTION[r.status as keyof typeof ACTION]);
    if ('outdated_warning' in r) expect(typeof r.outdated_warning).toBe('string');
  });
});

What this suite should prove rather than leave to assumption:

  • usage_caution.safe_for_automated_adverse_decision is false on every result, intact included. intact means no structural evidence of modification was found. It is not a certificate that the document is authentic.
  • inconclusive routes to a person or to the issuer, never to an automatic rejection. The three inconclusive scenarios are the cheapest way to show that each status_reason lands in a review or request-from-issuer path, not a reject branch.
  • Markers are a set. Compare with expect.arrayContaining or a sorted copy, never by index.
  • There is no numeric risk score. The verdict is the status string plus named markers. Don’t add a score in your adapter and then test against it.

Live traffic can also carry more status_reason values than the three the scenarios reproduce: unverifiable_metadata, filled_form_origin, fill_sign_origin, visible_signature_origin and analysis_incomplete are all in the result reference. Cover them – and an unrecognised value – with a unit test on a hand-built object, so they hit one fallback path instead of falling through to accept. Don’t encode ‘inconclusive means no markers’ as a rule either: the reference notes that an analysis_incomplete result keeps any markers already found.

Covering error paths

These error paths are reachable with a test key:

RequestStatuscode
No Authorization header401missing_api_key
Unknown htpbe_test_... key401invalid_api_key
POST /v1/analyze with no url in the body400invalid_request
Any non-test URL, or an unknown test filename403test_url_required
trigger-402.pdf402payment_required
GET /v1/result/not-a-uuid400invalid_request
GET /v1/result/{real ID} with a test key404not_found
GET /v1/result/{synthetic ID} with a live key404not_found
GET /v1/checks with a test key200– (data: [], total: 0, has_more: false)

Every error body has the shape { error, code, details? }. Assert on the HTTP status and code, never on error. error is human-readable text and can change between releases; code is the contract.

// analyzeDocument and OutOfCreditsError are your own wrapper and error class.
it('treats 402 as a billing event, not a retry', async () => {
  await expect(analyzeDocument(testUrl('trigger-402'))).rejects.toBeInstanceOf(OutOfCreditsError);
});

No test scenario reproduces these: 429 server_at_capacity, 500, 413 file_too_large, 422 invalid_pdf, 400 download_failed, 403 inactive_client and the Idempotency-Key conflict codes. Cover these with a stubbed fetch in unit tests – especially your 429 handling, which should honour Retry-After. The retry rules themselves are in the verdict-handling article, and the queue-side version is in the batch processing guide.

CI layout

A layout that keeps the fast path free and the real path honest:

  1. Unit tests, every PR, no network. Stubbed fetch for retries, capacity, timeouts, unknown status_reason values and null fields.
  2. Contract tests, every PR, test key. The scenario and error-path suites above, with HTPBE_TEST_KEY stored as a CI secret. These go over the network to api.htpbe.tech but cost nothing.
  3. Live smoke test, separate job, live key. Run it on release or on a schedule, not on every PR – each check spends a credit. Use one or two of your own representative documents, and assert only that the call succeeds, status is one of the three values and usage_caution is present. Don’t pin a specific verdict on a real file: results can change between algorithm versions, which is what outdated_warning exists to tell you.

Make each job refuse the wrong key type before it runs anything:

// vitest setup for the contract suite
const key = process.env.HTPBE_TEST_KEY ?? '';
if (!key.startsWith('htpbe_test_')) throw new Error('HTPBE_TEST_KEY must be a test key');

The mirror-image check belongs in the live job. A live key pointed at a test URL gets no synthetic result – it treats the URL as a real file, and the call fails; a test key pointed at any other URL fails with 403. Both are easier to diagnose at startup than halfway through a run.

Moving from test to live

Before switching keys, check that:

  • every verdict lane has at least one passing scenario, and every status_reason has a fallback;
  • 402 alerts the account owner and is never retried;
  • null metadata fields and an empty markers array don’t break rendering or storage;
  • keys live in environment variables, with test and live in separate environments;
  • the live smoke job has run at least once against your own files.

Then generate a live key from the API keys page in the dashboard. The language-specific clients are in the Node.js guide and the Python guide; if analysis runs from an upload pipeline, the event-driven integration guide covers the worker side. Endpoint reference is on the API page. After the welcome credits, pay-per-check starts at $5 for 8 credits and plans start at $15 a month – see pricing.

To get a test key, create an account and open the API keys page; the test key is generated the first time you visit it.

Frequently asked questions

Do test-key calls cost credits?

No. A test key never spends credits, no check is stored, and nothing appears in your check history. GET /v1/checks with a test key always returns an empty list.

Can I send my own PDF with a test key?

No. Any URL that is not one of the documented test URLs returns 403 with the code test_url_required. To analyse your own files, use a live key; signup includes 5 welcome credits.

Do the test scenarios show what HTPBE? would find in a real document?

No. They are synthetic results built to exercise your integration code. They say nothing about how the analysis would treat any real file. To see real behaviour, run a live key against representative documents from your own pipeline.

Can I hardcode the synthetic check IDs?

Yes. Each test URL maps to a fixed check ID, and the same URL returns the same verdict, markers and flags for every account and every call. The current list is in the public testing guide.

Why does my test key return 404 for a check I ran with a live key?

Test keys are strictly synthetic and never resolve a real check, not even one your own account created. The reverse also holds, so a synthetic ID requested with a live key returns 404 not_found.

Share This Article

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

https://htpbe.tech/blog/testing-pdf-verification-integration-test-keys

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.