PDF Security Blog

PDF Tamper Detection API for Symfony: Integration Guide

HTPBE Team··32 min read
PDF Tamper Detection API for Symfony: Integration Guide

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

A Symfony app that accepts PDFs from the outside world – bank statements for an underwriting flow, invoices for accounts payable, payslips for a rental application – usually checks the file type and size, stores it, and moves on. Whether the file was edited after the issuing system produced it is a separate question, and nothing in that pipeline answers it. HTPBE? does: it is a structural PDF tamper detection service. It reads how a file was built and saved and reports whether it found evidence of modification after creation. It does not read what the document says, check who sent it, or confirm anything with the issuer.

This guide wires the PDF tamper detection API into Symfony using the framework’s own primitives: a scoped HttpClient, a readonly result DTO, a Messenger handler with a retry policy that honours Retry-After, and Flysystem for the pre-signed URL the API downloads from. The thread running through all of it is the API’s Idempotency-Key header. It is what makes a Messenger retry safe – a retried request after a timeout or a dropped connection does not spend a second credit or produce a second check – and it interacts with pre-signed URLs in a way that is easy to get wrong.

If you want the concepts before the code, start with How to Detect PDF Tampering Programmatically. If you need plain PHP with Guzzle, or you run Laravel, the Laravel and PHP integration guide covers that ground; this article does not repeat it.

TL;DR

  • The integration is two calls: one POST /analyze to submit a PDF URL (it returns 201 with an id), one GET /result/{id} to read the verdict. status is intact, modified or inconclusive.
  • Configure a scoped HttpClient with base_uri, auth_bearer from an environment variable, and timeouts longer than the API’s own download and analysis limits.
  • Mint the Idempotency-Key when you dispatch the Messenger message, not inside the handler, and derive it from the document and the exact URL you submit. A redelivered message then reuses it; a fresh pre-signed URL gets a fresh key.
  • In the handler, map 429 and 409 idempotency_key_in_progress to RecoverableMessageHandlingException with a retryDelay, 5xx and network failures to ordinary retries, and every other 4xx to UnrecoverableMessageHandlingException.
  • Route on usage_caution.recommended_action. No verdict is grounds for an automatic rejection – safe_for_automated_adverse_decision is always false.

How to read this guide

The code targets Symfony 7.4 LTS on PHP 8.2+, with notes where Symfony 8.1 behaves differently. Every snippet uses the documented response shape and nothing else.

  • To get a verdict from Symfony quickly, read Steps 1 and 2. A scoped client and a DTO are enough for a synchronous call from a controller or a console command.
  • To process uploads in the background, add Steps 3 and 4. This is where the idempotency design lives.
  • To ship it, add Steps 5 and 6, the error reference, test mode and the security notes.

Throughout, Document stands for your own Doctrine entity holding an uploaded file. Its methods in the snippets are illustrative; map them onto whatever your model looks like.

Sequence flow

 Controller            Messenger worker                  HTPBE API
     |                        |                              |
     | store PDF, pre-sign URL|                              |
     | mint Idempotency-Key   |                              |
     |--- AnalyzeDocument --->|                              |
     |                        |--- POST /v1/analyze -------->|  downloads the URL,
     |                        |    Idempotency-Key: ...      |  analyses synchronously
     |                        |<-- 201 {"id": "..."} --------|
     |                        |--- GET /v1/result/{id} ----->|
     |                        |<-- 200 {status, ...} --------|
     |                        | record verdict, route it     |

The analysis happens inside the POST. By the time you get the 201, the result exists; the GET reads it.

Prerequisites

  • PHP 8.2+ and Symfony 7.4 with symfony/http-client and symfony/messenger
  • A Messenger transport (Doctrine, Redis or AMQP – the examples use whatever MESSENGER_TRANSPORT_DSN points at)
  • league/flysystem-bundle with league/flysystem-aws-s3-v3, or any storage that can produce a time-limited download URL
  • An HTPBE? account: sign up, copy your test key (htpbe_test_...) from the dashboard, and create a live key (htpbe_live_...) there when you are ready for real documents

There is no official PHP package on Packagist. The API is small enough that a hand-written client is the right size, and it lets you use Symfony’s own HTTP and retry machinery rather than a third-party wrapper’s.

Step 1: A scoped HttpClient for the API

A scoped client keeps the base URL, credentials and timeouts in configuration, so the service code only says analyze and result/{id}.

# config/packages/framework.yaml
framework:
  http_client:
    scoped_clients:
      htpbe.client:
        base_uri: 'https://api.htpbe.tech/v1/'
        auth_bearer: '%env(HTPBE_API_KEY)%'
        headers:
          Accept: 'application/json'
        timeout: 90
        max_duration: 120
# .env.local – never commit a live key
HTPBE_API_KEY=htpbe_live_...

Three details matter more than they look:

The trailing slash on base_uri. Symfony merges relative URLs into base_uri by RFC 3986 rules. With https://api.htpbe.tech/v1/, a request to analyze goes to /v1/analyze. A request to /analyze – with a leading slash – goes to https://api.htpbe.tech/analyze and drops the version. So does a base_uri without the trailing slash. Use the trailing slash in config and no leading slash in code.

