api

POST /api/audits/:id/pdf and GET /api/pdfs/:id

Generate a branded PDF report for a completed audit. Two-step: POST to queue the render, then GET the JSON status or the PDF stream when ready.

What these endpoints do

PDF generation is a two-step flow. First, queue the render with POST /api/audits/:id/pdf. Then poll GET /api/pdfs/:id until status === "ready" and stream the file. The render takes 5 to 20 seconds because it spawns a headless Chromium to print the white-label HTML report.

  • POST /api/audits/:id/pdf queues a render job. Body is optional and accepts language (one of en, es, de, fr, pt, it) and brand_id (a saved brand kit, or null for plain MetricSpot branding).
  • GET /api/pdfs/:id returns JSON status by default. Send Accept: application/pdf to stream the file once status === "ready".
  • Branded PDFs are Pro-only: white-label branding (logo, color, footer) is gated by the plan. Plain MetricSpot-branded PDFs are available on every paid plan.
  • The audit must be status: "completed" before a PDF can be queued. PDF for a still-running audit returns 409.

Why it matters

PDFs are the deliverable for agencies, freelancers, and consultants. An automated nightly render means every client opens their inbox to a fresh report without anyone in the agency clicking a button.

Concrete workflows:

  • An agency cron iterates every saved client URL, calls POST /api/audits/:id/pdf with the agency’s brand_id, polls until ready, and emails the PDF to the client.
  • A Zapier zap watches for new audits in GET /api/audits, triggers a PDF render, and uploads the file to a Dropbox folder shared with the client.

How to use it

Step 1: queue the render

POST /api/audits/12345/pdf HTTP/1.1
Host: app.metricspot.com
Authorization: Bearer ms_live_xxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json

{ "language": "en", "brand_id": null }

Step 2: poll for status

GET /api/pdfs/9876 HTTP/1.1
Host: app.metricspot.com
Authorization: Bearer ms_live_xxxxxxxxxxxxxxxxxxxxxxxx

Step 3: download the file

GET /api/pdfs/9876 HTTP/1.1
Host: app.metricspot.com
Authorization: Bearer ms_live_xxxxxxxxxxxxxxxxxxxxxxxx
Accept: application/pdf

curl (full flow)

TOKEN="ms_live_xxxxxxxxxxxxxxxxxxxxxxxx"

# 1. Queue
PDF_ID=$(curl -s -X POST https://app.metricspot.com/api/audits/12345/pdf \
  -H "authorization: Bearer $TOKEN" \
  -H "content-type: application/json" \
  -d '{"language":"en"}' | jq -r .pdf.id)

# 2. Poll status
while true; do
  STATUS=$(curl -s https://app.metricspot.com/api/pdfs/$PDF_ID \
    -H "authorization: Bearer $TOKEN" | jq -r .pdf.status)
  [ "$STATUS" = "ready" ] && break
  [ "$STATUS" = "failed" ] && { echo "render failed"; exit 1; }
  sleep 2
done

# 3. Download
curl -o report.pdf https://app.metricspot.com/api/pdfs/$PDF_ID \
  -H "authorization: Bearer $TOKEN" \
  -H "accept: application/pdf"

Node (queue + poll + download)

const headers = { authorization: "Bearer ms_live_xxxxxxxxxxxxxxxxxxxxxxxx" };

const queue = await fetch("https://app.metricspot.com/api/audits/12345/pdf", {
  method: "POST",
  headers: { ...headers, "content-type": "application/json" },
  body: JSON.stringify({ language: "en" }),
});
const { pdf } = await queue.json();

while (true) {
  await new Promise((r) => setTimeout(r, 2000));
  const r = await fetch(`https://app.metricspot.com/api/pdfs/${pdf.id}`, { headers });
  const data = await r.json();
  if (data.pdf.status === "ready") break;
  if (data.pdf.status === "failed") throw new Error(data.pdf.error_message);
}

