tech/google/workspace

WORKSPACE

Google Workspace API skills. Use skills in this domain when:

production Google APIs client libraries (Node, Python), REST v1/v3/v4, Cloudflare Workers (Web Crypto)
requires: tech/google
improves: tech/googletech

Google Workspace APIs

Workspace APIs sit on the same OAuth 2.0 / service-account identity plane as GCP, but access user-owned data (email, files, calendar) rather than project-owned infrastructure. The consent model is consequently different: either the end user authorises your app, or a Workspace admin grants a service account domain-wide delegation to impersonate any user in their domain.

In the 2nth.ai stack, Workspace APIs back:

Workspace APIs in scope

APIPurposeCommon 2nth use
GmailRead, send, label, search emailEnquiry capture, auto-reply, notification routing, Penny briefings
DriveFiles, folders, sharingDocument ingestion, asset management, report delivery
SheetsSpreadsheet read/writeLive data feeds, reporting outputs, config tables
CalendarEvents, availabilityBooking automation, appointment scheduling
Admin SDKUsers, groups, org unitsProvisioning, access control, audit
ContactsPeople APICRM sync, contact enrichment

Sub-skills

PathFocusStatus
tech/google/workspace/gmailGmail API — read, send, label, Watch pipeline, AI automation✓ production
tech/google/workspace/driveDrive API v3 — files, folders, permissions, shared drivesstub
tech/google/workspace/sheetsSheets API v4 — read, append, batch update, formulasstub
tech/google/workspace/calendarCalendar API v3 — events, availability, push notificationsstub
tech/google/workspace/adminAdmin SDK Directory + Reports — users, groups, DWD managementstub

Auth decision: user OAuth vs DWD

Your app needs to...Use
Access one user's Gmail/Drive (they click "allow")User OAuth 2.0 (Authorization Code + PKCE)
Access all users in a Workspace domain (server job, no user clicks)Service account + Domain-Wide Delegation
Access project-owned GCP resources onlyService account, no DWD needed
Access external user data across multiple orgsUser OAuth — one consent per user

DWD is powerful and dangerous. It lets a service account impersonate any user in the domain. Grant narrow scopes, audit regularly, and store the JSON key in Secret Manager (or Cloudflare Worker secrets) — never in the repo.

User OAuth 2.0 (per-user consent, googleapis SDK)

# Required setup in GCP Console (APIs & Services → OAuth consent screen):
#   1. App type: External (for consumer Gmail accounts) OR Internal (Workspace-only)
#   2. Authorized redirect URIs: https://your-app.example.com/oauth/callback
#   3. Scopes: add only what you need (see below)
#   4. Test users (during dev) OR submit for verification (production external)
// Node.js — googleapis client (user OAuth flow)
import { google } from 'googleapis';

const oauth2 = new google.auth.OAuth2(
  process.env.GOOGLE_CLIENT_ID,
  process.env.GOOGLE_CLIENT_SECRET,
  'https://your-app.example.com/oauth/callback'
);

// Step 1: redirect user to consent URL
const authUrl = oauth2.generateAuthUrl({
  access_type: 'offline',           // required for refresh_token
  prompt: 'consent',                 // force refresh_token re-issue
  scope: [
    'https://www.googleapis.com/auth/gmail.readonly',
    'https://www.googleapis.com/auth/gmail.send',
  ],
});

// Step 2: exchange ?code for tokens
const { tokens } = await oauth2.getToken(codeFromQuery);
oauth2.setCredentials(tokens);
// Store tokens.refresh_token against the user
// tokens.access_token expires in ~1 hour; auto-refreshed via refresh_token

// Later: use for API calls
const gmail = google.gmail({ version: 'v1', auth: oauth2 });
const { data } = await gmail.users.messages.list({ userId: 'me', maxResults: 10 });

Service account + Domain-Wide Delegation

# Step 1: create SA + note its *numeric OAuth client ID* (not the email)
gcloud iam service-accounts create workspace-bot \
  --display-name "Workspace automation bot"

gcloud iam service-accounts describe \
  [email protected] \
  --format="value(oauth2ClientId)"
# → 123456789012345678901

# Step 2: Workspace admin grants DWD
#   admin.google.com → Security → API Controls → Domain-wide Delegation → Add new
#   Client ID: 123456789012345678901
#   OAuth Scopes: https://www.googleapis.com/auth/gmail.readonly, etc.

# Step 3: SA impersonates a domain user at runtime
// Node — impersonate [email protected] via DWD (googleapis SDK)
import { google } from 'googleapis';

const auth = new google.auth.JWT({
  keyFile: '/secrets/workspace-bot-key.json',
  scopes: ['https://www.googleapis.com/auth/gmail.readonly'],
  subject: '[email protected]',   // the user being impersonated
});

