· 5 min read
Push, not just poll: real-time notifications for inbound email
Inbound email on MailFlo has always worked the same way: point an MX record at us, and every message that lands gets parsed and forwarded to your webhook. That's still true today. What changed is that it's no longer the only way to find out mail arrived.
The webhook's blind spot
A webhook assumes something you can usually take for granted but not always: a public HTTPS endpoint that MailFlo can reach and POST to. That's a fine assumption for a production API. It's a bad one for a local dev environment behind NAT, a desktop or CLI tool with no server component, a backend sitting entirely inside a private network, or a support console someone wants to update live without wiring up infrastructure just to receive a callback. Those consumers were stuck polling GET /api/v1/inbound on a timer, trading latency for simplicity.
What's new: a push channel, alongside the webhook
Every project with inbound enabled can now open a WebSocket connection and get notified the instant a message lands — no public endpoint required, no polling loop, no added latency waiting for the next tick. It authenticates with the same project-scoped API key your REST calls already use, and each project gets its own isolated connection pool: a socket can only ever be routed to the project whose key opened it.
This is additive, not a migration. Your webhook keeps firing exactly as before — retried with backoff, then dead-lettered if your endpoint is down. The push channel is a second delivery path off the same event, for the cases a webhook can't reach.
How a message actually reaches you
The push itself is deliberately thin — an id, the sender, and the subject, not the full message. The body, headers and attachments live behind the same authenticated REST endpoint the webhook payload already points back to, so there's exactly one source of truth regardless of which path told you a message existed:
Mail arrives
you@yourdomain.com — MX points at MailFlo
Parsed & stored
One durable record: sender, subject, body, attachments
New: push
Thin push over WebSocket
id, from, subject — no body, sent the instant mail lands
You fetch the body
GET /api/v1/inbound/:id — only when you actually need it
Existing: webhook
Webhook queue
Retries with backoff, then a dead-letter queue
POST to your endpoint
Full JSON payload delivered in one call
Why a thin push instead of pushing the whole email
- No public endpoint required. Anything that can hold a WebSocket open — a local dev server, a desktop app, a worker inside a private VPC — can now get inbound mail in real time.
- Lower latency than polling, no wasted requests. You learn about a message the moment it's stored instead of on your next poll interval, without hammering the API on a timer to find out nothing changed.
- You decide what's worth fetching. The notification carries enough to filter — sender, subject — so you only pay the cost of fetching a full body, and any attachments, for messages you actually care about.
- One source of truth either way. Push and webhook both resolve back to
GET /api/v1/inbound/:id, so nothing about the message's content depends on which delivery path told you it existed. - No new credential to manage. The stream reuses your existing project API key — nothing extra to generate, rotate or leak.
- Structural tenant isolation. Each project's connections are held in their own isolated hub, not merely gated by a channel name you're trusting to stay secret.
Trying it
Turn on inbound for a project, grab its API key from the console, and connect:
import WebSocket from "ws";
const ws = new WebSocket("wss://YOUR_INBOUND_HOST/v1/inbound/stream", {
headers: { Authorization: `Bearer ${process.env.MAILFLO_API_KEY}` },
});
ws.on("message", async (raw) => {
// Thin push: just enough to decide whether you care.
const { id, from_email, subject } = JSON.parse(raw.toString());
const res = await fetch(`https://console.mailflo.dev/api/v1/inbound/${id}`, {
headers: { Authorization: `Bearer ${process.env.MAILFLO_API_KEY}` },
});
const { data: email } = await res.json();
console.log(email.subject, email.text); // full body, fetched on demand
});Your webhook URL, if you have one set, keeps working unchanged — the two paths run side by side, and you can lean on whichever one fits a given consumer.
Want inbound email on your own domain?
Apply for early access and get a project set up in minutes.
Request an invite