Skip to content
Ingestion & Parsing

Mobile App DVIR Export Integration

The mobile app is where the federal record is born, and a Driver Vehicle Inspection Report that leaves a driver’s phone but never lands intact in the compliance store is, to a DOT auditor, a report that was never prepared. Under 49 CFR § 396.11(a) the driver must prepare a report at the completion of each day’s work, and under § 396.11©(2) the carrier must certify any safety-affecting defect corrected before the vehicle is dispatched again — obligations that both depend on the export layer capturing the driver identity, VIN, UTC timestamp, per-component condition, defect entries, and signature without silent loss. This page specifies the mobile export integration as the primary structured-input channel into the DVIR Ingestion & Digital/Paper Parsing Workflows pipeline: the client-side capture and buffering contract, the envelope the app must emit, the validation gate that admits or rejects it, and the idempotency and cryptographic guarantees that make the resulting record legally defensible.

Because native mobile submissions arrive as structured JSON, they bypass the PDF & Image OCR Pipeline Setup entirely and flow straight to synchronous validation — this is the highest-fidelity, lowest-latency door into the system, and the export contract exists to keep it that way.

Mobile DVIR export path from device capture to immutable audit store A driver mobile app captures each inspection into an offline draft store, then a background outbox flush emits a signed MobileExportEnvelope over HTTPS with a client-generated idempotency key. At the ingest gateway the payload passes through three admission checks in series, each of which can reject: an idempotency-key dedupe lookup returns 409 for a key already recorded; strict schema validation returns 422 with field-level errors for a malformed envelope; and Ed25519 or HMAC-SHA256 signature verification returns 401 for an unsigned or tampered export. Only an envelope that clears all three is admitted with a 202 Accepted, recorded in the idempotency ledger, and published to the asynchronous batch queue, which feeds schema normalization and then the write-once immutable audit store. Every rejection is diverted to a dead-letter queue with a structured failure manifest for manual reconciliation. Driver mobile app Offline draft store capture · dvir_id set Outbox flush backoff · jitter signed envelope HTTPS · POST Idempotency-Key Ingest gateway — three checks, closed by default 1 · Idempotency ledger key already seen? 2 · Schema validation MobileExportEnvelope 3 · Signature verify Ed25519 / HMAC-SHA256 409 422 401 Dead-letter queue failure manifest admit · record key · 202 Async batch queue absorbs shift-end burst Schema normalization UTC · canonical fields WORM audit store SHA-256 chained

Prerequisites and Environment Setup

Anchor link to "Prerequisites and Environment Setup"

The gateway that receives mobile exports is a pure validation-and-acknowledge service; enrichment and persistence happen downstream. Target Python 3.10+ (the code below uses match statements, the X | None union operator, and StrEnum) with:

  • pydantic (v2) — declarative schema enforcement and OpenAPI generation for the export contract.
  • fastapi + uvicorn — the ingest endpoint that terminates the mobile HTTPS connection and returns 202 Accepted.
  • cryptography — Ed25519 / HMAC-SHA256 verification of the driver certification signature.
  • redis (or any durable KV store) — the idempotency-key ledger used to deduplicate retried submissions.
  • tenacity — bounded retry/backoff on the mobile client and on gateway-to-queue handoff.

The mobile client itself must persist an offline draft store (SQLite or an encrypted key-value store) and an outbox queue. The extracted-field contract the envelope carries is owned upstream by the Standardized DVIR JSON Schema Design; the mobile layer adds only the transport metadata — the client-generated idempotency key, the source timezone, and the signature — that the gate needs to make an admit/reject decision.

Every mobile submission must present the same envelope, so the gate has exactly one shape to validate and the audit store has exactly one shape to persist. The envelope wraps the canonical DVIR fields together with the transport metadata that governs deduplication and non-repudiation.

Field Type Enumeration / format Compliance tag
dvir_id string client-generated UUIDv4; the idempotency key Deduplication key — prevents a second record for one inspection
channel enum mobile Provenance marker; mobile bypasses OCR
vin string 17-char ISO 3779, uppercase alphanumeric § 396.11(a) vehicle identity
driver_id string UUID or DOT-qualified identifier § 396.11(a) operator identity
inspection_type enum pre_trip, post_trip § 396.11(a) end-of-day report is post_trip
inspected_at datetime ISO 8601 with offset, coerced to UTC § 396.11 preparation timestamp
source_timezone string IANA zone name (e.g. America/Chicago) Forensic context for temporal audit
odometer integer non-negative Correlates with HOS / maintenance records
defects array[DefectEntry] see below § 396.11(a)(3) defect capture
defect_status enum no_defects, defects_noted, out_of_service § 396.11©(2) certification trigger
certification_sig string base64 Ed25519 or HMAC-SHA256 Non-repudiation of driver certification

