PDF Security Blog

PDF Tamper Detection API for Java: Spring Boot Integration Guide

HTPBE Team··22 min read
PDF Tamper Detection API for Java: Spring Boot Integration Guide

This article is a snapshot — content was accurate as of July 2026 (code examples tested against the API as of June 2026). The product evolves actively; specific counts, examples, and detection rules may have changed since publication — see the changelog for the current state.

PDF fraud is a backend problem, and in enterprise fintech, lending, banking, and insurance the backend runs on the JVM. A Spring Boot service ingests an uploaded bank statement, a payslip, or a claim packet, writes a row, and hands the document to underwriting — and by the time your @RestController has returned 201, the document’s claims have already propagated into your business logic. Your KYC provider verified that the applicant is a real person with a valid identity. It said nothing about whether the PDF they uploaded was edited after the bank generated it. That structural-tampering layer is invisible to identity verification, and the right place to catch it is at ingress: before your service trusts the file, not after.

This guide walks through integrating the PDF tamper detection API into a Spring Boot application — from the first curl command to an idiomatic @Service built on Spring’s RestClient, with a typed record DTO, @ConfigurationProperties for the API key, error handling that distinguishes a configuration failure from a transient one, and a small bank-statement gate that decides accept / reject / review. The patterns target Spring Boot 3.2+ / Spring Framework 6.1+ (where RestClient is stable); a WebClient variant is included for reactive stacks. Treat the code as a reference architecture — it runs the real request flow against the documented error codes, but you should adapt and harden it for your own traffic profile and threat model. (If you want the conceptual overview first, start with How to Detect PDF Tampering Programmatically. Integrating from another stack? See the Go, Node.js, Python, and Laravel / PHP guides.)

TL;DR

  • Two API calls, three verdicts: POST /analyze returns a check id, GET /result/{id} returns the flat verdict object whose status is one of intact, modified, or inconclusive.
  • The minimum integration is a RestClient and two method calls. No extra dependency beyond spring-boot-starter-web.
  • Production shape: a typed record DTO, @ConfigurationProperties for the key, a custom ResponseErrorHandler that maps status codes to a typed exception, and Spring Retry that backs off on 5xx and 429 only.
  • A DocumentGate that maps the three verdicts to an ACCEPT / REJECT / REVIEW decision for documents that claim institutional origin.
  • This is structural PDF tamper and forgery detection — not KYC, not OCR, not AI-text detection. It complements an identity stack; it does not replace one.

Prerequisites

  • Java 17+ (records, sealed types, switch expressions) — Java 21 if you want virtual threads for batch fan-out
  • Spring Boot 3.2+ (RestClient is stable from 3.2; the WebClient path works on any 3.x)
  • spring-boot-starter-web on the classpath
  • An HTPBE? API key (Dashboard → copy key)

Step 1: Test the API with curl

Before writing any Java, confirm your key works. The API uses a two-step flow: POST /analyze submits a PDF URL and returns a check id, then GET /result/{id} retrieves the full verdict. (For a language-agnostic overview of what the API detects, see how PDF tamper detection works.)

Step 1a — submit for analysis:

curl -X POST https://api.htpbe.tech/v1/analyze \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://api.htpbe.tech/v1/test/clean.pdf"}'

You will receive: {"id": "00000000-0000-4000-8000-000000000001"}

Step 1b — retrieve the result:

curl https://api.htpbe.tech/v1/result/YOUR_CHECK_ID \
  -H "Authorization: Bearer YOUR_API_KEY"

You will receive a flat JSON object with "status": "intact" and the full set of analysis fields:

{
  "id": "00000000-0000-4000-8000-000000000001",
  "status": "intact",
  "origin": { "type": "institutional", "software": null },
  "creator": "Adobe Acrobat Pro DC",
  "producer": "Adobe PDF Library 15.0",
  "modification_confidence": "none",
  "has_incremental_updates": false,
  "update_chain_length": 1,
  "signature_removed": false,
  "modifications_after_signature": false,
  "modification_markers": []
}

