DEV Community

Cover image for How to Batch-Fill USCIS Immigration Packets (G-28, I-129, I-140, I-485, I-765) from CSV/JSON Without Adobe Acrobat or XFA Desync
Maciej S.
Maciej S.

Posted on Fully Autonomous

How to Batch-Fill USCIS Immigration Packets (G-28, I-129, I-140, I-485, I-765) from CSV/JSON Without Adobe Acrobat or XFA Desync

If you work in immigration legal ops, corporate mobility HR, or build document automation for law firms, you already know the arithmetic of an employment-based or family-based USCIS filing:

  • A single H-1B / L-1 / O-1 nonimmigrant packet (Form G-28 + Form I-129) spans 42 pages and 1,081 interactive PDF fields (101 fields in G-28 + 980 fields in I-129).
  • A single concurrent EB-2 / EB-3 green card filing (Form G-28 + Form I-140 + Form I-485 + Form I-765) spans 43 pages and 1,283 fields (101 + 261 + 760 + 161).
  • Across a 40-employee H-1B cap season batch or a 25-family adjustment cohort, paralegals end up re-typing the same petitioner FEIN, attorney bar number, beneficiary passport number, I-94 record, and physical address tens of thousands of times.

Yet when engineering teams try to automate G-28, I-129, I-140, I-485, I-765, or N-400 from a clean CSV, Excel (.xlsx), or JSON payload using standard Python or Node.js PDF libraries, they immediately hit three structural walls baked into official uscis.gov PDFs:

  1. Owner Encryption + /Perms Usage-Rights Locks: 24 of 26 USCIS PDFs carry empty-password owner encryption (modifyDenied: true) plus an Adobe /UR3 digital signature inside /Root/Perms.
  2. Cryptic LiveCycle Subform Paths: Instead of semantic names like beneficiary_last_name, fields are nested inside XFA-mirrored trees like form1[0].#subform[0].Pt1Line1a_FamilyName[0]—and inconsistent across forms (Pt1Line1a_FamilyName[0] on one form vs. Part2_Line1_FamilyName[0] on the next).
  3. Hybrid /XFA vs. /AcroForm Desynchronization: Updating /AcroForm /Fields /V without stripping /AcroForm/XFA causes Windows Adobe Acrobat to prioritize the empty <xfa:datasets> XML stream and display every populated field 100% blank.

Below is a complete engineering breakdown of how official USCIS packet PDFs are structured, how to extract a unified cross-form schema using /TU (tooltip) metadata, and how to batch-fill multi-form USCIS packets from CSV/JSON in Python or the browser.


1. Structural Anatomy of a 6-Form USCIS Filing Packet

When we audited all 26 USCIS forms in our structural study using pypdf 6.6 and pikepdf, every core petition and application shared the same Adobe LiveCycle / AEM Forms hybrid architecture:

Form Code Official Purpose Pages Size Total Fields Field Breakdown (Tx / Btn / Ch) Hybrid /XFA Owner /Encrypt + modifyDenied
Form G-28 Notice of Entry of Appearance as Attorney 4 387 KB 101 76 Tx, 23 Btn, 2 Ch Yes Yes
Form I-129 Petition for a Nonimmigrant Worker (H-1B, L-1, O-1) 38 2,242 KB 980 623 Tx, 333 Btn, 24 Ch Yes Yes
Form I-140 Immigrant Petition for Alien Workers (EB-1/2/3) 8 513 KB 261 179 Tx, 79 Btn, 3 Ch Yes Yes
Form I-485 Application to Register Permanent Residence 24 1,175 KB 760 334 Tx, 416 Btn, 10 Ch Yes Yes
Form I-765 Application for Employment Authorization (EAD) 7 456 KB 161 119 Tx, 38 Btn, 4 Ch Yes Yes
Form N-400 Application for Naturalization 14 758 KB 440 237 Tx, 198 Btn, 5 Ch Yes Yes

