MVR Batching API — Developer Guide

Submit Missouri driver- and vehicle-record batches, poll their status, and download official-format PDF reports — programmatically, over a simple JSON REST API. This guide takes you from zero to your first authenticated call, then covers every endpoint and how to debug your integration yourself.

1. Create your account

Everything is self-service. Access to driver records is regulated under the federal DPPA and GLBA, so new accounts are reviewed before they go live.

  1. Register at https://batching.missourimvr.com/account/register. You'll provide your company details and certify a permissible purpose.
  2. Verify your email — we send a confirmation link (valid for 24 hours).
  3. Approval — an administrator reviews and approves the account. You'll get an email when it's active. Until then, sign-in and API calls return an "account not approved" error.
  4. Sign in at https://batching.missourimvr.com/account/login.
The API acts with your account's permissions and pricing. There is no separate developer account — the same login that runs batches in the web app issues the keys your code uses.

2. Generate an API key

Once signed in, open Account › Integration › API keys and create a key. A key has a public key ID (mvrk_…) and a show-once secret (mvrsk_…).

  • The secret is shown once at creation — copy it immediately. We store only a hash (and, for signed keys, an encrypted copy) and can't show it again.
  • Scopes — grant each key only what it needs: batches:read, batches:write, reports:read, balance:read, billing:write (and webhooks:* when you use webhooks). A call outside a key's scopes returns 403.
  • Mode — choose Bearer (simple) or Signed (HMAC; see below).
  • Optional expiry and IP allowlist (CIDR or exact IPs) per key.
  • Rotation — "Roll" issues a new secret while the old one keeps working for 24 hours (Signed-mode keys keep the same key ID; only the secret changes), so you can swap with zero downtime. You can hold up to 5 active keys; revoking stops a key immediately.

Treat secrets like passwords: never commit them to source control or expose them in a browser. Server-to-server only. (Legacy mvr_… keys created before this change keep working as Bearer keys with full scope.)

3. Authenticate

Two modes, chosen per key. All request/response bodies are JSON (Content-Type: application/json); report downloads return application/pdf.

Bearer (simple)

Send the secret as a token:

Authorization: Bearer mvrsk_your_secret
# — or —
X-Api-Key: mvrsk_your_secret

Signed (HMAC — highest security)

The secret never travels. Sign each request and send the key ID plus a signature; we verify it, enforce a ±5-minute window, and reject replayed nonces.

  • Header MVR-Key-Id: mvrk_…
  • Header MVR-Signature: t=<unix>,n=<nonce>,v1=<hex>
  • Signed content: "{t}.{n}.{sha256(raw_body)}"; key = your secret; algo = HMAC-SHA256.

Python

import hashlib, hmac, time, secrets, requests

key_id, secret = "mvrk_…", "mvrsk_…"
body = b""                                  # GET: empty; POST: the exact JSON bytes you send
t = str(int(time.time())); n = secrets.token_hex(8)
body_hash = hashlib.sha256(body).hexdigest()
sig = hmac.new(secret.encode(), f"{t}.{n}.{body_hash}".encode(), hashlib.sha256).hexdigest()
requests.get("https://batching.missourimvr.com/api/v1/me", headers={
    "MVR-Key-Id": key_id,
    "MVR-Signature": f"t={t},n={n},v1={sig}",
})

Node

import crypto from "node:crypto";
const body = Buffer.from("");
const t = Math.floor(Date.now() / 1000).toString();
const n = crypto.randomBytes(8).toString("hex");
const bodyHash = crypto.createHash("sha256").update(body).digest("hex");
const v1 = crypto.createHmac("sha256", secret).update(`${t}.${n}.${bodyHash}`).digest("hex");
await fetch("https://batching.missourimvr.com/api/v1/me", { headers: { "MVR-Key-Id": keyId, "MVR-Signature": `t=${t},n=${n},v1=${v1}` } });

C#

var body = Array.Empty<byte>();
var t = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
var n = Convert.ToHexString(RandomNumberGenerator.GetBytes(8)).ToLowerInvariant();
var bodyHash = Convert.ToHexString(SHA256.HashData(body)).ToLowerInvariant();
var v1 = Convert.ToHexString(HMACSHA256.HashData(
    Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes($"{t}.{n}.{bodyHash}"))).ToLowerInvariant();
http.DefaultRequestHeaders.Add("MVR-Key-Id", keyId);
http.DefaultRequestHeaders.Add("MVR-Signature", $"t={t},n={n},v1={v1}");