The same shape comes back for modified and inconclusive verdicts — only the values change. Two fields are conditional: status_reason appears only when status is inconclusive, and outdated_warning only when the check ran against an older algorithm version.

The URL https://api.htpbe.tech/v1/test/clean.pdf is a test mock — it returns a predictable response without consuming quota. Test keys (prefix htpbe_test_) accept only these mock URLs; live keys (prefix htpbe_live_) accept any public PDF URL.

Step 2: Configuration Properties

Keep the key and base URL out of code. Bind them with @ConfigurationProperties so they are typed, validated at startup, and overridable per environment.

package com.example.htpbe;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
import jakarta.validation.constraints.NotBlank;
import java.time.Duration;

@Validated
@ConfigurationProperties(prefix = "htpbe")
public record HtpbeProperties(
        @NotBlank String apiKey,
        String baseUrl,
        Duration timeout,
        int maxRetries) {

    public HtpbeProperties {
        // Defaults applied to the compact constructor so a partial
        // configuration block still produces a usable instance.
        if (baseUrl == null || baseUrl.isBlank()) {
            baseUrl = "https://api.htpbe.tech/v1";
        }
        if (timeout == null) {
            timeout = Duration.ofSeconds(35);
        }
        if (maxRetries <= 0) {
            maxRetries = 3;
        }
        // Tolerate a trailing slash so either form of the base URL works.
        while (baseUrl.endsWith("/")) {
            baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
        }
    }
}

Enable binding and validation on your configuration class:

@Configuration
@EnableConfigurationProperties(HtpbeProperties.class)
class HtpbeConfig { }

And supply the values in application.yml — the key resolves from an environment variable so it never lands in source control:

htpbe:
  api-key: ${HTPBE_API_KEY}
  base-url: https://api.htpbe.tech/v1
  timeout: 35s
  max-retries: 3

The @NotBlank on apiKey means the application context fails to start if HTPBE_API_KEY is unset — a misconfigured deployment is caught at boot, not on the first document that arrives at 2 a.m.

Step 3: The Result DTO

Model the GET /result/{id} response as a Java record. Jackson maps snake_case JSON to the record components when you enable @JsonNaming(SnakeCaseStrategy.class) (or set spring.jackson.property-naming-strategy: SNAKE_CASE globally). Configure your ObjectMapper to ignore unknown properties so a newly added API field never breaks deserialization. Nullable fields are reference types so “absent” stays distinguishable from a genuine zero.

package com.example.htpbe;

import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import java.util.List;

@JsonNaming(SnakeCaseStrategy.class)
public record AnalysisResult(
        String id,
        String filename,
        Long fileSize,
        Integer pageCount,

        String algorithmVersion,
        String currentAlgorithmVersion,
        String outdatedWarning,        // present only on an outdated check

        // Primary verdict: "intact" | "modified" | "inconclusive"
        String status,
        // Present only when status == "inconclusive":
        // "consumer_software_origin" | "online_editor_origin" |
        // "scanned_document" | "html_renderer_origin"
        String statusReason,

        Origin origin,

        // "certain" | "high" | "none" | null
        String modificationConfidence,

        String creator,
        String producer,
        Long creationDate,             // Unix seconds
        Long modificationDate,         // Unix seconds
        String pdfVersion,

        Boolean dateSequenceValid,
        Integer metadataCompletenessScore,

        Integer xrefCount,
        Boolean hasIncrementalUpdates,
        Integer updateChainLength,

        Boolean hasDigitalSignature,
        Integer signatureCount,
        Boolean signatureRemoved,
        Boolean modificationsAfterSignature,

        Integer objectCount,
        Boolean hasJavascript,
        Boolean hasEmbeddedFiles,

        // Stable HTPBE_* marker ids, e.g. ["HTPBE_SIGNATURE_REMOVED"].
        // Empty when status is "intact" or "inconclusive".
        List<String> modificationMarkers) {

    @JsonNaming(SnakeCaseStrategy.class)
    public record Origin(
            // "consumer_software" | "institutional" | "unknown" |
            // "online_editor" | "scanned"
            String type,
            String software) {
    }

    public boolean isIntact() {
        return "intact".equals(status);
    }

    public boolean isModified() {
        return "modified".equals(status);
    }

    public boolean isInconclusive() {
        return "inconclusive".equals(status);
    }
}

