Skip to main content

Inbound email webhook

Turn the emails in your own inbox
into parsed JSON on your server.

Inbound parse services want your MX records or a forwarding address. Connect the mailbox you already use instead: each new email arrives at your URL with its subject, HTML, attachments and thread, and you reply from that same address.

In a hurry? Summarize this article with an AI

The short answer

How do you send incoming emails to a webhook?

Connect the mailbox itself. Unlimited Messaging logs into your existing mailbox over IMAP (Gmail with an app password, your own domain, any IMAP host), watches its inbox, and posts every new email to your URL as a signed JSON event, message.received, with subject, text, HTML, attachments and thread ids. Your MX records stay where they are, unlike SendGrid Inbound Parse. Webhooks are on the paid Medium and Max plans.

Inbound parse services sit in front of the mail. SendGrid asks you to point the MX record of a dedicated hostname at mx.sendgrid.net, then posts multipart/form-data (or the raw MIME) to your URL. Postmark gives each server an address like [email protected], which you forward to from Gmail or route with inbound domain forwarding, and its inbound processing comes with the Pro plan, from $16.50 a month. Either way the address your customers write to changes, or its mail is relayed.

Unlimited Messaging reads the mailbox your customers already write to. A bridge keeps one IMAP IDLE connection on its inbox, read-only, so a new email is picked up as soon as the server announces it. The email is stored, its attachments too (up to 20 per email), and your webhook receives one JSON body: externalId is the Message-ID, conversation.id the root of its thread, replyTo the message it answers.

The reply closes the loop: POST /message with replyToMessageId sends from the same mailbox, through its own SMTP server, with Re: and the In-Reply-To and References headers, and files a copy in its Sent folder. The thread stays one thread in your customer's mail app.

Before you connect: set up Gmail over IMAP with an app password, and why sending from your own mailbox matters.

Illustration: a grid of small icons with one highlighted, with the headline “Your inbox, as JSON”.

The events

What does the inbound email webhook contain?

Email shares the envelope of every channel and adds three fields: subject, html and attachments.

  1. message.received

    A new email landed in the inbox. content is the text body, or the HTML converted to text when the email has no text part; html is the body as received, untrusted, to sanitise before you render it.

  2. attachments

    Every file in order, up to 20 per email: url (the stored copy), filename, mimeType, size, and contentId for an inline image the HTML references as cid:. The first one is also mirrored on mediaUrl.

  3. Threading

    externalId is the RFC 5322 Message-ID without angle brackets. conversation.id is the thread root (first References entry, else In-Reply-To, else the email itself), so every email of a thread shares it.

  4. message.sent and message.status

    Your replies are announced as message.sent, and a hard bounce turns into message.status UNDELIVERABLE. The bounced address is then paused for 30 days on that mailbox to protect its reputation.

  5. account.status_changed

    The mailbox disconnected, for instance because the server refused the credentials after a password change. Subscribe to it so a silent inbox gets noticed.

POST https://example.com/webhooks/umJSON
{
  "type": "message.received",
  "eventId": "whd_9a4f2e61-7c3b-4d85-b0e9-5f1a8c2d7e34",
  "occurredAt": "2026-11-09T14:05:12.000Z",
  "data": {
    "message": {
      "id": "msg_3f8a1c2e-9d4b-4e7a-8b6c-2d1e0f9a8b7c",
      "externalId": "[email protected]",
      "direction": "IN",
      "fromMe": false,
      "content": "Hi, please find the signed quote attached. Claire",
      "sentAt": "2026-11-09T14:05:12.000Z",
      "receivedAt": "2026-11-09T14:05:12.000Z",
      "sender": "[email protected]",
      "senderId": "[email protected]",
      "senderName": null,
      "participant": null,
      "replyTo": { "externalId": "[email protected]", "participant": null },
      "mediaUrl": "https://api.unlimitedmessaging.app/message/media/stored/4c1d...",
      "mediaType": "document",
      "mimeType": "application/pdf",
      "mediaFilename": "quote-1042-signed.pdf",
      "transcript": null,
      "subject": "Re: Quote #1042",
      "html": "<p>Hi, please find the signed quote attached.</p><p>Claire</p>",
      "attachments": [
        {
          "url": "https://api.unlimitedmessaging.app/message/media/stored/4c1d...",
          "filename": "quote-1042-signed.pdf",
          "mimeType": "application/pdf",
          "size": 184320,
          "contentId": null
        }
      ]
    },
    "channel": {
      "type": "EMAIL",
      "account": { "id": "acc_8b2e4f6a-1c3d-4e5f-a7b9-0c1d2e3f4a5b", "name": "[email protected]" },
      "conversation": {
        "id": "[email protected]",
        "isGroup": false,
        "address": "[email protected]"
      }
    }
  }
}
A real message.received body for an email, built from the published schema (media URLs shortened). sender is the From address; senderName is not filled for email.

