Skip to content
Docs

How-to

Process inbound email with webhooks

Subscribe a webhook to email.received, verify each request, then fetch the body, headers and attachments of every received message with the API.

Updated Oct 1, 2026

This guide shows how to react to received email in your own code. Emailit notifies your endpoint with an email.received event, and your code fetches the full message with the API. The examples verify the signature, handle batches of events, download attachments and skip duplicates.

Before you begin

  • Inbound is set up and a test message shows up on the Incoming tab.
  • An API key with Full Access. Reading email content isn’t allowed with Sending Only keys. See API keys.
  • A public HTTPS endpoint that accepts POST requests. For local development, use a tunnel such as ngrok or Cloudflare Tunnel.

How the flow works

The webhook tells you that a message arrived. It doesn’t contain the body or attachments, which keeps requests small and lets you fetch content only when you need it.

email.received (one event in the request array)
{
  "event_id": "evt_2xGk9Tb4QmF6wN2cJpR7uZsE1kD",
  "type": "email.received",
  "object": {
    "id": "em_2xGk9Rz2NcV7bL4pWqH8sYdT5fA",
    "object": "email",
    "from": "ada@example.com",
    "to": "support@inbound.acme.com",
    "subject": "Question about order 1042",
    "created_at": "2026-10-01T09:14:05.317000+00:00"
  },
  "data": {
    "object": {
      "id": "em_2xGk9Rz2NcV7bL4pWqH8sYdT5fA",
      "object": "email",
      "from": "ada@example.com",
      "to": "support@inbound.acme.com",
      "subject": "Question about order 1042",
      "created_at": "2026-10-01T09:14:05.317000+00:00"
    }
  }
}

Use data.object.id with Retrieve an email to get the parsed message:

GET /v2/emails/em_2xGk9Rz2NcV7bL4pWqH8sYdT5fA (abridged)
{
  "object": "email",
  "id": "em_2xGk9Rz2NcV7bL4pWqH8sYdT5fA",
  "type": "inbound",
  "status": "received",
  "from": "ada@example.com",
  "to": "support@inbound.acme.com",
  "subject": "Question about order 1042",
  "message_id": "<CAF3x1@mail.example.com>",
  "headers": {
    "From": "Ada Lovelace <ada@example.com>",
    "Reply-To": "ada@example.com",
    "Date": "Thu, 01 Oct 2026 09:14:02 +0000"
  },
  "body": {
    "text": "Hi, my order hasn't arrived yet...",
    "html": "<p>Hi, my order hasn't arrived yet...</p>"
  },
  "attachments": [
    {
      "filename": "receipt.pdf",
      "content_type": "application/pdf",
      "size": 48213,
      "content_id": null,
      "content_disposition": "attachment",
      "content": "JVBERi0xLjcKJcfsj6IK..."
    }
  ]
}

Attachment content is Base64-encoded. Header names keep their original casing, and when a header appears more than once (such as Received), only the last value is kept. If you only need part of the message, use the narrower endpoints: Retrieve the body, List attachments, or Retrieve raw MIME for the original source.

Create the webhook

  1. Add the webhook. Go to Email APIWebhooks, select Add webhook, enter a name and your endpoint URL, and select Create.

  2. Copy the secret. The dialog shows the webhook secret (whsec_…) once. Store it as EMAILIT_WEBHOOK_SECRET in your app’s environment.

  3. Subscribe to email.received only. On the webhook’s Settings tab, turn off All events, select email.received under Emails, and select Save.

On Pro, Business and Custom plans you can add a payload filter so the webhook only receives mail for some addresses, for example to ends with @inbound.acme.com.

Write the handler

Each request body is a JSON array of up to 100 events. The handler below verifies the signature, loops over the array, skips anything that isn’t email.received or was already processed, and fetches each message.

server.js
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const WEBHOOK_SECRET = process.env.EMAILIT_WEBHOOK_SECRET; // whsec_...
const API_KEY = process.env.EMAILIT_API_KEY; // secret_..., Full Access
const processed = new Set(); // use a database table with a unique key in production

function verify(rawBody, signature, timestamp) {
  if (!signature || !timestamp) return false;
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!(age <= 300)) return false; // 5-minute tolerance
  const expected = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

async function fetchEmail(id) {
  const res = await fetch(`https://api.emailit.com/v2/emails/${id}`, {
    headers: { Authorization: `Bearer ${API_KEY}` },
  });
  if (!res.ok) throw new Error(`Emailit API returned ${res.status}`);
  return res.json();
}

async function handleReceived(event) {
  const email = await fetchEmail(event.data.object.id);
  console.log(`From ${email.from} to ${email.to}: ${email.subject}`);
  console.log(email.body.text ?? email.body.html);

  for (const file of email.attachments ?? []) {
    const bytes = Buffer.from(file.content, 'base64');
    console.log(`Attachment ${file.filename} (${file.content_type}, ${bytes.length} bytes)`);
  }
}

// Keep the raw body: the signature is computed over the exact bytes.
app.post('/webhooks/emailit', express.raw({ type: 'application/json' }), async (req, res) => {
  if (!verify(req.body, req.get('X-Emailit-Signature'), req.get('X-Emailit-Timestamp'))) {
    return res.status(401).send('Invalid signature');
  }

  const events = JSON.parse(req.body.toString('utf8'));
  try {
    for (const event of events) {
      if (event.type !== 'email.received' || processed.has(event.event_id)) continue;
      await handleReceived(event);
      processed.add(event.event_id);
    }
    res.sendStatus(200);
  } catch (err) {
    console.error(err);
    res.sendStatus(500); // Emailit retries the whole batch later
  }
});

app.listen(3000);

Handle retries and duplicates

  • Respond within 30 seconds. Emailit waits up to 30 seconds for a 2xx response. A timeout, a non-2xx status or a redirect counts as a failure, and the whole batch is retried on the retry schedule. If fetching and processing can take longer, store the events in a queue, return 200, and process them in a background job.
  • Deduplicate by event_id. A retried batch contains every event in it again, including ones you already handled before the failure. Record each event_id after processing and skip IDs you’ve seen. Each received message also has its own email ID, which you can use as a second key.
  • Acknowledge events you don’t handle. Return 2xx even when a batch only contains event types you ignore. Otherwise Emailit keeps retrying them.
  • Fetch content promptly. Message contents are kept for a limited time that depends on your plan (7 days on Pay as you go). After that, the API returns the email without its body or attachments. See Data retention.

Route messages by address

Because every local part is accepted, you can encode information in the address and read it back from to. For example, send notifications with reply_to set to reply+4821@inbound.acme.com, then route replies to ticket 4821:

JavaScript
const match = email.to.match(/^reply\+(\d+)@inbound\.acme\.com$/i);
if (match) {
  await addReplyToTicket(match[1], email.body.text);
}

Verify it worked

  1. Send a message to an address on your inbound subdomain.
  2. On the webhook’s Requests tab, the email.received request shows Delivered.
  3. Your application logs the sender, subject and any attachments.

If the request shows Attempting or Failed, select View on a failed row to see the status code and response body your endpoint returned. See Retries and failures.

Was this page helpful?

Thanks for the feedback.

Thanks, we read every message.