timeout is idle time, not total time. It is how long the client waits without receiving any data. Because the analysis runs synchronously inside the POST, the server is silent while it works. The API documents a 30-second limit for downloading your file and a separate 20-second limit for the analysis, with typical requests finishing in 2–5 seconds and complex documents in 5–15. An idle timeout close to the sum of those limits turns a slow but successful analysis into a client-side timeout, so set it comfortably above. Ninety seconds of idle time and a 120-second max_duration leave headroom; tune them to your worker’s own limits.

Leave retry_failed off. HttpClient can retry failed requests on its own, but here Messenger owns the retry policy (Step 4). Two retry layers multiply each other and block a worker while they sleep. Keep the client plain and let the transport schedule retries.

Each scoped client also gets a named autowiring alias. An argument typed HttpClientInterface and named $htpbeClient receives the htpbe.client service.

Step 2: A typed result and a small API service

The result object has more fields than most integrations need. Decode the ones you act on, validate their types, and fail loudly on anything you do not recognise rather than guessing.

<?php
// src/Htpbe/Verdict.php

declare(strict_types=1);

namespace App\Htpbe;

enum Verdict: string
{
    case Intact = 'intact';
    case Modified = 'modified';
    case Inconclusive = 'inconclusive';
}
<?php
// src/Htpbe/RecommendedAction.php

declare(strict_types=1);

namespace App\Htpbe;

enum RecommendedAction: string
{
    case NoAction = 'no_action';
    case RouteToHumanReview = 'route_to_human_review';
    case RequestFromIssuer = 'request_from_issuer';
}
<?php
// src/Htpbe/AnalysisResult.php

declare(strict_types=1);

namespace App\Htpbe;

final readonly class AnalysisResult
{
    /**
     * @param list<string> $modificationMarkers
     */
    public function __construct(
        public string $id,
        public Verdict $status,
        public ?string $statusReason,
        public ?string $modificationConfidence,
        public array $modificationMarkers,
        public RecommendedAction $recommendedAction,
        public string $algorithmVersion,
        public ?string $outdatedWarning,
    ) {
    }

    /**
     * @param array<string, mixed> $data
     */
    public static function fromArray(array $data): self
    {
        $caution = $data['usage_caution'] ?? null;
        if (!\is_array($caution)) {
            throw new \UnexpectedValueException('HTPBE result has no usage_caution object.');
        }

        $markers = $data['modification_markers'] ?? [];

        return new self(
            id: self::requireString($data, 'id'),
            // from() throws on an unknown value: better a failed message than a guessed verdict.
            status: Verdict::from(self::requireString($data, 'status')),
            statusReason: self::optionalString($data, 'status_reason'),
            modificationConfidence: self::optionalString($data, 'modification_confidence'),
            modificationMarkers: \is_array($markers) ? array_values(array_filter($markers, 'is_string')) : [],
            recommendedAction: RecommendedAction::from(self::requireString($caution, 'recommended_action')),
            algorithmVersion: self::requireString($data, 'algorithm_version'),
            outdatedWarning: self::optionalString($data, 'outdated_warning'),
        );
    }

    /**
     * @param array<array-key, mixed> $data
     */
    private static function requireString(array $data, string $key): string
    {
        $value = $data[$key] ?? null;
        if (!\is_string($value) || $value === '') {
            throw new \UnexpectedValueException(sprintf('HTPBE result field "%s" is missing or not a string.', $key));
        }

        return $value;
    }

    /**
     * @param array<array-key, mixed> $data
     */
    private static function optionalString(array $data, string $key): ?string
    {
        $value = $data[$key] ?? null;

        return \is_string($value) ? $value : null;
    }
}

Two choices in there are deliberate. status and recommended_action are enums, because your code branches on them and a value you have never seen should stop the pipeline, not fall into a default branch. status_reason and the marker IDs stay strings: their sets of values grow over time, so your routing should not hard-code them. Marker IDs such as HTPBE_DATES_DISAGREE or HTPBE_SIGNATURE_REMOVED are stable and new ones get added; store them, show them to reviewers, and look up their labels in the published dictionary on how it works.

safe_for_automated_adverse_decision is not on the DTO. It is always false, and an always-false field is better as a rule in your head than a property someone eventually writes an if around.

The service makes the two calls and turns non-success responses into one exception type that carries what the retry logic needs.

<?php
// src/Htpbe/HtpbeApiException.php

declare(strict_types=1);

namespace App\Htpbe;

use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;

final class HtpbeApiException extends \RuntimeException
{
    private function __construct(
        string $message,
        public readonly int $httpStatus,
        public readonly ?string $errorCode,
        public readonly ?int $retryAfterSeconds,
        public readonly ?string $requestId,
    ) {
        parent::__construct($message, $httpStatus);
    }

