# Check a VIN at the registration counter


![The Department of Vehicle Services counter with a 2022 Ram 3500 decoded into the registration form, beside the pane that lists the Cardog calls behind it](https://cardog.app/marketing/guides/registration-counter/desktop.jpg "2880x1800")

You'll build **Department of Vehicle Services**, a motor vehicle office's app: the clerk scans the VIN and the form fills itself, gaps marked. [See what it does](https://cardog.app/use-cases/registration-counter).

- **Time:** About 40 minutes, or 5 to clone and run.
- **You need:** Node 20 or later and pnpm, A Cardog API key, Some Next.js
- **Cardog SDK:** `cardog.vin.image`, `cardog.v2.vin.getByVin`, `cardog.v2.recalls.entity`
- **Clone the finished app:** [cardog-ai/example-registration-counter](https://github.com/cardog-ai/example-registration-counter), or build it step by step below.

```bash
git clone https://github.com/cardog-ai/example-registration-counter.git
```

## 1. Get an API key

Every request to Cardog carries a key. Create one under [Account → API](https://cardog.app/account/api), then copy `.env.example` to `.env.local`. The app reads it on the server; the browser never sees it.

The example values run as shipped. Without a key the counter runs on responses recorded for five sample VINs, so you can follow along before you sign up.

`BETTER_AUTH_SECRET` ships as a development placeholder, so a fresh clone builds and runs. Replace it with your own (`openssl rand -base64 32`) before you deploy: a production server will not start without one, and warns on every start while the placeholder is set.

**`.env.example`**

```ini
# Copy to .env.local. With these values the app runs on recorded fixtures
# and a development auth secret.

# Your Cardog API key (https://cardog.app/account/api). Server-side only; it
# never reaches the browser. Leave empty for fixture mode.
CARDOG_API_KEY=

# Signs sessions. This value is a DEVELOPMENT PLACEHOLDER so a fresh clone
# builds and runs; the app warns on every production start while it is set.
# Replace it before deploying: openssl rand -base64 32
BETTER_AUTH_SECRET=dvs-counter-dev-only-secret-replace-before-deploying

# Where the app is served. Needed when it is not http://localhost:3029.
BETTER_AUTH_URL=http://localhost:3029

# SQLite file. Created on first run.
DATABASE_PATH=data/counter.db

# The seeded review logins are listed in README.md.
```

## 2. Create the Next.js app

The counter is one Next.js app: the counter screen, the day log, the supervisor queue, and the server code that talks to Cardog. SQLite keeps it to one process. `better-sqlite3` is a native module, so the config loads it at runtime.

A counter tool is read across a desk, so the theme is plain: 18px base type, black on white, one action colour and three status colours.

**`terminal`**

```bash
pnpm create next-app@latest registration-counter --ts --app --tailwind --src-dir
cd registration-counter
pnpm add @cardog/api better-auth better-sqlite3 drizzle-orm server-only
pnpm add -D drizzle-kit tsx @types/better-sqlite3
```

**`next.config.ts`**

```ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  // better-sqlite3 is a native module: load it from node_modules at runtime.
  serverExternalPackages: ["better-sqlite3"],
  // This app is its own root, even when it sits inside another repository.
  outputFileTracingRoot: process.cwd(),
};

export default nextConfig;
```

## 3. Add staff sign-in

Clerks and supervisors sign in with email and password, and a session lasts one shift. `role` is a user field sign-up cannot set: everyone who signs up is a clerk, and the seed script makes the supervisor.

`authSecret()` picks the secret that signs sessions: yours whenever `BETTER_AUTH_SECRET` is set, and the development placeholder only on your machine and during `next build`. A production server with no secret stops, and one still on the placeholder logs a warning.

**`src/lib/auth.ts`**

```ts
function authSecret(): string {
  const set = process.env.BETTER_AUTH_SECRET?.trim();
  const production = process.env.NODE_ENV === "production" && process.env.NEXT_PHASE !== "phase-production-build";
  if (set) {
    if (production && set === DEV_SECRET) {
      console.warn("[dvs-counter] BETTER_AUTH_SECRET is the development placeholder. Replace it before deploying.");
    }
    return set;
  }
  if (!production) return DEV_SECRET;
  throw new Error("[dvs-counter] BETTER_AUTH_SECRET is not set. Generate one with `openssl rand -base64 32`.");
}
```

**`src/lib/auth.ts`**

```ts
export const auth = betterAuth({
  appName: "Department of Vehicle Services",
  baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3029",
  secret: authSecret(),
  database: drizzleAdapter(db, {
    provider: "sqlite",
    schema: { user: schema.user, session: schema.session, account: schema.account, verification: schema.verification },
  }),
  emailAndPassword: { enabled: true, minPasswordLength: 8, autoSignIn: true },
  // A counter shift, not a month: sessions last 12 hours.
  session: { expiresIn: 60 * 60 * 12, updateAge: 60 * 60 },
  user: {
    additionalFields: {
      role: { type: "string", required: false, defaultValue: "clerk", input: false },
    },
  },
  plugins: [nextCookies()],
});
```

**`src/app/api/auth/[...all]/route.ts`**

```ts
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth";

export const { GET, POST } = toNextJsHandler(auth);
```

## 4. Construct the Cardog client on the server

One file holds every Cardog call, and there are three. The client is built once from the key, and the `server-only` import fails the build if a browser bundle reaches for it. Each call is timed and logged, which is what the "What the app called" pane shows.

SDK call: `new CardogClient({ apiKey, appId })`

**`src/lib/cardog.ts`**

```ts
const apiKey = process.env.CARDOG_API_KEY?.trim();

/** The one Cardog client. Server-side only. */
export const cardog: CardogClient | null = apiKey
  ? new CardogClient({ apiKey, appId: "dvs-registration-counter", baseUrl: process.env.CARDOG_BASE_URL || undefined })
  : null;

export const dataMode: "live" | "fixture" = cardog ? "live" : "fixture";
export { FIXTURE_VINS };

// The response types, read off the client's own method signatures.
type V2 = CardogClient["v2"];
export type VinIdentity = Awaited<ReturnType<V2["vin"]["getByVin"]>>;
export type ModelYearRecalls = Awaited<ReturnType<V2["recalls"]["entity"]>>;
export type VinImageRead = Awaited<ReturnType<CardogClient["vin"]["image"]>>;

export interface Result<T> {
  data: T | null;
  call: CallRecord;
  error: string | null;
}
```

## 5. Build the VIN strip

Most VINs at a counter come from a barcode scanner, which adds spaces, dashes, Code 39 asterisks and sometimes a leading `I`. Clean those off first.

Then work out the check digit as the VIN goes in. The decode reads the build, positions 1 to 8, 10 and 11, so a VIN one character off at position 9 can still decode; the counter's own check is what catches it.

**`src/lib/vin.ts`**

```ts
export function normalizeVin(raw: string) {
  let v = raw.toUpperCase().replace(/[\s\-*]/g, "");
  if (v.length === 18 && v.startsWith("I")) v = v.slice(1);
  return v.slice(0, 17);
}
```

**`src/lib/vin.ts`**

```ts
export function checkVin(vin: string): VinCheck {
  if (!vin) return { state: "empty" };
  const bad = vin.search(/[IOQ]/);
  if (bad >= 0) {
    const c = vin[bad];
    const hint = c === "I" ? "1 (one)" : "0 (zero)";
    return {
      state: "invalid",
      at: bad,
      message: `Character ${bad + 1} is the letter ${c}. VINs never use ${c}: it is almost always ${hint}.`,
    };
  }
  const other = vin.search(/[^A-HJ-NPR-Z0-9]/);
  if (other >= 0) return { state: "invalid", at: other, message: `Character ${other + 1} is not a letter or a number.` };
  if (vin.length < 17) return { state: "typing", remaining: 17 - vin.length };
  const expected = expectedCheckDigit(vin);
  if (expected && expected !== vin[8]) {
    return {
      state: "check-mismatch",
      expected,
      message: `Check digit fails. The 9th character is ${vin[8]}; the other sixteen say it should be ${expected}. Read the VIN from the vehicle again.`,
    };
  }
  return { state: "ok", message: "17 characters. Check digit passes." };
}
```

![The VIN strip with a VIN one character off and its check digit failing](https://cardog.app/marketing/guides/registration-counter/vin.jpg "2880x1800")

## 6. Read the VIN from a photo

Some labels are too worn for the scanner. The clerk photographs the label or the document instead; the route sends the image from the server and returns the VIN, which then goes through the same clean-up and check as a scanned one.

SDK call: `cardog.vin.image(base64)`

**What comes back.** `success` and `result`, the VIN read from the photo.

**`src/lib/cardog.ts`**

```ts
export async function readVinFromImage(base64: string, step: string): Promise<Result<VinImageRead>> {
  return run<VinImageRead>({
    step,
    vin: FIXTURE_VINS[0].vin,
    key: "image",
    method: "cardog.vin.image",
    args: [`<${Math.round((base64.length * 3) / 4 / 1024)} KB image, base64>`],
    http: "POST /vin/image",
    returns: "The VIN read from the photo",
    live: (c) => c.vin.image(base64),
  });
}
```

**`src/app/api/vin/photo/route.ts`**

```ts
import { NextResponse } from "next/server";
import { getViewer } from "@/lib/session";
import { readVinFromImage } from "@/lib/cardog";
import { normalizeVin } from "@/lib/vin";

const MAX_BYTES = 8 * 1024 * 1024;

/** POST multipart { photo } → the VIN read from it. Counter staff only. */
export async function POST(req: Request) {
  const me = await getViewer();
  if (!me) return NextResponse.json({ ok: false, error: "Sign in first." }, { status: 401 });
  const form = await req.formData().catch(() => null);
  const file = form?.get("photo");
  if (!(file instanceof File)) return NextResponse.json({ ok: false, error: "No photo received." }, { status: 400 });
  if (file.size > MAX_BYTES) return NextResponse.json({ ok: false, error: "That photo is over 8 MB." }, { status: 413 });

  const base64 = Buffer.from(await file.arrayBuffer()).toString("base64");
  const r = await readVinFromImage(base64, "Counter · photo");
  const vin = r.data?.success ? normalizeVin(r.data.result) : null;
  const ok = Boolean(vin && vin.length === 17);
  return NextResponse.json({
    ok,
    vin,
    error: ok ? null : (r.error ?? "No VIN found in that photo. Take it closer and straight on, or type the VIN."),
    calls: [r.call],
  });
}
```

## 7. Decode the VIN into the form

Send the VIN from a route handler, never from the browser. The decode returns year, make and model as names, and body style, fuel, weight class and vehicle type as entity refs.

Map each ref onto the form's own code list. When the decode leaves a field empty, or returns a value the form has no code for, the field is left for the clerk with the reason in words. Nothing is guessed.

SDK call: `cardog.v2.vin.getByVin(vin)`

**What comes back.** Year, make and model, and `refs` such as `bodyStyle`, `fuelType`, `electrificationLevel`, `vehicleType`, `gvwr` and `modelYear`.

**`src/lib/cardog.ts`**

```ts
export function decodeVin(vin: string, step: string) {
  return run<VinIdentity>({
    step,
    vin,
    key: "decode",
    method: "cardog.v2.vin.getByVin",
    args: [vin],
    http: `GET /v2/vin/${vin}`,
    returns: "Identity card: year, make, model, and the refs for body, fuel, vehicle type and weight class",
    live: (c) => c.v2.vin.getByVin(vin),
  });
}
```

**`src/lib/registration.ts`**

```ts
export function fieldsFromDecode(d: DecodeLike): RegFields {
  const r = d.refs;
  const coded = (key: FieldKey, ref: string | null | undefined, code: string | null, noun: string): RegField => {
    if (code) return { key, value: code, decoded: code, source: "vin", gap: null };
    const gap = ref
      ? `The decode says “${words(ref)}”, which has no ${noun} code on this form. Choose one.`
      : `The VIN does not say the ${noun}. Enter it from the document or the vehicle.`;
    return { key, value: null, decoded: null, source: "manual", gap };
  };
  const text = (key: FieldKey, v: string | number | null, noun: string): RegField =>
    v !== null && v !== ""
      ? { key, value: String(v), decoded: String(v), source: "vin", gap: null }
      : { key, value: null, decoded: null, source: "manual", gap: `The VIN does not say the ${noun}. Enter it from the document.` };

  return {
    year: text("year", d.year, "model year"),
    make: text("make", d.make, "make"),
    model: text("model", d.model, "model"),
    body: coded("body", r.bodyStyle, bodyCode(r.bodyStyle), "body type"),
    fuel: coded("fuel", r.fuelType ?? r.electrificationLevel, fuelCode(r.fuelType, r.electrificationLevel), "fuel"),
    weightClass: coded("weightClass", r.gvwr, weightCode(r.gvwr), "weight class"),
    vehicleType: coded("vehicleType", r.vehicleType, typeCode(r.vehicleType), "vehicle type"),
  };
}
```

![The registration form filled from the VIN, each field marked as from the VIN](https://cardog.app/marketing/guides/registration-counter/counter.jpg "2880x1800")

## 8. Add the model year's recall count

After the decode, read the recalls issued for the model and year and show the count as one line of information. It is not a check of the vehicle at the counter and it is not part of the registration.

The read is held per clerk for an hour, so recording the check does not pay for the same calls twice.

SDK call: `cardog.v2.recalls.entity(modelYearRef, { limit: 5 })`

**What comes back.** `total` and the newest campaigns issued for the model year, from Transport Canada, NHTSA and other authorities.

**`src/lib/cardog.ts`**

```ts
export function readModelYearRecalls(modelYearRef: string, vin: string, step: string) {
  return run<ModelYearRecalls>({
    step,
    vin,
    key: "recalls",
    method: "cardog.v2.recalls.entity",
    args: [modelYearRef, { limit: 5 }],
    http: `GET /v2/recalls/entity/${modelYearRef}?limit=5`,
    returns: "How many recall campaigns were issued for the model year, and the newest five",
    live: (c) => c.v2.recalls.entity(modelYearRef, { limit: 5 }),
  });
}
```

**`src/lib/counter-read.ts`**

```ts
export async function readVin(
  clerkId: string,
  vin: string,
  step = "Counter · decode"
): Promise<{ ok: true; read: CounterRead } | { ok: false; error: string; calls: CallRecord[] }> {
  const decoded = await decodeVin(vin, step);
  if (!decoded.data) return { ok: false, error: decoded.error ?? "The decode failed.", calls: [decoded.call] };
  const d: VinIdentity = decoded.data;
  if (!d.valid) {
    const why = d.reasons?.length ? ` (${d.reasons.join(", ")})` : "";
    return {
      ok: false,
      error: `This VIN does not decode to a vehicle${why}. Read it from the vehicle again, or register it by hand.`,
      calls: [decoded.call],
    };
  }

  const calls = [decoded.call];
  let recalls: RecallLine | null = null;
  if (d.refs.modelYear) {
    const r = await readModelYearRecalls(d.refs.modelYear, vin, step);
    calls.push(r.call);
    if (r.data) recalls = recallLine(d.refs.modelYear, r.data);
  }

  const check = checkVin(vin);
  const read: CounterRead = {
    vin,
    checkDigit: check.state === "check-mismatch" ? "mismatch" : "ok",
    vehicle: [d.year, d.make, d.model].filter(Boolean).join(" ") || null,
    fields: fieldsFromDecode(d),
    recalls,
    calls,
  };
  held.set(keyOf(clerkId, vin), { read, at: Date.now() });
  return { ok: true, read };
}
```

**`src/app/api/vin/decode/route.ts`**

```ts
import { NextResponse } from "next/server";
import { getViewer } from "@/lib/session";
import { readVin } from "@/lib/counter-read";
import { checkVin, normalizeVin } from "@/lib/vin";

/**
 * POST { vin, force? } → the decode as registration fields, plus the
 * model year's recall count. A VIN that fails its check digit is refused
 * unless `force` is set: the clerk chose to record it as typed, and the
 * check will be flagged.
 */
export async function POST(req: Request) {
  const me = await getViewer();
  if (!me) return NextResponse.json({ ok: false, error: "Sign in first." }, { status: 401 });
  const body = (await req.json().catch(() => ({}))) as { vin?: string; force?: boolean };
  const vin = normalizeVin(body.vin ?? "");
  const check = checkVin(vin);
  if (check.state === "empty" || check.state === "typing" || check.state === "invalid") {
    return NextResponse.json({ ok: false, error: "A VIN has 17 letters and numbers, without I, O or Q." }, { status: 400 });
  }
  if (check.state === "check-mismatch" && !body.force) {
    return NextResponse.json({ ok: false, error: check.message, checkDigit: "mismatch" }, { status: 422 });
  }
  return NextResponse.json(await readVin(me.id, vin));
}
```

## 9. Check the document and record

The customer's ownership document carries a make and a year. Compare both with the VIN as the clerk types them; makes compare without case, spaces or punctuation, and nothing else is assumed.

A document that disagrees holds the check for a supervisor. A field entered or changed by hand, or a VIN recorded despite its check digit, flags it. The server works the outcome out again from the read it holds, so it never depends on the browser.

**`src/lib/registration.ts`**

```ts
export function compareDocument(fields: RegFields, stated: Stated): Mismatch[] {
  const out: Mismatch[] = [];
  const vinMake = fields.make.decoded;
  const vinYear = fields.year.decoded;
  if (vinMake && stated.make.trim() && squashMake(vinMake) !== squashMake(stated.make)) {
    out.push({ field: "make", document: stated.make.trim(), vin: vinMake });
  }
  if (vinYear && stated.year.trim() && stated.year.trim() !== vinYear) {
    out.push({ field: "year", document: stated.year.trim(), vin: vinYear });
  }
  return out;
}
```

**`src/lib/registration.ts`**

```ts
export function statusFor(mismatches: Mismatch[], flags: Flag[]): Status {
  if (mismatches.length) return "held";
  if (flags.length) return "flagged";
  return "clear";
}
```

**`src/app/actions.ts`**

```ts
export async function recordCheck(input: RecordInput): Promise<RecordResult> {
  const me = await getViewer();
  if (!me) return { ok: false, error: "Your session has ended. Sign in again." };
  const vin = normalizeVin(input.vin);

  let read = heldRead(me.id, vin);
  if (!read) {
    const r = await readVin(me.id, vin, "Counter · record");
    if (!r.ok) return { ok: false, error: r.error };
    read = r.read;
  }

  const fields = withEntries(read.fields, input.entries);
  const stated: Stated = { make: input.stated.make.trim(), year: input.stated.year.trim() };
  const gaps = missing(fields, stated);
  if (gaps.length) return { ok: false, error: `Still to enter: ${gaps.join(", ")}.` };

  const mismatches = compareDocument(fields, stated);
  const flags = flagsFor(fields, read.checkDigit);
  const status = statusFor(mismatches, flags);
  const note = input.note.trim().slice(0, 600) || null;

  const row = createCheck({
    vin,
    clerkId: me.id,
    clerkName: me.name,
    status,
    checkDigit: read.checkDigit,
    year: fields.year.value ? Number(fields.year.value) : null,
    make: fields.make.value,
    model: fields.model.value,
    fields,
    stated,
    mismatches,
    flags,
    recalls: read.recalls,
    clerkNote: note,
    calls: read.calls,
  });
  dropRead(me.id, vin);
  revalidatePath("/log");
  revalidatePath("/queue");
  return { ok: true, id: row.id, number: row.number, status };
}
```

![A check held for a supervisor because the document's make differs from the VIN's](https://cardog.app/marketing/guides/registration-counter/held.jpg "2880x1800")

## 10. Build the supervisor queue

Held checks come first, then flagged ones, oldest first within each. The supervisor opens a check, sees each field and where it came from, and overrides it or returns it to the clerk, always with a reason that stays on the record.

**`src/lib/checks.ts`**

```ts
export function queue(): Check[] {
  const rows = db
    .select()
    .from(schema.check)
    .where(inArray(schema.check.status, ["held", "flagged"]))
    .orderBy(asc(schema.check.createdAt))
    .all()
    .map(shape);
  return [...rows.filter((r) => r.status === "held"), ...rows.filter((r) => r.status === "flagged")];
}
```

**`src/app/actions.ts`**

```ts
export async function decideCheck(id: string, outcome: "approved" | "returned", reason: string): Promise<DecideResult> {
  const me = await getViewer();
  if (!me || me.role !== "supervisor") return { ok: false, error: "Only a supervisor can decide a check." };
  const why = reason.trim();
  if (why.length < 8) return { ok: false, error: "Give the reason in a few words, so the record says why." };
  if (!decide(id, me.name, outcome, why.slice(0, 1000))) {
    return { ok: false, error: "This check has already been decided." };
  }
  revalidatePath("/queue");
  revalidatePath("/log");
  revalidatePath(`/checks/${id}`);
  return { ok: true };
}
```

![A supervisor's view of one held check, with the override and return options](https://cardog.app/marketing/guides/registration-counter/check.jpg "2880x1800")

## 11. Deploy

The counter ships as one container with SQLite on a volume. Build the image, mount the volume, and set the key, the auth secret and the public URL in the host's environment. On a serverless host, swap SQLite for a hosted database; the Cardog code does not change.

**`Dockerfile`**

```dockerfile
# Registration counter: one container, SQLite on a mounted volume.
#   docker build -t dvs-counter .
#   docker run -p 3029:3029 -v dvs-counter-data:/app/data \
#     -e CARDOG_API_KEY=... -e BETTER_AUTH_SECRET=... -e BETTER_AUTH_URL=https://your.host dvs-counter
FROM node:22-bookworm-slim AS build
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build

FROM node:22-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
RUN corepack enable
COPY --from=build /app ./
VOLUME /app/data
EXPOSE 3029
CMD ["pnpm", "start"]
```

**`terminal`**

```bash
docker build -t registration-counter .
docker run -p 3029:3029 -v counter-data:/app/data \
  -e CARDOG_API_KEY=your_cardog_api_key \
  -e BETTER_AUTH_SECRET=$(openssl rand -base64 32) \
  -e BETTER_AUTH_URL=https://counter.example.com \
  registration-counter
```

## Next steps

- [VIN decode](https://cardog.app/docs/vin): Every field the decode returns.
- [Recalls](https://cardog.app/docs/recalls): Campaigns by model year, from each authority.
- [Entity refs](https://cardog.app/docs/entity-resolution): How refs like body style and weight class are named.


---

Previous: [Build a fleet management app](https://cardog.app/docs/guides/fleet-management.md) (Build guides)
Next: [Check a ride-share driver's vehicle](https://cardog.app/docs/guides/rideshare-vehicle-check.md) (Build guides)
