Originally published at htpbe.tech. The version on htpbe.tech stays in sync with the latest detection algorithm — refer to it for the canonical text.
This guide covers one narrow job: checking whether a PDF file was structurally modified after it was created, from a Rust service, before your business logic trusts it. It is not KYC, not identity or issuer verification, not OCR and not AI-generated-content detection. The check looks at the file’s structure and metadata, says whether that structure shows evidence of post-creation editing, and leaves the person who sent it entirely out of the answer.
The PDF tamper detection API is a small REST surface: one POST to submit a URL, one GET to read the verdict. There is no official Rust SDK, so this guide builds a client by hand against the REST contract with reqwest, serde, thiserror and tokio: result types, an error enum that knows what is worth retrying, idempotent retries, an axum handler that routes each verdict, and a presigned-URL helper for private buckets.
If you want the concepts first, read How to Detect PDF Tampering Programmatically. The same integration exists for Go, Node.js, Python, Java / Spring Boot, C#, Laravel / PHP and Ruby on Rails.
TL;DR
- Two calls:
POST /analyzetakes a PDF URL and returns201with a checkid;GET /result/{id}returns a flat JSON object whosestatusisintact,modifiedorinconclusive. - Analysis is synchronous. When the
POSTreturns, the result is ready to read. - Send an
Idempotency-Keyon everyPOSTand reuse it on every retry of that request. A retry of a completed request returns the sameidand is not charged again. - Retry
409,429,500,502,504and transport failures with the same key. Never retry400,401,402,403,404,413or422; a503surfaces as a pending check to retry once later. - Route on
usage_caution.recommended_action. No branch in this guide auto-declines a person: the API marks every verdictsafe_for_automated_adverse_decision: false. - A structural verdict is about the file.
intactmeans no structural modification was detected, not that the document is genuine or its contents are true.
Prerequisites
- A recent stable Rust toolchain.
- An HTPBE account. Every account has a test key (
htpbe_test_...) on the API keys page of the dashboard; live keys start withhtpbe_live_.... - These dependencies, at the crate versions the listings below target:
[dependencies] reqwest = { version = "0.13", features = ["json"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "time"] } serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" uuid = { version = "1", features = ["v4"] } # Step 5 (HTTP handler) and Step 6 (presigned URLs) only axum = "0.8" aws-config = "1" aws-sdk-s3 = "1" json is an opt-in reqwest feature, so it has to be listed. In reqwest 0.13, query also became a separate feature that is off by default, so this guide builds query strings by hand rather than calling .query().
The listings assume four modules under src/htpbe/:
// src/htpbe/mod.rs pub mod client; pub mod error; pub mod routing; pub mod types; src/main.rs declares mod htpbe; (Step 5). Add mod upload; if you use the Step 6 helper.
Step 1: Test the API with curl
Before any Rust, confirm the key and the flow with curl, using your test key and a fixed test URL (test keys accept only those and never consume credits).
Submit the file:
curl -i -X POST https://api.htpbe.tech/v1/analyze \ -H "Authorization: Bearer htpbe_test_YOUR_TEST_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 7c2f6b6e-1a3d-4b8a-9c3a-2e6f1d9b4a10" \ -d '{"url": "https://api.htpbe.tech/v1/test/signature-removed.pdf"}' The response is 201 Created with a Location header pointing at the result URL and a body that contains only the id:
{ "id": "00000000-0000-4000-8000-00000000000c" } Read the result:
curl https://api.htpbe.tech/v1/result/00000000-0000-4000-8000-00000000000c \ -H "Authorization: Bearer htpbe_test_YOUR_TEST_KEY" The response is one flat object. Trimmed to the fields this guide uses, it looks like this:
{ "id": "00000000-0000-4000-8000-00000000000c", "filename": "signature-removed.pdf", "check_date": null, "status": "modified", "modification_confidence": "certain", "modification_markers": ["HTPBE_SIGNATURE_REMOVED"], "signature_removed": true, "modifications_after_signature": false, "usage_caution": { "safe_for_automated_adverse_decision": false, "recommended_action": "route_to_human_review", "message": "Structural evidence of post-creation modification was found. Route this document to a human reviewer before any decision — do not use this verdict as the sole basis for an automatic adverse decision against a person." } } check_date is null for test results. Swap the URL for clean.pdf, dates-mismatch.pdf, inconclusive.pdf or scanned-document.pdf to see the other shapes.
Step 2: Result types with serde
serde skips JSON fields your struct doesn’t declare (so don’t add deny_unknown_fields): the struct can be partial, and a field added to the API later can’t break decoding. Each enum has a #[serde(other)] catch-all, so a value added after you ship decodes as Unknown, and Step 5 sends Unknown to a person.
// src/htpbe/types.rs use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Status { Intact, Modified, Inconclusive, #[serde(other)] Unknown, } /// Present only when `status` is `inconclusive`. It explains why the /// integrity check could not reach `intact` or `modified`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum StatusReason { ConsumerSoftwareOrigin, OnlineEditorOrigin, ScannedDocument, UnverifiableMetadata, FilledFormOrigin, FillSignOrigin, VisibleSignatureOrigin, /// Describes the check, not the document: part of the analysis did not finish. AnalysisIncomplete, #[serde(other)] Unknown, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Confidence { Certain, High, /// The wire value is "none". Renamed so it doesn't read like `Option::None`. #[serde(rename = "none")] NoneDetected, #[serde(other)] Unknown, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RecommendedAction { RouteToHumanReview, RequestFromIssuer, NoAction, #[serde(other)] Unknown, } #[derive(Debug, Clone, Deserialize)] pub struct UsageCaution { /// Always false. The verdict alone must never drive an adverse decision. pub safe_for_automated_adverse_decision: bool, pub recommended_action: RecommendedAction, pub message: String, } /// The flat GET /result/{id} response. Partial on purpose: serde skips /// any field not declared here, so new API fields never break decoding. #[derive(Debug, Clone, Deserialize)] pub struct CheckResult { pub id: String, pub filename: String, /// Unix seconds. Null for test-key results and legacy records. pub check_date: Option<i64>, pub file_size: u64, pub algorithm_version: String, pub current_algorithm_version: String, /// Present only when the check ran on an older algorithm version. pub outdated_warning: Option<String>, pub status: Status, pub status_reason: Option<StatusReason>, /// Null on legacy records. pub modification_confidence: Option<Confidence>, /// Public HTPBE_* ids, strongest first. pub modification_markers: Vec<String>, pub has_digital_signature: bool, pub signature_removed: bool, pub modifications_after_signature: bool, pub page_count: u32, pub usage_caution: UsageCaution, } Markers stay Vec<String>. The HTPBE_* ids are append-only, so an enum would break on the first new one. Branch on the ids you care about and render labels from the dictionary on how PDF tamper detection works; the first marker is the strongest.
outdated_warning is worth logging: it means the stored check ran on an older algorithm version.
Step 3: A typed error enum
The retry loop needs to know whether the same request could succeed if sent again; the handler needs to know what to tell the caller. One enum serves both if it keeps the HTTP status, the code and Retry-After. The API’s error body is {"error": "...", "code": "...", "details": "..."}, but a proxy in front of it can answer with HTML, so parsing is best effort.
// src/htpbe/error.rs use std::time::Duration; #[derive(Debug, thiserror::Error)] pub enum HtpbeError { /// A non-2xx response from the API (or from something in front of it). #[error("HTPBE API returned {status} ({code}): {message}")] Api { status: u16, /// Machine-readable code, e.g. "payment_required". "unknown" when /// the body was not the API's JSON error shape. code: String, message: String, details: Option<String>, /// Parsed from Retry-After (seconds) when the response carried it. retry_after: Option<Duration>, }, /// Connect failure, timeout, reset connection, unreadable body. #[error("transport error: {0}")] Transport(#[from] reqwest::Error), /// A 2xx response whose body did not match the documented shape. #[error("unexpected response body: {0}")] Decode(#[from] serde_json::Error), #[error("configuration error: {0}")] Config(&'static str), } impl HtpbeError { /// Whether sending the SAME request again (same Idempotency-Key) could succeed. pub fn is_retryable(&self) -> bool { match self { // 409 idempotency_key_in_progress: the original is still running. // 429 server_at_capacity: server-wide concurrency, not your quota. // 500/502/504: transient on the server side or in front of it. // 503 is NOT retried here: it surfaces to the caller (CheckPending). HtpbeError::Api { status, .. } => matches!(*status, 409 | 429 | 500 | 502 | 504), // Timeouts and dropped connections are exactly what the // Idempotency-Key exists for. A builder error is a bug, not a blip. HtpbeError::Transport(e) => !e.is_builder(), // A malformed 2xx body will be malformed again. HtpbeError::Decode(_) | HtpbeError::Config(_) => false, } } pub fn code(&self) -> Option<&str> { match self { HtpbeError::Api { code, .. } => Some(code.as_str()), _ => None, } } pub fn retry_after(&self) -> Option<Duration> { match self { HtpbeError::Api { retry_after, .. } => *retry_after, _ => None, } } /// 409 idempotency_key_in_progress: the original request with this key /// is still running. The retry loop waits on it separately. pub fn is_in_progress(&self) -> bool { matches!(self, HtpbeError::Api { status: 409, .. }) } /// No credit source left on the account. Not retryable: it fails the /// same way until credits are added, so send it to whoever owns billing. pub fn is_out_of_credits(&self) -> bool { self.code() == Some("payment_required") } } code stays a String for the same reason as the markers. The codes worth knowing:
| Status | code | Retry? | What it means for you |
|---|---|---|---|
| 400 | invalid_request, invalid_url_format, download_failed | No | Bad body or URL, or the server could not fetch the file |
| 401 | missing_api_key, invalid_api_key | No | Configuration problem; alert, don’t loop |
| 402 | payment_required | No | No credits left on the account |
| 403 | inactive_client, test_url_required, account_restricted | No | Key deactivated, test key with a real URL, or account restriction |
| 404 | not_found | No | Unknown id, or an id owned by another account |
| 409 | idempotency_key_in_progress | Yes | The original request with this key is still running |
| 413 | file_too_large | No | Over the 10 MB limit |
| 422 | invalid_pdf, idempotency_key_fingerprint_mismatch | No | Unreadable PDF, or one key reused for two different URLs |
| 429 | server_at_capacity | Yes | Server-wide concurrency limit; honour Retry-After |
| 500 | internal_error | Yes | Server-side error; retry with the same key |
| 503 | internal_error | Later | No result stored, no Retry-After; contact support if it repeats |
The client retries 500 (and a 502 or 504 from a proxy) within its attempt cap. A 503 is left to the caller: the handler in Step 5 records it as check pending, to be retried once later.
Step 4: The client
Build one reqwest::Client at startup and share it: it pools connections and is cheap to clone.
Three design points drive the rest of the code.
Timeouts sized to the server’s limits. The server allows 30 seconds to download your file and 20 to analyse it before the POST returns, so the client allows 90 seconds per request, plus a short connect timeout. Anything tighter cuts off slow but valid checks and triggers pointless retries.
One Idempotency-Key per logical request, owned by the caller. check_pdf takes the key as an argument and resends it on every retry, so a retry of a completed request returns the same id with no second charge. The caller owns it because the logical request can outlive one call: a pending case retried later, while its document URL is still valid, must send the same key and the same URL, or it risks spending credit twice on one document.
POST and GET are retried separately. Once you have an id, a failed read is retried by reading again, never by re-submitting.
// src/htpbe/client.rs use std::future::Future; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use reqwest::header::RETRY_AFTER; use reqwest::Response; use serde::{Deserialize, Serialize}; use super::error::HtpbeError; use super::types::CheckResult; const DEFAULT_BASE_URL: &str = "https://api.htpbe.tech/v1"; const MAX_RETRY_AFTER_SECS: u64 = 60; /// How long to keep waiting on a 409 (original request still running). const MAX_IN_PROGRESS_WAIT: Duration = Duration::from_secs(90); #[derive(Clone)] pub struct HtpbeClient { http: reqwest::Client, base_url: String, api_key: String, max_attempts: u32, } #[derive(Serialize)] struct AnalyzeRequest<'a> { url: &'a str, #[serde(skip_serializing_if = "Option::is_none")] original_filename: Option<&'a str>, } #[derive(Deserialize)] struct AnalyzeResponse { id: String, } #[derive(Deserialize)] struct ErrorBody { error: String, code: String, details: Option<String>, } impl HtpbeClient { pub fn new(api_key: impl Into<String>) -> Result<Self, HtpbeError> { let api_key = api_key.into(); if api_key.is_empty() { return Err(HtpbeError::Config("HTPBE API key is empty")); } let http = reqwest::Client::builder() .connect_timeout(Duration::from_secs(10)) // Above the server's 30 s download + 20 s analysis limits, // so a slow but valid check is never cut off client-side. .timeout(Duration::from_secs(90)) .build()?; Ok(Self { http, base_url: DEFAULT_BASE_URL.to_owned(), api_key, max_attempts: 4, }) } /// Point the client at a mock server in tests. #[allow(dead_code)] pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self { let base_url: String = base_url.into(); self.base_url = base_url.trim_end_matches('/').to_owned(); self } /// One logical check: submit (retried under the caller's key), then read. /// Persist `idempotency_key` with the case and reuse it if you retry later. pub async fn check_pdf( &self, pdf_url: &str, original_filename: Option<&str>, idempotency_key: &str, ) -> Result<CheckResult, HtpbeError> { let id = self.analyze(pdf_url, original_filename, idempotency_key).await?; self.result(&id).await } /// POST /analyze. Pass the same `idempotency_key` on every retry of /// one logical request, and a new key for every new document. pub async fn analyze( &self, pdf_url: &str, original_filename: Option<&str>, idempotency_key: &str, ) -> Result<String, HtpbeError> { let body = AnalyzeRequest { url: pdf_url, original_filename }; let body = &body; self.with_retry(|| async move { let resp = self .http .post(format!("{}/analyze", self.base_url)) .bearer_auth(&self.api_key) .header("Idempotency-Key", idempotency_key) .json(body) .send() .await?; let resp = check_status(resp).await?; let bytes = resp.bytes().await?; let parsed: AnalyzeResponse = serde_json::from_slice(&bytes)?; Ok(parsed.id) }) .await } /// GET /result/{id}. A plain read, so retrying it is always safe. pub async fn result(&self, id: &str) -> <...
