Automate Gmail customer support using Gmail Push notifications via Cloud Pub/Sub: call users.watch to subscribe, receive push payloads with only {emailAddress, historyId}, then fetch actual changes via users.history.list. Watch subscriptions expire every 7 days maximum and must be renewed. Service Account + Domain-Wide Delegation is required for server-to-server Workspace automation. Per-user quota limit: 15,000 units/minute.
API Quick Reference
OAuth 2.0 / Service Account + DWD
15,000 quota units/minute per user; 1 Pub/Sub event/second per inbox
JSON
Available
Understanding the Gmail API
The Gmail API provides RESTful access to Gmail mailboxes, but for real-time support automation the key component is Gmail Push — a Cloud Pub/Sub-based notification system. Unlike a traditional webhook where the payload contains the event data, Gmail Push payloads contain only the user's email address and a historyId. You must then call users.history.list with the received historyId to get the actual changes.
For automated support triage, the architecture is: Gmail Push fires → your Cloud Run/App Engine endpoint receives the Pub/Sub message → decode the base64url payload to get historyId → call users.history.list to get new messages → fetch each message with users.messages.get → apply triage logic → send automated reply or apply labels.
For Google Workspace deployments serving multiple support inboxes, Service Account + Domain-Wide Delegation (DWD) is the correct approach — it lets your server impersonate any user in the domain without per-user OAuth consent flows. Official docs: https://developers.google.com/workspace/gmail/api/guides/push
https://gmail.googleapis.com/gmail/v1Setting Up Gmail API Authentication
For automated support systems running server-side, Service Account + Domain-Wide Delegation is strongly preferred over per-user OAuth. With DWD, your server application can impersonate any user in your Workspace domain without them needing to authorize each time. You must explicitly call setSubject() to specify which user to impersonate.
- 1Go to https://console.cloud.google.com → create or select your project
- 2Enable Gmail API: APIs & Services → Library → Gmail API → Enable
- 3Enable Cloud Pub/Sub API: APIs & Services → Library → Cloud Pub/Sub API → Enable
- 4Create a Service Account: APIs & Services → Credentials → Create Credentials → Service Account. Download the JSON key file
- 5In Google Workspace Admin Console: Security → API Controls → Manage Domain Wide Delegation → Add new → paste your Service Account client_id → add scopes: https://www.googleapis.com/auth/gmail.modify https://www.googleapis.com/auth/gmail.send https://www.googleapis.com/auth/gmail.readonly
- 6Create a Pub/Sub topic: gcloud pubsub topics create gmail-support-notifications
- 7Grant Gmail the publisher role: gcloud pubsub topics add-iam-policy-binding gmail-support-notifications --member='serviceAccount:gmail-api-push@system.gserviceaccount.com' --role='roles/pubsub.publisher'
- 8Create a Pub/Sub push subscription pointing to your webhook endpoint: gcloud pubsub subscriptions create gmail-support-sub --topic=gmail-support-notifications --push-endpoint=https://your-server.com/gmail-webhook
1from google.oauth2 import service_account2from googleapiclient.discovery import build34SERVICE_ACCOUNT_FILE = 'service-account-key.json'5SCOPES = [6 'https://www.googleapis.com/auth/gmail.modify',7 'https://www.googleapis.com/auth/gmail.send',8 'https://www.googleapis.com/auth/gmail.readonly'9]1011def get_gmail_service(user_email):12 """Get Gmail service impersonating a specific user via DWD."""13 credentials = service_account.Credentials.from_service_account_file(14 SERVICE_ACCOUNT_FILE,15 scopes=SCOPES16 ).with_subject(user_email) # CRITICAL: impersonate the target user17 return build('gmail', 'v1', credentials=credentials)Security notes
- •Store the Service Account JSON key file outside your repository and use environment variable GOOGLE_APPLICATION_CREDENTIALS to reference it
- •Domain-Wide Delegation grants powerful access — restrict the service account's scopes to only what's needed
- •Validate that Pub/Sub messages come from Google by checking the Authorization header on your webhook endpoint
- •Rotate Service Account keys every 90 days via Google Cloud Console
- •For single-user setups, never store refresh tokens in code — use a secrets manager like Google Secret Manager
Key endpoints
/gmail/v1/users/{userId}/watchRegisters a Gmail mailbox for push notifications via Cloud Pub/Sub. Returns a historyId (starting point) and expiration timestamp. Must be renewed before expiration — Google recommends daily renewal. Maximum watch duration is 7 days.
| Parameter | Type | Required | Description |
|---|---|---|---|
topicName | string | required | Full Pub/Sub topic name: projects/{project-id}/topics/{topic-name} |
labelIds | array | optional | Label IDs to filter notifications — only notify for messages with these labels |
labelFilterBehavior | string | optional | INCLUDE to watch only labeled messages, EXCLUDE to watch messages without those labels |
Request
1{"topicName":"projects/my-project/topics/gmail-support-notifications","labelIds":["INBOX"],"labelFilterBehavior":"INCLUDE"}Response
1{"historyId":"2054799","expiration":"1749600000000"}/gmail/v1/users/{userId}/historyLists changes to the mailbox since a given historyId. Each notification from Pub/Sub contains a historyId — use this endpoint to fetch the actual message additions, label changes, and deletions that occurred.
| Parameter | Type | Required | Description |
|---|---|---|---|
startHistoryId | string | required | The historyId from the Pub/Sub notification payload |
historyTypes | array | optional | Types to return: messageAdded, messageDeleted, labelAdded, labelRemoved |
labelId | string | optional | Filter history to messages with this label |
maxResults | number | optional | Maximum records to return, default 100 |
Response
1{"history":[{"id":"2054800","messagesAdded":[{"message":{"id":"18c3a2b1d4e5f6a7","threadId":"18c3a2b1d4e5f6a7","labelIds":["INBOX","UNREAD"]}}]}],"historyId":"2054802"}/gmail/v1/users/{userId}/messages/{id}Fetches a full message including body and headers. Required after receiving a notification — Pub/Sub only provides the message ID, not the content.
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | required | The message ID from history.list |
format | string | optional | 'full' for complete message including body, 'metadata' for headers only |
Response
1{"id":"18c3a2b1d4e5f6a7","threadId":"18c3a2b1d4e5f6a7","payload":{"headers":[{"name":"From","value":"customer@company.com"},{"name":"Subject","value":"Urgent: login not working"}],"parts":[{"mimeType":"text/plain","body":{"data":"SGVsbG8sIEkgY2FuJ3QgbG9n..."}}]}}/gmail/v1/users/{userId}/messages/sendSends an automated reply. For threaded support replies, include the threadId and proper In-Reply-To/References headers. Costs 100 quota units.
| Parameter | Type | Required | Description |
|---|---|---|---|
raw | string | required | RFC 2822 message encoded as base64url |
threadId | string | optional | Thread ID for threaded replies |
Request
1{"raw":"RnJvbTogc3VwcG9ydEBjb21wYW55LmNvbQ==","threadId":"18c3a2b1d4e5f6a7"}Response
1{"id":"18d1f2e3c4b5a697","threadId":"18c3a2b1d4e5f6a7","labelIds":["SENT"]}/gmail/v1/users/{userId}/messages/{id}/modifyApplies or removes labels from a specific message — use this to mark tickets as handled, add 'Needs Human Review' labels, or move from INBOX to a support folder.
| Parameter | Type | Required | Description |
|---|---|---|---|
addLabelIds | array | optional | Label IDs to add to the message |
removeLabelIds | array | optional | Label IDs to remove from the message |
Request
1{"addLabelIds":["Label_Support_Replied"],"removeLabelIds":["INBOX","UNREAD"]}Response
1{"id":"18c3a2b1d4e5f6a7","threadId":"18c3a2b1d4e5f6a7","labelIds":["Label_Support_Replied"]}Step-by-step automation
Set Up Cloud Pub/Sub and Register Gmail Watch
Why: Gmail Push requires a Pub/Sub topic as its delivery mechanism — there is no direct HTTP webhook option.
Create a Pub/Sub topic, grant Gmail's service account publisher access to it, then call users.watch to register the mailbox. The watch response includes a historyId (your starting point for history queries) and an expiration timestamp (max 7 days out). Store the historyId — you'll need it when the first notification arrives.
1# Register a Gmail watch (requires valid access token)2curl -X POST \3 -H "Authorization: Bearer $ACCESS_TOKEN" \4 -H "Content-Type: application/json" \5 -d '{6 "topicName": "projects/my-project/topics/gmail-support-notifications",7 "labelIds": ["INBOX"],8 "labelFilterBehavior": "INCLUDE"9 }' \10 'https://gmail.googleapis.com/gmail/v1/users/me/watch'Pro tip: Set up a daily cron job to renew the watch before it expires (max 7 days). If your watch expires before renewal, you'll miss notifications — store expiration in your database and renew at 6.5 days.
Expected result: Returns {historyId: '2054799', expiration: '1749600000000'}. The historyId is your starting point for the first history.list call when a notification arrives.
Handle Pub/Sub Push Notifications and Decode Payload
Why: Pub/Sub delivers notifications to your HTTP endpoint — the payload is base64url-encoded and contains only the email address and historyId, not the actual message.
Your webhook endpoint receives POST requests from Pub/Sub with a JSON body. The message data field is base64url-encoded JSON containing {emailAddress, historyId}. Decode it, extract the historyId, and use it as startHistoryId in the next step's history.list call. Always return 200 quickly — Pub/Sub retries on non-2xx responses, creating duplicate processing.
1# Pub/Sub sends this shape to your endpoint:2# POST /gmail-webhook3# Content-Type: application/json4# {5# "message": {6# "data": "eyJlbWFpbEFkZHJlc3MiOiJzdXBwb3J0QGNvbXBhbnkuY29tIiwiaGlzdG9yeUlkIjoiMjA1NDgwMCJ9",7# "messageId": "136969346945",8# "publishTime": "2025-04-05T14:15:22Z"9# },10# "subscription": "projects/my-project/subscriptions/gmail-support-sub"11# }1213# Decode the data field:14echo 'eyJlbWFpbEFkZHJlc3MiOiJzdXBwb3J0QGNvbXBhbnkuY29tIiwiaGlzdG9yeUlkIjoiMjA1NDgwMCJ9' | base64 -dPro tip: Pub/Sub will retry delivery for up to 7 days if it doesn't receive a 2xx response. Always return 200/204 immediately and process asynchronously. Use a task queue (Cloud Tasks, Celery, etc.) rather than blocking the webhook handler.
Expected result: Your endpoint returns 204 immediately, preventing Pub/Sub retries. The emailAddress and historyId are extracted for async processing.
Fetch Actual Email Changes via history.list
Why: The Pub/Sub notification tells you something changed — history.list tells you what actually changed, including new message IDs.
Call users.history.list with startHistoryId from the notification to get the list of changes since that point. This returns messagesAdded, labelsAdded, and labelsRemoved records. For support triage, you care about messagesAdded with INBOX label. Extract the message IDs, then fetch each full message with users.messages.get. Track the latest historyId returned to use as startHistoryId on the next call.
1curl -H "Authorization: Bearer $ACCESS_TOKEN" \2 'https://gmail.googleapis.com/gmail/v1/users/me/history?startHistoryId=2054800&historyTypes=messageAdded&labelId=INBOX'Pro tip: You may receive the same historyId in multiple Pub/Sub notifications — always check if a message has already been processed using a database de-duplication key on the message ID before sending an auto-reply.
Expected result: Returns an array of new message IDs (only INBOX + UNREAD messages) and the latest historyId. Store the latest historyId for the next call.
Triage Email Content and Send Automated Reply
Why: This step differentiates ticket categories and sends appropriate responses — the actual intelligence layer of your support automation.
Fetch each message with messages.get, parse the subject and body, then apply triage logic. Keyword matching is the simplest approach: check for 'urgent', 'not working', 'broken' to escalate, check for 'password reset', 'how to', 'billing' to send canned responses. After replying, apply a label to prevent re-processing. Build your canned responses as RFC 2822 messages with proper threading headers.
1# Fetch full message content2curl -H "Authorization: Bearer $ACCESS_TOKEN" \3 'https://gmail.googleapis.com/gmail/v1/users/support@company.com/messages/18c3a2b1d4e5f6a7?format=full'45# Apply 'Auto-Replied' label6curl -X POST \7 -H "Authorization: Bearer $ACCESS_TOKEN" \8 -H "Content-Type: application/json" \9 -d '{"addLabelIds":["Label_AutoReplied"],"removeLabelIds":["UNREAD"]}' \10 'https://gmail.googleapis.com/gmail/v1/users/support@company.com/messages/18c3a2b1d4e5f6a7/modify'Pro tip: Always apply a custom label after processing (like 'Auto-Replied') and check for it before processing — this prevents duplicate replies if the same Pub/Sub notification is delivered twice (Pub/Sub guarantees at-least-once delivery, not exactly-once).
Expected result: Escalated emails get an 'Escalated' label for human review. Routine queries receive an immediate automated response and are labeled 'Auto-Replied' and removed from INBOX.
Complete working code
This complete script sets up Gmail Push notifications, processes incoming support emails via history.list, triages them by keyword matching, sends canned auto-replies for common issues, and escalates complex queries with a label for human review. It includes watch renewal scheduling and deduplication.
1#!/usr/bin/env python32"""Gmail Customer Support Automation — Push-based triage and auto-reply."""3import os4import base645import json6import logging7from email.mime.text import MIMEText8from flask import Flask, request, jsonify9from google.oauth2 import service_account10from googleapiclient.discovery import build1112logging.basicConfig(level=logging.INFO)13log = logging.getLogger(__name__)14app = Flask(__name__)1516SCOPES = ['https://www.googleapis.com/auth/gmail.modify', 'https://www.googleapis.com/auth/gmail.send']17SERVICE_ACCOUNT_FILE = os.environ.get('GOOGLE_APPLICATION_CREDENTIALS', 'service-account-key.json')18SUPPORT_EMAIL = os.environ.get('SUPPORT_EMAIL', 'support@yourcompany.com')19GCP_PROJECT = os.environ.get('GCP_PROJECT_ID', 'my-project')20PUBSUB_TOPIC = 'gmail-support-notifications'2122# State (use a database in production)23processed_message_ids = set()24last_history_id = None25auto_replied_label_id = None26escalated_label_id = None2728def get_service(user_email):29 creds = service_account.Credentials.from_service_account_file(30 SERVICE_ACCOUNT_FILE, scopes=SCOPES31 ).with_subject(user_email)32 return build('gmail', 'v1', credentials=creds)3334def setup_labels(service):35 global auto_replied_label_id, escalated_label_id36 labels = service.users().labels().list(userId=SUPPORT_EMAIL).execute().get('labels', [])37 names = {l['name']: l['id'] for l in labels}38 if 'Support/Auto-Replied' not in names:39 r = service.users().labels().create(userId=SUPPORT_EMAIL,40 body={'name': 'Support/Auto-Replied'}).execute()41 auto_replied_label_id = r['id']42 else:43 auto_replied_label_id = names['Support/Auto-Replied']44 if 'Support/Escalated' not in names:45 r = service.users().labels().create(userId=SUPPORT_EMAIL,46 body={'name': 'Support/Escalated'}).execute()47 escalated_label_id = r['id']48 else:49 escalated_label_id = names['Support/Escalated']5051def get_email_body(message):52 payload = message.get('payload', {})53 if payload.get('mimeType') == 'text/plain' and payload.get('body', {}).get('data'):54 return base64.urlsafe_b64decode(payload['body']['data'] + '==').decode('utf-8', errors='ignore')55 for part in payload.get('parts', []):56 if part['mimeType'] == 'text/plain' and part.get('body', {}).get('data'):57 return base64.urlsafe_b64decode(part['body']['data'] + '==').decode('utf-8', errors='ignore')58 return ''5960def process_message(service, message_id):61 global processed_message_ids62 if message_id in processed_message_ids:63 log.info(f'Skipping already-processed message: {message_id}')64 return65 msg = service.users().messages().get(userId=SUPPORT_EMAIL, id=message_id, format='full').execute()66 labels = msg.get('labelIds', [])67 if auto_replied_label_id in labels or escalated_label_id in labels:68 return69 headers = {h['name']: h['value'] for h in msg['payload']['headers']}70 subject = headers.get('Subject', '')71 sender = headers.get('From', '')72 msg_id_header = headers.get('Message-ID', '')73 body = get_email_body(msg)74 combined = (subject + ' ' + body).lower()75 escalate = any(kw in combined for kw in ['urgent', 'broken', 'not working', 'outage'])76 if escalate:77 service.users().messages().modify(userId=SUPPORT_EMAIL, id=message_id,78 body={'addLabelIds': [escalated_label_id], 'removeLabelIds': ['UNREAD']}).execute()79 log.info(f'Escalated: {subject}')80 else:81 if 'password' in combined:82 reply_body = 'To reset your password: https://app.example.com/reset'83 elif 'billing' in combined:84 reply_body = 'For billing questions: https://app.example.com/billing'85 else:86 reply_body = 'Thank you for contacting support. We will respond within 24 hours.'87 reply = MIMEText(reply_body, 'plain', 'utf-8')88 reply['From'] = SUPPORT_EMAIL89 reply['To'] = sender90 reply['Subject'] = f'Re: {subject}'91 reply['In-Reply-To'] = msg_id_header92 reply['References'] = msg_id_header93 raw = base64.urlsafe_b64encode(reply.as_bytes()).decode()94 service.users().messages().send(userId=SUPPORT_EMAIL,95 body={'raw': raw, 'threadId': msg['threadId']}).execute()96 service.users().messages().modify(userId=SUPPORT_EMAIL, id=message_id,97 body={'addLabelIds': [auto_replied_label_id], 'removeLabelIds': ['UNREAD', 'INBOX']}).execute()98 log.info(f'Auto-replied: {subject}')99 processed_message_ids.add(message_id)100101@app.route('/gmail-webhook', methods=['POST'])102def webhook():103 global last_history_id104 res = jsonify({})105 res.status_code = 204106 envelope = request.get_json(silent=True)107 if not envelope or 'message' not in envelope:108 return res109 try:110 data = base64.urlsafe_b64decode(envelope['message']['data'] + '==').decode()111 notification = json.loads(data)112 history_id = notification.get('historyId')113 service = get_service(SUPPORT_EMAIL)114 start_id = last_history_id or history_id115 result = service.users().history().list(116 userId=SUPPORT_EMAIL, startHistoryId=start_id,117 historyTypes=['messageAdded'], labelId='INBOX'118 ).execute()119 for record in result.get('history', []):120 for added in record.get('messagesAdded', []):121 if 'UNREAD' in added['message'].get('labelIds', []):122 process_message(service, added['message']['id'])123 last_history_id = result.get('historyId', history_id)124 except Exception as e:125 log.error(f'Webhook error: {e}')126 return res127128@app.route('/setup', methods=['POST'])129def setup():130 global last_history_id131 service = get_service(SUPPORT_EMAIL)132 setup_labels(service)133 result = service.users().watch(userId=SUPPORT_EMAIL, body={134 'topicName': f'projects/{GCP_PROJECT}/topics/{PUBSUB_TOPIC}',135 'labelIds': ['INBOX'], 'labelFilterBehavior': 'INCLUDE'136 }).execute()137 last_history_id = result['historyId']138 return jsonify({'status': 'ok', 'historyId': last_history_id})139140if __name__ == '__main__':141 app.run(port=8080)Error handling
Pub/Sub Publisher role not granted to gmail-api-push@system.gserviceaccount.comThe Gmail infrastructure service account doesn't have permission to publish to your Pub/Sub topic. users.watch call succeeds but no notifications are delivered.
Run: gcloud pubsub topics add-iam-policy-binding YOUR_TOPIC --member='serviceAccount:gmail-api-push@system.gserviceaccount.com' --role='roles/pubsub.publisher'
After fixing permissions, call users.watch again to restart the subscription.
History ID is too oldThe historyId from the notification is older than Gmail's history retention window. This happens when your watch expires and you miss notifications, causing a gap.
Do a full sync: list all UNREAD INBOX messages with messages.list, process them, then call users.watch again to get a fresh historyId.
No retry on the history call — perform full resync instead.
Request had insufficient authentication scopesService Account DWD was not set up with the required scopes, or the setSubject() call to impersonate the target user was omitted.
In Workspace Admin Console, verify DWD is enabled for your Service Account with the exact scope strings. Ensure your code calls .with_subject(user_email) when building credentials.
Fix credentials configuration, no retry.
Too Many Requests / User Rate Limit ExceededAt high support volume, the combination of history.list (10 units) + multiple messages.get (5 units each) + messages.send (100 units) per notification can quickly exhaust the 15,000 units/minute per-user limit.
Implement exponential backoff. For very high volumes, request a quota increase from Google Cloud Console. Consider batching history queries.
Exponential backoff: 2^n seconds (max 64s) with jitter.
Watch expiration has passedThe Gmail watch subscription expired after 7 days and was not renewed. No more push notifications will arrive.
Call users.watch again to create a new subscription. Set up a cron job to renew before the 7-day expiration.
Immediate — just call users.watch again to restart.
Rate Limits for Gmail API
| Scope | Limit | Window |
|---|---|---|
| Per user | 15,000 quota units | per minute |
| Per project | 1,200,000 quota units | per minute |
| Gmail Push event rate | 1 event | per second per mailbox |
| Watch expiration | 7 days maximum | must renew before expiry |
1import time2import random3from googleapiclient.errors import HttpError45def call_with_backoff(api_call, max_retries=6):6 """Execute Gmail API call with exponential backoff."""7 for attempt in range(max_retries):8 try:9 return api_call()10 except HttpError as e:11 if e.resp.status in [429, 403, 500, 503]:12 wait = min((2 ** attempt) + random.uniform(0, 1), 64)13 print(f'Rate limited ({e.resp.status}), retrying in {wait:.1f}s')14 time.sleep(wait)15 else:16 raise17 raise Exception('Max retries exceeded')- Process Pub/Sub notifications asynchronously — return 200 immediately and use a task queue for actual processing
- Deduplicate by message ID in your database — Pub/Sub guarantees at-least-once delivery, not exactly-once
- Renew your Gmail watch every 6.5 days via a scheduled job — don't wait for it to expire
- At high volume, cache the auto_replied label ID rather than calling labels.list on every request
- Use historyTypes=['messageAdded'] to filter history events — reduces API calls vs fetching all history types
Security checklist
- Store Service Account JSON key in Google Secret Manager or as an environment variable — never commit to version control
- Validate Pub/Sub push requests: check Authorization header contains a valid Google-signed token for your subscription
- Apply the principle of least privilege to DWD scopes — only grant gmail.modify and gmail.send, not full gmail scope
- Implement message deduplication using the message ID to prevent double-replies from Pub/Sub retries
- Audit all auto-replies in a separate log — an auto-reply bug can send hundreds of incorrect responses before you notice
- Never include sensitive data (order numbers, account IDs) in automated replies without proper authentication
- Set up alerting on your Pub/Sub subscription's undelivered message count — spikes indicate webhook failures
- Regularly verify your watch is still active by checking its expiration — silent expiration means missed customer emails
Automation use cases
Tiered Support Triage
advancedAuto-categorize incoming tickets by urgency (P1/P2/P3) and assign to appropriate team queues using labels, with SLA timers tracked via message timestamps.
FAQ Auto-Responder
intermediateMaintain a lookup table of common questions and their answers, sending immediate responses to FAQ-matching emails while human agents handle complex queries.
Multi-Inbox Workspace Support
advancedUse Service Account + DWD to monitor multiple support@ inboxes across a Workspace organization from a single application instance.
Out-of-Hours Auto-Reply
beginnerCheck the current time and send different automated responses during business hours vs out-of-hours, with appropriate escalation paths.
No-code alternatives
Don't want to write code? These platforms can automate the same workflows visually.
Zapier
Free tier (100 tasks/month); Professional from $49/month for PathsZapier's Gmail integration supports triggers on new emails with filters, allowing routing to different response flows without coding.
- + No Pub/Sub setup required
- + Visual flow builder
- + Easy canned response templates
- - No real-time push — polls every 1-15 minutes
- - Complex triage logic requires Paths (paid)
- - Limited label manipulation
Make (Integromat)
Free (1,000 ops/month); Core from $9/monthMake offers Gmail watch triggers with routing modules for building support triage workflows visually.
- + Near real-time triggers
- + Router modules for triage logic
- + Better value than Zapier for complex flows
- - Still polling-based, not true push
- - Pub/Sub not natively supported
- - 1,000 ops/month free tier
n8n
Free self-hosted; Cloud from €20/monthSelf-hosted n8n supports Gmail triggers with webhook nodes, allowing Pub/Sub-based real-time processing in a visual workflow editor.
- + True webhook support for real-time processing
- + Free self-hosted
- + Code nodes for complex triage logic
- - Requires server setup and Pub/Sub configuration
- - More technical than Zapier/Make
- - No managed cloud option below €20/month
Best practices
- Always return 200/204 from your webhook endpoint immediately — process asynchronously to prevent Pub/Sub retries that cause duplicate actions
- Implement an idempotency check using the message ID before processing — store processed IDs in a database
- Renew your Gmail watch via a daily cron job — don't rely on expiration error handling alone
- Apply a processed label immediately when you start handling a ticket, before sending the reply — prevents race conditions
- Test your history.list fallback for the case where historyId is too old — you need a full-inbox rescan path
- Monitor your Pub/Sub dead letter queue for failed deliveries — support emails that error on processing could get lost
- Use format='metadata' for initial message fetching when you only need headers — reduces data transfer and processing time
Ask AI to help
Copy one of these prompts to get a personalized, working implementation.
I'm building a Gmail support automation using Gmail Push notifications and Cloud Pub/Sub. My webhook receives notifications correctly (I can see the {emailAddress, historyId} payload) but when I call history.list with the received historyId, I get empty results or a 404. My code is: [paste code]. What's the correct flow for historyId bookkeeping between notifications?
Build a Gmail support dashboard. Features: 1) Real-time feed of incoming support emails with auto-triage categories (FAQ/Escalated/Auto-Replied), 2) Edit canned response templates for different categories, 3) One-click 'take over' to move an auto-replied ticket to human handling, 4) Metrics: tickets per hour, auto-reply rate, escalation rate. Use Supabase Realtime for live updates and a Supabase Edge Function to handle Gmail Push webhook delivery.
Frequently asked questions
Why does my Gmail webhook receive a notification but the Pub/Sub message only contains historyId, not the email itself?
This is by design. Gmail Push payloads contain only {emailAddress, historyId} — never the message content. You must call users.history.list with the received historyId as startHistoryId to get the list of changes (message IDs), then call users.messages.get for each message ID to fetch the actual content. The two-step process is intentional for security reasons.
How often does the Gmail watch subscription expire?
Maximum 7 days. The expiration timestamp (in epoch milliseconds) is returned in the watch response. If it expires, you stop receiving notifications — there's no error, just silence. Google recommends renewing daily via a cron job. Call users.watch again to get a new subscription and updated historyId.
Can I use OAuth instead of a Service Account for this automation?
Yes for single-user deployments. The same API endpoints work with user OAuth tokens. Use Service Account + Domain-Wide Delegation when you need to automate multiple inboxes in a Google Workspace domain without per-user consent flows, or when running a headless server that can't present a browser for OAuth consent.
What happens when I hit the rate limit while processing support emails?
You'll get a 403 userRateLimitExceeded or 429 error. The per-user limit is 15,000 quota units/minute. At high support volume: history.list costs 10 units, each messages.get costs 5 units, each messages.send costs 100 units. For 100 emails/minute: 100×10 + 100×5 + 100×100 = 11,500 units — near the limit. Implement exponential backoff and consider queuing messages for processing at a controlled rate.
Is the Gmail API free for support automation?
The Gmail API is free. Cloud Pub/Sub has a free tier (10 GB/month, then $0.04/GB) which is sufficient for most support volumes. Google Cloud Run or App Engine hosting for your webhook endpoint has separate costs. No per-email API charges.
How do I prevent my automation from replying to the same email twice?
Apply a custom label (e.g., 'Support/Auto-Replied') to the message immediately when you start processing it. Before processing any message, check if it already has this label. Since Pub/Sub guarantees at-least-once delivery, you may receive the same notification multiple times — label-based deduplication is more reliable than in-memory sets for production systems.
Can RapidDev help build a custom Gmail support automation?
Yes. RapidDev has built 600+ apps including email automation and support systems. We handle the full stack: Gmail Push setup, Pub/Sub infrastructure, triage logic, canned responses, escalation workflows, and a management dashboard. Contact us for a free consultation at rapidevelopers.com.
Need this automated?
Our team has built 600+ apps with API automations. We can build this for you.
Book a free consultation