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
curl -X POST https://verify.switchlabs.dev/api/v1/vehicle-id \
-H "X-API-Key: vai_your_api_key" \
-F "image=@windshield.jpg"Response:
{
"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:
-
VIN format + checksum. Every claimed VIN is validated by
lib/verify-ai/vehicle/vin-checksum.ts, which checks the ISO-3779 alphabet (noI,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 — butvalid_format/valid_checksumtell you whether to trust it. -
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 theverify_ai_vehicle_cachetable 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. -
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:
// 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:
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:
{
"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:
// 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_cacheare free and immediate. A fresh decode costs one NHTSA vPIC call (free, but rate limited) plus onegemini-2.0-flashcall (billed at the same rate as/v1/verify). - One call per request.
/vehicle-idis a single Gemini call; there is no in-line piggyback on/v1/verifytoday, 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_verificationsreferences the storage path, but/vehicle-idcalls withoutpolicydon'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_cacheby VIN.
What's next
- Sending an inspection link —
use vehicle ID to pre-fill
context.vinbefore sending a session. - Generating PDF condition reports — the vehicle block on the PDF is populated straight from this endpoint's output.
- Verifying parked vehicles
— pair a
/v1/verifyparking check with/vehicle-idon the same shot to stampvehicle_identifieronto the verification row.