PaperType

Generate a PDF from a Google Sheets row with Apps Script

Google Sheets can print a sheet as a PDF, but not turn one row into a formatted document. Apps Script can: it reads the row, posts JSON to the API with UrlFetchApp, and gets a PDF blob back that it can save to Drive or attach to an email. This page gives you a single script file with a PDF menu (row → report, row → invoice) and a form-submit trigger that emails the submission as a PDF. The request each function builds was run against the live API before publishing; the script itself was not run inside a Google account, so the Sheets-side steps are quoted from Google's documentation.

Setup: paste the script, store the key

Create a free API key at /signup. Then, in the spreadsheet:

  • Extensions → Apps Script. Replace the editor contents with papertype-sheets.gs (about 90 lines, MIT) and save.
  • Project Settings → Script properties → Add script property: name PAPERTYPE_API_KEY, value your dp_… key. Google's Properties guide describes script properties as key-value strings stored with the script and shared by all its users; the key stays out of the code and out of the sheet.
  • Reload the spreadsheet. The onOpen function adds a PDF menu with two items (Google's menu guide: createMenu → addItem → addToUi). The first run asks you to authorise the script for Drive, Gmail and external requests.

The function that talks to the API is short. muteHttpExceptions matters: without it a 4xx throws a generic exception and you never see the API's message (Google's reference: default false).

function renderPdf_(body) {
  const key = PropertiesService.getScriptProperties().getProperty('PAPERTYPE_API_KEY');
  const res = UrlFetchApp.fetch('https://papertype.dev/v1/pdf', {
    method: 'post',
    contentType: 'application/json',
    headers: { Authorization: 'Bearer ' + key },
    payload: JSON.stringify(body),
    muteHttpExceptions: true,
  });
  if (res.getResponseCode() !== 200) {
    throw new Error('PaperType ' + res.getResponseCode() + ': ' + res.getContentText());
  }
  return res.getBlob().setName(body.filename || 'document.pdf');
}

Row → report PDF saved to Drive

Select any cell in a data row and choose PDF → Save selected row as report. reportBody() uses the first column as the title and turns the other columns into a Field | Value table in Markdown; date cells (which Sheets hands to the script as Date objects) become yyyy-MM-dd, pipes are escaped and line breaks inside a cell become <br>. For a row Q4 launch plan · Jane Doe · 2026-11-15 · On track | review Friday · Line one⏎Line two the script sends:

{
  "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"
}

The response is the PDF (Content-Type: application/pdf, X-Pages: 1). DriveApp.createFile(blob) saves it in the root of your Drive (Google's reference), and the menu shows the file link. To file it in a folder instead, use DriveApp.getFolderById(id).createFile(blob). Swap the Markdown builder for your own layout, or send html instead; every field from the API docs works here.

Row → invoice with the hosted template

PDF → Save selected row as invoice fills the hosted invoice template. Header names are matched case-insensitively: Number, Date, Due date, Client, Client address (line breaks allowed), Items, Tax rate, Currency. Items is one cell with one line per item, description, quantity, unit price, so a whole invoice fits on one row. Edit the from block in the script once with your own details. The row INV-1042 · 2026-10-22 · 2026-11-21 · Acme Client Ltd · 1 Sample Street⏎Shelbyville · Design, October, 10, 120⏎Hosting, 1, 45.50 · 8.25 becomes:

{
  "template": "invoice",
  "data": {
    "number": "INV-1042", "date": "2026-10-22", "due_date": "2026-11-21", "currency": "USD",
    "from": {"name": "Northwind Studio", "address_lines": ["12 Example Road", "Springfield"]},
    "to": {"name": "Acme Client Ltd", "address_lines": ["1 Sample Street", "Shelbyville"]},
    "items": [
      {"description": "Design, October", "quantity": 10, "unit_price": 120},
      {"description": "Hosting", "quantity": 1, "unit_price": 45.5}
    ],
    "tax_rate": 8.25, "tax_label": "Sales tax"
  },
  "page_size": "Letter",
  "filename": "INV-1042.pdf"
}

Live result: one Letter page, Content-Disposition: attachment; filename="INV-1042.pdf", totals $1,200.00 + $45.50 + 8.25% tax = $1,348.25. A row without a number returns 422 Missing required field(s) for template 'invoice': number, which the script surfaces as the alert text. The certificate and report templates work the same way with their own fields.

Form submission → PDF by email

For a Google Form that writes to the sheet, onFormSubmit(e) renders the new row as a report and emails it to the address in the form's Email question. It is an installable trigger: in the script editor open Triggers → Add Trigger, choose function onFormSubmit, event source From spreadsheet, event type On form submit, and save (steps from Google's installable-triggers guide). The handler reads the row through e.range and the address through e.namedValues, both documented on the spreadsheet form-submit event:

function onFormSubmit(e) {
  const sheet = e.range.getSheet();
  const lastCol = sheet.getLastColumn();
  const headers = sheet.getRange(1, 1, 1, lastCol).getValues()[0];
  const values = sheet.getRange(e.range.getRow(), 1, 1, lastCol).getValues()[0];
  const pdf = renderPdf_(reportBody(headers, values));
  const to = (e.namedValues['Email'] || [])[0];
  GmailApp.sendEmail(to, 'Your submission as a PDF', 'Attached is a copy of what you submitted.', { attachments: [pdf] });
}

The first column of a form-linked sheet is the timestamp, so the report title is the submission time; move the title column first or set title explicitly if you want a name there. This path was not exercised end to end (it needs a Google account); the request it builds is the same reportBody request shown above, which was.

Limits that matter in Sheets

  • Apps Script quotas (Google's quota page, October 2026): URL Fetch calls 20,000 per day on consumer accounts and 100,000 on Google Workspace accounts; 50 MB per response; 6 minutes per execution. One row is one call, well inside all three.
  • Our quota. Free-plan output carries a small papertype.dev footer mark. Paid plans (from $9/month) do not. The free plan is 50 documents a month; the API returns 429 at the limit, never overage. A form that gets 200 submissions a month needs the $9 plan.
  • 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. For a logo, read the file from Drive and inline it: 'data:image/png;base64,' + Utilities.base64Encode(DriveApp.getFileById(id).getBlob().getBytes()) (both methods are in Google's reference) into the template's logo field or an <img>.
  • 2 MB of source per request, 300 pages per document, 2 concurrent renders per account. A single row is nowhere near 2 MB; a whole sheet rendered as one document can be, so render per row.
  • Errors. 401 (key missing or wrong), 413, 422 (invalid Markdown options or a missing template field, named in the message) and 429 come back as JSON with a detail field; the script throws it as the alert text. Failed renders are not counted against the quota.

Questions

Can I run it for every row at once?

Yes. Loop over `sheet.getDataRange().getValues()` from row 2 and call `renderPdf_(reportBody(headers, row))` for each; each is one API call and one quota document. Stay under the 6-minute execution limit (a render takes well under a second) and the free plan's 50 documents a month.

Does my API key end up in the spreadsheet?

No. It lives in the script's properties store, which only the script's editors can see. Anyone who can edit the Apps Script project can read it, so share the sheet, not the script, with people who only need to use the menu.

Why does the first run ask for broad permissions?

Apps Script asks for every scope the code touches: external requests (UrlFetchApp), Drive (createFile), Gmail (sendEmail) and the spreadsheet. Delete the functions you do not use (for example the Gmail trigger) and the prompt shrinks accordingly.

Can I generate the PDF with my own HTML instead of a table?

Yes. Build an HTML string from the row and send `html` instead of `markdown`, with `theme` and `css` as in the API docs. Nothing is fetched from the network, so inline images as data: URIs.

Did you test this inside Google Sheets?

Partly. The functions that build the two request bodies were executed with sample rows (including Date cells, a pipe and a multi-line cell) and their output was posted to the live API with the same method, headers and payload UrlFetchApp sends; the PDFs, filenames and the 422/401 error paths were checked. The Sheets UI steps (menu, properties, trigger) are quoted from Google's documentation, not observed. 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