Each DefectEntry carries a normalized component identifier, a free-text description, an optional defect_code (assigned downstream, may be null at export), a boolean safety_affecting flag mapped to § 396.11(a)(3), and optional media_refs[] pointing to defect photos uploaded out-of-band. Normalize timestamps to UTC at the export boundary and preserve the source zone as metadata — never silently rewrite a timestamp, because a defensible record must show what the device reported.

python
from datetime import datetime
from enum import StrEnum
from zoneinfo import ZoneInfo
from pydantic import BaseModel, Field, field_validator

class Channel(StrEnum):
    MOBILE = "mobile"

class InspectionType(StrEnum):
    PRE_TRIP = "pre_trip"
    POST_TRIP = "post_trip"          # the § 396.11(a) end-of-day report

class DefectStatus(StrEnum):
    NO_DEFECTS = "no_defects"
    DEFECTS_NOTED = "defects_noted"
    OUT_OF_SERVICE = "out_of_service"

class DefectEntry(BaseModel):
    component: str                    # normalized component key, e.g. "service_brakes"
    description: str
    defect_code: str | None = None    # assigned downstream; null at export
    safety_affecting: bool            # § 396.11(a)(3)
    media_refs: list[str] = Field(default_factory=list)

class MobileExportEnvelope(BaseModel):
    """The single shape every mobile submission must produce at the gate."""
    dvir_id: str = Field(min_length=8)          # client UUID; the idempotency key
    channel: Channel = Channel.MOBILE
    vin: str = Field(min_length=17, max_length=17)
    driver_id: str
    inspection_type: InspectionType
    inspected_at: datetime
    source_timezone: str
    odometer: int = Field(ge=0)
    defects: list[DefectEntry] = Field(default_factory=list)
    defect_status: DefectStatus
    certification_sig: str

    @field_validator("vin")
    @classmethod
    def vin_shape(cls, v: str) -> str:
        if len(v) != 17 or not v.isalnum():
            raise ValueError("VIN must be exactly 17 alphanumeric characters")
        return v.upper()

    @field_validator("inspected_at")
    @classmethod
    def to_utc(cls, v: datetime, info) -> datetime:
        tz = info.data.get("source_timezone")
        if v.tzinfo is None and tz:
            v = v.replace(tzinfo=ZoneInfo(tz))
        return v.astimezone(ZoneInfo("UTC"))

Core Workflow: Resilient Client Export and Gateway Admission

Anchor link to "Core Workflow: Resilient Client Export and Gateway Admission"

The export workflow has two halves: the mobile client, which must survive dead zones and duplicate sends, and the gateway, which must admit exactly one immutable record per inspection.

Client side — buffer, then flush. The app writes every completed inspection to its offline draft store with a locally generated dvir_id before any network call. A background flush drains the outbox with exponential backoff and randomized jitter so that a fleet returning to coverage at shift-end does not stampede the gateway. Because dvir_id is generated once at capture, a retried submission carries the same key and is deduplicated at the gate rather than creating a second compliance record.

python
import random
from tenacity import retry, stop_after_attempt, wait_exponential_jitter

@retry(
    stop=stop_after_attempt(8),
    wait=wait_exponential_jitter(initial=1, max=120, jitter=random.random),
    reraise=True,
)
def flush_one(client, envelope: MobileExportEnvelope) -> str:
    resp = client.post(
        "/v1/dvir/ingest",
        content=envelope.model_dump_json(),
        headers={
            "Idempotency-Key": envelope.dvir_id,
            "Content-Type": "application/json",
        },
        timeout=15.0,
    )
    if resp.status_code in (200, 202, 409):   # 409 = already ingested; treat as success
        return envelope.dvir_id
    resp.raise_for_status()                    # 4xx (except 409) / 5xx -> retry or surface

Gateway side — deduplicate, verify, gate, acknowledge. The endpoint checks the idempotency ledger first, verifies the certification signature, validates the envelope against the contract, and only then hands the payload to the asynchronous batch queue. It never blocks the HTTP response on enrichment.

python
from fastapi import FastAPI, Header, Request, Response
from pydantic import ValidationError

app = FastAPI()

