How to Handle PDF/A Archival Compliance in Your Document Pipeline

A practical guide to generating PDF/A-compliant documents in automated pipelines, covering the format variants, common pitfalls, and how to validate output before it ships to long-term storage.

pdfpdf-acompliancearchivalnode.jsdocument-generation

How to Handle PDF/A Archival Compliance in Your Document Pipeline

If your system generates invoices, contracts, medical records, or anything else that needs to survive a decade-long audit, regular PDFs will eventually bite you. Fonts get unlinked, color profiles drift, embedded JavaScript breaks, and a document that opened perfectly in 2024 renders as garbled boxes in 2034. PDF/A is the ISO-standardized fix for this, and integrating it into a document pipeline is mostly a matter of knowing which constraints to enforce at generation time.

This post walks through what PDF/A actually requires, where most pipelines fail, and how to wire validation into your CI or runtime flow.

What PDF/A actually demands

PDF/A is a restricted subset of PDF defined in ISO 19005. The headline rule: everything needed to render the document must live inside the file. No external font lookups, no linked images, no JavaScript, no audio, no transparency in older variants.

You'll mostly deal with these flavors:

  • PDF/A-1b — basic visual reproduction, based on PDF 1.4. Most restrictive, widest reader support.
  • PDF/A-2b — adds JPEG2000, transparency, and PDF/A file attachments.
  • PDF/A-3b — same as A-2b but allows arbitrary file attachments (used for ZUGFeRD/Factur-X e-invoicing).
  • The a variants (e.g. PDF/A-1a) add tagged-PDF accessibility requirements. Harder to produce; only worth it if you actually need them.

For most archival use cases, PDF/A-2b is the right default. Pick PDF/A-3b only if you're embedding structured data like XML invoices.

Where pipelines usually fail

In my experience, broken PDF/A output almost always traces back to one of:

  1. Missing or non-embedded fonts. A common culprit is system fonts referenced by name but never embedded.
  2. No output intent. PDF/A requires an ICC color profile declaring the target color space (typically sRGB).
  3. Missing XMP metadata. PDF/A needs a specific XMP packet identifying the conformance level.
  4. Disallowed content. Form JavaScript, encrypted streams, audio/video annotations, external hyperlinks without proper annotations.
  5. Transparency in PDF/A-1. A-1 forbids it entirely. Flatten or upgrade to A-2.

Generating PDF/A in a Node pipeline

If you're building from scratch with a low-level library, you control everything but you also have to do everything. Here's the rough shape with pdf-lib plus a post-processing step using Ghostscript:

bash
gs -dPDFA=2 \ -dBATCH -dNOPAUSE \ -sProcessColorModel=DeviceRGB \ -sDEVICE=pdfwrite \ -sPDFACompatibilityPolicy=1 \ -sOutputFile=invoice.pdfa.pdf \ PDFA_def.ps invoice.pdf

You'll need a PDFA_def.ps file that points to an sRGB ICC profile. Ghostscript ships a default one you can copy and edit.

The problem with this approach: you've now glued together a font-embedding strategy, an ICC profile, an XMP metadata block, and a Ghostscript post-process. Each of those is a place to break compliance silently.

Using a service that handles compliance

If you'd rather not maintain Ghostscript in your container images, Kamy can emit PDF/A directly. You pass a conformance flag at render time:

ts
import { kamy } from "@kamydev/sdk"; const pdf = await kamy.documents.render({ template: "invoice", data: { invoiceNumber: "INV-2024-0188", total: "€1,420.00", }, output: { format: "pdf", conformance: "PDF/A-2b", }, }); console.log(pdf.url);

The render pipeline embeds all fonts referenced by the template, attaches an sRGB output intent, writes the required XMP packet, and strips disallowed features. The returned file is ready for an archival bucket.

For ZUGFeRD-style e-invoices where you need to attach an XML payload:

ts
const pdf = await kamy.documents.render({ template: "invoice", data: invoiceData, output: { format: "pdf", conformance: "PDF/A-3b", attachments: [ { name: "factur-x.xml", contentType: "application/xml", relationship: "Alternative", data: xmlBuffer.toString("base64"), }, ], }, });

Validate, don't trust

Whether you generate yourself or use a service, validate the output. The reference validator is veraPDF, which has a CLI:

bash
verapdf --flavour 2b --format json invoice.pdfa.pdf > report.json

A passing report looks roughly like this:

json
{ "jobs": [{ "validationResult": { "profileName": "PDF/A-2B validation profile", "compliant": true, "statement": "PDF file is compliant with Validation Profile requirements." } }] }

Wire this into CI for any template that's expected to produce archival output. A simple guard:

bash
#!/usr/bin/env bash set -euo pipefail verapdf --flavour 2b --format json "$1" > report.json compliant=$(jq -r '.jobs[0].validationResult.compliant' report.json) if [ "$compliant" != "