PDF Security Blog

PDF Tamper Detection in Rust: Integration Guide

HTPBE Team··27 min read
PDF Tamper Detection in Rust: Integration Guide

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.

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 /analyze takes a PDF URL and returns 201 with a check id; GET /result/{id} returns a flat JSON object whose status is intact, modified or inconclusive.
  • Analysis is synchronous. When the POST returns, the result is ready to read.
  • Send an Idempotency-Key on every POST and reuse it on every retry of that request. A retry of a completed request returns the same id and is not charged again.
  • Retry 409, 429, 500, 502, 504 and transport failures with the same key. Never retry 400, 401, 402, 403, 404, 413 or 422; a 503 surfaces 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 verdict safe_for_automated_adverse_decision: false.
  • A structural verdict is about the file. intact means 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 with htpbe_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:

StatuscodeRetry?What it means for you
400invalid_request, invalid_url_format, download_failedNoBad body or URL, or the server could not fetch the file
401missing_api_key, invalid_api_keyNoConfiguration problem; alert, don’t loop
402payment_requiredNoNo credits left on the account
403inactive_client, test_url_required, account_restrictedNoKey deactivated, test key with a real URL, or account restriction
404not_foundNoUnknown id, or an id owned by another account
409idempotency_key_in_progressYesThe original request with this key is still running
413file_too_largeNoOver the 10 MB limit
422invalid_pdf, idempotency_key_fingerprint_mismatchNoUnreadable PDF, or one key reused for two different URLs
429server_at_capacityYesServer-wide concurrency limit; honour Retry-After
500internal_errorYesServer-side error; retry with the same key
503internal_errorLaterNo 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) -> Result<CheckResult, HtpbeError> {
        self.with_retry(|| async move {
            let resp = self
                .http
                .get(format!("{}/result/{}", self.base_url, id))
                .bearer_auth(&self.api_key)
                .send()
                .await?;
            let resp = check_status(resp).await?;
            let bytes = resp.bytes().await?;
            Ok(serde_json::from_slice(&bytes)?)
        })
        .await
    }

    async fn with_retry<T, F, Fut>(&self, mut op: F) -> Result<T, HtpbeError>
    where
        F: FnMut() -> Fut,
        Fut: Future<Output = Result<T, HtpbeError>>,
    {
        let mut retries: u32 = 0;
        let mut waited_in_progress = Duration::ZERO;
        loop {
            match op().await {
                Ok(value) => return Ok(value),
                // The original request is still running: wait on it without
                // spending a normal attempt, up to MAX_IN_PROGRESS_WAIT.
                Err(err) if err.is_in_progress() && waited_in_progress < MAX_IN_PROGRESS_WAIT => {
                    let delay = err
                        .retry_after()
                        .unwrap_or(Duration::from_secs(2))
                        .min(MAX_IN_PROGRESS_WAIT - waited_in_progress);
                    waited_in_progress += delay;
                    tokio::time::sleep(delay).await;
                }
                // 409 is excluded here, so the in-progress budget is a hard stop.
                Err(err) if !err.is_in_progress() && err.is_retryable() && retries + 1 < self.max_attempts => {
                    retries += 1;
                    let delay = err.retry_after().unwrap_or_else(|| backoff(retries));
                    tokio::time::sleep(delay).await;
                }
                Err(err) => return Err(err),
            }
        }
    }
}

/// Turns a non-2xx response into HtpbeError::Api. The body is parsed on a
/// best-effort basis: a proxy in front of the API can answer with HTML.
async fn check_status(resp: Response) -> Result<Response, HtpbeError> {
    let status = resp.status();
    if status.is_success() {
        return Ok(resp);
    }

    let retry_after = resp
        .headers()
        .get(RETRY_AFTER)
        .and_then(|v| v.to_str().ok())
        .and_then(|s| s.trim().parse::<u64>().ok())
        .map(|secs| Duration::from_secs(secs.clamp(1, MAX_RETRY_AFTER_SECS)));

    let bytes = resp.bytes().await.unwrap_or_default();
    let (code, message, details) = match serde_json::from_slice::<ErrorBody>(&bytes) {
        Ok(body) => (body.code, body.error, body.details),
        Err(_) => (
            "unknown".to_owned(),
            status.canonical_reason().unwrap_or("unexpected status").to_owned(),
            None,
        ),
    };

    Err(HtpbeError::Api {
        status: status.as_u16(),
        code,
        message,
        details,
        retry_after,
    })
}

