Before/after rental inspection delta
A single damage verification answers: "what damage does this photo show?". That's enough for one-shot intake — but for rentals, test-drives, and loaner workflows, the real question is:
"What damage is new since this vehicle left our lot?"
That answer requires two verifications and a deterministic diff. This guide walks through the pairing model, the diff algorithm, and a worked example.
The two-verification flow
┌─ checkout ──────┐ ┌─ check-in ────────┐
│ POST /v1/verify │ │ POST /v1/verify │
│ policy: │ │ policy: │
│ pol_fleet_damage│ │ pol_fleet_damage │
│ metadata: │ │ metadata: │
│ inspection_ │ │ inspection_ │
│ session_id=… │ │ session_id=… │
└────────┬────────┘ └────────┬──────────┘
│ │
▼ ▼
damage_findings_A damage_findings_B
panel_inventory_A panel_inventory_B
│ │
└──────────┬───────────────────────┘
▼
diffFindings(A, B)
▼
{ new_damage, worsened, unchanged, improved,
excluded_panels_not_visible_before }Both ends of the flow are normal damage-mode verifications — there's
no special endpoint. The only thing that links them is a pairing
row in verify_ai_inspection_pairings, and a matching
metadata.inspection_session_id on each verification.
The verify_ai_inspection_pairings table
Migration supabase/migrations/20260603_inspection_pairings.sql
creates the table. Key columns:
| Column | Notes |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------ |
| id | UUID. The pairing identifier — pass this as metadata.inspection_session_id on both verifications. |
| customer_id | Tenant scoping. Required. |
| vehicle_identifier | Optional. Customer-supplied (e.g. VIN, plate, internal ID). |
| inspection_session_id | Optional FK to verify_ai_inspection_sessions for web-link self-inspection flows. |
| checkout_verification_id | The "before" verification. Set when checkout is recorded. |
| checkin_verification_id | The "after" verification. |
| delta_findings | JSONB. Output of diffFindings(). Populated when the check-in is recorded. |
| delta_repair_estimate_cents | Optional rollup cost across the new damage only. |
| channel | "api" \\| "web_link" \\| "whatsapp" \\| "sms" \\| "email" \\| null. |
| status | "open" → "checked_out" → "closed". "expired" for stale rentals. |
| expires_at | Optional. Useful for auto-closing abandoned rentals. |
The table is RLS-scoped so customers only see their own pairings; service-role contexts (cron jobs, the dashboard backend) bypass.
No public API endpoint yet. Pairings are managed from your backend via the Supabase service-role client — there's no
POST /v1/inspection-pairingsroute in v1. Themetadata.inspection_session_idmatching key on the verification side is, however, public and stable.
The diff algorithm
diffFindings() lives in lib/verify-ai/damage/delta.ts. It is a
pure function — no DB calls, no I/O — so you can unit-test it and
re-run it against historical pairings to backfill new buckets.
export interface DeltaInput {
findings: DamageFinding[];
/** Panels visible in this frame; used to suppress angle false positives. */
panel_inventory: string[];
}
export interface FindingPair {
before: DamageFinding;
after: DamageFinding;
}
export interface DeltaResult {
new_damage: DamageFinding[]; // present after, no before match
worsened: FindingPair[]; // severity went up
unchanged: FindingPair[]; // same severity
improved: FindingPair[]; // severity went down (rare — detailing)
excluded_panels_not_visible_before: DamageFinding[]; // panel not in before.panel_inventory
}Matching rules
For each after finding:
- Skip
severity: "none"— these aren't damage. - Drop findings whose
panelis not inbefore.panel_inventory. The before photo couldn't see this panel, so we cannot honestly claim the damage is new — log it toexcluded_panels_not_visible_beforeand move on. This is the single biggest defense against angle-induced false positives. - Find the best
beforematch. A candidate must have the samepanelanddamage_type, and its bounding box must overlap the after-finding's box by IoU > 0.3 (IOU_THRESHOLDin the module). Each before-finding can be claimed at most once (greedy by best IoU). - Classify:
- No match →
new_damage. - Match + severity went up →
worsened. - Match + severity went down →
improved. - Match + severity unchanged →
unchanged.
- No match →
Severity ordering: none < light < medium < severe.
IoU rationale
The IoU threshold (0.3) is intentionally permissive. Customers photograph the same dent from slightly different angles between checkout and check-in, so requiring a tight overlap would produce phantom "new damage" entries for the same scratch shifted half a panel width. 0.3 is the lowest threshold at which manual review on the validation set converged with the algorithm's verdict.
Worked example
1. Open a pairing (server-side)
import { getSupabaseAdminClient } from "@/lib/supabase-admin";
const supabase = getSupabaseAdminClient();
const { data: pairing } = await supabase
.from("verify_ai_inspection_pairings")
.insert({
customer_id: customerId,
vehicle_identifier: "1FAFP404X1F123456",
channel: "api",
status: "open",
})
.select("id")
.single();
const pairingId = pairing.id;2. Record the checkout verification
curl -X POST https://verify.switchlabs.dev/api/v1/verify \
-H "X-API-Key: vai_your_api_key" \
-F "image=@checkout.jpg" \
-F "policy=pol_fleet_damage" \
-F 'metadata={"inspection_session_id":"<pairingId>","inspection_slot":"checkout","vin":"1FAFP404X1F123456"}'On your server, after the API returns ver_…, link it back to the
pairing and flip status:
await supabase
.from("verify_ai_inspection_pairings")
.update({ checkout_verification_id: verificationId, status: "checked_out" })
.eq("id", pairingId);3. Record the check-in verification (return)
curl -X POST https://verify.switchlabs.dev/api/v1/verify \
-H "X-API-Key: vai_your_api_key" \
-F "image=@checkin.jpg" \
-F "policy=pol_fleet_damage" \
-F 'metadata={"inspection_session_id":"<pairingId>","inspection_slot":"checkin","vin":"1FAFP404X1F123456"}'4. Compute and store the delta
import { diffFindings } from "@/lib/verify-ai/damage/delta";
const { data: rows } = await supabase
.from("verify_ai_verifications")
.select("id, damage_findings, panel_inventory")
.in("id", [checkoutVerificationId, checkinVerificationId]);
const before = rows.find((r) => r.id === checkoutVerificationId)!;
const after = rows.find((r) => r.id === checkinVerificationId)!;
const delta = diffFindings(
{ findings: before.damage_findings, panel_inventory: before.panel_inventory },
{ findings: after.damage_findings, panel_inventory: after.panel_inventory },
);
await supabase
.from("verify_ai_inspection_pairings")
.update({
checkin_verification_id: checkinVerificationId,
delta_findings: delta,
status: "closed",
})
.eq("id", pairingId);A typical delta payload:
{
"new_damage": [
{
"finding_id": "f1",
"panel": "car_door_fr",
"damage_type": "scratch",
"severity": "light",
"severity_score": 0.3,
"bbox": [0.42, 0.51, 0.58, 0.56],
"area_pct": 0.04,
"confidence": 0.82
}
],
"worsened": [
{
"before": { "finding_id": "f3", "panel": "car_door_fl", "damage_type": "dent", "severity": "light", /* ... */ },
"after": { "finding_id": "f5", "panel": "car_door_fl", "damage_type": "dent", "severity": "medium", /* ... */ }
}
],
"unchanged": [],
"improved": [],
"excluded_panels_not_visible_before": [
{
"finding_id": "f2",
"panel": "car_quarter_rl",
"damage_type": "scratch",
"severity": "light"
}
]
}new_damage is what you charge the renter for. excluded_panels_not_visible_before
is the audit log entry that explains why a finding on the rear quarter
panel doesn't appear in new_damage even though the check-in photo
shows it — there was no checkout shot of that panel.
Choosing the matching key
The pairing's identity flows through metadata.inspection_session_id
on each verification. Two practical patterns:
- Server-issued pairing ID (recommended). Your backend creates the
pairing row first, then includes the pairing's UUID as
metadata.inspection_session_idon both submissions. This is the example above. - Vehicle identifier. If you don't want to round-trip an ID, set
vehicle_identifieron the pairing and on each verification's metadata, and have your reconciliation job pair the latestopenrow per vehicle. Theidx_verify_ai_inspection_pairings_vehicleindex supports this lookup directly.
Honest limitations
- No public pairing API endpoint in v1. You manage the table
from your backend via the Supabase service role. A
/v1/inspection-pairingsREST surface is on the roadmap once a second customer needs it. - Same camera, similar angles. The IoU heuristic assumes the checkout and check-in photos frame the vehicle the same way. For walk-arounds, capture each angle separately and pair them slot by slot.
- No multi-angle aggregation. Each pairing diffs exactly two frames. If your workflow captures four photos per inspection, create four pairings (one per slot) or pre-aggregate findings on your side before pairing.
- Excluded panels aren't free passes. A finding in
excluded_panels_not_visible_beforemay still be real, pre-existing damage — it just can't be attributed to the rental period. Surface these in your review queue, not in your "new damage" tally.
What's next
- Detecting and grading damage — the per-verification primitive that feeds the diff.
- AIAG codes and K1–K5 grading — run
the same rollups against just the
new_damagearray. - Estimating repair cost via Mitchell
— price the
new_damagearray, not the whole findings list.