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:

EndpointAuthUse case
GET /api/v1/verifications/[id]/pdfX-API-KeyOne PDF per /v1/verify call. Server-to-server access.
GET /api/v1/inspection-sessions/[token]/pdfSigned tokenMulti-shot session reports. Safe to expose to end users.

Both stream application/pdf directly (no redirect). An inspection's report is rendered once it is submitted (after the Pickup comparison, for a pickup), stored, and served from storage after that; it is also linked from the inspection's pdf_url. The per-verification report is rendered on every request, so cache the bytes on your side if you need them more than once.

1. Pull a verification PDF

bash
curl \
  -H "X-API-Key: vai_your_api_key" \
  https://verdictcapture.com/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://verdictcapture.com/api/v1/inspection-sessions/vai_lnk_eyJ.../pdf" \
  -o my-inspection.pdf

The endpoint takes the same token as the inspection link. It keeps working after the inspection is submitted, so the link you already hold is enough to fetch the finished report. An expired, replaced or tampered token gets 410. With your API key, the inspection's pdf_url (from GET /api/v1/inspection-sessions/{id} or the inspection.submitted webhook) is a signed link to the same file.

3. What's in the report

The renderer lives in lib/verify-ai/pdf/render.tsx and uses @react-pdf/renderer. An inspection report is built from the inspection's summary, so every label, status and reason reads exactly as it does in the API, the webhook and the dashboard.

An inspection report contains:

  1. Header — your logo and display name, the title (Delivery inspection, Pickup inspection, or Inspection when no stage was set), the inspection id, and when the report was generated. Times are printed in UTC.
  2. Details — your reference_id, the vehicle (year, make and model), VIN and plate from the vehicle you sent (or, when you sent none, from a VIN or plate read off a photo), the submitted time, and the driver's name from recipient.name.
  3. Outcome — Clear or Needs review, with every outcome reason.
  4. Odometer — the reading with its unit, and the fuel or charge level.
  5. Changes since delivery — pickups only. How many new damage items were found, the odometer at Delivery and Pickup with the distance driven, the fuel change, and each compared view's result (no new damage, new items, or photos not comparable). For each view with new damage, the items (description, location, severity) and the Delivery and Pickup photos side by side. When there was nothing to compare against, or the comparison failed, the section says so.
  6. Photos — one tile per view, labelled with the view name ("Driver side", "Odometer", "Damage close-up 1"), its result (Accepted, Flagged for review, Continued without an accepted photo, …) with the policy category, and the feedback for that photo. A required view that was never captured shows Missing.
  7. Damage findings — only when a photo's check reported damage. One row per finding: a number that matches the box on the photo, the view, the panel, the damage type, the severity, and the share of the panel it covers.
  8. Review — the staff decision (Approved or Damage confirmed), who made it and when, and the note, when the report is rendered after a decision is recorded.
  9. Signature — only when a signature was captured, with the signer's name and the time.

A per-verification report has the same header, the vehicle from the photo when one was read, the photo with its result and feedback, and the damage table.

4. Damage overlays

When a photo's check reported damage, each finding is drawn on the photo as a box, numbered to match its row in the damage table, before the photo goes into the report. Findings are stored with bbox: [x1, y1, x2, y2] as fractions of the image size, and are scaled to the photo as it appears in the report. Colors follow severity:

  • severity: "light" → yellow
  • severity: "medium" → orange (also the default when severity is missing)
  • severity: "severe" → red

Findings with severity: "none" are not drawn or listed. If a box can't be drawn, the photo is still included without it — a bad box never blanks a photo. Photos are oriented and downsized for the page, so a report with a dozen phone photos stays a reasonable size.

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.

Reports are written in English. recipient.locale changes the inspection page and the invite email, not the PDF.

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://verdictcapture.com/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 inspection PDF. For end-user-facing flows, put the token PDF URL straight into the email and skip the attachment. The token already authorizes it; the recipient doesn't need an account. Remember the link stops working when the token expires.

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

What do you need to verify?

Tell us about the evidence, the decision and the workflow around it.

Discuss your workflow