NewThe TypeScript SDK is on npm as an alpha. Try the quickstart

Blog

Gmail push notifications: watch, Pub/Sub and history replay.

Gmail tells you that a mailbox changed, not what changed, and it may tell you twice, late or not at all.

 · 6 min read

Polling Gmail works until you have a few thousand mailboxes. Then you are spending quota to learn that nothing changed, and new mail still shows up a minute late. Gmail’s answer is push: you register each mailbox with users.watch, and Gmail publishes to a Cloud Pub/Sub topic whenever the mailbox changes.

The notification is small, delivery is at least once, and Google says some notifications arrive late or not at all. This article covers what a push pipeline has to handle, from Google’s documentation, and how Workwae’s Gmail sync handles it.

users.watch registers a mailbox.

A watch is one call per mailbox, made with that user’s token (users.watch). It names a topic in the projects/{project}/topics/{topic} format, and the project part must exactly match the Google Cloud project behind the OAuth client making the request. A topic in any other project is refused.

users.watch
POST https://gmail.googleapis.com/gmail/v1/users/me/watch
Content-Type: application/json

{
  "topicName": "projects/example-mail/topics/gmail",
  "labelIds": ["INBOX"],
  "labelFilterBehavior": "include"
}

labelIds is optional. Without it, every change in the mailbox triggers a notification. With it, labelFilterBehavior either limits notifications to those labels (include) or sends everything except them (exclude). The older labelFilterAction field is deprecated.

The response has the mailbox’s current historyId and an expiration in epoch milliseconds. Google requires a new watch call at least every 7 days and recommends one a day, and a successful call publishes a notification right away (Configure push notifications). Renewal is the same call again. users.stop ends a watch, and Google says notifications stop within a few minutes.

Two grants in your Cloud project.

Gmail publishes as gmail-api-push@system.gserviceaccount.com, so that account needs the Pub/Sub publisher role on your topic. An organization policy with domain-restricted sharing can block the grant, and Google’s guide points to adding an exception for this one account.

The second grant is for your endpoint. A push subscription can attach an OpenID Connect JWT, signed by Pub/Sub, to the Authorization header of every request (Authenticate push subscriptions). The token is issued for a service account you choose, and the project’s Pub/Sub service agent needs roles/iam.serviceAccountTokenCreator on that account to mint it. Your endpoint then checks the signature, the audience and the service account email. Without that check, anyone who finds the URL can send notifications.

A notification is a cursor, not a change.

A push delivery is a Pub/Sub envelope. The Gmail part is in message.data, Base64URL-encoded JSON with two fields:

push request
{
  "message": {
    "data": "eyJlbWFpbEFkZHJlc3MiOiAidXNlckBleGFtcGxlLmNvbSIsICJoaXN0b3J5SWQiOiAiMTIzNDU2Nzg5MCJ9",
    "messageId": "2070443601311540",
    "publishTime": "2021-02-26T19:13:55.749Z"
  },
  "subscription": "projects/example-mail/subscriptions/gmail-push"
}

// message.data, decoded
{ "emailAddress": "user@example.com", "historyId": "1234567890" }

There is no message id, no label and no hint of what happened. The historyId is the mailbox’s position after the change. To find the change, call users.history.list with startHistoryId set to the last id you processed, page through the records, and store the response’s historyId when there is no nextPageToken (users.history.list). Records list messagesAdded, messagesDeleted, labelsAdded and labelsRemoved, and their messages usually carry only id and threadId, so a new message still costs a messages.get.

Treat each notification as “this mailbox moved past N”, not as an event to process. Gmail sends at most one notification per second per watched user and drops the rest, so a burst of changes can arrive as a single notification. History replay picks up every change either way.

When the history id is too old.

Google documents history ids as typically valid for at least a week, and in rare cases for only a few hours. An invalid or expired startHistoryId typically returns 404, and the documented response is a full sync. A mailbox that was disconnected for ten days, or a worker that was down over a weekend, lands here. Your full sync has to reconcile against what you already stored. Treating every listed message as new would replay the mailbox to your users.

At-least-once, sometimes late, occasionally lost.

Pub/Sub delivers at least once with no ordering guarantee by default (Subscription overview), and exactly-once delivery is only available for pull subscriptions (Exactly-once delivery). A push endpoint acknowledges with 102, 200, 201, 202 or 204. Any other status, or a missed deadline, triggers a redelivery, and repeated failures put the whole subscription into push backoff (Push subscriptions).

