Trading Partner Verification & Onboarding
Onboarding a new trading partner is the handshake that makes DSCSA interoperability real, and this topic is part of DSCSA Compliance Architecture & Standards Mapping — it governs everything that must be true before you exchange a single production verification message with a manufacturer, wholesaler, repackager, or dispenser. Under the Enhanced Drug Distribution Security (EDDS) requirements now in force, trading information may only flow between authorized trading partners, and that exchange must be secure, interoperable, and electronic at the package level. Onboarding is where “authorized” and “interoperable” stop being adjectives and become a checklist: prove the partner’s identity, agree on how EPCIS documents will move between you, exchange and pin cryptographic credentials, wire up the Verification Router Service (VRS), confirm authorized-trading-partner (ATP) status, and run an end-to-end integration test before anyone flips the go-live switch. This section is written for compliance officers who must trace every onboarding gate back to a regulatory driver, and for the Python engineers who have to make the gates executable.
Architecture
Onboarding is a state machine, not a form. A partner advances through a fixed sequence of gates, and any gate can bounce them back into remediation without exposing your production endpoints. Modeling it this way — rather than as an email thread and a shared spreadsheet — is what lets you prove, months later and under inspection, exactly when and how a partner became authorized to trade. The diagram below is the reference flow the rest of this section implements.
Figure — Trading partner onboarding sequence, from identity to production go-live.
Three properties make this flow defensible. First, every gate is a hard predicate: a partner either satisfies it or drops into remediation, and there is no side channel that lets a half-onboarded partner reach a production endpoint. Second, the ATP status check is decoupled from the physical connectivity work, so a partner can be perfectly wired up over mTLS and still be blocked at go-live because their license lapsed. Third, the integration test is mandatory and runs against a sandbox that mirrors production message contracts, so the first real verification exchange is never also the first exchange you have ever run with that partner. The identity, capability, credential, and connectivity gates map one-to-one onto the guides and code below.
Foundational Concepts & Data Contracts
Partner identity in DSCSA is anchored to the GS1 Global Location Number (GLN), a 13-digit identifier that names a legal entity or a physical location and carries the same GS1 modulo-10 check digit as a GTIN. When a GLN is carried inside an EPCIS event as a read point or business location it appears as an SGLN URI (urn:epc:id:sgln:...), and when it travels as a barcoded location it uses Application Identifier (414). The GLN is the join key for the entire relationship: it identifies who you are talking to in the VRS directory, whose certificate you pinned, and which party attested to authorized-trading-partner status. Getting a partner’s GLN wrong — or accepting one whose check digit fails — quietly poisons every downstream lookup, which is why GLN registration earns its own guide in registering GLNs for DSCSA trading partners.
On top of identity sits the product-identifier contract you already share with every partner: the serialized DataMatrix encodes the GTIN (01), serial (21), expiration date (17) in YYMMDD, and lot (10), and together the GTIN and serial form the SGTIN a verification request resolves against. Onboarding does not change these identifiers; it establishes how the events that carry them will be exchanged. Partners commit to a connectivity profile: which EPCIS event types they will send and receive — typically ObjectEvent for commissioning and observation and TransactionEvent for change of ownership, sometimes AggregationEvent for case and pallet pedigree — which EPCIS version and serialization (1.2 XML versus 2.0 JSON-LD), which transport (AS2, HTTPS REST, or SFTP), and which endpoints carry capture versus query traffic. That negotiation is subtle enough to warrant the dedicated guide on negotiating EPCIS capability with trading partners, because a mismatch here surfaces as silently dropped events rather than as an error.
The last contract is trust. Interoperable electronic exchange under EDDS assumes mutual authentication, so partners exchange X.509 certificates and pin them, and the VRS layer relies on the GS1 Lightweight Messaging Standard to route verification requests to the responsible party by GTIN. ATP status is the regulatory predicate laid over all of it: a partner must hold the appropriate license or registration for their role, and you must record that you confirmed it. The data contracts in this section — a TradingPartner identity record, a ConnectivityProfile, a pinned certificate, and an append-only onboarding audit entry — are the typed shapes that make each of those gates checkable in code.
Step-by-Step Implementation
Each step below is a gate from the diagram, expressed as runnable Python. The models use Pydantic v2 so that a malformed partner record is rejected structurally at the boundary rather than by scattered conditionals deep in the flow.
Step 1 — Register and verify the partner’s GLN identity
The first gate proves the partner is who they claim to be and that their GLN is syntactically valid before it is used as a key anywhere else. This satisfies the DSCSA requirement that trading information only pass between identifiable authorized partners, and it enforces the GS1 GLN check-digit rule.
from pydantic import BaseModel, field_validator
def gs1_check_digit_ok(code: str) -> bool:
"""GS1 modulo-10 check shared by GTIN, GLN, and SSCC bodies."""
body, check = code[:-1], int(code[-1])
total = sum(
int(d) * (3 if i % 2 == 0 else 1)
for i, d in enumerate(reversed(body))
)
return (10 - total % 10) % 10 == check
class TradingPartner(BaseModel):
gln: str # GS1 Global Location Number — AI (414)
legal_name: str
role: str # manufacturer | wholesaler | dispenser | repackager
state_license: str # board-of-pharmacy or equivalent licence id
atp_attested: bool # partner asserts authorized-trading-partner status
@field_validator("gln")
@classmethod
def _gln(cls, v: str) -> str:
if len(v) != 13 or not v.isdigit():
raise ValueError("GLN must be 13 numeric digits (AI 414)")
if not gs1_check_digit_ok(v):
raise ValueError("GLN check digit failed GS1 modulo-10")
return v
@field_validator("role")
@classmethod
def _role(cls, v: str) -> str:
allowed = {"manufacturer", "wholesaler", "dispenser", "repackager"}
if v not in allowed:
raise ValueError(f"role must be one of {sorted(allowed)}")
return v
@field_validator("atp_attested")
@classmethod
def _atp(cls, v: bool) -> bool:
if not v:
raise ValueError("cannot onboard a partner without ATP attestation")
return v
DSCSA/GS1 note: rejecting a partner record whose atp_attested flag is false at the model boundary makes the authorized-trading-partner requirement a structural invariant of onboarding, not a manual reviewer’s memory. The GLN registration guide covers resolving a GLN against the GS1 registry and handling sub-location GLNs.
Step 2 — Negotiate EPCIS capability and connectivity
The second gate agrees on the wire contract. Both sides publish a connectivity profile, and you compute the highest EPCIS version both can speak — falling back gracefully rather than assuming everyone is on 2.0. This satisfies the interoperable-electronic-exchange mandate: events only flow if both parties agree on version, format, and transport.
from enum import Enum
from pydantic import BaseModel, field_validator
class EpcisVersion(str, Enum):
V1_2 = "1.2"
V2_0 = "2.0"
class ConnectivityProfile(BaseModel):
epcis_versions: list[EpcisVersion] # versions the partner can emit/consume
transport: str # AS2 | HTTPS-REST | SFTP
message_format: str # XML | JSON-LD
capture_endpoint: str # where they POST EPCIS documents
query_endpoint: str # where you send EPCIS queries
@field_validator("epcis_versions")
@classmethod
def _non_empty(cls, v: list[EpcisVersion]) -> list[EpcisVersion]:
if not v:
raise ValueError("partner must support at least one EPCIS version")
return v
def negotiate_epcis_version(
ours: ConnectivityProfile, theirs: ConnectivityProfile
) -> EpcisVersion:
"""Pick the highest EPCIS version both partners can speak."""
common = set(ours.epcis_versions) & set(theirs.epcis_versions)
if not common:
raise ValueError("no shared EPCIS version — capability negotiation failed")
return max(common, key=lambda ver: ver.value)
DSCSA/GS1 note: a partner that can only emit EPCIS 1.2 XML while you assume 2.0 JSON-LD is not a partner you can trade with until one side adapts; surfacing that at negotiation time — instead of discovering it in a failed capture — is the whole point. The EPCIS capability negotiation guide walks through exchanging and validating the full capability document, and GS1 Standards Implementation covers the event formatting each version implies.
Step 3 — Exchange and pin credentials
The third gate establishes mutual trust. The partner sends their X.509 certificate; you confirm it is currently valid and that it actually binds the GLN you registered in Step 1, then pin it. This satisfies the confidentiality and integrity expectations of EDDS and dovetails with the controls in Data Security & Encryption Boundaries.
from datetime import datetime, timezone
from cryptography import x509
from cryptography.x509.oid import ExtensionOID
def verify_partner_certificate(pem: bytes, expected_gln: str) -> x509.Certificate:
"""Validate a partner's mTLS certificate before trusting their endpoint."""
cert = x509.load_pem_x509_certificate(pem)
now = datetime.now(timezone.utc)
if now < cert.not_valid_before_utc or now > cert.not_valid_after_utc:
raise ValueError("partner certificate is expired or not yet valid")
san = cert.extensions.get_extension_for_oid(
ExtensionOID.SUBJECT_ALTERNATIVE_NAME
).value
uris = san.get_values_for_type(x509.UniformResourceIdentifier)
if not any(expected_gln in uri for uri in uris):
raise ValueError(f"certificate SAN does not bind GLN {expected_gln}")
return cert
DSCSA/GS1 note: binding the certificate to the partner’s GLN is what stops a valid-but-unrelated certificate from standing in for the party you actually authorized. Record the certificate’s SHA-256 fingerprint in the audit log (Step in the audit section) so a later rotation is provable rather than silent.
Step 4 — Establish the VRS connection and check ATP status
The fourth gate wires up the Verification Router Service over the pinned mTLS credentials and performs the authorized-trading-partner status check against the VRS/ATP directory. This is the connectivity backbone the sibling Verification Router Service Architecture topic details; here it confirms the partner is present, active, and authorized before any product-level verification is attempted.
import httpx
async def check_atp_status(
client: httpx.AsyncClient, vrs_base: str, gln: str
) -> bool:
"""Look up a partner in the VRS/ATP directory via GS1 Lightweight Messaging."""
resp = await client.get(f"{vrs_base}/atp/lookup", params={"gln": gln})
resp.raise_for_status()
record = resp.json()
# An authorized partner must be present, active, and unexpired.
return (
record.get("gln") == gln
and record.get("status") == "active"
and record.get("authorized") is True
)
def build_vrs_client(cert_path: str, key_path: str, ca_path: str) -> httpx.AsyncClient:
"""mTLS-pinned client so every VRS call is mutually authenticated."""
return httpx.AsyncClient(
cert=(cert_path, key_path),
verify=ca_path,
timeout=httpx.Timeout(5.0, connect=2.0),
limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
)
DSCSA/GS1 note: the GS1 Lightweight Messaging Standard routes verification requests to the responsible party by GTIN, so the ATP lookup and the later product verification share one mutually-authenticated channel. Bounding the connect and read timeouts keeps a slow directory from stalling the onboarding queue.
Step 5 — Run the integration test before go-live
The final gate is a mandatory sandbox round-trip that mirrors production message contracts. Only after a partner resolves as authorized and returns a well-formed verification response does the flow permit go-live. The full test harness, including negative cases and response-shape assertions, lives in VRS integration testing with Python.
import pytest
@pytest.mark.asyncio
async def test_partner_go_live_readiness(sandbox_client, partner):
"""Gate a partner to production only after a full round-trip in sandbox."""
# 1. ATP directory resolves the partner as authorized.
assert await check_atp_status(sandbox_client, partner.vrs_base, partner.gln)
# 2. A signed verification request returns a well-formed response.
resp = await sandbox_client.post(
f"{partner.vrs_base}/verify",
json={
"gtin": "00312345678906",
"serial": "SN-TEST-001",
"lot": "LOT-QA",
"expiry": "271231",
},
)
body = resp.json()
assert resp.status_code == 200
assert body["verified"] in (True, False) # a decision, not an error
assert body["responder_gln"] == partner.gln
DSCSA/GS1 note: asserting that verified is an explicit boolean — rather than merely a 200 status — catches partners whose endpoint answers but cannot actually resolve an SGTIN, the most common reason a “connected” partner still fails their first real verification.
Validation & Error Handling
Onboarding fails in two fundamentally different ways, and conflating them is the classic mistake. A partner whose GLN check digit is wrong, whose certificate does not bind their GLN, or who cannot attest ATP status has a blocking defect: retrying will never help, and the partner must be routed back to remediation with a specific, partner-facing reason. A partner whose VRS directory call times out or whose sandbox endpoint refuses a connection has a transient fault: the underlying relationship may be fine, and the step should be retried with backoff. The classifier below draws that line so the onboarding queue never burns retries on a defect it cannot fix, and never permanently fails a partner over a five-second network blip.
from dataclasses import dataclass
import httpx
@dataclass
class OnboardingResult:
gln: str
stage: str
ok: bool
detail: str
def classify_onboarding_error(exc: Exception, stage: str, gln: str) -> OnboardingResult:
"""Split blocking defects from retryable connectivity faults."""
transient = (httpx.ConnectError, httpx.ReadTimeout, httpx.PoolTimeout)
if isinstance(exc, transient):
return OnboardingResult(gln, stage, False, f"retryable: {exc!r}")
# A ValueError from our validators is a hard, partner-facing defect.
return OnboardingResult(gln, stage, False, f"blocking: {exc}")
Every blocking result must carry enough context for the partner’s technical contact to act — “certificate SAN does not bind GLN 0312345000009” is actionable; “onboarding failed” is not. Blocking defects are written to an onboarding exception log keyed by GLN and stage, exactly as malformed events are routed to a dead-letter queue in the ingestion pipeline, so nothing is silently dropped. The discipline mirrors the Schema Validation & Error Handling patterns on the ingestion side: validate against a typed contract, quarantine on failure with a structured reason, and preserve the partner’s submitted artifact so the fix is verifiable. A partner is never advanced past a gate that raised; the state machine holds them until the specific defect clears.
Performance & Scalability
A single onboarding is cheap; onboarding at scale is where design choices show. When a large distributor brings on hundreds of dispensers in a wave, or when you re-verify the entire partner roster after a quarterly license cycle, the ATP directory lookups must run concurrently without stampeding the VRS. A bounded-concurrency prescreen — a semaphore over an asyncio.gather — keeps throughput high while capping in-flight requests to a number the directory can serve, and it degrades gracefully by marking unreachable partners as not-yet-authorized rather than crashing the batch.
import asyncio
import httpx
async def prescreen_partners(
client: httpx.AsyncClient,
vrs_base: str,
glns: list[str],
concurrency: int = 8,
) -> dict[str, bool]:
"""ATP-prescreen many partners without stampeding the VRS directory."""
sem = asyncio.Semaphore(concurrency)
async def one(gln: str) -> tuple[str, bool]:
async with sem:
try:
return gln, await check_atp_status(client, vrs_base, gln)
except httpx.HTTPError:
return gln, False
pairs = await asyncio.gather(*(one(g) for g in glns))
return dict(pairs)
Three tuning levers matter beyond raw concurrency. Reuse one mutually-authenticated httpx.AsyncClient with keep-alive across all lookups so you pay the TLS handshake once per partner host, not once per request. Cache ATP directory responses with a short time-to-live — long enough to collapse a burst of re-checks during an onboarding wave, short enough that a partner whose authorization was revoked is not treated as active for long; the same caching discipline used for product verification carries over from the VRS topic. Finally, batch certificate-expiry scans on a schedule rather than checking on every call, so credential rotation is proactive: a partner whose certificate expires in seven days is queued for re-exchange before their channel breaks, not after.
Audit & Compliance Checkpoints
Onboarding is a regulated event, and under 21 CFR Part 11 the record of how a partner became authorized must be as durable as the transaction records that follow. Every gate transition — GLN registered and resolved, capability profile agreed, certificate pinned, ATP status confirmed, integration test passed, go-live approved — is written to an append-only log with a UTC timestamp, the acting identity, and a hash chaining it to the prior entry, so any attempt to backdate or alter an authorization decision is detectable. The certificate fingerprint is captured at pinning time so a later rotation is a new, provable entry rather than a silent substitution.
import hashlib
import json
from datetime import datetime, timezone
def onboarding_audit_entry(
partner_gln: str, event: str, cert_fingerprint: str, prev_hash: str
) -> dict:
"""Append-only onboarding audit record for 21 CFR Part 11 retention."""
record = {
"partner_gln": partner_gln,
"event": event, # e.g. atp_verified | cert_pinned
"cert_sha256": cert_fingerprint,
"actor": "onboarding-service",
"recorded_at": datetime.now(timezone.utc).isoformat(),
"prev_hash": prev_hash,
}
payload = json.dumps(record, sort_keys=True).encode()
record["entry_hash"] = hashlib.sha256(payload).hexdigest()
return record
These records fall under the same six-year DSCSA retention obligation as transaction information, and they must be reproducible on demand: when an inspector or a partner asks “on what date, and on what basis, did you treat this GLN as an authorized trading partner?”, the answer is a single query returning the attestation, the license reference, the pinned certificate fingerprint, and the integration-test result — not a reconstructed email thread. The full control set for immutable logging, encryption at rest, and key management is covered in Data Security & Encryption Boundaries; onboarding simply feeds it the events that prove authorization was earned, not assumed.
Troubleshooting
| Failure mode | Likely symptom | Remediation |
|---|---|---|
| GLN check digit fails | TradingPartner rejects the record at registration |
Confirm the 13-digit GLN with the partner; recompute the GS1 modulo-10 check; watch for a stripped leading zero |
| No shared EPCIS version | negotiate_epcis_version raises; captures silently empty |
Re-exchange capability documents; agree a common version or add a translation shim before go-live |
| Certificate does not bind GLN | verify_partner_certificate raises on SAN mismatch |
Have the partner reissue a certificate whose SAN URI carries the registered GLN, then re-pin |
| Certificate expired or not yet valid | mTLS handshake fails; VRS calls 495/525 | Trigger the scheduled expiry scan earlier; queue a credential re-exchange before the not-after date |
| ATP lookup returns inactive | check_atp_status returns false despite connectivity |
Hold the partner at the ATP gate; request current license/registration; do not advance to go-live |
| VRS lookup times out | httpx.ReadTimeout / PoolTimeout during prescreen |
Classify as transient; retry with backoff; lower concurrency if the directory is rate-limiting |
Sandbox verify answers but verified is absent |
Integration test asserts on response shape and fails | Partner endpoint cannot resolve the SGTIN; fix their query binding before production |
| Go-live approved but first live verify fails | Production contract differs from sandbox | Ensure the sandbox mirrors production message format and endpoints; re-run the integration test |
Frequently Asked Questions
What makes a trading partner “authorized” under DSCSA?
An authorized trading partner is one that holds the appropriate federal or state authorization for its role — a registered manufacturer or repackager, a licensed wholesale distributor, or a licensed dispenser. Under Enhanced Drug Distribution Security, product and its associated transaction information may only be exchanged between authorized trading partners, so onboarding must both capture the partner’s attestation and record your confirmation of their status before any production exchange. See the FDA DSCSA guidance for the statutory definitions.
Why is the GLN, not the company name, the identity anchor?
Company names are ambiguous, change with mergers, and are not machine-resolvable, whereas the GLN is a globally unique, check-digit-protected GS1 identifier that appears directly in EPCIS read points and business locations and in the VRS directory. Anchoring identity to the GLN means the same key ties together the partner’s attestation, pinned certificate, capability profile, and every verification message, which is what makes the relationship auditable end to end.
How does EPCIS capability negotiation actually work during onboarding?
Each side publishes a connectivity profile listing the EPCIS versions, message formats, transports, and endpoints it supports. You compute the highest EPCIS version both partners share — commonly falling back from 2.0 JSON-LD to 1.2 XML when a partner has not migrated — and agree on the capture and query endpoints. If there is no shared version, negotiation fails loudly at onboarding rather than as dropped events in production. The capability negotiation guide covers the full document exchange.
Do I need mutual TLS, or is a shared API key enough?
Interoperable electronic exchange under EDDS assumes both parties are cryptographically authenticated, so mutual TLS with pinned X.509 certificates is the norm: your service authenticates the partner and the partner authenticates you on the same channel used for VRS verification. A shared API key authenticates only one direction and cannot be bound to the partner’s GLN, so it fails the non-repudiation and integrity expectations that onboarding is meant to satisfy.
What must pass before a partner can go live?
A partner may go live only after all onboarding gates clear: the GLN is registered and resolves, an EPCIS capability profile is agreed, a valid certificate is pinned to the GLN, the ATP directory confirms active authorized status, and a sandbox integration test returns a well-formed verification response. The integration test is mandatory — the first real production verification must never also be the first exchange you have run with that partner.
Related
- DSCSA Compliance Architecture & Standards Mapping — the parent section this onboarding topic sits within.
- Verification Router Service Architecture — the sibling topic covering routing, rate limiting, and non-repudiation for the VRS channel onboarding wires up.
- Registering GLNs for DSCSA Trading Partners — resolving and validating partner GLNs, the identity gate.
- Negotiating EPCIS Capability with Trading Partners — exchanging and validating the connectivity profile.
- VRS Integration Testing with Python — the sandbox round-trip harness that gates go-live.