Ga naar inhoud

Batchgeneratie via Connect met voortgang bijhouden

Verwerk een lijst documenten vanuit één clientproces volledig via NextPDF Connect, de zelfstandige HTTP-servicedistributie van de engine. Dit recipe dient elk renderverzoek in bij het asynchrone-job-eindpunt POST /api/v1/jobs, pollt elke job met GET /api/v1/jobs/{id} totdat die een eindtoestand bereikt, leest de velden status en progress die de server voor elke job rapporteert, en downloadt elke voltooide PDF van GET /api/v1/jobs/{id}/result.

De levenscyclus van een job is vast en beperkt. Een job is pending, daarna running, en komt daarna in precies één eindtoestand terecht: completed, failed of cancelled. De statusrespons bevat een geheeltallige progress van 0 tot 100 wanneer de server die bijhoudt, en bij elke niet-eindige poll een Retry-After-header die aangeeft wanneer u het volgende verzoek moet sturen. Geef elke indiening een Idempotency-Key mee, zodat een herhaalde indiening dezelfde job teruggeeft in plaats van een tweede render te starten.

Dit recipe gebruikt het wire-level pad. Het roept het REST-oppervlak rechtstreeks aan en gaat niet uit van een taalspecifieke software development kit (SDK), zodat u dezelfde flow naar elke HTTP-client kunt overzetten.

De serverzijde gebruikt de standaard Connect-distributie:

Terminal window
composer require nextpdf/server

De PHP-client in het productievoorbeeld hieronder gebruikt een Hypertext Transfer Protocol (HTTP)-client en bericht-factory’s die voldoen aan PSR-18 en PSR-17. Installeer de implementaties waarop uw project al is gestandaardiseerd, bijvoorbeeld:

Terminal window
composer require psr/http-client psr/http-factory

Het asynchrone-job-oppervlak scheidt indiening van ophaling. U houdt niet per document één langdurige HTTP-verbinding open. In plaats daarvan dient u een job in, ontvangt u een identifier en pollt u een lichtgewicht statuseindpunt totdat de job klaar is. Die aanpak maakt een batch beheersbaar: de client volgt N onafhankelijke jobs tegelijk zonder N geblokkeerde verbindingen.

Drie eindpunten dragen de flow:

  • POST /api/v1/jobs accepteert dezelfde renderverzoekbody als het synchrone eindpunt /api/v1/render: een page_size, een orientation, en een geordende operations-array. Het geeft 201 Created terug voor een nieuwe job, of 200 OK wanneer een Idempotency-Key overeenkomt met een job die u al hebt ingediend.
  • GET /api/v1/jobs/{id} geeft het huidige jobrecord terug. Voor een niet-eindige job stelt het ook een Retry-After-header in (de server gebruikt een interval van 2 seconden) en een poll_url-veld. Respecteer de header in plaats van in een strakke lus te pollen.
  • GET /api/v1/jobs/{id}/result streamt de voltooide PDF als application/pdf. Het geeft 409 Conflict terug als de job completed niet heeft bereikt, dus roep het pas aan zodra de statuspoll de eindtoestand bevestigt.

Elke geslaagde respons deelt één envelop: een data-object met de jobvelden, en een meta-object met de request_id, timestamp, duration_ms, en api_version. De jobvelden die u leest, bevinden zich onder data: data.status, data.progress, data.job_id, en bij een voltooide job data.result_url.

Eén kanttekening voor de huidige release: de server verwerkt een ingediende job inline voordat hij de POST beantwoordt. In de praktijk kan de indieningsrespons dus al een eindige status bevatten, en kan het resultaat bij de eerste poll al gereed zijn. Het hier gedocumenteerde poll-en-voortgangscontract is de stabiele vorm van de Application Programming Interface (API). De server houdt dit contract ongewijzigd terwijl de verwerkingsbackend overgaat op een wachtrij-werkerpool, zodat een client die vandaag correct pollt ook na die wijziging correct blijft. Schrijf de poll-lus. Ga er niet van uit dat de eerste respons niet-eindig is, en ga er evenmin van uit dat die eindig is.

