HTML to PDF/A: archival PDFs that pass veraPDF

Archives, public tenders and e-invoicing rules often ask for PDF/A, the ISO 19005 profile for long-term storage. Headless Chrome (Puppeteer, Playwright, and most HTML-to-PDF APIs built on them) never produces it. We ran Chrome PDFs through veraPDF, the reference validator, then tried the Ghostscript command most tutorials give, then our pdf_a option. Every command below was run as shown on 2026-10-24.

Why a Chrome PDF is not PDF/A

A one-page HTML invoice printed by Chromium 156 failed veraPDF's PDF/A-2b profile on 3 rules. The Wikipedia article on PDF/A (9 pages) failed on the same 3:

Rule (ISO 19005-2)What is missing
6.2.4.3An OutputIntent with an RGB ICC profile. Chrome writes plain DeviceRGB colours, which PDF/A only allows next to a declared colour profile.
6.6.2.1An XMP metadata stream in the document catalog (the title and dates in a form archives can read).
6.1.3A file ID in the trailer.

Fonts were not the problem: Chrome already embeds every font (pdffonts showed 0 unembedded fonts in both files).

The Ghostscript one-liner fails without telling you

The usual advice is to run the PDF through Ghostscript with -dPDFA:

gs -dPDFA=2 -dBATCH -dNOPAUSE -dPDFACompatibilityPolicy=1 \
  -sColorConversionStrategy=RGB -sDEVICE=pdfwrite -o out.pdf chrome.pdf

Ghostscript 10.06 exits with status 0 and prints nothing, and the file's metadata claims PDF/A-2b, but veraPDF still fails it on rule 6.2.4.3: there is no OutputIntent, because Ghostscript only adds one if you hand it an ICC profile through a PostScript definition file. The working recipe is in the last section. If you don't validate, you won't see the problem until an archive rejects the file.

One API call: pdf_a

Add "pdf_a": "2b" (or true) to a normal /v1/pdf request. It works with html or url, JSON, form bodies or GET:

curl -s https://shotmint.creasework.com/v1/pdf \
  -H "X-API-Key: $SHOTMINT_KEY" \
  -H "Content-Type: application/json" \
  -d '{"html": "<h1>Invoice INV-2026-0042</h1><p>Total: €229.00</p>", "pdf_a": "2b"}' \
  -o invoice.pdf

Get a key with one click below (free, 25 credits a month, no card) and set it as SHOTMINT_KEY. The same call from Node.js 18+, combined with A4 and a watermark (the watermark is drawn before the conversion, so the result is still valid PDF/A):

// pdfa.mjs, run with: SHOTMINT_KEY=... node pdfa.mjs  (Node 18+)
import { readFile, writeFile } from "node:fs/promises";

const html = await readFile("invoice.html", "utf8");
const res = await fetch("https://shotmint.creasework.com/v1/pdf", {
  method: "POST",
  headers: { "X-API-Key": process.env.SHOTMINT_KEY, "Content-Type": "application/json" },
  body: JSON.stringify({ html, pdf_a: "2b", format: "A4", watermark: "COPY" }),
});
if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
await writeFile("invoice.pdf", Buffer.from(await res.arrayBuffer()));
console.log("saved invoice.pdf");

And from Python with requests, asking for PDF/A-3b:

# pdfa.py, run with: SHOTMINT_KEY=... python3 pdfa.py  (pip install requests)
import os
import requests

with open("invoice.html", encoding="utf-8") as f:
    html = f.read()

res = requests.post(
    "https://shotmint.creasework.com/v1/pdf",
    headers={"X-API-Key": os.environ["SHOTMINT_KEY"]},
    json={"html": html, "pdf_a": "3b", "format": "A4"},
    timeout=60,
)
res.raise_for_status()
with open("invoice.pdf", "wb") as f:
    f.write(res.content)
print("saved invoice.pdf")

All three outputs passed veraPDF (2b, 2b, 3b) through the live API.

What we measured

