The honest SendGrid Inbound Parse alternative
SendGrid Inbound Parse POSTs inbound mail to your endpoint as multipart/form-data: a headers blob, body text, attachment file parts, and a charsets map you must honor to re-decode each field to UTF-8. MailKite (which we build) delivers the same mail as one JSON webhook with decoded text/html, an auth{spf,dkim,dmarc,spam} object, and attachments as short-lived signed URLs. A fair comparison with working code for both sides.
Here is that difference in one picture: the same inbound email, and everything you decode and operate to receive it on each side. The rest of the post is the honest version of that diagram: where Inbound Parse genuinely wins, where the charsets map and the unsigned POST bite, and the 18 lines of working code that are the entire MailKite side.
Here is the entire MailKite side. Not a fragment: this runs as pasted on Node 18+, one dependency (npm install mailkite).
// The whole MailKite integration: verify, parse, act.
import { createServer } from "node:http";
import { MailKite } from "mailkite";
const SECRET = process.env.MAILKITE_WEBHOOK_SECRET ?? "whsec_demo_secret";
createServer(async (req, res) => {
let raw = "";
for await (const chunk of req) raw += chunk;
// signature check, replay window, constant-time compare: one call
if (!MailKite.verifyWebhook(req.headers["x-mailkite-signature"], raw, SECRET)) {
res.writeHead(401).end();
return;
}
const event = JSON.parse(raw); // parsed email: no form fields, no charset map, no multipart parts
if (event.type === "email.received") {
console.log(event.from.address, "·", event.subject, "·", event.text);
}
res.writeHead(200).end("ok");
}).listen(3000);
(Can’t take a dependency? The header is x-mailkite-signature: t=<ms>,v1=<hex>, where v1 is an HMAC-SHA256 over "<t>.<rawBody>" with your webhook secret and t is in milliseconds. You can hand-roll that with node:crypto, but the SDK call also handles the replay window and the constant-time compare, which is where hand-rolled versions usually go wrong. Details in webhook security.)
Where SendGrid wins, honestly
Inbound Parse is a real, widely-used feature and this is a comparison, not a hit piece. SendGrid moves enormous volumes of mail reliably, Inbound Parse has existed for years, and it does genuinely parse the MIME for you before it POSTs. What you actually get in the form:
If you’re already deep in SendGrid for outbound and inbound is a simple “email us and we’ll POST it to a script,” it works and you don’t need us. This post is for when that modest case grows teeth: the charset bug, the attachment mangling, and a dropped POST you never see.
What Inbound Parse actually gives you
The POST is multipart/form-data, and the charsets field is the tell. SendGrid hands you bytes plus a label per field ({"subject":"ISO-8859-1","text":"UTF-8"}), and re-decoding to UTF-8 is your job. Miss it and you get the canonical bug: a £ arriving as £, an emoji as mojibake, because the field was latin-1 or ISO-2022-JP and you read it as UTF-8. This is the single most-reported Inbound Parse pain, and it’s structural: the format makes correct decoding opt-in.
req.body.text compiles, passes your tests on ASCII mail, then corrupts the first message from a customer in Osaka or London. Every text field carries a charsets label you have to honor, on every message, forever.That decode is only one stage. The whole Inbound Parse pipeline puts every step after SendGrid’s own parse back on your side of the line:
Here’s the handler that does the charset dance right, which is more than reading req.body.text:
// SendGrid Inbound Parse: multipart/form-data → re-decode per-field charsets yourself.
import express from "express";
import multer from "multer";
import iconv from "iconv-lite";
const app = express();
const upload = multer(); // Parse POSTs multipart/form-data; attachments ride as file parts
app.post("/hooks/sendgrid", upload.any(), (req, res) => {
// There's no signature on the POST — Inbound Parse doesn't sign it. Secure the URL instead.
const charsets = JSON.parse(req.body.charsets || "{}"); // e.g. {"subject":"ISO-8859-1","text":"UTF-8"}
const decode = (field) =>
charsets[field] && charsets[field].toUpperCase() !== "UTF-8"
? iconv.decode(Buffer.from(req.body[field], "binary"), charsets[field])
: req.body[field];
console.log(decode("from"), "·", decode("subject"), "·", decode("text"));
// attachments: req.files — multipart parts you extract, re-decode the filenames of, and store
res.sendStatus(200);
});
app.listen(3000);
Then there’s the delivery contract, which is where a bad deploy quietly costs you mail:
SPF/sender_ip fields. (SendGrid's separate Event Webhook does support Ed25519; Inbound Parse just never got it.) Delivery is best-effort too: a 5xx is retried for up to 72 hours, but a 4xx or DNS error is treated as permanent and the message is dropped with nowhere to replay it from.Attachments are the other thing still yours to extract. They arrive as multipart file parts you pull from the request body, not a URL you fetch on demand, so a large file rides inline in the POST, and developers repeatedly report attachments arriving mislabeled, re-encoded, or with the wrong filename charset.
The comparison, no adjective inflation
| SendGrid Inbound Parse | MailKite inbound | |
|---|---|---|
| Payload format | multipart/form-data you parse | Single JSON webhook |
| Body decoding | You re-decode per charsets field | text/html pre-decoded to UTF-8 |
| Attachments | Multipart file parts, inline | Metadata + short-lived signed url |
| Sender auth | SPF/dkim fields, no DMARC verdict | auth{spf,dkim,dmarc,spam} in payload |
| Signature on the POST | None, secure the URL yourself | MailKite.verifyWebhook(sig, rawBody, secret) |
| On endpoint error | 5xx retried ~72h; 4xx/DNS dropped, no replay | Retried with backoff, replayable from the dashboard |
| Free tier | SendGrid’s free tier changed in 2025 | 3,000 msgs/mo, 100/day |
The through-line: SendGrid parses the body for you (credit where due), but you still own the form-parsing, the per-field re-decoding, the attachment extraction, and — because there’s no signature — the endpoint’s authentication. With MailKite that work is done before the webhook reaches you.
What the JSON looks like
Same inbound email, delivered parsed:
{
"id": "msg_2Hk9…",
"type": "email.received",
"from": { "address": "ada@example.com" },
"to": [{ "address": "support@myapp.ai" }],
"subject": "Re: invoice #1042",
"text": "Looks good — approved!",
"html": "<p>Looks good — approved!</p>",
"threadId": "<a1b2c3@mail.example.com>",
"auth": { "spf": "pass", "dkim": "pass", "dmarc": "pass", "spam": "ham" },
"attachments": [
{ "id": "msg_2Hk9…:0", "filename": "po.pdf", "contentType": "application/pdf",
"size": 18213, "url": "https://api.mailkite.dev/att/2Hk9…/0?exp=…&sig=…" }
]
}
No charsets field to reconcile — text and html are already UTF-8, so the £ is a £. Attachments are out of the payload as signed URLs. And the auth block matters the moment you act on an email: a forged From: is an authorization decision, and having SPF/DKIM/DMARC in the payload means you don’t infer trust from raw headers or trust blindly. Per-address routing is just a different address (or a catch-all) pointed at your webhook. The same 18-line handler exists for Python, Ruby, Go, PHP, and Java; see the receiving docs and webhook security.
Where I won’t overclaim
SendGrid is a mature, high-scale sending platform with deliverability tooling and a track record we’re not going to pretend to match on day one. If you’re already deep in SendGrid for outbound and Inbound Parse is a minor side feature that works, switching purely for inbound may not be worth it. My claim is narrower and specific: for the inbound direction — parsing, decoding, attachments, auth, and retries — MailKite hands you a finished JSON message and a signed, replayable delivery instead of a form to parse and an unsigned POST to guard. That’s the pain Inbound Parse is famous for, and it’s the one we built for.
If neither fits, the other managed options are Mailgun Routes (compared here), SES receiving via S3 + Lambda (compared here), and Cloudflare Email Routing as a receive-only primitive (why it can’t reply). The DIY path is a self-hosted Haraka or Postfix box feeding a MIME parser; the inbound pillar sketches what that actually takes to run.
FAQ
What is SendGrid Inbound Parse, exactly?
It’s SendGrid’s inbound feature: you MX a domain to mx.sendgrid.net, and mail to it is POSTed to your endpoint as multipart/form-data with the body, a headers blob, attachments as file parts, and a charsets map you honor to re-decode each text field to UTF-8.
Why do attachments and special characters get mangled with Inbound Parse?
Because decoding is opt-in on your side. Text fields carry a charsets map you must honor to re-decode to UTF-8, and attachments come as multipart parts you extract — both are easy to get subtly wrong, which produces £-style corruption and mislabeled files. MailKite hands you text/html already decoded and attachments as signed URLs.
Does SendGrid Inbound Parse sign its webhook?
No. Inbound Parse doesn’t sign the POST, so you secure the endpoint with a hard-to-guess URL or by checking the SPF/sender_ip fields. (SendGrid’s separate Event Webhook does support Ed25519 signature verification; Inbound Parse doesn’t.) MailKite signs every webhook and the SDK verifies it in one call.
What happens if my endpoint is down? A 5xx response is retried for up to 72 hours, but a 4xx or DNS error is treated as permanent and the message is dropped. MailKite retries with backoff and lets you replay a delivery from the dashboard, so a bad deploy doesn’t mean lost mail.
Should everyone switch off Inbound Parse? No. If Inbound Parse meets your needs and you’re invested in SendGrid for sending, stay. Switch when the parsing, decoding, attachment, and auth work has become your problem to maintain.
If Inbound Parse has you writing form parsers and chasing £ bugs, there’s a cleaner shape. Point a domain at MailKite and your next inbound email arrives as parsed JSON.
Related: Receiving email is the part nobody warns you about makes the full case for inbound, handling attachments without losing the £ sign goes deep on the encoding bug, and Cloudflare Email Routing can’t reply is the same comparison for Cloudflare.