appscript.dev
Automation Advanced Docs Drive

Build a form-to-PDF web service

Convert Northwind form submissions to PDFs on the fly — POST in, PDF out.

Published Oct 27, 2025

Northwind’s booking form, intake questionnaire and event sign-up sheets all end the same way — somebody needs a tidy PDF of what was submitted. Doing that by hand means opening a template, pasting in the answers, exporting, and emailing it on. Multiply that by every submission and it is an afternoon a week gone.

This script turns a Google Doc template into a tiny PDF-generation service. It publishes a web app endpoint that accepts a JSON POST, fills the placeholders in the template with the posted values, renders the result as a PDF, and sends it straight back in the response. Any form, script or workflow that can make an HTTP request now has a PDF generator on tap.

What you’ll need

  • A Google Doc to act as the template, with placeholders in the body written as {{key}} — for example {{name}}, {{date}}, {{total}}. The keys must match the keys in the JSON you post.
  • The template’s file ID, copied from its URL, set as TEMPLATE_ID in the config block.
  • A Drive folder you are happy to use as scratch space — the script creates a short-lived copy of the template per request and deletes it again.
  • The script deployed as a web app (covered in Run it below).

The script

// The Google Doc used as the PDF template. Body text should contain
// {{placeholder}} tokens that match the keys you POST.
const TEMPLATE_ID = '1abcPdfTemplateId';

// Prefix for the throwaway copy made on each request. Handy when
// hunting for stragglers in Drive if a run ever fails mid-way.
const TEMP_PREFIX = 'tmp-pdf-';

/**
 * Web app entry point. Accepts a JSON POST, fills the template, and
 * returns the rendered PDF as base64 text.
 * @param {Object} e - The event object from the web app request.
 * @returns {TextOutput} Base64-encoded PDF bytes.
 */
function doPost(e) {
  // 1. Guard against an empty or malformed body before doing any work.
  if (!e || !e.postData || !e.postData.contents) {
    return ContentService.createTextOutput('No POST body received.')
      .setMimeType(ContentService.MimeType.TEXT);
  }
  const data = JSON.parse(e.postData.contents);

  // 2. Copy the template so the original is never touched.
  const copy = DriveApp.getFileById(TEMPLATE_ID)
    .makeCopy(TEMP_PREFIX + Date.now());
  const doc = DocumentApp.openById(copy.getId());
  const body = doc.getBody();

  // 3. Swap every {{key}} placeholder for its posted value.
  for (const [key, value] of Object.entries(data)) {
    body.replaceText('{{' + key + '}}', String(value));
  }
  doc.saveAndClose();

  // 4. Render the filled copy as a PDF, then bin the working copy.
  const pdf = copy.getAs('application/pdf').getBytes();
  copy.setTrashed(true);

  // 5. Return the PDF as base64 so it travels safely over HTTP.
  return ContentService.createTextOutput(Utilities.base64Encode(pdf))
    .setMimeType(ContentService.MimeType.TEXT);
}

How it works

  1. doPost is the web app entry point — Apps Script calls it automatically whenever the deployed URL receives an HTTP POST. It first checks that a request body actually arrived and bails out with a plain message if not.
  2. It parses the body as JSON, giving an object of key: value pairs that map directly onto the template’s placeholders.
  3. It makes a copy of the template with makeCopy, so the master document is never edited. The copy is named with a timestamp and the TEMP_PREFIX.
  4. It opens the copy, then loops over every posted pair and calls replaceText to swap each {{key}} token for its value. replaceText replaces every occurrence, so a placeholder can appear more than once.
  5. After saveAndClose, it calls getAs('application/pdf') to render the doc, reads the raw bytes, and immediately trashes the working copy.
  6. The PDF bytes are base64-encoded and returned as the response body, ready for the caller to decode and save.

Example run

Post a JSON body to the deployed web app URL:

const response = UrlFetchApp.fetch(WEB_APP_URL, {
  method: 'post',
  contentType: 'application/json',
  payload: JSON.stringify({
    name: 'Acme Co.',
    date: '27 October 2025',
    total: '£2,400',
  }),
});

// The response body is base64 — decode it back into PDF bytes.
const pdfBlob = Utilities.newBlob(
  Utilities.base64Decode(response.getContentText()),
  'application/pdf',
  'booking.pdf'
);
DriveApp.createFile(pdfBlob);

If the template body reads Booking for {{name}} on {{date}} — total {{total}}, the returned PDF renders as Booking for Acme Co. on 27 October 2025 — total £2,400. Any placeholder with no matching key is simply left as-is.

Run it

This automation runs as a deployed web app, not on a schedule:

  1. In the Apps Script editor, choose Deploy → New deployment.
  2. Pick Web app as the type.
  3. Set Execute as to yourself, and Who has access to whoever needs to call it — Anyone for an open endpoint, or Anyone with Google account for a lightly protected one.
  4. Click Deploy, approve the authorisation prompt, and copy the web app URL. That URL is your PDF service — point any form or script at it.

Watch out for

  • The response is base64 text, not a binary file. Web apps cannot stream a true binary download, so the caller must decode the body itself. If you need a direct download link instead, write the PDF to Drive and return its URL.
  • A redeploy can change the URL. Use Manage deployments → Edit to push a new version to the same deployment, otherwise existing callers break.
  • Placeholder keys are literal. {{Name}} and {{name}} are different tokens — keep the casing in the template and the JSON identical.
  • Each request copies and renders a whole Doc, which is not instant. Under heavy load you will hit the daily UrlFetch and document quotas, and concurrent requests can leave orphaned tmp-pdf- copies if a run fails — sweep the scratch folder occasionally.
  • JSON.parse throws on a malformed body. For a public endpoint, wrap the parse in a try/catch and return a clear error so callers know what went wrong.

Related