Generating FDA Form 3911 Notifications in Python

When a case inside Suspect Product Investigation Workflows closes with a determination of ILLEGITIMATE, the clock is already running: DSCSA gives you 24 hours from that determination to notify the FDA using Form FDA 3911 and to alert every affected immediate trading partner. The compliance officer needs the field set captured correctly on the first attempt; the Python engineer needs a repeatable way to turn a closed investigation record into that field set without hand-transcribing a GTIN or an NDC under time pressure. This guide is the narrow, code-first slice of the DSCSA Compliance Architecture & Standards Mapping framework that solves exactly that: a Pydantic v2 model that encodes the Form 3911 field contract, and a builder function that maps a closed investigation case onto it, producing a validated notification draft ready to hand to your filing process. Nothing in this pattern submits anything to an FDA system — the output is a checked, structured data package that a compliance officer or a downstream, separately audited submission channel actually files.

Assembling an FDA Form 3911 notification from a closed investigation case A closed, ILLEGITIMATE investigation case feeds a field-assembly step that pulls the product identifier (GTIN, serial, lot, expiry), the mapped NDC, the reporting contact, and the determination reason. The assembled fields pass through a Form3911 Pydantic validation gate; a clean result produces a notification draft ready for filing, while a failed validation routes back to manual review. A clock bar beneath shows the 24-hour statutory window from determination to notify-by deadline. valid validation error → manual review Closed case state = ILLEGITIMATE Assemble fields identifier, NDC, contact, reason Notification draft, ready to file Form3911 validation gate Pydantic v2 24-HOUR NOTIFICATION CLOCK determined_at case closed notify_by FDA + partners, ≤ 24h

Prerequisites

  • Python 3.10+ — the snippets use X | Y union syntax, list[dict] generics, and standard-library enum/datetime.
  • Pydantic v2BaseModel, field_validator, and model_validator in mode="after" for the cross-field 24-hour deadline check.
  • A closed investigation case from the suspect-product workflow, bearing GTIN (01), serial (21), lot (10), expiry (17), the ILLEGITIMATE determination timestamp, and the triggering reason — the same case shape produced by the state machine in Suspect Product Investigation Workflows.
  • An NDC-to-GTIN mapping table or service so the builder can resolve the National Drug Code the FDA form requires; see how to map GTINs to NDCs for DSCSA compliance for the leading-zero and package-indicator pitfalls that most often corrupt this lookup.
  • A current reporting-entity contact directory — company name, named contact, phone, and email for whoever the FDA and trading partners should reach about this notification.
  • Familiarity with the statutory notification window — per the FDA’s guidance on the Drug Supply Chain Security Act, a product determined illegitimate must be reported to the FDA and to affected immediate trading partners within 24 hours of that determination.

Step-by-Step Solution

Step 1 — Define the notification type and reason enums

Form 3911 distinguishes a suspect-product notification from a confirmed-illegitimate one, and every illegitimate notification must carry one of a small, closed set of statutory reasons. Modeling both as string enums keeps the value set closed and makes the builder’s output directly serializable.

from enum import Enum


class NotificationType(str, Enum):
    SUSPECT = "SUSPECT_PRODUCT"
    ILLEGITIMATE = "ILLEGITIMATE_PRODUCT"


class IllegitimacyReason(str, Enum):
    COUNTERFEIT = "COUNTERFEIT"
    DIVERTED = "DIVERTED"
    STOLEN = "STOLEN"
    INTENTIONALLY_ADULTERATED = "INTENTIONALLY_ADULTERATED"
    UNFIT_FOR_DISTRIBUTION = "UNFIT_FOR_DISTRIBUTION"
    FRAUDULENT_TRANSACTION = "FRAUDULENT_TRANSACTION"

DSCSA/GS1 note: these two enums mirror the classification the investigation workflow already produces when a case leaves the UNDER_INVESTIGATION state, so the notification-type value can be copied straight from the case’s disposition rather than re-derived.

Step 2 — Model the product identifier: GTIN (01), serial (21), lot (10), expiry (17), and NDC

The product identifier block is the part of the form most likely to be transcribed wrong under time pressure, so it gets the strictest validation: a 14-digit GTIN with a correct GS1 modulo-10 check digit, a bounded serial, and an NDC normalized to 10 or 11 digits.

from datetime import date
from pydantic import BaseModel, Field, field_validator