const file = await fetch(`https://app.metricspot.com/api/pdfs/${pdf.id}`, {
  headers: { ...headers, accept: "application/pdf" },
});
const buf = await file.arrayBuffer();
await Bun.write("report.pdf", buf);

Python httpx

import httpx, time, pathlib

HEADERS = {"authorization": "Bearer ms_live_xxxxxxxxxxxxxxxxxxxxxxxx"}

r = httpx.post(
    "https://app.metricspot.com/api/audits/12345/pdf",
    headers=HEADERS, json={"language": "en"}, timeout=30.0,
)
pdf_id = r.json()["pdf"]["id"]

while True:
    time.sleep(2)
    s = httpx.get(f"https://app.metricspot.com/api/pdfs/{pdf_id}", headers=HEADERS).json()
    if s["pdf"]["status"] == "ready":
        break
    if s["pdf"]["status"] == "failed":
        raise RuntimeError(s["pdf"]["error_message"])

resp = httpx.get(
    f"https://app.metricspot.com/api/pdfs/{pdf_id}",
    headers={**HEADERS, "accept": "application/pdf"},
    timeout=60.0,
)
pathlib.Path("report.pdf").write_bytes(resp.content)

Response

POST /api/audits/:id/pdf

201 Created:

{
  "pdf": {
    "id": 9876,
    "audit_id": 12345,
    "brand_id": null,
    "language": "en",
    "status": "pending",
    "created_at": "2026-05-14T10:20:01.000Z"
  }
}

GET /api/pdfs/:id (JSON status)

200 OK:

{
  "pdf": {
    "id": 9876,
    "audit_id": 12345,
    "status": "ready",
    "error_message": null,
    "created_at": "2026-05-14T10:20:01.000Z",
    "generated_at": "2026-05-14T10:20:18.000Z",
    "download_url": "/api/pdfs/9876/download"
  }
}

Fields:

  • status: pending (queued), rendering (Chromium running), ready, or failed.
  • error_message: populated when status === "failed".
  • generated_at: ISO timestamp the file finished rendering, null until ready.
  • download_url: relative path to the streamable download endpoint, only set when ready.

GET /api/pdfs/:id with Accept: application/pdf

Streams the file with Content-Type: application/pdf and Content-Disposition: attachment; filename="<domain>-audit.pdf". Status code is 200. If the file is missing from disk (rare, indicates GC), returns 410 Gone.

Error envelope

{ "error": "Audit not yet complete" }

Common errors

CodeWhenAction
UNAUTHORIZED (401)Missing or invalid Bearer tokenMint a key at app.metricspot.com/settings/api-keys
FORBIDDEN (403)Audit or PDF belongs to a different accountUse the owning account’s key
AUDIT_NOT_FOUND (404)Audit id doesn’t existPass an audit you own
INVALID_URL (400):id not a positive integerPass an integer id
CONFLICT (409)Audit not yet completed, or PDF not yet readyWait for the audit to finish; poll the PDF status
QUOTA_EXCEEDED (402)Plan doesn’t allow white-label branding for the requested brand_idDrop brand_id for a plain PDF, or upgrade to Pro
GONE (410)PDF file missing on diskRe-queue with POST /api/audits/:id/pdf

Frequently asked questions

How long does rendering take?

5 to 20 seconds. The render pipeline spawns a headless Chromium, navigates to the white-label HTML report, and prints to PDF. JS-heavy charts and large finding lists push toward the upper end.

Can I queue a PDF before the audit completes?

No. POST /api/audits/:id/pdf returns 409 Conflict if the audit is still queued or running. Wait until GET /api/audits/:id reports status: "completed", then queue.

How do I get a branded PDF?

Save a brand kit (logo, primary color, footer text) at app.metricspot.com/settings/brands, then pass its id in brand_id. If you omit brand_id, the API auto-applies your singular saved brand kit when present, falling back to plain MetricSpot branding.

Where is the PDF stored?

On the app server’s filesystem, behind the auth-gated GET /api/pdfs/:id/download route. The signed download URL is part of the JSON status response and is only served to the account that owns the audit.

Sources

Last updated 2026-05-14