Two fields deserve a closer look. statusReason is populated only when status is inconclusive, and it carries one of four values — consumer_software_origin, online_editor_origin, scanned_document, or html_renderer_origin. The difference matters: a scanned document is benign for a user-submitted handwritten form, but an html_renderer_origin on something that claims to be a payslip is the kind of origin you would not expect from a real payroll system — a strong signal to route for review. Branch on the specific reason, not just on the top-level inconclusive.

modificationMarkers returns stable, machine-readable ids prefixed HTPBE_ — for example HTPBE_SIGNATURE_REMOVED, HTPBE_DATES_DISAGREE, HTPBE_POST_SIGNATURE_EDIT. Branch your integration logic on the id; render the human-readable label from the dictionary published on htpbe.tech/how. These ids are part of the public contract and never change once shipped. The API does not return a numeric risk score — the verdict plus the named markers are the whole signal, by design, so there is no threshold to tune on your side.

A short enum keeps the rest of your codebase from comparing against bare string literals:

public enum Verdict {
    INTACT, MODIFIED, INCONCLUSIVE;

    public static Verdict of(String status) {
        return switch (status) {
            case "intact" -> INTACT;
            case "modified" -> MODIFIED;
            case "inconclusive" -> INCONCLUSIVE;
            default -> throw new IllegalArgumentException("unknown status: " + status);
        };
    }
}

Step 4: A Typed Exception

A 401 means your key is wrong; a 402 means the credit pool is dry; a 500 is transient. Both the retry layer and your business logic need to branch on the status code, so wrap every non-2xx response in a typed exception that carries it.

package com.example.htpbe;

public class HtpbeApiException extends RuntimeException {

    private final int statusCode;
    private final String code;          // machine-readable code from the JSON body
    private final Integer retryAfterSeconds; // parsed from Retry-After on 429; null if absent

    public HtpbeApiException(int statusCode, String code, String message,
                             Integer retryAfterSeconds) {
        super("htpbe: %d %s: %s".formatted(statusCode, code, message));
        this.statusCode = statusCode;
        this.code = code;
        this.retryAfterSeconds = retryAfterSeconds;
    }

    public int statusCode() { return statusCode; }
    public String code() { return code; }
    public Integer retryAfterSeconds() { return retryAfterSeconds; }

    /**
     * Only 5xx and 429 are transient. Every other 4xx is permanent —
     * retrying it burns latency and, for 402, can never succeed until
     * the account is topped up.
     */
    public boolean retryable() {
        return statusCode >= 500 || statusCode == 429;
    }
}

Step 5: The RestClient Service

Here is the complete @Service on RestClient. A custom ResponseErrorHandler converts every non-2xx response into an HtpbeApiException, parsing the JSON error body and the Retry-After header in one place.

package com.example.htpbe;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.stereotype.Service;
import org.springframework.web.client.ResponseErrorHandler;
import org.springframework.web.client.RestClient;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.Instant;
import java.util.List;
import java.util.Map;

@Service
public class HtpbeClient {

    private final RestClient restClient;
    private final ObjectMapper objectMapper;

    public HtpbeClient(HtpbeProperties props,
                       RestClient.Builder builder,
                       ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
        this.restClient = builder
                .baseUrl(props.baseUrl())
                .defaultHeader("Authorization", "Bearer " + props.apiKey())
                .defaultHeader("Accept", "application/json")
                .defaultStatusHandler(new HtpbeErrorHandler(objectMapper))
                .build();
    }

