New: PDF/A-3b conversion with a compliance report included.See Docs

Convert PDFs to PDF/A in Python and Handle Validation Results with PDF.co

8 Minutes Read

If an archive, a court or a tax authority has rejected one of your PDFs, PDF/A is usually why. It's the version of PDF built for long-term storage: every font embedded, colors defined, no encryption, no JavaScript, nothing that depends on outside references.

This tutorial builds a small archiving script in Python. It sends a PDF to PDF.co, waits for conversion, and then does the part most conversion scripts skip — it checks whether the output actually conforms, and routes the file accordingly.

That check matters because a successful job can still return a non-compliant file. PDF.co converts the document and then validates it with veraPDF, the PDF Association's open-source validator. If the source document was defective in a way conversion couldn't resolve, the job finishes with status: "success" and isCompliant: false. A script that only checks the status will file those documents in your archive and report success.

By the end you'll have a script that puts compliant files in an archive folder with their validation reports, and sends everything else to a review folder with the rules it failed.

What you'll need

  • A PDF.co account and API key, from your dashboard
  • Python 3.8 or later
  • The requests library: pip install requests
  • A PDF reachable at a public URL. For a file behind authentication, generate a temporary or presigned URL first.

Keep your API key out of the script. The examples below read it from an environment variable:

export PDFCO_API_KEY="your_key_here"

The script writes into two folders, which it creates on first run:

archive/   compliant files and their validation reports
review/    everything that needs a human

Step 1: Submit the conversion

The endpoint is POST /v1/pdf/convert/to/pdfa, and it runs asynchronously. There is no synchronous mode, so the first response hands back a job ID rather than a file.

The only required field is url. There is no profile to pick and no validation toggle. Every conversion produces PDF/A-3b and every conversion is validated.

import os
import requests

API_KEY = os.environ["PDFCO_API_KEY"]
BASE_URL = "https://api.pdf.co/v1"
HEADERS = {"x-api-key": API_KEY, "Content-Type": "application/json"}


def submit_conversion(source_url, output_name=None):
    """Queue a PDF/A conversion and return its job ID."""
    payload = {"url": source_url}
    if output_name:
        payload["name"] = output_name

    response = requests.post(
        f"{BASE_URL}/pdf/convert/to/pdfa",
        json=payload,
        headers=HEADERS,
        timeout=30,
    )
    response.raise_for_status()
    data = response.json()

    if data.get("error"):
        raise RuntimeError(data.get("message", "Submission was rejected"))

    return data["jobId"]

A successful submission looks like this:

{
  "error": false,
  "status": 200,
  "jobId": "EXAMPLEJOBID0123456789ABCDEFGHIJ--166-400",
  "name": "invoice.pdf",
  "validationReportName": "invoice-pdfa-validation.json",
  "pageCount": 1,
  "credits": 25,
  "remainingCredits": 400000
}

status is the HTTP status of the submission, not the job status — the job status is a string and arrives later from the job check. And pageCount is reported when the job is accepted, before conversion has run; read the output's real page count from the final result.

Step 2: Wait for the result

Poll POST /v1/job/check with the job ID until the status is terminal. Note that the field is jobid, lowercase.

Each poll costs credits, so don't check excessively. Three seconds between checks is reasonable for a document of a few pages.

import time

TERMINAL = {"success", "failed", "aborted"}
RETRYABLE = {429, 500, 502, 503, 504}


def wait_for_result(job_id, interval=3, timeout=300):
    """Poll until the job reaches a terminal state, then return the result."""
    deadline = time.time() + timeout

    while time.time() < deadline:
        response = requests.post(
            f"{BASE_URL}/job/check",
            json={"jobid": job_id},
            headers=HEADERS,
            timeout=30,
        )

        # Rate limits and server errors are transient. Retry the same job
        # rather than resubmitting, which would create a second billable job.
        if response.status_code in RETRYABLE:
            time.sleep(interval)
            continue

        response.raise_for_status()
        result = response.json()

        if result.get("status") in TERMINAL:
            return result

        time.sleep(interval)

    raise TimeoutError(f"Job {job_id} did not finish within {timeout}s")

If you'd rather not poll at all, pass a callback URL on the original request and PDF.co will post the result to it when the job finishes.

