All posts
Receive Gmail emails live with forwarding and a webhook
Gabe 7 min read

Receive Gmail emails live with forwarding and a webhook

Keep Gmail as your mailbox and forward new messages to a separate MailKite receiving address. Your application gets signed JSON webhooks instead of polling Gmail. This walkthrough covers the confirmation message, a Python receiver, selective forwarding and the limits of the approach. MailKite is the platform we build.

New mail → GmailOriginal mailbox stays Gmail forwards a copyTo a MailKite address Signed JSON POSTTo your HTTPS app Verify, then storeCommit before 200
Forwarding adds a mail-delivery hop; the webhook starts when that forwarded message reaches MailKite.

The runnable source and delivery checks live in the shared receiving-email demo repository.

First make the destination work. The downloadable Python receiver below stores Google’s confirmation email as well as later messages, so you can read the confirmation link even before forwarding is enabled.

import json
import os
import sqlite3
from flask import Flask, request
from mailkite import verify_webhook

app = Flask(__name__)
SECRET = os.environ["MAILKITE_WEBHOOK_SECRET"]
DB = os.environ.get("INBOX_DB", "inbox.sqlite")
with sqlite3.connect(DB) as db:
    db.execute("CREATE TABLE IF NOT EXISTS inbox (id TEXT PRIMARY KEY, payload TEXT NOT NULL)")

@app.post("/hooks/mailkite")
def receive():
    raw = request.get_data()
    signature = request.headers.get("x-mailkite-signature", "")
    if not verify_webhook(signature, raw, SECRET):
        return "bad signature", 401
    try:
        event = json.loads(raw)
    except (ValueError, UnicodeDecodeError):
        return "bad JSON", 400
    if not isinstance(event, dict):
        return "bad event", 400
    if event.get("type") != "email.received":
        return {"status": "ok"}
    if not isinstance(event.get("id"), str) or not event["id"]:
        return "missing id", 400
    try:
        with sqlite3.connect(DB, timeout=5) as db:
            db.execute("INSERT INTO inbox (id, payload) VALUES (?, ?) ON CONFLICT(id) DO NOTHING",
                       (event["id"], raw.decode("utf-8")))
    except sqlite3.Error:
        return "storage unavailable", 503
    return {"status": "ok"}  # The transaction has committed before this response.

if __name__ == "__main__":
    app.run(port=int(os.environ.get("PORT", "3000")))

Configure the destination before asking Gmail to confirm it

Add a receiving domain or hosted subdomain to MailKite, complete its verification, and choose a destination such as gmail@your-receiving-domain. This is a separate address: do not change the MX records of your existing Gmail or Google Workspace mailbox.

Create an exact-address MailKite webhook route for that destination before obtaining its signing secret or starting the receiver. Point it at your public HTTPS /hooks/mailkite URL; a development tunnel can forward that URL to local port 3000. Install mailkite-dev and Flask in a Python virtual environment, supply the route’s secret as MAILKITE_WEBHOOK_SECRET through your environment, then run receive.py. The Gmail setup guide includes dependency commands.

Download the synthetic checker as check.mjs. With Node.js 18+ and the same webhook secret set in a second terminal, run:

node check.mjs http://127.0.0.1:3000/hooks/mailkite

The checker exercises valid and duplicate events, tampered bodies, missing signatures and signed malformed JSON. It sends HTTP requests, not email. The dashboard’s domain test targets a catch-all *@domain webhook; it does not validate your exact-address route. After the local check, send a real email directly to the exact MailKite receiving address and confirm its delivery and stored payload. Only then ask Gmail to send its confirmation message. This separates receiver failures from forwarding setup failures.

The receiver needs only the webhook secret for verification. SQLite stores one row per stable message id and commits before returning 200. Keep the database on persistent storage and outside any public file-serving directory. The sample accepts and stores events; a separate worker should process them and record completion.

Gmail’s verification message is part of the setup

