Inspect a document or image for fraud/tamper signals
Accepts multipart/form-data (field "file") or a raw body, up to the configured size cap. Returns an InspectionReport. Uploaded bytes are processed in memory.
Retention. A call with no account (the anonymous checker, a key with no account) keeps nothing. A call made for an account whose history is on (see GET /api/v1/retention; on by default for accounts created since retention launched, off for older ones until they turn it on) also keeps a short record of the result: a closed list of derived fields, never the file, its name or any text from it. The file itself is kept, encrypted, only when the account opted in to file retention. A key set to retention: none, or the header X-Tamperlens-Retention: none on one call, keeps nothing; the header can only turn keeping off, never on. When a record was kept the response carries retention: {stored, id, expiresAt, file?}, and tenantMatches lists up to 20 earlier records of the same account (of the same key, for a key) that share an origin with this file. Both sit outside the engine's report and inside the body the receipt signs.
Accepted formats. PDF; Office Open XML documents (.docx, .xlsx, .pptx and their macro-enabled twins); plus JPEG, PNG, WebP, HEIC/HEIF and AVIF images. TIFF, GIF and BMP are recognised and reported but their containers are not walked. Such a report carries an image-structure-anomalies signal saying so, because an empty result must never be mistaken for a clean one. The format is determined by sniffing the bytes, not from the Content-Type header or a filename, so a mislabelled upload is still analysed correctly.
Image reports set mediaType: "image" and add an image block (format, dimensions, camera make/model, metadata containers, JPEG quality, encoder fingerprint). document is still populated so existing integrations keep working: producer becomes the writing software and creator the capture device. The nine image-prefixed signal families read container structure and metadata, no pixels are decoded, so this is not error-level analysis or noise-residual forensics.
Office reports set mediaType: "office" and add an office block (format, application and version, who saved it last, save count, editing minutes, part count, macro presence). document is still populated so existing integrations keep working: producer becomes the writing application, creator the recorded author, and revisions the cp:revision save counter. Eight office-prefixed families read the package's XML and structure. Unaccepted tracked changes (whose deleted text is recovered and returned, the Word analogue of text under a redaction box), hidden text, hidden and very-hidden sheets, hidden slides, embedded comments, macros and remote templates, author/last-editor disagreement, and timestamp anomalies. The document is not rendered, so text hidden by being coloured to match its background or covered by a shape is NOT detected; every report discloses that blind spot rather than leaving it to be inferred.
Prompt-injection markers, and their untrusted payload. document-injection-markers (PDF) and office-injection-markers (OOXML) report text placed in a document to be read by a language model rather than by a person, in Info-dictionary values, XMP text nodes, annotation /Contents, embedded-file names and /Desc, docProps/*, Word comments and hidden (w:vanish) runs. Concealed PAGE text is NOT covered: an OCR layer is invisible text too, and that needs discriminators this engine does not ship yet.
The recovered text is attacker-authored. It appears only under evidence.fields[].payload and evidence.fields[].name, sanitised and truncated, on a signal carrying evidence.payloadIsUntrusted: true — never in title, detail or message.params, which name the kind of field from a fixed list. Do not put it in a model prompt without neutralising it. Add ?redact=payload to receive the finding with those two keys replaced by "[redacted]" and evidence.payloadRedacted: true set, so "nothing was found" stays distinguishable from "you were not given it". The same flag and elision cover evidence.recovered and evidence.authors on office-hidden-content and office-tracked-changes since engine 1.30.0: comment text and deleted text are document-authored too. Everything else — the score, the band, the cue categories, every count, and a verdict policy, which is evaluated on the unredacted report — is unchanged. Any other redact value is ignored rather than rejected. The MCP server applies this elision unconditionally: an MCP tool result is a model's context.
What each revision of a PDF did (engine 1.32.0). A PDF that was saved more than once keeps every earlier version's bytes, and the report describes the chain revision by revision rather than only in total. Two blocks carry it, both 1-based (revision: 1 is the original save):
- incremental-updates.evidence.changedPerRevision[] — for each appended revision: xrefOffset, the object numbers it wrote (objectsWritten), which of those replaced an object an earlier revision had written (objectsOverwritten, each with a kind of page | content | image | annotation | other), and touched.
- signature-permissions.evidence.revisionsAssessed[] — present when the document carries a certification signature: for each revision appended after it, the same touched counters plus that revision's own assessment against /DocMDP /P (content | structure | annotation | opaque | ltv | permitted). This is what names which append broke the certification, where the signal's document-level assessment says only that one did.
touched counts the objects that revision wrote, by kind. Seven of the eight keys partition them — pageContent, images, pages, widgetAnnotations, otherAnnotations, signatures, other — and sum to objectsWrittenTotal. The eighth, pageImages, is a subset of images and not a bucket beside it: it is the count a page actually names, so totalling all eight double-counts every page-drawn image. That narrowing is what keeps a permitted form fill-in from reading as a repainted page — a regenerated appearance stream and the graphic inside a signature widget are not page-reachable, so they arrive as widgetAnnotations and images with pageContent: 0 and pageImages: 0. Both lists are capped like every array in a report; the companion *Total/*Truncated keys say when what you received is a sample.
These counts are not a diff, and must not be presented as one. They are counts of PDF objects by kind. Tamperlens does not render two revisions and compare them: "revision 3 rewrote one page content stream and added two form fields" is what the counts say. A revision that only ADDS objects has an empty objectsOverwritten and a non-zero touched — the two answer different questions and neither one substitutes for the other.
Which amounts of money the last revision changed (engine 1.42.0, opt-in). Off by default: only a request with ?changes=values (here or on /case) gets these fields, and a report without it carries none of them; the free checker and the MCP server never ask. With it, incremental-updates.evidence.textChanges[] compares the LAST revision with the one immediately before it, reading the text layer of both (no rendering), and lists the money amounts that changed: {revision, page, kind, tokenKind, before, after, region}, with kind one of added, removed, replaced and tokenKind always money: an amount with a decimal separator and exactly two decimals, optionally after R$, US$ or $, thousands groups of three, at most twelve integer digits, at most 40 characters. Nothing else is ever returned: no words, labels or names, no dates, no numbers that are not money, and no identifier, not even masked. A printed line is not read when its own text holds a run of nine or more digits or up to eleven digit groups that pass a CPF or CNPJ check, when such a run or group in its neighbourhood (widened until 13 digits are gathered on each side, at most 12 lines each way, joined without separators) takes digits from it, when it carries an identifier label (CPF, RG, conta, account, phone...), or when it sits up to two lines below one that its own line does not answer with a complete, checkable value (a CEP of eight digits, a CPF or CNPJ that passes its check digits), on either revision. Residual risk: an identifier written in money format with no recognizable label nearby (an account printed as 12.345,67), or split into pieces more than 12 lines apart, cannot be told from an amount and can be reported as one; a caller that stores or forwards reports should not enable it. The values are still document content, so the evidence carries payloadIsUntrusted: true when it holds one, and ?redact=payload and the MCP server replace before and after with "[redacted]". region locates the value on the page (the after-value; for removed, where the before-value was). At most 50 entries; textChangesTotal and textChangesTruncated say when the list is a sample. textChangesSkipped (linearized, too-many-revisions, budget for a work limit, time-budget when the clock ran out, which also sets analysisIncomplete) means the comparison was declined and textChanges is empty; the fields are absent when no comparison was attempted (an encrypted file, triage scope, a revision chain that could not be cut cleanly). Nothing here moves a severity or the score.
Optional issuer baseline. Name the institution the document claims to come from and it is additionally compared against a structural profile built from genuine documents of that issuer — which is what catches a file that was fabricated cleanly rather than edited. Two spellings:
- multipart: an issuer field, sent before the file part;
- raw body: an X-Tamperlens-Issuer header.
The slug must match ^[a-z0-9][a-z0-9-]{0,63}$; anything else is 422 invalid_issuer. An unknown slug is not an error: the normal report is returned with issuerBaseline: {requested, resolved: false, reason: "no_profile"}, so a caller can distinguish "matched" from "nothing to compare against". When a profile resolves, issuerBaseline reports which tier answered (the account's own profile, else a built-in public specimen), its sample count and the divergence; a confident deviation also raises an issuer-mismatch signal. Omit issuer and the response is exactly what it has always been — the key is not present at all. Issuer baselines are a PDF feature; naming one alongside an image returns issuerBaseline: {resolved: false, reason: "not_applicable"}.
Optional verdict policy. Tamperlens reports risk signals, never verdicts — the engine cannot know whether an appended revision is a forgery or your payroll provider, because that depends on your population and what a false accusation costs you. You can, so you can send the rule and get it back applied. Two spellings, same as issuer: a multipart policy field sent before the file part, or an X-Tamperlens-Policy header. Both carry a JSON object:
{ "review": 30, "reject": 70,
"rejectOn": ["redaction-exposure"],
"reviewOn": ["incremental-updates"] }
review and reject are score cut-points (defaults 30 and 70, matching the engine's own bands). rejectOn/reviewOn name signal families that force a verdict whatever the score says — a rule beats a threshold, and rejectOn beats reviewOn, because a caller naming a family means it matters regardless of the arithmetic. Only signals above info severity can trigger a rule.
ignore names signal families your channel makes noisy (default []). An ignored family cannot trigger a rule and does not count toward the score the thresholds read: the policy rescores the remaining signals with the engine's own scoring function and returns that number as effectiveRiskScore, beside the unchanged riskScore. ignore beats rejectOn/reviewOn for the same id. The signal stays in the report.
preset (strict | balanced | lenient, default none) is a named starting point that expands before your own keys, so anything you send wins; a list you send replaces the preset's. balanced is the engine's bands (30 and 70). strict also sends any low-severity finding to review (review from 10, reject from 70). lenient sends only the high band to review (review from 70) and rejects by score only at 100. No preset rejects below 70. The published false-positive and recall figures behind them are at /evidence; they are a starting point, not a guarantee about your own documents.
The response gains a policy block: {verdict, riskScore, thresholds, reason, triggeredBy, policyVersion} where verdict is accept | review | reject and reason is score or signal. With a non-empty ignore it also carries effectiveRiskScore and ignored (the ignored families that actually fired). policyVersion is pv1- plus 12 hex characters of a sha256 over the normalized policy (preset expanded, lists sorted and de-duplicated), so the same rule sent with keys or lists in another order gets the same value; store it beside the decision. Nothing about the report changes: not the score, not the band, not a single signal. The verdict is yours, computed from your numbers, and labelled as such.
Unknown signal ids in rejectOn/reviewOn/ignore are accepted and simply never match, so a family we rename cannot take your pipeline down. A malformed policy is 422 invalid_policy, because silently falling back to our defaults would mean a typo leaves you believing a rule is enforced when it is not. Omit policy and the response is exactly what it has always been — the key is not present at all.
Measured I/O cost (engine 1.34.0). Every report carries summary.cost: {physicalBytes, expandedBytes, expansionRatio}. physicalBytes is the file size; expandedBytes is the decompressed output the parse produced (content, object and cross-reference streams); expansionRatio is the second over the first, rounded to 3 decimals. Both counts are deterministic, so they ride in the byte-identical body. Wall-clock time is NOT deterministic and is not in the body by default; add ?timing=1 to receive a separate timing: {elapsedMs} block.
Cheap triage (?mode=triage, engine 1.34.0). For an agent deciding whether a document is worth ingesting at all. A PDF is analysed for structure, metadata, signatures and presence flags only — the per-page content walk is skipped — for a fraction of the decompression cost (measured ~9% of a full parse on the phase-0 corpus). The response is an ordinary report plus a triage block {contentAnalysed: false, suppressedFamilies, guidance}. A quiet triage is not a clean document: the eight page-content families in suppressedFamilies (hidden text, redaction failure, altered arithmetic, glyph tampering, embedded-image anomalies, certification breaks) did not run. Use it to decide whether to run a full inspection, never as the full inspection. Office and image formats have no separate expensive walk, so they return their ordinary report; issuer is ignored in triage.
Regions, file hash, fingerprint, coverage and reason code (engine 1.39.0). Additive facts; none is a signal and none moves the score or the band. redaction-exposure, glyph-anomalies, running-balance-break, br-identifier-checksum and hybrid-page carry evidence.regions (at most 50, with regionsTotal and regionsTruncated): {page, x0, y0, x1, y1}, page 1-based, in the PDF's default user space with no /Rotate applied and the MediaBox origin kept, so a viewer converts them with the page's own viewport. On glyph-anomalies and br-identifier-checksum each region also carries findingIndex, the index into findings. Numbers only, never text. document.sha256 is the SHA-256 of the bytes received, on every media type. fingerprint (PDF only; null for images, Office files, encrypted PDFs and triage) has issuerProfileHash, the SHA-256 of the issuer-baseline profile, which groups genuine documents of one issuer and is never evidence on its own, and baseFile (idPermanent = trailer /ID[0], creationDate, sorted fontSubsetTags, at most 20, only tags that look random: generators that number them, like Chrome's AAAAAA/BAAAAA and mPDF's MPDF.., are excluded) for files saved from the same base file. The tags are a weak hint that depends on the generator; with idPermanent they are a pseudonym of the file, not personal data. coverage is {scope, blindSpots}: scope is triage exactly when the triage block is present, metadata-only for images, encrypted PDFs and unwalkable containers, partial for a PDF whose analysis a work limit or the clock cut short (engine 1.43.0, see analysisIncomplete below), full otherwise; blindSpots is a closed list in fixed order (page-content-not-walked, pixels-not-decoded, office-not-rendered, encrypted-content, text-extraction-untrusted, clean-regeneration, the last always present). summary.reasonCode names the first matching rule: SIGNED_BYTES_CHANGED, CONTENT_REPLACED_AFTER_CREATION, REDACTION_EXPOSED, APPENDED_AFTER_SIGNATURE (bytes past a signature that are not later signatures; a countersigned contract does not get it), SIGNATURE_ONLY_REVISIONS (a countersigned contract and nothing else: at least one medium finding, and every finding above info, low included, is signature-coverage, incremental-updates or id-inconsistency carrying exemptionReason: signature-only-revision-capped-elevated; fails closed), SEVERAL_FAMILIES (2+ families at medium or above), ONE_FAMILY (1 at medium or above), LOW_SEVERITY_ONLY (only low findings), NO_FINDINGS_PARTIAL_SCOPE (nothing above info and scope not full, or extraction untrusted, Office not rendered, or a scanned PDF), NO_FINDINGS. The codes describe what was observed, never a verdict.
When the analysis was cut short (engine 1.43.0). A PDF report carries analysisIncomplete only when part of the analysis did not run; a complete report has no such key. {reason: "work-limit", limit} means a deterministic limit on work stopped it, and limit names the first one hit, in parse order: input-bytes, inflated-bytes (64 MiB decompressed per document), stream-inflated-bytes (32 MiB per stream), xref-entries, xref-free-entries, xref-chain, objects, decoded-scan-bytes, xmp-packet-bytes, analysed-pages (200), text-runs (100 000), page-content-bytes (8 MiB a page), page-content-operators (2 000 000 a page), content-bytes (24 MiB of page content per document, Form XObject content counted at every draw), content-operators (8 000 000 per document), text-operators (250 000 Tj, TJ, ' and " per document), forms-per-page (400), form-content-bytes (1 MiB a form), form-invocations (2 000 a page), form-depth (8) or graphics-state-depth (50). A work limit stops the analysis at the same point for the same bytes. {reason: "time-budget"} means the 10-second clock ran out first: a safety net the work limits are sized to stay well inside on the production host, which adversarial input or a slower, busier machine can still reach. A report without time-budget was not touched by the clock; one with it can differ on a retry. {reason: "parse-error"} means the structural parse failed part-way. In every case coverage.scope is partial and a report with no findings carries NO_FINDINGS_PARTIAL_SCOPE, so an unfinished analysis never reads as a clean one, and structure-warnings lists the same cut among its warning codes. A cut can also change a real document's score: since a cut parse cannot vouch for what a later signature revision touched, a countersigned contract that hits one is not exempted and reads high instead of elevated.
Usage caution. Every report carries a constant usageCaution block: {safeForAutomatedAdverseDecision: false, guidance}. It is the machine-readable form of the disclaimer, for integrations that gate on a field instead of parsing prose. It is false on every report, including a clean one — absence of signals is not proof of authenticity, and no report is safe as the sole basis for an automated adverse decision.
Request
- Base URL: https://tamperlens.com (the document declares no server; this is the origin it was published from)
- URL: https://tamperlens.com/api/v1/inspect
- Auth: none declared
Query parameters
Set to payload to receive injection findings with the attacker-authored text elided (evidence.payloadRedacted: true). Any other value is ignored.
Set to values to add, on a PDF whose last revision rewrote a page, the money amounts that revision changed (incremental-updates.evidence.textChanges, engine 1.42.0). Off by default: without it a report carries no textChanges field at all. Residual risk: an identifier written as money with no label nearby can appear as an amount, so a caller that stores or forwards reports should leave it off. Any other value is ignored.
Set to triage for the cheap tier (engine 1.34.0): a PDF is analysed for structure, metadata, signatures and presence flags only — no per-page content walk — at a fraction of the decompression cost. The response carries a triage disclosure block. A quiet triage is NOT a clean document: hidden text, redaction failure, altered arithmetic, glyph tampering and certification breaks are not checked in this scope. Office and image formats have no expensive walk to skip, so they return their ordinary report. issuer is ignored in triage.
Set to 1 to add a timing: { elapsedMs } block. Off by default because wall-clock is not deterministic and the report body is byte-identical; the deterministic cost counters live in summary.cost and are always present.
Response
Default Response
Changes
No recorded changes to this operation across all 1 revision of this API.