All posts
Guide·Jun 30, 2026·7 min read

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

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/push

2. 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.publisher

3. 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 historyId older than about 7 days returns a 404 from history.list. When that happens you must do a full re-sync with messages.list and then re-watch to 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 nextPageToken before 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

ApproachReal-timeSetupRenewal / upkeepBest for
Pub/Sub push (users.watch)YesTopic + subscription + IAM + OAuthDaily watch renewal, historyId re-syncLiving inside a human's real Gmail
Polling (history.list on a timer)No, delayedOAuth onlyCursor managementLow volume, simplicity over latency
Apps Script / Zapier forwardingNear real-time to minutesLow-codeVendor-managedQuick automations, no code
Dedicated inbox APIYesOne POSTNoneAgents 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.

Talk to a real person