Set it up

How do you parse incoming emails in Next.js?

Connect the mailbox, register an endpoint, verify each POST. No MIME parser, no IMAP library in your code.

  1. 01

    Connect your mailbox

    In the web app, add an email account with the address and its password (an app password for Gmail). IMAP and SMTP settings are detected from the address.

  2. 02

    Register the endpoint

    POST /webhooks with your URL and eventTypes, or add it on the Webhooks page of the dashboard. The secret (whsec_...) is shown once.

  3. 03

    Verify, store, reply

    Check the webhook-signature header, upsert on eventId, fetch attachments from their url when you need them, reply with replyToMessageId.

POST /webhookscurl
curl https://api.unlimitedmessaging.app/webhooks \
  -H "Authorization: Bearer $UM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/um",
    "eventTypes": ["message.received", "account.status_changed"],
    "description": "Inbound email"
  }'

# The response carries "secret": "whsec_..." once. Store it as UM_WEBHOOK_SECRET.
Add accountIds to scope the endpoint to one mailbox, for instance support@ but not billing@. Without it, every account is covered.
app/api/webhooks/um/route.tsNext.js route handler
// app/api/webhooks/um/route.ts (Next.js App Router)
import crypto from 'node:crypto';
import { UnlimitedMessagingApiClient } from '@unlimited-messaging/sdk';

const um = new UnlimitedMessagingApiClient({ token: process.env.UM_API_KEY! });
const key = Buffer.from(process.env.UM_WEBHOOK_SECRET!.replace(/^whsec_/, ''), 'base64');

// Standard Webhooks: HMAC-SHA256 of "id.timestamp.rawBody", base64.
function verify(raw: string, id: string, ts: string, header: string) {
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const expected = crypto.createHmac('sha256', key).update(`${id}.${ts}.${raw}`).digest('base64');
  return header.split(' ').some((entry) => {
    const sig = entry.startsWith('v1,') ? entry.slice(3) : '';
    return sig.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  });
}

export async function POST(req: Request) {
  const raw = await req.text(); // the raw body, before any JSON parsing
  const h = req.headers;
  if (!verify(raw, h.get('webhook-id') ?? '', h.get('webhook-timestamp') ?? '',
    h.get('webhook-signature') ?? '')) {
    return new Response('bad signature', { status: 401 });
  }

  const event = JSON.parse(raw);
  if (event.type === 'message.received' && event.data.channel.type === 'EMAIL') {
    const m = event.data.message;
    const thread = event.data.channel.conversation.id; // same id for the whole thread
    const files = m.attachments ?? []; // [{ url, filename, mimeType, size, contentId }]
    await saveTicket({ eventId: event.eventId, thread, from: m.sender, subject: m.subject,
      text: m.content, files }); // upsert on eventId: delivery is at least once

    // Answer in the same thread, from the same mailbox.
    await um.messages.messageSend({ replyToMessageId: m.id, text: 'Thanks, received. We reply within a day.' });
  }
  return new Response(null, { status: 200 }); // within 10 s; queue slow work
}
req.text() keeps the raw body the signature is computed over (Standard Webhooks, HMAC-SHA256 over id.timestamp.rawBody). saveTicket is yours. The reply goes out with Re: and the thread headers, from the same mailbox.

Vibe coding

Building with Cursor, Claude Code, Lovable or Bolt? Paste this

Your AI editor gets the route, the signature check and the email fields it needs, without inventing a MIME parser.

Prompt to copy
Add an Unlimited Messaging webhook to this app so incoming emails from my own mailbox become support tickets.

