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
403with codetest_url_required; - never fetches the URL – the filename selects a built-in result;
- returns
201 Createdwith{ "id": "..." }and aLocationheader, 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, includingstatus_reason,usage_cautionand, 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/checkswith 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 path | Test URL (/v1/test/...) | status | modification_confidence | modification_markers | Also assert |
|---|---|---|---|---|---|
| Intact | clean.pdf | intact | none | [] | – |
| Null metadata fields | clean-no-dates.pdf | intact | none | [] | creation_date, modification_date, creator, producer all null |
| Signature fields | signature-valid.pdf | intact | none | [] | has_digital_signature: true |
Modified, high | modified-high.pdf | modified | high | HTPBE_EDITING_TOOL_FINGERPRINT | – |
Modified, certain | dates-mismatch.pdf | modified | certain | HTPBE_DATES_DISAGREE | – |
| Signature removed | signature-removed.pdf | modified | certain | HTPBE_SIGNATURE_REMOVED | signature_removed: true |
| Edited after signing | modified-after-sign.pdf | modified | certain | HTPBE_POST_SIGNATURE_EDIT | modifications_after_signature: true |
| Content flags | both-threats.pdf | modified | certain | HTPBE_SIGNATURE_REMOVED | has_javascript: true, has_embedded_files: true |
| Invalid date sequence | date-sequence-invalid.pdf | modified | certain | HTPBE_DATES_DISAGREE | date_sequence_valid: false |
| Inconclusive, consumer | inconclusive.pdf | inconclusive | none | [] | status_reason: consumer_software_origin |
| Inconclusive, online editor | inconclusive-online-editor.pdf | inconclusive | none | [] | status_reason: online_editor_origin |
| Inconclusive, scan | scanned-document.pdf | inconclusive | none | [] | status_reason: scanned_document |
| Stale result | outdated-version.pdf | intact | none | [] | algorithm_version: "1.0.0", outdated_warning present |
| Out of credits | trigger-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_decisionisfalseon every result,intactincluded.intactmeans no structural evidence of modification was found. It is not a certificate that the document is authentic.inconclusiveroutes to a person or to the issuer, never to an automatic rejection. The three inconclusive scenarios are the cheapest way to show that eachstatus_reasonlands in a review or request-from-issuer path, not a reject branch.- Markers are a set. Compare with
expect.arrayContainingor a sorted copy, never by index. - There is no numeric risk score. The verdict is the
statusstring 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:
| Request | Status | code |
|---|---|---|
No Authorization header | 401 | missing_api_key |
Unknown htpbe_test_... key | 401 | invalid_api_key |
POST /v1/analyze with no url in the body | 400 | invalid_request |
| Any non-test URL, or an unknown test filename | 403 | test_url_required |
trigger-402.pdf | 402 | payment_required |
GET /v1/result/not-a-uuid | 400 | invalid_request |
GET /v1/result/{real ID} with a test key | 404 | not_found |
GET /v1/result/{synthetic ID} with a live key | 404 | not_found |
GET /v1/checks with a test key | 200 | – (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:
- Unit tests, every PR, no network. Stubbed
fetchfor retries, capacity, timeouts, unknownstatus_reasonvalues and null fields. - Contract tests, every PR, test key. The scenario and error-path suites above, with
HTPBE_TEST_KEYstored as a CI secret. These go over the network toapi.htpbe.techbut cost nothing. - 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,
statusis one of the three values andusage_cautionis present. Don’t pin a specific verdict on a real file: results can change between algorithm versions, which is whatoutdated_warningexists 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_reasonhas a fallback; 402alerts 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.