/// 1 s, 2 s, 4 s ... capped at 30 s, plus up to one second of jitter.
/// The jitter comes from the clock's sub-second nanos: enough to keep a
/// batch of workers from retrying in lockstep, without an RNG crate.
fn backoff(attempt: u32) -> Duration {
    let base = Duration::from_secs(1u64 << attempt.saturating_sub(1).min(5))
        .min(Duration::from_secs(30));
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.subsec_nanos())
        .unwrap_or(0);
    base + Duration::from_millis(u64::from(nanos % 1_000))
}

Retry-After is read as integer seconds and clamped to 1–60. The API sends it on 429 server_at_capacity and on 409 idempotency_key_in_progress; otherwise the loop uses exponential backoff.

409 has its own budget. A 409 means the original is still running, so the loop keeps waiting on it for up to 90 seconds instead of spending its normal attempts.

Test keys never see 429: capacity limits apply to live keys only. Keep the retry path anyway, so the code behaves the same once a live key goes in.

Four attempts is a starting point. Exhausting them doesn’t mean the document failed; it means you don’t know yet, and the handler in the next step treats it that way.

Step 5: Routing the verdict (axum handler)

The API ships its own recommendation in every result: usage_caution.recommended_action is route_to_human_review for modified, request_from_issuer for inconclusive and no_action for intact. The router follows it, with one special case and one fail-safe rule.

The special case is status_reason: analysis_incomplete, which describes the check rather than the document, so the router asks for a resubmission. A resubmission is a new logical request, so it gets a new Idempotency-Key.

The fail-safe rule: anything the router doesn’t recognise, or any combination that doesn’t add up, goes to a human. Proceed requires both status: intact and recommended_action: no_action.

// src/htpbe/routing.rs
use super::types::{CheckResult, Confidence, RecommendedAction, Status, StatusReason};

/// Where a checked document goes next.
///
/// IMPORTANT: no variant here declines an application, flags a person or
/// takes any adverse action. The API marks every verdict
/// `safe_for_automated_adverse_decision: false`. A structural verdict is a
/// statement about the FILE, never about the person who sent it.
#[derive(Debug)]
pub enum Route {
    /// intact + no_action: no structural modification was detected.
    /// Not a statement that the document is authentic or its contents true.
    Proceed,
    /// modified + route_to_human_review, and every unknown or inconsistent case.
    HumanReview {
        markers: Vec<String>,
        confidence: Option<Confidence>,
    },
    /// inconclusive + request_from_issuer: the limit of what the file's
    /// structure can show. Not a suspicion of fraud.
    RequestFromIssuer { reason: Option<StatusReason> },
    /// status_reason analysis_incomplete: run the check again with a NEW
    /// Idempotency-Key. If it repeats, the caller downgrades to RequestFromIssuer.
    Resubmit,
}

pub fn route_check(r: &CheckResult) -> Route {
    // The incomplete case first: it describes the check, not the document.
    // (An incomplete analysis that still found a modification comes back as
    // `modified` with no status_reason, and the match below routes it.)
    if r.status_reason == Some(StatusReason::AnalysisIncomplete) {
        return Route::Resubmit;
    }

    match (r.usage_caution.recommended_action, r.status) {
        (RecommendedAction::NoAction, Status::Intact) => Route::Proceed,
        (RecommendedAction::RequestFromIssuer, Status::Inconclusive) => Route::RequestFromIssuer {
            reason: r.status_reason,
        },
        // route_to_human_review, plus anything unknown or inconsistent.
        // Fail safe: an unrecognised answer goes to a person, never to Proceed.
        _ => Route::HumanReview {
            markers: r.modification_markers.clone(),
            confidence: r.modification_confidence,
        },
    }
}

The handler receives a case from your own system, with everything it needs stored on the case: a reachable document URL (Step 6), the idempotency key minted when the case was recorded, and the resubmission key if one was ever needed.

The error branch matters as much as the routing. If the API can’t be reached or returns an error, the document has not been checked. The case stays ‘check pending’. It doesn’t proceed, and it doesn’t count against anyone. Because its keys are stored with it, a retry within the URL’s lifetime replays the same logical request; after a failed attempt whose URL has expired, restage first (Step 6).

// src/main.rs
mod htpbe;

use axum::{extract::State, http::StatusCode, routing::post, Json, Router};
use serde::{Deserialize, Serialize};

