Gmail API Push Notifications: Complete Guide to Pub/Sub, Watch & History (2026)

Gmail API Guide

Gmail API Push Notifications: Complete Guide to Pub/Sub, Watch & History (2026)

Set up Gmail API push notifications end-to-end: create a Pub/Sub topic, register a watch endpoint, decode webhook payloads, reconcile changes with users.history.list, automate watch renewal, and skip the entire GCP setup with a unified webhook alternative.

gmail-watch.js
// 1. Register Gmail watch via Unipile const res = await fetch('https://api8.unipile.com:13815/api/v1' + '/accounts/{id}/watch', { method: 'POST', headers: { 'X-API-KEY': 'YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ webhook_url: 'https://app.you.com/webhooks/gmail' }) }); // 2. Receive unified webhook payload app.post('/webhooks/gmail', (req, res) => { const { event, account_id, email } = req.body; // event: "new_email" | historyId abstracted handleNewEmail(email); });
Real-time Gmail events - no GCP setup required
Core Concept

What are Gmail API push notifications?

Before implementing Gmail API push notifications in production, it helps to understand exactly what they are, how they differ from simple polling, and what infrastructure they require.

Definition

Gmail API push notifications are a real-time delivery mechanism that uses Google Cloud Pub/Sub to push mailbox change events to a developer-controlled HTTPS endpoint. When a new message arrives or an existing message is modified, Gmail publishes a notification containing an encoded historyId to a Pub/Sub topic you own, which then forwards that event to your webhook. Your server calls users.history.list to retrieve the actual changes.

Event-driven, not polling

Gmail API push notifications eliminate the need to repeatedly call messages.list on a schedule. Events are delivered within seconds of the mailbox change, reducing both latency and API quota usage.

Powered by Google Pub/Sub

The delivery channel is Google Cloud Pub/Sub, not a direct HTTP callback from Gmail. This adds durability: if your endpoint is temporarily unavailable, Pub/Sub can retry delivery according to its subscription ack deadline.

7-day watch expiry

A Gmail API watch endpoint expires after 7 days. Your application must renew it proactively with a daily cron job or risk missing events silently. This is a critical operational detail covered in the Renewal section.

Push (Pub/Sub)

Gmail API push notifications deliver events within 1-10 seconds of the change. No constant polling means lower quota consumption on the Gmail API and faster time-to-react for your application. Ideal for any inbox-level real-time use case: CRM sync, ticketing systems, workflow automation.

Pull (polling)

Polling messages.list every 60 seconds is simpler to set up but introduces artificial lag, wastes quota on empty responses, and scales poorly across large numbers of authenticated user accounts. Acceptable for low-volume prototypes only.

Architecture

Architecture: watch + Pub/Sub + historyId in one flow

Gmail API push notifications involve four distinct layers working in sequence. Understanding each layer before writing code prevents the most common implementation mistakes.

End-to-end flow
1
Your app callsusers.watch

You POST to https://gmail.googleapis.com/gmail/v1/users/me/watch with your Pub/Sub topic name and optionally a label filter. Gmail returns a historyId and an expiration Unix timestamp. Store both. This watch expires in 7 days.

2
Gmail publishes toPub/Sub topic

When any change occurs in the watched mailbox (new message, label change, read/unread toggle), Gmail publishes a JSON notification to your Cloud Pub/Sub topic. The payload is a base64-encoded object containing the user's email address and a new historyId.

3
Pub/Sub pushes to yourwebhook

Your Pub/Sub subscription forwards the message to a registered HTTPS push endpoint. This is your webhook URL, which must respond with HTTP 200-299 within the ack deadline (default 10-600 seconds). A non-2xx response triggers automatic retries.

4
Your webhook extractshistoryId

Decode the base64 Pub/Sub message data. Extract the new historyId. Compare it against the lastHistoryId stored in your database for this user.

5
Callusers.history.listto reconcile

Call users.history.list with startHistoryId set to your stored value. Gmail returns all changes (new messages, label additions, deletions) between the two IDs. Update your stored lastHistoryId to the new value. Never use the historyId from the Pub/Sub notification as startHistoryId directly.

6
Renew watch before expiry

