MailKite
Get your API key
All server guides
Enterprise mail services

Receive Gmail changes with Pub/Sub and mailbox history

Watch a Gmail inbox, process authenticated Pub/Sub notifications, and synchronize mailbox history with cursor recovery and watch renewal.

Reviewed

Gmail push notifications tell your backend that a mailbox changed. They do not contain a complete email. A reliable integration uses Pub/Sub as a wake-up signal, reads Gmail history from a durable cursor, and fetches the messages it needs through the Gmail API.

Prepare OAuth and Pub/Sub

Complete mailbox authorization with an appropriate read scope. gmail.send alone cannot support this receiving workflow. Use the shared gmail-client.mjs from that guide.

In the Google Cloud project executing the watch request:

  1. Create a Pub/Sub topic and subscription with approved retention/retry settings.
  2. Grant topic publishing permission to gmail-api-push@system.gserviceaccount.com, as documented by Google.
  3. Choose pull delivery to a trusted worker, or an authenticated HTTPS push subscription. For push, configure and validate its OIDC token, expected audience, and service-account identity before trusting the body.
  4. Ensure organization policy permits the publisher grant. Do not disable domain-restricted sharing organization-wide as a shortcut.

The topic’s project must match the Google developer project executing the Gmail watch request. A Gmail OAuth token and the subscription’s push-authentication token authorize different boundaries.

Register the mailbox watch

Set GMAIL_TOPIC to the full topic name, such as projects/your-project/topics/gmail-inbox. Save as watch-gmail.mjs:

import { gmail } from "./gmail-client.mjs";

if (!process.env.GMAIL_TOPIC) throw new Error("Set GMAIL_TOPIC");
const { data } = await gmail.users.watch({
  userId: "me",
  requestBody: {
    topicName: process.env.GMAIL_TOPIC,
    labelIds: ["INBOX"],
    labelFilterBehavior: "include",
  },
});

console.log({ historyId: data.historyId, expiration: data.expiration });

Run node watch-gmail.mjs after the infrastructure is ready. It creates/updates a real watch and triggers an initial notification. Record the returned history ID and expiration durably. The lowercase include value follows the current Gmail discovery schema.

For a new connection, decide whether you need existing mail: the watch establishes a baseline, not a mailbox-history import. For an existing connection, do not overwrite an unprocessed durable cursor with a renewal’s newer history ID; that can skip changes.

Synchronize from the last committed cursor

After verifying a notification, decode its message.data to obtain the mailbox identity and history hint. Resolve that mailbox to an already authorized application connection. Never let an arbitrary request body choose which user’s credentials the worker loads.

This inspection example uses GMAIL_HISTORY_ID as the last committed cursor and follows every page. It prints message IDs only; a production worker must persist work and fetch/process messages before committing a new cursor.

import { gmail } from "./gmail-client.mjs";

const startHistoryId = process.env.GMAIL_HISTORY_ID;
if (!startHistoryId) throw new Error("Set the last committed GMAIL_HISTORY_ID");
let pageToken;
let candidateCursor;
do {
  const { data } = await gmail.users.history.list({
    userId: "me",
    startHistoryId,
    historyTypes: ["messageAdded"],
    labelId: "INBOX",
    pageToken,
  });
  for (const change of data.history ?? []) {
    for (const added of change.messagesAdded ?? []) {
      console.log({ messageId: added.message?.id });
    }
  }
  candidateCursor = data.historyId;
  pageToken = data.nextPageToken ?? undefined;
} while (pageToken);

console.log({ candidateCursor }); // Commit only after durable processing.

Keep history IDs as strings; they are not small sequential JavaScript numbers. Deduplicate message work and serialize or coordinate cursor updates per mailbox. Fetch required content with users.messages.get under the correct scope; the Pub/Sub message ID is not a Gmail message ID.

Handle missed notifications and expired history

Acknowledge notifications only after safely handing work to durable processing. Push retries and duplicate notifications are normal. Google also documents that notifications can be delayed or dropped, so periodically reconcile from the stored cursor even when no notification arrives.

A stale startHistoryId can produce HTTP 404. Recover through an appropriately scoped full synchronization, then establish a new cursor without silently declaring the missing interval processed. Renew watches before expiration; Google requires renewal at least every seven days and recommends daily renewal. Monitor renewal failures and disconnected OAuth grants.

Connect to MailKite without mixing event contracts

Gmail Pub/Sub payloads do not carry MailKite webhook signatures. If your application handles both, keep provider authentication and event identifiers distinct. For future-message forwarding instead of mailbox synchronization, use the Gmail-to-MailKite forwarding walkthrough; it does not mirror mailbox history or labels.

References