    /**
     * Submits a PDF URL and returns the full verdict. The two steps are kept
     * separate on purpose: POST /analyze is the billable, job-creating call,
     * GET /result/{id} is a free read. Retry policy (Step 6) wraps each step
     * independently so a transient read failure never re-submits — and never
     * re-bills — a fresh analysis.
     *
     * @param originalFilename optional; pass it so the result's `filename`
     *                         shows a human-readable name, not a storage key.
     */
    public AnalysisResult verify(String pdfUrl, String originalFilename) {
        String id = submitAnalysis(pdfUrl, originalFilename);
        return getResult(id);
    }

    String submitAnalysis(String pdfUrl, String originalFilename) {
        var body = (originalFilename == null)
                ? Map.of("url", pdfUrl)
                : Map.of("url", pdfUrl, "original_filename", originalFilename);

        JsonNode node = restClient.post()
                .uri("/analyze")
                .body(body)
                .retrieve()
                .body(JsonNode.class);

        if (node == null || node.get("id") == null) {
            throw new HtpbeApiException(502, "BAD_RESPONSE",
                    "analyze response missing id", null);
        }
        return node.get("id").asText();
    }

    AnalysisResult getResult(String id) {
        return restClient.get()
                .uri("/result/{id}", id)
                .retrieve()
                .body(AnalysisResult.class);
    }

    /** Maps every non-2xx response to a typed HtpbeApiException. */
    static final class HtpbeErrorHandler implements ResponseErrorHandler {

        private final ObjectMapper mapper;

        HtpbeErrorHandler(ObjectMapper mapper) {
            this.mapper = mapper;
        }

        @Override
        public boolean hasError(ClientHttpResponse response) throws IOException {
            return response.getStatusCode().isError();
        }

        @Override
        public void handleError(ClientHttpResponse response) throws IOException {
            HttpStatusCode status = response.getStatusCode();
            String raw = new String(response.getBody().readAllBytes(), StandardCharsets.UTF_8);

            String code = "UNKNOWN";
            String message = status.toString();
            try {
                JsonNode body = mapper.readTree(raw);
                if (body.hasNonNull("code")) code = body.get("code").asText();
                if (body.hasNonNull("error")) message = body.get("error").asText();
            } catch (Exception ignore) {
                // body was not JSON — keep the status-derived defaults
            }

            Integer retryAfter = null;
            int sc = status.value();
            switch (sc) {
                case 401 -> message = "invalid API key — check HTPBE_API_KEY";
                case 402 -> message = "no credits available for this key — top up or subscribe";
                case 403 -> message = "test key sent to a live URL, or vice versa";
                case 413 -> message = "PDF exceeds the 10 MB size limit";
                case 422 -> message = "the URL did not return a valid PDF file";
                case 429 -> retryAfter = parseRetryAfter(
                        response.getHeaders().getFirst("Retry-After"));
                default -> { /* keep parsed message */ }
            }
            throw new HtpbeApiException(sc, code, message, retryAfter);
        }

        /**
         * Handles both the delay-seconds form ("30") and the HTTP-date form,
         * clamped to [1, 600]. Returns null when absent or unparseable so the
         * caller can fall back to its own backoff.
         */
        private static Integer parseRetryAfter(String header) {
            if (header == null || header.isBlank()) return null;
            try {
                return clamp(Integer.parseInt(header.trim()));
            } catch (NumberFormatException ignore) {
                // not a plain number — try HTTP-date
            }
            try {
                ZonedDateTime when = ZonedDateTime.parse(
                        header, DateTimeFormatter.RFC_1123_DATE_TIME);
                long secs = when.toEpochSecond() - Instant.now().getEpochSecond();
                return clamp((int) secs);
            } catch (DateTimeParseException ignore) {
                return null;
            }
        }

        private static int clamp(int v) {
            return Math.max(1, Math.min(600, v));
        }
    }
}

