Resend inbound email
Receiving inbound email turns the messages your users send into structured data your app can act on: a reply becomes a support ticket, a forwarded PDF becomes a row in your database, an incoming message becomes a task for an agent. This guide walks through receiving email on Resend end to end, then shows the same flow on MailKite.
What you'll need
- A domain you control, with access to its DNS records.
- A public HTTPS endpoint to receive the webhook (a tunnel like ngrok works while testing).
- A Resend account and API key for the first half.
Part 1 — Receive inbound email with Resend
Inbound is a newer part of Resend, so treat its exact record values and API surface as whatever the receiving docs show at setup time — the details below are current as of this writing.
1. Point your domain at Resend
Add an MX record so mail for the domain is delivered to Resend.
Resend shows the exact value to use when you enable receiving on a domain;
the record must have the lowest priority so mail routes to Resend
rather than another server:
Host Type Priority Value
yourdomain.com MX 10 <shown in your Resend dashboard>
Once the record resolves, any address at that domain —
support@yourdomain.com, ada@yourdomain.com — will
flow into Resend. (You can also test against a
.resend.app address without touching DNS.)
2. Subscribe to the inbound event
Resend delivers inbound mail over its webhook system, which is signed with
Svix. In the dashboard, add a webhook
endpoint and subscribe it to the email.received event. Resend
then signs and POSTs to your URL every time a message arrives,
including the headers svix-id, svix-timestamp, and
svix-signature.
3. Handle the event and fetch the body
One thing to know up front: the email.received webhook carries
metadata only — email_id, from,
to/cc/bcc, subject, and
attachment descriptors. It does not include the message
body, headers, or attachment contents. You retrieve those with a follow-up
call to the Received Emails API:
{
"type": "email.received",
"created_at": "2024-02-22T23:41:12.126Z",
"data": {
"email_id": "a1b2c3d4-…",
"from": "ada@example.com",
"to": ["support@yourdomain.com"],
"cc": [],
"bcc": [],
"subject": "Can't update my card",
"attachments": [
{
"id": "att_abc123",
"filename": "screenshot.png",
"content_type": "image/png"
}
]
}
}
Verify the Svix signature over the raw body first — the Resend SDK's
webhooks.verify() helper does this using the three
svix-* headers and your webhook signing secret (you can also use
the standalone svix library). Then read the metadata and fetch
the full message:
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
export async function POST(req) {
const payload = await req.text(); // raw body — Svix signs the bytes
const event = resend.webhooks.verify({
payload,
headers: {
"svix-id": req.headers.get("svix-id"),
"svix-timestamp": req.headers.get("svix-timestamp"),
"svix-signature": req.headers.get("svix-signature"),
},
secret: process.env.RESEND_WEBHOOK_SECRET,
});
if (event.type === "email.received") {
const { email_id, from, subject } = event.data;
console.log("from", from, "·", subject);
// The webhook carries metadata only — no body, headers, or attachment
// content. Fetch the full message with a follow-up API call:
const full = await resend.emails.receiving.get(email_id);
const body = full.data?.text ?? full.data?.html;
// ...open a ticket, reply, hand to an agent.
}
return new Response("OK", { status: 200 });
}
That's receiving on Resend: an MX record at the lowest priority,
a Svix-signed email.received webhook, and a second API call per
message to pull the body and attachments.
Part 2 — The same flow on MailKite
When we built inbound into MailKite, we wanted three things to be true: one record to receive on your whole domain, a payload you don't have to fetch twice, and a webhook you can actually trust. Here's the same receive-a-reply flow.
1. Point your domain at MailKite
One MX record makes every address on the domain
receivable — no dedicated subdomain, no second record to prioritise:
Host Type Priority Value
yourdomain.com MX 10 mx.mailkite.dev The domain verifies as soon as that record resolves. See Domains & DNS for the SPF/DKIM/DMARC records that let you send from it too.
2. Register your webhook
Add your endpoint URL in the dashboard or via the
API and MailKite POSTs an email.received event
the moment mail arrives. Want specific addresses to hit specific endpoints?
Add a route — otherwise a catch-all sends the whole domain to one webhook.
3. Parse the payload
The message arrives already decoded — flat from,
to, subject, text, and html
fields, edge auth verdicts (SPF/DKIM/DMARC/spam), and attachments as
signed, time-limited links rather than IDs you have to redeem with a second
call:
{
"id": "msg_2Hk9…",
"type": "email.received",
"from": { "address": "ada@example.com", "name": "Ada Lovelace" },
"to": [{ "address": "support@myapp.ai", "name": "Support" }],
"subject": "Re: invoice #1042",
"text": "Looks good — approved!",
"html": "<p>Looks good — approved!</p>",
"threadId": "<a1b2c3@mail.example.com>",
"receivedAt": 1785196800000,
"receivedAtIso": "2026-07-28T00:00:00.000Z",
"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=…"
}
]
} Because the URL is public, MailKite signs every request. Verify the signature over the raw body first, then handle the event — one message per call, everything you need already in the payload:
import express from "express";
import { MailKite } from "mailkite";
const SECRET = process.env.MAILKITE_WEBHOOK_SECRET;
const app = express();
app.use("/hooks/mailkite", express.raw({ type: "application/json" }));
app.post("/hooks/mailkite", (req, res) => {
if (!MailKite.verifyWebhook(req.headers["x-mailkite-signature"], req.body, SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
// ...handle event.type === "email.received"
// Confirm receipt: returns the JSON body {"status":"ok"}.
res.type("application/json").send(MailKite.replyOk());
});import os
from flask import Flask, request, abort
from mailkite import verify_webhook, reply_ok
SECRET = os.environ["MAILKITE_WEBHOOK_SECRET"]
app = Flask(__name__)
@app.post("/hooks/mailkite")
def hook():
sig = request.headers.get("x-mailkite-signature", "")
if not verify_webhook(sig, request.get_data(), SECRET):
abort(401)
event = request.get_json()
# ...handle event["type"] == "email.received"
# Confirm receipt: returns the JSON body {"status":"ok"}.
return reply_ok(), 200, {"content-type": "application/json"}<?php
$mk = new \MailKite\Client(""); // no API key needed to verify
$secret = getenv("MAILKITE_WEBHOOK_SECRET");
$signature = $_SERVER["HTTP_X_MAILKITE_SIGNATURE"] ?? "";
$rawBody = file_get_contents("php://input");
if (!$mk->verifyWebhook($signature, $rawBody, $secret)) {
http_response_code(401);
exit;
}
$event = json_decode($rawBody, true);
// ...handle $event["type"] === "email.received"
header("content-type: application/json");
echo $mk->replyOk(); // {"status":"ok"}// Spring Boot
@PostMapping(value = "/hooks/mailkite", produces = "application/json")
public ResponseEntity<String> mailkite(
@RequestHeader("x-mailkite-signature") String signature,
@RequestBody byte[] rawBody) throws Exception {
MailKite mk = new MailKite(""); // no API key needed to verify
String body = new String(rawBody, StandardCharsets.UTF_8);
if (!mk.verifyWebhook(signature, body, System.getenv("MAILKITE_WEBHOOK_SECRET"))) {
return ResponseEntity.status(401).build();
}
// ...handle the event
return ResponseEntity.ok(mk.replyOk()); // {"status":"ok"}
}http.HandleFunc("/hooks/mailkite", func(w http.ResponseWriter, r *http.Request) {
rawBody, _ := io.ReadAll(r.Body)
sig := r.Header.Get("x-mailkite-signature")
if !mailkite.VerifyWebhook(sig, string(rawBody), secret) {
w.WriteHeader(http.StatusUnauthorized)
return
}
var event map[string]any
json.Unmarshal(rawBody, &event)
// ...handle event["type"] == "email.received"
w.Header().Set("content-type", "application/json")
w.Write([]byte(mailkite.ReplyOk())) // {"status":"ok"}
})require "sinatra"
require "json"
require "mailkite"
post "/hooks/mailkite" do
raw_body = request.body.read
sig = request.env["HTTP_X_MAILKITE_SIGNATURE"]
halt 401 unless Mailkite.verify_webhook(sig, raw_body, ENV["MAILKITE_WEBHOOK_SECRET"])
event = JSON.parse(raw_body)
# ...handle event["type"] == "email.received"
content_type :json
Mailkite.reply_ok # {"status":"ok"}
end
The verifyWebhook helper recomputes the HMAC and rejects forged
or stale events — see Verifying signatures
for the header format and a no-SDK version. Fields and attachment links are
documented in full under Inbound webhooks.
A little about MailKite
MailKite is programmable email for developers and AI agents. Publish one
MX record and every address on your domain becomes a signed
webhook of clean, decoded JSON — no IMAP, no polling, no MIME wrangling.
The same API sends mail, and any inbox can be handed straight to an
agent that reads and replies on its own.
Inbound and receiving are on the free tier.
Next: Inbound webhooks for routing, retries, and attachments, or the Quickstart to get a verified domain and API key.