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.

The events
What does the inbound email webhook contain?
Email shares the envelope of every channel and adds three fields: subject, html and attachments.
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.
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.
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.
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.
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.
{
"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]"
}
}
}
}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.
- 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.
- 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.
- 03
Verify, store, reply
Check the webhook-signature header, upsert on eventId, fetch attachments from their url when you need them, reply with replyToMessageId.
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.// 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
}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.
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.
Invoices and documents in
Pull the PDF from attachments[].url and send it to your accounting or OCR pipeline.
Lead capture
Turn contact-form notifications or direct emails into CRM leads, keyed on the sender address.
One inbox with WhatsApp and SMS
The same handler takes WhatsApp and SMS events: only data.channel.type changes.
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.
| SendGrid Inbound ParseMX on a dedicated hostname | Postmark InboundInbound address or domain | Unlimited MessagingYour mailbox over IMAP, paid plans | |
|---|---|---|---|
| Address that receives | Any address on a hostname dedicated to parsing | [email protected], or forwarded to it | Your existing address |
| DNS change | MX record to mx.sendgrid.net | None with forwarding; DNS for inbound domain forwarding | None |
| Body format | multipart/form-data, or the raw MIME message | JSON; HTTP basic auth in the URL as protection | JSON, signed per Standard Webhooks |
| Thread ids | In the headers you parse | In the parsed headers | conversation.id (thread root) and replyTo, ready to use |
| Reply from the same address | Through SendGrid sending, set up separately | Through Postmark sending, set up separately | replyToMessageId, from your mailbox's own SMTP, copy in Sent |
| Price | Part of a SendGrid plan | Inbound on Pro, from $16.50 a month | Webhooks on Medium or Max; no per-email fee |
| WhatsApp and SMS in the same webhook | No | No | Yes, 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.
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.
Inbox only
The bridge watches INBOX. An email your provider files as spam, or that a filter moves to another folder, is never seen.
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.
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.
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
Sources
Sources & references
To check the information in this article for yourself:
- Setting up the Inbound Parse webhook (MX to mx.sendgrid.net, payload)Twilio SendGrid Docs
- Configure an inbound server (inbound address, JSON, forwarding)Postmark
- Pricing (inbound processing on Pro, from $16.50 a month)Postmark
- RFC 5322, Internet Message Format (Message-ID, In-Reply-To, References)IETF
- Sign in with app passwordsGoogle Account Help
- Standard Webhooks specificationstandardwebhooks.com
- Unlimited Messaging API reference - WebhooksUnlimited Messaging

Written by
Ilian · 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.
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.