use htpbe::client::HtpbeClient;
use htpbe::error::HtpbeError;
use htpbe::routing::{route_check, Route};
use htpbe::types::{Confidence, StatusReason};
use uuid::Uuid;

#[derive(Clone)]
struct AppState {
    htpbe: HtpbeClient,
}

#[derive(Deserialize)]
struct SubmitDocument {
    document_url: String,
    original_filename: Option<String>,
    case_id: String,
    /// Minted (UUID v4) when the case was recorded and stored with it, next to
    /// the exact document_url. A pending case is retried with the SAME pair.
    check_key: String,
    /// Set once a first run came back analysis_incomplete. Stored on the case.
    resubmit_key: Option<String>,
}

#[derive(Serialize)]
#[serde(tag = "route", rename_all = "snake_case")]
enum CaseOutcome {
    Proceed { check_id: String },
    HumanReview {
        check_id: String,
        markers: Vec<String>,
        confidence: Option<Confidence>,
    },
    RequestFromIssuer { check_id: String, reason: Option<StatusReason> },
    /// The file itself could not be analysed (too large, unreadable,
    /// password-protected). Ask the submitter for a readable copy.
    FileNotCheckable { error_code: String },
    /// The check did not happen. Not a pass, not a fail: the case waits.
    CheckPending,
}

/// Stand-in for your case store: write the resubmission key to the case
/// record. Replace with a real database write.
async fn save_resubmit_key(case_id: &str, key: &str) -> std::io::Result<()> {
    // Demo only: never log real idempotency keys.
    let _ = (case_id, key);
    Ok(())
}

/// Runs the check and, for analysis_incomplete, exactly one fresh run.
async fn run_check(client: &HtpbeClient, req: &SubmitDocument) -> Result<(String, Route), HtpbeError> {
    let url = req.document_url.as_str();
    let name = req.original_filename.as_deref();

    let resubmit_key = match &req.resubmit_key {
        // A stored resubmit key means the first run already came back
        // incomplete: replay the resubmission, don't repeat the first check.
        Some(key) => key.clone(),
        None => {
            let first = client.check_pdf(url, name, &req.check_key).await?;
            match route_check(&first) {
                Route::Resubmit => {
                    // A resubmission is a new logical request, so it gets a NEW
                    // Idempotency-Key, persisted on the case BEFORE it is sent.
                    // If the write fails, nothing is sent and the case stays pending.
                    let key = Uuid::new_v4().to_string();
                    save_resubmit_key(&req.case_id, &key)
                        .await
                        .map_err(|_| HtpbeError::Config("could not persist the resubmit key"))?;
                    key
                }
                other => return Ok((first.id, other)),
            }
        }
    };

    let second = client.check_pdf(url, name, &resubmit_key).await?;
    let route = match route_check(&second) {
        // Incomplete twice: unexamined, so never clean.
        Route::Resubmit => Route::RequestFromIssuer {
            reason: Some(StatusReason::AnalysisIncomplete),
        },
        other => other,
    };
    Ok((second.id, route))
}

async fn submit_document(
    State(state): State<AppState>,
    Json(req): Json<SubmitDocument>,
) -> (StatusCode, Json<CaseOutcome>) {
    let checked = run_check(&state.htpbe, &req).await;

    let (status, outcome) = match checked {
        Ok((check_id, Route::Proceed)) => (StatusCode::OK, CaseOutcome::Proceed { check_id }),
        Ok((check_id, Route::HumanReview { markers, confidence })) => (
            StatusCode::OK,
            CaseOutcome::HumanReview { check_id, markers, confidence },
        ),
        Ok((check_id, Route::RequestFromIssuer { reason })) => {
            (StatusCode::OK, CaseOutcome::RequestFromIssuer { check_id, reason })
        }
        // run_check never returns Resubmit; if it ever did, the file is unexamined.
        Ok((_, Route::Resubmit)) => (StatusCode::ACCEPTED, CaseOutcome::CheckPending),

        Err(err) => match err.code() {
            Some(code @ ("file_too_large" | "invalid_pdf")) => (
                StatusCode::UNPROCESSABLE_ENTITY,
                CaseOutcome::FileNotCheckable { error_code: code.to_owned() },
            ),
            _ => {
                // Transport failure, retries exhausted, 401/402/403, 503, or a caller
                // bug such as idempotency_key_fingerprint_mismatch. The document
                // was not checked: never Proceed, never adverse.
                if err.is_out_of_credits() {
                    eprintln!("htpbe: no credits left, alert billing");
                } else {
                    eprintln!("htpbe check failed: {err}");
                }
                (StatusCode::ACCEPTED, CaseOutcome::CheckPending)
            }
        },
    };

    (status, Json(outcome))
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let api_key = std::env::var("HTPBE_API_KEY")?;
    let state = AppState { htpbe: HtpbeClient::new(api_key)? };

    let app = Router::new()
        .route("/documents", post(submit_document))
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
    axum::serve(listener, app).await?;
    Ok(())
}

