Generate a PDF in Retool from a table row or form
Retool apps show data; they do not print it as a formatted document. A REST API resource pointed at the API fixes that with one query: the selected table row becomes a report, a form becomes an invoice, and a button saves or opens the PDF. The same resource works in Retool Workflows, so a webhook-triggered workflow can hand a PDF link back to whatever called it. Every request body on this page was executed against the live API before publishing; the Retool editor steps are quoted from Retool's documentation and were not observed first-hand, so component property names should be checked against your editor's autocomplete.
Set up the REST API resource once
Create a free API key at /signup. In Retool, Retool's REST API guide says to go to Resources → Create new → Resource, search for REST API, and configure:
- Name
PaperType; Query mode Manual queries; Base URLhttps://papertype.dev/v1. - Authentication Bearer token, token = your
dp_…key. Retool's guide says this method sendsAuthorization: Bearer {token}and that credential fields are encrypted; its best-practice list says to keep the token in a configuration variable rather than typing it in. - Test connection. Retool describes this as a request to the base URL that checks it is reachable and the credentials are valid.
GET /v1answers200with your plan and usage for a valid key and401for a wrong one, so the result of the test is meaningful. - Create resource. The resource is now available in apps, workflows and agent tools across the organisation (Retool's wording).
Requests leave from Retool's servers, so the key is never in the browser. Retool's query guide also notes that it sanitises any header whose key contains authorization in its logs and debug console.
Selected table row → report PDF
Build the Markdown in a Transformer (Code panel → + → Transformer; Retool's guide: the return value becomes reportMarkdown.value, and the transformer re-runs whenever a referenced value changes). It turns the selected row of a Table component into a title and a Field | Value table, escaping pipes and line breaks:
// Transformer: reportMarkdown
const row = {{ table1.selectedRow }};
if (!row) return "";
const esc = v => String(v ?? "").replace(/\|/g, "\\|").replace(/\n/g, "<br>");
const lines = Object.entries(row)
.filter(([k]) => k !== "name")
.map(([k, v]) => `| ${esc(k)} | ${esc(v)} |`);
return `# ${esc(row.name)}\n\n| Field | Value |\n|---|---|\n${lines.join("\n")}`;Then add a query renderPdf: Resource PaperType, Action type POST, URL /pdf (appended to the base URL), Body Raw with a header Content-Type = application/json. Retool's guide on raw bodies says string values go in {{ }}, booleans and numbers go bare, and objects and arrays go through JSON.stringify(); using JSON.stringify for every string too is what keeps a quote or line break inside the Markdown from breaking the body:
{
"markdown": {{ JSON.stringify(reportMarkdown.value) }},
"title": {{ JSON.stringify(table1.selectedRow.name) }},
"theme": "clean",
"page_numbers": true,
"filename": {{ JSON.stringify(table1.selectedRow.name.replace(/[^\w-]+/g, "-") + ".pdf") }},
"output": "url"
}For the row Q4 launch plan · Jane Doe · 2026-11-15 · On track | review Friday · Line one⏎Line two the body that leaves Retool, and that the live check sent, is:
{
"markdown": "# Q4 launch plan\n\n| Field | Value |\n|---|---|\n| owner | Jane Doe |\n| due | 2026-11-15 |\n| status | On track \\| review Friday |\n| notes | Line one<br>Line two |",
"title": "Q4 launch plan",
"theme": "clean",
"page_numbers": true,
"filename": "Q4-launch-plan.pdf",
"output": "url"
}The response is JSON: {"url": "https://papertype.dev/v1/files/….pdf", "filename": "Q4-launch-plan.pdf", "pages": 1, "bytes": …, "expires_at": "…"}. Set the query to Run query only when manually triggered (Retool's default for POST) and wire a Button: event handler Control query → renderPdf. On the query's Success event handler, pick one of two documented utilities:
- Open it: Run script →
utils.openUrl(renderPdf.data.url, { newTab: true })(Retool's reference:newTabopens the URL in a new tab). - Download it: add a second query
fetchPdfon the built-in REST API resource, GET, URL{{ renderPdf.data.url }}, no auth (the link is public for 24 hours). Retool's files guide says a binary response is exposed as a file object withbase64Data. Thenutils.downloadFile({ base64Binary: fetchPdf.data.base64Data }, renderPdf.data.filename.replace(/\.pdf$/, ""), "pdf"): Retool's reference takes a string or an object withbase64Binary, a file name and the extension.
With output: "url" the finished PDF (never your source) is kept at an unguessable link for 24 hours and then deleted; see the privacy policy. The same two utilities also work with html instead of markdown; every field from the API reference applies.
Form → invoice with the hosted template
For an invoice, send the Form's values to the hosted invoice template instead of writing Markdown. With a Form component invoiceForm (fields number, date, due_date, client, client_address as a multi-line text input) and an editable Table lineItems with columns description, quantity, unit_price, the raw body is:
{
"template": "invoice",
"data": {{ JSON.stringify({
number: invoiceForm.data.number,
date: invoiceForm.data.date,
due_date: invoiceForm.data.due_date,
currency: "USD",
from: { name: "Northwind Studio", address_lines: ["12 Example Road", "Springfield"] },
to: { name: invoiceForm.data.client, address_lines: invoiceForm.data.client_address.split("\n") },
items: lineItems.data.map(r => ({ description: r.description, quantity: Number(r.quantity), unit_price: Number(r.unit_price) })),
tax_rate: 8.25, tax_label: "Sales tax"
}) }},
"page_size": "Letter",
"filename": {{ JSON.stringify(invoiceForm.data.number + ".pdf") }},
"output": "url"
}Live result for INV-1042, two line items (Design, October: 10 × 120; Hosting: 1 × 45.50) and 8.25% tax: one Letter page, filename INV-1042.pdf, totals $1,200.00 + $45.50 + tax = $1,348.25. Submitting the form with the number blank returns 422 Missing required field(s) for template 'invoice': number; the query fails, renderPdf.error holds the message, and the Failure event handler can show it with utils.showNotification. Number() matters: an editable table hands you strings, and the template wants numbers. The certificate and report templates take the same route with their own fields.
Workflows: a PDF endpoint any system can call
Retool Workflows can turn the same resource into a small service: a webhook trigger receives JSON, a Resource query block renders it, and a Response block hands back the link. Quoted from Retool's webhook and Response block pages:
- Trigger: open the startTrigger block and add a Webhook trigger. Events must be sent with
Content-Type: application/json; the payload is available asstartTrigger.data. Put{"markdown": "# Hello\n\nFrom a workflow.", "title": "Hello", "filename": "hello.pdf"}in Test JSON Parameters for manual runs. - Block
render: a Resource query block on the PaperType resource, POST/pdf, raw body with the JSON header. Pass through only the fields you mean to accept, and force the link output:
{{ JSON.stringify({
markdown: startTrigger.data.markdown,
title: startTrigger.data.title,
filename: startTrigger.data.filename,
theme: "clean",
page_numbers: true,
output: "url"
}) }}- Response block: status
200, body{{ { url: render.data.url, pages: render.data.pages, expires_at: render.data.expires_at } }}. Retool: a workflow with a Response block does not answer until that block runs, and synchronous workflows have a 120-second timeout per block; a render here takes well under a second. - Error path: add a second Response block as the workflow's global error handler (Retool's error-handler guide) with status
422and body{{ { error: render.error } }}, so a bad key or an over-quota month comes back as JSON rather than a timeout.
Calling it, in the shape Retool's docs give for a webhook event:
curl -X POST "https://api.retool.com/v1/workflows/<workflow-id>/startTrigger" \
-H 'Content-Type: application/json' -H 'X-Workflow-Api-Key: retool_wk_...' \
-d '{"markdown": "# Hello\n\nFrom a workflow.", "title": "Hello", "filename": "hello.pdf"}'
# → {"url": "https://papertype.dev/v1/files/….pdf", "pages": 1, "expires_at": "2026-10-25T12:30:00Z"}Retool recommends the X-Workflow-Api-Key header over the workflowApiKey query parameter, and warns not to publicise the endpoint URL. The request body the render block builds from that test payload was executed live; the Retool side of the workflow was not.
Limits that matter in Retool
- Images. The renderer has no network access. Images, fonts and stylesheets at external URLs are never fetched; inline them as
data:URIs. A request that references an external URL fails with a 422 that names the URL, rather than silently dropping the asset. Retool's files guide says a File Input's value holds the upload as Base64, so a logo is"data:image/png;base64," + logoInput.value[0]in the template'slogofield or an<img>; for a fixed logo paste the data: URI into a Transformer once. - 2 MB of source per request, 300 pages per document, 2 concurrent renders per account. A whole table rendered as one document can pass 2 MB; render per row, or send a Loop block through the rows in a workflow (two renders run concurrently per account; the rest wait).
- Quota. Free-plan output carries a small
papertype.devfooter mark. Paid plans (from $9/month) do not. The free plan is 50 documents a month; the API returns429at the limit, never overage. An internal tool used daily by a team usually needs the $9 plan. - Links expire. A
urlresponse is valid for 24 hours; open, download or store the file in the same action rather than saving the link in a database. - Errors.
401,413,422and429are JSON with adetailmessage; in Retool the query fails and the message is inrenderPdf.error. Failed renders are not counted against the quota. The API accepts requests from any address, so Retool Cloud needs no IP allowlisting; a self-hosted Retool needs outbound HTTPS topapertype.dev. - No JavaScript is executed. Send the final HTML; anything a script would have drawn or filled in will not appear.
Questions
Can the query return the PDF itself instead of a link?
Leave `output` out and the response body is the PDF. Retool's files guide documents that a binary response to a REST query is exposed as a file object with `base64Data`, which `utils.downloadFile({ base64Binary: … }, name, "pdf")` can save; the guide shows it for a GET of a remote file, and we have not observed it for a POST in the editor. If the query preview shows text instead of a file object, use the link plus the `fetchPdf` GET described above, which is the documented path.
Is my API key visible to the people who use the app?
No. The key lives on the resource, encrypted, and requests are made from Retool's servers; the browser only sees the query result. Retool also redacts authorization headers in its logs and debug console. Anyone with edit permission on resources can read or change the resource, so keep the key in a configuration variable and use a separate free key per environment.
Why does "Test connection" matter here?
Retool sends a request to the base URL and reports whether it was reachable and the credentials were accepted. `GET /v1` returns your plan, quota and usage with a valid key and 401 with a wrong one, so a failed test means the key is wrong or mistyped, and a passing test means the first query will authenticate.
Can a workflow render many PDFs at once?
Yes: a Loop block over the rows with the `render` block inside. Each row is one API call and one quota document; renders are processed two at a time per account, so a 200-row loop takes a few minutes rather than seconds. Watch the free plan's 50 documents a month and the 120-second per-block timeout on synchronous (webhook-answering) workflows; for large batches run the workflow asynchronously and store the links.
Did you test this inside Retool?
Partly. Every request body on this page, including the one the workflow block builds from the test payload, was executed against the live API with the same method, headers and body Retool would send; the PDFs, filenames, totals, the 24-hour link and the 422 and 401 paths were checked. The Retool editor steps, utilities and component properties are quoted from Retool's documentation and were not exercised in a Retool account. If a step is wrong, tell support@papertype.dev and the page gets fixed.
Related
Free previews carry a watermark and are deleted after 24 hours. Free API output carries a footer mark. The renderer runs no JavaScript and never fetches external URLs. Privacy · Terms · Refunds