Three rules follow:

  • Acknowledge fast. Record the wake-up and answer. Don’t run the sync inside the request.
  • Make duplicates free. If the stored cursor is already at or past the notification’s historyId, there is nothing to read. Out-of-order notifications are harmless for the same reason.
  • Keep polling. Google says Pub/Sub typically delivers within seconds but that notifications can be delayed or dropped, and recommends calling history.list periodically when a user has gone quiet. A watch that lapsed, a refused renewal or a misconfigured subscription look the same from your side: nothing arrives.

How Workwae handles it.

  • One watch per account, renewed daily. Workwae watches the whole mailbox, with no label filter, because it indexes every label. It renews the watch a day after the last call, or sooner if Google set an earlier expiration. If a renewal fails, the watch Gmail already holds stays in place until it expires.
  • Notifications only wake the sync. The push endpoint verifies the OIDC token, wakes the matching accounts and answers 204. The sync replays history.list from the stored cursor. On a customer app’s endpoint, an account whose cursor already covers the notification’s historyId is not woken at all.
  • A 404 means a full re-read. The re-read is diffed against the index, so only messages that actually changed produce events. Indexed messages the listing missed are read by id and removed only if Gmail no longer has them.
  • Polling stays on. With push active, a backstop pass still runs at least every 10 minutes. An account without a watch is polled every minute.

With your own Google OAuth app, the topic has to live in that app’s Cloud project, so you create it there. Workwae stores no Google Cloud credentials. On the app’s page in the console’s OAuth app settings, the Gmail push section shows the push endpoint, which is also the token audience, and generates a gcloud script for the topic, the publisher grant, the token creator grant and the push subscription. Then you save the topic and the push service account, in the console or through the API:

gmail-push.ts
import { createClient } from "@workwae/sdk";

const workwae = createClient({ apiKey: process.env.WORKWAE_API_KEY! });

const app = await workwae.oauthApps.update({
  appId,
  gmailPush: {
    topic: "projects/example-mail/topics/workwae-gmail",
    serviceAccount: "workwae-gmail-push@example-mail.iam.gserviceaccount.com",
  },
});
// app.gmailPushEndpoint is the subscription's push endpoint and token audience

Saving wakes the app’s accounts so they watch right away. Pushes to that endpoint are accepted only with tokens issued for your service account and that audience, and they only wake that app’s accounts. If Gmail refuses a watch (topic in another project: 400; no publisher grant: 403), the account keeps polling, and its runtime.detail gives the reason gmail_watch_refused. Workwae asks again after an hour, doubling up to a day, or immediately when you save the push settings again. Setting gmailPush to null stops the watches and returns the accounts to polling.

Your backend never sees any of this. The first sync ends with one account.initialized instead of an event per message. After that, changes arrive as message.created, message.updated and message.deleted webhooks, signed with Standard Webhooks (Standard Webhooks). The event id doubles as the webhook-id header, and it is the key to deduplicate on:

message.created
// Your webhook, after a new message is indexed (abridged)
{
  "id": "48213",
  "type": "message.created",
  "accountId": "6f1c2a4e-8b3d-4e5f-9a7b-1c2d3e4f5a6b",
  "createdAt": "2026-10-05T09:30:02.000Z",
  "data": {
    "message": {
      "id": "c4e6a8b0-1d3f-4a5c-8e7b-9d1f3a5c7e9b",
      "subject": "Quarterly report",
      "from": { "name": "Grace Hopper", "address": "grace@example.org" },
      "receivedAt": "2026-10-05T09:30:00.000Z",
      "isRead": false,
      "direction": "inbound"
    },
    "mailboxes": [{ "id": "8a3f5c7e-2d4b-4c6e-9f1a-3b5d7f9e1c2a", "name": "INBOX", "role": "inbox" }]
  }
}

parseWebhook from @workwae/sdk verifies the signature and returns the event typed by type.

Limits.

  • Latency is Google’s. A delayed or dropped notification is covered by the backstop pass, so in the worst case a change shows up up to 10 minutes late.
  • Mail only. Google Calendar uses its own watch channels, and Google contacts have no push and are polled daily.
  • Your project, your bill. With your own OAuth app, the topic and subscription live in your Cloud project, so Pub/Sub usage is billed there (Pub/Sub pricing).
  • Check the account, not the setting. Saved push settings don’t prove a watch exists. An account’s runtime.detail says whether it is on push or polling, and why.
  • Alpha SDK. @workwae/sdk is at 0.1.0-alpha.

Webhooks covers delivery, retries and replays, and the Connect flows guide shows how to add your own Google OAuth app.