DEV Community

Vin Lookup
Vin Lookup

Posted on

Batch Decoding VINs with NHTSA DecodeVinValuesBatch: Rate Limits and Partial Failures

Single-VIN decode is the happy path. Real products decode fleets, marketplace listings, and import queues. NHTSA vPIC exposes DecodeVinValuesBatch so you can send many VINs in one request. That saves round trips, but it does not remove rate limits, and it does not mean every row succeeds.

This post covers how batch decode works, how to handle partial failures, and a TypeScript pattern that keeps your UI honest when half the batch is fine and half is not.

Why batch exists

DecodeVinValues (and the Extended variant) takes one VIN. If a dealer uploads 200 listings, 200 sequential calls are slow, chatty, and easier to throttle. Batch lets you POST a list and get a Results array back in one response.

Use batch when:

  • You validate a CSV or spreadsheet of VINs.
  • A marketplace page shows many listings and you want identity fields for each.
  • A nightly job refreshes decode caches for a known set.

Do not use batch as a substitute for caching. If the same VIN is decoded every page view, cache by VIN first. Batch is for many distinct VINs, not for replaying the same ones.

What the endpoint expects

vPIC batch endpoints accept a delimited list of VINs (commonly semicolon-separated in the form body, depending on the exact route you call). You get back a flat results structure similar to single decode: each result row carries Make, Model, ModelYear, ErrorCode, ErrorText, and the rest of the attribute set.

Mental model:

  • One HTTP response can contain many logical outcomes.
  • HTTP 200 means the batch call was accepted and parsed, not that every VIN decoded cleanly.
  • Each row is its own success or failure story.

Treat the batch like a map from input VIN to a decode row (or an error row), not like a single boolean.

Rate limits still apply

Public NHTSA APIs are free and shared. Bursting hundreds of batch calls per minute is how you get temporary failures, empty bodies, or long tails. Product rules that help:

  1. Cap concurrent outbound requests (for example one in flight, or a small pool).
  2. Cap VINs per batch (start around 50; tune from real latency and error rates).
  3. Back off on 429 / 5xx / empty Results with jitter.
  4. Deduplicate VINs before the call so you never pay twice for the same string.
  5. Persist successful rows so retries only cover misses.

Batch reduces connection overhead. It does not give you unlimited throughput.

const MAX_PER_BATCH = 50;
const MAX_CONCURRENT = 1;

export function chunkVins(vins: string[], size = MAX_PER_BATCH): string[][] {
  const unique = [...new Set(vins.map((v) => v.trim().toUpperCase()).filter(Boolean))];
  const chunks: string[][] = [];
  for (let i = 0; i < unique.length; i += size) {
    chunks.push(unique.slice(i, i + size));
  }
  return chunks;
}
Enter fullscreen mode Exit fullscreen mode

Queue chunks. Process with MAX_CONCURRENT. Log how many VINs you attempted versus how many rows you accepted.

Partial failures are normal

In a 50-VIN batch you might see:

  • 40 rows with solid Make / Model / ModelYear
  • 6 rows with ErrorText about check digit or incomplete decode
  • 4 rows missing or unusable because the input was truncated, padded, or not 17 characters

If your code does results.every(r => r.Make) and throws on the first blank, you discard good data. If you only check response.ok, you show empty cards as if they were complete.

Better contract:

type BatchItem =
  | { vin: string; status: "ok"; make: string; model: string; year: string; raw: Record<string, unknown> }
  | { vin: string; status: "partial"; make?: string; model?: string; year?: string; notes: string; raw: Record<string, unknown> }
  | { vin: string; status: "failed"; reason: string };

function clean(raw: unknown): string | null {
  if (raw == null) return null;
  const s = String(raw).trim();
  if (!s || s === "0" || /^not\s*applicable$/i.test(s)) return null;
  return s;
}

export function classifyRow(vin: string, row: Record<string, unknown>): BatchItem {
  const make = clean(row.Make);
  const model = clean(row.Model);
  const year = clean(row.ModelYear);
  const notes = clean(row.ErrorText) ?? clean(row.ErrorCode) ?? "";

  if (make && model && year) {
    if (notes) {
      return { vin, status: "partial", make, model, year, notes, raw: row };
    }
    return { vin, status: "ok", make, model, year, raw: row };
  }

  if (make || model || year) {
    return {
      vin,
      status: "partial",
      make: make ?? undefined,
      model: model ?? undefined,
      year: year ?? undefined,
      notes: notes || "Incomplete identity fields",
      raw: row,
    };
  }

  return {
    vin,
    status: "failed",
    reason: notes || "No usable decode fields",
  };
}
Enter fullscreen mode Exit fullscreen mode