InputResult
HTML invoice, 1 page
Chrome 20.6 KB, PDF/A-2b 22.3 KB
Chrome FAIL (3 rules), pdf_a PASS for 1b, 2b and 3b. Both links kept, text extractable, € sign intact.
Wikipedia "PDF/A", 9 pages
Chrome 212 KB, PDF/A-2b 139 KB
Chrome FAIL (3 rules), pdf_a PASS. All 454 link annotations, 9 pages and 6 embedded fonts kept.
Same invoice + watermarkPASS. A password with pdf_a is a 400 error, since PDF/A forbids encryption.

The conversion adds about 0.2 to 2 seconds, depending on page count. The result can be smaller than Chrome's file because Ghostscript rewrites and deduplicates the fonts.

Which level to ask for

ValueUse it when
"2b" or trueThe default. PDF/A-2 allows transparency and JPEG2000, and most archives accept it.
Based on PDF 1.7
"1b"The receiver names PDF/A-1 explicitly (older archive and court-filing rules).
Based on PDF 1.4; transparency gets flattened
"3b"You need PDF/A-3, which allows embedded files, e.g. the XML in Factur-X / ZUGFeRD e-invoices.
We produce the PDF/A-3b file but don't embed the XML: add it with your e-invoicing library. We have not tested that step.

Level a (accessible) and u are not offered: Ghostscript drops the structure tree, and a PDF/A-2a file needs one. If you need tags for accessibility rather than archiving, use tagged: true without pdf_a.

Check the file yourself

Don't trust a "PDF/A" label in the metadata, ours included. veraPDF is free and open source:

# Java 11+; download the CLI from verapdf.org, then:
verapdf -f 2b --format text invoice.pdf
# PASS /path/invoice.pdf 2b

Use -f 1b or -f 3b for the other levels. A FAIL lists the rule numbers, as in the first table.

Doing it yourself with Ghostscript

If you would rather convert on your own server, this is the recipe that passed veraPDF for us. Save the definition file as pdfa_def.ps:

/ICCProfile (srgb.icc) def
[/_objdef {icc_PDFA} /type /stream /OBJ pdfmark
[{icc_PDFA} << /N 3 >> /PUT pdfmark
[{icc_PDFA} ICCProfile (r) file /PUT pdfmark
[/_objdef {OutputIntent_PDFA} /type /dict /OBJ pdfmark
[{OutputIntent_PDFA} << /Type /OutputIntent /S /GTS_PDFA1 /DestOutputProfile {icc_PDFA} /OutputConditionIdentifier (sRGB) >> /PUT pdfmark
[{Catalog} << /OutputIntents [ {OutputIntent_PDFA} ] >> /PUT pdfmark

Then run it on the PDF that Chrome, Puppeteer or Playwright printed (chrome.pdf):

cp /usr/share/ghostscript/*/iccprofiles/srgb.icc .
gs -dPDFA=2 -dBATCH -dNOPAUSE -dSAFER --permit-file-read=srgb.icc \
  -dPDFACompatibilityPolicy=1 -sColorConversionStrategy=RGB \
  -sDEVICE=pdfwrite -o pdfa.pdf pdfa_def.ps chrome.pdf

-dSAFER blocks file reads, so --permit-file-read must name the ICC profile. -dPDFACompatibilityPolicy=1 drops anything PDF/A forbids instead of aborting. Put the PDF/A level in both -dPDFA=N and the veraPDF check. Check Ghostscript's AGPL licence before shipping it inside a closed-source product. The API runs the same steps for you, after printing and watermarking, and refuses combinations that can't be valid.

What it costs

pdf_a costs nothing extra: one PDF is one credit, whatever its page count. Free keys get 25 credits a month; packs start at €5 for 2,000 PDFs, credits never expire and there's no subscription (pricing). For the invoice template itself (page breaks, fonts, totals), see HTML to PDF invoices.

Try it with 25 free renders a month

No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).

Get a free API key Full API reference

More guides: HTML to PDF and HTML to image

All guides: guides · integrations · comparisons