1. Create a POST route (Next.js: app/api/webhooks/um/route.ts) that reads the RAW body with req.text().
2. Verify the Standard Webhooks signature: key = base64-decode(UM_WEBHOOK_SECRET without "whsec_"); expected = base64(HMAC-SHA256(key, `${webhook-id}.${webhook-timestamp}.${rawBody}`)); accept if any space-separated entry of the webhook-signature header equals `v1,${expected}` (constant-time compare); reject timestamps more than 5 minutes off; return 401 otherwise.
3. On type "message.received" with data.channel.type "EMAIL": upsert a ticket keyed on body.eventId with data.channel.conversation.id (thread id), data.message.sender, data.message.subject, data.message.content (text) and data.message.attachments ([{ url, filename, mimeType, size, contentId }]). Treat data.message.html as untrusted: sanitise before rendering.
4. To answer, install @unlimited-messaging/sdk, read UM_API_KEY from env, and call `client.messages.messageSend({ replyToMessageId: data.message.id, text })`: it replies in the same thread from the same mailbox.
5. Return 200 within 10 seconds; queue slow work.
6. Put UM_API_KEY and UM_WEBHOOK_SECRET in .env and .env.example.

Docs: https://docs.unlimitedmessaging.app (Webhooks). Register with POST https://api.unlimitedmessaging.app/webhooks and eventTypes ["message.received"].

What people build

What can you build on inbound email?

Anything that today means someone copying an email into another tool.

A helpdesk on support@

One ticket per conversation.id, every reply appended, answers sent from support@ in the same thread. No ticketing address to publish.

AI triage and auto-reply

Give subject and content to an LLM, tag and route the email, or send a first answer with replyToMessageId and let a human take over.

  1. Invoices and documents in

    Pull the PDF from attachments[].url and send it to your accounting or OCR pipeline.

  2. Lead capture

    Turn contact-form notifications or direct emails into CRM leads, keyed on the sender address.

  3. One inbox with WhatsApp and SMS

    The same handler takes WhatsApp and SMS events: only data.channel.type changes.

  4. No-code flows

    Point the webhook at n8n, Make or Zapier and branch on subject or sender without a server.

The table

Inbound email to webhook: SendGrid vs Postmark vs Unlimited Messaging

Parse services receive mail for a new address or hostname. Unlimited Messaging reads the mailbox you already have.

Inbound email to webhook: SendGrid vs Postmark vs Unlimited Messaging
SendGrid Inbound ParseMX on a dedicated hostnamePostmark InboundInbound address or domainUnlimited MessagingYour mailbox over IMAP, paid plans
Address that receivesAny address on a hostname dedicated to parsing[email protected], or forwarded to itYour existing address
DNS changeMX record to mx.sendgrid.netNone with forwarding; DNS for inbound domain forwardingNone
Body formatmultipart/form-data, or the raw MIME messageJSON; HTTP basic auth in the URL as protectionJSON, signed per Standard Webhooks
Thread idsIn the headers you parseIn the parsed headersconversation.id (thread root) and replyTo, ready to use
Reply from the same addressThrough SendGrid sending, set up separatelyThrough Postmark sending, set up separatelyreplyToMessageId, from your mailbox's own SMTP, copy in Sent
PricePart of a SendGrid planInbound on Pro, from $16.50 a monthWebhooks on Medium or Max; no per-email fee
WhatsApp and SMS in the same webhookNoNoYes, same envelope

Checked on 9 November 2026 on SendGrid's Inbound Parse setup guide and Postmark's inbound server guide and pricing page (links in the sources).

The catch

What are the limits of a mailbox-to-webhook bridge?

Reading your own mailbox removes the DNS work. It brings the rules of that mailbox with it.

  1. Webhooks are a paid feature

    On the Free plan, incoming emails show up in the web app inbox, grouped by thread. Pushing them to your server needs Medium (€19 a month, or €17 a month billed yearly) or Max.

  2. Inbox only

    The bridge watches INBOX. An email your provider files as spam, or that a filter moves to another folder, is never seen.

  3. No Outlook or Microsoft 365 yet

    Outlook, Hotmail and Microsoft 365 require OAuth, which is planned but not available. Gmail works with an app password, which needs 2-Step Verification on the Google account.

  4. Up to 20 attachments per email

    Files beyond the 20th are not stored. The HTML is passed as received: sanitise it before you show it anywhere.

  5. At least once, not in order

    The same event can arrive twice, and events are not delivered in the order things happened: upsert on eventId and sort on sentAt.

FAQ

Questions about inbound email webhooks

Ilian, senior software engineer

Written by

· Senior Software Engineer

A senior software engineer, he founds and runs several tech companies. He builds products around messaging APIs, automation and AI integration, and shares here what he learns while building.

View LinkedIn profileUpdated

Your inbox, as clean JSON.

Create an account, connect your mailbox, and turn on webhooks with Medium or Max. Sending email stays free on every plan.