Two status codes deserve explicit handling in your own code:

  • 402 (Payment Required) — the key has no credit source left. Credits are universal: a subscription’s monthly quota, a one-time top-up batch, and the welcome credits all draw from one pool. A 402 means all three are exhausted (or there is no active plan on a live key). HtpbeApiException.retryable() returns false for it — surface it to your billing path rather than retrying, because retrying fails identically until the account is topped up at the pricing page.
  • 429 (Too Many Requests) — this is server-wide concurrency, not per-key rate limiting. The response carries a Retry-After header, which the handler parses (both delay-seconds and HTTP-date forms, clamped to [1, 600]) and stashes on the exception. Your retry policy reads that value before falling back to exponential backoff.

Step 6: Retry on Transient Failures Only

The error handler classifies failures; the retry policy acts on that classification. Spring Retry (spring-retry plus @EnableRetry) is the idiomatic fit. Retry only the billable submitAnalysis step and only when the exception is retryable() — never wrap the whole verify in one retry, or a transient getResult failure would replay the POST and bill a second analysis.

package com.example.htpbe;

import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Component;

@Component
public class HtpbeVerificationService {

    private final HtpbeClient client;

    public HtpbeVerificationService(HtpbeClient client) {
        this.client = client;
    }

    public AnalysisResult verify(String pdfUrl, String originalFilename) {
        // submit (billable, retried) → read (free, retried separately)
        String id = submitWithRetry(pdfUrl, originalFilename);
        return readWithRetry(id);
    }

    @Retryable(
            retryFor = HtpbeApiException.class,
            // Only retry when the exception says it is transient.
            // exceptionExpression evaluates retryable() on the thrown instance.
            exceptionExpression = "#{#root instanceof T(com.example.htpbe.HtpbeApiException) "
                    + "&& #root.retryable()}",
            maxAttempts = 4,
            backoff = @Backoff(delay = 1000, multiplier = 2.0, maxDelay = 8000))
    String submitWithRetry(String pdfUrl, String originalFilename) {
        return client.submitAnalysis(pdfUrl, originalFilename);
    }

    @Retryable(
            retryFor = HtpbeApiException.class,
            exceptionExpression = "#{#root instanceof T(com.example.htpbe.HtpbeApiException) "
                    + "&& #root.retryable()}",
            maxAttempts = 4,
            backoff = @Backoff(delay = 1000, multiplier = 2.0, maxDelay = 8000))
    AnalysisResult readWithRetry(String id) {
        return client.getResult(id);
    }
}

Spring Retry’s annotation backoff is fixed at configuration time, so the @Backoff above applies a generic exponential curve. If you want to honour the server’s exact Retry-After on a 429, drop the annotation in favour of a programmatic RetryTemplate whose BackOffPolicy reads HtpbeApiException.retryAfterSeconds() from the last failure — the typed exception already carries the parsed value, clamped to a safe range. For most integrations the annotation form is enough; the server-supplied delay matters most under sustained capacity pressure.

Step 7: The Bank-Statement Gate

The service returns facts. Turning those facts into an ACCEPT / REJECT / REVIEW decision is a policy choice that depends on what the document claims to be. A bank statement, a payslip, or a diploma claims institutional origin, so anything other than intact should stop the automated path. A user-generated form is held to a looser standard.

package com.example.htpbe;

public final class DocumentGate {

    public enum Decision { ACCEPT, REJECT, REVIEW }

    private DocumentGate() { }

    /**
     * Maps a verdict to a decision for documents that claim institutional
     * origin (bank statements, payslips, diplomas). For these, "inconclusive"
     * is treated as strictly as "modified": a document that should have come
     * from a bank's own system but looks like it was built in Word does not
     * get the benefit of the doubt.
     */
    public static Decision forInstitutional(AnalysisResult r) {
        return switch (Verdict.of(r.status())) {
            case MODIFIED -> Decision.REJECT;
            // A bank statement that comes back inconclusive should not be
            // auto-accepted: it typically came from consumer software rather
            // than a bank's own system — a signal to route for review, not
            // proof of tampering. Send it to a human.
            case INCONCLUSIVE -> Decision.REVIEW;
            case INTACT -> Decision.ACCEPT;
        };
    }
}