const gmail = google.gmail({ version: 'v1', auth });
await gmail.users.messages.list({ userId: 'me', maxResults: 10 });
// 'me' refers to the impersonated subject, not the service account

Scope minimisation

Workspace scopes are categorised by Google as Non-sensitive / Sensitive / Restricted. Restricted scopes (full Gmail, full Drive content) require annual security audit + verification for external apps.

Prefer granular over broad:

Broad (avoid)Narrow (prefer)
gmail.modifygmail.readonly + gmail.labels + gmail.send
drivedrive.file (only files your app creates/opens)
drive.readonlydrive.metadata.readonly (if you don't need content)
calendarcalendar.events or calendar.events.readonly

OAuth verification (for External apps)

If your app uses sensitive or restricted scopes AND is published externally (not Internal to one Workspace domain):

For 2nth.ai internal-domain apps, set OAuth consent screen to Internal and skip verification entirely.

Webhook / push notifications (Watch API)

All major Workspace APIs support push notifications — instead of polling, Google POSTs to your endpoint when something changes.

// Register a Gmail watch (expires every 7 days — must renew)
await fetch(`https://gmail.googleapis.com/gmail/v1/users/me/watch`, {
  method: 'POST',
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    topicName: 'projects/PROJECT_ID/topics/PUBSUB_TOPIC',
    labelIds: ['INBOX'],
    labelFilterAction: 'include',
  }),
});

Gmail watch requires a Pub/Sub topic — notifications are published there, then pushed to your Worker (or Cloud Run). See tech/google/cloud/data for Pub/Sub setup.

Renewal pattern: Cloud Scheduler calls a renewal endpoint every 6 days.

Admin SDK — common operations

const base = 'https://admin.googleapis.com/admin/directory/v1';

// List all users in domain
const users = await gFetch(`${base}/users?domain=yourdomain.co.za&maxResults=500`, token);

// Get a single user
const user = await gFetch(`${base}/users/[email protected]`, token);

// List groups
const groups = await gFetch(`${base}/groups?domain=yourdomain.co.za`, token);

// Suspend a user
await gFetch(`${base}/users/[email protected]`, token, 'PATCH', { suspended: true });

2nth.ai ↔ Trusted Workspace Tenant — Integration Pattern

This is the canonical Cloudflare-native pattern for connecting 2nth.ai infrastructure to a specific, trusted Google Workspace organisation — for exploration, internal tooling, and client platform builds. It's the alternative to running a Node googleapis SDK inside Cloud Run.

Architecture

Cloudflare Worker (any 2nth.ai service)
  → calls workspace-bridge Worker (service binding)
      → mints JWT using service account key (stored as CF secret)
      → exchanges JWT for Google access_token
      → calls Workspace API (Gmail, Drive, Sheets, Calendar, Admin)
      → returns structured result

One service account. One set of secrets. Any Worker in the account can reach any Workspace API in the trusted tenant by calling the bridge.

Step 1: GCP project setup

Create a dedicated GCP project for the 2nth.ai ↔ Workspace bridge. Keep it separate from any client GCP projects.

# Create project
gcloud projects create 2nth-workspace-bridge --name="2nth Workspace Bridge"
gcloud config set project 2nth-workspace-bridge

# Enable required APIs
gcloud services enable \
  gmail.googleapis.com \
  drive.googleapis.com \
  sheets.googleapis.com \
  calendar-json.googleapis.com \
  admin.googleapis.com \
  people.googleapis.com \
  iamcredentials.googleapis.com

# Create service account
gcloud iam service-accounts create workspace-bridge \
  --display-name="2nth Workspace Bridge" \
  --project=2nth-workspace-bridge

# Download key (store immediately — cannot re-download)
gcloud iam service-accounts keys create ./workspace-bridge-key.json \
  --iam-account=workspace-bridge@2nth-workspace-bridge.iam.gserviceaccount.com

Note the client_id from workspace-bridge-key.json — you need it for Step 2.

Step 2: Grant domain-wide delegation in the Workspace tenant

The Workspace super admin must complete this step. This is what creates the trust.

  1. Go to admin.google.com → Security → Access and data control → API controls
  2. Click Manage domain-wide delegationAdd new
  3. Enter:

- Client ID: the client_id from workspace-bridge-key.json - OAuth scopes (comma-separated — start with this set for exploration): `` https://www.googleapis.com/auth/gmail.readonly, https://www.googleapis.com/auth/gmail.send, https://www.googleapis.com/auth/drive.readonly, https://www.googleapis.com/auth/spreadsheets.readonly, https://www.googleapis.com/auth/calendar.readonly, https://www.googleapis.com/auth/admin.directory.user.readonly ``

  1. Click Authorise

