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:
- A POSTed session with required shots, recipient details, and an expiry.
- A delivered link the user can open on any phone.
- A submitted, signed inspection — every shot verified through the same policy engine as your in-app scanner.
1. Create the session
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:
{
"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:
- Intro. Brand logo,
inspect.intro.heading+inspect.intro.bodytranslation (the body interpolates{brand}), recipient name. CTA:inspect.intro.cta. - Capture loop. For each
required_shotsentry, theCameraCapturecomponent opens the device camera, shows the label, and lets the user take or retake the photo. Each accepted shot is uploaded immediately as amultipart/form-dataPOST to/api/v1/inspection-sessions/[token]/submitwithimageandslotfields. The server runs the sameprocessVerification()pipeline as/v1/verify, then upserts a row inverify_ai_inspection_shotsuniquely keyed by(session_id, slot). - Review. All shots are shown side-by-side with their compliance verdict. The user can retake any single shot before finalizing.
- Signature. The
SignaturePadcomponent captures a touch signature as a PNG. The localized prompt comes frominspect.sign.body(seelib/verify-ai/i18n/en.json). - Submit. Calling
/api/v1/inspection-sessions/[token]/finalizewith{ signature_data_url, signer_name? }(PNG data URL) stores the signature, marks the sessionsubmitted, 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:
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:
// 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:
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), andexp(UNIX seconds). Default TTL is 7 days, max 30 days; override withttl_secondson 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 callsverifyToken()server-side on every request — expired or tampered tokens renderinspect.expired.*strings and never reach the API. (The dictionary uses single-brace{var}interpolation — seelib/verify-ai/i18n/index.ts.)
What's next
- Generating PDF condition reports — turn the finished session into a signed PDF.
- Reading VIN and license plate from a photo
— auto-fill
context.vinfrom the captured photos. - Verifying parked vehicles — the policy structure used to grade each shot.