class ProductIdentifier(BaseModel):
    gtin: str = Field(pattern=r"^\d{14}$")            # AI (01)
    serial: str = Field(min_length=1, max_length=20)  # AI (21)
    lot: str = Field(min_length=1, max_length=20)      # AI (10)
    expiry: date                                        # AI (17), decoded from YYMMDD
    ndc: str

    @field_validator("gtin")
    @classmethod
    def _gtin_check_digit(cls, v: str) -> str:
        body, check = v[:-1], int(v[-1])
        total = sum(
            (3 if i % 2 == 0 else 1) * int(d)
            for i, d in enumerate(reversed(body))
        )
        if (10 - total % 10) % 10 != check:
            raise ValueError("GTIN (01) failed GS1 modulo-10 check digit")
        return v

    @field_validator("ndc")
    @classmethod
    def _ndc_normalized(cls, v: str) -> str:
        digits = v.replace("-", "")
        if not digits.isdigit() or len(digits) not in (10, 11):
            raise ValueError("NDC must be 10 or 11 digits, hyphens optional")
        return v

DSCSA/GS1 note: GTIN (01), serial (21), lot (10), and expiry (17) are the minimum saleable-unit identifier set DSCSA requires for traceability; the NDC is not a GS1 Application Identifier at all, but the FDA form requires it, so the builder must resolve it from master data rather than trusting a value copied off the label.

Step 3 — Model the reporting contact and the Form3911 envelope

The form also needs a named reporting contact and a narrative describing what happened, plus the two timestamps that anchor the statutory clock: when the product was determined illegitimate, and the deadline by which the FDA and trading partners must be notified. A model_validator enforces that the deadline itself never exceeds 24 hours, so a bad default can never silently widen the window.

from datetime import datetime
from pydantic import BaseModel, Field, model_validator


class ReportingContact(BaseModel):
    company_name: str = Field(min_length=1)
    contact_name: str = Field(min_length=1)
    phone: str = Field(pattern=r"^\+?[0-9\-\s()]{7,20}$")
    email: str = Field(pattern=r"^[^@\s]+@[^@\s]+\.[^@\s]+$")


class Form3911(BaseModel):
    notification_type: NotificationType
    reason: IllegitimacyReason
    product: ProductIdentifier
    quantity: int = Field(gt=0)
    reporting_entity: ReportingContact
    determined_at: datetime
    notify_by: datetime
    narrative: str = Field(min_length=20, max_length=4000)

    @model_validator(mode="after")
    def _deadline_within_24h(self) -> "Form3911":
        if self.notification_type is NotificationType.ILLEGITIMATE:
            window = self.notify_by - self.determined_at
            if window.total_seconds() > 24 * 3600:
                raise ValueError(
                    "notify_by exceeds the 24-hour statutory window "
                    "for an illegitimate-product determination"
                )
        return self

DSCSA/GS1 note: encoding the 24-hour bound as a structural validator, not a downstream cron check, means an invalid Form3911 instance simply cannot exist — the same “reject at the boundary” discipline used for GTIN validation everywhere else on this site.

Step 4 — Build a Form3911 from a closed investigation case

The builder is the piece that actually removes hand-transcription: it takes the closed case, an NDC lookup keyed by GTIN, and a reporting contact, and returns a validated Form3911. The notify_by field is computed, never entered by hand.

from datetime import timedelta


def build_form_3911(
    case: dict,
    ndc_lookup: dict[str, str],
    reporting_entity: ReportingContact,
    reason: IllegitimacyReason,
    narrative: str,
) -> Form3911:
    """Assemble a validated Form3911 from a closed, ILLEGITIMATE case."""
    gtin = case["gtin"]
    ndc = ndc_lookup.get(gtin)
    if ndc is None:
        raise KeyError(f"no NDC mapping on file for GTIN {gtin}")

    determined_at: datetime = case["illegitimate_determined_at"]  # tz-aware UTC
    product = ProductIdentifier(
        gtin=gtin,
        serial=case["serial"],
        lot=case["lot"],
        expiry=case["expiry"],
        ndc=ndc,
    )
    return Form3911(
        notification_type=NotificationType.ILLEGITIMATE,
        reason=reason,
        product=product,
        quantity=case.get("quantity", 1),
        reporting_entity=reporting_entity,
        determined_at=determined_at,
        notify_by=determined_at + timedelta(hours=24),
        narrative=narrative,
    )

DSCSA/GS1 note: notify_by is derived arithmetically from determined_at rather than left to a caller’s discretion, so the notification draft’s own deadline field always matches the moment the investigation workflow’s state machine transitioned the case to ILLEGITIMATE.

Step 5 — Route assembly failures without losing the clock

A missing NDC mapping or a malformed identifier must not silently stall the notification — it has to fail loudly and land on someone’s desk immediately, because the 24-hour window keeps running whether or not the draft was built. Wrap the builder so a failure produces a structured, timestamped alert instead of a swallowed exception.

from datetime import datetime, timezone
from pydantic import ValidationError


def build_or_escalate(case: dict, ndc_lookup: dict[str, str],
                       reporting_entity: ReportingContact,
                       reason: IllegitimacyReason,
                       narrative: str) -> Form3911 | None:
    try:
        return build_form_3911(
            case, ndc_lookup, reporting_entity, reason, narrative
        )
    except (KeyError, ValidationError) as exc:
        review_queue.put({
            "case_id": case["case_id"],
            "error": str(exc),
            "determined_at": case["illegitimate_determined_at"].isoformat(),
            "escalated_at": datetime.now(timezone.utc).isoformat(),
        })
        return None

