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.
- Register at https://batching.missourimvr.com/account/register. You'll provide your company details and certify a permissible purpose.
- Verify your email — we send a confirmation link (valid for 24 hours).
- 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.
- Sign in at https://batching.missourimvr.com/account/login.
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(andwebhooks:*when you use webhooks). A call outside a key's scopes returns403. - 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 code | Permissible use |
|---|---|
DPPA-INS | Insurance underwriting and maintenance |
DPPA-FRAUD | Fraud prevention and detection |
DPPA-BUS | For use in the normal course of business |
DPPA-FID | Representative or fiduciary of consumer |
DPPA-COLL | Collections |
DPPA-RISK | Risk control & dispute resolution |
DPPA-EMP | Employer interests |
DPPA-TOW | Towing |
DPPA-SAFETY | Motor vehicle or driver safety |
| GLBA code | Permissible use |
|---|---|
GLB-FRAUD | Fraud prevention or detection |
GLB-LEGAL | Legal compliance (including insurance claims & underwriting) |
GLB-TRANS | Transactions authorized by consumer |
GLB-TRANS-AV | Transactions authorized by consumer (application verification only) |
GLB-LAW | Law enforcement purposes |
GLB-INTEREST | Use by person holding a legal or beneficial interest |
GLB-FID | Use by person acting in a fiduciary capacity on behalf of the consumer |
GLB-RISK | Required 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 }
- Authorize — open
checkoutUrl; the full quote is authorized (manual-capture hold). Card data never touches this API. - Queue — once Stripe confirms, the batch moves Draft → Queued automatically. Confirmation arrives server-side (webhook), not to your app.
- 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.
Managing your balance
GET /api/v1/balance— current available balance.GET /api/v1/credit/ledger— movements (top-ups, reservations, settlements), newest first. Paginated withlimit(1–100, default 100) andoffset; returns a flat array.POST /api/v1/credit/topup{ "amount": 100 }— returns acheckoutUrlto 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" }
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 Requestwith anerrorsarray of human-readable messages (unknown purpose code, too many records, malformed record). - Auth:
401when the key is missing, invalid, revoked, or the account isn't approved. - Not found:
404for 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 (includesbalance,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/docsConfirm your key
Call GET /api/v1/me to verify the key works and
see your account limits before debugging anything else.
API activity log
Every call your account makes — method, path, status, timing, and the reason for failures — at Account › API activity.
View activity9. 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.createdbatch.payment_requiredbatch.queuedbatch.processingbatch.completedpayment.authorizedpayment.capturedpayment.releasedpayment.failedpayment.settledpayment.refundedpayment.disputedpayment.dispute_closedbalance.topped_upbalance.adjustedbalance.lowbalance.depletedapikey.createdapikey.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
2xxwithin 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).