Step 3: Branch on the validation result

There are three outcomes your code has to tell apart.

  • status: "success", isCompliant: true
    • You will get the the PDF/A file and its report. This is charged.
  • status: "success", isCompliant: false
    • You will get the the PDF/A file, plus a report naming the rules it failed. This is charged.
  • status: "failed"
    • An error message; the file and report may both be absent. This is not charged and will be refunded.

status: "success", isCompliant: false is the one to handle deliberately. Conversion and validation both ran, but the source document was defective in a way the conversion couldn't resolve, and the report tells you which rules it broke.

def route(result):
    """Decide where a finished job's output belongs."""
    if result.get("status") != "success":
        # Rejected input, conversion failure, or a validator that
        # could not run. The processing charge is refunded once.
        return "failed", result.get("message", "Conversion did not complete")

    if result.get("isCompliant"):
        return "archive", f"PDF/A-3b, {result['pageCount']} page(s), 0 failed rules"

    return "review", (
        f"Not compliant: {result['failedRules']} failed rule(s), "
        f"{result['failedChecks']} failed check(s)"
    )

status: "success" on its own shouldn’t be considered proof of conformance. Check isCompliant.

Step 4: Download both files, and read the warnings

The output and report URLs are temporary signed links. The expiration request field sets retention in minutes, dependent on your plan level, and capped at seven days for PDF/A. A compliance record that disappears is not a record, so download both files and store them yourself.

from pathlib import Path


def download(url, destination):
    destination.parent.mkdir(parents=True, exist_ok=True)
    with requests.get(url, stream=True, timeout=120) as response:
        response.raise_for_status()
        with open(destination, "wb") as handle:
            for chunk in response.iter_content(chunk_size=8192):
                handle.write(chunk)
    return destination

Conversion rewrites the document, so the output can differ from the original. The warnings array reports what changed:

for warning in result.get("warnings", []):
    detail = ""
    if "count" in warning:
        detail = f" ({warning['count']})"
    elif "inputCount" in warning:
        detail = f" ({warning['inputCount']} → {warning['outputCount']})"
    print(f"  {warning['code']}{detail}: {warning['message']}")

Step 1: Codes worth knowing

  • documentModified
    • The PDF was rewritten for PDF/A. Appears on most conversions.
  • linkFlagsCorrected
    • Link flags were adjusted so links survive. Carries a count of flags corrected, not links removed.
  • nonEmbeddedFonts
    • The source referenced fonts it didn't embed. They may have been substituted, changing appearance. Carries fontNames.
  • formFieldsChanged
    • Fewer interactive form fields in the output. Carries inputCount and outputCount.
  • annotationsChanged
    • Fewer annotations of a given type.
  • pdfaConversionIncomplete
    • Input features stopped the converter from maintaining PDF/A output. Expect isCompliant: false.
  • conversionWarningDetailsUnavailable
    • Extra diagnostics that couldn't be classified. Usually benign.

Warnings are informational. A file can carry several and still be fully compliant.

Step 5: The complete script

"""Convert PDFs to PDF/A-3b and file them by validation result.

Usage: python archive_pdfa.py https://example.com/document.pdf
"""

import os
import sys
import time
from pathlib import Path

import requests

API_KEY = os.environ["PDFCO_API_KEY"]
BASE_URL = "https://api.pdf.co/v1"
HEADERS = {"x-api-key": API_KEY, "Content-Type": "application/json"}

ARCHIVE_DIR = Path("archive")
REVIEW_DIR = Path("review")

TERMINAL = {"success", "failed", "aborted"}
RETRYABLE = {429, 500, 502, 503, 504}


def submit_conversion(source_url, output_name=None):
    payload = {"url": source_url}
    if output_name:
        payload["name"] = output_name

    response = requests.post(
        f"{BASE_URL}/pdf/convert/to/pdfa",
        json=payload,
        headers=HEADERS,
        timeout=30,
    )
    response.raise_for_status()
    data = response.json()

    if data.get("error"):
        raise RuntimeError(data.get("message", "Submission was rejected"))

    return data["jobId"]


