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.
// 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);
});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.
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.
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.
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.
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.
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.
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: 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.
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.
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.
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.
Decode the base64 Pub/Sub message data. Extract the new historyId. Compare it against the lastHistoryId stored in your database for this user.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
No Pub/Sub topic. No IAM grant. No 7-day watch renewal cron. Build Gmail push notifications with one webhook URL.
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.
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;
}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 resultHandling 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.
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);
}
});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({}), 200Reconciling 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.
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;
}
}
}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.
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.
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.
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.
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.
// 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);
}
}
}
}Unipile handles watch renewal automatically on behalf of each authenticated user. No cron job needed.
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 |
gmail-api-push@system.gserviceaccount.com not granted Pub/Sub Publisher.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.
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.
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.
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.
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 |
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 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.
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.
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.
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.
// 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();
});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.
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.
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.
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.
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.