The same HMAC convention is used to sign our outbound webhooks, so one verification routine covers both directions.

Correlation IDs

Send an X-Correlation-ID header to tie a request to your own logs — we echo it back on the response and record it against the call. Omit it and we generate one for you and return it. Either way the value appears next to the call (with its duration) under Account › Integration › API activity and in our logs and audit trail — quote it in any support request.

curl -i -H "Authorization: Bearer mvrsk_your_secret" \
  -H "X-Correlation-ID: my-trace-123" https://batching.missourimvr.com/api/v1/me
# Response includes:  X-Correlation-ID: my-trace-123

4. Your first call

Before wiring up batches, confirm your key works with GET /api/v1/me. It returns the account the key belongs to, your per-batch record cap, your price per record, and the rate limit. A 401 means the key is missing, invalid, revoked, or the account isn't approved yet.

curl -H "Authorization: Bearer mvrsk_your_secret" https://batching.missourimvr.com/api/v1/me
{
  "userId": "8f3a…",
  "email": "you@example.com",
  "company": "Acme Logistics LLC",
  "accountStatus": "Approved",
  "apiKeyPrefix": "mvrk_1a2b3c4d5e6f",
  "maxRecordsPerBatch": 500,
  "pricePerRecord": 9.95,
  "rateLimitPerMinute": 60,
  "sources": [
    { "sourceKey": "drivers", "displayName": "Missouri Driver Records", "pricePerRecord": 9.95, "maxRecordsPerBatch": 500, "rateLimitPerMinute": 60 }
  ]
}

Each entry in sources is a data source your account can batch (POST to /api/v1/{sourceKey}/batches) with the price and limits that apply to you. The top-level price/limit fields mirror the default drivers source.

5. Endpoint reference

Base URL: https://batching.missourimvr.com/api/v1. The interactive reference lists request/response schemas in full and, outside production, lets you try calls live with your key.

Submit a batch — POST /api/v1/{source}/batches

Batches are scoped to a data source: drivers or vehicles. POST to /api/v1/drivers/batches or /api/v1/vehicles/batches; /api/v1/batches is a legacy alias for drivers. Call GET /api/v1/me to see which sources your account can batch and the price/limits for each. Purposes default to the batch-level dppaPurpose/glbPurpose and can be overridden per record.

Permissible-purpose codes. Every batch must certify both a federal DPPA purpose and a GLBA purpose — pick the codes that match your permissible use. Send the code value (e.g. DPPA-BUS) in dppaPurpose/glbPurpose. These are the codes the API accepts; an unknown code returns 400 validation_failed.

DPPA codePermissible use
DPPA-INSInsurance underwriting and maintenance
DPPA-FRAUDFraud prevention and detection
DPPA-BUSFor use in the normal course of business
DPPA-FIDRepresentative or fiduciary of consumer
DPPA-COLLCollections
DPPA-RISKRisk control & dispute resolution
DPPA-EMPEmployer interests
DPPA-TOWTowing
DPPA-SAFETYMotor vehicle or driver safety
GLBA codePermissible use
GLB-FRAUDFraud prevention or detection
GLB-LEGALLegal compliance (including insurance claims & underwriting)
GLB-TRANSTransactions authorized by consumer
GLB-TRANS-AVTransactions authorized by consumer (application verification only)
GLB-LAWLaw enforcement purposes
GLB-INTERESTUse by person holding a legal or beneficial interest
GLB-FIDUse by person acting in a fiduciary capacity on behalf of the consumer
GLB-RISKRequired institutional risk control

Driver records — each record is identified by a Missouri driver-license number, or by first name + last name + date of birth.

curl -X POST https://batching.missourimvr.com/api/v1/drivers/batches \
  -H "Authorization: Bearer mvrsk_your_secret" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "June fleet review",
    "dppaPurpose": "DPPA-BUS",
    "glbPurpose": "GLB-LEGAL",
    "records": [
      { "dlNumber": "F123456789", "referenceNumber": "EMP-001" },
      { "firstName": "Bill", "lastName": "Jones", "dob": "1980-05-01" }
    ]
  }'

Add an optional "payment" field — "balance" (prepaid, headless) or "checkout" (hosted Stripe page) — or omit it to use your account default. A successful submission returns 201; the response billingMethod and status tell you what happened. See Payment patterns below.

