Some of the most important things that happen to your product never show up as a clean webhook. A customer replies to a notification instead of filing a ticket. A vendor emails an invoice. A tool you depend on has no API and just sends you a CSV. The event is real and it matters — it just arrives as a message sitting in an inbox, in prose, addressed to a human.
The webhook you wish you'd gotten doesn't exist. What you have is an inbound email. So the question is how to turn that inbound email into something the rest of your stack can actually act on.
the things that only arrive as email
Once you start looking, there are more of them than you'd think. The person who emails hello@ because they couldn't find your support form. The SaaS that emails a nightly report no endpoint will ever see. The partner who replies to a thread with the number you needed buried in paragraph three. The form on some no-code tool whose only output is “email me.” None of these come with a type field or a signature to verify. They come with a subject line and a wall of text.
And because they look like email, they get handled like email — by a human, eventually, if it doesn't get buried first. The signal is right there; it just never becomes data.
the webhook is just the front door
The standard fix is to point inbound email at a webhook. SendGrid, Postmark, Mailgun, SES — they'll all take a message sent to an address you control, parse it into from, subject, text, and any attachments, and POST that to a URL. That part is genuinely solved, and it's the part everyone reaches for first.
It also stops exactly where the work starts.
// the inbound-parse webhook everyone wires first
app.post("/inbound", upload.any(), async (req) => {
const { from, subject, text } = parseInbound(req.body); // sendgrid/ses hand you this
// ...and now what? you still have to, by hand, in here:
// resolve who 'from' is · summarize 'text' · strip the PII ·
// decide where it goes · write the row · ping the channel
await doEverythingElse(from, subject, text);
});
// parsing the email was the easy 5%. the endpoint is where the work starts.The parse was the easy five percent. You've got an unstructured message at an endpoint, and now you write the rest by hand: figure out who from actually is, boil the body down to something readable, strip the personal data before it lands anywhere shared, decide where it should go, write the row, ping the channel. In a handler. For every kind of message that might arrive. The inbound-parse webhook is delivery, not processing — it gets the email to your code and then wishes you luck.
an email is just an event with worse formatting
Here's the reframe that makes it easy. Once it's parsed, an inbound email isn't special. It's a payload — a sender, a subject, a body, some attachments — exactly like the events your SDK and your webhooks already produce, only messier. So stop treating the address as a special case and treat it as a source: the email lands, becomes a typed event, and walks the same pipeline as everything else.
Which means all the work you were about to hand-write is just steps. enrich turns the bare from into a name and the company behind the domain. summarize collapses a forwarded thread into the two lines a human actually needs. redact strips the phone number and masks the email before the message reaches a shared channel. classify reads it, if you need a label to route on. Same actions, same pipe — the email just happens to be where this one came in.
one inbound email, parsed and routed
Put it together and a forwarding address becomes a real ingestion point. Forward anything to it — or point an MX record at it — and each message runs the same path: parsed, deduped, enriched, summarized, cleaned, and fanned out to the places that should know. Here's the whole thing as the diagram you'd see in the app, or the file you commit. Flip between them.
representation
01source
02pipeline · 4 steps
- 01CTLdedupekey $event.message_id · within 24h
- 02ENRenrich.person$event.from → name + company
- 03ENRsummarize$event.body → two lines
- 04MUTredact.piiemail → mask · phone → drop, for Slack
03destinations · 3
- toslackSlackchannel#inbox
- tonotion.dbNotiontarget_id$env.NOTION_INBOX_DB
- towarehouse.pgPostgrestableevents.inbox
One message in, three shapes out. #inbox gets a short, readable heads-up with the sender resolved and the PII masked. Notion gets a row someone can triage. Postgres keeps the whole message, attachments and all, so nothing is lost even when the summary leaves something out. The thing that used to sit in an inbox until someone noticed is structured data the moment it arrives.
how ingestlayer does this
Inbound email is a first-class source. You get an address — or point your own domain's MX at it — and every message that lands becomes a typed event: from, subject, body, attachments. From there it flows into a pipeline like anything else. The enrich, summarize, and redact steps are the same ones every other event uses; nothing about them knows the event started as an email.
It's the same shape as sorting an inbox with a model or stripping PII before a channel sees it — an email is just one more thing that arrives with more volume than attention, and the job is to do the thinking once, in the pipe, before a human has to. The address is the only new part.
Turn an email address into a pipeline — forward one message and watch it come out the other side as a row, a summary, and a heads-up.