Gmail webhook: how to get a real-time push when email arrives
Gmail has no native webhook. Here is how to build one with Gmail API push notifications and Cloud Pub/Sub, plus simpler alternatives for agents.
Dvir Atias
Founder
Gmail does not offer a webhook you can point at your server. To get a real-time
Gmail webhook, you use Gmail API push notifications delivered through Google
Cloud Pub/Sub: you call users.watch on the mailbox, Gmail publishes a tiny
notification to a Pub/Sub topic whenever the mailbox changes, and a push
subscription forwards that to your HTTPS endpoint. Your endpoint then calls
users.history.list to find out what actually changed. It works, but there are
moving parts and a 7-day renewal you cannot skip.
Does Gmail support webhooks?
No. There is no "paste your URL here" webhook setting in Gmail or in the Gmail
API. What Gmail gives you instead is a change-notification pipeline built on
Cloud Pub/Sub. The notification itself is deliberately thin: it tells you the
mailbox changed and gives you a historyId, not the message. You still have to
call back into the Gmail API to fetch the new mail. So a "Gmail webhook" is
really three services wired together - Gmail, Pub/Sub, and your own endpoint.
How do you get a webhook when a Gmail message arrives?
Here is the full setup, in order.
1. Create a Pub/Sub topic and a push subscription
In your Google Cloud project, create a topic and a subscription whose delivery type is "push" pointed at your HTTPS endpoint.
gcloud pubsub topics create gmail-events
gcloud pubsub subscriptions create gmail-push \
--topic=gmail-events \
--push-endpoint=https://your-app.com/gmail/push2. Let Gmail publish to your topic
Gmail publishes as a service account. Grant it the Publisher role on the topic,
or the watch call will fail.
gcloud pubsub topics add-iam-policy-binding gmail-events \
--member=serviceAccount:gmail-api-push@system.gserviceaccount.com \
--role=roles/pubsub.publisher3. Call users.watch to start notifications
With an OAuth token for the mailbox (scope gmail.readonly is enough to watch),
call watch and point it at your topic.
import { google } from "googleapis";
const gmail = google.gmail({ version: "v1", auth: oauth2Client });
const res = await gmail.users.watch({
userId: "me",
requestBody: {
topicName: "projects/YOUR_PROJECT/topics/gmail-events",
labelIds: ["INBOX"],
},
});
// Store res.data.historyId as your baseline cursor.
console.log(res.data.historyId, res.data.expiration);4. Handle the push and fetch what changed
The push body is base64-encoded JSON with the email address and a new
historyId. Decode it, then call history.list starting from the last
historyId you stored to get the actual message IDs, and fetch each one.
app.post("/gmail/push", async (req, res) => {
const data = JSON.parse(
Buffer.from(req.body.message.data, "base64").toString()
);
// data = { emailAddress, historyId }
const history = await gmail.users.history.list({
userId: "me",
startHistoryId: storedHistoryId,
historyTypes: ["messageAdded"],
});
for (const h of history.data.history ?? []) {
for (const m of h.messagesAdded ?? []) {
const msg = await gmail.users.messages.get({
userId: "me",
id: m.message.id,
});
// hand msg to your agent
}
}
storedHistoryId = data.historyId; // advance the cursor
res.sendStatus(204); // ack so Pub/Sub stops retrying
});5. Renew the watch every day
A watch expires after 7 days, silently, with no error and no final
notification. The standard fix is a daily cron that re-calls users.watch for
every active mailbox. Renewing is idempotent - it just resets the 7-day timer -
and a daily cadence gives you a six-day buffer against a failed run.
The gotchas nobody mentions up front
- Watch expiry is silent. Miss the renewal and notifications simply stop. Nothing errors. Renew daily, not weekly.
- historyId gaps. A
historyIdolder than about 7 days returns a 404 fromhistory.list. When that happens you must do a full re-sync withmessages.listand then re-watchto get a fresh baseline. - History can be empty or paginated. A notification does not guarantee a new
message - it can fire for reads, label changes, or deletes. And a busy mailbox
paginates, so follow
nextPageTokenbefore advancing your cursor. - Pub/Sub is its own product. Topic, subscription, IAM binding, and an endpoint that returns 2xx quickly or Pub/Sub keeps retrying.
- OAuth consent. Every mailbox needs an OAuth grant, and Gmail scopes push most apps into Google's verification and security review.
None of this is exotic, but "add a Gmail webhook" turns into a small distributed system with a cron job, a database cursor, and a re-sync path.
Gmail webhook vs polling vs a dedicated inbox API
| Approach | Real-time | Setup | Renewal / upkeep | Best for |
|---|---|---|---|---|
Pub/Sub push (users.watch) | Yes | Topic + subscription + IAM + OAuth | Daily watch renewal, historyId re-sync | Living inside a human's real Gmail |
Polling (history.list on a timer) | No, delayed | OAuth only | Cursor management | Low volume, simplicity over latency |
| Apps Script / Zapier forwarding | Near real-time to minutes | Low-code | Vendor-managed | Quick automations, no code |
| Dedicated inbox API | Yes | One POST | None | Agents that need their own address |
Simpler alternatives
If you do not strictly need to live inside a specific human's Gmail, several paths skip the Pub/Sub machinery.
Polling. Call history.list on a schedule. Higher latency and you still
manage OAuth and a cursor, but no topic and no push endpoint.
Apps Script forwarding. A Gmail-side script on an onNewEmail-style trigger
can UrlFetchApp.fetch your endpoint. Fine for personal automations, awkward to
operate at scale.
Zapier-style middlemen. Zapier's Gmail trigger is polling under the hood, so it checks every 1 to 15 minutes depending on plan - convenient, not instant.
A dedicated inbox API. If the agent does not need a human's existing mailbox, give it its own address where a webhook is one call and there is no Pub/Sub, no watch, and nothing to renew. That is the model behind AgenticEmail: inboxes are a resource you create with an API call.
# Create an inbox
curl -X POST https://api.agenticemail.dev/v1/inboxes \
-H "Authorization: Bearer $AGENTICEMAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "username": "agent-intake" }'
# -> agent-intake@inbox.agenticemail.dev
# Point a webhook at your endpoint - one call, signed per Standard Webhooks
curl -X POST https://api.agenticemail.dev/v1/webhooks \
-H "Authorization: Bearer $AGENTICEMAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://your-app.com/hooks/email", "event_types": ["message.received"] }'Every inbound message arrives as a signed JSON payload with parsed headers,
text, HTML, and attachments. Prefer a stream? Open a WebSocket at
wss://api.agenticemail.dev/v1/events?token=am_... and the same events push
down a long-lived connection. There is a free tier, and the full reference is
in the docs. The mechanics of the payload and signature verification
are covered in what an email webhook is and how to consume one.
Frequently asked questions
Does Gmail have a native webhook?
No. Gmail has no built-in webhook. The closest thing is Gmail API push
notifications over Cloud Pub/Sub, which you assemble yourself from a topic, a
push subscription, a users.watch call, and your own endpoint.
How often must a Gmail watch be renewed?
At least once every 7 days, because a watch expires after 7 days. Google recommends renewing daily so a single failed run does not silently cut off notifications. As of July 2026 the 7-day limit still holds.
Why do I get a notification but no new message?
Gmail notifies on any mailbox change - label edits, reads, deletes - not just
new mail. The push only carries a historyId; you call history.list to see
what changed, and it may return nothing relevant. Filter by messageAdded and
by labelIds: ["INBOX"] on the watch.
What happens if my historyId expires?
A historyId older than roughly 7 days returns a 404 from history.list. Fall
back to messages.list for a full re-sync, then call users.watch again to get
a fresh baseline cursor.
Can Zapier trigger on a new Gmail message?
Yes, but its Gmail trigger polls rather than pushing, so it fires every 1 to 15 minutes depending on your plan. For true real-time you need Pub/Sub push or a provider that emits webhooks natively.
How do I send a reply once the agent has read the mail?
Through the Gmail API's messages.send, or, if you moved to a dedicated inbox,
one REST call - see sending email over the Gmail API
for the send side.