{
  "id": "2c9f…",
  "status": "Queued",
  "recordCount": 2,
  "quotedTotal": 19.90,
  "checkoutUrl": null
}

Vehicle records — POST to /api/v1/vehicles/batches. Each record is identified by a VIN, a license-plate number (with optional plateState, default MO), or a title number. The request envelope (name, purposes, payment) and the response shape are identical — only the per-record keys differ.

curl -X POST https://batching.missourimvr.com/api/v1/vehicles/batches \
  -H "Authorization: Bearer mvrsk_your_secret" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Fleet title check",
    "dppaPurpose": "DPPA-BUS",
    "glbPurpose": "GLB-LEGAL",
    "records": [
      { "vin": "1HGCM82633A004352", "referenceNumber": "UNIT-12" },
      { "plateNumber": "AB1C2D", "plateState": "MO" },
      { "titleNumber": "MO123456789" }
    ]
  }'

Python

import requests

resp = requests.post(
    "https://batching.missourimvr.com/api/v1/batches",
    headers={"Authorization": "Bearer mvrsk_your_secret"},
    json={
        "name": "June fleet review",
        "dppaPurpose": "DPPA-BUS",
        "glbPurpose": "GLB-LEGAL",
        "records": [
            {"dlNumber": "F123456789", "referenceNumber": "EMP-001"},
            {"firstName": "Bill", "lastName": "Jones", "dob": "1980-05-01"},
        ],
    },
)
resp.raise_for_status()
batch = resp.json()
print(batch["id"], batch["status"])

C#

using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "mvrsk_your_secret");

var payload = new
{
    name = "June fleet review",
    dppaPurpose = "DPPA-BUS",
    glbPurpose = "GLB-LEGAL",
    records = new object[]
    {
        new { dlNumber = "F123456789", referenceNumber = "EMP-001" },
        new { firstName = "Bill", lastName = "Jones", dob = "1980-05-01" },
    },
};

var resp = await http.PostAsJsonAsync("https://batching.missourimvr.com/api/v1/batches", payload);
resp.EnsureSuccessStatusCode();

List batches — GET /api/v1/batches

Returns your batches newest-first as lightweight summaries (no per-record detail). Paginated with limit (1–100, default 50) and offset; the response carries hasMore and total so you can page without guessing.

curl -H "Authorization: Bearer mvrsk_your_secret" "https://batching.missourimvr.com/api/v1/batches?limit=50&offset=0"
{
  "data": [
    { "id": "2c9f…", "name": "June fleet review", "source": "drivers", "status": "Completed",
      "recordCount": 2, "quotedTotal": 19.90, "capturedAmount": 9.95,
      "createdUtc": "2026-06-14T17:02:11Z" }
  ],
  "hasMore": true,
  "total": 137
}

Poll status — GET /api/v1/batches/{id}

Returns the batch status and every record's result. Each completed record exposes a pdfUrl you can download.

curl -H "Authorization: Bearer mvrsk_your_secret" https://batching.missourimvr.com/api/v1/batches/<id>
{
  "id": "2c9f…",
  "name": "June fleet review",
  "source": "drivers",
  "status": "Completed",
  "recordCount": 2,
  "capturedAmount": 9.95,
  "records": [
    {
      "rowNumber": 1,
      "searchType": "Dl",
      "status": "Completed",
      "matchQuality": "Exact",
      "priceCharged": 9.95,
      "pdfUrl": "/api/v1/records/7b1e…/pdf"
    }
  ]
}

A vehicles batch returns the same shape with "source": "vehicles"; each record echoes its vehicle keys (vin/plateNumber/plateState/titleNumber) and a searchType of Vin, Plate, or Title.

Download a report — GET /api/v1/records/{recordId}/pdf

Streams the official-format Missouri record PDF (driver record or vehicle/title report, matching the batch's source) for a completed record.

curl -OJ -H "Authorization: Bearer mvrsk_your_secret" https://batching.missourimvr.com/api/v1/records/<recordId>/pdf

6. Payment patterns

Two ways to pay, chosen per batch with the "payment" field (or your account default). Either way you're quoted the full amount up front but charged only for completed records — no-match and error records are free.

A. Prepay balance — headless recommended for automation

Fund a balance once (see Managing your balance), then submit with "payment": "balance". The quote is reserved from your balance and the batch is queued immediately — no redirect, no browser, fully server-to-server.

curl -X POST https://batching.missourimvr.com/api/v1/batches \
  -H "Authorization: Bearer mvrsk_your_secret" -H "Content-Type: application/json" \
  -d '{ "name": "...", "dppaPurpose": "DPPA-BUS", "glbPurpose": "GLB-LAW",
        "payment": "balance", "records": [ … ] }'
