Turn an Apps Script project into an API (and Test It with Postman)

I’ve been doing freelancing for 5+ years now. Every month, I’d add a new row in my follow-up spreadsheet, duplicate my invoice, and export it as PDF to send by email. Yes, manually.

In French we say: “les cordonniers sont toujours les plus mal chaussés”. (or “the cobbler’s children go barefoot” in English).

Well I’ve decided this has to stop, and I’ve been working on a custom SaaS to handle my invoices in a proper way! Backend runs on Cloud Run, the UI is a simple web app. And for the invoice document generation itself… I’m using Apps Script!

In this article, I’ll focus on the Apps Script project itself, and its deployment so that it can be called from another app or from Postman to start with.

The connection with the SaaS itself will be for another article!

Why use Apps Script as an API?

You get a lot for free!

  • Google Docs is your template engine (layout, fonts, logo… all in the editor)
  • Drive stores your invoices in the right folder
  • Apps Script can run as you and use your Drive/Docs permissions
  • You get an HTTP endpoint that your backend (or Postman) can POST JSON to

Also, my entire blog is about Apps Script, so…

So the main idea in our example is:

  1. Have a Docs template with placeholders like {{ClientName}}, {{TotalPrice}}.
  2. Send a JSON payload to a Web App URL.
  3. Apps Script copies the template, replaces the tags, and returns the document URL.

No custom PDF rendering, no extra infra.

The Apps Script “Invoice API” code

We will first create a new Apps Script project. By default, Apps Script links new project to GCP, but for the next steps, we will need to manually link our GCP project.

I also setup a very simple invoice template, with the tags I wanted.

Here is the full code.

/**
 * Web app entry point for POST requests.
 * Expects JSON payload with:
 * {
 *   "tempId": "TEMPLATE_DOC_ID" | "templateId",
 *   "folderId": "DRIVE_FOLDER_ID",
 *   "filename": "Invoice 001",
 *   "tags": {
 *     "InvoiceNumber": "Lidia",
 *     "date": "123 €",
 *     "ClientName":"",
 *   }
 * }
 *
 * @param {GoogleAppsScript.Events.DoPost} e - The POST event.
 * @returns {GoogleAppsScript.Content.TextOutput} JSON response.
 */
function doPost(e) {
  let output;

  try {
    if (!e || !e.postData || !e.postData.contents) {
      throw new Error("Missing POST body.");
    }
    const payload = JSON.parse(e.postData.contents);

    const result = createInvoiceFromTemplate_(payload);

    const response = {
      success: true,
      invoiceId: result.id,
      invoiceUrl: result.url,
      filename: result.name,
    };

    output = ContentService
      .createTextOutput(JSON.stringify(response))
      .setMimeType(ContentService.MimeType.JSON);
  } catch (err) {
    const errorResponse = {
      success: false,
      error: String(err && err.message ? err.message : err),
    };

    output = ContentService
      .createTextOutput(JSON.stringify(errorResponse))
      .setMimeType(ContentService.MimeType.JSON);
  }

  return output;
}

/**
 * @typedef {Object} InvoiceRequestPayload
 * @property {string} [tempId] - Template document ID (legacy name).
 * @property {string} [templateId] - Template document ID (preferred name).
 * @property {string} [folderId] - Drive folder ID where the new doc should be stored.
 * @property {string} filename - Name for the new document.
 * @property {Object.<string, string|number|boolean|null>} tags - Map of placeholders to values.
 */

/**
 * @typedef {Object} InvoiceCreationResult
 * @property {string} id - Created document ID.
 * @property {string} url - Created document URL.
 * @property {string} name - Created document name.
 */

/**
 * Creates an invoice document from a template and replaces placeholders.
 *
 * @param {InvoiceRequestPayload} payload - Data describing the invoice to create.
 * @returns {InvoiceCreationResult} Information about the created document.
 */