Schedule a daily cron job to call users.watch again for each authenticated user account. Watch renewal is idempotent: a new call replaces the previous expiry. The returned historyId becomes your new baseline.

historyId

A monotonically increasing integer assigned by Gmail to every mailbox change. It is your cursor for incremental sync. Always store the latest historyId per user in your database.

users.watch

The Gmail API endpoint that registers a push notification subscription for a mailbox. Returns a historyId baseline and a Unix ms expiration timestamp. Must be renewed within 7 days.

users.history.list

The reconciliation endpoint. Given a startHistoryId, it returns all message additions, deletions, and label changes that occurred after that point. This is where you get the actual message data.

Setup

Prerequisites: GCP project, Pub/Sub topic, IAM grant

Gmail API push notifications require three GCP-side resources before your first users.watch call. Most implementation failures trace back to a missing IAM grant on the Pub/Sub topic, the step developers most frequently skip.

1
GCP project with Gmail API enabled

In the Google Cloud Console, create or select an existing project. Navigate to APIs and Services > Library and enable the Gmail API. You also need the Cloud Pub/Sub API enabled in the same project. Ensure your OAuth 2.0 client credentials include the https://www.googleapis.com/auth/gmail.readonly scope (or a broader scope if you need write access). For multi-user applications, see our guide on Gmail OAuth 2.0 integration and the Google OAuth app verification requirements.

2
Create a Cloud Pub/Sub topic

In the GCP Console under Pub/Sub > Topics, click Create Topic. Give it a name like gmail-notifications. The full topic name will be projects/YOUR_PROJECT_ID/topics/gmail-notifications. You will pass this exact string to users.watch in the topicName field.

3
Grant Publisher role to gmail-api-push@system.gserviceaccount.com

This is the step most developers miss. Gmail uses a Google-managed service account (gmail-api-push@system.gserviceaccount.com) to publish notifications to your Pub/Sub topic. Without granting this account the Pub/Sub Publisher role on your topic, users.watch will succeed but no notifications will ever be delivered. In the Console: Topics > select your topic > Permissions > Add principal > enter gmail-api-push@system.gserviceaccount.com > assign role Pub/Sub Publisher.

4
Create a Push subscription pointing to your webhook

Under your Pub/Sub topic, create a Push subscription. Set the push endpoint to your HTTPS webhook URL (must use a valid TLS certificate, self-signed certs are rejected). Optionally configure a token validation header so your endpoint can verify requests come from Google. Note the subscription name, you may need it to monitor delivery metrics in Cloud Monitoring.

100-user cap on unverified apps: If your OAuth consent screen is in "Testing" status, only 100 Gmail accounts can authorize your app. This cap applies to all OAuth scopes, including the watch endpoint. For production deployments with more than 100 users, you must complete Google's verification process. See our full guide on the 100-user limit and verification path.

Skip GCP setup entirely

No Pub/Sub topic. No IAM grant. No 7-day watch renewal cron. Build Gmail push notifications with one webhook URL.

Build Now
Step by Step

Step-by-step: create topic, subscription, and users.watch

With GCP prerequisites in place, here is the complete code to register a Gmail API watch endpoint in both Node.js and Python, using the Google API client library.

Node.js
Python
watch.js
const { google } = require('googleapis'); // Assumes OAuth2 client is already authorized with a valid access token // See: https://www.unipile.com/gmail-oauth-20-integration-complete-guide/ async function registerGmailWatch(auth, userId = 'me') { const gmail = google.gmail({ version: 'v1', auth }); const response = await gmail.users.watch({ userId, requestBody: { // Your full Pub/Sub topic name topicName: 'projects/YOUR_PROJECT_ID/topics/gmail-notifications', // Optional: filter to specific labels only labelIds: ['INBOX'], labelFilterBehavior: 'INCLUDE' } }); const { historyId, expiration } = response.data; // Store these per-user in your database await db.upsert({ userId, lastHistoryId: historyId, // expiration is Unix ms timestamp watchExpiry: new Date(parseInt(expiration)) }); console.log(`Watch registered. historyId: ${historyId}, expires: ${expiration}`); return response.data; }
watch.py
from googleapiclient.discovery import build from google.oauth2.credentials import Credentials def register_gmail_watch(credentials: Credentials, user_id: str = 'me') -> dict: """Register Gmail API push notifications watch for an authenticated user.""" service = build('gmail', 'v1', credentials=credentials) body = { 'topicName': 'projects/YOUR_PROJECT_ID/topics/gmail-notifications', 'labelIds': ['INBOX'], 'labelFilterBehavior': 'INCLUDE' } result = service.users().watch(userId=user_id, body=body).execute() # Store per-user in your database db_upsert(user_id=user_id, last_history_id=result['historyId'], watch_expiry=int(result['expiration']) // 1000) return result
Implementation

