# 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.

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](/docs/inbound/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](/docs/developers/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.

```json title="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](/docs/api-reference/emails/get/) to get the parsed message:

```json title="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](/docs/api-reference/emails/body/), [List attachments](/docs/api-reference/emails/attachments/), or [Retrieve raw MIME](/docs/api-reference/emails/raw/) for the original source.

## Create the webhook

**Dashboard**

  1. **Add the webhook.** Go to **Email API → Webhooks**, 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**.

**API**

  Call [Create a webhook](/docs/api-reference/webhooks/create/) with the event list. The response includes the `secret`.

```bash
curl https://api.emailit.com/v2/webhooks \
  -H "Authorization: Bearer $EMAILIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Inbound mail",
    "url": "https://acme.com/webhooks/emailit",
    "events": ["email.received"]
  }'
```

On Pro, Business and Custom plans you can add a [payload filter](/docs/webhooks/set-up/#filter-events-by-payload) 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](/docs/webhooks/request-signature/), loops over the array, skips anything that isn't `email.received` or was already processed, and fetches each message.

**Node.js**

```javascript title="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);
```

**Python**

```python title="app.py"
import base64
import hashlib
import hmac
import json
import os
import time

import requests
from flask import Flask, abort, request

app = Flask(__name__)
WEBHOOK_SECRET = os.environ["EMAILIT_WEBHOOK_SECRET"].encode()  # whsec_...
API_KEY = os.environ["EMAILIT_API_KEY"]  # secret_..., Full Access
processed = set()  # use a database table with a unique key in production

def verify(raw_body: bytes, signature: str | None, timestamp: str | None) -> bool:
    if not signature or not timestamp:
        return False
    try:
        if abs(time.time() - int(timestamp)) > 300:  # 5-minute tolerance
            return False
    except ValueError:
        return False
    signed = timestamp.encode() + b"." + raw_body
    expected = hmac.new(WEBHOOK_SECRET, signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

def fetch_email(email_id: str) -> dict:
    response = requests.get(
        f"https://api.emailit.com/v2/emails/{email_id}",
        headers={"Authorization": f"Bearer {API_KEY}"},
        timeout=10,
    )
    response.raise_for_status()
    return response.json()

def handle_received(event: dict) -> None:
    email = fetch_email(event["data"]["object"]["id"])
    print(f"From {email['from']} to {email['to']}: {email['subject']}")
    print(email["body"]["text"] or email["body"]["html"])

    for attachment in email.get("attachments") or []:
        data = base64.b64decode(attachment["content"])
        print(f"Attachment {attachment['filename']} ({len(data)} bytes)")

@app.post("/webhooks/emailit")
def emailit_webhook():
    raw_body = request.get_data()  # raw bytes, before any JSON parsing
    if not verify(
        raw_body,
        request.headers.get("X-Emailit-Signature"),
        request.headers.get("X-Emailit-Timestamp"),
    ):
        abort(401)

    for event in json.loads(raw_body):
        if event["type"] != "email.received" or event["event_id"] in processed:
            continue
        handle_received(event)  # an exception returns 500 and Emailit retries
        processed.add(event["event_id"])

    return "", 200
```

**PHP**

```php title="webhook.php"
<?php
$webhookSecret = getenv('EMAILIT_WEBHOOK_SECRET'); // whsec_...
$apiKey = getenv('EMAILIT_API_KEY'); // secret_..., Full Access

// Dedupe table: CREATE TABLE processed_events (event_id TEXT PRIMARY KEY)
$db = new PDO('sqlite:' . __DIR__ . '/events.sqlite');

$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_EMAILIT_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_EMAILIT_TIMESTAMP'] ?? '';

$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $webhookSecret);
$fresh = ctype_digit($timestamp) && abs(time() - (int) $timestamp) <= 300;
if (!$fresh || !hash_equals($expected, $signature)) {
    http_response_code(401);
    exit('Invalid signature');
}

function fetchEmail(string $id, string $apiKey): array
{
    $ch = curl_init('https://api.emailit.com/v2/emails/' . rawurlencode($id));
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => ["Authorization: Bearer {$apiKey}"],
        CURLOPT_TIMEOUT => 10,
    ]);
    $body = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($status !== 200) {
        throw new RuntimeException("Emailit API returned {$status}");
    }
    return json_decode($body, true);
}

try {
    foreach (json_decode($rawBody, true) as $event) {
        if ($event['type'] !== 'email.received') {
            continue;
        }
        $seen = $db->prepare('SELECT 1 FROM processed_events WHERE event_id = ?');
        $seen->execute([$event['event_id']]);
        if ($seen->fetchColumn()) {
            continue;
        }

        $email = fetchEmail($event['data']['object']['id'], $apiKey);
        error_log("From {$email['from']} to {$email['to']}: {$email['subject']}");
        foreach ($email['attachments'] ?? [] as $attachment) {
            $bytes = base64_decode($attachment['content']);
            file_put_contents(sys_get_temp_dir() . '/' . basename($attachment['filename']), $bytes);
        }

        $db->prepare('INSERT INTO processed_events (event_id) VALUES (?)')
           ->execute([$event['event_id']]);
    }
    http_response_code(200);
} catch (Throwable $e) {
    error_log($e->getMessage());
    http_response_code(500); // Emailit retries the whole batch later
}
```

## 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](/docs/webhooks/retries-and-failures/). 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](/docs/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](/docs/webhooks/retries-and-failures/).

## Related

  - [Verify webhook signatures](/docs/webhooks/request-signature/)
  - [Webhook request format](/docs/webhooks/webhook-requests/)
  - [Forward with automations](/docs/inbound/forward-with-automations/)
  - [Retrieve an email](/docs/api-reference/emails/get/)

---
Source: https://emailit.com/docs/inbound/process-with-webhooks/
