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

plaintext
   ┌─ 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-pairings route in v1. The metadata.inspection_session_id matching 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.

ts
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:

  1. Skip severity: "none" — these aren't damage.
  2. Drop findings whose panel is not in before.panel_inventory. The before photo couldn't see this panel, so we cannot honestly claim the damage is new — log it to excluded_panels_not_visible_before and move on. This is the single biggest defense against angle-induced false positives.
  3. Find the best before match. A candidate must have the same panel and damage_type, and its bounding box must overlap the after-finding's box by IoU > 0.3 (IOU_THRESHOLD in the module). Each before-finding can be claimed at most once (greedy by best IoU).
  4. Classify:
    • No match → new_damage.
    • Match + severity went up → worsened.
    • Match + severity went down → improved.
    • Match + severity unchanged → unchanged.

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)

ts
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

bash
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:

ts
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)

bash
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

ts
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:

json
{
  "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_id on both submissions. This is the example above.
  • Vehicle identifier. If you don't want to round-trip an ID, set vehicle_identifier on the pairing and on each verification's metadata, and have your reconciliation job pair the latest open row per vehicle. The idx_verify_ai_inspection_pairings_vehicle index 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-pairings REST 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_before may 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

Get in Touch

Questions about pricing, integrations, or custom deployments? We'd love to hear from you.