    public static function fromResponse(ResponseInterface $response, int $httpStatus): self
    {
        try {
            $body = $response->toArray(false);
        } catch (DecodingExceptionInterface) {
            $body = []; // not JSON, e.g. an error page from a proxy in between
        }

        $headers = $response->getHeaders(false);
        $retryAfter = $headers['retry-after'][0] ?? null;
        $requestId = $body['request_id'] ?? $headers['x-request-id'][0] ?? null;

        return new self(
            message: \is_string($body['error'] ?? null) ? $body['error'] : sprintf('HTPBE API returned HTTP %d.', $httpStatus),
            httpStatus: $httpStatus,
            errorCode: \is_string($body['code'] ?? null) ? $body['code'] : null,
            retryAfterSeconds: \is_string($retryAfter) && preg_match('/^\d+$/', $retryAfter) === 1 ? (int) $retryAfter : null,
            requestId: \is_string($requestId) ? $requestId : null,
        );
    }
}
<?php
// src/Htpbe/HtpbeClient.php

declare(strict_types=1);

namespace App\Htpbe;

use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;

final class HtpbeClient
{
    public function __construct(
        private readonly HttpClientInterface $htpbeClient, // the htpbe.client scoped client
    ) {
    }

    /**
     * Submits a PDF URL for analysis and returns the check id.
     */
    public function submit(string $pdfUrl, string $idempotencyKey, ?string $originalFilename = null): string
    {
        $body = ['url' => $pdfUrl];
        if ($originalFilename !== null) {
            $body['original_filename'] = $originalFilename;
        }

        $response = $this->htpbeClient->request('POST', 'analyze', [
            'json' => $body,
            'headers' => ['Idempotency-Key' => $idempotencyKey],
        ]);

        $id = $this->decode($response, 201)['id'] ?? null;
        if (!\is_string($id) || $id === '') {
            throw new \UnexpectedValueException('HTPBE analyze response has no id.');
        }

        return $id;
    }

    public function result(string $checkId): AnalysisResult
    {
        $response = $this->htpbeClient->request('GET', 'result/'.rawurlencode($checkId));

        return AnalysisResult::fromArray($this->decode($response, 200));
    }

    /**
     * @return array<string, mixed>
     */
    private function decode(ResponseInterface $response, int $expectedStatus): array
    {
        // Network errors and timeouts surface here as TransportExceptionInterface.
        $status = $response->getStatusCode();

        if ($status !== $expectedStatus) {
            throw HtpbeApiException::fromResponse($response, $status);
        }

        return $response->toArray();
    }
}

Symfony’s responses are lazy: the request is sent when you first read from the response, and a response that is destroyed without its status being read throws from its destructor on a 3xx–5xx. Calling getStatusCode() first, then passing false to toArray() and getHeaders() on the error path, keeps every outcome explicit. Transport failures – DNS, connection resets, the idle timeout from Step 1 – are not caught here on purpose. They are exactly the failures the next step is designed around.

For a synchronous call from a controller or command, this is already enough: $client->result($client->submit($url, $key)). The rest of the guide is about running it in the background without paying twice.

Step 3: Idempotent requests – mint the key at dispatch

Here is the failure the Idempotency-Key header exists for. Your worker sends POST /analyze. The server downloads the file, runs the analysis, stores the check and spends a credit. The response is on its way back when the connection drops, or your idle timeout fires a moment too early. The worker sees a TransportException, Messenger schedules a retry, and the retry sends the same document again. Without a key, that is a second check and a second credit, and nothing on your side can tell that the first one succeeded.

With a key, the API documents four outcomes for a repeated request:

  • the original completed – the retry returns 201 with the same check id, no credit is spent again and the document is not re-analysed;
  • the original is still being processed – 409 with idempotency_key_in_progress, and you retry later with the same key;
  • the original failed – the retry proceeds as a normal new request;
  • the key is sent with a different url – 422 with idempotency_key_fingerprint_mismatch.

Keys are up to 255 characters and scoped to your account and request source. Sending none is supported; you just get no protection.

Where the key comes from

The tempting implementation generates a UUID at the top of the handler. That defeats the purpose: every Messenger retry runs the handler again and gets a new key, so every retry is a new, independently charged request. The key has to exist before the first attempt and travel with the message, so every redelivery of that message sends the same value.

The fourth outcome adds a constraint that is specific to how you get the PDF to the API. The url you submit is usually a pre-signed storage URL (Step 6), and a pre-signed URL is different every time you sign it – new expiry, new signature. Reuse a key with a freshly signed URL for the same document and the API sees a different url and refuses with 422. So the unit the key identifies is not ‘this document’ but ‘this document at this exact URL’. Sign the URL once, at dispatch, and put the URL and the key on the message together:

<?php
// src/Message/AnalyzeDocument.php

declare(strict_types=1);

namespace App\Message;

final readonly class AnalyzeDocument
{
    public function __construct(
        public string $documentId,
        public string $pdfUrl,
        public int $urlExpiresAt, // Unix timestamp
        public string $idempotencyKey,
        public ?string $originalFilename,
    ) {
    }
}
<?php
// src/Htpbe/IdempotencyKey.php

declare(strict_types=1);

namespace App\Htpbe;