Wire the service and the gate into a controller that accepts a JSON body with a reachable URL. The handler runs the check before any business logic touches the file:

package com.example.htpbe;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/documents")
public class DocumentController {

    private final HtpbeVerificationService verification;

    public DocumentController(HtpbeVerificationService verification) {
        this.verification = verification;
    }

    public record VerifyRequest(String documentUrl, String originalFilename) { }

    @PostMapping
    public ResponseEntity<?> verify(@RequestBody VerifyRequest req) {
        if (req.documentUrl() == null || req.documentUrl().isBlank()) {
            return ResponseEntity.badRequest()
                    .body(Map.of("error", "document_url is required"));
        }

        AnalysisResult result;
        try {
            result = verification.verify(req.documentUrl(), req.originalFilename());
        } catch (HtpbeApiException e) {
            return mapApiError(e);
        }

        return switch (DocumentGate.forInstitutional(result)) {
            case REJECT -> ResponseEntity.unprocessableEntity().body(Map.of(
                    "decision", "reject",
                    "reason", "document modified after creation",
                    "modification_markers", result.modificationMarkers()));
            case REVIEW -> ResponseEntity.accepted().body(Map.of(
                    "decision", "review",
                    "status_reason", result.statusReason()));
            case ACCEPT -> ResponseEntity.ok(Map.of(
                    "decision", "accept",
                    "check_id", result.id()));
        };
    }

    private ResponseEntity<?> mapApiError(HtpbeApiException e) {
        return switch (e.statusCode()) {
            // Configuration / billing errors — never leak the cause to the caller.
            case 401, 402 -> ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
                    .body(Map.of("error", "verification temporarily unavailable"));
            case 422 -> ResponseEntity.unprocessableEntity()
                    .body(Map.of("error", "the URL did not return a valid PDF"));
            case 413 -> ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
                    .body(Map.of("error", "PDF must be under 10 MB"));
            default -> ResponseEntity.status(HttpStatus.BAD_GATEWAY)
                    .body(Map.of("error", "verification failed"));
        };
    }
}

An inconclusive result should not be auto-accepted — it typically indicates the file came from consumer software, an online editor, an HTML renderer, or a scanner rather than an institutional generator. That is a signal to route for review, not proof of tampering. For a deeper explanation, see what “inconclusive” really means. For documents that claim institutional origin, treat inconclusive with the same caution as modified: do not accept automatically, route to a human reviewer. Inverting that policy — treating inconclusive as a pass — is the single most common integration mistake, because it hands an automatic accept to exactly the consumer-software-built documents a bank statement should never be.

Step 8: The Reactive Variant (WebClient)

If your service is built on Spring WebFlux, swap RestClient for WebClient and return a Mono<AnalysisResult>. The two-step flow becomes a flatMap, and onStatus plays the role the ResponseErrorHandler played above. The retry semantics are the same: retry the submit and the read independently, and only on a retryable() exception.

package com.example.htpbe;

import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;

import java.time.Duration;
import java.util.Map;

@Service
public class ReactiveHtpbeClient {

    private final WebClient webClient;

    public ReactiveHtpbeClient(HtpbeProperties props, WebClient.Builder builder) {
        this.webClient = builder
                .baseUrl(props.baseUrl())
                .defaultHeader("Authorization", "Bearer " + props.apiKey())
                .build();
    }

    public Mono<AnalysisResult> verify(String pdfUrl, String originalFilename) {
        return submit(pdfUrl, originalFilename)
                .retryWhen(transientBackoff())     // billable step
                .flatMap(id -> read(id).retryWhen(transientBackoff())); // free read
    }

