PDF Tamper Detection API for Ruby on Rails: 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.
A large share of fintech still ships on Rails. Stripe, Gusto, GitHub, Shopify, Instacart — the generation of companies that defined modern payments and payroll built their backends on Ruby, and the startups following them keep reaching for the same stack. So when a forged bank statement, an altered payslip, or a doctored invoice lands in an underwriting queue, more often than you would guess it lands on a Rails controller. Your KYC provider already confirmed 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 Document model saves, before the row reaches underwriting, before any downstream system trusts the file.
This guide walks through integrating the PDF tamper detection API into a Ruby on Rails application: from the first curl command to an idiomatic HtpbeClient service object built on Faraday, a Data-class result struct, configuration-bound credentials, a typed error class, an ActiveJob that analyzes an uploaded document and routes on the verdict, and a request spec that stubs the API with WebMock. The patterns target Rails 7.x and Ruby 3.x, but they map cleanly onto Sinatra, Hanami, or a plain Ruby worker. 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 Python, Node.js, Go, Java / Spring Boot, Laravel / PHP, and C# / .NET guides.
TL;DR
- Two API calls, three verdicts:
POST /analyzereturns a top-levelid, thenGET /result/{id}returns the flat verdict object whosestatusis one ofintact,modified, orinconclusive. - The minimum integration is Faraday and two calls. No dependency beyond the gem you already have.
- Production shape: an
HtpbeClientservice object that reads the key from Rails credentials, parses the response into an immutableDataresult, raises a typedHtpbeErrorcarrying the HTTP status, and retries on 5xx and 429 only. - An
ActiveJobthat analyzes an uploaded document off the request thread and maps the verdict to an accept / reject / review decision. - 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
- Ruby 3.x (for
Data.define, pattern matching, and endless methods) and Rails 7.x - An HTPBE? API key (Dashboard → copy key)
- The Faraday gem:
gem "faraday"in yourGemfile, thenbundle install. Faraday is already a transitive dependency of many Rails apps, and it is the de facto standard HTTP client in the Ruby ecosystem. If you would rather not add it, the standard-libraryNet::HTTPworks too — this guide uses Faraday for its cleaner middleware and timeout handling, and keeps to one client throughout.
Step 1: Test the API with curl
Before writing any Ruby, 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 a flat object with a single field: {"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",
"filename": "clean.pdf",
"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. Note that there is no data wrapper and no numeric risk score — the response is the flat object above, and the whole signal is the verdict plus the named markers.
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: Bind the Key to Credentials, Not Constants
Keep the key and base URL out of code. Rails ships an encrypted credentials store for exactly this. Run rails credentials:edit and add a block:
htpbe:
api_key: htpbe_live_your_key_here
base_url: https://api.htpbe.tech/v1
timeout_seconds: 35The encrypted file is safe to commit; the config/master.key that decrypts it is not, and Rails already gitignores it. In containers where mounting the master key is awkward, set RAILS_MASTER_KEY as an environment variable instead. Read the values through a small config object so a missing key fails fast at boot rather than on the first document that arrives at 2 a.m.:
# config/initializers/htpbe.rb
module Htpbe
Config = Data.define(:api_key, :base_url, :timeout_seconds)
def self.config
@config ||= begin
creds = Rails.application.credentials.htpbe || {}
api_key = creds[:api_key] || ENV["HTPBE_API_KEY"]
raise "HTPBE api_key is not configured (credentials.htpbe.api_key or ENV['HTPBE_API_KEY'])" if api_key.blank?
Config.new(
api_key: api_key,
base_url: creds[:base_url] || "https://api.htpbe.tech/v1",
timeout_seconds: creds.fetch(:timeout_seconds, 35),
)
end
end
endReading from ENV as a fallback keeps the same code working in CI and on platforms that prefer environment variables. Either way the key never lands in source control as a bare literal.
Step 3: The Result Struct
Model the GET /result/{id} response as an immutable Data class. Ruby 3.2’s Data.define gives you a frozen value object with keyword initialization — the right shape for a response you parse once and never mutate. Map only the fields you branch on; the API returns more, and ignoring the rest means a newly added field never breaks your parser.
# app/services/htpbe/result.rb
module Htpbe
Result = Data.define(
:id,
:filename,
:status, # "intact" | "modified" | "inconclusive"
:status_reason, # present only when status == "inconclusive"
:origin_type,
:origin_software,
:modification_confidence, # "certain" | "high" | "none" | nil
:creator,
:producer,
:signature_removed,
:modifications_after_signature,
:has_incremental_updates,
:update_chain_length,
:modification_markers, # array of stable HTPBE_* ids
) do
def self.from_response(body)
origin = body["origin"] || {}
new(
id: body.fetch("id"),
filename: body["filename"],
status: body.fetch("status"),
status_reason: body["status_reason"],
origin_type: origin["type"],
origin_software: origin["software"],
modification_confidence: body["modification_confidence"],
creator: body["creator"],
producer: body["producer"],
signature_removed: body.fetch("signature_removed", false),
modifications_after_signature: body.fetch("modifications_after_signature", false),
has_incremental_updates: body.fetch("has_incremental_updates", false),
update_chain_length: body.fetch("update_chain_length", 0),
modification_markers: Array(body["modification_markers"]),
)
end
def intact? = status == "intact"
def modified? = status == "modified"
def inconclusive? = status == "inconclusive"
end
endTwo fields deserve a closer look. status_reason is populated only when status is inconclusive, and it carries one of several values — consumer_software_origin, online_editor_origin, scanned_document, and a few more. The difference matters: a scanned document is benign for a user-submitted handwritten form, but a consumer_software_origin on something that claims to be a payslip is the kind of origin you would not expect from a real payroll system — that class covers both consumer apps and freely available HTML-to-PDF renderers, so it is a strong signal to route for review. Branch on the specific reason, not just on the top-level inconclusive.
modification_markers returns stable, machine-readable ids prefixed HTPBE_ — for example HTPBE_SIGNATURE_REMOVED, HTPBE_DATES_DISAGREE, HTPBE_POST_SIGNATURE_EDIT, and HTPBE_MULTIPLE_REVISION_LAYERS. 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.
Step 4: A Typed Error
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-success response in one error type that carries it.
# app/services/htpbe/error.rb
module Htpbe
class HtpbeError < StandardError
attr_reader :status, :code, :retry_after
def initialize(message, status:, code: "unknown", retry_after: nil)
super(message)
@status = status
@code = code
@retry_after = retry_after
end
# 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.
def retryable? = status >= 500 || status == 429
end
endStep 5: The Service Object
Here is the complete client. It is a plain service object — instantiate it once and reuse it, or resolve it per request. It builds a Faraday connection with the base URL, the Authorization header, and a JSON request/response middleware, exposes one public method verify, and converts every non-success response into an HtpbeError in one place.
# app/services/htpbe/client.rb
require "faraday"
module Htpbe
class Client
MAX_RESULT_POLLS = 5
def initialize(config = Htpbe.config)
@config = config
@conn = Faraday.new(url: config.base_url) do |f|
f.request :json
f.response :json, content_type: /\bjson$/
f.headers["Authorization"] = "Bearer #{config.api_key}"
f.headers["Accept"] = "application/json"
f.options.timeout = config.timeout_seconds
f.options.open_timeout = 10
end
end
# Submits a PDF URL and returns a parsed Result. The two steps are kept
# separate on purpose: POST /analyze is the billable, job-creating call;
# GET /result/{id} is a free read.
def verify(pdf_url:, original_filename: nil)
id = submit_analysis(pdf_url, original_filename)
fetch_result(id)
end
private
def submit_analysis(pdf_url, original_filename)
body = { url: pdf_url }
body[:original_filename] = original_filename if original_filename
response = with_retries { @conn.post("analyze", body) }
raise_for_status(response) unless response.success?
id = response.body["id"]
raise HtpbeError.new("analyze response missing id", status: 502, code: "bad_response") if id.to_s.empty?
id
end
def fetch_result(id)
# POST /analyze runs the analysis synchronously, so the result is normally
# ready on the first GET. The bounded poll below is defensive: it tolerates
# a brief replication lag and re-reads on a transient 404 before giving up.
last_error = nil
MAX_RESULT_POLLS.times do |attempt|
response = with_retries { @conn.get("result/#{id}") }
return Result.from_response(response.body) if response.success?
last_error = error_for(response)
# Only a 404 is worth re-reading (the row may not be visible yet).
# Every other error is terminal — surface it without burning attempts.
raise last_error unless response.status == 404
sleep(0.3 * (attempt + 1))
end
raise last_error || HtpbeError.new("result not ready after polling", status: 504, code: "result_timeout")
end
# Retry transient failures (5xx, 429, connection errors) with backoff.
# Permanent 4xx codes short-circuit immediately — a 402 can never succeed
# until the account is topped up.
def with_retries(max: 3)
attempt = 0
begin
attempt += 1
response = yield
if !response.success? && transient_status?(response.status) && attempt < max
sleep(retry_delay(response, attempt))
raise Faraday::Error, "retrying transient #{response.status}"
end
response
rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
raise HtpbeError.new(e.message, status: 503, code: "network_error") if attempt >= max
sleep(2**(attempt - 1))
retry
rescue Faraday::Error
retry if attempt < max
raise
end
end
def transient_status?(status) = status >= 500 || status == 429
def retry_delay(response, attempt)
retry_after = parse_retry_after(response.headers["retry-after"])
retry_after || 2**(attempt - 1) # 1s, 2s, 4s
end
def raise_for_status(response) = raise(error_for(response))
def error_for(response)
body = response.body.is_a?(Hash) ? response.body : {}
code = body["code"] || "unknown"
retry_after = parse_retry_after(response.headers["retry-after"])
message =
case response.status
when 400 then "Bad request: #{body["error"] || "check the url field"}"
when 401 then "Invalid API key — check credentials.htpbe.api_key"
when 402 then "No credits available for this key — top up or subscribe"
when 403 then "Test key sent to a live URL, or vice versa"
when 413 then "PDF exceeds the 10 MB size limit"
when 422 then "The URL did not return a valid PDF file"
when 429 then "Server is at analysis capacity — honour Retry-After before retrying"
else body["error"] || "HTPBE error #{response.status}"
end
HtpbeError.new(message, status: response.status, code: code, retry_after: retry_after)
end
# RFC 7231 Retry-After: delay-seconds or an HTTP-date. Clamped to [1, 600]
# so a hostile or misconfigured value cannot stall a worker indefinitely.
def parse_retry_after(header)
return nil if header.blank?
seconds =
if header.to_s.match?(/\A\d+\z/)
header.to_i
else
((Time.httpdate(header) - Time.now).round rescue nil)
end
return nil if seconds.nil?
seconds.clamp(1, 600)
end
end
endTwo 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).HtpbeError#retryable?returnsfalsefor 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 aRetry-Afterheader, whichparse_retry_afterreads (both delay-seconds and HTTP-date forms, clamped to[1, 600]) so the retry loop waits the server-suggested interval before falling back to exponential backoff.
Step 6: The Verdict Gate
The client 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. Ruby’s pattern matching makes the mapping readable:
# app/services/htpbe/document_gate.rb
module Htpbe
module 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.
def self.for_institutional(result)
case result.status
when "modified" then :reject
when "inconclusive" then :review
when "intact" then :accept
else :review
end
end
end
endAn 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 7: An ActiveJob, Not a Blocking Controller
Analysis takes 2–5 seconds for a typical document. Blocking a Rails request handler for that long is fine for an internal admin tool, but for any user-facing upload flow you want to return immediately and resolve the verdict on a worker. ActiveJob is the idiomatic place. The controller stores the upload on a private bucket, records a pending Document, and enqueues; the job mints a short-lived URL, calls the API, and routes on the verdict.
# app/jobs/verify_document_job.rb
class VerifyDocumentJob < ApplicationJob
queue_as :verification
# Retry on transient failures with growing delay; ActiveJob handles the backoff.
retry_on Htpbe::HtpbeError, wait: :polynomially_longer, attempts: 5 do |job, error|
# Out of attempts. If it was a billing failure, retrying never helps —
# flag the document for an operator rather than silently dropping it.
job.arguments.first.then do |document_id|
Document.find_by(id: document_id)&.update(verdict: "error", review_reason: error.code)
end
end
def perform(document_id)
document = Document.find(document_id)
# Idempotency: a re-queued job from a worker crash must not double-charge credits.
return if document.verdict.present?
# A 5-minute presigned URL keeps the bucket private; the file is reachable
# only for the few seconds the analysis takes.
pdf_url = document.file.url(expires_in: 5.minutes)
begin
result = Htpbe::Client.new.verify(
pdf_url: pdf_url,
original_filename: document.original_filename,
)
rescue Htpbe::HtpbeError => e
# 402 (no credits) is permanent — stop retrying immediately and alert billing.
raise e if e.retryable?
document.update(verdict: "error", review_reason: e.code)
Rails.logger.warn("HTPBE verification failed for ##{document_id}: #{e.message}")
return
end
apply_verdict(document, result)
end
private
def apply_verdict(document, result)
decision = Htpbe::DocumentGate.for_institutional(result)
document.update(
htpbe_check_id: result.id,
verdict: result.status,
status_reason: result.status_reason,
producer: result.producer,
modification_markers: result.modification_markers,
requires_review: decision == :review,
)
case decision
when :reject
DocumentMailer.flagged(document).deliver_later
when :review
ReviewQueue.enqueue(document)
when :accept
document.approve!
end
end
endEnqueue it from the controller after storing the upload, and return without waiting:
# app/controllers/documents_controller.rb
class DocumentsController < ApplicationController
def create
document = current_user.documents.create!(
file: params.require(:file),
original_filename: params[:file].original_filename,
verdict: nil,
)
VerifyDocumentJob.perform_later(document.id)
render json: { id: document.id, status: "verification_pending" }, status: :accepted
end
endA few production details worth getting right (the broader patterns are covered in the async queue guide):
- Idempotency. The job returns early when
document.verdictis already set, so a re-queue after a worker crash never double-charges credits. - A dedicated queue. Run verification on its own queue (
queue_as :verification) so a backlog of PDFs cannot starve other jobs. - Permanent vs. transient.
retry_onhandles 5xx and 429 with backoff; theraise e if e.retryable?line lets those bubble up, while permanent codes (401, 402, 422) are caught and recorded so the document does not loop forever.
Step 8: 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. With Active Storage on S3, GCS, or Cloudflare R2, document.file.url(expires_in: 5.minutes) mints exactly that: the bucket stays private, the link expires in minutes, and passing original_filename keeps the audit trail readable instead of showing an opaque storage key.
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 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) 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. A presigned URL is also a bearer token — anyone who sees it inside the five-minute window can fetch the PDF, so do not log full URLs and scope the signing policy to read the one object and nothing else.
Step 9: 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. For unit specs you do not even need the network: stub the two endpoints with WebMock and assert that your gate branches correctly. Here is an RSpec job spec covering all three verdicts:
# spec/jobs/verify_document_job_spec.rb
require "rails_helper"
RSpec.describe VerifyDocumentJob, type: :job do
let(:document) { create(:document, verdict: nil, original_filename: "statement.pdf") }
before do
allow_any_instance_of(Document)
.to receive_message_chain(:file, :url).and_return("https://files.example.test/doc.pdf")
end
def stub_flow(status:, markers: [], status_reason: nil, signature_removed: false)
stub_request(:post, "https://api.htpbe.tech/v1/analyze")
.to_return(
status: 201,
headers: { "Content-Type" => "application/json" },
body: { id: "00000000-0000-4000-8000-000000000001" }.to_json,
)
stub_request(:get, "https://api.htpbe.tech/v1/result/00000000-0000-4000-8000-000000000001")
.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: {
id: "00000000-0000-4000-8000-000000000001",
filename: "statement.pdf",
status: status,
status_reason: status_reason,
origin: { type: "institutional", software: nil },
signature_removed: signature_removed,
modification_markers: markers,
}.to_json,
)
end
it "accepts an intact document" do
stub_flow(status: "intact")
described_class.perform_now(document.id)
expect(document.reload.verdict).to eq("intact")
expect(document.requires_review).to be(false)
end
it "rejects a modified document and surfaces the markers" do
stub_flow(status: "modified", markers: ["HTPBE_SIGNATURE_REMOVED"], signature_removed: true)
described_class.perform_now(document.id)
expect(document.reload.verdict).to eq("modified")
expect(document.modification_markers).to include("HTPBE_SIGNATURE_REMOVED")
end
it "routes an inconclusive document to review, not accept" do
stub_flow(status: "inconclusive", status_reason: "consumer_software_origin")
described_class.perform_now(document.id)
expect(document.reload.verdict).to eq("inconclusive")
expect(document.requires_review).to be(true)
end
endFor an end-to-end smoke test against the live mock fixtures, point a request spec at the real test key and the real test URLs. Useful fixtures: clean.pdf → intact, signature-removed.pdf → modified, dates-mismatch.pdf → modified, modified-critical.pdf → modified, and inconclusive.pdf → inconclusive. A direct client spec is the smallest version:
# spec/services/htpbe/client_spec.rb
require "rails_helper"
RSpec.describe Htpbe::Client do
# Requires HTPBE_TEST_API_KEY in the environment — test keys consume no quota.
subject(:client) { described_class.new }
it "returns inconclusive for a consumer-software fixture", :external do
result = client.verify(pdf_url: "https://api.htpbe.tech/v1/test/inconclusive.pdf")
expect(result.status).to eq("inconclusive")
expect(result.status_reason).not_to be_nil
expect(Htpbe::DocumentGate.for_institutional(result)).to eq(:review)
end
it "flags a stripped signature as modified", :external do
result = client.verify(pdf_url: "https://api.htpbe.tech/v1/test/signature-removed.pdf")
expect(result.status).to eq("modified")
expect(result.signature_removed).to be(true)
expect(Htpbe::DocumentGate.for_institutional(result)).to eq(:reject)
end
endKeep test and live keys in separate credentials environments and never commit either. For audit dashboards, GET /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 until it resets — add a one-time credit pack or move to a higher tier, and handle the 402 so a quota boundary never silently drops a check.
What the Verdicts Mean
The whole signal is three verdicts and a list of named markers. Encoding them correctly in your DocumentGate matters more than any other choice in the integration:
intact— no post-creation modification was detected and the origin looks institutional. Safe to accept on the automated path.modified— forensic evidence of an edit after the document was created. Themodification_markersarray names the signal:HTPBE_DATES_DISAGREEfor inconsistent internal timestamps,HTPBE_SIGNATURE_REMOVEDfor a stripped digital signature,HTPBE_POST_SIGNATURE_EDITfor changes made after signing,HTPBE_MULTIPLE_REVISION_LAYERSfor a document saved repeatedly after creation. Reject, or route to fraud review.inconclusive— the document was built with consumer software, an online editor, an HTML renderer, or a scanner, so there is no institutional “original” to verify integrity against. This is not a failure and not a clean pass — it is a routing signal. For a document that should have come from an institution (a bank statement, a payslip),inconclusivemeans it did not, which is exactly whyDocumentGate.for_institutionalroutes it to a human reviewer.
What This Does Not Catch
Structural analysis has honest limits, and a Rails 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
inconclusiverather thanintact: the analysis cannot vouch for a document anyone could have produced from scratch. - 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 defense. 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 error above. The complexity lives on the Rails side, and two choices matter most:
- Where verification runs. Synchronous inside the controller gives the caller an immediate decision but blocks for a few seconds; an
ActiveJobon a dedicated queue returns instantly and defers the verdict. Sync suits low-volume internal tools; async suits any user-facing upload flow, which is why the job is the default above. inconclusiverouting. For documents that claim institutional origin (bank statements, diplomas, payslips), treatinconclusivewith the same caution asmodifiedand route to human review — that is whatDocumentGate.for_institutionalencodes. 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 (see current pricing) — 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 Rails client branches on.