Notice two things right away:

  1. Zero native /Btn radio groups (radios: 0): Even mutually exclusive "Yes / No" questions on I-485 (416 checkboxes) and I-129 (333 checkboxes) are implemented as individual checkbox widgets where each /AP /N dictionary defines a specific export key (typically /Y, /N, /1, or /Yes, paired with /Off).
  2. Every single form is both xfa: true and encrypted: true: Before your script can modify widgets or merge pages into a single filing packet, you must decrypt the empty-password owner lock and remove both /AcroForm/XFA and /Root/Perms.

2. Why One Canonical JSON Record Can't Naive-Match Across USCIS Forms

Suppose you have a canonical JSON or CSV row representing one beneficiary and one sponsoring employer/attorney:

{
  "case_id": "2026-H1B-042",
  "attorney.family_name": "Vance",
  "attorney.given_name": "Elena",
  "attorney.bar_number": "CA-294810",
  "attorney.firm_name": "Vance Global Immigration LLP",
  "employer.legal_name": "Acme Robotics Inc.",
  "employer.fein": "94-1234567",
  "beneficiary.family_name": "Kowalski",
  "beneficiary.given_name": "Jan",
  "beneficiary.alien_number": "A-219847561",
  "beneficiary.dob": "04/19/1991",
  "beneficiary.i94_number": "84920194821"
}
Enter fullscreen mode Exit fullscreen mode

If you inspect the /AcroForm /Fields trees across g-28.pdf, i-129.pdf, i-140.pdf, i-485.pdf, and i-765.pdf, the beneficiary's last name lives at a different hierarchical path on every form:

Form G-28  -> form1[0].#subform[1].Pt3Line5a_FamilyName[0]
Form I-129 -> form1[0].#subform[2].Part3_Line1a_FamilyName[0]
Form I-140 -> form1[0].#subform[1].Pt3Line1a_FamilyName[0]
Form I-485 -> form1[0].#subform[0].Pt1Line1a_FamilyName[0]
Form I-765 -> form1[0].#subform[0].Pt1Line1a_FamilyName[0]
Form N-400 -> form1[0].#subform[0].Pt2Line1_FamilyName[0]
Enter fullscreen mode Exit fullscreen mode

Fortunately, USCIS embeds detailed human-readable descriptions inside every widget's /TU (Alternate Field Name / Tooltip) entry for Section 508 accessibility—for example:

312 0 obj
<<
  /Type /Annot
  /Subtype /Widget
  /FT /Tx
  /T (Pt1Line1a_FamilyName[0])
  /TU (Part 1. Information About You. Line 1a. Family Name (Last Name))
  /MaxLen 35
  /DA (/CourierStd 10 Tf 0 g)
  /Rect [ 54.00 582.10 294.00 596.50 ]
>>
endobj
Enter fullscreen mode Exit fullscreen mode

By extracting (full_field_path, /FT, /TU, /MaxLen, /AP/N states) once per form edition, you can build a deterministic Canonical Packet Mapping (packet_map.json) that binds one canonical key (beneficiary.family_name, attorney.bar_number, employer.fein) to its exact target widget across G-28, I-129, I-140, I-485, I-765, and N-400.


3. Python Pipeline: Inspecting & Batch-Filling a Multi-Form USCIS Packet

Below is a complete, self-contained Python pipeline (pikepdf + pypdf) that:

  1. Unlocks official USCIS PDFs (G-28, I-140, I-485, I-765, I-129, N-400) by stripping owner encryption, /Root/Perms, and /AcroForm/XFA.
  2. Maps a single canonical CSV/JSON record across every form in the packet.
  3. Correctly sets both /V and /AS on USCIS checkboxes (NameObject("/Y"), NameObject("/1"), etc.) while clearing cached /AP appearance streams on modified text fields so every viewer renders the text cleanly.
