developerAPIIMAPwebhooksemail

How to Receive Emails via API: Webhooks, IMAP, and Polling

Three approaches to receiving email programmatically — IMAP polling, webhooks, and API polling — with code examples, architecture guidance, and performance comparisons.

September 22, 2025·14 min read·Alex Chen
How to Receive Emails via API: Webhooks, IMAP, and Polling

Your application needs to react when an email arrives. Maybe it is parsing verification codes in an automated test. Maybe it is routing customer emails into a support ticket system. Maybe it is processing inbound data from a partner that communicates via email. Maybe it is powering a disposable email product where users need to see incoming messages in real time.

Whatever the use case, "receive email programmatically" boils down to three approaches: IMAP polling, webhooks, and API polling. Each has different tradeoffs in latency, complexity, and infrastructure requirements. Choosing the right approach depends on your latency tolerance, whether you control the server environment, and how many inboxes you need to monitor simultaneously.

This guide walks through all three approaches with working code, explains when each one fits best, and shows how they integrate with real email infrastructure.

Understanding the Problem Space

Before diving into solutions, it helps to understand why receiving email programmatically is different from sending it. Sending email is a push operation — your code initiates the action, and a success or failure response comes back within seconds. Receiving email is inherently asynchronous. An email might arrive at any time, from any sender, with any content. Your application needs a strategy for detecting new arrivals and processing them.

The Internet Message Access Protocol (IMAP), defined in RFC 3501, provides the standard mechanism for reading email from a server. IMAP was designed for exactly this purpose — multiple clients accessing a shared mailbox, with server-side message management. It supports searching, flagging, folder organization, and persistent connections. Understanding IMAP's capabilities is essential for building reliable email reception.

Webhooks, by contrast, are not part of the email protocol stack at all. They are an application-layer pattern where the email provider converts an email event into an HTTP request. This shifts the responsibility from "your code polls for new messages" to "the provider pushes new messages to your endpoint." Both patterns are valid; the right choice depends on your architecture.

Approach 1: IMAP Polling

IMAP is the standard protocol for reading email from a server. You connect to the IMAP server, search for messages, and download them. Every email client — Thunderbird, Outlook, Apple Mail — uses IMAP under the hood. For programmatic access, you do the same thing in code.

How It Works

  1. Open an IMAP connection to the mail server
  2. Select the inbox (or a specific folder)
  3. Search for messages matching your criteria (unseen, subject contains X, from Y)
  4. Fetch the matching messages
  5. Parse the content (headers, body, attachments)
  6. Repeat on an interval

This is a pull-based model. Your application periodically checks the inbox for new messages, processes anything that has arrived since the last check, and sleeps until the next iteration. The polling interval determines your latency — a 5-second interval means up to 5 seconds of delay before you see a new message.

Python Example

import imaplib
import email
import time
from email.header import decode_header

def poll_inbox(host, port, user, password, interval=5):
    """Continuously poll for new emails via IMAP."""
    imap = imaplib.IMAP4_SSL(host, port)
    imap.login(user, password)

    while True:
        imap.select("INBOX")
        status, messages = imap.search(None, "UNSEEN")

        for msg_id in messages[0].split():
            status, msg_data = imap.fetch(msg_id, "(RFC822)")
            raw = msg_data[0][1]
            msg = email.message_from_bytes(raw)

            subject = decode_header(msg["Subject"])[0][0]
            if isinstance(subject, bytes):
                subject = subject.decode()

            sender = msg.get("From")
            print(f"New email from {sender}: {subject}")

            # Process the email here — extract codes, route to app logic, etc.

        time.sleep(interval)

# Connect to a Reusable.Email managed inbox
poll_inbox(
    host="imap.reusable.email",
    port=993,
    user="[email protected]",
    password="your-password",
    interval=10,
)

Node.js Example

The same pattern using the imapflow library, which provides a modern async interface:

const { ImapFlow } = require("imapflow");
const { simpleParser } = require("mailparser");

async function pollInbox(config, interval = 5000) {
  const client = new ImapFlow({
    host: config.host,
    port: config.port,
    secure: true,
    auth: { user: config.user, pass: config.pass },
  });

  await client.connect();

  while (true) {
    const lock = await client.getMailboxLock("INBOX");
    try {
      for await (const message of client.fetch({ seen: false }, { source: true })) {
        const parsed = await simpleParser(message.source);
        console.log(`New email from ${parsed.from.text}: ${parsed.subject}`);
        // Process the email here
      }
    } finally {
      lock.release();
    }
    await new Promise((resolve) => setTimeout(resolve, interval));
  }
}