In Gmail on a computer, choose Settings → See all settings → Forwarding and POP/IMAP. Some accounts show a Forwarding tab instead. Select Add a forwarding address, enter your MailKite destination, and continue through Google’s confirmation dialog.

Gmail sends a verification message to the destination. Download the inbox inspector as inspect-inbox.py beside the receiver’s database, then read that message from the stored Python webhook payload:

python3 inspect-inbox.py inbox.sqlite

Pass the actual database path if you set INBOX_DB. The inspector prints the latest ten message ids, subjects and bodies, falling back to HTML when no text is available. Keep mailbox content and confirmation links private: do not publish the output, add it to application logs or expose the inspector as a web route. Read Google’s confirmation link and follow it yourself, or read the message in MailKite’s inbox. Do not automatically follow arbitrary inbound links.

After confirming, return to Gmail, refresh settings, choose Forward a copy of incoming mail to, and select the verified destination. Keep Gmail’s copy in the inbox for this walkthrough, then choose Save Changes. Adding the address and confirming it are not the same as enabling forwarding.

1. Add the addressWorking MailKite inbox 2. Read Google's emailFollow confirmation link 3. Enable forwardingChoose Save Changes
If step three is missing, confirmation succeeds but later mail is not forwarded.

Google’s automatic forwarding documentation, retrieved on October 14, 2026, says this forwards new messages except spam. It is not a backfill of old messages. A managed Workspace account may also be subject to administrator forwarding restrictions.

Forward selected mail when the application has one job

For a receipts processor or support workflow, use a forwarding filter rather than sending your whole mailbox. Verify the destination first, leave blanket forwarding disabled, then create a Gmail filter using the desired sender, recipient or other search criteria. Choose Forward it and select the verified address.

Send a new message that matches the filter and another that does not. Confirm that only the matching one reaches the application. Do not use existing search results as evidence that new-message forwarding is working; send fresh messages for the test.

Avoid forwarding loops. The receiving address should terminate at the application, not forward back into the same Gmail inbox. If the app sends replies, keep routing and filtering rules specific enough that its own replies do not continuously re-enter the workflow.

Be precise about what “live Gmail” means

This is event-driven email delivery, not Gmail account synchronization. Your application receives a parsed copy after Gmail forwards it and MailKite accepts it. SMTP and provider forwarding contribute latency; there is no fixed delivery-time promise.

RequirementForwarding path
Act on a new message body or attachmentFits the webhook model
Import years of existing Gmail mailRequires a separate import
Read or change Gmail labels and read stateUse Gmail’s API
Create drafts or send as the Gmail userUse an authorized Gmail sending path
Keep receiving when your laptop sleepsGmail forwarding is server-side

For a full mailbox application, Gmail API watches with Pub/Sub provide a different integration: notifications tell you to retrieve changes, and you own the authorization and subscription lifecycle. IMAP polling is another option when its operational trade-offs fit your application. For a domain you control, SES receipt rules or a self-hosted SMTP-to-HTTP bridge can replace the MailKite hop, but you must handle parsing, signing and retries yourself.

Check receipt, storage and processing separately

Send a real external message to Gmail. Look for the forwarded message in MailKite, inspect the webhook delivery status, then confirm the SQLite row. A 200 response proves this receiver accepted the event; it does not prove your future worker completed its business task.

Replay that delivery and confirm the row count remains unchanged. The local checker only verifies the duplicate response, and the inspector displays recent content; neither asserts the database row count. If storage is unavailable, the receiver returns 503 instead of acknowledging a message it didn’t save. Check webhook signing separately from the email’s authentication verdict: forwarding can change SPF behavior, and a signed webhook is not proof of the original sender’s identity.

Use the Gmail forwarding guide for the checklist, Python receiving guide for runtime details, or PHP receiving guide for the same boundary in PHP. The complete code is the downloadable receiver.

Discuss this post: Hacker News Share on X Share on LinkedIn

Related posts