final class IdempotencyKey
{
    /**
     * Deterministic for one document at one URL; a re-signed URL yields a new key.
     */
    public static function for(string $documentId, string $pdfUrl): string
    {
        return 'doc-'.$documentId.'-'.hash('sha256', $pdfUrl); // ~105 chars, under the 255 limit
    }
}
<?php
// src/Htpbe/DocumentAnalysisDispatcher.php

declare(strict_types=1);

namespace App\Htpbe;

use App\Entity\Document;
use App\Message\AnalyzeDocument;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Messenger\Stamp\DispatchAfterCurrentBusStamp;

final class DocumentAnalysisDispatcher
{
    // The URL's lifetime is the message's retry window – see Steps 4 and 6.
    private const URL_TTL = '+30 minutes';

    public function __construct(
        private readonly PresignedPdfUrl $presignedPdfUrl, // Step 6
        private readonly MessageBusInterface $bus,
    ) {
    }

    public function dispatch(Document $document): void
    {
        $expiresAt = new \DateTimeImmutable(self::URL_TTL);
        $pdfUrl = $this->presignedPdfUrl->for($document->getStorageKey(), $expiresAt);

        $message = new AnalyzeDocument(
            documentId: $document->getId(),
            pdfUrl: $pdfUrl,
            urlExpiresAt: $expiresAt->getTimestamp(),
            idempotencyKey: IdempotencyKey::for($document->getId(), $pdfUrl),
            originalFilename: $document->getOriginalFilename(),
        );

        // From a controller this dispatches immediately. From inside a handler (a
        // resubmission, Step 5) it waits until that handler has finished without error.
        $this->bus->dispatch((new Envelope($message))->with(new DispatchAfterCurrentBusStamp()));
    }
}

Deriving the key from the document ID and a hash of the URL makes the 422 impossible by construction: the same URL always produces the same key, and a different URL always produces a different one. It also means a key in your logs points back to one document and one URL. A random UUID generated in dispatch() would be just as correct for retries, as long as it is generated there and not in the handler.

What the key does not cover is your own code dispatching the same document twice – a double-clicked upload button, a controller that runs twice. Each dispatch signs its own URL, gets its own key, and is a separate, separately charged request. The header protects one logical request through its retries; deciding that a document should be submitted once is still your application’s job, for example by dispatching only on the transition of an upload into a pending_analysis state.

Signing at dispatch has two consequences:

  • The handler must never re-sign the URL. It uses $message->pdfUrl as-is.
  • The URL’s lifetime is your retry window. The URL has to stay valid for as long as Messenger might retry the message, which is why its expiry travels on the message as urlExpiresAt. Step 4 uses it to stop retrying once the URL is dead, and Step 6 sets the TTL.

A deliberate resubmission – for example after an analysis_incomplete result (Step 5) – goes back through dispatch(), gets a new URL and therefore a new key. That is what you want: a fresh key makes the resubmission unambiguously a new request, independent of how the API treats the earlier key.

The two idempotency errors, in Symfony terms

  • 409 idempotency_key_in_progress means an earlier attempt with this key is still running – typically your own timed-out request, which the server is still finishing. Retry after a short delay with the same key. Switching to a new key here is precisely the double charge you set out to avoid; the API says as much in the error details.
  • 422 idempotency_key_fingerprint_mismatch is a bug in your code, never a transient condition. Do not retry; fail the message and log loudly enough that someone looks at it.

Step 4: The Messenger handler and its retry strategy

Route the message to its own transport, give that transport a bounded retry strategy, and configure a failure transport so nothing is silently discarded.

# config/packages/messenger.yaml
framework:
  messenger:
    failure_transport: failed

    transports:
      htpbe:
        dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
        retry_strategy:
          max_retries: 4
          delay: 5000 # ms – first retry after 5 s
          multiplier: 3 # then 15 s, 45 s, 135 s
          max_delay: 300000
          jitter: 0.1
      failed: 'doctrine://default?queue_name=failed'

    routing:
      'App\Message\AnalyzeDocument': htpbe

These numbers are a reasonable starting point for this guide, not something HTPBE? prescribes. Four retries over a little over three minutes ride out a brief network or server hiccup without holding the message for long. A dedicated transport lets you cap the number of workers consuming it, which matters because the API’s 429 is about server-wide analysis capacity, not a per-key rate limit: more parallel workers will not get you more throughput, only more 429s.

The handler does the two calls and translates each failure into the right Messenger outcome:

<?php
// src/MessageHandler/AnalyzeDocumentHandler.php

declare(strict_types=1);

namespace App\MessageHandler;

use App\Htpbe\HtpbeApiException;
use App\Htpbe\HtpbeClient;
use App\Htpbe\VerdictRouter;
use App\Message\AnalyzeDocument;
use App\Repository\DocumentRepository;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\RecoverableMessageHandlingException;
use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;

#[AsMessageHandler]
final class AnalyzeDocumentHandler
{
    public function __construct(
        private readonly HtpbeClient $htpbe,
        private readonly DocumentRepository $documents,
        private readonly VerdictRouter $router,
        private readonly EntityManagerInterface $entityManager,
        private readonly LoggerInterface $logger,
    ) {
    }