UI rules that follow:

  • Show ok rows normally.
  • Show partial rows with a short note ("Decode incomplete: ...").
  • Show failed rows as "Could not decode" with the reason, not as blank make/model.
  • Never invent trim, title brand, or history to fill gaps.

Aligning inputs to outputs

Batch responses do not always preserve your original order in a way that is obvious if you also send bad strings. Keep a request-side list, normalize VINs the same way before and after, and match by VIN string when possible.

export function indexByVin(items: BatchItem[]): Map<string, BatchItem> {
  return new Map(items.map((i) => [i.vin, i]));
}

export function mergeBatch(
  requested: string[],
  items: BatchItem[]
): BatchItem[] {
  const byVin = indexByVin(items);
  return requested.map((raw) => {
    const vin = raw.trim().toUpperCase();
    return (
      byVin.get(vin) ?? {
        vin,
        status: "failed" as const,
        reason: "Missing from batch response",
      }
    );
  });
}
Enter fullscreen mode Exit fullscreen mode

If a VIN is absent from Results, treat that as a failed item for that VIN, not as success with empty fields.

Retries without doubling work

Retry only failed and maybe partial rows, not the whole batch.

  1. Run batch 1..N.
  2. Collect failed (and optionally thin partial) VINs.
  3. Wait with exponential backoff.
  4. Re-batch only those VINs.
  5. Cap total attempts (for example 3).

Do not infinite-loop on a VIN that fails check-digit validation. Validate length, charset (no I/O/Q), and check digit locally before the network call. Garbage in the batch wastes quota and pollutes ErrorText.

export async function decodeAll(
  vins: string[],
  decodeChunk: (chunk: string[]) => Promise<BatchItem[]>
): Promise<BatchItem[]> {
  const out: BatchItem[] = [];
  for (const chunk of chunkVins(vins)) {
    const first = await decodeChunk(chunk);
    out.push(...first.filter((i) => i.status !== "failed"));
    const retry = first.filter((i) => i.status === "failed").map((i) => i.vin);
    if (retry.length === 0) continue;
    await new Promise((r) => setTimeout(r, 1000 + Math.random() * 500));
    const second = await decodeChunk(retry);
    out.push(...second);
  }
  return out;
}
Enter fullscreen mode Exit fullscreen mode

Tune sleeps from observed errors. Prefer being boring and slow over being blocked.

Product copy for batch UIs

When a user uploads 100 VINs:

  • "Decoded 86 of 100. 9 incomplete. 5 failed validation."
  • Link each failed row to a reason.
  • Offer download of successes only, or successes plus notes columns.
  • Do not label the upload "fully verified" because most rows returned Make.

Batch decode is identity enrichment. It is not salvage, flood, odometer, lien, or theft history.

Operational checklist

  • Validate and dedupe before batching.
  • Chunk and serialize outbound calls.
  • Classify each row: ok / partial / failed.
  • Retry only misses.
  • Cache successes by VIN with a TTL that matches how often you re-check.
  • Log counts, not full VIN lists, when you can avoid retaining PII-like identifiers longer than needed.
  • Surface ErrorText to operators; hide jargon from end buyers or translate it.

Disclosure

I maintain VIN Lookup, a free VIN decode for manufacturer attributes built around public NHTSA-style data. Batch patterns above are the same honesty rules as single decode: show what came back, leave blanks blank, and never pretend a successful batch HTTP call means every vehicle is "clear."

Takeaways

  • DecodeVinValuesBatch is for many distinct VINs, not a license to ignore caching or rate limits.
  • Partial failures are expected; model them explicitly.
  • Match by VIN, retry only failures, validate locally first.
  • Report counts honestly in the UI.

If you already ship single-VIN decode, adding batch is mostly queueing and classification. The hard part is product trust when the spreadsheet is only mostly good.

Top comments (0)