Sending an inspection link

Sometimes the person doing the inspection isn't using your app. They might be a renter at the end of a trip, a driver at a dock door, or a new customer who hasn't installed anything yet. Inspection sessions solve that: you POST a session, we return a short URL, and the recipient does the whole flow in a mobile browser.

By the end of this guide you'll have:

  1. A POSTed session with required shots, recipient details, and an expiry.
  2. A delivered link the user can open on any phone.
  3. A submitted, signed inspection — every shot verified through the same policy engine as your in-app scanner.

1. Create the session

bash
curl -X POST https://verify.switchlabs.dev/api/v1/inspection-sessions \
  -H "X-API-Key: vai_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "policy": "pol_forest1",
    "recipient": {
      "name": "Alex Rivera",
      "email": "alex@example.com",
      "phone": "+14155551234",
      "locale": "es"
    },
    "context": {
      "rental_id": "rnt_8821",
      "vin": "1HGCM82633A004352",
      "bol_number": "BOL-44219",
      "site_external_id": "depot-sf-3"
    },
    "required_shots": [
      { "slot": "front",          "label": "Front of vehicle" },
      { "slot": "driver_side",    "label": "Driver side" },
      { "slot": "passenger_side", "label": "Passenger side" },
      { "slot": "rear",           "label": "Rear of vehicle" }
    ],
    "ttl_seconds": 86400
  }'

The response:

json
{
  "id": "ins_2P9aVZ...",
  "token": "vai_lnk_eyJzaWQiOi...",
  "url": "https://verify.switchlabs.dev/inspect/vai_lnk_eyJzaWQiOi...",
  "expires_at": "2026-05-19T20:30:00.000Z"
}

url is what you send to the user. It points at the public /inspect/[token] route — no auth, no app. The token is HMAC-SHA256 signed using VERIFY_AI_TOKEN_SECRET and prefixed vai_lnk_ (see lib/verify-ai/tokens.ts). A SHA-256 hash of the token is stored on the row (the plaintext token is never persisted), so you can revoke it later without invalidating every other session.

recipient.locale is one of en, es, fr, de and drives every piece of UI copy on the page. Anything missing in the recipient's locale falls back to English (see lib/verify-ai/i18n/).

context is opaque to us — it's whatever rental / shipment / site IDs you need to thread back into your system when the session finishes. It's echoed back on the GET endpoint and stamped onto every shot's verification metadata.

2. Delivery

When the session is created, we look up verify_ai_branding for your customer and use display_name, logo_path, primary_color, contact_email, contact_phone, pdf_footer_text, and default_locale on every surface the recipient sees — the landing page, the email, and the final PDF. (In TypeScript, getBranding() returns these as camelCase: displayName, logoPath, primaryColor, etc.)

| Channel | Status | Notes | | ---------- | -------- | -------------------------------------------------------------------------------------------------------------------- | | Email | GA | Sent immediately via the same Nodemailer transport as the rest of the app. Subject and body are localized. | | SMS | Preview | The session row records recipient.phone, but Twilio wiring lands with Cluster 4. POST will succeed; no SMS is sent. | | WhatsApp | Preview | Same — recipient.phone is captured, but template approval and Twilio dispatch are Cluster 4 work. | | Copy / API | GA | The returned url is always usable. You can also paste it into your own email or in-app message. |

3. What the recipient sees

/inspect/[token] is a Next.js page (app/inspect/[token]/page.tsx) that boots an InspectionFlow component. The flow:

  1. Intro. Brand logo, inspect.intro.heading + inspect.intro.body translation (the body interpolates {brand}), recipient name. CTA: inspect.intro.cta.
  2. Capture loop. For each required_shots entry, the CameraCapture component opens the device camera, shows the label, and lets the user take or retake the photo. Each accepted shot is uploaded immediately as a multipart/form-data POST to /api/v1/inspection-sessions/[token]/submit with image and slot fields. The server runs the same processVerification() pipeline as /v1/verify, then upserts a row in verify_ai_inspection_shots uniquely keyed by (session_id, slot).
  3. Review. All shots are shown side-by-side with their compliance verdict. The user can retake any single shot before finalizing.
  4. Signature. The SignaturePad component captures a touch signature as a PNG. The localized prompt comes from inspect.sign.body (see lib/verify-ai/i18n/en.json).
  5. Submit. Calling /api/v1/inspection-sessions/[token]/finalize with { signature_data_url, signer_name? } (PNG data URL) stores the signature, marks the session submitted, and schedules a PDF render. Finalize 422s with {error: "missing_required_shots", missing: [...]} if any required shot hasn't been captured.

Because each shot is verified inline, by the time the user reaches the signature step you already know which shots passed. If pol_forest1 flags bad_parking on the rear photo, the review screen surfaces that and prompts a retake before the user can sign.

4. Resending or revoking

People lose links. Resend rotates the token:

bash
curl -X POST https://verify.switchlabs.dev/api/v1/inspection-sessions/ins_2P9aVZ.../resend \
  -H "X-API-Key: vai_your_api_key"

The previous token is invalidated (its hash is replaced on the row), resend_count increments, and a new {id, url, token, expires_at, resend_count} comes back. Resend reuses the existing expires_at — it doesn't extend the deadline. To kill a session without resending, flip its status to revoked in the DB; the next token lookup will return 410.

5. Closing the loop

The most reliable signal that a session is done is the inspection.submitted webhook (Cluster 4). Finalize fires it best-effort once the row transitions to submitted:

ts
// Your webhook handler
app.post("/webhooks/verify-ai", async (req, res) => {
  if (req.body.event === "inspection.submitted") {
    const { session_id, customer_id } = req.body.data;
    // Hit your own DB / Supabase to read the session row, shots,
    // and signature_path — the session GET endpoint is keyed by
    // token and goes 410 once a session is submitted.
    await closeRental(session_id);
  }
  res.sendStatus(200);
});

While a session is still pending or opened, you can hit the token-authenticated GET to see partial progress:

bash
curl https://verify.switchlabs.dev/api/v1/inspection-sessions/vai_lnk_eyJ...

It returns {session, policy, branding, locale, strings, shots} — the shots array contains {slot, verification_id, is_compliant, captured_at} for each captured slot.

Every submitted session is also reachable as a PDF — see Generating PDF condition reports. If you need to also pull a VIN or plate from one of the captured shots, see Reading VIN and license plate from a photo.

Token security cheatsheet

  • Tokens are HMAC-SHA256 signed using VERIFY_AI_TOKEN_SECRET. Rotate the secret and every outstanding session is instantly dead.
  • The token payload carries sid (session id), cid (customer id), kind: "inspection", v: 1 (schema), and exp (UNIX seconds). Default TTL is 7 days, max 30 days; override with ttl_seconds on create.
  • A SHA-256 hash of every issued token is stored on the session row so resending a session revokes the prior link.
  • The /inspect/[token] page calls verifyToken() server-side on every request — expired or tampered tokens render inspect.expired.* strings and never reach the API. (The dictionary uses single-brace {var} interpolation — see lib/verify-ai/i18n/index.ts.)

What's next

Get in Touch

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