#!/usr/bin/env python3
"""
uscis_packet_batch_filler.py
Batch-generate multi-form USCIS immigration packets (G-28, I-129, I-140, I-485, I-765, N-400)
from CSV or JSON while stripping hybrid /XFA streams and /Perms UR3 locks.
"""

import csv
import json
import sys
from pathlib import Path
import pikepdf
from pypdf import PdfReader, PdfWriter
from pypdf.generic import BooleanObject, NameObject, TextStringObject


def index_terminal_widgets(node_ref, prefix="", out=None):
    if out is None:
        out = {}
    node = node_ref.get_object()
    name = str(node.get("/T", ""))
    full_name = f"{prefix}.{name}" if prefix and name else (name or prefix)

    if "/Kids" in node:
        for kid_ref in node["/Kids"]:
            index_terminal_widgets(kid_ref, full_name, out)
    else:
        out[full_name] = node
        if name:
            # Also index by terminal key for convenience when unique
            out.setdefault(name, node)
    return out


def set_uscis_widget_value(widget, raw_value: str) -> None:
    ft = widget.get("/FT")
    if ft == "/Btn":
        ap_n = widget.get("/AP", {}).get("/N", {})
        on_states = [k for k in ap_n.keys() if k != "/Off"]
        truthy = str(raw_value).strip().lower() in ("true", "yes", "y", "1", "x", "checked")

        target_state = NameObject("/Off")
        if truthy and on_states:
            # If caller passed an explicit state like "/Y" or "/1", honor it; else pick first on-state
            explicit = f"/{str(raw_value).strip().lstrip('/')}"
            target_state = NameObject(explicit if explicit in on_states else on_states[0])

        widget[NameObject("/V")] = target_state
        widget[NameObject("/AS")] = target_state
    else:
        text = "" if raw_value is None else str(raw_value).strip()
        max_len = widget.get("/MaxLen")
        if isinstance(max_len, int) and max_len > 0:
            text = text[:max_len]
        widget[NameObject("/V")] = TextStringObject(text)
        # Remove cached blank appearance stream so viewer synthesizes glyphs from /V + /DA
        if NameObject("/AP") in widget:
            del widget[NameObject("/AP")]


def populate_uscis_form(template_pdf: Path, field_map: dict, record: dict, out_pdf: Path) -> int:
    # Step 1: Remove owner encryption, /Perms UR3 signature lock, and hybrid /XFA array
    unlocked_tmp = out_pdf.with_suffix(".unlocked.tmp.pdf")
    with pikepdf.open(template_pdf, password="") as pdf:
        if "/Perms" in pdf.Root:
            del pdf.Root["/Perms"]
        if "/AcroForm" in pdf.Root and "/XFA" in pdf.Root.AcroForm:
            del pdf.Root.AcroForm["/XFA"]
        pdf.save(unlocked_tmp)

    # Step 2: Populate AcroForm widgets with pypdf
    reader = PdfReader(str(unlocked_tmp))
    writer = PdfWriter()
    writer.append(reader)

    root = writer._root_object
    acroform = root[NameObject("/AcroForm")].get_object()
    acroform[NameObject("/NeedAppearances")] = BooleanObject(True)

    widgets = {}
    for ref in acroform.get("/Fields", []):
        index_terminal_widgets(ref, "", widgets)

    filled_count = 0
    for canonical_key, pdf_field_path in field_map.items():
        val = record.get(canonical_key)
        if val in (None, ""):
            continue
        if pdf_field_path not in widgets:
            raise KeyError(f"Field '{pdf_field_path}' not found in {template_pdf.name}")
        set_uscis_widget_value(widgets[pdf_field_path], val)
        filled_count += 1

    out_pdf.parent.mkdir(parents=True, exist_ok=True)
    with open(out_pdf, "wb") as f:
        writer.write(f)
    unlocked_tmp.unlink(missing_ok=True)
    return filled_count


