EPA e-Manifest Integration for Hazardous Waste Routing
File electronic RCRA hazardous-waste manifests (Form 8700-22) from routing outputs and reconcile declared tonnage.
The moment a waste route touches regulated hazardous material, a paper trail becomes a legal obligation, and the moment that trail is paper it stops reconciling. A hand-filled manifest that rides in the cab is transcribed at the transfer station, keyed into a spreadsheet a week later, and by the time the declared tonnage is compared against the weighbridge ticket the two numbers disagree and nobody can say why. This is the sub-system that closes that gap: integrating with EPA’s electronic manifest system so the manifest is generated directly from routing output, transmitted as EPA Form 8700-22, and answered with a Manifest Tracking Number (MTN) the receiving facility can certify against. What breaks without it is exactly the reconciliation the whole compliance layer depends on — regulated tonnage that will not tie back to the scale, and a designated facility that cannot confirm what it received.
This is the electronic-manifest sub-system of the Compliance Reporting & Automation layer. It shows how to model a manifest as a typed record, how to submit it to the e-Manifest REST API with retry and idempotency so a flaky network never double-files a shipment, how each field maps to the regulation that authorizes it, and how to verify the MTN came back well-formed. The step-by-step submission client, including certification and correction flows, is built out in submitting RCRA hazardous-waste manifests in Python. Every code block is complete and pasteable into a Python 3.10+ compliance worker.
Prerequisites
Pin the HTTP and validation stack so a manifest submission is reproducible during an audit. These are the versions the code below is tested against:
python --version # 3.10 or newer
pip install \
requests==2.32.3 \
pydantic==2.9.2
A manifest is only as trustworthy as the record it is built from, so every field is validated by a typed schema before anything is transmitted. The Manifest model below carries the generator’s EPA ID, the transporter, the designated treatment-storage-disposal facility (TSDF), the waste-stream codes, the quantity with its unit, and the container types — the same fields EPA Form 8700-22 requires. Reject an under-specified manifest at construction rather than discovering the gap in a 4xx rejection after the shipment has already left.
from __future__ import annotations
from enum import Enum
from pydantic import BaseModel, Field, field_validator
class ContainerType(str, Enum):
"""EPA container type codes (Form 8700-22, Item 10)."""
DRUM_METAL = "DM"
DRUM_PLASTIC = "DP"
CUBIC_YARD_BOX = "CY"
TANKER = "TT"
ROLL_OFF = "RO"
class UnitOfMeasure(str, Enum):
"""EPA quantity units (Form 8700-22, Item 12)."""
KILOGRAMS = "K"
POUNDS = "P"
TONS = "T"
LITERS = "L"
class WasteLine(BaseModel):
"""One regulated waste stream on the manifest (Items 9b-13)."""
dot_description: str = Field(min_length=1)
waste_codes: list[str] = Field(min_length=1) # RCRA codes, e.g. ["D001", "F003"]
quantity: int = Field(gt=0)
unit: UnitOfMeasure
container_count: int = Field(gt=0)
container_type: ContainerType
@field_validator("waste_codes")
@classmethod
def codes_are_uppercase(cls, v: list[str]) -> list[str]:
for code in v:
if not (len(code) == 4 and code[0].isalpha() and code[1:].isdigit()):
raise ValueError(f"malformed RCRA waste code: {code!r}")
return [c.upper() for c in v]
class Manifest(BaseModel):
generator_epa_id: str = Field(pattern=r"^[A-Z]{3}\d{9}$") # e.g. OHD000000000
transporter_epa_id: str = Field(pattern=r"^[A-Z]{3}\d{9}$")
designated_tsdf_epa_id: str = Field(pattern=r"^[A-Z]{3}\d{9}$")
waste_lines: list[WasteLine] = Field(min_length=1)
@field_validator("waste_lines")
@classmethod
def reject_duplicate_streams(cls, v: list[WasteLine]) -> list[WasteLine]:
seen = set()
for line in v:
key = (line.dot_description, tuple(line.waste_codes))
if key in seen:
raise ValueError("duplicate waste stream on a single manifest")
seen.add(key)
return v
The EPA ID pattern — three letters followed by nine digits — is not cosmetic validation; a manifest submitted with a malformed or wrong generator ID cannot reconcile at the TSDF, so catching it in the model is far cheaper than catching it in a rejection.
Core Implementation
Filing a manifest is three moves: build the transmittable payload from the validated model, POST it to the e-Manifest REST API with retry and idempotency, and parse the returned MTN out of the response. The client below writes against a mock base URL so it is runnable in a test harness, but the request shape, the idempotency header, and the retry policy are exactly what the production client needs.
The declared quantity is the field most likely to cause a downstream reconciliation failure, so it is derived from the router’s payload accounting rather than typed by hand. The tonnage the truck actually carried comes from the capacity and weight-limits dimension, and the manifest declares the scale-reconciled figure so the number EPA sees matches the number on the weighbridge ticket.
import hashlib
import json
import logging
import time
from typing import Any, Optional
import requests
logger = logging.getLogger("emanifest")
logger.setLevel(logging.INFO)
EMANIFEST_BASE = "https://mock-emanifest.local/api/v1" # swap for the RCRAInfo base
def build_payload(manifest: Manifest) -> dict[str, Any]:
"""Serialize the validated model into the e-Manifest submission shape."""
return {
"generatorId": manifest.generator_epa_id,
"transporters": [{"epaId": manifest.transporter_epa_id, "order": 1}],
"designatedFacilityId": manifest.designated_tsdf_epa_id,
"wastes": [
{
"dotDescription": line.dot_description,
"wasteCodes": line.waste_codes,
"quantity": {"value": line.quantity, "unit": line.unit.value},
"containers": {"count": line.container_count,
"type": line.container_type.value},
}
for line in manifest.waste_lines
],
}
def idempotency_key(manifest: Manifest) -> str:
"""Stable key over manifest content — a retry carries the same key,
so the endpoint never files one shipment twice."""
canonical = json.dumps(build_payload(manifest), sort_keys=True,
separators=(",", ":")).encode("utf-8")
return hashlib.sha256(canonical).hexdigest()
class ManifestClient:
def __init__(self, base_url: str = EMANIFEST_BASE, token: str = "",
max_retries: int = 4):
self._base = base_url.rstrip("/")
self._session = requests.Session()
self._session.headers.update({"Authorization": f"Bearer {token}",
"Content-Type": "application/json"})
self._max_retries = max_retries
def submit(self, manifest: Manifest) -> str:
"""Submit a manifest and return its Manifest Tracking Number (MTN)."""
payload = build_payload(manifest)
key = idempotency_key(manifest)
for attempt in range(1, self._max_retries + 1):
try:
resp = self._session.post(
f"{self._base}/manifests",
data=json.dumps(payload),
headers={"Idempotency-Key": key},
timeout=15,
)
except requests.RequestException as exc:
logger.warning("transport_error", extra={"attempt": attempt,
"error": str(exc)})
self._backoff(attempt)
continue
if resp.status_code in (200, 201):
mtn = resp.json().get("manifestTrackingNumber")
if not mtn:
raise ValueError("accepted response missing MTN")
logger.info("manifest_accepted", extra={"mtn": mtn,
"generator": manifest.generator_epa_id})
return mtn
if 400 <= resp.status_code < 500 and resp.status_code != 429:
# Defective payload — retrying will not help. Surface it.
logger.error("manifest_rejected", extra={
"status": resp.status_code, "body": resp.text[:500]})
raise ManifestRejected(resp.status_code, resp.text)
# 429 / 5xx -> retry the SAME payload under the SAME idempotency key.
logger.warning("retryable_status", extra={"status": resp.status_code,
"attempt": attempt})
self._backoff(attempt)
raise RuntimeError(f"submission failed after {self._max_retries} attempts")
@staticmethod
def _backoff(attempt: int) -> None:
time.sleep(min(2 ** attempt, 30))
class ManifestRejected(Exception):
def __init__(self, status: int, body: str):
super().__init__(f"e-Manifest rejected submission: HTTP {status}")
self.status = status
self.body = body
The idempotency key is the safety mechanism that lets the retry loop be aggressive. Because the key is a SHA-256 over the canonical payload, a resubmission after an ambiguous timeout carries the identical key, and the e-Manifest system treats it as the same submission rather than a second shipment — the difference between an at-least-once transport and an exactly-once filing. This is the same idempotency discipline the parent Compliance Reporting & Automation pipeline applies to every artifact type, and it pairs with the hours-of-service work in DOT Hours-of-Service Logging and the reconciliation the FMCSA ELD Sync subsystem performs against the same shipments.
Regulatory Mapping
Every field in the model above traces to a specific regulation, and pinning each to its citation is what lets the compliance registry version the rules an inspector will quote. Do not assert manifest requirements in prose — map them.
| Model element | Regulatory source | What it authorizes |
|---|---|---|
| Manifest requirement + 30-day submission | 40 CFR 262.20–262.23 | Generator must prepare a manifest; receiving facility submits it electronically |
Manifest payload / form layout |
EPA Form 8700-22 (Uniform Hazardous Waste Manifest) | The field set and item numbering the payload mirrors |
waste_codes (D/F/K/P/U codes) |
40 CFR 261 Subparts C & D | Characteristic and listed hazardous-waste code assignment |
| Electronic submission channel | 40 CFR 262.20(a)(3); e-Manifest system (RCRAInfo) | Authorizes the electronic manifest in place of paper |
| Manifest user fee | 40 CFR 264.1311 | Per-manifest fee the receiving facility owes for electronic submission |
The waste codes deserve emphasis because they are the field most often mismatched. A characteristic code such as D001 (ignitability) under 40 CFR 261 Subpart C describes why a stream is regulated, and it must agree with the DOT description and the receiving facility’s acceptance list. When the router’s waste-stream classification and the manifest’s declared codes disagree, the manifest is rejected at intake — so the classification that feeds the manifest must be the same one recorded upstream, not a re-entry. Reconciling declared tonnage against the weighbridge is likewise a 40 CFR 262 obligation, and it ties directly to the payload figures the capacity and weight-limits dimension produces.
Validation & Verification
A manifest client that “usually works” is a liability — the failure mode is a shipment that already moved with a manifest the endpoint silently mangled. Prove the two things that matter: a well-formed manifest is built from valid inputs, and a successful submission captures a usable MTN. The test below stubs the HTTP layer so it runs in CI without a live endpoint.
import pytest
from unittest.mock import patch, MagicMock
def _valid_manifest() -> Manifest:
return Manifest(
generator_epa_id="OHD000000001",
transporter_epa_id="OHD000000002",
designated_tsdf_epa_id="OHD000000003",
waste_lines=[
WasteLine(dot_description="Waste flammable liquid, n.o.s.",
waste_codes=["D001"], quantity=8300, unit=UnitOfMeasure.KILOGRAMS,
container_count=4, container_type=ContainerType.DRUM_METAL),
],
)
def test_manifest_is_wellformed() -> None:
m = _valid_manifest()
payload = build_payload(m)
assert payload["generatorId"] == "OHD000000001"
assert payload["wastes"][0]["wasteCodes"] == ["D001"]
assert payload["wastes"][0]["quantity"] == {"value": 8300, "unit": "K"}
def test_submit_captures_mtn() -> None:
m = _valid_manifest()
fake = MagicMock(status_code=201)
fake.json.return_value = {"manifestTrackingNumber": "100032179ELC"}
with patch.object(requests.Session, "post", return_value=fake) as post:
mtn = ManifestClient(token="test").submit(m)
assert mtn == "100032179ELC"
# The idempotency key must be present and stable.
sent_key = post.call_args.kwargs["headers"]["Idempotency-Key"]
assert sent_key == idempotency_key(m)
def test_malformed_waste_code_rejected_at_model() -> None:
with pytest.raises(ValueError):
WasteLine(dot_description="x", waste_codes=["DOO1"], # letter O, not zero
quantity=1, unit=UnitOfMeasure.KILOGRAMS,
container_count=1, container_type=ContainerType.DRUM_METAL)
In production, emit the MTN capture as a structured field so the assertion has a logged counterpart. The mtn and generator_epa_id pair is what an auditor uses to tie a filed manifest back to the shipment and the site, and every accepted submission should produce exactly one such record.
Failure Modes & Edge Cases
API 4xx rejection. The endpoint refuses the payload — a wrong EPA ID, a missing DOT description, a container type it does not recognize. The client above raises ManifestRejected rather than retrying, because retrying a defective payload only burns the window. Route the rejection, with the endpoint’s error body attached, to a review queue and fix the record at its source.
Waste-code mismatch. The declared RCRA codes disagree with the receiving facility’s acceptance list or with the DOT shipping description. This is a 4xx at intake. The mitigation is upstream: the codes on the manifest must be the classification recorded when the stop was serviced, so validate that they still match the TSDF’s accepted set before transmission rather than after.
def check_tsdf_acceptance(manifest: Manifest,
accepted_codes: set[str]) -> list[str]:
"""Return waste codes the designated TSDF is not permitted to accept.
A non-empty result means the manifest WILL 4xx — fix before transmit."""
declared = {c for line in manifest.waste_lines for c in line.waste_codes}
unaccepted = sorted(declared - accepted_codes)
if unaccepted:
logger.error("tsdf_cannot_accept", extra={
"tsdf": manifest.designated_tsdf_epa_id, "codes": unaccepted})
return unaccepted
Quantity/unit drift. The declared quantity was entered in pounds but the payload marks it kilograms, or the router’s tonnage and the scale ticket disagree beyond tolerance. Because the unit is a typed enum and the quantity is derived from the reconciled scale figure, the model rejects a bare number without a unit, and the reconciliation keeps both figures so a real discrepancy is visible rather than silently rounded.
Duplicate submission. A retry after a timeout, or two workers picking up the same shipment, would file one manifest twice. The Idempotency-Key header — the SHA-256 over the canonical payload — collapses those to one filing; the endpoint returns the original MTN for the duplicate rather than minting a second manifest for the same waste. Keep the key deterministic: any incidental field that changes between attempts (a timestamp, a retry counter) must stay out of the payload the key is computed over.
Integration Checklist
Complete every item before an e-Manifest integration reaches production filing:
FAQ
What is a Manifest Tracking Number and why capture it programmatically?
The MTN is the unique identifier EPA assigns to a manifest when it enters the e-Manifest system. It is the key every later action references — the transporter’s and receiving facility’s signatures, the reconciliation against the weighbridge ticket, and any correction. Capturing it programmatically from the submission response, rather than transcribing it from a portal, is what lets declared tonnage tie back to the shipment automatically.
Should the declared quantity come from the router or the scale?
The physical scale is authoritative for the declared figure, but keep the router’s computed payload alongside it. Declaring the scale-reconciled number matches what the weighbridge ticket shows, while retaining the router figure makes any discrepancy auditable instead of averaged away. A delta beyond tolerance should flag the shipment for manual review before the manifest is filed.
How do I avoid filing the same shipment twice on a retry?
Send an idempotency key that is a hash of the canonical payload, and reuse that exact key on every retry. The e-Manifest system treats a second submission with the same key as the same manifest and returns the original MTN. The one rule to hold: never let a value that changes between attempts — a timestamp or retry counter — leak into the payload the key is computed over, or the key stops being stable.
Which regulation sets the submission deadline?
The manifest rules at 40 CFR 262.20–262.23 require the designated receiving facility to submit the completed manifest to EPA within 30 days of delivery. That window is enforced against the filing itself, so a late electronic submission is a discrepancy even when the physical shipment and handling were entirely correct.
Up: Compliance Reporting & Automation
Related
- Submitting RCRA Hazardous Waste Manifests in Python — the full submission, certification, and correction client
- FMCSA ELD Sync — reconciling the same shipments against electronic logging device records
- DOT Hours-of-Service Logging — proving the driving-time ceilings held for the manifested run
- Audit Report Generation — sealing filed manifests into a tamper-evident report
- Capacity & Weight Limits — the payload accounting the declared tonnage reconciles against