This is axum 0.8: a tokio::net::TcpListener handed to axum::serve, with Json last because it consumes the body. Cloning HtpbeClient only clones a handle to the same pool. When run inline, the worst case (retries, a wait on a 409, one resubmission) can hold a request for several minutes, which is why a queue suits higher volume; see batch PDF checks with a queue.

What each route means for the people reviewing cases

  • Proceed means the file showed no structural sign of being edited after it was created. Your other checks still apply, and it says nothing about whether the figures in the document are true.
  • Human review means the file’s structure shows evidence of post-creation editing, and the markers say what kind. The reviewer decides what that means for this case, typically by asking for the original or contacting the issuer. Results the router doesn’t recognise also land here, often with an empty marker list, so check the list before drawing any conclusion.
  • Request from issuer means the file came from a source where the structure can’t confirm integrity either way, such as an office suite, an online editor or a scan. That is a limit of the evidence, not a suspicion of the applicant. It also covers a file whose analysis came back incomplete twice; in that case the file was not fully examined, which says nothing about its source.
  • Check pending means the check did not run, and the case waits until it does.

For the full reasoning behind each verdict and each status_reason, see handling verdicts in code and what ‘inconclusive’ means.

Step 6: Giving the API a reachable URL

The API downloads the PDF from the URL you send, so the file must be reachable without authentication and no larger than 10 MB. For a private bucket, use a short-lived presigned GET URL, and pass original_filename so the stored result shows a readable name.

// src/upload.rs
use std::time::Duration;

use aws_sdk_s3::presigning::PresigningConfig;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::Client as S3Client;
use uuid::Uuid;

type BoxError = Box<dyn std::error::Error + Send + Sync>;

/// Covers one in-process check plus its retries, NOT the pending-case
/// window: a case retried after the URL expired is restaged instead.
const PRESIGN_TTL: Duration = Duration::from_secs(30 * 60);

/// Persist all three fields with the case. Every check of this case runs
/// from the stored URL and key while the URL is valid.
pub struct StagedCheck {
    pub object_key: String,
    pub document_url: String,
    pub check_key: String,
}

/// Uploads once, signs once, mints the Idempotency-Key once.
pub async fn stage_upload(s3: &S3Client, bucket: &str, pdf: Vec<u8>) -> Result<StagedCheck, BoxError> {
    let object_key = format!("incoming/{}.pdf", Uuid::new_v4());
    s3.put_object()
        .bucket(bucket)
        .key(&object_key)
        .content_type("application/pdf")
        .body(ByteStream::from(pdf))
        .send()
        .await?;
    sign(s3, bucket, object_key).await
}

/// Only for a check whose original attempt FAILED and whose URL has since
/// expired: a new URL is a new logical request, so it gets a new key too.
pub async fn restage(s3: &S3Client, bucket: &str, object_key: String) -> Result<StagedCheck, BoxError> {
    sign(s3, bucket, object_key).await
}

async fn sign(s3: &S3Client, bucket: &str, object_key: String) -> Result<StagedCheck, BoxError> {
    let presigned = s3
        .get_object()
        .bucket(bucket)
        .key(&object_key)
        .presigned(PresigningConfig::expires_in(PRESIGN_TTL)?)
        .await?;
    Ok(StagedCheck {
        object_key,
        document_url: presigned.uri().to_owned(),
        check_key: Uuid::new_v4().to_string(),
    })
}

The check itself runs from the stored pair: htpbe.check_pdf(&staged.document_url, Some(name), &staged.check_key). The Idempotency-Key is tied to the full URL, query string included, so a re-signed URL under the same key gets 422 idempotency_key_fingerprint_mismatch. That’s why the pair is signed once and stored, never rebuilt on retry.

Replaying a request that completed with a verdict returns the original check before the file is downloaded, so an expired URL doesn’t break a replay. If the original attempt failed and the URL has since expired, the retry comes back 400 download_failed: call restage for a new URL and a new key, store the new pair (and clear any stored resubmit key), and check again. That’s safe because a failed attempt is not charged.

