Blog/Use cases

Add a Save as PDF button to a website

Use cases··5 min read

To add save as pdf to website UI in a SaaS app, keep the button in the browser and the print job on your server. The click POSTs a record id (or form state) to a route you own. That route builds a UTF-8 HTML document, calls POST /v1/pdf with a Bearer key, and sets response to url. The JSON includes a public GET /v1/files/:id link that expires in 24 hours. The page then opens or downloads that URL. Do not put pdf_live_ keys in frontend bundles, Next.js public env, or a browser fetch to api.relaypdf.com. Product: https://relaypdf.com/html-to-pdf. Docs: https://relaypdf.com/docs/pdf. Client-side canvas libraries are a different path: https://relaypdf.com/blog/html2pdf-js-vs-api.

window.print opens the OS dialog. A user can save as PDF there, but you do not get a stored file, a stable filename you control after the fact, or a URL you can email. html2pdf.js and jsPDF run in the tab. They never see your API key, and they also never run Chromium print the way /v1/pdf does. If the artifact has to match print CSS, survive webfonts, and land on a 24-hour download URL, use the API from the backend.

Frontend button

The control is ordinary UI: a button, a menu item, an export icon. Disable it while the request is in flight. Show a short error if the server returns a non-2xx. Do not embed RELAYPDF_API_KEY, NEXT_PUBLIC_RELAYPDF_API_KEY, or a hard-coded Bearer header. Query-string keys are rejected on the API. A header on a request that originates in the browser is visible in DevTools and in any XSS that can read the page.

On click, POST your own path, for example /api/export-pdf, with a JSON body the user is already allowed to see: invoiceId, reportId, or a snapshot of form fields. Session cookies or your existing auth header stay on that same origin. The server decides whether the signed-in account may export that record. The browser never talks to RelayPDF.

Backend HTML

The handler loads the record, renders a print-ready HTML document as a UTF-8 string, and sends it as html. HTML is a JSON string, not Base64. Provide exactly one of html, url, markdown, or templateId. Mixing sources is invalid_request. filename, if present, must end in .pdf.

html is the right source for a private app view. url fetches a public http(s) page only. Private, loopback, and metadata hosts return url_not_allowed. cookies (name, value, optional domain / path / url) and extraHTTPHeaders apply to that Chromium fetch. They are not a login robot and they do not open RFC1918 hosts. Do not try to print an authenticated dashboard by passing the live app URL plus the user's cookie jar from the browser.

If you already store print layouts, use a published templateId plus templateData instead of inline html. templateVersion pins an integer published version. That surface lives on https://relaypdf.com/docs/templates. This post stays on raw html so the Save button does not depend on the template gallery.

RelayPDF and the 24-hour download URL

POST https://api.relaypdf.com/v1/pdf with Authorization: Bearer pdf_live_… and Content-Type: application/json. response is binary (default), url, or async. For a Save button, url is the usual mode. The body you get back is JSON: id, status, url, filename, sizeBytes, expiresAt. url looks like https://api.relaypdf.com/v1/files/pdf_… and can be fetched for 24 hours without a key. After expiresAt the file is gone. Do not treat it as an archive. If you need a copy in your bucket, GET the file on the server before you return, or use binary and stream the bytes yourself.

binary returns 200 application/pdf with x-relaypdf-id, x-relaypdf-size, and content-disposition. That works if your route proxies the PDF. async returns 202; poll GET /v1/jobs/:id. callbackUrl must be https. Use async when the document is heavy and you do not want the button request to sit on the Chromium render budget (options.timeout, max 60000). HTML/URL/Markdown/template PDF debit is $0.015 on success. Failed jobs, 429, and 402 are not billed.

printBackground defaults to true. format defaults to letter. waitUntil defaults to networkidle0 (load, domcontentloaded, networkidle0, networkidle2). waitForSelector is a CSS selector or { selector, timeout, visible }. waitForTimeout is extra milliseconds after the other waits, max 30000. margin is CSS lengths. landscape is a boolean. headerTemplate and footerTemplate are Chromium margin fragments with pageNumber, totalPages, date, title. Field list: https://relaypdf.com/docs/options. There is no documented mediaType or emulateMedia field.

Request

Server-side Node @relaypdf/sdk. The frontend only calls your route. Field names in options stay camelCase.

// server: /api/export-pdf

import { RelayPDF } from "@relaypdf/sdk";
const client = new RelayPDF({ apiKey: process.env.RELAYPDF_API_KEY });
const html = renderInvoiceHtml(invoice); // your function, UTF-8 string
const job = await client.pdf.fromHtml(html, {
  filename: "invoice-1042.pdf",
  response: "url",
  options: {
    format: "letter",
    printBackground: true,
    margin: { top: "12mm", right: "12mm", bottom: "14mm", left: "12mm" },
  },
});
// job.url is GET /v1/files/:id — 24h, no key return { url: job.url, expiresAt: job.expiresAt }; Equivalent curl from the same backend, not from the browser:
curl -X POST https://api.relaypdf.com/v1/pdf
  -H "Authorization: Bearer pdf_live_..."
  -H "Content-Type: application/json"
  -d '{
    "html": "<h1>Invoice #1042</h1><p>Total: $1,200.00</p>",
    "filename": "invoice-1042.pdf",
    "response": "url",
    "options": { "format": "letter", "printBackground": true }
  }'

Browser side, after your route returns { url, expiresAt }: window.location.assign(url), or create an <a download> pointing at url. The 24-hour file endpoint does not need your Bearer token. Do not append the key as a query parameter.

What the button must not do

Anti-patternWhy
fetch api.relaypdf.com from the tabLeaks the key; CORS is not a security control
NEXT_PUBLIC_ / VITE_ API keyBundled into JS the user can read
url = live /app/invoice/1042Private hosts → url_not_allowed
html2pdf.js for the same fileCanvas PDF, not Chromium print
Treat files/:id as permanentExpires in 24 hours
Query-string api keyRejected

Errors

Failures return { error: { code, message } }. invalid_request for mixed sources, a filename that is not .pdf, or an options value outside the documented range. url_not_allowed for a private host. render_failed when Chromium times out or cannot print; those jobs are not billed. payment_required on an empty wallet. rate_limited with Retry-After (trial 20/min, funded 60/min). Map those codes to a button error. Do not retry payment_required in a loop.

Ship it

Button on the origin, HTML on the server, POST /v1/pdf with response url, hand the 24-hour file link back to the tab, keep the key off the network the user can see. CTA: https://relaypdf.com/html-to-pdf. Endpoint: https://relaypdf.com/docs/pdf. Options: https://relaypdf.com/docs/options. Why not a canvas library: https://relaypdf.com/blog/html2pdf-js-vs-api.

Ready to generate?

One API for HTML, Markdown, URLs, and Office. REST, SDK, CLI, or MCP.