Het OpenAPI-document van de server en de JobHandler-routing definiëren het asynchrone-job-REST-oppervlak van Connect:

  • POST /api/v1/jobs: dien een renderjob in. Optionele Idempotency-Key-verzoekheader. De body is een renderverzoek (operations is verplicht en moet ten minste één bewerking bevatten). Responsen: 201 nieuw, 200 idempotente herhaling, 422 ongeldige body, 409 idempotentieconflict, 429 snelheidsbeperkt.
  • GET /api/v1/jobs/{id}: poll de status. Respons 200 met het jobrecord; Retry-After-header aanwezig zolang niet-eindig; 404 als de job niet bestaat of toebehoort aan een andere client.
  • GET /api/v1/jobs/{id}/result: download de PDF. 200 application/pdf wanneer completed; 409 wanneer nog niet voltooid; 404 als onbekend.
  • DELETE /api/v1/jobs/{id}: annuleer een pending- of running-job, of verwijder een completed-job (204).

Het jobrecord onder data bevat deze velden, precies zoals de server ze serialiseert:

  • job_id: de identifier (een job_-voorvoegsel en 24 hexadecimale tekens).
  • status: een van pending, running, completed, failed, cancelled. De eerste twee zijn niet-eindig; de laatste drie zijn eindig.
  • created_at, en eenmaal ingesteld, started_at en completed_at: ISO-8601-tijdstempels.
  • progress: een geheel getal van 0 tot 100, alleen aanwezig wanneer de server het voor de job bijhoudt; anders afwezig (behandel als onbekend).
  • error: een berichttekenreeks, alleen aanwezig bij een failed-job.
  • result_url: alleen aanwezig bij een completed-job; het pad naar de resultaatdownload.
  • poll_url: alleen aanwezig zolang de job niet-eindig is.

Authenticatie verloopt met een bearertoken in de Authorization-header: Authorization: Bearer npk_live_{kid}_{secret}.

Dit stuurt één job volledig aan op wire-level, zodat u de drie aanroepen en de velden die ze teruggeven kunt zien. Het dient in, pollt eenmaal en downloadt. Het productievoorbeeld hieronder voegt de batchlus, de Retry-After-wachttijd en volledige foutafhandeling toe.

Terminal window
# 1. Submit an async render job. Capture the job_id from data.job_id.
curl -sS -X POST "$NEXTPDF_CONNECT_URL/api/v1/jobs" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: invoice-2026-04-0001" \
-d '{"page_size":"A4","orientation":"portrait","operations":[{"type":"add_text","text":"Invoice 0001"}]}'
# 2. Poll status. Read data.status and data.progress; honour Retry-After.
curl -sS "$NEXTPDF_CONNECT_URL/api/v1/jobs/job_0123456789abcdef01234567" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN"
# 3. Once data.status is "completed", download the PDF binary.
curl -sS "$NEXTPDF_CONNECT_URL/api/v1/jobs/job_0123456789abcdef01234567/result" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN" \
-o invoice-0001.pdf

Deze op zichzelf staande client dient een batch renderverzoeken in, begrenst hoeveel jobs tegelijk in behandeling zijn, pollt elke job in het tempo dat de server via Retry-After opgeeft, rapporteert de progress-waarde die de server teruggeeft, downloadt elke voltooide PDF en registreert mislukkingen. De client gebruikt een PSR-18 HTTP-client en PSR-17-factory’s: het transportcontract waarop de Connect-recipes standaardiseren. De client vangt ook de meest specifieke uitzondering op die elke aanroep kan opwerpen: Psr\Http\Client\ClientExceptionInterface voor een transportfout, en een getypeerde BatchJobException voor een serverrespons die voorkomt dat de batch doorgaat. Geen enkel catch-blok is leeg. Elk blok logt en werpt opnieuw op, of registreert een gedefinieerde uitkomst.