Handling the Gmail API push notifications webhook payload

When Gmail fires a push notification, your HTTPS endpoint receives a Pub/Sub push message. The actual Gmail change data is double-encoded: the Pub/Sub envelope contains a base64-encoded JSON string that itself contains the user email and historyId.

webhook.js (Express)
Node.js - Express handler
app.post('/webhooks/gmail', async (req, res) => { // Acknowledge immediately : Pub/Sub retries on non-2xx res.status(200).end(); try { const message = req.body.message; if (!message?.data) return; // Decode the base64 Pub/Sub data field const decoded = Buffer.from(message.data, 'base64').toString('utf-8'); const payload = JSON.parse(decoded); // payload = { emailAddress: "user@gmail.com", historyId: "12345" } const { emailAddress, historyId } = payload; // Queue reconciliation (don't block the ack) await queue.enqueue({ emailAddress, historyId }); } catch (err) { // Log but don't re-throw : ack was already sent console.error('Webhook parse error', err); } });
webhook.py (Flask)
Python - Flask handler
import base64, json from flask import Flask, request, jsonify app = Flask(__name__) @app.route('/webhooks/gmail', methods=['POST']) def gmail_webhook(): # Acknowledge immediately data = request.get_json(silent=True) or {} message = data.get('message', {}) if 'data' in message: # Decode base64 and parse JSON raw = base64.b64decode(message['data'] + '==') payload = json.loads(raw) # { "emailAddress": "user@gmail.com", "historyId": "12345" } email = payload.get('emailAddress') history_id = payload.get('historyId') # Enqueue async reconciliation enqueue_reconcile(email, history_id) return jsonify({}), 200
Reconciliation

Reconciling changes with users.history.list

The Pub/Sub notification only tells you something changed. It does not tell you what. You must call users.history.list with your stored lastHistoryId as the cursor to get the actual delta.

reconcile.js
async function reconcileHistory(auth, emailAddress, newHistoryId) { const gmail = google.gmail({ version: 'v1', auth }); // Retrieve our stored lastHistoryId for this user const user = await db.findByEmail(emailAddress); const startHistoryId = user.lastHistoryId; try { const response = await gmail.users.history.list({ userId: 'me', // Use the STORED ID as cursor : NOT the new notification historyId startHistoryId, // Filter to message additions only (optional) historyTypes: ['messageAdded'] }); const histories = response.data.history || []; for (const record of histories) { for (const added of (record.messagesAdded || [])) { // added.message = { id, threadId, labelIds } await processNewMessage(auth, added.message.id); } } // Update cursor to the new historyId from the notification await db.updateLastHistoryId(emailAddress, newHistoryId); } catch (err) { if (err.code === 404) { // historyId is too old (> 7 days). Re-initialize from messages.list await reinitializeFromMessages(auth, emailAddress); } else { throw err; } } }
Always use stored cursor, not notification historyId

The historyId in the Pub/Sub notification is the current state. Your startHistoryId must be the previous value you stored. Using the notification historyId directly as startHistoryId means you will miss all the changes between your last processed point and now.

Handle duplicate notifications idempotently

Pub/Sub may deliver the same notification more than once. Your reconciliation logic must be idempotent: processing the same message ID twice should be a no-op. Use a unique constraint on message IDs in your database, or check for existence before inserting.

Handle 404 historyId too old

If you pass a startHistoryId older than 7 days, the API returns a 404. In this case, fall back to messages.list to re-sync from scratch, then call users.watch again to get a fresh historyId baseline.

Paginate history.list results

If many changes occurred between your last historyId and now, the history.list response may be paginated. Always follow nextPageToken until exhausted before updating your stored cursor.

Operations

Watch renewal strategy: the 7-day expiration problem

A Gmail API watch expires silently after 7 days. There is no automatic renewal and no warning notification. If your cron fails, new emails arrive but your application receives nothing - with no errors on either end. This makes renewal the most operationally critical part of any Gmail push notifications implementation.

Renew daily, not every 7 days. Run your renewal cron every 24 hours (not every 6 or 7 days). Watch renewal is idempotent - calling users.watch again simply resets the 7-day timer. A daily cadence gives you a 6-day safety buffer against transient failures.

renew-watches.js
// Daily cron: 0 3 * * * (runs at 3am daily) async function renewAllWatches() { // Get all authenticated users from your database const users = await db.getAllActiveUsers(); for (const user of users) { try { // Refresh the access token if needed const auth = await getAuthClient(user.id); const gmail = google.gmail({ version: 'v1', auth }); const res = await gmail.users.watch({ userId: 'me', requestBody: { topicName: 'projects/YOUR_PROJECT_ID/topics/gmail-notifications', labelIds: ['INBOX'] } }); // Update historyId baseline : new watch returns a fresh historyId await db.update(user.id, { lastHistoryId: res.data.historyId, watchExpiry: new Date(parseInt(res.data.expiration)) }); } catch (err) { if (err.code === 401) { // Refresh token revoked : user needs to re-authorize await db.markUserDisconnected(user.id); } else if (err.code === 404) { // Watch expired : call watch again (already doing this, so 404 = retry next run) console.warn(`Watch already expired for ${user.email}, will retry`); } else { // Log and continue : don't abort the entire cron for one user console.error(`Watch renewal failed for ${user.email}`, err); } } } }
Build real-time Gmail sync with one webhook

Unipile handles watch renewal automatically on behalf of each authenticated user. No cron job needed.

Build it with Unipile
Troubleshooting

Troubleshooting Gmail API push notifications

These are the four error classes that account for nearly all Gmail push notifications failures. Most have a single root cause once you know what to look for.

Error / Symptom Root Cause Fix Severity
403 on users.watch Gmail service account gmail-api-push@system.gserviceaccount.com was not granted Pub/Sub Publisher role on the topic. In GCP Console: Pub/Sub > Topics > your topic > Permissions. Add the service account with the Pub/Sub Publisher role. Blocker
watch succeeds but no notifications received Pub/Sub subscription push endpoint URL is not registered, rejected by Google (invalid TLS), or push subscription type is "Pull" instead of "Push". Verify your subscription is type "Push" with your webhook URL as the endpoint. Ensure TLS certificate is valid (not self-signed). Test endpoint returns 200. Blocker
404 on history.list - historyId too old Your stored lastHistoryId is older than 7 days. Gmail only retains history for 7 days. Fall back to messages.list to resync. Then call users.watch for a fresh historyId baseline. Recoverable
Push endpoint validation token rejected Google sends an X-Goog-Channel-Token header. If your endpoint validates it and the token doesn't match, it returns non-2xx and Pub/Sub retries indefinitely. Either disable token validation during initial setup, or configure the same token value in both the GCP subscription settings and your application config. Recoverable
403 on users.watch
Missing IAM: gmail-api-push@system.gserviceaccount.com not granted Pub/Sub Publisher.
Add service account as Publisher in GCP Console > Pub/Sub > Topics > Permissions.
watch succeeds but no notifications
Subscription is Pull type, or push endpoint URL invalid/TLS rejected.
Set subscription to Push type. Ensure HTTPS endpoint with valid TLS. Verify 200 response.
404 historyId too old
Stored cursor is older than 7 days.
Resync via messages.list, then re-register watch for fresh historyId.
Validation token rejected
Token mismatch between Pub/Sub subscription config and app config.
Match token values in both GCP subscription settings and application code.
Limits

Quotas & rate limits for Gmail API push notifications

Gmail API push notifications have specific quota constraints that differ from standard Gmail API quota buckets. The key constraint is per-user event throughput.

1 event/sec

Maximum Pub/Sub notification rate per authenticated user. Bursts may temporarily exceed this but are throttled over time. If a mailbox receives more than 1 change per second continuously, notifications will be batched or delayed, not dropped.

7 days

Maximum watch expiration. All watches must be renewed before this deadline. Gmail retains history.list data for the same 7-day window - a historyId older than 7 days returns a 404.

1M units/day

Default Gmail API daily quota per project. Each users.history.list call costs 5 units. users.watch costs 100 units per call. Plan your reconciliation volume accordingly.

For a deeper breakdown of per-method quotas, per-user limits, and quota increase request procedures, see our dedicated Gmail API rate limits and quotas guide.

Comparison

Trade-offs: Pub/Sub vs IMAP IDLE vs polling vs unified webhook

Choosing the right Gmail push notifications strategy depends on your infrastructure constraints, provider coverage needs, and operational tolerance. Here is a direct comparison of the four approaches.

Approach Latency Setup complexity Multi-provider Ops overhead
Gmail Pub/Sub watch 1-10s High - GCP, IAM, cron Gmail only 7-day renewal cron
IMAP IDLE 1-30s Medium - persistent TCP Gmail + IMAP servers Keep-alive management
Polling 30-300s lag Low Any provider High quota burn
Unified webhook (Unipile) 1-10s Low - 1 webhook URL Gmail + Outlook + IMAP None - managed
Gmail Pub/Sub watch
Latency1-10s
SetupHigh (GCP + IAM + cron)
ProvidersGmail only
IMAP IDLE
Latency1-30s
SetupMedium (persistent TCP)
ProvidersGmail + IMAP
Polling
Latency30-300s lag
SetupLow
ProvidersAny
Unified webhook (Unipile)
Latency1-10s
SetupLow (1 webhook)
ProvidersGmail + Outlook + IMAP
Unified Alternative

The Unified Webhook Alternative: Gmail + Outlook + IMAP with one endpoint

If you need Gmail API push notifications plus real-time events from Outlook and IMAP mailboxes - with a unified payload format and no GCP infrastructure - Unipile's Gmail API abstracts the entire Pub/Sub layer. As an independent technical intermediary, Unipile acts on behalf of each authenticated user to deliver email events through a single webhook URL your application already controls.

No GCP setup

No Pub/Sub topic to create, no IAM grant to configure, no GCP project to maintain. Watch registration and renewal happen inside Unipile's infrastructure, not yours.

Watch renewal is managed

The 7-day watch expiration is handled on behalf of each linked account. You never need a renewal cron job. If a refresh token is revoked, Unipile surfaces an account status webhook instead of silently dropping events.

historyId reconciliation abstracted

You receive a parsed, normalized email object - not a raw historyId. There is no need to call users.history.list or manage per-user cursors. Unipile resolves the delta and delivers structured message data.

Gmail + Outlook + IMAP in one payload

The same webhook endpoint and the same event schema covers Gmail, Outlook (including Microsoft 365 / Exchange Online), and IMAP. No per-provider integration logic, no separate webhooks for Microsoft Graph subscriptions vs Gmail pub sub notifications.

unified-webhook.js
// 1. Link user Gmail account (OAuth on behalf of authenticated user) // See: https://developer.unipile.com/docs/getting-started // 2. Configure your webhook once const config = await fetch('https://api8.unipile.com:13815/api/v1/webhooks', { method: 'POST', headers: { 'X-API-KEY': 'YOUR_API_KEY', 'Content-Type': 'application/json' }, body: JSON.stringify({ url: 'https://app.you.com/webhooks/email', events: ['email.new'] }) }); // 3. Handle unified payload : same shape for Gmail, Outlook, IMAP app.post('/webhooks/email', (req, res) => { const { event, account_id, email } = req.body; // event: "email.new" // email.provider: "gmail" | "outlook" | "imap" // email.subject, .from, .to, .body_html... // No historyId. No base64. No cursor to manage. processInboundEmail(email); res.status(200).end(); });
Works for Gmail, Outlook and IMAP linked accounts
Data Handling Note

Unipile does not build a parallel email archive or store message content independently. Access is scoped to the session of each authenticated user. Unipile retrieves email data on behalf of each linked account and delivers it to your webhook endpoint in real time. No data is retained beyond what is necessary to deliver the webhook payload.

How Unipile Operates

Unipile is an independent technical intermediary. It acts on behalf of each authenticated user who has authorized your application via OAuth. Unipile is not affiliated with, endorsed by, or sponsored by Google. It uses the same Gmail API endpoints described in this guide, on a per-user basis, under each user's own OAuth authorization. Credentials are never shared between accounts. All operations are a customer-side decision delegated to Unipile's infrastructure.

Platform Limits and Responsible Use

Unipile relays the Gmail API rate limits and quota constraints to your application through its own quota management layer. Decisions about event volume, polling frequency, and message handling remain a customer-side decision. Unipile surfaces Gmail API quota errors as structured webhook events so your application can respond appropriately.

Start building with unified webhooks

Connect your first Gmail account in minutes. No GCP project. No Pub/Sub billing. No watch renewal cron. See our Gmail API integration guide and the Email API provider overview to explore all supported providers.

Build it with Unipile

Gmail API Push Notifications - FAQ

Answers to the most common questions about Gmail API push notifications, Pub/Sub setup, historyId, watch renewal, and real-time email sync alternatives.

Gmail API push notifications use Google Cloud Pub/Sub to deliver real-time mailbox change events to your HTTPS webhook. You register a watch endpoint via users.watch, which links a Gmail mailbox to a Pub/Sub topic you own. When a change occurs - new message, label change - Gmail publishes a notification to that topic, which forwards it to your push webhook. Your webhook then calls users.history.list with a stored historyId cursor to retrieve the actual message delta. The Pub/Sub notification itself contains only the user email and a new historyId - not the message content.

users.watch registers a push notification subscription for a Gmail mailbox and returns a historyId baseline. It is the entry point that connects Gmail to your Pub/Sub topic. users.history.list is the reconciliation endpoint you call after receiving a Gmail push notification to get the actual changes (message additions, deletions, label changes) that occurred since your stored historyId cursor. Watch tells Gmail where to send alerts. History tells you what actually changed.

Gmail API watch endpoints expire after 7 days. Best practice is to run a daily cron job rather than every 6 or 7 days, so you have a multi-day buffer against transient failures. Watch renewal is idempotent: a new users.watch call simply resets the timer and returns a fresh historyId baseline. Expiration happens silently - there is no warning notification, so a failed cron means missed events with no errors on either side.

The most common cause is a missing IAM grant. Gmail uses the service account gmail-api-push@system.gserviceaccount.com to publish to your Pub/Sub topic. Without the Pub/Sub Publisher role on your topic for this account, users.watch succeeds but no notifications are ever delivered. Other causes: subscription type is "Pull" instead of "Push", invalid TLS certificate on your webhook endpoint, or your endpoint returns non-2xx responses causing Pub/Sub to stop delivering.

The historyId is a monotonically increasing integer that Gmail assigns to every mailbox change event. It acts as an incremental sync cursor. When you register a users.watch, Gmail returns a historyId baseline representing the current state. Subsequent Gmail push notifications include a new historyId. You pass your stored (previous) historyId as startHistoryId to users.history.list to get all changes between the two points. You must store the latest historyId per authenticated user in your database. HistoryIds older than 7 days return a 404 error.

Not directly through the Gmail API - Pub/Sub is the required delivery channel for Gmail push notifications. However, you can skip the GCP infrastructure entirely by using a unified email API like Unipile, which acts as an independent technical intermediary on behalf of each authenticated user, abstracts the Pub/Sub layer, and delivers Gmail push notifications to your webhook with a normalized payload. No GCP project, no IAM grant, no watch renewal cron required.

Run a daily cron job that calls users.watch for all active authenticated users. Store the returned historyId as the new baseline and update the stored expiry timestamp. Handle per-user errors without aborting the batch: a 401 means the OAuth refresh token was revoked (user must re-authorize), a 404 means the watch was already expired. Never wait until the 7-day expiry to renew - treat the daily run as maintenance, not a reactive fix.

Gmail API push notifications are capped at approximately 1 event per second per authenticated user. Bursts above this are batched or delayed, not dropped. The users.history.list call costs 5 quota units and users.watch costs 100 units per call. The default Gmail API daily quota is 1 million units per project. For a complete breakdown of per-method limits and quota increase procedures, see our Gmail API rate limits guide.

Need help setting up Gmail API push notifications for your app? Our team can walk you through it.

Talk to an expert
en_USEN