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.

Deploy your first backend to Cloud Run (with a Node / React-friendly example)

If you’ve been living in the comfy world of Apps Script and suddenly need “a real backend”, Google Cloud Run is a perfect next step.

You still don’t manage servers.
But now you can run any containerized app: Node, React SSR, Python, Go, whatever.

In this tutorial, we’ll:

  • Explain what Cloud Run is in simple terms.
  • Build a tiny Node.js HTTP API (which could just as well serve a React app).
  • Deploy it to Cloud Run from source with a single gcloud run deploy command (no Docker installed locally).
  • Get a public HTTPS URL you can call from your frontend (React, Apps Script, or anything).

I will also include every relevant links in order to have evrything in one place 🙂

What is Cloud Run (in human words)?

Cloud Run is a serverless platform for containers:

  • You package your code as a container image (or just push your source code and let Google build that image).
  • Cloud Run runs that container on Google’s infrastructure, scaling up and down automatically depending on traffic.
  • It can even scale to zero when nobody calls your service, so you don’t pay when it’s idle. Cloud Run documentation
  • It works with any language or framework that can run in a container and listen for HTTP requests on the port specified by the PORT environment variable. Build sources to containers

If you’re used to Apps Script web apps:

Think of Cloud Run as “Web Apps, but for any container you want, not just Apps Script.”

What we’ll build

We’ll deploy a very small Node.js backend:

  • GET / → returns a JSON message.
  • It listens on the PORT environment variable (so Cloud Run can wire it correctly).
  • We’ll deploy it using “Deploy from source”, which builds the container for you via buildpacks. Deploy services from source code

The exact same steps apply if your “backend” is a React SSR app, Next.js app, or any Node-based server.

Prerequisites

You’ll need:

  1. A Google account.
  2. A Google Cloud project with billing enabled. I personally always setup a max budget to avoid any surprise 🙂
  3. Cloud Run and Cloud Build APIs enabled (the console usually prompts you, or you can enable them manually). Quickstarts
  4. Node.js (v18+ recommended) and npm installed.
  5. Google Cloud SDK (gcloud) installed and authenticated on your machine. Deploy services from source code

Step 1 – Create the backend project

Create a new folder and init package.json:

mkdir cloud-run-backend
cd cloud-run-backend
npm init -y
npm install express

Create index.js:

const express = require("express");

/**
 * Creates an Express application with a simple JSON endpoint
 *
 * @returns {import("express").Express} The configured Express app.
 */
function createApp() {
  const app = express();

  app.get("/", (req, res) => {
    res.json({
      message: "Hello from Cloud Run",
      project: "First backend",
      timestamp: new Date().toISOString(),
    });
  });

  return app;
}

/**
 * Starts the HTTP server for the given Express app.
 *
 * @param {import("express").Express} app - The Express application instance.
 * @returns {void}
 */
function startServer(app) {
  const port = process.env.PORT || 8080; // Cloud Run injects PORT
  app.listen(port, () => {
    // eslint-disable-next-line no-console
    console.log(`Server listening on port ${port}`);
  });
}

const app = createApp();
startServer(app);

The important detail:
Cloud Run will set an environment variable PORT. Your app must listen on that port.

Step 2 – Add the start script

Open package.json and add a "start" script:

{
   "name": "cloud-run-backend",
   "version": "1.0.0",
   "main": "index.js",
   "type": "commonjs",
   "scripts": {
      "start": "node index.js"
    },
   "dependencies": {
      "express": "^4.21.0"
   }
}

Cloud Run’s Node buildpack knows how to run npm start by default.

Step 3 – Test locally

npm install
npm start

You should see:

Server listening on port 8080

In another terminal:

curl http://localhost:8080

You’ll get a JSON response with your message and a timestamp.

If it works locally, you’re ready for the cloud.

Step 4 – Configure gcloud

Log in:

gcloud auth login

Set your project and region (example: australia-southeast1):

gcloud config set project YOUR_PROJECT_ID<br>gcloud config set run/region australia-southeast1

Pick the region closest to your users.

You can find your project ID in the Cloud Overview dashboard of your project:

Step 5 – Deploy to Cloud Run from source

From inside cloud-run-backend:

gcloud run deploy cloud-run-backend \
--source . \
--allow-unauthenticated

This single command:

  1. Sends your source code to Cloud Build
  2. Builds a container image (using buildpacks or Docker under the hood)
  3. Creates a Cloud Run service called cloud-run-backend
  4. Gives it a public HTTPS URL

At the end you’ll see something like:

Service [cloud-run-backend] revision [cloud-run-backend-00001-abc]
has been deployed and is serving 100 percent of traffic at:
https://cloud-run-backend-xxxxx-uc.a.run.app

Copy that URL, that’s your backend, live on the internet 🎉

Step 6 – Call your new backend

From anywhere:

curl https://cloud-run-backend-xxxxx-uc.a.run.app

or open it in the browser.

Cloud Run gives you:

  • HTTPS by default
  • Auto-scaling
  • A generous free tier (millions of requests/month) to experiment safely

TL;DR

  • If you’ve been living in Apps Script land and need a “real backend”, Cloud Run lets you run any containerized app (Node, Python, Go, React SSR…) without managing servers.
  • You build a tiny Node.js HTTP API that listens on the PORT environment variable.
  • You deploy it with a single command: gcloud run deploy cloud-run-backend \ --source . \ --allow-unauthenticated
  • Google Cloud builds the container for you, creates a Cloud Run service, and gives you a public HTTPS URL.
  • That URL can be called from React, Apps Script, or any frontend, and Cloud Run handles scaling, HTTPS, and a generous free tier in the background.

Conclusion

Cloud Run is a really nice bridge between the simplicity of Apps Script and the flexibility of “real” backends. You keep the good parts (no servers to manage, automatic scaling, pay-per-use) and you unlock the ability to run any language or framework you like.

In this tutorial, you:

  • Created a small Node.js API
  • Tested it locally
  • Deployed it to Cloud Run from source
  • Got a live HTTPS URL you can plug into any frontend

From here, you can start wiring Cloud Run into your own projects: connect it to a React app, protect your endpoints with authentication, or add a database like Firestore or Cloud SQL.

If you try this out or deploy your own mini SaaS on Cloud Run, let me know, I’d love to see what you build !