    public function __invoke(AnalyzeDocument $message): void
    {
        $document = $this->documents->find($message->documentId);
        if ($document === null) {
            return; // deleted since dispatch – nothing to analyse
        }

        if ($document->getAnalysedWithKey() === $message->idempotencyKey) {
            return; // this exact request already completed and was recorded
        }

        if (time() >= $message->urlExpiresAt) {
            // The API could no longer download the file. Bounds every retry path, 429 included.
            throw new UnrecoverableMessageHandlingException('Pre-signed URL expired; re-dispatch for a fresh URL and key.');
        }

        try {
            $checkId = $this->htpbe->submit($message->pdfUrl, $message->idempotencyKey, $message->originalFilename);
            $result = $this->htpbe->result($checkId);
        } catch (HtpbeApiException $e) {
            throw $this->classify($e, $message);
        }

        $document->recordAnalysis($message->idempotencyKey, $result);
        $this->router->route($document, $result);
        $this->entityManager->flush(); // verdict and any review case commit together
    }

    private function classify(HtpbeApiException $e, AnalyzeDocument $message): \Throwable
    {
        return match (true) {
            // Server-wide capacity: wait as long as the API asks, then retry the same request.
            $e->httpStatus === 429 => new RecoverableMessageHandlingException(
                message: 'HTPBE is at analysis capacity.',
                previous: $e,
                retryDelay: 1000 * ($e->retryAfterSeconds ?? 10),
            ),

            // Our own earlier attempt is still running: same key, short wait.
            $e->errorCode === 'idempotency_key_in_progress' => new RecoverableMessageHandlingException(
                message: 'Earlier attempt with this Idempotency-Key still in progress.',
                previous: $e,
                retryDelay: 1000 * ($e->retryAfterSeconds ?? 5),
            ),

            // Transient server error: an ordinary exception, so retry_strategy bounds it.
            $e->httpStatus >= 500 => $e,

            // A key reused for another URL is a bug in our code. Never retry it.
            $e->errorCode === 'idempotency_key_fingerprint_mismatch' => $this->bug($e, $message),

            // 400, 401, 402, 403, 413, 422: retrying the same request cannot succeed.
            default => new UnrecoverableMessageHandlingException(
                sprintf('HTPBE rejected the request: %s (HTTP %d).', $e->errorCode ?? 'unknown', $e->httpStatus),
                0,
                $e,
            ),
        };
    }

    private function bug(HtpbeApiException $e, AnalyzeDocument $message): UnrecoverableMessageHandlingException
    {
        $this->logger->critical('Idempotency-Key reused for a different URL.', [
            'document_id' => $message->documentId,
            'idempotency_key' => $message->idempotencyKey,
        ]);

        return new UnrecoverableMessageHandlingException('Idempotency-Key fingerprint mismatch.', 0, $e);
    }
}

Some of this behaviour comes from Messenger itself:

retryDelay is in milliseconds and needs Symfony 7.2 or later. The retryDelay argument to RecoverableMessageHandlingException was added in 7.2, and the Symfony docs give ‘use the value from the Retry-After header’ as its intended use. Retry-After is in seconds, hence the * 1000. The fallback of 10 seconds applies only when the header is missing or not a plain number.

On Symfony 7.2 to 8.0, a recoverable exception is retried forever. The docs are explicit: a message that throws RecoverableMessageHandlingException is ‘retried infinitely and max_retries setting will be ignored’. Left alone, a sustained run of 429s or 409s would keep one message cycling until the condition cleared. The expiry check at the top of the handler is the bound on 7.4: once the pre-signed URL has expired, the next attempt fails as unrecoverable before it calls the API, and the message goes to the failure transport instead of looping. On Symfony 8.1, you can bound it directly by passing forceRetry: false, which makes the configured max_retries apply to these exceptions too:

// Symfony 8.1+
new RecoverableMessageHandlingException(
    message: 'HTPBE is at analysis capacity.',
    previous: $e,
    retryDelay: 1000 * ($e->retryAfterSeconds ?? 10),
    forceRetry: false,
);

Unrecoverable is not the same as discarded. A message that throws UnrecoverableMessageHandlingException is not retried, but it still goes to the failure transport. That is the right place for a 402 payment_required (out of credits) or a 413 (file over 10 MB): someone needs to see them. Inspect them with messenger:failed:show. Be careful with messenger:failed:retry, though: it replays the original message, with the original URL and key. If the URL has expired by then – likely, if the fix was topping up credits on pricing – the replay just fails again on the expiry check. Send the document back through DocumentAnalysisDispatcher instead, which signs a new URL and mints a new key.

That is safe after a definitive 4xx such as a 413 or an invalid_pdf: the API refused the request, so no check exists that a new key could pay for twice. It is ambiguous for a message that ran out of retries on timeouts or 5xx errors – one of those attempts may have completed on the server without your worker seeing the response, and a new key cannot find out. Treat a 402 that follows earlier timeouts or 5xx errors on the same message the same way: the attempt that timed out may be the one that spent the last credit. In both cases, if the URL is still valid, replay the original message with messenger:failed:retry – same URL, same key, topping up first in the 402 case – before minting a new one.

