# Appraise a trade-in


![Larkspur Motors: the desk's appraisal sheet for a 2021 Jeep Wrangler, from retail anchor to suggested offer, beside the pane that lists the Cardog calls behind it](https://cardog.app/marketing/guides/trade-in-appraisal/desktop.jpg "2880x1800")

You'll build **Larkspur Motors**, a car dealership's app: an estimate online, a walk-around on the lot, a firm offer from the desk. [See what it does](https://cardog.app/use-cases/trade-in-appraisal).

- **Time:** About an hour, or 5 minutes 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.search`, `cardog.v2.vin.windowSticker`, `cardog.v2.recalls.entity`
- **Clone the finished app:** [cardog-ai/example-trade-in-appraisal](https://github.com/cardog-ai/example-trade-in-appraisal), or build it step by step below.

```bash
git clone https://github.com/cardog-ai/example-trade-in-appraisal.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 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 three VINs, so you can follow along before you sign up. `DEALER_LAT`, `DEALER_LNG` and `DEALER_RADIUS_KM` place the store, for comparables nearby.

`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=larkspur-dev-only-secret-replace-before-deploying

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

# SQLite file and the folder walk-around photos are saved in. Created on first run.
DATABASE_PATH=data/larkspur.db
PHOTOS_DIR=data/photos

# Where the store is, for comparable listings nearby. Defaults to the
# store's own lot; radius in kilometres.
DEALER_LAT=43.5448
DEALER_LNG=-80.2482
DEALER_RADIUS_KM=250

# Seeded review logins are listed in the README.
```

## 2. Create the Next.js app

Larkspur is one Next.js app: the store's public estimate, the customer's account, the appraiser's phone screen and the desk. SQLite holds the appraisals and photos sit on disk, so there is nothing else to run. `better-sqlite3` is a native module, so the config loads it at runtime.

**`terminal`**

```bash
pnpm create next-app@latest larkspur --ts --app --tailwind --src-dir
cd larkspur
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. 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 ever reaches for it. Each call is timed and logged, which is what the app's "What the app called" pane shows.

Start with the two calls every appraisal makes. The decode returns the vehicle and the refs every other call takes; the quote returns the asking prices of live listings for the model year.

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: "larkspur-trade-in", baseUrl: process.env.CARDOG_BASE_URL || undefined })
  : null;

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

/** Where the store is: comparable listings are searched around it. */
export const DEALER_LOCATION = {
  lat: Number(process.env.DEALER_LAT ?? 43.5448),
  lng: Number(process.env.DEALER_LNG ?? -80.2482),
  radius: Number(process.env.DEALER_RADIUS_KM ?? 250),
};

// 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 ListingSearch = Awaited<ReturnType<V2["listings"]["search"]>>;
export type WindowSticker = Awaited<ReturnType<V2["vin"]["windowSticker"]>>;
export type ModelYearRecalls = Awaited<ReturnType<V2["recalls"]["entity"]>>;
type ListingsV2SearchParams = NonNullable<Parameters<V2["listings"]["search"]>[0]>;

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

**`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, trim, refs, build grains",
    live: (c) => c.v2.vin.getByVin(vin),
  });
}
```

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

```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, live count and days on market for the model year",
    live: (c) => c.v2.quotes.getByRef(modelYearRef),
  });
}
```

## 4. Add accounts and three roles

Customers, appraisers and the desk manager sign in with email and password. `role` is a user field that sign-up cannot set, so anyone who signs up is a customer; staff are made in the database. Every server action checks the role itself, and each role lands on its own screen.

`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("[larkspur] BETTER_AUTH_SECRET is the development placeholder. Replace it before deploying.");
    }
    return set;
  }
  if (!production) return DEV_SECRET;
  throw new Error("[larkspur] BETTER_AUTH_SECRET is not set. Generate one with `openssl rand -base64 32`.");
}
```

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

```ts
export const auth = betterAuth({
  appName: "Larkspur Motors",
  baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3022",
  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: "customer", 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);
```

**`src/lib/session.ts`**

```ts
export async function requireRole(...roles: Role[]): Promise<Viewer> {
  const v = await requireViewer();
  if (!roles.includes(v.role)) redirect(HOME[v.role]);
  return v;
}
```

## 5. 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`**

```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." };
}
```

## 6. Give an instant estimate on the website

The store's public page asks for the VIN, the kilometres and one of four conditions. The route decodes the VIN, reads the quote for its model year and works a range from the middle half of the market: adjusted for kilometres against a typical car of that year, less the condition's reconditioning, the store's pack and its target gross.

Reads are held for an hour for each VIN, so a customer who books right after the estimate does not cost two more calls.

SDK call: `cardog.v2.quotes.getByRef(modelYearRef)`

**What comes back.** `priceP25`, `priceMedian` and `priceP75` of live listings for the model year, the live count, average days listed and `asOf`.

**`src/lib/reads.ts`**

```ts
export async function readForEstimate(vin: string, step: string): Promise<EstimateRead> {
  const hit = held.get(vin);
  if (hit && Date.now() - hit.at < HOUR) return hit.read;

  const decode = await decodeVin(vin, step);
  const calls = [decode.call];
  if (!decode.data) return { facts: null, calls, error: decode.error ?? "That VIN did not decode." };
  const modelYear = decode.data.refs?.modelYear;
  if (!modelYear) {
    return {
      facts: buildTradeFacts(vin, { decode: decode.data }),
      calls,
      error: "We could not place this VIN's model year, so there is no market to price it from.",
    };
  }
  const quote = await readMarketQuote(modelYear, vin, step);
  calls.push(quote.call);
  const read: EstimateRead = {
    facts: buildTradeFacts(vin, { decode: decode.data, quote: quote.data }),
    calls,
    error: quote.data ? null : quote.error,
  };
  if (!read.error) held.set(vin, { at: Date.now(), read });
  return read;
}
```

**`src/lib/valuation.ts`**

```ts
export function instantEstimate(
  m: { p25: number | null; p75: number | null; median: number | null },
  year: number,
  km: number,
  condition: Condition
): Estimate | null {
  if (m.p25 == null || m.p75 == null || m.median == null) return null;
  const c = conditionOf(condition);
  const kmAdj = kmAdjustment(m.median, km, typicalKm(year));
  const costs = c.recon + DEFAULTS.pack + DEFAULTS.gross;
  const low = Math.max(500, round(m.p25 * c.retail + kmAdj - costs));
  const high = Math.max(low + 500, round(m.p75 * c.retail + kmAdj - costs));
  return {
    low,
    high,
    basis: { p25: m.p25, p75: m.p75, kmAdj, recon: c.recon, pack: DEFAULTS.pack, gross: DEFAULTS.gross, factor: c.retail },
  };
}
```

**`src/app/api/estimate/route.ts`**

```ts
import { NextResponse } from "next/server";
import { readForEstimate } from "@/lib/reads";
import { CONDITIONS, instantEstimate, type Condition } from "@/lib/valuation";
import { checkVin, normalizeVin } from "@/lib/vin";

/**
 * POST /api/estimate { vin, km, condition }
 * The website's instant estimate. Public: anyone on the store's site can
 * ask. Two Cardog calls (decode, quote), held for an hour per VIN.
 */
export async function POST(req: Request) {
  const body = (await req.json().catch(() => ({}))) as { vin?: string; km?: number; condition?: string };
  const vin = normalizeVin(String(body.vin ?? ""));
  const km = Math.round(Number(body.km));
  const condition = String(body.condition ?? "") as Condition;
  const check = checkVin(vin);
  if (vin.length !== 17 || check.state === "invalid") {
    return NextResponse.json({ error: "Enter the 17-character VIN." }, { status: 400 });
  }
  if (!Number.isFinite(km) || km <= 0 || km > 1_500_000) {
    return NextResponse.json({ error: "Enter the kilometres on the odometer." }, { status: 400 });
  }
  if (!CONDITIONS.some((c) => c.id === condition)) {
    return NextResponse.json({ error: "Pick the condition that fits best." }, { status: 400 });
  }

  const read = await readForEstimate(vin, "Instant estimate");
  const f = read.facts;
  const estimate = f?.market && f.year ? instantEstimate(f.market, f.year, km, condition) : null;
  return NextResponse.json({
    facts: f,
    estimate,
    calls: read.calls,
    error: read.error ?? (f && !estimate ? "There are not enough live listings to price this one online. Book it and we will look in person." : null),
  });
}
```

![Larkspur's website estimate for a 2021 Honda Civic LX](https://cardog.app/marketing/guides/trade-in-appraisal/estimate.jpg "2880x1800")

## 7. Book the appraisal

**Book my appraisal** signs the customer in or up on the way and saves the appraisal with the facts and the estimate they saw. It lands in the lot's queue, and the customer follows it under **My trade**.

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

```ts
export async function bookAppraisal(form: FormData) {
  const me = await requireRole("customer");
  const vin = normalizeVin(String(form.get("vin") ?? ""));
  const km = parseKm(form.get("km"));
  const condition = String(form.get("condition") ?? "");
  if (checkVin(vin).state === "invalid" || vin.length !== 17 || !km || !isCondition(condition)) {
    redirect("/?error=details");
  }
  const read = await readForEstimate(vin, "Book an appraisal");
  if (!read.facts) redirect("/?error=decode");
  const est = read.facts.market && read.facts.year ? instantEstimate(read.facts.market, read.facts.year, km, condition) : null;
  const row = createAppraisal({
    status: "booked",
    customerId: me.id,
    customerName: me.name,
    customerPhone: String(form.get("phone") ?? "").trim() || null,
    facts: read.facts,
    declaredKm: km,
    declaredCondition: condition,
    estimate: est ? { low: est.low, high: est.high } : null,
    calls: read.calls,
    actor: me,
  });
  redirect(`/trade/${row.id}`);
}
```

## 8. Walk the car on a phone

The appraiser's screen is built for one hand on a lot: the odometer, thirteen OK-or-work checks with a cost on each that needs work, and eight photo slots that open the camera. Everything autosaves, and a pinned bar keeps the reconditioning total in view.

**Send to desk** refuses until the odometer is read, every item is checked, and the front, rear and odometer photos are in.

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

```ts
export async function saveWalkaround(
  id: string,
  input: { odometerKm: number | null; checklist: Checklist; lotNote: string }
): Promise<{ ok: boolean; recon: number }> {
  await requireRole("appraiser", "desk");
  const a = getAppraisal(id);
  if (!a || a.status !== "walking") return { ok: false, recon: 0 };
  const checklist: Checklist = {};
  for (const item of CHECK_ITEMS) {
    const c = input.checklist[item.id];
    const state = c && ["unchecked", "ok", "work"].includes(c.state) ? c.state : "unchecked";
    const cost = state === "work" ? Math.max(0, Math.min(50_000, Math.round(Number(c?.cost) || 0))) : 0;
    checklist[item.id] = { state, cost };
  }
  const odometerKm =
    input.odometerKm && input.odometerKm > 0 && input.odometerKm < 1_500_000 ? Math.round(input.odometerKm) : null;
  updateAppraisal(id, { checklist, odometerKm, lotNote: input.lotNote.slice(0, 2000) || null });
  return { ok: true, recon: reconTotal(checklist) };
}
```

**`src/app/api/appraisals/[id]/photos/route.ts`**

```ts
import { NextResponse } from "next/server";
import { db, schema } from "@/db";
import { getAppraisal, photoFor } from "@/lib/appraisals";
import { savePhoto } from "@/lib/photos";
import { getViewer } from "@/lib/session";
import { PHOTO_SLOTS } from "@/lib/trade-file";
import { eq } from "drizzle-orm";

/** POST /api/appraisals/{id}/photos (multipart: slot, file). Staff only, while the car is on the lot. */
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
  const me = await getViewer();
  if (!me || me.role === "customer") return NextResponse.json({ error: "Staff only." }, { status: 403 });
  const { id } = await params;
  const a = getAppraisal(id);
  if (!a || a.status !== "walking") return NextResponse.json({ error: "Not on the lot." }, { status: 409 });

  const form = await req.formData();
  const slot = String(form.get("slot") ?? "");
  const file = form.get("file");
  if (!PHOTO_SLOTS.some((s) => s.id === slot)) return NextResponse.json({ error: "Unknown slot." }, { status: 400 });
  if (!(file instanceof Blob) || file.size === 0 || file.size > 8 * 1024 * 1024) {
    return NextResponse.json({ error: "One image, up to 8 MB." }, { status: 400 });
  }
  const bytes = new Uint8Array(await file.arrayBuffer());
  const path = await savePhoto(id, slot, bytes);

  const existing = photoFor(id, slot);
  if (existing) {
    db.update(schema.photo)
      .set({ file: path, bytes: bytes.length, updatedAt: new Date() })
      .where(eq(schema.photo.id, existing.id))
      .run();
  } else {
    db.insert(schema.photo).values({ id: crypto.randomUUID(), appraisalId: id, slot, file: path, bytes: bytes.length }).run();
  }
  return NextResponse.json({ ok: true });
}
```

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

```ts
export async function sendToDesk(id: string): Promise<{ error: string } | void> {
  const me = await requireRole("appraiser", "desk");
  const a = getAppraisal(id);
  if (!a || a.status !== "walking") return { error: "This appraisal is not on the lot." };
  if (!a.odometerKm) return { error: "Enter the odometer reading." };
  if (checkedCount(a.checklist) < CHECK_ITEMS.length) return { error: "Check every item first." };
  const shots = photosFor(id).map((p) => p.slot);
  const missing = ["front", "rear", "odometer"].filter((s) => !shots.includes(s));
  if (missing.length) return { error: "Add the front, rear and odometer photos." };
  updateAppraisal(id, { status: "desk", walkedAt: new Date() });
  logEvent(
    id,
    me,
    "walked",
    `Walk-around done: ${a.odometerKm.toLocaleString("en-CA")} km, ${cad(reconTotal(a.checklist))} reconditioning, ${shots.length} photos`
  );
  redirect("/lot?sent=" + encodeURIComponent(vehicleName(a)));
}
```

![Larkspur's walk-around for a 2022 Toyota RAV4](https://cardog.app/marketing/guides/trade-in-appraisal/walkaround.jpg "2880x1800")

## 9. Read the market for the desk

When the desk opens a sheet, four reads run in parallel: the quote again, live listings of the same model a year either side and within 40,000 km, near the store, the factory window sticker, and the recalls issued for the model and year. **Refresh market** runs them again.

The listings become the comparables, with the trade's own listing left out if it is for sale somewhere. The sticker comes back as the original document or the reason there is none. Recalls are matched by model year, and the sheet labels them that way.

SDK call: `cardog.v2.listings.search({ filters })`

**What comes back.** Listing rows with price, odometer, year and trim, filtered by model ref, year and odometer ranges and `location: { lat, lng, radius }`.

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

```ts
export function searchComparables(
  q: { modelRef: string; year: number; km: number },
  vin: string,
  step: string
) {
  const params: ListingsV2SearchParams = {
    filters: {
      models: [q.modelRef as `model:${string}`],
      year: { min: q.year - 1, max: q.year + 1 },
      odometer: { min: Math.max(0, q.km - 40_000), max: q.km + 40_000 },
      location: DEALER_LOCATION,
    },
    pagination: { page: 1, limit: 12 },
    sort: { field: "price", order: "asc" },
  };
  const f = params.filters!;
  return run<ListingSearch>({
    step,
    vin,
    key: "comps",
    method: "cardog.v2.listings.search",
    args: [params],
    http:
      `GET /v2/listings/search?models=${q.modelRef}&year=${f.year!.min}-${f.year!.max}` +
      `&odometer=${f.odometer!.min}-${f.odometer!.max}&lat=${DEALER_LOCATION.lat}&lng=${DEALER_LOCATION.lng}` +
      `&radius=${DEALER_LOCATION.radius}&sort=price&order=asc&limit=12`,
    returns: "Live listings of the same model, a year either side, within 40,000 km, near the store",
    live: (c) => c.v2.listings.search(params),
  });
}
```

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

```ts
export function readWindowSticker(vin: string, step: string) {
  return run<WindowSticker>({
    step,
    vin,
    key: "sticker",
    method: "cardog.v2.vin.windowSticker",
    args: [vin],
    http: `GET /v2/vin/${vin}/window-sticker`,
    returns: "The factory window sticker held for this VIN, or the reason there is none",
    live: (c) => c.v2.vin.windowSticker(vin),
  });
}
```

**`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: 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/reads.ts`**

```ts
export async function readForDesk(prev: TradeFacts, km: number, step: string): Promise<DeskRead> {
  const { vin } = prev;
  const my = prev.refs.modelYear;
  const model = prev.refs.model;
  const [quote, comps, sticker, recalls] = await Promise.all([
    my ? readMarketQuote(my, vin, step) : null,
    model && prev.year ? searchComparables({ modelRef: model, year: prev.year, km }, vin, step) : null,
    readWindowSticker(vin, step),
    my ? readModelYearRecalls(my, vin, step) : null,
  ]);
  const near =
    dataMode === "fixture"
      ? "across Canada"
      : `within ${DEALER_LOCATION.radius} km of the store`;
  const fresh = buildTradeFacts(vin, {
    decode: null,
    quote: quote?.data,
    sticker: sticker.data,
    recalls: recalls?.data,
  });
  return {
    facts: {
      ...prev,
      market: fresh.market ?? prev.market,
      sticker: fresh.sticker ?? prev.sticker,
      recalls: fresh.recalls ?? prev.recalls,
    },
    comps: comps?.data ? buildComps(comps.data, vin, near) : null,
    calls: [quote, comps, sticker, recalls].filter((r) => r !== null).map((r) => r.call),
  };
}
```

![Larkspur's desk board: a Wrangler that needs an offer and a Civic with an offer out](https://cardog.app/marketing/guides/trade-in-appraisal/board.jpg "2880x1800")

## 10. Price the offer on the sheet

Every offer is worked back from what the car will retail for. The anchor is the comparables' median asking price when there are three or more, and the market median otherwise. Add or take off the kilometres against the comparables' median, then take off the walk-around's reconditioning, the pack and the target gross.

The desk can change the anchor, the rate per kilometre, the pack and the gross on each sheet, and type over the suggested offer. Sending it records an event in the appraisal's history; the customer accepts or declines in their account.

**`src/lib/valuation.ts`**

```ts
export function worksheet(i: DeskInputs, km: number, referenceKm: number, recon: number): Worksheet {
  const kmAdj = kmAdjustment(i.anchor, km, referenceKm, i.perKm);
  const offer = Math.max(0, round(i.anchor + kmAdj - recon - i.pack - i.gross, 100));
  return { anchor: i.anchor, kmAdj, recon, pack: i.pack, gross: i.gross, offer, referenceKm };
}
```

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

```ts
export async function sendOffer(
  id: string,
  input: { offer: number; note: string; desk: DeskInputs }
): Promise<{ error: string } | void> {
  const me = await requireRole("desk");
  const a = getAppraisal(id);
  if (!a || !["desk", "offered", "declined"].includes(a.status)) return { error: "This appraisal is not at the desk." };
  const offer = Math.round(Number(input.offer));
  if (!Number.isFinite(offer) || offer < 0 || offer > 2_000_000) return { error: "Enter the offer in dollars." };
  const revised = a.status !== "desk";
  updateAppraisal(id, {
    status: "offered",
    offer,
    offerNote: input.note.trim().slice(0, 1000) || null,
    desk: {
      anchor: Math.round(input.desk.anchor) || 0,
      anchorSource: input.desk.anchorSource,
      perKm: Number(input.desk.perKm) || 0,
      pack: Math.round(input.desk.pack) || 0,
      gross: Math.round(input.desk.gross) || 0,
    },
    offeredBy: me.id,
    offeredAt: new Date(),
    respondedAt: null,
  });
  logEvent(
    id,
    me,
    revised ? "revised" : "offered",
    revised ? `Offer revised from ${cad(a.offer)} to ${cad(offer)}` : `Offer of ${cad(offer)} sent to ${a.customerName}`
  );
  revalidatePath(`/desk/${id}`);
}
```

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

```ts
export async function respondToOffer(id: string, answer: "accepted" | "declined") {
  const me = await requireRole("customer");
  const a = getAppraisal(id);
  if (!a || a.customerId !== me.id || a.status !== "offered") return;
  updateAppraisal(id, { status: answer, respondedAt: new Date() });
  logEvent(id, me, answer, answer === "accepted" ? `Accepted the offer of ${cad(a.offer)}` : `Declined the offer of ${cad(a.offer)}`);
  revalidatePath(`/trade/${id}`);
}
```

![Larkspur's appraisal sheet for a 2021 Jeep Wrangler Unlimited Sport](https://cardog.app/marketing/guides/trade-in-appraisal/sheet.jpg "2880x1800")

## 11. Deploy

Larkspur ships as one container with SQLite and the photos 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 and the photo folder for object storage; the Cardog code does not change.

**`Dockerfile`**

```dockerfile
# Larkspur trade-in appraisal: one container, SQLite and photos on a mounted volume.
#   docker build -t larkspur .
#   docker run -p 3022:3022 -v larkspur-data:/app/data \
#     -e CARDOG_API_KEY=... -e BETTER_AUTH_SECRET=... -e BETTER_AUTH_URL=https://your.host larkspur
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 3022
CMD ["pnpm", "start"]
```

**`terminal`**

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

## Next steps

- [Market data](https://cardog.app/docs/market-data): Quotes, comparables and the price history behind them.
- [VIN decode](https://cardog.app/docs/vin): Every field the decode returns.
- [Window sticker](https://cardog.app/docs/window-sticker): The factory sticker for a VIN, or the reason there is none.


---

Previous: [Revalue a lease portfolio](https://cardog.app/docs/guides/portfolio-revaluation.md) (Build guides)
Next: [Publish a vehicle report for every car on the lot](https://cardog.app/docs/guides/lot-vehicle-reports.md) (Build guides)