@app.post("/v1/dvir/ingest")
async def ingest(request: Request, idempotency_key: str = Header(...)) -> Response:
    # 1. Deduplicate on the client-generated key — one inspection, one record.
    if await ledger.seen(idempotency_key):
        return Response(status_code=409)       # already ingested; safe to retry
    raw = await request.body()

    # 2. Validate against the strict contract before anything touches the store.
    try:
        envelope = MobileExportEnvelope.model_validate_json(raw)
    except ValidationError as exc:
        # Reject the payload; return machine-readable, field-level errors.
        return Response(content=exc.json(), status_code=422,
                        media_type="application/json")

    # 3. Verify the driver certification signature (non-repudiation).
    if not verify_signature(envelope):
        return Response(status_code=401)       # reject unsigned / tampered exports

    # 4. Admit: record the key, enqueue for async processing, acknowledge fast.
    await ledger.record(idempotency_key)
    await batch_queue.publish(envelope)
    return Response(status_code=202)           # 202 Accepted; downstream continues

For high-volume operations, decouple this acknowledgement from the parsers by publishing to a broker so a burst of end-of-shift submissions cannot overrun downstream workers — that handoff is specified in Async Batching for High-Volume Ingestion. The webhook, header-gating, and offline-draft edge cases at the endpoint are covered in depth in Best Practices for Mobile DVIR API Integration.

Compliance Thresholding and Routing

Anchor link to "Compliance Thresholding and Routing"

The export layer makes exactly one compliance decision — admit or reject — but it also sets the flags that the downstream engine acts on. Map each gate outcome to an imperative action rather than a passive “flag for review”:

  • Missing mandatory field (VIN, driver_id, timestamp, signature): reject the payload with 422 and return field-level errors; do not admit a partial record.
  • Signature absent or invalid: reject the export with 401; an uncertified report cannot satisfy the § 396.11©(2) certification chain.
  • Timestamp drift > 15 minutes from NTP-synced server clock: admit the record but tag it for manual temporal review; never silently rewrite the driver’s reported time.
  • defect_status = out_of_service: admit, then immediately signal a hold — the vehicle must not be dispatched until repair is certified per § 396.11©(2).
  • Any safety_affecting = true defect: admit and forward to downstream severity scoring; a safety-affecting defect must escalate, not default to low priority.

Once admitted, the normalized defects flow to the Severity Scoring Algorithms for DVIR Defects engine, which resolves each defect to a reproducible 0–100 score. Those scores map to the same severity bands used across the pipeline — 0–34 minor, 35–69 major, 70–100 critical — and a critical band triggers the out-of-service workflow in Critical vs Non-Critical Routing Logic. The export layer’s job is to guarantee the scorer receives a complete, typed, signed record; it must never guess a missing field to keep the pipeline moving.

Production Integration and Platform Synchronization

Anchor link to "Production Integration and Platform Synchronization"

Once the gateway returns 202 Accepted, downstream workflows emit events without the mobile client polling. On successful ingestion, publish a dvir.ingested event keyed on dvir_id so fleet management, CMMS, and telematics/ELD platforms react in near-real time — a critical defect can open a repair order in the CMMS and raise a driver alert before the truck leaves the yard.

Idempotency chains across the whole path: the same dvir_id that deduplicates at the gate also keys the downstream event, so a replayed event never opens a duplicate repair order. For evidentiary integrity, hash the raw export bytes and the normalized record with SHA-256 and append both digests to the audit ledger; chain each entry to the prior digest so any tampering breaks the chain. Ed25519 or HMAC-SHA256 signing at the mobile layer plus SHA-256 chaining at ingest together give the non-repudiation a DOT audit expects. Retry logic must be bounded, with permanently failed exports captured in a dead-letter queue and a structured failure manifest for manual reconciliation — a failed export must be visible, never dropped.

How do I prevent duplicate DVIR records when a mobile client retries after a timeout?

Generate the dvir_id once at capture on the device and send it as both the envelope field and the Idempotency-Key header on every retry. The gateway checks the idempotency ledger first and returns 409 for a key it has already recorded, which the client treats as success. One inspection therefore yields exactly one immutable record no matter how many times the flush retries.

Should the mobile app coerce timestamps to UTC before export, or should the gateway?

Coerce at the export boundary using the device’s IANA source timezone, and carry source_timezone as metadata so the original context survives. If the incoming timestamp still drifts more than fifteen minutes from the server’s NTP-synced clock, admit the record but tag it for manual temporal review under § 396.11 — never silently rewrite a driver’s reported time, because that compromises the legal defensibility of the report.

What HTTP status should the gateway return for a payload missing the driver signature?

Reject it with 401, distinct from the 422 used for schema failures. An export without a verifiable driver certification signature cannot support the § 396.11©(2) certification chain, so admitting it would create a record the carrier cannot defend in a DOT audit.

Do native mobile exports go through the OCR pipeline?

No. Native submissions arrive as structured JSON and route directly to synchronous validation, bypassing optical recognition entirely. Only scanned or flattened documents enter the OCR pipeline; keeping the two channels separate prevents OCR latency from blocking real-time digital submissions.

Back to DVIR Ingestion & Digital/Paper Parsing Workflows.