Reading VIN and license plate from a photo

Most rental, FBT, and fleet workflows start with one question: "which vehicle is this?" Asking the user to type a VIN doesn't work — they mistype, abandon, or just skip it. The /v1/vehicle-id endpoint takes a photo and returns a validated VIN, a plate string, and (when the VIN checks out) the year/make/model/trim.

1. The endpoint

bash
curl -X POST https://verify.switchlabs.dev/api/v1/vehicle-id \
  -H "X-API-Key: vai_your_api_key" \
  -F "image=@windshield.jpg"

Response:

json
{
  "vin": {
    "value": "1HGCM82633A004352",
    "confidence": 0.94,
    "valid_format": true,
    "valid_checksum": true
  },
  "plate": {
    "value": "7ABC123",
    "confidence": 0.81,
    "jurisdiction_guess": "CA"
  },
  "decoded": {
    "make": "Honda",
    "model": "Accord",
    "year": 2003,
    "trim": "EX V6"
  },
  "source": "gemini+nhtsa_vpic",
  "processing_time_ms": 1184
}

vin.value is normalized to uppercase and whitespace-stripped. valid_format is true when the string matches ^[A-HJ-NPR-Z0-9]{17}$ (the ISO-3779 alphabet — no I, O, Q); valid_checksum is true when the 9th character matches the weighted-mod-11 check digit. plate.value is the raw visible string — uppercased, whitespace stripped — and jurisdiction_guess is a best-effort state / province / country code (e.g. "CA", "ONTARIO", "MX") based on what's printed on the plate. Either vin or plate can be null when nothing readable is in the frame.

decoded is only set when the VIN passed both the format and checksum checks and NHTSA vPIC returned a match.

2. How it works

The endpoint runs a single gemini-2.0-flash call with responseMimeType: 'application/json' and a structured-output prompt that asks for the VIN, plate, and confidences in a fixed JSON shape — same pattern as the verify endpoint, just with a different schema. We then post-process:

  1. VIN format + checksum. Every claimed VIN is validated by lib/verify-ai/vehicle/vin-checksum.ts, which checks the ISO-3779 alphabet (no I, O, Q) and then the weighted-mod-11 check digit at position 9. A failing check doesn't reject the result — we still return the string — but valid_format / valid_checksum tell you whether to trust it.

  2. NHTSA vPIC decode. When both format and checksum pass, we look up the year/make/model/trim against the free NHTSA vPIC API, implemented in lib/verify-ai/vehicle/decode.ts. Results are cached in the verify_ai_vehicle_cache table keyed by VIN with no TTL — the VIN-to-spec mapping is immutable once a vehicle is built, so a cache hit is always correct.

  3. No plate-owner lookup. We deliberately do not look up plates against owner databases, DMV records, or commercial registries. Plate reads return the visible string and a jurisdiction guess — nothing else. If you need owner data, you'll need a separate contract with a vendor like NMVTIS; we won't proxy that for you.

3. Worked example: extracting a VIN from a windshield

Most US passenger vehicles have a VIN visible through the driver-side windshield. Capture from outside, low angle, with the dashboard placard filling at least a third of the frame:

ts
// Mobile-side, capture and POST
async function captureVin(photoUri: string) {
  const form = new FormData();
  form.append("image", {
    uri: photoUri,
    name: "vin.jpg",
    type: "image/jpeg",
  } as any);
 
  const res = await fetch("https://verify.switchlabs.dev/api/v1/vehicle-id", {
    method: "POST",
    headers: { "X-API-Key": process.env.EXPO_PUBLIC_VERIFY_AI_KEY! },
    body: form,
  });
 
  const data = await res.json();
 
  if (data.vin?.valid_format && data.vin?.valid_checksum) {
    // Trust it
    return { vin: data.vin.value, vehicle: data.decoded };
  }
  if (data.vin?.value) {
    // We read something but format and/or checksum failed — likely
    // an OCR error. Show it to the user for confirmation rather than
    // auto-using it.
    return { vin: data.vin.value, needsConfirmation: true };
  }
  return null;
}

4. Falling back to the plate

