PDF Tamper Detection API for Laravel and PHP: Integration Guide

This article is a snapshot — content was accurate as of June 2026 (code examples tested against the API as of May 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 layered problem. KYC verification, process controls, human review, and vendor risk all play roles — but a measurable share of it is a backend problem. The forged bank statement, the altered invoice, the doctored payslip — none of it reaches a human reviewer untouched. By the time your controller renders a response, the document’s claims have already been propagated into your database, your queue, and your business logic. The right place to catch the structural-tampering layer is at ingress: before your Form Request validates, before your Eloquent model saves, before any downstream system trusts the file.
This guide walks through integrating the PDF tamper detection API into a Laravel application: from the first curl command to a Service Provider with typed client, a Form Request validation pipeline, and a queued job for asynchronous workflows. What follows is an end-to-end Laravel integration pattern that compiles, runs the request flow, and handles the documented error codes — adapt and harden it for your own environment, traffic profile, and threat model. (If you want the conceptual overview first, start with How to Detect PDF Tampering Programmatically. If you are integrating from Node.js or Python instead, see the Node.js integration guide and the Python integration guide.)
TL;DR
- Two API calls, three verdicts:
POST /analyzereturns a check id,GET /result/{id}returns the flat verdict object withstatus∈ {intact,modified,inconclusive}. - Minimum integration is vanilla PHP plus Guzzle — ~30 lines of code, no Laravel required.
- Production-grade Laravel client: typed readonly DTO, Service Provider binding, parsed
Retry-After, retry loop that backs off on 5xx/429 only. - Queued-job pattern that survives worker crashes (idempotency via
verdict !== nullshort-circuit) and honours server-suggested release delays. - Security checklist before going live: SSRF guard on URLs, MIME magic-byte check, pre-signed URL hygiene, what to log and what not to log.
How to read this guide
The integration has three layers. Most readers do not need all three on day one.
Fast path (5 minutes) — Step 1 (curl) → Step 2 (vanilla PHP + Guzzle). Enough to call the API from any existing PHP application, get a verdict, and decide whether to integrate further. No Laravel, no DI, no queue.
Production path — the fast path plus Step 3 (typed client and DTO) → Step 6 (queued job with Retry-After-aware release) → Step 7 (error code reference). The right shape for most real Laravel integrations: predictable error handling, async processing, defensive deserialization.
Advanced hardening — the production path plus Step 4 (Service Provider) → Step 5 (Form Request validation) → Security considerations → Step 9 (/checks audit history). For fraud-ops teams with compliance requirements, multi-tenant routing, or a need to reconstruct verdicts months after the fact.
Sequence flow
┌──────────────────────────────────────────────────────────────┐
│ client ──POST /analyze {"url":...} ───────▶ HTPBE API │
│ client ◀── 201 Created {"id": "uuid"} ─── HTPBE API │
│ client ──GET /result/{id} ──────────────▶ HTPBE API │
│ client ◀── 200 {flat verdict object} ─── HTPBE API │
└──────────────────────────────────────────────────────────────┘Two endpoints, one synchronous round trip per document. Everything below is about wiring that round trip into Laravel idioms.
Prerequisites
- PHP 8.1+ (for readonly properties, enums, and
neverreturn types) - Composer
- Laravel 10 or 11 (the patterns below also apply to Lumen and standalone PHP)
- An HTPBE? API key (Dashboard → copy key)
- Guzzle 7 (
composer require guzzlehttp/guzzle) — ships transitively with Laravel’s HTTP client
Step 1: Test the API with curl
Before writing any PHP, 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": "some-uuid-here"}
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 all analysis fields:
{
"id": "00000000-0000-4000-8000-000000000001",
"filename": "clean.pdf",
"check_date": null,
"file_size": 245632,
"algorithm_version": "2.23.2",
"current_algorithm_version": "2.23.2",
"status": "intact",
"origin": {
"type": "institutional",
"software": null
},
"creation_date": 1704110400,
"modification_date": 1704110400,
"creator": "Adobe Acrobat Pro DC",
"producer": "Adobe PDF Library 15.0",
"modification_confidence": "none",
"date_sequence_valid": true,
"metadata_completeness_score": 90,
"xref_count": 1,
"has_incremental_updates": false,
"update_chain_length": 1,
"pdf_version": "1.7",
"has_digital_signature": false,
"signature_count": 0,
"signature_removed": false,
"modifications_after_signature": false,
"page_count": 12,
"object_count": 487,
"has_javascript": false,
"has_embedded_files": false,
"modification_markers": []
}This confirms authentication works and your network can reach the API. The same payload shape comes back for modified and inconclusive verdicts — only field values change. (status_reason is the one exception: it appears only when status === "inconclusive". outdated_warning similarly appears only when the check was run against an older algorithm version.)
The URL https://api.htpbe.tech/v1/test/clean.pdf is a test mock URL — it returns a predictable response without consuming quota. Test keys (prefix htpbe_test_) only accept these mock URLs; live keys (prefix htpbe_live_) accept any public PDF URL.
Step 2: Minimum Working PHP (No Laravel)
Before reaching for a Service Provider, here is the smallest amount of code that gets you a verdict. This is plain PHP with Guzzle — useful in workers, console scripts, or Lumen apps.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
$apiKey = getenv('HTPBE_API_KEY');
$pdfUrl = 'https://api.htpbe.tech/v1/test/clean.pdf';
$client = new Client([
'base_uri' => 'https://api.htpbe.tech/v1/',
'timeout' => 30,
'headers' => [
'Authorization' => 'Bearer ' . $apiKey,
'Accept' => 'application/json',
],
]);
try {
// Step 1: submit the PDF URL, get back a check id
$submit = $client->post('analyze', [
'json' => ['url' => $pdfUrl],
]);
$checkId = json_decode((string) $submit->getBody(), true)['id'];
// Step 2: retrieve the full flat result
$result = $client->get('result/' . $checkId);
$verdict = json_decode((string) $result->getBody(), true);
echo "Status: " . $verdict['status'] . PHP_EOL;
echo "Producer: " . ($verdict['producer'] ?? 'null') . PHP_EOL;
echo "Markers: " . implode(', ', $verdict['modification_markers']) . PHP_EOL;
} catch (RequestException $e) {
fwrite(STDERR, 'HTPBE call failed: ' . $e->getMessage() . PHP_EOL);
exit(1);
}That is the complete contract: two HTTP calls, one JSON body in, one flat JSON object out. Everything that follows is about wrapping this in patterns that survive production load — typed responses, retry logic, dependency injection, validation, and queueing.
Step 3: The Laravel Client Class
Define a small DTO for the response, an exception type, and a HTPBEClient class that handles both steps of the flow internally. The client uses Laravel’s built-in HTTP facade (a thin wrapper over Guzzle) which gives you retry, timeout, and macroable testing out of the box.
app/Services/HTPBE/HTPBEResult.php — a readonly DTO that mirrors the API response:
<?php
declare(strict_types=1);
namespace App\Services\HTPBE;
final readonly class HTPBEResult
{
/**
* @param list<string> $modificationMarkers Stable HTPBE_* ids
*/
public function __construct(
public string $id,
public string $filename,
public ?int $checkDate,
public int $fileSize,
public int $pageCount,
public string $algorithmVersion,
public string $currentAlgorithmVersion,
public ?string $outdatedWarning,
public string $status, // 'intact' | 'modified' | 'inconclusive'
public ?string $statusReason, // only present when status === 'inconclusive'
public string $originType,
public ?string $originSoftware,
public ?string $modificationConfidence,
public ?string $creator,
public ?string $producer,
public ?int $creationDate,
public ?int $modificationDate,
public ?string $pdfVersion,
public bool $dateSequenceValid,
public int $metadataCompletenessScore,
public int $xrefCount,
public bool $hasIncrementalUpdates,
public int $updateChainLength,
public bool $hasDigitalSignature,
public int $signatureCount,
public bool $signatureRemoved,
public bool $modificationsAfterSignature,
public int $objectCount,
public bool $hasJavascript,
public bool $hasEmbeddedFiles,
public array $modificationMarkers,
) {}
/**
* @param array<string, mixed> $payload
*
* @throws \DomainException when a required field is missing.
*/
public static function fromArray(array $payload): self
{
// Required fields — fail loudly with a domain exception rather than
// letting an "undefined array key" warning leak into business logic.
foreach (['id', 'filename', 'file_size', 'page_count', 'algorithm_version', 'status', 'origin'] as $key) {
if (! array_key_exists($key, $payload)) {
throw new \DomainException("HTPBE response is missing required field: {$key}");
}
}
$origin = is_array($payload['origin']) ? $payload['origin'] : [];
return new self(
id: (string) $payload['id'],
filename: (string) $payload['filename'],
checkDate: $payload['check_date'] ?? null,
fileSize: (int) $payload['file_size'],
pageCount: (int) $payload['page_count'],
algorithmVersion: (string) $payload['algorithm_version'],
currentAlgorithmVersion: (string) ($payload['current_algorithm_version'] ?? $payload['algorithm_version']),
outdatedWarning: $payload['outdated_warning'] ?? null,
status: (string) $payload['status'],
statusReason: $payload['status_reason'] ?? null,
originType: (string) ($origin['type'] ?? 'unknown'),
originSoftware: $origin['software'] ?? null,
modificationConfidence: $payload['modification_confidence'] ?? null,
creator: $payload['creator'] ?? null,
producer: $payload['producer'] ?? null,
creationDate: $payload['creation_date'] ?? null,
modificationDate: $payload['modification_date'] ?? null,
pdfVersion: $payload['pdf_version'] ?? null,
dateSequenceValid: (bool) ($payload['date_sequence_valid'] ?? true),
metadataCompletenessScore: (int) ($payload['metadata_completeness_score'] ?? 0),
xrefCount: (int) ($payload['xref_count'] ?? 0),
hasIncrementalUpdates: (bool) ($payload['has_incremental_updates'] ?? false),
updateChainLength: (int) ($payload['update_chain_length'] ?? 0),
hasDigitalSignature: (bool) ($payload['has_digital_signature'] ?? false),
signatureCount: (int) ($payload['signature_count'] ?? 0),
signatureRemoved: (bool) ($payload['signature_removed'] ?? false),
modificationsAfterSignature: (bool) ($payload['modifications_after_signature'] ?? false),
objectCount: (int) ($payload['object_count'] ?? 0),
hasJavascript: (bool) ($payload['has_javascript'] ?? false),
hasEmbeddedFiles: (bool) ($payload['has_embedded_files'] ?? false),
modificationMarkers: is_array($payload['modification_markers'] ?? null) ? $payload['modification_markers'] : [],
);
}
}The API contract is stable and versioned via algorithm_version — new fields are added, never silently renamed or removed — but defensive deserialization with ?? and explicit casts is cheap insurance against schema drift, partial responses, or proxy layers that drop fields. The guard at the top of fromArray throws a DomainException when a genuinely required field is missing, so a malformed payload surfaces as a typed error rather than an undefined array key warning bubbling into your controller. If you do not want to throw, swap the loop for sane defaults and a warning log — the principle is to make schema assumptions explicit.
This is a working example, not a production-grade contract. In a serious codebase you’d back status with a BackedEnum ('intact'|'modified'|'inconclusive'), wrap modificationMarkers in a value object that normalises and validates the HTPBE_* ids, and add schema tests against a fixture payload so a silent contract change fails CI rather than production. The required fields the API always returns are id, filename, file_size, page_count, algorithm_version, status, and origin — everything else is optional and can be null depending on file type and verdict. For example, creation_date is null for files without metadata, signature_count is 0 for unsigned files, and modification_markers is [] for intact verdicts.
Two fields deserve a closer look. statusReason is only populated 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 for something that claims to be a payslip is the same red flag as a modified verdict — that class covers both consumer apps and freely available HTML-to-PDF renderers. 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_MULTIPLE_REVISION_LAYERS, HTPBE_POST_SIGNATURE_EDIT. Branch your integration logic on the id; render the human-readable label from the catalog described earlier. These ids are part of the public contract and never change once shipped.
app/Services/HTPBE/HTPBEException.php:
<?php
declare(strict_types=1);
namespace App\Services\HTPBE;
use RuntimeException;
final class HTPBEException extends RuntimeException
{
public function __construct(
string $message,
public readonly int $statusCode,
public readonly string $errorCode,
/** Parsed Retry-After in seconds, when the server sent one (429/503). */
public readonly ?int $retryAfterSeconds = null,
) {
parent::__construct($message, $statusCode);
}
}app/Services/HTPBE/HTPBEClient.php — the production client:
<?php
declare(strict_types=1);
namespace App\Services\HTPBE;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
final class HTPBEClient
{
private const BASE_URL = 'https://api.htpbe.tech/v1';
public function __construct(
private readonly HttpFactory $http,
private readonly string $apiKey,
private readonly int $timeoutSeconds = 30,
) {
if ($apiKey === '') {
throw new \InvalidArgumentException('HTPBE API key is required.');
}
}
public function verify(string $pdfUrl, ?string $originalFilename = null): HTPBEResult
{
$checkId = $this->submitAnalysis($pdfUrl, $originalFilename);
return $this->getResult($checkId);
}
private function submitAnalysis(string $pdfUrl, ?string $originalFilename): string
{
$body = ['url' => $pdfUrl];
if ($originalFilename !== null) {
$body['original_filename'] = $originalFilename;
}
$response = $this->request()->post(self::BASE_URL . '/analyze', $body);
$this->ensureSuccess($response);
return $response->json('id');
}
private function getResult(string $checkId): HTPBEResult
{
$response = $this->request()->get(self::BASE_URL . '/result/' . $checkId);
$this->ensureSuccess($response);
return HTPBEResult::fromArray($response->json());
}
private function request(): PendingRequest
{
return $this->http
->withToken($this->apiKey)
->acceptJson()
->timeout($this->timeoutSeconds)
// Retry up to 3 times on transient failures only.
// The closure runs after each failed attempt; returning false
// aborts the retry loop early on permanent failures (401/402/422).
->retry(3, 1000, function (\Throwable $e, PendingRequest $request) {
if (! $e instanceof \Illuminate\Http\Client\RequestException) {
return true; // network/timeout — retry
}
$status = $e->response->status();
// Retry only on 5xx and 429; never on 401/402/403/404/413/422.
return $status >= 500 || $status === 429;
}, throw: false);
}
private function ensureSuccess(Response $response): void
{
if ($response->successful()) {
return;
}
$body = $response->json() ?? [];
$errorMessage = $body['error'] ?? $response->reason();
$code = $body['code'] ?? 'UNKNOWN';
$retryAfter = self::parseRetryAfter($response->header('Retry-After'));
throw match ($response->status()) {
400 => new HTPBEException("Bad request: {$errorMessage}", 400, 'BAD_REQUEST'),
401 => new HTPBEException(
'Invalid API key. Check the HTPBE_API_KEY environment variable.',
401,
'UNAUTHORIZED',
),
402 => new HTPBEException(
'Payment required. No credits available for this key.',
402,
'PAYMENT_REQUIRED',
),
403 => new HTPBEException(
'Access forbidden. The key may not have permission for this resource.',
403,
'FORBIDDEN',
),
404 => new HTPBEException('Check not found.', 404, 'NOT_FOUND'),
413 => new HTPBEException(
'PDF exceeds the 10 MB size limit.',
413,
'FILE_TOO_LARGE',
),
422 => new HTPBEException(
'The URL did not return a valid PDF file.',
422,
'INVALID_PDF',
),
429 => new HTPBEException(
'Server is at analysis capacity. Honour the Retry-After header before retrying.',
429,
'SERVER_AT_CAPACITY',
$retryAfter,
),
500, 502, 503, 504 => new HTPBEException(
"HTPBE server error ({$response->status()}).",
$response->status(),
'SERVER_ERROR',
),
default => new HTPBEException(
"Unexpected error {$response->status()}: {$errorMessage}",
$response->status(),
$code,
),
};
}
/**
* Parse RFC 7231 Retry-After (either delay-seconds or HTTP-date) into an
* integer number of seconds. The header may be absent, in which case
* callers fall back to their own default. Result is clamped to a safe
* range so a hostile or misconfigured value cannot stall a worker
* indefinitely.
*/
private static function parseRetryAfter(?string $header): ?int
{
if ($header === null || $header === '') {
return null;
}
// delay-seconds form: "30"
if (ctype_digit(trim($header))) {
return self::clampRetry((int) $header);
}
// HTTP-date form: "Wed, 21 Oct 2026 07:28:00 GMT"
$timestamp = strtotime($header);
if ($timestamp === false) {
return null;
}
return self::clampRetry($timestamp - time());
}
private static function clampRetry(int $seconds): int
{
return max(1, min(600, $seconds));
}
}Two status codes deserve special attention 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 welcome credits all draw from one pool. A 402 means all three are exhausted (or there is no active plan on a live key). Surface this to your billing logic rather than retrying — retrying will fail identically until the account is topped up.429 SERVER_AT_CAPACITY— server-wide concurrency, not per-key rate limiting. The response carries aRetry-Afterheader, which the client above parses (handling bothdelay-secondsandHTTP-dateforms, clamped to[1, 600]) and stashes onHTPBEException::$retryAfterSeconds. The Laravel HTTP client’sretry()method does not read the header on its own; downstream code (Step 6) honours the parsed value to release queued jobs with the server-suggested delay.
Step 4: Service Provider and Config Binding
To make HTPBEClient injectable anywhere via Laravel’s container, register it with a Service Provider.
config/services.php — add an entry:
'htpbe' => [
'api_key' => env('HTPBE_API_KEY'),
'timeout' => (int) env('HTPBE_TIMEOUT_SECONDS', 30),
],app/Providers/HTPBEServiceProvider.php:
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Services\HTPBE\HTPBEClient;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Support\ServiceProvider;
final class HTPBEServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(HTPBEClient::class, function ($app) {
$apiKey = (string) config('services.htpbe.api_key');
if ($apiKey === '') {
throw new \RuntimeException(
'HTPBE_API_KEY is not configured. Set it in your .env file.',
);
}
return new HTPBEClient(
http: $app->make(HttpFactory::class),
apiKey: $apiKey,
timeoutSeconds: (int) config('services.htpbe.timeout', 30),
);
});
}
}Register the provider in bootstrap/providers.php (Laravel 11) or config/app.php (Laravel 10). From now on, type-hinting HTPBEClient in any controller, job, or service resolves to a configured singleton.
Step 5: Form Request Validation Pipeline
The cleanest place to call the API in a Laravel app is inside a Form Request — this keeps fraud detection alongside the rest of your input validation rather than inside controller bodies. The pattern: the user uploads a PDF, you store it on a private disk, generate a temporary URL, run the verification, and attach the verdict to the request.
When this pattern fits — and when it does not. Running a synchronous HTTP call inside
passedValidation()is convenient because the verdict lands next to the rest of the request validation, and the controller body stays clean. It is the right shape for low-volume flows — internal admin tools, manual document upload, single-document onboarding steps where 2–5 seconds of latency is acceptable and the request is human-driven.For anything higher-volume or where UX is critical (public uploads, batch flows, mobile clients, anything behind a synchronous API endpoint that fronts the user), make the queued job (Step 6) the default. The Form Request validates the file (
mimes:pdf,max:10240, virus scan), stores it, dispatches the job, and returns immediately. The verdict surfaces via a separate notification, webhook, or pull from/checks. Sync verification at the validation layer is the simpler pattern; async at the job layer is the safer one. Most production Laravel apps that take user uploads should pick the second.
app/Http/Requests/StoreDocumentRequest.php:
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use App\Services\HTPBE\HTPBEClient;
use App\Services\HTPBE\HTPBEException;
use App\Services\HTPBE\HTPBEResult;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
final class StoreDocumentRequest extends FormRequest
{
public ?HTPBEResult $verification = null;
public function authorize(): bool
{
return $this->user() !== null;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'document' => ['required', 'file', 'mimes:pdf', 'max:10240'], // 10 MB
];
}
protected function passedValidation(): void
{
/** @var UploadedFile $file */
$file = $this->file('document');
// 1. Store on a private S3-compatible disk under a UUID key.
$key = 'incoming/' . Str::uuid() . '.pdf';
Storage::disk('s3')->putFileAs(
dirname($key),
$file,
basename($key),
['visibility' => 'private'],
);
// 2. Mint a short-lived temporary URL.
$temporaryUrl = Storage::disk('s3')->temporaryUrl(
$key,
now()->addMinutes(5),
);
// 3. Call HTPBE — passing the original filename so it shows up
// in audit logs instead of the opaque UUID.
$client = app(HTPBEClient::class);
try {
$this->verification = $client->verify(
pdfUrl: $temporaryUrl,
originalFilename: $file->getClientOriginalName(),
);
} catch (HTPBEException $e) {
// 402, 401 etc. are configuration/billing errors — fail loud,
// do not let the user upload proceed silently without a verdict.
report($e);
abort(503, 'Document verification is temporarily unavailable.');
}
}
}app/Http/Controllers/DocumentController.php:
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Http\Requests\StoreDocumentRequest;
use App\Models\Document;
use Illuminate\Http\JsonResponse;
final class DocumentController extends Controller
{
public function store(StoreDocumentRequest $request): JsonResponse
{
$verdict = $request->verification;
if ($verdict->status === 'modified') {
return response()->json([
'error' => 'Document appears to have been modified after creation.',
'modification_markers' => $verdict->modificationMarkers,
], 422);
}
if ($verdict->status === 'inconclusive') {
// Queue for manual review rather than accepting outright.
$document = Document::create([
'user_id' => $request->user()->id,
'check_id' => $verdict->id,
'verdict' => $verdict->status,
'status_reason' => $verdict->statusReason,
'requires_review' => true,
]);
return response()->json([
'message' => 'Document accepted for manual review.',
'document_id' => $document->id,
'reason' => $verdict->statusReason,
], 202);
}
// status === 'intact' — proceed
$document = Document::create([
'user_id' => $request->user()->id,
'check_id' => $verdict->id,
'verdict' => $verdict->status,
'requires_review' => false,
]);
return response()->json([
'message' => 'Document accepted.',
'document_id' => $document->id,
], 201);
}
}The inconclusive verdict means the document was created with consumer software, an online editor, an HTML renderer, or a scanner rather than institutional document management software. For a deeper explanation, see what “inconclusive” really means. For documents that claim institutional origin — bank statements, diplomas, contracts — inconclusive warrants the same treatment as modified: do not accept automatically, route to a human reviewer.
Step 6: Queued Job for Asynchronous Workflows
When the upload endpoint must return quickly, or when you process documents in batches (KYC backfills, claim portfolios, end-of-month reconciliation), run the verification inside a queued job. The controller stores the file and dispatches; the worker calls the API and updates the database.
app/Jobs/VerifyDocumentJob.php:
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Document;
use App\Services\HTPBE\HTPBEClient;
use App\Services\HTPBE\HTPBEException;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
final class VerifyDocumentJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 5;
public int $timeout = 60;
/** @var list<int> Per-attempt delay in seconds — exponential backoff */
public array $backoff = [10, 30, 60, 180, 600];
public function __construct(
public readonly int $documentId,
public readonly string $storageDisk,
public readonly string $storageKey,
public readonly string $originalFilename,
) {}
public function handle(HTPBEClient $client): void
{
$document = Document::findOrFail($this->documentId);
// Skip if already verified (idempotency for re-runs).
if ($document->verdict !== null) {
return;
}
$temporaryUrl = Storage::disk($this->storageDisk)->temporaryUrl(
$this->storageKey,
now()->addMinutes(5),
);
try {
$result = $client->verify(
pdfUrl: $temporaryUrl,
originalFilename: $this->originalFilename,
);
} catch (HTPBEException $e) {
// 402 means the credit pool is dry — every remaining retry
// will fail identically. Mark the job failed immediately
// and let an operator top up before re-dispatching.
if ($e->errorCode === 'PAYMENT_REQUIRED') {
$this->fail($e);
return;
}
// 429 should be honoured with Retry-After; release the job
// back to the queue for the suggested delay. The header may
// be absent, in which case fall back to a conservative 30s.
if ($e->errorCode === 'SERVER_AT_CAPACITY') {
$this->release($e->retryAfterSeconds ?? 30);
return;
}
throw $e; // bubble up — Laravel will apply backoff
}
$document->update([
'check_id' => $result->id,
'verdict' => $result->status,
'status_reason' => $result->statusReason,
'producer' => $result->producer,
'creator' => $result->creator,
'modification_markers' => $result->modificationMarkers,
'requires_review' => $result->status !== 'intact',
]);
}
}Dispatch it from the controller after storing the upload:
VerifyDocumentJob::dispatch(
documentId: $document->id,
storageDisk: 's3',
storageKey: $key,
originalFilename: $file->getClientOriginalName(),
);A few production details worth getting right at scale (and the broader patterns are covered in the async queue guide):
- Idempotency. The job checks
$document->verdictat the top and returns early if already populated. A re-queue from a worker crash never double-charges credits. - Concurrency. Set
--max-jobsor use a dedicated queue for verification (queue:work --queue=verification) so a backlog of PDFs cannot starve other workers. Retry-Afteron 429. The client parses the header (delay-seconds or HTTP-date), clamps it to[1, 600], and the job releases for that exact duration via$e->retryAfterSeconds ?? 30. The fallback to 30 seconds applies only when the header is absent or unparseable.
Step 7: Error Handling Reference
The HTTP codes the client surfaces and what each one means in a Laravel app:
| Code | errorCode | What it means | What to do |
|---|---|---|---|
| 400 | BAD_REQUEST | Malformed JSON body or missing url field | Fix the request payload; never retry |
| 401 | UNAUTHORIZED | Invalid or revoked API key | Check config('services.htpbe.api_key'); never retry |
| 402 | PAYMENT_REQUIRED | No credits left in the universal pool | Fail the job; alert billing; never retry |
| 403 | FORBIDDEN | Test key sent to a live URL, or vice versa | Switch keys; never retry |
| 404 | NOT_FOUND | Check id does not exist (or is not yours) | Confirm the id; never retry |
| 413 | FILE_TOO_LARGE | PDF exceeds the 10 MB limit | Pre-validate file size in your Form Request; never retry |
| 422 | INVALID_PDF | The URL did not return a parseable PDF | Confirm storage upload; never retry |
| 429 | SERVER_AT_CAPACITY | Server-wide concurrency saturation | Honour Retry-After; release the queued job |
| 5xx | SERVER_ERROR | Transient server failure | Retry with exponential backoff (already wired in retry()) |
The pattern in your Form Request is to fail loud (abort(503)) on configuration errors so a misconfigured key never silently lets a user upload a document without a verdict. The pattern in a queued job is the opposite — release on transient codes, fail() on permanent ones, throw on the genuinely unexpected.
Step 8: Test Mode
All HTPBE? plans — including free — include a test API key. Test API keys (prefix htpbe_test_) use mock URLs that return predefined responses, similar to Stripe test cards. They are designed for integration testing without consuming production quota or requiring real documents.
Test URL format: https://api.htpbe.tech/v1/test/{filename}.pdf
A selection of the available fixtures:
| URL | status | Description |
|---|---|---|
clean.pdf | intact | Clean document, institutional origin |
modified-low.pdf | modified | Single incremental update |
modified-critical.pdf | modified | Signature removed, JavaScript, Excel origin |
signature-removed.pdf | modified | Digital signature stripped |
inconclusive.pdf | inconclusive | Excel origin, no structural modifications |
dates-mismatch.pdf | modified | Modification date 14 days after creation |
Use them in your PHPUnit or Pest tests to cover every branch of your business logic:
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Services\HTPBE\HTPBEClient;
use Illuminate\Http\Client\Factory as HttpFactory;
use Tests\TestCase;
final class HTPBEClientTest extends TestCase
{
private HTPBEClient $client;
protected function setUp(): void
{
parent::setUp();
$this->client = new HTPBEClient(
http: $this->app->make(HttpFactory::class),
apiKey: (string) env('HTPBE_TEST_API_KEY'),
);
}
public function test_clean_document_returns_intact(): void
{
$result = $this->client->verify('https://api.htpbe.tech/v1/test/clean.pdf');
$this->assertSame('intact', $result->status);
$this->assertSame([], $result->modificationMarkers);
}
public function test_modified_document_returns_modified(): void
{
$result = $this->client->verify('https://api.htpbe.tech/v1/test/modified-critical.pdf');
$this->assertSame('modified', $result->status);
}
public function test_inconclusive_document_carries_status_reason(): void
{
$result = $this->client->verify('https://api.htpbe.tech/v1/test/inconclusive.pdf');
$this->assertSame('inconclusive', $result->status);
$this->assertNotNull($result->statusReason);
}
public function test_signature_removed_is_flagged(): void
{
$result = $this->client->verify('https://api.htpbe.tech/v1/test/signature-removed.pdf');
$this->assertSame('modified', $result->status);
$this->assertTrue($result->signatureRemoved);
}
}For unit tests of your controllers and jobs without hitting the network at all, use Laravel’s Http::fake() and bind a fake response in the client:
use Illuminate\Support\Facades\Http;
Http::fake([
'api.htpbe.tech/v1/analyze' => Http::response(['id' => 'test-uuid-1234'], 200),
'api.htpbe.tech/v1/result/*' => Http::response([
'id' => 'test-uuid-1234',
'filename' => 'statement.pdf',
'status' => 'modified',
'modification_markers' => ['HTPBE_DATES_DISAGREE'],
// ... rest of the flat result payload
], 200),
]);Keep your test API key in phpunit.xml or .env.testing (separate from production) and never commit either key to version control.
Security considerations
The integration above moves user-supplied PDFs from your app to a third-party analysis endpoint. A few concrete risks worth working through before going live:
- SSRF surface. The API downloads whatever URL you give it. If the URL ever originates from untrusted input (a user-pasted link, a webhook payload, a CSV import), validate that it resolves to a public host before submitting — reject
localhost,127.0.0.0/8,169.254.169.254(cloud metadata),10.0.0.0/8,172.16.0.0/12,192.168.0.0/16, and your own internal hostnames. When you mint the URL yourself from a private S3 bucket viatemporaryUrl()the risk is minimal, but the validation belongs in the request flow either way. - Temporary link accessibility. An S3 pre-signed URL is a bearer token: anyone who sees it inside the 5-minute window can fetch the PDF. Do not log full URLs in plaintext, do not write them to an audit table without redaction, and scope the signing IAM policy to the minimum (
s3:GetObjecton the one bucket, nothing else). - MIME spoofing. Laravel’s
mimes:pdfrule resolves the MIME type against Apachemime.types— not against the file’s magic bytes — so a crafted file with a.pdfextension can pass validation. HTPBE? itself parses the binary, so the verdict is honest regardless, but if you use the file locally before submitting (preview generation, byte-count display, embedded-text extraction) check the first four bytes for%PDF-before trusting it. - Download size limits. The API rejects PDFs over 10 MB with
413 FILE_TOO_LARGE. Themax:10240rule inStoreDocumentRequestenforces the same limit at the edge so oversized files never round-trip through your storage or your S3 bill. - Document privacy. Submitted PDFs are analysed and deleted immediately after analysis per the DPA. If you handle medical records, legal filings, or other tightly regulated documents, your own customer-facing terms should disclose that files are processed by a third-party verification service.
- Result storage in your DB. Persisting the full response payload is convenient but rarely necessary —
status,status_reason,modification_markers,id, andalgorithm_versionare enough to drive auto-routing and to reconstruct a verdict later viaGET /result/{id}. Avoid logging the full payload in application logs; markers and verdict are sufficient signal. - API key handling.
htpbe_live_*keys belong inconfig('services.htpbe.api_key')backed by.env, never in a client bundle, frontend code, committed config, or CI logs. Rotate the key from the dashboard if it appears in a shared transcript or pull-request diff.
Step 9: Reviewing Past Checks
The GET /api/v1/checks endpoint returns a paginated list of all analysis results for your API key. Use it for audit dashboards, weekly reports, or pulling a history for a specific date range:
public function recentModified(HTPBEClient $client): array
{
$response = Http::withToken(config('services.htpbe.api_key'))
->acceptJson()
->get('https://api.htpbe.tech/v1/checks', [
'status' => 'modified',
'limit' => 50,
]);
$response->throw();
return $response->json('data');
}Monitor your quota consumption via the HTPBE? dashboard. 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.
A Note on Official Clients
There is no official PHP package on Packagist. The Python client exists if you want a reference architecture for typed responses and retry behaviour — the patterns translate cleanly to PHP, as the code above demonstrates. The API surface is small enough (two endpoints, one JSON body, three verdicts) that a hand-rolled client like HTPBEClient above is the right level of abstraction for most Laravel apps. The changelog records algorithm-version history if you need to pin behaviour during a rollout.
Decisions Before You Ship
The integration surface is intentionally small: one POST, one GET, three verdicts, the error reference above. The complexity lives on the Laravel side — Service Provider wiring, Form Request validation, queue patterns, defensive deserialization, test coverage — and those choices matter more than the API surface itself.
Three decisions to settle before going live:
- Where verification runs. Synchronous inside a Form Request gives the user immediate feedback but blocks the response for 2–5 seconds. A queued job returns instantly but defers the verdict. Sync is the right default for low-volume B2B onboarding; queued is the right default for high-volume claim portals.
inconclusiverouting. For documents that claim institutional origin (bank statements, diplomas, payslips), treatinconclusivethe same asmodifiedand route to human review. For genuinely user-generated content (handwritten forms, signed agreements), it may be acceptable as-is.- Test key separation. Keep test and production keys in separate env files. Test keys return synthetic data from the mock URLs and should never touch production flows; live keys cost real credits and should never run against fixture URLs.
Two endpoints, three verdicts, the error table above. The Laravel-side patterns — defensive DTO, parsed Retry-After, queue idempotency, SSRF-safe URL handling — are what determines whether the integration holds up under production traffic.