# → 201 { "status": "Queued", "billingMethod": "Balance", "quotedTotal": 10.00 }

After processing, the balance is settled to the completed-record total and the unused remainder is returned. If the balance is short, you get 402:

{ "errors": ["Insufficient balance: 40.00 available, 150.00 required."],
  "balance": 40.00, "shortfall": 110.00, "quotedTotal": 150.00,
  "topUpUrl": "https://batching.missourimvr.com/api/v1/credit/topup" }

Status walks through Queued → Processing → Completed; poll GET /api/v1/batches/{id} and read capturedAmount for the final charge.

B. Redirect (hosted Checkout) — for reselling

Submit with "payment": "checkout" and you get a checkoutUrl. The batch stays Draft until someone pays on Stripe's hosted page — ideal if you resell access and want each end-customer to pay with their own card rather than pre-funding a balance for them.

# → 201 { "status": "PaymentRequired", "billingMethod": "StripeCheckout",
#         "checkoutUrl": "https://checkout.stripe.com/…", "quotedTotal": 19.90 }
  1. Authorize — open checkoutUrl; the full quote is authorized (manual-capture hold). Card data never touches this API.
  2. Queue — once Stripe confirms, the batch moves Draft → Queued automatically. Confirmation arrives server-side (webhook), not to your app.
  3. Capture — after processing, only the completed-record total is captured; the rest of the authorization is released to the card.

Status walks through PaymentRequired → Queued → Processing → Completed. Your app learns payment landed by polling GET /api/v1/batches/{id} until status leaves PaymentRequired — or, preferably, by subscribing to webhooks (batch.queued, payment.settled, batch.completed) so you're pushed the update instead of polling.

One account, both patterns. There's no separate "reseller" account — keys, usage, and balance all live under your one account (see Account). Prepay for your own automated pipelines, use redirect for customers you don't pre-fund, or mix per batch. Card data never touches this API in either pattern (PCI SAQ A).