def wait_for_result(job_id, interval=3, timeout=300):
    deadline = time.time() + timeout

    while time.time() < deadline:
        response = requests.post(
            f"{BASE_URL}/job/check",
            json={"jobid": job_id},
            headers=HEADERS,
            timeout=30,
        )

        if response.status_code in RETRYABLE:
            time.sleep(interval)
            continue

        response.raise_for_status()
        result = response.json()

        if result.get("status") in TERMINAL:
            return result

        time.sleep(interval)

    raise TimeoutError(f"Job {job_id} did not finish within {timeout}s")


def download(url, destination):
    destination.parent.mkdir(parents=True, exist_ok=True)
    with requests.get(url, stream=True, timeout=120) as response:
        response.raise_for_status()
        with open(destination, "wb") as handle:
            for chunk in response.iter_content(chunk_size=8192):
                handle.write(chunk)
    return destination


def report_warnings(result):
    for warning in result.get("warnings", []):
        detail = ""
        if "count" in warning:
            detail = f" ({warning['count']})"
        elif "inputCount" in warning:
            detail = f" ({warning['inputCount']} → {warning['outputCount']})"
        print(f"  {warning['code']}{detail}: {warning['message']}")


def archive(source_url):
    job_id = submit_conversion(source_url)
    print(f"Job {job_id} submitted")

    result = wait_for_result(job_id)

    if result.get("status") != "success":
        print(f"FAILED: {result.get('message')}")
        print("The processing charge is refunded once.")
        return False

    compliant = result.get("isCompliant", False)
    target = ARCHIVE_DIR if compliant else REVIEW_DIR
    name = result.get("name", "output.pdf")

    download(result["url"], target / name)

    report_url = result.get("validationReportUrl")
    if report_url:
        report_name = result.get("validationReportName", f"{name}-validation.json")
        download(report_url, target / report_name)

    if compliant:
        print(f"COMPLIANT: PDF/A-3b, {result['pageCount']} page(s) -> {target}/")
    else:
        print(
            f"NOT COMPLIANT: {result['failedRules']} failed rule(s), "
            f"{result['failedChecks']} failed check(s) -> {target}/"
        )
        print(f"  See {result.get('validationReportName')} for the specific rules.")

    report_warnings(result)
    return compliant


if __name__ == "__main__":
    if len(sys.argv) < 2:
        sys.exit("Usage: python archive_pdfa.py <pdf-url>")
    archive(sys.argv[1])

Running it against a clean document prints something like:

Job EXAMPLEJOBID...--166-400 submitted
COMPLIANT: PDF/A-3b, 285 page(s) -> archive/
  documentModified: The PDF was rewritten for PDF/A. Change details are reported where available.
  linkFlagsCorrected (549): Link annotation flags were adjusted for PDF/A: printing was enabled and incompatible visibility flags were cleared.

To process a batch, call archive() in a loop over your source URLs — each one is an independent job.

Step 6: What gets rejected

Two kinds of input will not reach the conversion stage, so your script gets status: "failed" with no output file and no report. Both are refunded.

Digitally signed PDFs. Conversion rewrites the document, which invalidates the original signature, so the endpoint refuses rather than silently breaking it. Keep the signed original and submit an unsigned copy; you can sign the PDF/A output afterwards if your signing process keeps it compliant. Empty, unsigned signature fields are fine.

This catches more documents than you’d expect. Many government and tax forms — IRS forms among them — carry an Adobe usage-rights signature applied by the publisher so the form can be filled in and saved. The file counts as signed even though nobody signed it, so those forms are rejected too.

Password-protected PDFs. There's no password parameter on this endpoint. Unlock the file before submitting it.

A few other things change during conversion without stopping it. Form fields may be flattened into page content. JavaScript is removed, and calculation scripts aren't re-run, so the saved display is what you keep. Creation and modification dates change, and custom metadata fields can be lost.

If your archive depends on any of those, check the output rather than assuming.

Step 7: Where to go next

A few ways to extend this:

  • Swap the folders for real storage. Point the compliant path at S3 or your document management system, and the review path at a ticket or a Slack channel carrying failedRules and the report link.
  • Use a callback instead of polling. Pass a callback URL on submission and PDF.co posts the result when the job finishes, which removes the poll loop and its per-check cost.
  • Keep the reports. The validation report is the artifact an auditor will ask for. Store it next to the PDF, not just alongside the job log.

Related Tutorials

See Related Tutorials