Generating PDF condition reports

When a rental ends, a freight load arrives, or an audit lands on your desk, a JSON verification result isn't enough. You need a single artifact — branded, dated, signed — that someone can email, file, or hand to a regulator. That's what the PDF endpoints produce.

There are two of them, with the same renderer behind both:

| Endpoint | Auth | Use case | | ------------------------------------------------- | ------------- | -------------------------------------------------------- | | GET /api/v1/verifications/[id]/pdf | X-API-Key | One PDF per /v1/verify call. Server-to-server access. | | GET /api/v1/inspection-sessions/[token]/pdf | Signed token | Multi-shot session reports. Safe to expose to end users. |

Both stream application/pdf directly (no redirect) and cache the rendered file to the verify-ai-pdfs Supabase Storage bucket. The session endpoint serves cached bytes from storage on the second hit; the per-verification endpoint re-renders unless you cache the bytes on your side.

1. Pull a verification PDF

bash
curl \
  -H "X-API-Key: vai_your_api_key" \
  https://verify.switchlabs.dev/api/v1/verifications/ver_3aF92.../pdf \
  -o condition.pdf

The endpoint streams application/pdf bytes with Content-Disposition: inline and a short private, max-age=300 cache header. There is no redirect — always call the API endpoint, never the underlying storage path directly.

2. Pull a session PDF (for the end user)

The token-authenticated variant takes the same token as the inspection page itself — no API key required, so it's safe to embed in the confirmation email at the end of a self-inspection flow:

bash
curl "https://verify.switchlabs.dev/api/v1/inspection-sessions/vai_lnk_eyJ.../pdf" \
  -o my-inspection.pdf

The endpoint reuses the same inspection-kind token that authed the inspection flow. lookupSessionByToken() calls verifyToken() from lib/verify-ai/tokens.ts; expired/tampered tokens 410. The PDF is downloadable while the session is open or submitted (the route handles already_submitted by falling back to a direct token-hash lookup so finalized sessions still work).

3. What's in the report

The renderer lives in lib/verify-ai/pdf/render.tsx and uses @react-pdf/renderer — the same JSX-based flow you already know, with <Document>, <Page>, <View>, <Image>, and <Text>. It runs server-only because react-pdf pulls in Node-only PDF primitives.

A finished page contains:

  1. Header — brand logo (when branding.logoUrl resolves to a URL), <displayName> — Condition Report, the report id, and the generated-at timestamp.
  2. Inspection details — inspector name (from session.recipient.name), submitted timestamp, and a vehicle subblock with VIN, plate, and a year make model line. The vehicle subblock is rendered only if at least one shot's verification has vehicle_identifier populated; it's read via pickVehicleSummary() in lib/verify-ai/pdf/render.tsx.
  3. Photo grid — every captured shot at a fixed aspect ratio, labeled with the slot and a trailing (flagged) marker when is_compliant === false. For sessions, this is the captured shots set; for single verifications it's the one image.
  4. Damage summary tableonly rendered when at least one verification has damage_findings populated. Columns: Panel, Type, Severity (color-coded badge), Area %. damage_findings is filled by Cluster 1's damage pipeline; reports for policies without damage findings skip this section entirely.
  5. Signature block — the recipient's PNG signature and the session.submitted_at date. branding.pdfFooterText (falls back to <displayName> — Powered by Verify AI) is rendered as a fixed page footer.

4. Damage overlays

When damage_findings is present, we don't just print the table — we also overlay severity-colored bounding boxes on the affected photos before they go into the grid. The overlay happens in lib/verify-ai/pdf/annotate.ts using sharp to composite an SVG layer server-side, then re-encode the photo as JPEG:

  • severity: "none" → green stroke
  • severity: "light" → yellow stroke
  • severity: "medium" → orange stroke (default when severity is missing)
  • severity: "severe" → red stroke

When a finding carries a label (e.g. the panel name), the annotator adds a colored label tag in the top-left of the box. The annotation runs each time a PDF is rendered — there's no separate per-image cache; the full rendered PDF is what gets cached in verify-ai-pdfs. Photos with no damage findings are passed through untouched.

5. Branding

The header, color accents, and footer are read from verify_ai_branding for the verification's customer:

ts
import { getBranding } from "@/lib/verify-ai/branding";
 
const brand = await getBranding(customerId);
// brand.displayName, brand.logoPath, brand.primaryColor,
// brand.contactEmail, brand.pdfFooterText, brand.defaultLocale

getBranding returns the Switch Labs defaults (DEFAULT_BRANDING) when a customer has no row — every report still renders, it just isn't co-branded. branding.logoUrl starts null and is resolved separately by the renderer; if no logo is uploaded the header falls back to text-only.

Locale follows inspection_session.recipient.locale for session reports, or branding.defaultLocale for direct verification reports. The PDF renderer itself currently hard-codes English-language section headings; the t() helper in lib/verify-ai/i18n/ (single-brace {var} interpolation) is used by the invite email and the end-user inspection flow.

6. Embedding in email and the app

Two patterns work well:

Attach the PDF. Pull the bytes once on your server, attach them to the transactional email, and send. The renderer is fast, but you don't want it on the hot path of every email send — cache the result locally or trust the storage cache.

ts
const pdf = await fetch(
  `https://verify.switchlabs.dev/api/v1/verifications/${id}/pdf`,
  { headers: { "X-API-Key": process.env.VERIFY_AI_KEY! } },
).then((r) => r.arrayBuffer());
 
await sendMail({
  to: customer.email,
  subject: `Condition report — ${rentalId}`,
  attachments: [{ filename: "condition.pdf", content: Buffer.from(pdf) }],
});

Link to the session PDF. For end-user-facing flows, drop the session PDF URL straight into the email and skip the attachment. The token already authorizes the page; the recipient doesn't need an account, and Gmail/Outlook will preview the PDF inline when they click.

7. Inline preview vs. download

The endpoint sets Content-Disposition: inline; filename="verification-<id>.pdf" (or inspection-<id>.pdf for the session endpoint), so browsers preview the PDF in-tab by default. If you want a save-to-disk dialog, use an <a download> link on your side — the browser will honor the download attribute and override the inline disposition.

What's next

Get in Touch

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