Vervang de inline $documents-lijst door uw eigen invoer. Injecteer de concrete HTTP-client en factory’s van uw project op de plekken waar de constructor de PSR-interfaces verwacht.

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
/**
* Raised when a Connect job response prevents the batch from proceeding.
*
* Distinct from the PSR-18 transport exception: this means the request was
* delivered and the server answered, but the answer is one the batch
* cannot act on (a non-success status code, or a job that ended in a
* terminal failure).
*/
final class BatchJobException extends RuntimeException
{
}
/**
* Drives a batch of async render jobs over the NextPDF Connect REST surface.
*
* The client submits each render request, polls every job on the cadence
* the server requests through Retry-After, and downloads each completed
* PDF. It enforces bounded concurrency so a large batch never opens more
* in-flight jobs than the host should track at once.
*/
final readonly class ConnectBatchRunner
{
/**
* @param non-empty-string $baseUrl Connect base URL, no trailing slash
* @param non-empty-string $bearerToken Connect API key (npk_live_...)
* @param positive-int $maxInFlight Concurrent jobs cap
* @param positive-int $maxPolls Per-job poll attempts before giving up
*/
public function __construct(
private ClientInterface $httpClient,
private RequestFactoryInterface $requestFactory,
private StreamFactoryInterface $streamFactory,
private string $baseUrl,
private string $bearerToken,
private int $maxInFlight = 8,
private int $maxPolls = 150,
) {}
/**
* Render every document in the batch and write each completed PDF.
*
* @param array<non-empty-string, array<string, mixed>> $documents
* Map of stable document key to render request body. The key
* doubles as the Idempotency-Key, so a re-run of the same batch
* does not duplicate server-side work.
* @param non-empty-string $outputDir Directory for the written PDFs
*
* @throws BatchJobException When the batch cannot proceed at all
* @throws ClientExceptionInterface When the transport cannot send a request
*
* @return array<non-empty-string, string> Map of document key to a
* human-readable outcome line
*/
public function run(array $documents, string $outputDir): array
{
$this->assertWritableDir($outputDir);
$outcomes = [];
// Process in bounded windows so the in-flight job count never
// exceeds the configured cap, regardless of batch size.
foreach (array_chunk($documents, $this->maxInFlight, preserve_keys: true) as $window) {
$jobIds = [];
foreach ($window as $key => $body) {
$jobIds[$key] = $this->submit($key, $body);
}
foreach ($jobIds as $key => $jobId) {
$record = $this->pollToTerminal($jobId);
$outcomes[$key] = $this->finish($key, $record, $outputDir);
}
}
return $outcomes;
}
/**
* Submit one render job and return its identifier.
*
* @param non-empty-string $idempotencyKey Stable per-document key
* @param array<string, mixed> $body Render request body
*
* @throws BatchJobException
* @throws ClientExceptionInterface
*
* @return non-empty-string The job_id from data.job_id
*/
private function submit(string $idempotencyKey, array $body): string
{
$request = $this->requestFactory
->createRequest('POST', $this->baseUrl . '/api/v1/jobs')
->withHeader('Authorization', 'Bearer ' . $this->bearerToken)
->withHeader('Content-Type', 'application/json')
->withHeader('Idempotency-Key', $idempotencyKey)
->withBody($this->streamFactory->createStream($this->encode($body)));
$response = $this->httpClient->sendRequest($request);
$status = $response->getStatusCode();
// 201 new job; 200 idempotent replay. Anything else stops the batch.
if ($status !== 201 && $status !== 200) {
throw new BatchJobException(
sprintf('Submit for "%s" returned HTTP %d.', $idempotencyKey, $status),
);
}
$data = $this->decodeData($response->getBody()->__toString());
$jobId = $data['job_id'] ?? null;
if (!is_string($jobId) || $jobId === '') {
throw new BatchJobException(
sprintf('Submit for "%s" returned no job_id.', $idempotencyKey),
);
}
return $jobId;
}
/**
* Poll one job until it reaches a terminal state.
*
* Honours the Retry-After header on every non-terminal poll. Gives up
* after maxPolls attempts and reports the wait as a failure so the
* batch records it rather than blocking forever.
*
* @param non-empty-string $jobId
*
* @throws BatchJobException
* @throws ClientExceptionInterface
*
* @return array<string, mixed> The terminal job record (data object)
*/
private function pollToTerminal(string $jobId): array
{
$url = $this->baseUrl . '/api/v1/jobs/' . rawurlencode($jobId);
for ($attempt = 0; $attempt < $this->maxPolls; $attempt++) {
$request = $this->requestFactory
->createRequest('GET', $url)
->withHeader('Authorization', 'Bearer ' . $this->bearerToken);
$response = $this->httpClient->sendRequest($request);
$status = $response->getStatusCode();
if ($status !== 200) {
throw new BatchJobException(
sprintf('Poll for job "%s" returned HTTP %d.', $jobId, $status),
);
}
$data = $this->decodeData($response->getBody()->__toString());
$jobStatus = is_string($data['status'] ?? null) ? $data['status'] : 'unknown';
$progress = is_int($data['progress'] ?? null) ? $data['progress'] : null;
$this->logProgress($jobId, $jobStatus, $progress);
// Terminal states: completed, failed, cancelled.
if (in_array($jobStatus, ['completed', 'failed', 'cancelled'], strict: true)) {
return $data;
}
// Non-terminal: wait the interval the server asked for.
$this->waitRetryAfter($response->getHeaderLine('Retry-After'));
}
throw new BatchJobException(
sprintf('Job "%s" did not finish within %d polls.', $jobId, $this->maxPolls),
);
}
/**
* Act on a terminal job record: download a completed PDF, or report.
*
* @param non-empty-string $key Document key
* @param array<string, mixed> $record Terminal job record (data object)
* @param non-empty-string $outputDir Where to write the PDF
*
* @throws BatchJobException
* @throws ClientExceptionInterface
*
* @return string A human-readable outcome line
*/
private function finish(string $key, array $record, string $outputDir): string
{
$jobStatus = is_string($record['status'] ?? null) ? $record['status'] : 'unknown';
$jobId = is_string($record['job_id'] ?? null) ? $record['job_id'] : '';
if ($jobStatus !== 'completed') {
// A failed job carries an error message; surface it, do not swallow.
$error = is_string($record['error'] ?? null) ? $record['error'] : 'no detail';
return sprintf('%s -> %s (%s)', $key, $jobStatus, $error);
}
$path = rtrim($outputDir, '/\\') . DIRECTORY_SEPARATOR . $key . '.pdf';
$this->download($jobId, $path);
return sprintf('%s -> completed, written to %s', $key, $path);
}
/**
* Download a completed job result and write it to a server-derived path.
*
* @param non-empty-string $jobId
* @param non-empty-string $path Caller-controlled output path
*
* @throws BatchJobException
* @throws ClientExceptionInterface
*/
private function download(string $jobId, string $path): void
{
$request = $this->requestFactory
->createRequest('GET', $this->baseUrl . '/api/v1/jobs/' . rawurlencode($jobId) . '/result')
->withHeader('Authorization', 'Bearer ' . $this->bearerToken);
$response = $this->httpClient->sendRequest($request);
if ($response->getStatusCode() !== 200) {
throw new BatchJobException(
sprintf('Result download for job "%s" returned HTTP %d.', $jobId, $response->getStatusCode()),
);
}
$bytes = $response->getBody()->__toString();
if (!str_starts_with($bytes, '%PDF')) {
throw new BatchJobException(
sprintf('Result for job "%s" is not a PDF.', $jobId),
);
}
if (file_put_contents($path, $bytes) === false) {
throw new BatchJobException(sprintf('Could not write result to "%s".', $path));
}
}
/**
* Sleep for the server-requested interval, with a safe floor and ceiling.
*/
private function waitRetryAfter(string $retryAfter): void
{
$seconds = ctype_digit($retryAfter) ? (int) $retryAfter : 2;
// Clamp to a sane band so a hostile header cannot stall or busy-loop.
$seconds = max(1, min(30, $seconds));
sleep($seconds);
}
/**
* Emit a progress line. Replace with your logger.
*/
private function logProgress(string $jobId, string $jobStatus, ?int $progress): void
{
$pct = $progress === null ? 'n/a' : $progress . '%';
fwrite(STDERR, sprintf("[%s] status=%s progress=%s\n", $jobId, $jobStatus, $pct));
}
/**
* Decode a response envelope and return its data object.
*
* @throws BatchJobException When the body is not the expected envelope
*
* @return array<string, mixed>
*/
private function decodeData(string $json): array
{
try {
/** @var mixed $decoded */
$decoded = json_decode($json, true, 32, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new BatchJobException('Response body is not valid JSON.', previous: $e);
}
if (!is_array($decoded) || !isset($decoded['data']) || !is_array($decoded['data'])) {
throw new BatchJobException('Response is missing the data envelope.');
}
/** @var array<string, mixed> $data */
$data = $decoded['data'];
return $data;
}
/**
* @param array<string, mixed> $body
*
* @throws BatchJobException
*/
private function encode(array $body): string
{
try {
return json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
} catch (JsonException $e) {
throw new BatchJobException('Render request body is not encodable.', previous: $e);
}
}
/**
* @param non-empty-string $dir
*
* @throws BatchJobException
*/
private function assertWritableDir(string $dir): void
{
if (!is_dir($dir) || !is_writable($dir)) {
throw new BatchJobException(sprintf('Output directory "%s" is not writable.', $dir));
}
}
}
// ---------------------------------------------------------------------------
// Wiring. Provide your project's concrete PSR-18 client and PSR-17 factories.
// ---------------------------------------------------------------------------
/** @var ClientInterface $httpClient */
/** @var RequestFactoryInterface $requestFactory */
/** @var StreamFactoryInterface $streamFactory */
$baseUrl = getenv('NEXTPDF_CONNECT_URL');
$token = getenv('NEXTPDF_CONNECT_TOKEN');
if ($baseUrl === false || $baseUrl === '' || $token === false || $token === '') {
fwrite(STDERR, "Set NEXTPDF_CONNECT_URL and NEXTPDF_CONNECT_TOKEN.\n");
exit(2);
}
/** @var array<non-empty-string, array<string, mixed>> $documents */
$documents = [
'invoice-0001' => [
'page_size' => 'A4',
'orientation' => 'portrait',
'operations' => [
['type' => 'add_text', 'text' => 'Invoice 0001'],
],
],
'invoice-0002' => [
'page_size' => 'A4',
'orientation' => 'portrait',
'operations' => [
['type' => 'add_text', 'text' => 'Invoice 0002'],
],
],
];
$runner = new ConnectBatchRunner(
httpClient: $httpClient,
requestFactory: $requestFactory,
streamFactory: $streamFactory,
baseUrl: rtrim($baseUrl, '/'),
bearerToken: $token,
maxInFlight: 8,
);
try {
$outcomes = $runner->run($documents, getenv('NEXTPDF_COOKBOOK_OUTPUT') ?: sys_get_temp_dir());
} catch (BatchJobException $e) {
fwrite(STDERR, 'Batch stopped: ' . $e->getMessage() . "\n");
exit(1);
} catch (ClientExceptionInterface $e) {
fwrite(STDERR, 'Transport failure: ' . $e->getMessage() . "\n");
exit(1);
}
foreach ($outcomes as $line) {
echo $line, "\n";
}

De verwachte STDOUT is één regel per document. Paden hangen af van uw uitvoermap:

invoice-0001 -> completed, written to /tmp/invoice-0001.pdf
invoice-0002 -> completed, written to /tmp/invoice-0002.pdf
  • Lees jobvelden onder data, niet op het hoogste niveau. Elke geslaagde respons is verpakt in een { "data": ..., "meta": ... }-envelop. data.status en data.progress zijn de velden waarop u handelt; meta bevat request_id voor supportcorrelatie.
  • progress kan afwezig zijn. De server neemt progress alleen op wanneer hij het voor die job bijhoudt. Behandel een ontbrekend veld als “onbekend”, niet als nul, en stuur uw lus aan op basis van status, dat altijd aanwezig is.
  • Indiening kan al eindig zijn. In de huidige release rendert de server inline voordat hij de POST beantwoordt, zodat de indieningsrespons status: completed kan bevatten en het resultaat bij de eerste poll al gereed kan zijn. Uw poll-lus moet een eindtoestand bij poging nul accepteren in plaats van eerst op pending te staan.
  • Respecteer Retry-After. Niet-eindige statusresponsen stellen Retry-After in (een interval van 2 seconden). Sneller pollen verspilt verzoeken en lokt een 429 uit. Beperk de waarde tot een redelijke band in plaats van deze blindelings te vertrouwen.
  • /result vóór voltooiing is een 409. Roep het resultaateindpunt pas aan nadat de statuspoll completed toont. Een 409 Conflict betekent dat de job niet klaar is; het is geen transportfout.
  • Idempotency-Key voorkomt dubbel werk. Een opnieuw ingediende indiening met dezelfde sleutel geeft de oorspronkelijke job terug (200 in plaats van 201). Gebruik een stabiele sleutel per document zodat een netwerkherhaling nooit een tweede render start. Een hergebruikte sleutel met een andere body is een 409-conflict.
  • Jobs zijn afgebakend per eigenaar. Een job die onder de ene API-sleutel is ingediend, is onzichtbaar voor een andere; een GET over eigenaargrenzen heen geeft 404 terug, niet 403. Poll met dezelfde credential waarmee u hebt ingediend.
  • Een failed-job bevat een error-bericht. Lees data.error bij een eindige failed-status en registreer het. Probeer niet blindelings opnieuw.

De kosten van een batch zijn de som van de renders plus de polloverhead. Twee knoppen sturen de clientzijde aan. Begrens ten eerste de gelijktijdigheid: de maxInFlight-limiet bepaalt hoeveel jobs tegelijk worden gevolgd, waardoor het aantal open verzoeken en het geheugengebruik van de client constant blijven, ongeacht de batchgrootte. Stel deze limiet gelijk aan het aantal werkers van de server, niet hoger; meer jobs in behandeling dan werkers verlengt alleen de wachtrijtijd van elke job. Respecteer ten tweede het pollinterval: elke poll is een lichte statuslezing, maar een strakke lus verhoogt het verzoekvolume en triggert de snelheidsbeperker. De Retry-After van 2 seconden van de server is de juiste standaard, en de runner begrenst die tot een band van 1 tot 30 seconden zodat één trage job niet kan busy-loopen of het venster kan blokkeren.

Verwerk zeer grote batches in vensters (de runner gebruikt array_chunk) in plaats van alles vooraf in te dienen. Dat begrenst zowel de gevolgde toestand van de client als de wachtrijdiepte van de server, zodat een misvormde of te grote batch binnen één venster mislukt in plaats van pas na duizenden indieningen.

  • Houd het bearertoken uit logs en URL’s. De API-sleutel gaat alleen mee in de Authorization-header. Plaats het nooit in een querystring, een logregel of een geschreven artefact. De runner logt de job_id en status, nooit de credential.
  • Leid uitvoerpaden af van door de server gecontroleerde sleutels. De runner bouwt elk uitvoerpad uit de documentsleutel die uw code koos, samengevoegd met een vaste uitvoermap, nooit uit een waarde in een serverrespons. Interpoleer geen jobveld in een bestandssysteempad, want dat zou een path traversal openen.
  • Valideer de gedownloade bytes. De runner controleert een 200 van /result op de %PDF-header voordat hij het bestand schrijft. Een geslaagde downloadstatus is op zichzelf geen bewijs dat de body een PDF is.
  • Behandel het resultaat als niet-vertrouwd totdat het is geïnspecteerd. Een voltooide job betekent dat de server bytes heeft gerenderd, niet dat die bytes veilig zijn om door te sturen. Laat resultaten door een structurele inspectiestap gaan voordat u ze overdraagt aan een client of downstream-systeem.
  • Gebruik een sleutel met minimale rechten. Het asynchrone-job-oppervlak is rendering op core-niveau. Geef de batch een sleutel die precies tot de bewerkingen is afgebakend die hij nodig heeft, en roteer deze volgens het schema dat uw beleid voor geheimenbeheer voorschrijft.
  • Begrens het pollbudget. maxPolls voorkomt dat een vastgelopen job de client voor altijd vasthoudt. De batch registreert de time-out als een uitkomst in plaats van te blokkeren, zodat één slechte job de rest van de dienst niet berooft.

Dit recipe doet geen normatieve standaardenclaim. Het gebruikt de NextPDF Connect asynchrone-job-REST-eindpunten (POST /api/v1/jobs, GET /api/v1/jobs/{id}, GET /api/v1/jobs/{id}/result) en leest de jobrecordvelden die de server definieert (status, progress, error, result_url, poll_url). De %PDF-headercontrole op een gedownload resultaat bevestigt alleen dat de respons begint met de PDF-marker; het is geen geldigheids- of conformiteitsbepaling. Gebruik voor een standaardencontrole over een verzameling documenten de Enterprise batchconformiteitstool. Zie Batchstandaardencontrole via Connect, een ander oppervlak dan de renderjobs die hier worden behandeld.