pollInbox({
  host: "imap.reusable.email",
  port: 993,
  user: "[email protected]",
  pass: "your-password",
});

Pros and Cons

Pros:

  • Standard protocol — works with any mail server, any language
  • No public endpoint required (your code initiates the connection)
  • Full access to all messages, folders, and metadata
  • Works with every Reusable.Email managed inbox ($3 one-time)
  • Well-tested libraries exist in every programming language
  • No vendor lock-in — switch providers without changing code

Cons:

  • Polling adds latency (seconds to minutes depending on interval)
  • Persistent connections need management (timeouts, reconnects)
  • Scaling to many inboxes requires connection pooling
  • Each connection consumes server resources

IMAP IDLE: Reducing Latency

Most IMAP servers support the IDLE command (defined in RFC 2177), which holds the connection open and pushes notifications when new mail arrives. This reduces latency from your polling interval to near-real-time, typically under a second.

# Using imapclient for IDLE support
from imapclient import IMAPClient

client = IMAPClient("imap.reusable.email", ssl=True)
client.login("[email protected]", "your-password")
client.select_folder("INBOX")

# Start IDLE mode — server will notify us of new messages
client.idle()
responses = client.idle_check(timeout=300)  # Wait up to 5 minutes
client.idle_done()

# Process any new messages indicated by responses
if responses:
    messages = client.search(["UNSEEN"])
    for msg_id in messages:
        raw = client.fetch([msg_id], ["RFC822"])
        # Parse and process

IMAP IDLE is the best option when you need near-real-time email reception without running a public webhook endpoint. The tradeoff is that you need a persistent connection, which requires handling network interruptions, timeouts, and reconnection logic.

Scaling IMAP Polling to Multiple Inboxes

When monitoring dozens or hundreds of inboxes simultaneously, connection management becomes the primary challenge. Each IMAP connection is a persistent TCP socket. Here is a pattern using Python's asyncio for concurrent polling:

import asyncio
from imapclient import IMAPClient

async def monitor_inbox(address, password, callback):
    """Monitor a single inbox using IDLE, with automatic reconnection."""
    while True:
        try:
            client = IMAPClient("imap.reusable.email", ssl=True)
            client.login(address, password)
            client.select_folder("INBOX")

            while True:
                client.idle()
                responses = client.idle_check(timeout=300)
                client.idle_done()

                if responses:
                    messages = client.search(["UNSEEN"])
                    for msg_id in messages:
                        raw = client.fetch([msg_id], ["RFC822"])
                        await callback(address, raw[msg_id][b"RFC822"])
        except Exception as e:
            print(f"Connection lost for {address}: {e}. Reconnecting...")
            await asyncio.sleep(5)

async def main():
    inboxes = [
        ("[email protected]", "pass1"),
        ("[email protected]", "pass2"),
        ("[email protected]", "pass3"),
    ]

    async def handle_email(address, raw):
        msg = email.message_from_bytes(raw)
        print(f"[{address}] New: {msg['Subject']}")

    tasks = [monitor_inbox(addr, pwd, handle_email) for addr, pwd in inboxes]
    await asyncio.gather(*tasks)

asyncio.run(main())

Approach 2: Webhooks

With webhooks, the email server pushes an HTTP request to your endpoint when a new message arrives. Your application does not poll — it listens. This is the event-driven approach.

How It Works

  1. Register a webhook URL with the email provider
  2. When email arrives, the provider sends an HTTP POST to your URL
  3. The POST body contains the message data (sender, subject, body, attachments)
  4. Your endpoint processes the data and returns a 200 response
  5. If your endpoint is down, the provider retries (with backoff)

Architecture

Email arrives → Reusable.Email → HTTP POST → Your endpoint → Application logic

This is event-driven. No polling loop, no persistent connections, no wasted cycles checking empty inboxes. The email provider does the work of watching for new messages and notifying you when they arrive.

Implementation Example

Here is a Flask-based webhook handler that processes incoming emails:

from flask import Flask, request, jsonify
import hmac
import hashlib

app = Flask(__name__)
WEBHOOK_SECRET = "your-webhook-secret"

