Build guides
Check loan collateral at origination
Last updated:
Build Orvanne Credit's loan app on Next.js and better-auth: an application with the vehicle by VIN, its market value, loan-to-value, whether it is listed for sale, and a credit desk. See the use case.

You'll build Orvanne Credit, a vehicle lender's app: borrowers add the car by VIN; the credit desk sees its value and loan-to-value. See what it does.
- 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.v2.vin.getByVin,cardog.v2.quotes.getByRef,cardog.v2.listings.byVin,cardog.v2.recalls.entity - Clone the finished app: cardog-ai/example-loan-collateral, or build it step by step below.
git clone https://github.com/cardog-ai/example-loan-collateral.git1. Get an API key
Every request to Cardog carries a key. Create one under Account → API, then copy .env.example to .env.local. The app reads the key on the server; the browser never sees it.
The example values run as shipped. Without a key the app runs on responses recorded for four VINs, which the VIN field offers as samples: three listed for sale and one that is not.
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
# Copy to .env.local. With these values the app runs on stored 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=orvanne-dev-only-secret-replace-before-deploying
# Where the app is served. Needed when it is not http://localhost:3021.
BETTER_AUTH_URL=http://localhost:3021
# SQLite file. Created on first run.
DATABASE_PATH=data/orvanne.db
# Seeded review logins (created by `pnpm db:setup` on an empty database):
# borrower borrower@orvanne.example / orvanne-borrower
# officer officer@orvanne.example / orvanne-officer2. Create the Next.js app
Orvanne is one Next.js app: the borrower's applications, the credit desk, and the server code that talks to Cardog. SQLite keeps it to one process with nothing else to run. better-sqlite3 is a native module, so the config loads it at runtime.
terminal
pnpm create next-app@latest orvanne --ts --app --tailwind --src-dir
cd orvanne
pnpm add @cardog/api better-auth better-sqlite3 drizzle-orm server-only
pnpm add -D drizzle-kit tsx @types/better-sqlite3next.config.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 sign-in with better-auth
Borrowers and credit officers sign in with email and password, with sessions in the same SQLite file as the applications. role is a user field sign-up cannot set, so nobody makes themselves an officer from the form.
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
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("[orvanne] BETTER_AUTH_SECRET is the development placeholder. Replace it before deploying.");
}
return set;
}
if (!production) return DEV_SECRET;
throw new Error("[orvanne] BETTER_AUTH_SECRET is not set. Generate one with `openssl rand -base64 32`.");
}src/lib/auth.ts
export const auth = betterAuth({
appName: "Orvanne Credit",
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3021",
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 },
user: {
additionalFields: {
role: { type: "string", required: false, defaultValue: "borrower", input: false },
},
},
plugins: [nextCookies()],
});src/app/api/auth/[...all]/route.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. 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 app's "What the app called" pane shows.
SDK call: new CardogClient({ apiKey, appId })
src/lib/cardog.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: "orvanne-collateral", baseUrl: process.env.CARDOG_BASE_URL || undefined })
: null;
export const dataMode: "live" | "fixture" = cardog ? "live" : "fixture";
export { FIXTURE_VINS };
// The v2 response types, read off the client's own method signatures.
type V2 = CardogClient["v2"];
export type VinIdentity = Awaited<ReturnType<V2["vin"]["getByVin"]>>;
export type MarketQuote = Awaited<ReturnType<V2["quotes"]["getByRef"]>>;
export type ListingDetail = Awaited<ReturnType<V2["listings"]["byVin"]>>;
export type ModelYearRecalls = Awaited<ReturnType<V2["recalls"]["entity"]>>;
export interface Result<T> {
data: T | null;
call: CallRecord;
error: string | null;
/** The HTTP status when the call failed; 404 is an answer for a listing. */
status: number | null;
}5. Start the application
The borrower names the loan first: buying or refinancing, the amount and the term. That makes a draft, and every change to it writes a row to the audit trail.
src/app/actions.ts
export async function createApplication(input: {
amount: number;
termMonths: number;
purpose: string;
}): Promise<ActionResult> {
const me = await requireViewer();
const t = cleanTerms(input);
if ("error" in t) return { ok: false, error: t.error! };
const id = crypto.randomUUID();
db.insert(application)
.values({ id, userId: me.id, number: newNumber(), status: "draft", calls: [], ...t })
.run();
record(id, me, "created", null, { amount: t.amount, termMonths: t.termMonths, purpose: t.purpose });
revalidatePath("/applications");
redirect(`/applications/${id}`);
}
6. Check the VIN as it is typed
Most bad VINs are typos, and the ninth character catches them. Check it in the browser before spending a request: an I or O is almost always a 1 or 0, and a check digit that does not add up means a character is wrong.
src/lib/vin.ts
export function checkVin(vin: string): VinCheck {
if (!vin) return { state: "empty" };
const bad = vin.match(/[IOQ]/);
if (bad) {
const hint = bad[0] === "I" ? "1" : "0";
return { state: "invalid", message: `VINs never use the letter ${bad[0]}. It's probably the number ${hint}.` };
}
if (/[^A-HJ-NPR-Z0-9]/.test(vin)) return { state: "invalid", message: "Only letters and numbers." };
if (vin.length < 17) return { state: "typing", remaining: 17 - vin.length };
const expected = expectedCheckDigit(vin);
if (expected && expected !== vin[8]) {
return {
state: "check-mismatch",
message: `The 9th character should be ${expected} for this VIN. Check for a mistyped character.`,
};
}
return { state: "ok", message: "17 characters, check digit matches." };
}7. Decode the VIN
The decode is ledger line 01: who the vehicle is. Send the VIN from the server, never from the browser. The model-year ref it returns keys the market read and the recalls.
SDK call: cardog.v2.vin.getByVin(vin)
What comes back. Year, make, model, trim, body, fuel and drive, and refs.modelYear.
src/lib/cardog.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, trim and the entity refs",
live: (c) => c.v2.vin.getByVin(vin),
});
}8. Read the market value
Ledger line 02 is the quote: the asking prices of live Canadian listings for the model year, across all trims and mileages. The ledger says so; it is a market range, not an appraisal of this vehicle.
SDK call: cardog.v2.quotes.getByRef(modelYearRef)
What comes back. priceP25, priceMedian and priceP75, the live listing count and asOf.
src/lib/cardog.ts
export function readMarketQuote(modelYearRef: string, vin: string, step: string) {
return run<MarketQuote>({
step,
vin,
key: "quote",
method: "cardog.v2.quotes.getByRef",
args: [modelYearRef],
http: `GET /v2/quotes/${modelYearRef}`,
returns: "Asking-price quartiles and the live listing count for the model year",
live: (c) => c.v2.quotes.getByRef(modelYearRef),
});
}9. Work out the loan-to-value
Ledger line 03 needs no call: the amount divided by the market median, and again by the low end of the range for the conservative reading. It is recomputed on every keystroke as the amount changes. The bands are Orvanne's policy, not the data's, so they live in one constant.
src/lib/collateral.ts
export const LTV_BANDS = [
{ max: 0.9, key: "within", label: "Within guideline" },
{ max: 1.1, key: "watch", label: "Near the line" },
{ max: Infinity, key: "over", label: "Over guideline" },
] as const;
export type LtvBand = (typeof LTV_BANDS)[number]["key"];
export interface Ltv {
/** Amount ÷ market median. */
ratio: number;
/** Amount ÷ the low end of the range: the conservative reading. */
ratioLow: number;
band: LtvBand;
label: string;
}src/lib/collateral.ts
export function loanToValue(amount: number, value: MarketValue | null): Ltv | null {
if (!value || value.mid <= 0 || !(amount > 0)) return null;
const ratio = amount / value.mid;
const b = LTV_BANDS.find((x) => ratio <= x.max)!;
return { ratio, ratioLow: amount / value.low, band: b.key, label: b.label };
}
10. See whether the VIN is listed for sale
Ledger line 04 asks whether this exact VIN has a live listing: the asking price, when it was first listed, the odometer and the trim as listed. A 404 means none of the listings Cardog sees today carries the VIN; the app shows "Not listed for sale", an answer rather than an error.
SDK call: cardog.v2.listings.byVin(vin)
What comes back. The live listing for the VIN, with price, odometer, trim and createdAt, or a 404.
src/lib/cardog.ts
export function readListingByVin(vin: string, step: string) {
return run<ListingDetail>({
step,
vin,
key: "listing",
method: "cardog.v2.listings.byVin",
args: [vin],
http: `GET /v2/listings/vin/${vin}`,
returns: "The vehicle's live listing (asking price, odometer, first listed), or 404 when it is not listed",
live: (c) => c.v2.listings.byVin(vin),
});
}
11. Run the check and submit
The check runs the decode first, then the quote and the listing side by side. The result and its call log are saved to the application, so the pane replays it later. Once the vehicle is checked, the borrower submits to the credit desk.
src/lib/check.ts
export async function runCollateralCheck(vin: string, step: string): Promise<CheckResult> {
const decoded = await decodeVin(vin, step);
if (!decoded.data) {
return {
ok: false,
error: decoded.error ?? "The VIN could not be decoded.",
collateral: null,
calls: [decoded.call],
};
}
const modelYear = decoded.data.refs?.modelYear ?? null;
const [quote, listing] = await Promise.all([
modelYear ? readMarketQuote(modelYear, vin, step) : Promise.resolve(null),
readListingByVin(vin, step),
]);
const calls = [decoded.call, ...(quote ? [quote.call] : []), listing.call];
const collateral = buildCollateral(vin, {
decode: decoded.data,
quote: quote?.data ?? null,
listing: listing.data,
listingStatus: listing.status,
});
return { ok: true, error: null, collateral, calls };
}src/app/actions.ts
export async function checkVehicle(id: string, rawVin: string): Promise<ActionResult> {
const me = await requireViewer();
const app = applicationForUser(id, me.id);
if (!app) return { ok: false, error: "Not found." };
if (!editable(app.status)) return { ok: false, error: "This application is with the credit desk." };
const vin = normalizeVin(rawVin);
if (vin.length !== 17) return { ok: false, error: "A VIN has 17 characters." };
const r = await runCollateralCheck(vin, "Add the vehicle");
if (!r.ok || !r.collateral) return { ok: false, error: r.error ?? "The vehicle could not be read." };
db.update(application)
.set({
vin,
vehicle: vehicleName(r.collateral.identity),
collateral: r.collateral,
recalls: null,
calls: r.calls,
updatedAt: new Date(),
})
.where(eq(application.id, id))
.run();
record(id, me, "vehicle_checked", null, { vin, vehicle: vehicleName(r.collateral.identity) });
revalidatePath(`/applications/${id}`);
return { ok: true, calls: r.calls, collateral: r.collateral };
}src/app/actions.ts
export async function submitApplication(id: string): Promise<ActionResult> {
const me = await requireViewer();
const app = applicationForUser(id, me.id);
if (!app) return { ok: false, error: "Not found." };
if (!editable(app.status)) return { ok: false, error: "Already with the credit desk." };
if (!app.collateral) return { ok: false, error: "Add the vehicle first." };
db.update(application)
.set({
status: "submitted",
submittedAt: new Date(),
decidedAt: null,
decidedBy: null,
decisionNote: null,
approvedAmount: null,
updatedAt: new Date(),
})
.where(eq(application.id, id))
.run();
const ltv = loanToValue(app.amount, app.collateral.value);
record(id, me, "submitted", null, { amount: app.amount, ltv: ltv?.ratio ?? null });
revalidatePath(`/applications/${id}`);
revalidatePath("/applications");
return { ok: true };
}
12. Build the credit desk pipeline
Officers see applications by status: waiting, referred, decided or all. Each row carries the amount, the loan-to-value and whether the vehicle is listed for sale.
src/lib/applications.ts
export function pipeline(statuses: Status[]) {
return db
.select({ app: application, borrower: { name: user.name } })
.from(application)
.innerJoin(user, eq(application.userId, user.id))
.where(inArray(application.status, statuses))
.orderBy(desc(application.submittedAt))
.all();
}
13. Show the recalls issued for the model and year
The first time an officer opens a file, the app reads the recalls issued for the model and year and keeps them on the application. They match by model year, not by VIN, so the desk lists them as "Recalls issued for this model year". The borrower never sees them.
SDK call: cardog.v2.recalls.entity(modelYearRef)
What comes back. Transport Canada and NHTSA campaigns issued for the model year, and the total.
src/lib/cardog.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: 20 }],
http: `GET /v2/recalls/entity/${modelYearRef}?limit=20`,
returns: "Recall campaigns issued for the model year (Transport Canada and NHTSA)",
live: (c) => c.v2.recalls.entity(modelYearRef, { limit: 20 }),
});
}src/lib/check.ts
export async function readRecalls(
vin: string,
modelYearRef: string,
step: string,
): Promise<{ recalls: ModelYearRecallSet | null; call: CallRecord; error: string | null }> {
const r = await readModelYearRecalls(modelYearRef, vin, step);
return { recalls: r.data ? buildRecallSet(modelYearRef, r.data) : null, call: r.call, error: r.error };
}
14. Approve, refer or decline
The officer can change the amount, and the loan-to-value moves with it. A referral or a decline needs a note for the borrower. The amount and loan-to-value the decision was made at go on the audit trail.
src/app/actions.ts
export async function decide(
id: string,
decision: "approved" | "referred" | "declined",
note: string,
amount: number,
): Promise<ActionResult> {
const me = await requireOfficer();
const row = applicationById(id);
if (!row || row.app.status !== "submitted")
return { ok: false, error: "This application is not waiting for a decision." };
if (decision !== "approved" && !note.trim())
return { ok: false, error: decision === "referred" ? "Tell the borrower what to change." : "Give the reason." };
const at = Math.round(Number(amount)) || row.app.amount;
const ltv = loanToValue(at, row.app.collateral?.value ?? null);
db.update(application)
.set({
status: decision,
decidedAt: new Date(),
decidedBy: me.name,
decisionNote: note.trim() || null,
approvedAmount: decision === "approved" ? at : null,
updatedAt: new Date(),
})
.where(eq(application.id, id))
.run();
record(id, me, decision, note, { amount: at, requested: row.app.amount, ltv: ltv?.ratio ?? null });
revalidatePath("/desk");
revalidatePath(`/desk/${id}`);
return { ok: true };
}
15. Deploy
Orvanne 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
# Orvanne Credit collateral check: one container, SQLite on a mounted volume.
# docker build -t orvanne .
# docker run -p 3021:3021 -v orvanne-data:/app/data \
# -e CARDOG_API_KEY=... -e BETTER_AUTH_SECRET=... -e BETTER_AUTH_URL=https://your.host orvanne
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 3021
CMD ["pnpm", "start"]terminal
docker build -t orvanne .
docker run -p 3021:3021 -v orvanne-data:/app/data \
-e CARDOG_API_KEY=your_cardog_api_key \
-e BETTER_AUTH_SECRET=$(openssl rand -base64 32) \
-e BETTER_AUTH_URL=https://loans.example.com \
orvanneNext steps
- Market data: Quotes, listings and the price history behind them.
- VIN decode: Every field the decode returns.
- Portfolio revaluation: Orvanne's other app: revalue a lease book against the market.
Was this helpful?