Once the URL has expired, that option is gone: the handler refuses the message before it reaches the API, so an ambiguous message can no longer be replayed under its key. Re-dispatching it may then create a second check and spend a second credit. If that matters for the document, look before you re-dispatch: GET /checks filters by date and status but not by key or URL, so matching on filename (from original_filename) within the time window is the closest you can get, and it is a heuristic, not a lookup.

Network failures and timeouts take the ordinary path. A TransportExceptionInterface from HttpClient is neither recoverable nor unrecoverable in Messenger’s terms, so retry_strategy decides – four retries, each sending the same URL and the same key.

A failed GET re-sends the POST, and that is fine. If submit() succeeded but result() hit a timeout, the retry runs the whole handler again. The POST carries the same key, so the API returns 201 with the same check id and spends no credit; then the GET tries again. You do not need to persist the check ID between the two calls to stay safe.

The early return is about your side effects, not the API’s. The getAnalysedWithKey() guard catches a message that is redelivered after it already succeeded – the worker crashed after flush() but before acknowledging. The API call would have been safe to repeat; your routing, which might open a review case or notify someone, may not be. Recording the key alongside the verdict lets the handler tell ‘this request already landed’ apart from ‘this is a deliberate resubmission with a new key’.

Run the consumer under a process manager and recycle it periodically, as the Messenger docs recommend for long-running workers:

php bin/console messenger:consume htpbe --time-limit=3600 --memory-limit=256M

For the general shape of queue-based checking – backlogs, concurrency and bulk imports – see batch PDF verification with async queues. If you want verdicts to fan out to other services as events, event-driven PDF verification builds your own callback layer on top of a worker like this one; the HTPBE? API itself is request/response and does not push webhooks.

Step 5: Routing the verdict

Every result carries usage_caution.recommended_action, and that is the field to branch on. It maps one-to-one onto the verdict: route_to_human_review for modified, request_from_issuer for inconclusive, no_action for intact. Its sibling, safe_for_automated_adverse_decision, is always false: the verdict describes the file, not the person who sent it, and it is not grounds on its own for rejecting an applicant, a claim or a supplier.

<?php
// src/Htpbe/VerdictRouter.php

declare(strict_types=1);

namespace App\Htpbe;

use App\Entity\Document;
use App\Review\ReviewQueue;

final class VerdictRouter
{
    private const MAX_RESUBMISSIONS = 1;

    public function __construct(
        private readonly ReviewQueue $reviewQueue,
        private readonly DocumentAnalysisDispatcher $dispatcher,
    ) {
    }

    public function route(Document $document, AnalysisResult $result): void
    {
        // Not fully checked: says nothing about the file. Resubmit once with a new URL and key.
        if ($result->statusReason === 'analysis_incomplete'
            && $document->getResubmissionCount() < self::MAX_RESUBMISSIONS) {
            $document->incrementResubmissionCount();
            $this->dispatcher->dispatch($document);

            return;
        }

        match ($result->recommendedAction) {
            RecommendedAction::NoAction => $document->markNoModificationDetected(),
            RecommendedAction::RouteToHumanReview => $this->reviewQueue->openCase($document, $result),
            RecommendedAction::RequestFromIssuer => $this->reviewQueue->requestFromIssuer($document, $result),
        };
    }
}

What each branch should and should not mean in your product:

  • no_action (intact) – no structural evidence of modification was found. It is not a statement that the content is true or that the issuer exists. Let the document continue through whatever other checks you already run.
  • route_to_human_review (modified) – structural evidence of a post-creation edit. Give the reviewer the marker IDs and modification_confidence; that is what they need to look in the right place. Keep the reviewer’s decision, not the verdict, as the thing that changes the case outcome.
  • request_from_issuer (inconclusive) – the check could not confirm integrity: the file came from consumer software, an online editor or a scanner, or something similar – or, after the resubmission cap below, it still could not be checked in full. This is not evidence of fraud. The proportionate response is a cleaner copy from the source – a statement downloaded directly from the bank portal, an invoice sent by the supplier’s system rather than re-saved.

The match with no default arm is intentional: adding a case to RecommendedAction without handling it becomes an UnhandledMatchError you see in tests, not a silent fall-through.

The analysis_incomplete special case follows the API’s own guidance – re-submit, and if it keeps coming back that way, do not rely on it. The cap means a second incomplete result goes down the normal request_from_issuer path. Because the dispatcher adds a DispatchAfterCurrentBusStamp, the resubmission is only sent once the current handler – including its flush() – has finished without an exception; if the flush fails, the queued message is dropped and the original message is retried instead.

The full taxonomy – every status_reason, how modification_confidence should shape the reviewer’s queue, and the branches developers most often get wrong – is in handling verdicts in code. For explaining inconclusive to the operations team who will see it, point them at what ‘inconclusive’ really means.