The service account can now impersonate any user in that Workspace org for the listed scopes. No per-user consent required.

Step 3: Store secrets in Cloudflare

# Extract values from the key JSON and store as Worker secrets
# Run each interactively — paste value when prompted

npx wrangler secret put GOOGLE_SERVICE_ACCOUNT_EMAIL
# → [email protected]

npx wrangler secret put GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY
# → paste the full "private_key" value from the JSON (include -----BEGIN/END----- lines)

npx wrangler secret put GOOGLE_SERVICE_ACCOUNT_CLIENT_ID
# → the client_id value from the JSON

npx wrangler secret put GOOGLE_WORKSPACE_DOMAIN
# → the trusted tenant domain, e.g. 2nth.ai or b2bs.co.za

Never commit workspace-bridge-key.json to git. Delete it locally after storing the secrets.

Step 4: JWT minting in Cloudflare Workers (Web Crypto API)

Cloudflare Workers run in V8 isolates — no Node.js crypto or jsonwebtoken. Use the Web Crypto API instead. This helper is the core of the bridge.

// helpers/google-auth.ts

interface Env {
  GOOGLE_SERVICE_ACCOUNT_EMAIL: string;
  GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY: string;
  GOOGLE_SERVICE_ACCOUNT_CLIENT_ID: string;
}

/**
 * Get a Google access_token for a specific user and scope set.
 * Uses service account domain-wide delegation.
 *
 * @param userEmail  - The Workspace user to impersonate, e.g. [email protected]
 * @param scopes     - Array of OAuth scope URLs
 */
export async function getWorkspaceToken(
  env: Env,
  userEmail: string,
  scopes: string[]
): Promise<string> {
  const now = Math.floor(Date.now() / 1000);

  // Build JWT payload
  const header  = { alg: 'RS256', typ: 'JWT' };
  const payload = {
    iss:   env.GOOGLE_SERVICE_ACCOUNT_EMAIL,
    sub:   userEmail,
    scope: scopes.join(' '),
    aud:   'https://oauth2.googleapis.com/token',
    iat:   now,
    exp:   now + 3600,
  };

  const encode = (obj: object) =>
    btoa(JSON.stringify(obj)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');

  const signingInput = `${encode(header)}.${encode(payload)}`;

  // Import the PEM private key into Web Crypto
  const pemBody = env.GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY
    .replace(/-----BEGIN PRIVATE KEY-----/, '')
    .replace(/-----END PRIVATE KEY-----/, '')
    .replace(/\s/g, '');

  const keyData = Uint8Array.from(atob(pemBody), c => c.charCodeAt(0));

  const privateKey = await crypto.subtle.importKey(
    'pkcs8',
    keyData,
    { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
    false,
    ['sign']
  );

  // Sign
  const signature = await crypto.subtle.sign(
    'RSASSA-PKCS1-v1_5',
    privateKey,
    new TextEncoder().encode(signingInput)
  );

  const jwt = `${signingInput}.${btoa(String.fromCharCode(...new Uint8Array(signature)))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}`;

  // Exchange JWT for access_token
  const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
      assertion: jwt,
    }),
  });

  if (!tokenRes.ok) {
    const err = await tokenRes.text();
    throw new Error(`Google token exchange failed: ${err}`);
  }

  const { access_token } = await tokenRes.json() as { access_token: string };
  return access_token;
}

Step 5: workspace-bridge Worker

A thin internal Worker that other services call via service bindings — no public HTTP, no auth overhead.

// workspace-bridge/src/index.ts

import { getWorkspaceToken } from './helpers/google-auth';