function createInvoiceFromTemplate_(payload) {
  if (!payload) {
    throw new Error("Payload is required.");
  }

  const templateId = payload.templateId || payload.tempId;
  if (!templateId) {
    throw new Error("templateId (or tempId) is required.");
  }

  if (!payload.filename) {
    throw new Error("filename is required.");
  }


  let templateFile;
  try {
    templateFile = DriveApp.getFileById(templateId)
  } catch (err) {
    throw new Error("Template file not found or inaccessible.");
  }

  /** @type {GoogleAppsScript.Drive.Folder} */
  let targetFolder;

  if (payload.folderId) {
    try {
      targetFolder = DriveApp.getFolderById(payload.folderId);
    } catch (err) {
      throw new Error("Target folder not found or inaccessible.");
    }
  } else {
    // Fallback: use the template's parent folder if possible, or root
    const parents = templateFile.getParents();
    targetFolder = parents.hasNext() ? parents.next() : DriveApp.getRootFolder();
  }

  const copy = templateFile.makeCopy(payload.filename, targetFolder);
  const newDocId = copy.getId();
  const newDocUrl = copy.getUrl();

  const tags = payload.tags || {};
  if (Object.keys(tags).length > 0) {
    replaceTagsInDocument_(newDocId, tags);
  }

  return {
    id: newDocId,
    url: newDocUrl,
    name: copy.getName(),
  };
}

/**
 * Replaces placeholders in a Google Docs document body.
 * Placeholders are expected in the form {{key}}.
 *
 * @param {string} docId - ID of the document to update.
 * @param {Object.<string, string|number|boolean|null>} tags - Map of placeholders to values.
 * @returns {void}
 */
function replaceTagsInDocument_(docId, tags) {
  const doc = DocumentApp.openById(docId);
  const body = doc.getBody();

  const entries = Object.entries(tags);

  for (const [key, rawValue] of entries) {
    const value = rawValue === null || rawValue === undefined ? "" : String(rawValue);
    const placeholder = "{{" + key + "}}";

    body.replaceText(escapeForRegex_(placeholder), value);

  }

  doc.saveAndClose();
}

/**
 * Escapes a string to be used safely inside a regular expression.
 *
 * @param {string} text - Raw text to escape.
 * @returns {string} Escaped text safe to use in a regex.
 */