One more field to store: algorithm_version. Results record the detection version that produced them, and an outdated_warning appears when you re-read an old check after the algorithm has moved on. Keeping the version next to the verdict lets you explain, months later, which version made a call.

Step 6: Getting the PDF to a fetchable URL

The API downloads the PDF from the URL you submit. It must be a public http or https URL, reachable without authentication, for a file of at most 10 MB. You do not want your private uploads bucket to be public, so you give the API a short-lived pre-signed GET URL to one object.

With Flysystem 3.10 or later and the AWS S3 v3 adapter, temporaryUrl() does this. The one trap is the type: temporaryUrl() is defined on the concrete League\Flysystem\Filesystem class, not on the FilesystemOperator interface the bundle autowires by default. Inject the concrete class by service ID with #[Autowire]:

<?php
// src/Htpbe/PresignedPdfUrl.php

declare(strict_types=1);

namespace App\Htpbe;

use League\Flysystem\Filesystem;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

final class PresignedPdfUrl
{
    public function __construct(
        #[Autowire(service: 'documents.storage')]
        private readonly Filesystem $documentsStorage,
    ) {
    }

    public function for(string $storageKey, \DateTimeImmutable $expiresAt): string
    {
        return $this->documentsStorage->temporaryUrl($storageKey, $expiresAt);
    }
}

documents.storage is whatever you named the storage under flysystem.storages in config/packages/flysystem.yaml, configured with the aws adapter and your bucket. The adapter builds a GetObject command for the key and presigns it with the AWS SDK. If you use AsyncAws instead, its Flysystem adapter supports temporary URLs too. On other storage, the requirement is the same: a time-limited, read-only URL to exactly one object.

Choose the TTL by the retry window, not by habit. Five minutes is a common default for pre-signed links, but here the URL travels on a message that may wait out several retries and a Retry-After or two. Too short, and a message that would have succeeded on its fourth attempt fails with download_failed instead. Too long, and the link stays usable well after anyone needs it. The 30 minutes set in DocumentAnalysisDispatcher covers the policy in Step 4 – a little over three minutes of ordinary retries plus any Retry-After waits – with room to spare; if you lengthen the retry policy, lengthen the TTL with it.

Error handling reference

The codes POST /analyze and GET /result/{id} can return, and what the handler from Step 4 does with each:

HTTPcodeMeaningHandler outcome
400invalid_request, invalid_url_formatMalformed body, missing url, not an http(s) URL, or a bad check IDUnrecoverable – fix the code
400download_failedThe API could not fetch the URL: expired link, 403/404, 30-second timeoutUnrecoverable – re-dispatch with a fresh URL and key
401missing_api_key, invalid_api_keyKey absent or not recognisedUnrecoverable – check HTPBE_API_KEY
402payment_requiredNo credits left on the accountUnrecoverable – top up, then re-dispatch
403inactive_client, test_url_required, account_restrictedKey deactivated, test key used with a real URL, or account restrictionUnrecoverable
404not_foundCheck ID unknown, owned by another API client, or a real check read with a test keyUnrecoverable
409idempotency_key_in_progressAn earlier request with the same key is still runningRecoverable – same key, short delay
413file_too_largePDF over 10 MBUnrecoverable – enforce the limit at upload
422invalid_pdfNot a PDF, corrupt, encrypted, or could not be analysed within the limitUnrecoverable – retrying the same file will not help
422idempotency_key_fingerprint_mismatchKey reused for a different URLUnrecoverable and logged as a bug
429server_at_capacityServer-wide analysis capacity reached; Retry-After header in secondsRecoverable – retryDelay from Retry-After
500internal_errorServer error; request_id in the body and the x-request-id headerOrdinary retry, bounded by retry_strategy

Two notes. analysis_incomplete is not an error – it arrives as a successful 200 result with status: inconclusive and is handled in Step 5. And log the request_id from a 500: it is what support needs to find the request.

Test mode

Your test key (htpbe_test_...) only accepts the API’s predefined test URLs under https://api.htpbe.tech/v1/test/. It downloads nothing, consumes no credits, and returns deterministic synthetic results with fixed check IDs. Point HTPBE_API_KEY at the test key in .env.test and you can drive every verdict branch of VerdictRouter against the real API:

Test URLResultCheck ID
https://api.htpbe.tech/v1/test/clean.pdfintact00000000-0000-4000-8000-000000000001
https://api.htpbe.tech/v1/test/modified-high.pdfmodified00000000-0000-4000-8000-000000000005
https://api.htpbe.tech/v1/test/signature-removed.pdfmodified00000000-0000-4000-8000-00000000000c
https://api.htpbe.tech/v1/test/inconclusive.pdfinconclusive, consumer_software_origin00000000-0000-4000-8000-000000000011
https://api.htpbe.tech/v1/test/inconclusive-online-editor.pdfinconclusive, online_editor_origin00000000-0000-4000-8000-000000000014
https://api.htpbe.tech/v1/test/outdated-version.pdfresult with outdated_warning00000000-0000-4000-8000-000000000013
https://api.htpbe.tech/v1/test/trigger-402.pdf402 payment_required from POST /analyzenone

