Custom Workspace Integrations: Cloud Functions & Apps Script
Learn how to build custom Google Workspace integrations using Cloud Functions and Apps Script. Step-by-step tutorial for developers, with real code examples.
Most Google Workspace automation falls into one of two camps: you either stay entirely within Apps Script -- tight integration with Workspace services, zero infrastructure overhead -- or you move everything to Google Cloud Functions for proper backend control, scalable execution, and access to the full GCP API surface. The smarter approach, and the one that powers the most robust Workspace integrations we build for clients, is to use both together.
Apps Script excels at everything that touches Workspace directly: reading Sheets, sending Gmail, creating Calendar events, responding to form submissions. Cloud Functions excels at everything that requires server-side logic at scale: calling external APIs, processing webhooks from third-party systems, running compute-heavy tasks, and handling anything that needs to run outside Apps Script's six-minute execution limit.
When you wire the two together, you get a lightweight Workspace-native frontend (your Apps Script layer) backed by a scalable, event-driven cloud backend (your Cloud Functions layer). For Australian developer teams building serious Workspace integrations -- whether for internal tooling, client platforms, or ISV products -- this combination is the architecture worth understanding.
What this guide covers:
- When to use Apps Script alone versus Cloud Functions alongside it
- Setting up a Google Cloud project and deploying your first Cloud Function
- Calling a Cloud Function from Apps Script using
UrlFetchApp - Securing the connection with IAM and Google-signed ID tokens
- A practical worked example: a Sheets-triggered data enrichment pipeline
- Handling errors, timeouts, and retries across the stack
When to Use Each Tool (and When to Use Both)
Before writing a single line of code, it is worth being deliberate about where each piece of your integration logic should live.
Use Apps Script alone when:
- The workflow is entirely contained within Workspace (e.g., trigger on a new Sheet row, send a templated Gmail, log the result)
- Execution time stays well under six minutes per run
- You do not need to call APIs that require server-to-server authentication or credentials you cannot safely store in Script Properties
- The integration is built for one Workspace organisation and a few dozen users at most
Use Cloud Functions alongside Apps Script when:
- You need to call external APIs with sensitive credentials (API keys, OAuth client secrets) that must never be stored in Apps Script
- Individual executions might exceed six minutes (e.g., processing large datasets, calling slow third-party services)
- You need a public HTTPS endpoint to receive webhooks from external systems (Stripe, Shopify, your own CRM)
- The same backend logic needs to be called from multiple Workspace add-ons, different scripts, or a web application
- You want Cloud Logging, Cloud Monitoring, and proper alerting around your automation
The hybrid pattern: Apps Script handles Workspace events and data access. Cloud Functions handle external API calls, heavy processing, and anything that needs to run beyond Apps Script's constraints. Apps Script calls Cloud Functions via UrlFetchApp and processes the result. This pattern gives you the convenience of Workspace-native triggers alongside the power and security of a proper cloud backend.
Setting Up Your Google Cloud Project
If you do not already have a GCP project linked to your Workspace organisation, start here. The setup takes about ten minutes.
Step 1: Create a GCP project
- Go to console.cloud.google.com and sign in with your Google Workspace account.
- Click the project selector in the top navigation bar and select New Project.
- Give it a descriptive name (e.g.,
workspace-integrations-prod) and select your organisation's billing account. - Click Create and wait for the project to initialise.
Step 2: Enable required APIs
In the Cloud Console, navigate to APIs & Services > Library and enable the following:
- Cloud Functions API (for deploying functions)
- Cloud Build API (required by Cloud Functions deployment)
- Cloud Run API (Cloud Functions 2nd gen runs on Cloud Run)
You can also do this via the gcloud CLI:
gcloud services enable \
cloudfunctions.googleapis.com \
cloudbuild.googleapis.com \
run.googleapis.com \
--project YOUR_PROJECT_ID
Step 3: Install and initialise the gcloud CLI
If you have not already, install the Google Cloud CLI and authenticate:
gcloud auth login
gcloud config set project YOUR_PROJECT_ID
Deploying Your First Cloud Function
We will deploy a simple HTTP-triggered Cloud Function that accepts a JSON payload, does some processing, and returns a structured response. This will be the backend endpoint that our Apps Script integration calls.
Create a new directory for your function:
mkdir workspace-enrichment-fn && cd workspace-enrichment-fn
Create index.js:
const functions = require('@google-cloud/functions-framework');
functions.http('enrichContact', async (req, res) => {
// Only accept POST requests
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const { email, company } = req.body;
if (!email || !company) {
return res.status(400).json({ error: 'Missing required fields: email, company' });
}
try {
// In a real integration, you would call an external enrichment API here
// using credentials stored in Secret Manager -- never hardcoded
const enriched = {
email,
company,
domain: email.split('@')[1],
enrichedAt: new Date().toISOString(),
// Simulated enrichment result
industry: 'Technology',
employeeCount: '50-200',
country: 'AU',
};
return res.status(200).json(enriched);
} catch (err) {
console.error('Enrichment error:', err);
return res.status(500).json({ error: 'Internal server error' });
}
});
Create package.json:
{
"name": "workspace-enrichment-fn",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"@google-cloud/functions-framework": "^3.0.0"
}
}
Deploy the function:
gcloud functions deploy enrichContact \
--gen2 \
--runtime=nodejs20 \
--region=australia-southeast1 \
--source=. \
--entry-point=enrichContact \
--trigger-http \
--no-allow-unauthenticated
The --no-allow-unauthenticated flag is critical. It means only callers with a valid Google-signed identity token can invoke the function. Unauthenticated requests receive a 403. This is what keeps your Cloud Function private even though it has a public HTTPS URL.
Once deployment completes, note the function URL in the output. It will look like:
https://australia-southeast1-YOUR_PROJECT_ID.cloudfunctions.net/enrichContact
Calling Cloud Functions from Apps Script
Now that your function is deployed and secured, you need Apps Script to call it with a valid identity token. Apps Script running in a Workspace environment can generate these tokens automatically using the ScriptApp.getIdentityToken() method -- but there is a nuance.
By default, ScriptApp.getIdentityToken() returns a token for the Apps Script OAuth client, not a token that Cloud Functions IAM will accept. The cleanest approach for authenticating Apps Script to Cloud Functions is to use a service account and call the token exchange endpoint via UrlFetchApp. Here is how to set that up.
Step 1: Create a Service Account
In Cloud Console, go to IAM & Admin > Service Accounts and create a new service account:
- Name:
apps-script-invoker - Grant the role: Cloud Run Invoker (which covers 2nd gen Cloud Functions)
Download a JSON key for this service account. You will store the key contents securely in Apps Script's Script Properties -- not hardcoded in the script.
Step 2: Store the Service Account Key in Script Properties
In your Apps Script editor, go to Project Settings > Script Properties and add a property called SERVICE_ACCOUNT_KEY with the full JSON key content as the value.
Step 3: Write the Apps Script Caller
// Utility: fetch a short-lived access token for a given target audience (function URL)
function getCloudFunctionToken(targetAudience) {
const keyJson = JSON.parse(PropertiesService.getScriptProperties().getProperty('SERVICE_ACCOUNT_KEY'));
// Build a signed JWT to exchange for an ID token
const now = Math.floor(Date.now() / 1000);
const header = { alg: 'RS256', typ: 'JWT' };
const payload = {
iss: keyJson.client_email,
sub: keyJson.client_email,
aud: 'https://oauth2.googleapis.com/token',
iat: now,
exp: now + 3600,
target_audience: targetAudience,
};
const toSign = [
Utilities.base64EncodeWebSafe(JSON.stringify(header)),
Utilities.base64EncodeWebSafe(JSON.stringify(payload)),
].join('.');
const signature = Utilities.base64EncodeWebSafe(
Utilities.computeRsaSha256Signature(toSign, keyJson.private_key)
);
const jwt = `${toSign}.${signature}`;
// Exchange the signed JWT for a Google ID token
const tokenResponse = UrlFetchApp.fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
contentType: 'application/x-www-form-urlencoded',
payload: {
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: jwt,
},
muteHttpExceptions: true,
});
const tokenData = JSON.parse(tokenResponse.getContentText());
if (!tokenData.id_token) {
throw new Error('Failed to obtain ID token: ' + JSON.stringify(tokenData));
}
return tokenData.id_token;
}
// Call the Cloud Function with the ID token in the Authorization header
function callEnrichContact(email, company) {
const FUNCTION_URL = 'https://australia-southeast1-YOUR_PROJECT_ID.cloudfunctions.net/enrichContact';
const idToken = getCloudFunctionToken(FUNCTION_URL);
const response = UrlFetchApp.fetch(FUNCTION_URL, {
method: 'POST',
contentType: 'application/json',
headers: {
Authorization: 'Bearer ' + idToken,
},
payload: JSON.stringify({ email, company }),
muteHttpExceptions: true,
});
const statusCode = response.getResponseCode();
if (statusCode !== 200) {
throw new Error(`Cloud Function returned ${statusCode}: ${response.getContentText()}`);
}
return JSON.parse(response.getContentText());
}
The Worked Example: A Sheets-Triggered Enrichment Pipeline
Now let us put this together into a real workflow. The scenario: a sales team maintains a Google Sheet of leads. When a new row is added with an email address and company name, a script automatically calls the Cloud Function enrichment backend and writes the results back into the sheet.
This is the pattern we use for a range of client integrations -- swapping in real enrichment APIs (Clearbit, Apollo, Hunter.io) and credentials stored in Secret Manager instead of the simulated data in our example.
The Apps Script Trigger and Main Function
// Trigger: installable OnEdit trigger to detect new rows
function onEditTrigger(e) {
const sheet = e.source.getActiveSheet();
const range = e.range;
// Only process changes in column A (Email) on the "Leads" sheet
if (sheet.getName() !== 'Leads') return;
if (range.getColumn() !== 1) return;
if (range.getValue() === '') return;
const row = range.getRow();
if (row === 1) return; // Skip the header row
const email = sheet.getRange(row, 1).getValue();
const company = sheet.getRange(row, 2).getValue();
if (!email || !company) {
sheet.getRange(row, 5).setValue('Missing email or company');
return;
}
// Mark as processing
sheet.getRange(row, 5).setValue('Enriching...');
try {
const result = callEnrichContact(email, company);
// Write enrichment results back to the sheet
sheet.getRange(row, 3).setValue(result.industry || '');
sheet.getRange(row, 4).setValue(result.employeeCount || '');
sheet.getRange(row, 5).setValue('Done');
sheet.getRange(row, 6).setValue(result.enrichedAt || '');
SpreadsheetApp.flush();
} catch (err) {
console.error('Enrichment failed for row ' + row + ':', err.message);
sheet.getRange(row, 5).setValue('Error: ' + err.message);
}
}
Set up your sheet with these columns:
| A | B | C | D | E | F |
|---|---|---|---|---|---|
| Company | Industry | Employees | Status | Enriched At |
To install the trigger:
- In the Apps Script editor, go to Triggers (clock icon in the left sidebar).
- Click Add Trigger.
- Select
onEditTrigger, event source From spreadsheet, event type On edit. - Click Save and authorise the required permissions.
From this point, every new lead row you add will automatically call your Cloud Function backend and populate the enrichment columns within a few seconds.
Receiving Webhooks: Using Cloud Functions as an Ingress Layer
The pattern above shows Apps Script calling Cloud Functions. The complementary pattern is Cloud Functions receiving events from external systems and then calling back into Workspace via the Workspace APIs.
Consider this scenario: your ticketing system (Jira, Zendesk, or a bespoke system) needs to create a Google Calendar event whenever a high-priority ticket is assigned. The ticketing system sends a webhook. Your Cloud Function receives it, validates the payload signature, calls the Google Calendar API using a service account with domain-wide delegation, and creates the event. Apps Script is not involved at all -- this is a fully backend integration.
Here is the Cloud Function webhook receiver skeleton:
const functions = require('@google-cloud/functions-framework');
const { google } = require('googleapis');
functions.http('ticketWebhook', async (req, res) => {
if (req.method !== 'POST') return res.status(405).send('Method not allowed');
// Validate the webhook signature from your ticketing system
const signature = req.headers['x-webhook-signature'];
if (!isValidSignature(signature, req.rawBody)) {
return res.status(401).send('Invalid signature');
}
const { ticket_id, assignee_email, due_date, summary } = req.body;
if (!assignee_email || !due_date) {
return res.status(400).send('Missing required fields');
}
try {
// Use Application Default Credentials (ADC) in Cloud Functions --
// no key files needed when running on GCP infrastructure
const auth = new google.auth.GoogleAuth({
scopes: ['https://www.googleapis.com/auth/calendar'],
});
const calendar = google.calendar({ version: 'v3', auth });
await calendar.events.insert({
calendarId: assignee_email,
requestBody: {
summary: `[Ticket ${ticket_id}] ${summary}`,
start: { dateTime: new Date(due_date).toISOString() },
end: { dateTime: new Date(new Date(due_date).getTime() + 60 * 60 * 1000).toISOString() },
},
});
return res.status(200).send('Event created');
} catch (err) {
console.error('Calendar API error:', err);
return res.status(500).send('Failed to create event');
}
});
function isValidSignature(signature, body) {
// Implement HMAC-SHA256 validation against your webhook secret
// stored in Secret Manager
return true; // Placeholder
}
Notice the use of Application Default Credentials rather than a manually managed key. When your Cloud Function runs on GCP infrastructure, it automatically inherits the identity of the service account attached to it. This is the recommended approach -- no JSON key files, no rotation headaches.
Error Handling and Reliability
A production integration needs to handle failures gracefully. Here are the patterns worth implementing from the start.
In Apps Script: Always use muteHttpExceptions: true in UrlFetchApp.fetch() calls so that HTTP error responses (4xx, 5xx) do not throw uncaught exceptions. Check response.getResponseCode() explicitly and handle each failure case.
Retry logic in Apps Script:
function fetchWithRetry(url, options, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = UrlFetchApp.fetch(url, { ...options, muteHttpExceptions: true });
const status = response.getResponseCode();
if (status === 200) return response;
if (status >= 400 && status < 500) {
// Client error -- do not retry
throw new Error(`Client error ${status}: ${response.getContentText()}`);
}
// Server error -- retry after backoff
if (attempt < maxRetries) {
Utilities.sleep(attempt * 1500); // 1.5s, 3s, 4.5s
}
} catch (err) {
if (attempt === maxRetries) throw err;
Utilities.sleep(attempt * 1500);
}
}
throw new Error('Max retries exceeded');
}
In Cloud Functions: Use Cloud Logging (console.error(), console.log()) for all significant events. Configure a Log-based Alert in Cloud Monitoring to notify your team via email or PagerDuty when error rates exceed a threshold. For Cloud Functions 2nd gen (Cloud Run-backed), set a minimum instance count of zero and a maximum that matches your expected concurrency -- this prevents cold start delays from compounding during bursts.
Idempotency: Design your Cloud Functions to be safe to call multiple times with the same payload. If Apps Script retries after a timeout and the function already completed the action on the first call, a second call should not create a duplicate record or charge a payment twice. Use unique request identifiers stored in Firestore or Cloud Spanner to track completed operations.
Recommended Tools and Licensing
Before wrapping up, here are the platforms worth considering for building and hosting these integrations. Full disclosure: the links below may be affiliate or referral links, which means we may receive a small commission at no extra cost to you. We only recommend tools we use directly with clients.
Google Workspace
The runtime environment for all Apps Script integrations. Business Starter is sufficient for basic scripting. For integrations that require Gemini, Workspace Studio, or advanced security features, Business Standard or above is needed. Plans start from approximately AUD $10.80 per user per month.
Start or upgrade your Google Workspace plan
Google Cloud
Cloud Functions 2nd gen runs on Cloud Run pricing: you pay per request, per CPU-second, and per GB-second of memory. For most Workspace integration workloads -- hundreds of thousands of invocations per month -- the costs are minimal. The Cloud Functions free tier includes 2 million invocations per month at no charge. Enable billing and set a budget alert so there are no surprises.
Conclusion
The combination of Apps Script and Cloud Functions is, in practice, one of the most productive architectures available for teams building serious Google Workspace integrations. Apps Script gives you tight, low-friction access to every Workspace service. Cloud Functions give you a proper backend: scalable, secure, observable, and free from the execution constraints that limit what Apps Script can do alone.
The patterns in this guide -- authenticated UrlFetchApp calls from Apps Script to private Cloud Functions, webhook ingress via Cloud Functions into Workspace APIs, service account authentication using Application Default Credentials, retry logic with exponential backoff -- are the building blocks of every robust Workspace integration we have shipped for Australian clients.
Start with the enrichment pipeline example. Get the Auth flow working. Swap in your real backend logic. Once you have one integration running end to end, the second one takes a fraction of the time.
If your team is building Workspace integrations at scale and needs guidance on architecture, IAM design, or production hardening, get in touch with the CloudGeeks team. We work with development teams across Australia to design and review Workspace integration stacks built to last.
Ash Ganda is the founder of CloudGeeks, a Google Workspace consultancy helping Australian SMBs and development teams get more from their cloud tools. He writes about practical technology strategies at insights.cloudgeeks.com.au.