All posts
The honest SendGrid Inbound Parse alternative
Gabe 14 min read

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.

SendGrid Inbound Parse sender SendGridMX + parse POSTmultipart no sigsecure URL your handlerform + charset decode your app …plus re-decoding each text field per the charsets map, extracting attachment parts, and living with a 4xx/DNS POST getting dropped with no replay ← form decode, charset re-decode, attachment parts: yours MailKite sender MX edgeparse + auth JSON webhooksigned, retried, replayable your app the 18 lines below are the whole "your app" integration
Receiving one email: what you decode and operate with SendGrid Inbound Parse vs MailKite. Same input, one form parser and one charset map apart.

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:

  • Parsed text and html fields, not raw multipart MIME
  • A headers blob carrying the full original header set
  • SPF and dkim result fields on the sender
  • A spam_score when you turn on the spam check

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.

Decoding is opt-in, which makes it a bug factoryThe naive 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:

MX → mx.sendgrid.netyour domain's mail lands here SendGrid parses MIMEMIME decoded into form fields multipart POSTunsigned request to your app parse the formmulter or busboy, your code re-decode charsetseach text field back to UTF-8 extract attachmentspull file parts, store them your app logicfinally, act on the email Every box below the POST is yours to write and keep correct. On MailKite the blue box is the only box: one signed JSON webhook.
The SendGrid Inbound Parse receive pipeline, stage by stage. MailKite collapses every gray stage into one signed JSON webhook.

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:

There is no signature, and a 4xx is gone for goodInbound Parse doesn't sign its POST: no HMAC, no Ed25519, so you authenticate by keeping the URL secret or checking the 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 ParseMailKite inbound
Payload formatmultipart/form-data you parseSingle JSON webhook
Body decodingYou re-decode per charsets fieldtext/html pre-decoded to UTF-8
AttachmentsMultipart file parts, inlineMetadata + short-lived signed url
Sender authSPF/dkim fields, no DMARC verdictauth{spf,dkim,dmarc,spam} in payload
Signature on the POSTNone, secure the URL yourselfMailKite.verifyWebhook(sig, rawBody, secret)
On endpoint error5xx retried ~72h; 4xx/DNS dropped, no replayRetried with backoff, replayable from the dashboard
Free tierSendGrid’s free tier changed in 20253,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.

Discuss this post: Hacker News Share on X Share on LinkedIn

Related posts