def run_batch(packet_config_path: str, csv_path: str, out_root: str) -> None:
    """
    packet_config.json structure:
    {
      "forms": {
        "G-28": {"template": "./templates/g-28.pdf", "map": {"attorney.family_name": "Pt1Line2a_FamilyName[0]", ...}},
        "I-140": {"template": "./templates/i-140.pdf", "map": {...}},
        "I-485": {"template": "./templates/i-485.pdf", "map": {...}},
        "I-765": {"template": "./templates/i-765.pdf", "map": {...}}
      }
    }
    """
    config = json.loads(Path(packet_config_path).read_text(encoding="utf-8"))
    out_dir = Path(out_root)

    with open(csv_path, newline="", encoding="utf-8-sig") as f:
        for row_idx, record in enumerate(csv.DictReader(f), start=1):
            case_id = record.get("case_id", f"case_{row_idx:03d}")
            for form_code, form_spec in config["forms"].items():
                out_file = out_dir / case_id / f"{case_id}_{form_code}.pdf"
                n = populate_uscis_form(Path(form_spec["template"]), form_spec["map"], record, out_file)
                print(f"[{case_id}] {form_code}: populated {n} fields -> {out_file}")


if __name__ == "__main__":
    if len(sys.argv) != 4:
        print("Usage: python uscis_packet_batch_filler.py packet_config.json cases.csv ./output_packets")
        sys.exit(1)
    run_batch(sys.argv[1], sys.argv[2], sys.argv[3])
Enter fullscreen mode Exit fullscreen mode

4. Handling USCIS LiveCycle Input Masks & Comb Fields (SSN, A-Number, I-94)

When batch-filling I-129, I-140, I-485, I-765, and N-400, watch out for three field-level edge cases inside USCIS PDFs:

  1. Comb Fields (/Ff bit 25 = 16777216 + /MaxLen): Alien Registration Numbers (A-Number, 9 digits), Social Security Numbers (9 digits), USCIS Online Account Numbers (12 digits), and ZIP codes use fixed-cell comb boxes. Strip hyphens and spaces ("A-219-847-561" -> "219847561") before writing to /V, or the characters will shift one cell to the right and clip the final digit.
  2. LiveCycle JavaScript Validation Traps on Foreign Addresses & "Present" Dates: When opened inside Adobe Acrobat with XFA active, LiveCycle scripts validate US state codes and MM/DD/YYYY date masks—blocking common immigration entries like "PRESENT" in employment history or foreign postal codes. Stripping /AcroForm/XFA disables those rigid XFA script traps while preserving exact courier comb spacing in /AcroForm.
  3. macOS Preview Desynchronization: If a beneficiary edits an untouched USCIS PDF in macOS Preview, Preview writes to /AcroForm /Fields /V while leaving <xfa:datasets> empty. Running the pikepdf /XFA + /Perms strip step above (or using our XFA to Fillable PDF Unlocker) immediately restores visibility in Windows Adobe Acrobat.

5. Browser & Spreadsheet Workflows for Legal Teams and HR Ops

If your immigration practice, in-house HR mobility team, or paralegal staff wants to fill single petitions or batch-generate USCIS packets from .xlsx / .csv spreadsheets without maintaining Python scripts or paying for per-seat desktop PDF licenses:

  • Fill PDF from Excel / CSV Batch Mail Merge: Upload any USCIS or employment form alongside an Excel or CSV roster, preview 5 populated rows for free to verify comb alignment and checkbox states, and download a ZIP archive of clean, unencrypted PDFs.
  • XFA to Fillable PDF Unlocker: Convert Hybrid XFA and owner-locked government PDFs into standard ISO 32000-1 AcroForm or flattened PDFs that work identically across Chrome, Safari, macOS Preview, and Windows Adobe Acrobat.
  • Direct Online Fillers & Field Guides for Core USCIS Packet Forms:

Disclosure: I am the solo developer of JustFill.

AI disclosure: AI coding agents drafted and revised this article and verified the code examples above.

Top comments (0)