Managing your balance

  • GET /api/v1/balance — current available balance.
  • GET /api/v1/credit/ledger — movements (top-ups, reservations, settlements), newest first. Paginated with limit (1–100, default 100) and offset; returns a flat array.
  • POST /api/v1/credit/topup { "amount": 100 } — returns a checkoutUrl to add funds (completed on Stripe's page; credited by webhook). You can also top up and view the ledger under Account › Billing & credits, where an administrator can also apply invoice/ACH credit.
curl -H "Authorization: Bearer mvrsk_your_secret" https://batching.missourimvr.com/api/v1/balance
# → { "balance": 125.50, "currency": "usd" }
Refunds & chargebacks. If a top-up is later refunded — or you lose a card dispute on it — the credited funds are reversed out of your balance (a Chargeback ledger entry). If you'd already spent them, the balance goes negative and the next top-up clears the deficit first. We emit balance.adjusted on the reversal and payment.disputed / payment.dispute_closed as a dispute opens and resolves, so you can react in real time.

7. Rate limits & errors

  • Rate limit: 60 requests per minute, per account. Over the limit returns 429 Too Many Requests — back off and retry.
  • Validation: 400 Bad Request with an errors array of human-readable messages (unknown purpose code, too many records, malformed record).
  • Auth: 401 when the key is missing, invalid, revoked, or the account isn't approved.
  • Not found: 404 for a batch or record that doesn't belong to your account.

Error responses carry a stable, machine-readable code alongside the human-readable errors — branch on code, show errors to people. The wording in errors may change; the codes won't.

  • validation_failed — 400, one or more fields/records are invalid.
  • insufficient_balance — 402, prepaid balance too low (includes balance, shortfall, quotedTotal, topUpUrl).
  • amount_too_low — 400, top-up below the minimum.
  • payments_unavailable — 409, payments aren't configured.
  • payment_initiation_failed — 502, Stripe checkout couldn't start.
  • insufficient_scope — 403, the key lacks the required scope.
{ "code": "validation_failed",
  "errors": ["dppaPurpose must be one of: DPPA-BUS, DPPA-INS, ….", "A batch is limited to 500 records; you provided 612."] }

8. Debug your integration — self-service

Interactive reference

Browse schemas and, outside production, fire live requests with your key right from the browser.

Open /api/docs

Confirm your key

Call GET /api/v1/me to verify the key works and see your account limits before debugging anything else.

See the call

API activity log

Every call your account makes — method, path, status, timing, and the reason for failures — at Account › API activity.

View activity

9. Webhooks

Instead of polling, register an endpoint and we'll POST a signed JSON event the moment something happens. Manage endpoints in Account › Integration › Webhooks or via the API (/api/v1/webhook-endpoints) — full parity. Webhook deliveries are not rate-limited and are never billed.

Event envelope

Every delivery shares one envelope. Treat id as the idempotency key and dedupe on it — a resend (or an at-least-once retry) means you will see the same id again. Ordering isn't guaranteed; use created to reorder.

{
  "id": "evt_1a2b3c4d",
  "type": "batch.completed",
  "apiVersion": "v1",
  "created": "2026-06-14T01:42:10Z",
  "livemode": true,
  "data": { "object": { /* the batch / payment / balance resource */ } },
  "request": { "idempotencyKey": null }
}

batch.completed carries the full record summary + records array (with pdfUrls) — there are no per-record events.

Event catalog

  • batch.created
  • batch.payment_required
  • batch.queued
  • batch.processing
  • batch.completed
  • payment.authorized
  • payment.captured
  • payment.released
  • payment.failed
  • payment.settled
  • payment.refunded
  • payment.disputed
  • payment.dispute_closed
  • balance.topped_up
  • balance.adjusted
  • balance.low
  • balance.depleted
  • apikey.created
  • apikey.revoked

Verify the signature

Each request carries MVR-Signature: t=<unix>,n=<nonce>,v1=<hex> (one v1 per active secret during a roll). Recompute HMAC-SHA256(secret, "{t}.{n}.{sha256(raw_body)}"), constant-time compare against any v1, and reject timestamps skewed more than 5 minutes. This is the same scheme as inbound request signing, so one routine covers both.

Python

import hashlib, hmac, time

def verify(secret, raw_body, header):
    parts = dict(p.split("=", 1) for p in header.split(",") if "v1=" not in p or True)
    t, n = parts["t"], parts["n"]
    if abs(time.time() - int(t)) > 300: return False
    body_hash = hashlib.sha256(raw_body).hexdigest()
    expected = hmac.new(secret.encode(), f"{t}.{n}.{body_hash}".encode(), hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, v.split("=",1)[1])
               for v in header.split(",") if v.startswith("v1="))

Node

import crypto from "node:crypto";
function verify(secret, rawBody, header) {
  const map = Object.fromEntries(header.split(",").map(p => p.split("=")));
  if (Math.abs(Date.now()/1000 - Number(map.t)) > 300) return false;
  const bodyHash = crypto.createHash("sha256").update(rawBody).digest("hex");
  const expected = crypto.createHmac("sha256", secret).update(`${map.t}.${map.n}.${bodyHash}`).digest("hex");
  return header.split(",").filter(p => p.startsWith("v1="))
    .some(p => crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(p.slice(3))));
}

C#

var map = header.Split(',').Select(p => p.Split('=', 2)).Where(p => p.Length == 2).ToList();
var t = map.First(p => p[0] == "t")[1];
var n = map.First(p => p[0] == "n")[1];
var bodyHash = Convert.ToHexString(SHA256.HashData(rawBody)).ToLowerInvariant();
var expected = Convert.ToHexString(HMACSHA256.HashData(
    Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes($"{t}.{n}.{bodyHash}"))).ToLowerInvariant();
var ok = map.Where(p => p[0] == "v1")
    .Any(p => CryptographicOperations.FixedTimeEquals(
        Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(p[1])));

Retries & delivery

  • We expect a 2xx within 5 seconds; anything else is a failure.
  • Failures retry with exponential backoff + jitter — ~16 attempts spanning roughly 3–4 days (≈88h max, ≥66h even at minimum jitter), comfortably surviving at least 48 hours of downtime. An endpoint failing for ~72h is auto-disabled and the owner emailed; re-enable and resend from the console.
  • Resend any past event (failed or successful) to one or all endpoints to recover from your own processing errors — that's why consumers must be idempotent.
  • Use the console's Send test event and the delivery log to debug; for local development, tunnel with an ngrok-style HTTPS forwarder (private/non-HTTPS URLs are rejected).