function escapeForRegex_(text) {
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

Web App vs Execution API vs Library, in plain terms

There are three “flavours” of Apps Script that often get mixed up:

  • Web App: you deploy doGet / doPost, you get a /exec URL.
    Perfect for what we’re doing: lightweight HTTP API.
  • Execution API: URL on script.googleapis.com like .../scripts/ID:run.
    Needs OAuth tokens, used to run script functions from other backends. Great, but heavier.
  • Library: reusable code for other Apps Script projects. Not an HTTP endpoint.

In this tutorial, the only one we need is the Web App deployment.
In the upcoming article, we will focus on how to setup OAuth tokens with the execution API.

Deploying as Web App

In the Apps Script editor:

  1. Deploy → Manage deployments → New deployment
  2. Type: Web app
  3. Execute as: Me
  4. Who has access: Anyone (or “Anyone with the link” while testing)
  5. Deploy and copy the Web app URL (ends in /exec)

Open that URL once in your browser, go through the “unverified app” screen, and accept the scopes.

From now on:

  • The script runs as you (so it can access your Docs and Drive)
  • Your backend and Postman can call the /exec URL without any extra OAuth dance

Testing the API with Postman

In Postman:

  • Method: POST
  • URL: your /exec Web App URL
  • Headers: Content-Type: application/json
  • Body → raw → JSON:
{
  "templateId": "1H1bFyR3VPI8U5CO_EDqCOOx5k_p6CFGw0HcfwyQ0vvw",
  "folderId": "1R0Izx-_pA0HYurhb25pZ2Hqd8RWOlnUG",
  "filename": "Invoice-POSTMAN-TEST-001",
  "tags": {
    "myName": "Lidia",
    "MyAddress": "Surf Camp",
    "ClientName": "Client",
    "ClientAddress": "ClientAddress",
    "Month": "November",
    "amountExcl": "120.00",
    "qty": "20",
    "Price": "100",
    "TotalTaxFree": "2000",
    "TaxRate": "20",
    "TaxTotal": "200",
    "TotalPrice": "2400"
  }
}

Click Send.
If everything is set up correctly, you should get JSON like:

And the tags have been replaced!

The weird Drive “server error” and apps-macros@system.gserviceaccount.com

When I first tried calling DriveApp.getFileById(templateId), I had the following error:

And in the console of Apps Script:

The template existed. It opened fine in Drive.
The script was running as the same user.
But inside the Apps Script project (linked to a Cloud project), Drive access was just… failing.

This error happens because Apps Script relies on a Google-managed system account behind the scenes, and that account doesn’t have the permissions it needs to run your script.
To fix it, you have to manually grant the right role to apps-macros@system.gserviceaccount.com.

You won’t see this address in the “Service Accounts” list, but it’s still a real identity in your project that you can add in IAM and assign roles to.

To fix it, I had to:

  1. Go to IAM & Admin → IAM in the Cloud Console.
  2. Click Grant access.
  3. Add a new principal:
    apps-macros@system.gserviceaccount.com
  4. Give it the role: Service Usage Admin.
  5. Save.

After that, API began returning normal JSON responses instead of that generic “server error”.

Wrapping up

Using Apps Script as an API is a nice combo when:

  • You already live in Google Workspace
  • You want invoices/letters/contracts as Docs, not PDFs generated from scratch
  • You’re happy to let Google Drive handle storage and sharing

The flow looks like this:

  1. Frontend or backend creates invoice data.
  2. Backend calls the Apps Script Web App with JSON.
  3. Apps Script copies the template, replaces tags, and responds with the document URL.
  4. You store the URL in your database and show it in your UI.

And if you ever see that mysterious Drive “server error” in a linked project, check the logs and don’t be afraid to give apps-macros@system.gserviceaccount.com the role it’s asking for.

In the next article, we’ll look at how to connect an app to this API and how to integrate Apps Script into more complex, real-world workflows.

From Google Docs Placeholders to Spreadsheet Automation

In othe previous article, we learned how to replace placeholders like [name] or [date] inside a Google Doc using Apps Script. We also made sure to keep the original file intact by creating a copy before applying replacements.

That’s great for a single document… but what if you need to create dozens of personalized files? Contracts, certificates, letters, all with different values?

The answer: connect your Google Doc template to a Google Sheet.
Each row of your sheet will represent a new document, and the script will:

  1. Create a copy of the template
  2. Replace all placeholders with values from the sheet
  3. Paste the link of the generated file back into the spreadsheet

Step 1 – Prepare your Spreadsheet

Create a sheet with the following structure (headers in the first row):

Notice the last column generatedLink, that’s where the script will paste the URL of each generated document.

Step 2 – Prepare your Google Doc Template

In your template, write placeholders between brackets:

Step 3 – The Script

This time the script will be bounded to our Google Sheets.

Open the Apps Script editor from your Spreadsheet (Extensions → Apps Script) and paste:

/**
 * Generate documents from a template Doc,
 * replacing placeholders with data from the sheet,
 * and storing links back in the sheet.
 */
function generateDocsFromSheet() {
  // IDs
  const TEMPLATE_ID = 'YOUR_TEMPLATE_DOC_ID_HERE';
  const FOLDER_ID = 'YOUR_TARGET_FOLDER_ID_HERE';

  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getActiveSheet();
  const data = sheet.getDataRange().getValues();
  const headers = data[0];

  // Loop through each row (skip headers)
  for (let i = 1; i < data.length; i++) {
    const row = data[i];

    // Skip if doc already generated
    if (row[headers.indexOf('generatedLink')]) continue;

    // Build replacements
    const replacements = {};
    headers.forEach((h, idx) => {
      if (h !== 'generatedLink') {
        replacements[`\\[.*${h}.*\\]`] = row[idx];
      }
    });

    // Make copy of template
    const templateFile = DriveApp.getFileById(TEMPLATE_ID);
    const copyFile = templateFile.makeCopy(
      `Generated_${row[0]}_${new Date().toISOString().slice(0,10)}`,
      DriveApp.getFolderById(FOLDER_ID)
    );

    // Open and replace placeholders
    const doc = DocumentApp.openById(copyFile.getId());
    const body = doc.getBody();
    for (let tag in replacements) {
      body.replaceText(tag, replacements[tag]);
    }
    doc.saveAndClose();

    // Save link back into sheet
    sheet.getRange(i+1, headers.indexOf('generatedLink')+1)
      .setValue(copyFile.getUrl());
  }
}

Step 4 – Run the Script

  1. Replace YOUR_TEMPLATE_DOC_ID_HERE with your template’s Doc ID
  2. Replace YOUR_TARGET_FOLDER_ID_HERE with the ID of the folder where generated docs should be stored
  3. Run the script → authorize it → watch your documents appear!

Each row in your sheet now produces a new Google Doc, and the link is written back automatically:

Wrap Up

With just a few lines of code, you’ve upgraded your document automation:

  • One Google Doc becomes a reusable template
  • One Google Sheet becomes a data source
  • Each row produces a personalized document with its link logged back

This setup is basically a DIY mail-merge for Docs

In the next article, we’ll explore how to send those generated documents automatically by email, turning your workflow into a fully automated system.

Replace Tags in a Google Docs with Apps Script

Ever needed to prepare a contract, a template, or a letter where you just replace placeholders like [Client Name] or [Date] with real values?
Instead of manually editing each tag, we can automate the process with Google Apps Script.

This article shows you how to:

  • Add placeholders in your document
  • Replace them with real values in one click
  • Keep your templates clean and reusable

Why use placeholders?

Placeholders are short markers inside your document that can later be replaced with actual content.
For example:

With Apps Script, you can easily replace [name] with “Lidia”, or any other dynamic value from a sheet, a form, or an API.

Step 1 – Prepare your Google Docs

Open a new Google Docs file, or write docs.new in your search bar, and write some sample content with tags between square brackets [ ].

Step 2 – Open the Script Editor

  1. In your Google Doc, go to Extensions → Apps Script

2. Delete the default code and paste the following function:

function replaceAllPlaceholders() {
  const body = DocumentApp.getActiveDocument()
    .getActiveTab()
    .asDocumentTab()
    .getBody();
    
  const replacements = {
    '\\[.*name.*\\]': 'Lidia',
    '\\[.*date.*\\]': '16 Sept 2025',
    '\\[.*company.*\\]': 'Apps Script Lab'
  };
  
  for (let tag in replacements) {
    body.replaceText(tag, replacements[tag]);
  }
}

You can adapt the placeholders to match whatever data you need to inject.
Each occurrence of the placeholder will be swapped automatically using the replaceText() method.

Keep in mind: the function expects a regular expression as the search patter

Let’s make our template reusable

In this first example, we simply replaced the placeholders directly in the document.
But if you need to generate multiple documents while keeping your original file intact, it’s better to create a copy of the template and run the replacements in the copy.

/**
 * Creates a copy of the active Google Doc,
 * replaces placeholders in the copy,
 * and returns the URL of the generated document.
 */
function generateDocFromTemplate() {
  // Define placeholder values
  const replacements = {
    '\\[.*name.*\\]': 'Lidia',
    '\\[.*date.*\\]': '16 Sept 2025',
    '\\[.*company.*\\]': 'Apps Script Lab'
  };

  // Get the template (active document)
  const templateDoc = DocumentApp.getActiveDocument();
  const templateFile = DriveApp.getFileById(templateDoc.getId());

  // Create a copy next to the original
  const copyFile = templateFile.makeCopy(
    templateFile.getName() + ' - Generated ' + new Date().toISOString().slice(0, 10),
    templateFile.getParents().next() // same folder
  );

  // Open the copy for editing
  const copyDoc = DocumentApp.openById(copyFile.getId());
  const body = copyDoc.getBody();

  // Replace all placeholders
  for (let tag in replacements) {
    body.replaceText(tag, replacements[tag]);
  }

  // Save & close
  copyDoc.saveAndClose();

  // Return the URL (for logs or UI)
  Logger.log('Generated doc: ' + copyFile.getUrl());
  return copyFile.getUrl();
}

The script will create a new document based on your template.
Your original file remains untouched, and the placeholders are replaced only in the generated copy.

Much cleaner than editing manually, and your template is always safe for the next run.

Next Steps

In this tutorial, we learned how to:

  • Use placeholders in a Google Doc
  • Replace them automatically with Apps Script
  • Keep our original template safe by generating copies

The natural next step is to connect this process with Google Sheets.
That way, you can store your tags and values in a spreadsheet (e.g. [name], [date], [company]), run the script for each row, and automatically log the links to the generated documents back into the sheet.

This turns your simple template into a full document generation system