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.
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.
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.
| Requirement | Forwarding path |
|---|---|
| Act on a new message body or attachment | Fits the webhook model |
| Import years of existing Gmail mail | Requires a separate import |
| Read or change Gmail labels and read state | Use Gmail’s API |
| Create drafts or send as the Gmail user | Use an authorized Gmail sending path |
| Keep receiving when your laptop sleeps | Gmail 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.