    private Mono<String> submit(String pdfUrl, String originalFilename) {
        var body = (originalFilename == null)
                ? Map.of("url", pdfUrl)
                : Map.of("url", pdfUrl, "original_filename", originalFilename);
        return webClient.post()
                .uri("/analyze")
                .bodyValue(body)
                .retrieve()
                .onStatus(s -> s.isError(), resp -> resp.createException()
                        .map(ex -> new HtpbeApiException(
                                resp.statusCode().value(), "ERROR", ex.getMessage(), null)))
                .bodyToMono(Map.class)
                .map(m -> (String) m.get("id"));
    }

    private Mono<AnalysisResult> read(String id) {
        return webClient.get()
                .uri("/result/{id}", id)
                .retrieve()
                .onStatus(s -> s.isError(), resp -> resp.createException()
                        .map(ex -> new HtpbeApiException(
                                resp.statusCode().value(), "ERROR", ex.getMessage(), null)))
                .bodyToMono(AnalysisResult.class);
    }

    private Retry transientBackoff() {
        return Retry.backoff(3, Duration.ofSeconds(1))
                .filter(t -> t instanceof HtpbeApiException e && e.retryable());
    }
}

The reactive path is worth it only if the rest of your stack is reactive. For a conventional Spring MVC service, the blocking RestClient in Step 5 is simpler and easier to reason about — the 2–5 seconds the analysis takes is no worse on a platform thread than any other outbound call, and on Java 21 virtual threads remove even that cost.

Step 9: Giving the API a Reachable URL

The API does not accept file uploads — it downloads the PDF from a URL you supply, so the file must be publicly reachable for the few seconds the analysis takes. The cleanest pattern is a short-lived presigned URL from your object store: you never expose the bucket, the link expires in minutes, and passing originalFilename keeps the audit trail readable instead of showing an opaque storage key.

// Store the upload privately, mint a 5-minute presigned GET URL, verify.
String key = "incoming/" + UUID.randomUUID() + ".pdf";
s3Client.putObject(PutObjectRequest.builder()
        .bucket(bucket).key(key).contentType("application/pdf").build(),
        RequestBody.fromBytes(pdfBytes));

GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder()
        .signatureDuration(Duration.ofMinutes(5))
        .getObjectRequest(b -> b.bucket(bucket).key(key))
        .build();
String presignedUrl = s3Presigner.presignGetObject(presignRequest).url().toString();

AnalysisResult result = verification.verify(presignedUrl, originalFilename);

The same pattern works with Google Cloud Storage (Storage.signUrl), Azure Blob (a SAS token), or Cloudflare R2 (S3-compatible — reuse the AWS SDK with the R2 endpoint). One security note: the API fetches whatever URL you give it, so if a URL ever comes from untrusted input (a user-pasted link, a webhook payload), validate that it resolves to a public host first — reject localhost, 169.254.169.254 (cloud metadata), and the RFC 1918 private ranges to close the SSRF surface. When you mint the URL yourself from a private bucket the risk is minimal, but the validation belongs in the request flow either way.

Step 10: Testing Without Burning Quota

Every plan includes a test API key (prefix htpbe_test_) that accepts only mock URLs of the form https://api.htpbe.tech/v1/test/{filename}.pdf and returns deterministic responses — like Stripe test cards, with no quota cost. Point an integration test at these fixtures to cover every branch of the gate:

@SpringBootTest
@TestPropertySource(properties = "htpbe.api-key=${HTPBE_TEST_API_KEY}")
class HtpbeClientIntegrationTest {

    @Autowired
    HtpbeVerificationService verification;

    @Test
    void cleanDocumentReturnsIntact() {
        AnalysisResult r = verification.verify(
                "https://api.htpbe.tech/v1/test/clean.pdf", null);
        assertThat(r.status()).isEqualTo("intact");
        assertThat(r.modificationMarkers()).isEmpty();
        assertThat(DocumentGate.forInstitutional(r))
                .isEqualTo(DocumentGate.Decision.ACCEPT);
    }