Not every angle gets a VIN. If the windshield is dirty, glare-blown, or the placard is missing (common on older imports), fall through to plate:

ts
const id = await captureVehicleId(photo);
 
if (id.vin?.valid_format && id.vin?.valid_checksum) {
  rental.vin = id.vin.value;
  rental.vehicle = id.decoded;
} else if (id.plate?.value) {
  rental.plate = id.plate.value;
  rental.jurisdiction = id.plate.jurisdiction_guess;
} else {
  // Neither worked — ask the user to type it.
  promptManualEntry();
}

We don't run a second model when the first one comes back empty — it's usually a framing problem, not a model problem. The right fix is a UI prompt, not a retry. (CameraCapture in the inspection flow surfaces exactly that prompt by default.)

5. Populating a verification's vehicle block

The verify_ai_verifications row carries a nullable vehicle_identifier JSONB column with this shape:

json
{
  "vin":    { "value": "1HGCM82633A004352" },
  "plate":  { "value": "7ABC123" },
  "decoded": { "make": "Honda", "model": "Accord", "year": 2003 }
}

That column is what powers the vehicle block on the PDF condition report. The /v1/verify endpoint does not auto-populate it today — call /v1/vehicle-id on the same image (or a tighter VIN crop) and write the result back yourself, e.g. through a Supabase update keyed by verification id. When vehicle_identifier is null, the PDF just skips the vehicle subblock.

6. Validating a VIN client-side

The checksum is cheap to validate without round-tripping. The server implementation lives in lib/verify-ai/vehicle/vin-checksum.ts and exports validateVin(raw): { vin, valid_format, valid_checksum } plus the convenience isValidVin(raw): boolean. Below is a self-contained boolean version you can drop into a client to gate "submit" before sending:

ts
// Self-contained — mirrors validateVin() from
// lib/verify-ai/vehicle/vin-checksum.ts (the server version returns
// { vin, valid_format, valid_checksum } instead of a boolean).
const WEIGHTS = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2];
const VALUES: Record<string, number> = {
  A: 1, B: 2, C: 3, D: 4, E: 5, F: 6, G: 7, H: 8,
  J: 1, K: 2, L: 3, M: 4, N: 5, P: 7, R: 9,
  S: 2, T: 3, U: 4, V: 5, W: 6, X: 7, Y: 8, Z: 9,
};
 
export function isValidVin(raw: string): boolean {
  const vin = raw.trim().toUpperCase().replace(/\s+/g, "");
  if (!/^[A-HJ-NPR-Z0-9]{17}$/.test(vin)) return false;
 
  let sum = 0;
  for (let i = 0; i < 17; i++) {
    const c = vin[i];
    const v = /[0-9]/.test(c) ? Number(c) : VALUES[c];
    if (v === undefined) return false;
    sum += v * WEIGHTS[i];
  }
 
  const check = sum % 11;
  const expected = check === 10 ? "X" : String(check);
  return vin[8] === expected;
}

7. Caching and cost

  • VIN decode cache. Hits to verify_ai_vehicle_cache are free and immediate. A fresh decode costs one NHTSA vPIC call (free, but rate limited) plus one gemini-2.0-flash call (billed at the same rate as /v1/verify).
  • One call per request. /vehicle-id is a single Gemini call; there is no in-line piggyback on /v1/verify today, so a verify + vehicle-id flow costs two calls.
  • No retries on empty. If both VIN and plate come back null, you paid for the call but won't get charged a second time for an automatic retry — we treat it as a framing problem, not a transient failure.

Privacy and what we don't do

  • We do not maintain or query a plate-owner database. The string comes off the photo and that's it.
  • We do not store the raw image past the lifetime of the verifying request — the row in verify_ai_verifications references the storage path, but /vehicle-id calls without policy don't create a verification record.
  • The VIN-to-spec cache is intentionally indefinite. If you need a privacy-driven flush (e.g. a user erasure request), delete from verify_ai_vehicle_cache by VIN.

What's next

Get in Touch

Questions about pricing, integrations, or custom deployments? We'd love to hear from you.