@app.route("/webhook/email", methods=["POST"])
def handle_email():
    # Verify the webhook signature
    signature = request.headers.get("X-Webhook-Signature", "")
    expected = hmac.new(
        WEBHOOK_SECRET.encode(),
        request.data,
        hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(signature, expected):
        return jsonify({"error": "Invalid signature"}), 401

    data = request.json
    sender = data["from"]
    recipient = data["to"]
    subject = data["subject"]
    body = data.get("text", "")
    html = data.get("html", "")
    attachments = data.get("attachments", [])

    # Route based on recipient
    if "support" in recipient:
        create_support_ticket(sender, subject, body)
    elif "billing" in recipient:
        process_billing_email(sender, subject, body)
    elif "orders" in recipient:
        process_order_notification(sender, subject, body, attachments)

    return jsonify({"status": "processed"}), 200

if __name__ == "__main__":
    app.run(port=5000)

And the equivalent in Express.js:

const express = require("express");
const crypto = require("crypto");

const app = express();
app.use(express.json({ limit: "10mb" }));

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;

app.post("/webhook/email", (req, res) => {
  // Verify signature
  const signature = req.headers["x-webhook-signature"] || "";
  const expected = crypto
    .createHmac("sha256", WEBHOOK_SECRET)
    .update(JSON.stringify(req.body))
    .digest("hex");

  if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
    return res.status(401).json({ error: "Invalid signature" });
  }

  const { from, to, subject, text, html, attachments } = req.body;

  // Process the email
  console.log(`Email from ${from} to ${to}: ${subject}`);

  // Route to appropriate handler
  processInboundEmail({ from, to, subject, text, html, attachments });

  res.status(200).json({ status: "processed" });
});

app.listen(5000);

Considerations

You need a public endpoint. Your webhook URL must be reachable from the internet. In development, tools like ngrok can expose a local server. In production, this is your regular API infrastructure. If your application is serverless (AWS Lambda, Cloudflare Workers), webhooks are a natural fit since your handler only runs when an email arrives.

Handle retries. If your endpoint returns a non-200 response, the provider will retry. Your processing logic should be idempotent — processing the same email twice should not cause problems. Use the message ID as a deduplication key in your database.

Security. Validate that webhook requests actually come from your email provider. Check signatures, verify source IPs, or use webhook secrets. Never trust an unverified webhook payload — it could be a spoofed request attempting to inject malicious data into your system.

Payload size limits. Large emails with attachments can produce webhook payloads in the megabytes. Configure your web server to accept large request bodies and consider processing attachments asynchronously rather than in the webhook handler itself.

When to Use Webhooks

Webhooks are the right approach when:

  • You need real-time processing (no polling delay)
  • You are building a product where email is a core input (support tickets, inbound parsing)
  • You are already running a web server that can receive HTTP requests
  • You are on Reusable.Email's whitelabel tier, which supports webhooks for email events

The whitelabel tier ($30/month) includes webhook support, a REST API, and unlimited managed inboxes. For the full breakdown of on-demand credential provisioning, see On-Demand SMTP & IMAP Credentials.

Approach 3: API Polling

Some email providers expose a REST API for checking inboxes. Instead of connecting via IMAP, you make HTTP GET requests to an API endpoint that returns messages as JSON.

How It Works

  1. Make an HTTP request to the provider's API (e.g., GET /inboxes/{id}/messages)
  2. Parse the JSON response
  3. Process new messages
  4. Repeat on an interval

Example

import requests
import time

API_BASE = "https://api.example.com"
API_KEY = "your-api-key"
INBOX_ID = "inbox-123"

def poll_api(interval=10):
    last_seen_id = None

    while True:
        response = requests.get(
            f"{API_BASE}/inboxes/{INBOX_ID}/messages",
            headers={"Authorization": f"Bearer {API_KEY}"},
            params={"since": last_seen_id} if last_seen_id else {},
        )

        messages = response.json().get("messages", [])
        for msg in messages:
            print(f"New: {msg['subject']} from {msg['from']}")
            last_seen_id = msg["id"]
            # Process the message

        time.sleep(interval)

Pros and Cons

Pros:

  • Simple HTTP calls — no IMAP library needed
  • JSON responses are easy to parse
  • Works well with modern web frameworks and serverless functions
  • No persistent connections to manage

Cons:

  • Still polling (same latency tradeoffs as IMAP polling)
  • Provider-specific API — vendor lock-in
  • Rate limits on API calls
  • Not a standard protocol — switching providers means rewriting integration code

When to Use API Polling

API polling works well for lightweight integrations where adding an IMAP library feels heavyweight — serverless functions, simple scripts, or prototypes. For anything production-grade, IMAP or webhooks are more robust because they are based on standards rather than proprietary endpoints.

Choosing the Right Approach

Factor IMAP Polling IMAP IDLE Webhooks API Polling
Latency Seconds (configurable) Sub-second Real-time Seconds (configurable)
Requires public endpoint No No Yes No
Standard protocol Yes (IMAP) Yes (IMAP) No (HTTP) No (provider-specific)
Works with any mail server Yes Yes Provider-dependent Provider-dependent
Complexity Low Medium Medium (once set up) Low
Persistent connection Per poll Yes No Per poll
Best for Testing, batch Real-time monitoring Real-time products Simple scripts