    @Test
    void signatureRemovedIsRejected() {
        AnalysisResult r = verification.verify(
                "https://api.htpbe.tech/v1/test/signature-removed.pdf", null);
        assertThat(r.status()).isEqualTo("modified");
        assertThat(r.signatureRemoved()).isTrue();
        assertThat(DocumentGate.forInstitutional(r))
                .isEqualTo(DocumentGate.Decision.REJECT);
    }

    @Test
    void inconclusiveIsRoutedToReview() {
        AnalysisResult r = verification.verify(
                "https://api.htpbe.tech/v1/test/inconclusive.pdf", null);
        assertThat(r.status()).isEqualTo("inconclusive");
        assertThat(r.statusReason()).isNotNull();
        assertThat(DocumentGate.forInstitutional(r))
                .isEqualTo(DocumentGate.Decision.REVIEW);
    }
}

Useful fixtures: clean.pdfintact, signature-removed.pdfmodified, dates-mismatch.pdfmodified, and inconclusive.pdfinconclusive. For pure unit tests of the controller and gate without any network, stub the HtpbeVerificationService with Mockito and return a canned AnalysisResult. Keep test and live keys in separate property sources and never commit either.

For audit dashboards, GET /api/v1/checks returns a paginated list of every result for your key — filter by status and limit (/checks?status=modified&limit=50, same Authorization header). When you reach your monthly quota, further requests return 402 PAYMENT_REQUIRED until it resets — add a one-time credit pack or move to a higher tier to keep going, and handle the 402 so a quota boundary never silently drops a check.

What This Does Not Catch

Structural analysis has honest limits, and a Spring service making automated decisions should encode them rather than overstate the verdict:

  • Content fabricated in one pass. If someone opens Word, types a false salary, and exports once, the file was never modified after creation — it is structurally consistent. The fraud happened at authorship, not at the byte level. This is exactly why a payslip from a consumer tool tends to return inconclusive rather than intact: the analysis cannot vouch for a document anyone could have produced from scratch. The verdict is honest about what it can and cannot establish, which is why inconclusive is a routing signal, not a pass.
  • Born-synthetic forgeries. A fake document generated programmatically with a valid-looking account number and a real logo — never derived from a genuine original — has no post-creation edit to detect. Catching that is a content-verification problem (does this account number exist, does this employer match payroll records), a different product category from structural tamper detection.
  • Documents rebuilt from scratch in the original’s software. A determined attacker who recreates a document in the same institutional tool and matches the metadata leaves few structural signals. This is rare and high-effort, but possible.
  • Encrypted or password-protected PDFs. The service cannot parse a file it cannot open; remove the password before submitting.

These limits are why structural tamper detection works as one layer in a fraud-detection stack, not the whole stack. Pair the structural verdict with domain checks — amount validation, account-number lookups, sender authentication, and your KYC or OCR provider — for a layered defence. The structural layer answers a question identity verification cannot: was this file edited after it was issued? See PDF Fraud Prevention Best Practices.

Decisions Before You Ship

The integration surface is intentionally small: one POST, one GET, three verdicts, the typed exception above. The complexity lives on the Spring side, and two choices matter most:

  • Where verification runs. Synchronous inside the request handler gives the caller an immediate decision but blocks for a few seconds; a @Async method or a message-driven consumer returns instantly and defers the verdict. Sync suits low-volume B2B onboarding; async suits high-volume portals. On Java 21, virtual threads make the synchronous path cheap enough that most teams never need the async one.
  • inconclusive routing. For documents that claim institutional origin (bank statements, diplomas, payslips), treat inconclusive with the same caution as modified and route to human review — that is what DocumentGate.forInstitutional encodes. For genuinely user-generated content it may be acceptable as-is, so you may want a second gate with a looser policy.

To start, sign up for HTPBE? — new accounts get five checks to try, then pay-per-check credits or a subscription from $15/mo — copy your test key, and run the curl call from Step 1. The full API reference documents every response field, error code, and the marker dictionary the Spring client branches on.

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-java-spring-boot-integration-guide

Secure your workflow

Create your account — API key on signup, free test environment on every plan.
From $15/mo. No sales call. Cancel any time.