Gmail Webhook Setup
Configure the DaySurface webhook pipeline - Pub/Sub push, GCP setup, subscriptions, signature verification, retries, and the delivery runner.
Operator reference for the outbound webhook pipeline. For what Gmail's push notifications actually give you and why this layer exists at all, start with Gmail webhooks - this page assumes you have read it and want to configure the thing.
The whole pipeline is optional and off by default - it stays dormant until you configure a Pub/Sub topic.
Architecture
HOP 1 (inbound, optional) HOP 2 (outbound, durable)
┌───────────────────────────────────┐ ┌────────────────────────────────────┐
Gmail ──push──▶ Pub/Sub ──OIDC POST──▶ /api/v1/google/webhook/gmail
│ 1. verify OIDC JWT (aud + SA email)
│ 2. dedup on Pub/Sub messageId
│ 3. to_thread → process_notification
▼
users.history.list (messageAdded)
│ 404 (expired) → messages.list resync
│ advance forward-only historyId
▼
enqueue_event ──▶ webhook_events
│ fan-out per active sub
▼
webhook_deliveries (outbox)
│
periodic runner (loop or /renew) ───────────┤ drain_due_deliveries
│ HMAC sign + POST
▼
2xx → succeeded else → backoff retry → failedThe inbound and outbound halves are decoupled by the webhook_deliveries
outbox, so a slow or failing subscriber never blocks Gmail processing.
GCP setup
These steps happen once in the Google Cloud console and cannot be codified:
- Create a Pub/Sub topic.
- Grant
[email protected]the Pub/Sub Publisher role on that topic (this is what lets Gmail publish). - Create a push subscription whose endpoint is
https://<your-host>/api/v1/google/webhook/gmail, with OIDC authentication enabled using a service account you control. - Set
GMAIL_PUBSUB_TOPIC,GMAIL_PUSH_AUDIENCE(the endpoint URL you configured as the audience), andGMAIL_PUSH_SA_EMAIL(that service account).
The Gmail gmail.modify scope already granted during OAuth covers
users.watch and users.history.list - no re-consent is needed. When push is
configured, a watch is auto-started (fire-and-forget) as soon as a user
connects Gmail, and renewed automatically before its ~7-day expiry.
Configuration
All keys are optional; leaving GMAIL_PUBSUB_TOPIC unset disables the feature.
| Key | Purpose |
|---|---|
GMAIL_PUBSUB_TOPIC | Fully-qualified Pub/Sub topic, projects/<p>/topics/<t> |
GMAIL_PUSH_AUDIENCE | OIDC aud claim required on the push JWT |
GMAIL_PUSH_SA_EMAIL | Push subscription's service-account email (checked against the token's email) |
WEBHOOK_RUNNER_MODE | off (default), loop (in-process), or endpoint (external cron) |
WEBHOOK_RUNNER_INTERVAL_S | Seconds between in-process runner ticks (default 30) |
WEBHOOK_RUNNER_TOKEN | Shared bearer for the internal /renew endpoint |
WEBHOOK_MAX_ATTEMPTS | Delivery attempts before a row is marked failed (default 6) |
Self-service: the Settings app
Clients that support MCP Apps get a Settings panel (an iframe dashboard) so
a user can manage everything themselves - no operator, no curl. Ask the
assistant to "open my settings" and it calls the webhook_settings tool,
which renders ui://daysurface/settings. From there the user can:
- see their Gmail connection + watch status,
- add an HTTPS endpoint and copy the one-time signing secret,
- rotate a secret or remove an endpoint.
The panel talks to guarded app-only tools (settings.get / settings.subscribe
/ settings.rotate_secret / settings.unsubscribe); each ignores any
user_id on the wire and acts only on the authenticated principal, so the
iframe can never touch another user's settings. The signing secret is shown
once at create/rotate time and is never retrievable again - lost secrets
are replaced by rotating.
Non-App clients fall back to the headless webhook_settings result and the
plain subscription services below.
Subscribing to events
Subscriptions are managed through the shared service registry, so they work identically over CLI, MCP, and HTTP:
webhook_subscribe- register an https endpoint; returns a one-time signingsecret(store it - it is never shown again). You may passevent_typesto filter; omit for all events.webhook_list- list your subscriptions (secrets are never returned).webhook_unsubscribe- deactivate a subscription.webhook_rotate_secret- issue a fresh signing secret.
Subscriber URLs are validated: private, loopback, link-local, and reserved
addresses are rejected to prevent SSRF, and plaintext http:// is refused for
public hosts.
The event payload
Today there is one event type, gmail.message.new. The JSON body is the event
envelope:
{
"id": "<event id>",
"type": "gmail.message.new",
"created_at": "2026-07-04T12:00:00+00:00",
"data": {
"message_id": "...",
"thread_id": "...",
"label_ids": ["INBOX"],
"snippet": "first ~200 chars of the message",
"from": "[email protected]",
"subject": "...",
"date": "..."
}
}The payload is intentionally metadata + snippet only - never the full
message body. Fetch the message with gmail_get_thread if you need more, so
that mail content is pulled deliberately by an authenticated caller rather than
pushed to every registered endpoint.
Verifying deliveries
Each POST carries these headers:
| Header | Meaning |
|---|---|
X-Webhook-Signature | sha256=<hex> HMAC over {timestamp}.{body} |
X-Webhook-Timestamp | Unix seconds; reject if too old to stop replays |
X-Webhook-Event-Id / X-Webhook-Event-Type / X-Webhook-Delivery-Id | Event/delivery identifiers |
The scheme mirrors Stripe's: HMAC-SHA256 with your subscription's secret (they
are prefixed whsec_) over the timestamp, a literal ., and the raw request
body. Verify against raw bytes, not a re-serialized object - re-encoding JSON
changes key order and whitespace and will not match.
import hashlib
import hmac
import time
def verify(secret: str, body: bytes, signature: str, timestamp: str) -> bool:
"""Validate one delivery. `body` must be the raw request bytes."""
# Reject replays of a signature captured earlier.
if abs(time.time() - int(timestamp)) > 300:
return False
digest = hmac.new(
secret.encode("utf-8"),
f"{timestamp}.".encode() + body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(f"sha256={digest}", signature)With FastAPI, read the body with await request.body() so you get the exact
bytes that were signed.
import { createHmac, timingSafeEqual } from "node:crypto";
/** Validate one delivery. `body` must be the raw request Buffer. */
export function verify(secret, body, signature, timestamp) {
// Reject replays of a signature captured earlier.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const digest = createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(body)
.digest("hex");
const expected = Buffer.from(`sha256=${digest}`);
const actual = Buffer.from(signature);
return (
expected.length === actual.length && timingSafeEqual(expected, actual)
);
}With Express, mount express.raw({ type: "application/json" }) on the webhook
route so req.body is the unparsed Buffer.
Return a 2xx quickly and do your real work asynchronously. Any non-2xx
marks the delivery failed and schedules a retry.
Retries
A failed delivery is retried with exponential backoff - 30s, then doubling per
attempt, capped at one hour - until WEBHOOK_MAX_ATTEMPTS (default 6) is
reached, at which point the row is marked failed and left in the table for
inspection. Because retries are real, your handler must be idempotent: key
on X-Webhook-Event-Id, which is stable across every attempt of the same
event, rather than on X-Webhook-Delivery-Id, which is not.
Driving the runner
loop- an in-process asyncio task drains the outbox everyWEBHOOK_RUNNER_INTERVAL_Sand renews watches / prunes old rows hourly. Simplest for single-instance deployments.endpoint- point a scheduler (cron, Cloud Scheduler) atPOST /api/v1/google/internal/renewwith headerX-Runner-Token: <WEBHOOK_RUNNER_TOKEN>. Each call renews due watches and drains the outbox. Preferred when you run multiple replicas or serverless.
Delivery claiming is dialect-aware: on PostgreSQL rows are claimed with
FOR UPDATE SKIP LOCKED, so multiple runners never double-send.