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
curl \
-H "X-API-Key: vai_your_api_key" \
https://verify.switchlabs.dev/api/v1/verifications/ver_3aF92.../pdf \
-o condition.pdfThe 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:
curl "https://verify.switchlabs.dev/api/v1/inspection-sessions/vai_lnk_eyJ.../pdf" \
-o my-inspection.pdfThe 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:
- Header — brand logo (when
branding.logoUrlresolves to a URL),<displayName> — Condition Report, the report id, and the generated-at timestamp. - Inspection details — inspector name (from
session.recipient.name), submitted timestamp, and a vehicle subblock with VIN, plate, and ayear make modelline. The vehicle subblock is rendered only if at least one shot's verification hasvehicle_identifierpopulated; it's read viapickVehicleSummary()inlib/verify-ai/pdf/render.tsx. - Photo grid — every captured shot at a fixed aspect ratio, labeled
with the
slotand a trailing(flagged)marker whenis_compliant === false. For sessions, this is the captured shots set; for single verifications it's the one image. - Damage summary table — only rendered when at least one
verification has
damage_findingspopulated. Columns: Panel, Type, Severity (color-coded badge), Area %.damage_findingsis filled by Cluster 1's damage pipeline; reports for policies without damage findings skip this section entirely. - Signature block — the recipient's PNG signature and the
session.submitted_atdate.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 strokeseverity: "light"→ yellow strokeseverity: "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:
import { getBranding } from "@/lib/verify-ai/branding";
const brand = await getBranding(customerId);
// brand.displayName, brand.logoPath, brand.primaryColor,
// brand.contactEmail, brand.pdfFooterText, brand.defaultLocalegetBranding 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.
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
- Sending an inspection link — the upstream flow that produces multi-shot session PDFs.
- Reading VIN and license plate from a photo — populate the vehicle block so the header isn't blank.
- Verifying parked vehicles — enable damageMode on a policy to surface the damage section.