For most developer use cases, IMAP polling is the starting point. It works with any managed inbox, requires no public infrastructure, and uses standard libraries. When you need real-time processing at scale, upgrade to webhooks on the whitelabel tier. The underlying inboxes are the same — only the reception mechanism changes.

How Reusable.Email Fits

All managed inboxes ($3 one-time) support IMAP. Connect to imap.reusable.email:993 with SSL/TLS, use any IMAP library, and read emails programmatically. This works for testing, CI/CD pipelines, and applications that can tolerate polling latency. For teams that just need quick manual testing, public inboxes provide instant access with no cost and no signup.

The whitelabel tier ($30/month) adds webhooks. When email arrives at any inbox under your domain, Reusable.Email sends an HTTP POST to your endpoint with the full message data. This is the approach for products that need real-time email processing — SaaS applications with per-user inboxes, support ticket systems, and email-powered workflows.

Both approaches use the same underlying inboxes. You can start with IMAP polling during development and upgrade to webhooks when you need real-time delivery. Your existing IMAP integration code continues to work alongside webhooks, giving you a fallback mechanism.

Common Patterns

Wait for Email in a Test

This is the most common pattern in automated testing. Your test triggers an action that sends email, then waits for the email to arrive:

def wait_for_email(imap, subject_match, timeout=30):
    start = time.time()
    while time.time() - start < timeout:
        imap.select("INBOX")
        _, msgs = imap.search(None, "UNSEEN")
        for mid in msgs[0].split():
            _, data = imap.fetch(mid, "(RFC822)")
            msg = email.message_from_bytes(data[0][1])
            if subject_match in msg["Subject"]:
                return msg
        time.sleep(2)
    raise TimeoutError(f"No email matching '{subject_match}'")

For a complete testing environment setup with CI/CD integration, see How to Build an Email Testing Environment with a Disposable Email API.

Extract a Verification Code

Many applications send verification codes via email. Here is a robust extraction pattern that handles various formats:

import re

def extract_code(msg):
    """Extract a verification code from an email body."""
    # Handle multipart messages
    if msg.is_multipart():
        for part in msg.walk():
            if part.get_content_type() == "text/plain":
                body = part.get_payload(decode=True).decode()
                break
        else:
            body = ""
    else:
        body = msg.get_payload(decode=True).decode()

    # Try common code patterns
    # 4-8 digit numeric codes
    match = re.search(r"\b(\d{4,8})\b", body)
    if match:
        return match.group(1)

    # Alphanumeric codes (e.g., "Your code: AB12CD")
    match = re.search(r"code[:\s]+([A-Z0-9]{6,})", body, re.IGNORECASE)
    if match:
        return match.group(1)

    return None

For email verification flows that use links rather than codes:

import re
from urllib.parse import urlparse

def extract_verification_link(msg, domain="yourapp.com"):
    """Extract a verification URL from an email body."""
    if msg.is_multipart():
        for part in msg.walk():
            if part.get_content_type() == "text/html":
                body = part.get_payload(decode=True).decode()
                break
        else:
            body = msg.get_payload(decode=True).decode()
    else:
        body = msg.get_payload(decode=True).decode()

    # Find all URLs in the email
    urls = re.findall(r'https?://[^\s<>"]+', body)

    # Filter for verification-related URLs on your domain
    for url in urls:
        parsed = urlparse(url)
        if domain in parsed.netloc and any(
            keyword in parsed.path.lower()
            for keyword in ["verify", "confirm", "activate", "token"]
        ):
            return url

    return None

Route Email to Application Logic

A complete webhook handler that routes emails to different parts of your application based on the recipient address:

# Webhook handler (Flask example)
from flask import Flask, request

app = Flask(__name__)

ROUTE_MAP = {
    "support": handle_support_email,
    "billing": handle_billing_email,
    "orders": handle_order_email,
    "feedback": handle_feedback_email,
}

@app.route("/webhook/email", methods=["POST"])
def handle_email():
    data = request.json
    recipient = data["to"]

    # Extract the local part (before @)
    local_part = recipient.split("@")[0]

    # Route to the appropriate handler
    for prefix, handler in ROUTE_MAP.items():
        if local_part.startswith(prefix):
            handler(data)
            return "", 200

    # Default: log unrouted emails
    print(f"Unrouted email to {recipient}: {data['subject']}")
    return "", 200

Error Handling and Reliability

