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.
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:
{
"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.listperiodically 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 replayshistory.listfrom the stored cursor. On a customer app’s endpoint, an account whose cursor already covers the notification’shistoryIdis 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:
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:
// 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.detailsays whether it is on push or polling, and why. - Alpha SDK.
@workwae/sdkis 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.