const SCOPES = {
  gmail_read:  ['https://www.googleapis.com/auth/gmail.readonly'],
  gmail_send:  ['https://www.googleapis.com/auth/gmail.send'],
  drive_read:  ['https://www.googleapis.com/auth/drive.readonly'],
  sheets_read: ['https://www.googleapis.com/auth/spreadsheets.readonly'],
  calendar:    ['https://www.googleapis.com/auth/calendar.readonly'],
  admin:       ['https://www.googleapis.com/auth/admin.directory.user.readonly'],
};

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const { pathname } = new URL(request.url);
    const body = await request.json() as { user?: string; [key: string]: unknown };
    const user = body.user ?? `admin@${env.GOOGLE_WORKSPACE_DOMAIN}`;

    // POST /token — get a raw access token for a user + scope set
    if (pathname === '/token') {
      const { scope } = body as { scope: keyof typeof SCOPES; user: string };
      const token = await getWorkspaceToken(env, user, SCOPES[scope] ?? SCOPES.gmail_read);
      return Response.json({ token });
    }

    // POST /gmail/list — list recent inbox messages
    if (pathname === '/gmail/list') {
      const token = await getWorkspaceToken(env, user, SCOPES.gmail_read);
      const { q = 'is:unread in:inbox', maxResults = 10 } = body as { q?: string; maxResults?: number };
      const res = await fetch(
        `https://gmail.googleapis.com/gmail/v1/users/me/messages?q=${encodeURIComponent(q)}&maxResults=${maxResults}`,
        { headers: { Authorization: `Bearer ${token}` } }
      );
      return Response.json(await res.json());
    }

    // POST /gmail/send — send an email as the user
    if (pathname === '/gmail/send') {
      const { to, subject, html } = body as { to: string; subject: string; html: string };
      const token = await getWorkspaceToken(env, user, SCOPES.gmail_send);
      const raw = btoa(
        `To: ${to}\r\nFrom: ${user}\r\nSubject: ${subject}\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=utf-8\r\n\r\n${html}`
      ).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
      const res = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/messages/send', {
        method: 'POST',
        headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
        body: JSON.stringify({ raw }),
      });
      return Response.json(await res.json());
    }

    // POST /drive/list — list Drive files
    if (pathname === '/drive/list') {
      const token = await getWorkspaceToken(env, user, SCOPES.drive_read);
      const { q = "'root' in parents", pageSize = 20 } = body as { q?: string; pageSize?: number };
      const res = await fetch(
        `https://www.googleapis.com/drive/v3/files?q=${encodeURIComponent(q)}&pageSize=${pageSize}&fields=files(id,name,mimeType,modifiedTime,size)`,
        { headers: { Authorization: `Bearer ${token}` } }
      );
      return Response.json(await res.json());
    }

    // POST /sheets/read — read a range from a spreadsheet
    if (pathname === '/sheets/read') {
      const { spreadsheetId, range } = body as { spreadsheetId: string; range: string };
      const token = await getWorkspaceToken(env, user, SCOPES.sheets_read);
      const res = await fetch(
        `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${encodeURIComponent(range)}`,
        { headers: { Authorization: `Bearer ${token}` } }
      );
      return Response.json(await res.json());
    }

    // POST /calendar/events — list upcoming events
    if (pathname === '/calendar/events') {
      const token = await getWorkspaceToken(env, user, SCOPES.calendar);
      const now = new Date().toISOString();
      const res = await fetch(
        `https://www.googleapis.com/calendar/v3/calendars/primary/events?timeMin=${now}&maxResults=10&orderBy=startTime&singleEvents=true`,
        { headers: { Authorization: `Bearer ${token}` } }
      );
      return Response.json(await res.json());
    }

    return new Response('Not found', { status: 404 });
  }
};

wrangler.toml for the bridge:

name = "workspace-bridge"
main = "src/index.ts"
compatibility_date = "2026-01-01"

[vars]
GOOGLE_WORKSPACE_DOMAIN = "2nth.ai"   # or whichever tenant

Calling the bridge from another Worker via service binding:

# In the consuming Worker's wrangler.toml
[[services]]
binding = "WORKSPACE"
service  = "workspace-bridge"
// In the consuming Worker
const result = await env.WORKSPACE.fetch(new Request('http://internal/gmail/list', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ user: '[email protected]', q: 'is:unread', maxResults: 5 }),
})).then(r => r.json());

First experiments to run

Once the bridge is deployed and domain-wide delegation is active, validate the trust with these in order:

#EndpointWhat it proves
1POST /token { user, scope: 'gmail_read' }Service account JWT minting works, DWD is configured
2POST /gmail/list { user, q: 'is:unread' }Can read inbox — foundational for all email automation
3POST /gmail/send { user, to, subject, html }Can send as a Workspace user — replaces Resend for internal mail
4POST /drive/list { user }Can see Drive — opens document ingestion, RAG, asset sync
5POST /sheets/read { user, spreadsheetId, range }Can read Sheets — config tables, live data feeds, reporting
6POST /calendar/events { user }Can see calendar — scheduling, availability, meeting context

What this unlocks for 2nth.ai

CapabilityHow
Gmail as a CRM triggerWatch inbox → classify with Workers AI → route to D1 or notify
Send from Workspace domainReplace Resend for any mail that should come from @2nth.ai or @client.co.za
Google Sheets as a live config tableRead a client's Sheet for pricing, products, or settings — no code deploy needed
Drive as a document source for RAGIngest Drive docs into Vectorize for semantic search
Calendar availabilityBuild booking flows that check real availability before confirming
Cross-tenant client onboardingGrant DWD in a client's Workspace tenant, bridge connects instantly

Common gotchas

See Also