The expiry is short on purpose. A presigned URL is a bearer link: anyone holding it can fetch the document until it expires, and a short expiry costs nothing more than a restage when a failed case is retried later. A URL signed with temporary credentials also stops working when those credentials expire.

Build the S3 client once at startup with aws_config::load_defaults(BehaviorVersion::latest()).await and aws_sdk_s3::Client::new(&config). The same code works against Cloudflare R2 or any other S3-compatible store once you set the endpoint URL on the SDK config.

One security note: if a URL ever comes from untrusted input, validate it first and reject localhost, link-local addresses such as 169.254.169.254 (cloud metadata) and the RFC 1918 private ranges. Better still, download the file yourself and send the API your own presigned link.

Batch processing, test mode and credits

Synchronous by design. There’s nothing to poll and no webhook to register. For a backlog, run checks with a modest concurrency bound (a tokio::sync::Semaphore or a small worker pool) and one shared HtpbeClient. In a job queue, mint the key when the job is created and store it with the job:

#[derive(Clone)]
pub struct CheckJob {
    pub pdf_url: String,
    /// Minted at enqueue time and persisted, so a worker that picks the
    /// job up again replays the same logical request.
    pub idempotency_key: String,
}

Workers then call client.check_pdf(&job.pdf_url, None, &job.idempotency_key). The retry loop already handles 429, but a small concurrency bound means you rarely reach it. For queue-based designs, see the event-driven integration guide.

Past checks. GET /checks returns a paginated list of your checks, filterable by status, from_date, to_date, creator, producer and tool, paged with limit (1 to 500) and offset. With reqwest’s query feature off, build the string yourself, for example format!("{base}/checks?status=modified&limit=100&offset=0"), with the same bearer header.

Test mode. Test keys accept only the fixed URLs under https://api.htpbe.tech/v1/test/ (anything else returns 403 test_url_required), return deterministic results and cost nothing. Five of them exercise three of the four branches of route_check: clean.pdf, signature-removed.pdf, dates-mismatch.pdf, inconclusive.pdf and scanned-document.pdf. No test URL produces analysis_incomplete, so cover Resubmit with a mock server via with_base_url. Test keys also ignore Idempotency-Key, so 409, replays and idempotency_key_fingerprint_mismatch only appear with a live key. trigger-402.pdf exercises the out-of-credits path. Test results never appear in GET /checks. The full list is in the testing reference.

Credits. API calls and web uploads share one credit pool. With no credit source left, /analyze returns 402 payment_required until credits are added, so route it to billing rather than retrying. Plans, credit packs and welcome-credit terms are on pricing.

What this does not catch

Structural analysis has limits, and a service making automated routing decisions should encode them rather than discover them.

  • Content that was false from the start. The check finds evidence that a file was changed after it was created. A document whose figures were wrong when it was first produced carries no such evidence, because nothing was changed afterwards. This is also why a file produced by consumer software comes back inconclusive with consumer_software_origin rather than intact: the structure can’t vouch for a file anyone could have produced from scratch.
  • Skilled, deliberate fabrication. A well-resourced forger can sometimes produce a file that shows no structural sign of editing. It’s rare and takes effort, but intact means no modification was detected, not that the document is genuine. Confirmation with the issuer remains the stronger check.
  • Encrypted or password-protected PDFs. The file has to be readable to be analysed; these come back as 422 invalid_pdf.
  • The content layer. The check doesn’t read the document’s text or figures, so it can’t tell you whether an amount, a name or a date printed on the page is correct.

That’s why structural tamper detection is one layer, not the whole stack. Pair the verdict with domain checks: confirmation with the issuer, account or employer lookups, and your KYC or OCR provider where those apply. More in PDF fraud prevention best practices.

Decisions before you ship

The API surface is deliberately small: one POST, one GET, three verdicts and an error contract. The decisions that matter are on your side.

  • What reviewers see. Give reviewers the markers rendered as labels from /how, the status_reason for inconclusive results and the usage_caution.message, so the reasoning travels with the case.
  • Policy per document type. The router above follows the API’s recommendation. If your workflow needs a different path for a given document type, add it as a policy layer on top, and keep the rule that no structural verdict alone triggers an adverse action against a person.

To start, get a key from the API page, run the Step 1 curl with your test key at no cost, then point the client at a real file with a live key. New accounts get 5 welcome credits on signup, then pay-per-check credits or a subscription; see pricing for the terms.

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-rust-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.