Whichever approach you choose, email reception needs to handle failure gracefully. Email is inherently asynchronous and distributed, which means network failures, malformed messages, and timing issues are not edge cases — they are routine.

IMAP connection drops. Network interruptions will kill your IMAP connection. Wrap your polling loop in a reconnect handler that re-authenticates and resumes from where it left off. The UNSEEN flag on messages ensures you do not re-process emails you have already handled.

Webhook endpoint downtime. If your webhook endpoint is unreachable, the email provider will retry with exponential backoff. Design your handler to be idempotent — processing the same email twice should produce the same result, not duplicate entries in your database. Store the message ID and check for it before processing.

Malformed emails. Not every email follows RFC 5322 perfectly. Your parsing code should handle missing headers, invalid encoding, and unexpected MIME structures without crashing. Use try/except blocks around email parsing and log failures for investigation.

Rate limits. IMAP servers may limit the number of concurrent connections or operations per minute. For high-volume scenarios (many inboxes, frequent polling), use connection pooling and respect server limits.

# Robust IMAP polling with reconnection
def robust_poll(host, port, user, password, callback, interval=10):
    while True:
        try:
            imap = imaplib.IMAP4_SSL(host, port)
            imap.login(user, password)
            while True:
                imap.select("INBOX")
                _, msgs = imap.search(None, "UNSEEN")
                for mid in msgs[0].split():
                    try:
                        _, data = imap.fetch(mid, "(RFC822)")
                        msg = email.message_from_bytes(data[0][1])
                        callback(msg)
                        # Mark as seen to prevent reprocessing
                        imap.store(mid, "+FLAGS", "\\Seen")
                    except Exception as e:
                        print(f"Error processing message {mid}: {e}")
                time.sleep(interval)
        except (imaplib.IMAP4.error, ConnectionError, OSError) as e:
            print(f"Connection lost: {e}. Reconnecting in 5s...")
            time.sleep(5)

Performance Considerations

The performance characteristics of each approach differ significantly at scale:

Metric IMAP Polling (10s) IMAP IDLE Webhooks
Avg latency to detect new email 5s < 1s < 1s
Connections per inbox 1 per poll 1 persistent 0
Server load Moderate Low None (provider-side)
Bandwidth usage Higher (repeated searches) Low (push-based) Low (event-based)
Max inboxes per server (approx) 500-1000 1000-5000 Unlimited

For applications monitoring fewer than 50 inboxes, any approach works well. Above that, IMAP IDLE or webhooks become necessary to keep resource usage manageable.

What's Next

For a complete guide to email in development — testing, staging, per-user inboxes, and building email products — see Email API for Developers. For the specific use case of building automated test environments, read How to Build an Email Testing Environment with a Disposable Email API. If you are building a SaaS product that needs per-user email inboxes, the email inboxes for SaaS guide covers the architecture end to end.

FAQ

Which approach should I start with if I am building a new application?

Start with IMAP polling. It requires no public infrastructure, works with any managed inbox ($3 one-time), and uses standard libraries available in every language. You can always add webhooks later when your latency requirements tighten. The IMAP code you write during development continues to work as a fallback alongside webhooks.

Can I use webhooks and IMAP polling together?

Yes. Many production systems use webhooks as the primary reception mechanism and IMAP polling as a fallback. If your webhook endpoint goes down or a webhook delivery fails, the IMAP poller catches any missed messages. This belt-and-suspenders approach provides the highest reliability.

How do I test webhook handlers during local development?

Use a tunneling tool like ngrok to expose your local webhook endpoint to the internet. Start ngrok with ngrok http 5000, configure the resulting URL as your webhook endpoint in the Reusable.Email whitelabel dashboard, and your local handler will receive real email events. For automated testing, you can also mock the webhook payload and send it to your handler directly using curl or your testing framework.

What is the maximum email size I can receive via IMAP or webhooks?

IMAP has no practical size limit beyond server configuration — emails up to 25 MB (the common limit for most email providers) are handled normally. For webhooks, the payload includes the full message content, so ensure your web server accepts request bodies up to 25 MB. Reusable.Email managed inboxes accept messages up to 25 MB in size.

How do I handle emails with multiple attachments programmatically?

When using IMAP, parse the email's MIME structure using your language's email parsing library. Each attachment is a separate MIME part with a Content-Disposition header of "attachment". For webhooks, attachments are included in the payload as base64-encoded data or as download URLs, depending on the provider configuration. Process attachments asynchronously if they are large to avoid blocking your main processing loop.

Try it free

Get a disposable inbox in seconds

No sign-up required. Just visit an address and it's live. Works with any domain on reusable.email.

Open your inbox →