Because the IDs are fixed, a test can also call result() directly with a known ID and skip the POST. check_date is always null for synthetic results, so do not assert on it. There are no test URLs for analysis_incomplete or the other live-only status_reason values; the match on recommended_action covers them the same way.

The retry paths – 409, 422, 429, a timeout – are better tested without the network. Symfony’s MockHttpClient and MockResponse take a status code and headers directly:

<?php
// tests/Htpbe/HtpbeClientTest.php

declare(strict_types=1);

namespace App\Tests\Htpbe;

use App\Htpbe\HtpbeApiException;
use App\Htpbe\HtpbeClient;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;

final class HtpbeClientTest extends TestCase
{
    public function testCapacityErrorCarriesRetryAfter(): void
    {
        $http = new MockHttpClient(
            new MockResponse(
                '{"error":"Server is at analysis capacity, retry shortly","code":"server_at_capacity"}',
                ['http_code' => 429, 'response_headers' => ['Retry-After: 7']],
            ),
            'https://api.htpbe.tech/v1/',
        );

        try {
            (new HtpbeClient($http))->submit('https://example.com/a.pdf', 'doc-1-abc');
            self::fail('Expected HtpbeApiException.');
        } catch (HtpbeApiException $e) {
            self::assertSame(429, $e->httpStatus);
            self::assertSame('server_at_capacity', $e->errorCode);
            self::assertSame(7, $e->retryAfterSeconds);
        }
    }

    public function testSubmitSendsTheIdempotencyKey(): void
    {
        $response = new MockResponse('{"id":"00000000-0000-4000-8000-000000000001"}', ['http_code' => 201]);
        $http = new MockHttpClient($response, 'https://api.htpbe.tech/v1/');

        $id = (new HtpbeClient($http))->submit('https://example.com/a.pdf', 'doc-1-abc');

        self::assertSame('00000000-0000-4000-8000-000000000001', $id);
        self::assertSame('https://api.htpbe.tech/v1/analyze', $response->getRequestUrl());
        self::assertContains('Idempotency-Key: doc-1-abc', $response->getRequestOptions()['headers']);
    }
}

The second test also pins the base_uri merge from Step 1: if someone changes 'analyze' to '/analyze', the URL assertion fails. Test the handler the same way – feed it a client backed by a MockHttpClient that returns a 409, and assert it throws RecoverableMessageHandlingException with getRetryDelay() of 5000.

Security considerations

Most of the risk in this integration is about the pre-signed URL and where it ends up, not about the API call itself.

  • The URL is a bearer credential, and Messenger stores it. Anyone holding it can download the PDF until it expires. It sits in your transport while the message waits, and in the failure transport after a permanent failure, where messenger:failed:show -vv displays the message details, URL included. Restrict who can read those tables or queues, keep the TTL no longer than the retry window needs, and keep the URL out of application logs. Log the document ID and the idempotency key instead; the key contains a hash of the URL, not the URL.
  • Sign narrowly. The credentials that sign URLs should only be able to read the uploads bucket, ideally only the uploads prefix. A leaked link then exposes one file for a limited time, not the bucket.
  • Only submit URLs you minted. In this design the API only ever sees URLs your own storage produced. If you ever accept a URL from a user or a partner instead, validate that it points where you expect before submitting it – a URL from outside is a request you are making on someone else’s behalf.
  • Keep the live key server-side. HTPBE_API_KEY belongs in the environment or Symfony’s secrets vault, not in front-end code or committed config. Use the test key in .env.test and CI; a test key cannot analyse a real file, so a leaked one cannot spend credits.
  • Store less of the result than you receive. The verdict, status_reason, marker IDs, check ID and algorithm_version are enough to route the document and to re-read the full result later with GET /result/{id}.

Decisions before you ship

  • Where the analysis runs. A synchronous call from the controller gives the user an answer in the same request but holds it for the length of the analysis. The Messenger path returns immediately and is the right default for anything user-facing or high-volume.
  • The retry window, as one number. retry_strategy, the Retry-After behaviour of your Symfony version and the pre-signed URL’s TTL are three settings describing one thing. Change one and check the other two.
  • What inconclusive means for each document type. For a document that should come from an institution – a bank statement, a payslip – request_from_issuer usually means asking for the original from the source. For a document a person legitimately writes themselves, your policy may be looser. Either way, a human decides; the verdict informs.
  • Who sees the failure transport. A 402 or a run of download_failed errors is an operational signal. Make sure it reaches someone, not just a table.

The API side is one POST to submit and one GET to read the verdict. The Symfony side – a key that travels with the message, a URL that lives as long as the retries, and exceptions that tell Messenger what kind of failure it is looking at – is what keeps a busy queue from paying twice for one request while its URL is alive, and from losing a document when something fails.

To try it, create an account, copy your test key – test calls spend no credits – and run the test URLs above through the client from Step 2. When you move to live documents, credits come from pay-per-check packs or a subscription on pricing, and the API page has the full response reference.

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-symfony-integration-guide

Secure your workflow

Create your account – check PDFs on the web or with an API key, both ready on signup.
No sales call. Cancel any time.