DSCSA/GS1 note: this mirrors the dead-letter discipline used across automating DSCSA compliance gap checks with Python — a data-quality defect escalates to a human immediately rather than being retried silently against a hard regulatory deadline.

Step 6 — Serialize the validated draft for filing

Once a Form3911 instance exists, it validates every constraint the class defines. Serializing it to a plain dict hands a compliance officer, a PDF-fill tool, or a portal-entry step exactly the field set the form requires — this is the last stop in this pipeline; nothing here calls the FDA.

def to_notification_payload(form: Form3911) -> dict:
    """Serialize a validated Form3911 into the field set a compliance
    officer, PDF-fill tool, or a separately audited submission channel
    consumes. This function makes no network call and files nothing —
    it only produces the checked data package."""
    return form.model_dump(mode="json")

Verification

Confirm the model and builder behave correctly before wiring them into a live investigation pipeline. A table-driven pytest suite covering the GTIN check digit, the NDC shape, and the 24-hour deadline gives the fastest signal:

import pytest
from datetime import date, datetime, timedelta, timezone
from pydantic import ValidationError


def test_gtin_check_digit_rejects_bad_digit():
    with pytest.raises(ValidationError):
        ProductIdentifier(
            gtin="00312345678900", serial="S1", lot="L1",
            expiry=date(2027, 12, 31), ndc="0312345678",
        )


def test_notify_by_beyond_24h_rejected():
    now = datetime.now(timezone.utc)
    with pytest.raises(ValidationError):
        Form3911(
            notification_type=NotificationType.ILLEGITIMATE,
            reason=IllegitimacyReason.COUNTERFEIT,
            product=ProductIdentifier(
                gtin="00312345678906", serial="S1", lot="L1",
                expiry=date(2027, 12, 31), ndc="0312345678",
            ),
            quantity=1,
            reporting_entity=ReportingContact(
                company_name="Acme Pharma", contact_name="J. Rivera",
                phone="+1-555-010-2000", email="compliance@acme.example",
            ),
            determined_at=now,
            notify_by=now + timedelta(hours=30),  # violates the 24h window
            narrative="Confirmed counterfeit identified during investigation.",
        )


def test_builder_computes_notify_by():
    now = datetime.now(timezone.utc)
    case = {
        "case_id": "INV-00312345678906-S1", "gtin": "00312345678906",
        "serial": "S1", "lot": "L1", "expiry": date(2027, 12, 31),
        "illegitimate_determined_at": now, "quantity": 24,
    }
    form = build_form_3911(
        case, {"00312345678906": "0312345678"},
        ReportingContact(company_name="Acme Pharma", contact_name="J. Rivera",
                         phone="+1-555-010-2000", email="compliance@acme.example"),
        IllegitimacyReason.COUNTERFEIT,
        "Confirmed counterfeit identified during investigation.",
    )
    assert form.notify_by == now + timedelta(hours=24)
    assert form.product.ndc == "0312345678"

Beyond unit tests, reconcile output against the underlying case record: every Form3911 produced should trace back to exactly one closed investigation case ID, and every case that reached ILLEGITIMATE should produce exactly one draft or one entry in the escalation queue — never neither. If your investigation workflow keeps an audit log, cross-check the log’s ILLEGITIMATE_REPORTED entries against the notifications your pipeline actually built to confirm nothing was dropped between determination and draft.

Gotchas & Edge Cases

  • NDC segment and leading zeros. The NDC embeds labeler, product, and package segments in one of several 4-4-2, 5-3-2, or 5-4-1 digit patterns depending on the source format. Never cast it to int; treat it as an opaque string the way you would a GTIN, and normalize hyphens only for length checking, not for storage.
  • Expiry (17) as YYMMDD. The GS1 expiration date sometimes encodes day 00 to mean “end of month.” If your investigation case still carries the raw YYMMDD string rather than a decoded date, resolve that ambiguity before it reaches ProductIdentifier, or the parsed expiry silently rounds to the wrong day.
  • Naive determined_at timestamps. If the investigation workflow ever hands the builder a timezone-naive datetime, notify_by will still compute an offset, but comparisons against a real UTC “now” downstream will drift by however many hours local time differs from UTC — effectively shortening or lengthening the statutory window without raising an error.
  • Idempotency on retried case processing. Message redelivery or a retried orchestration step can call build_form_3911 twice for the same case. Key any queued or filed notification on case_id so a redelivery updates the same draft rather than producing a duplicate FDA notification for one incident.
  • Quantity is not “one row per serial.” A single case can represent many affected units under one lot; quantity captures that count. Do not conflate it with the serial count of every unit ever commissioned against that GTIN — only the units implicated in this specific investigation belong in the notification.