Inbound Email Webhook: Receive Email as JSON
Short answer: An inbound email webhook turns every message sent to an address on your domain into an HTTPS POST to your app, with the sender, subject, text and HTML bodies, headers and attachments as JSON. You don't run a mail server, poll an IMAP inbox or parse MIME yourself. On NameOcean it is part of the free email forwarding for domains registered with us: point an address such as inbound@example.com at your endpoint, and verify each request with its HMAC signature. This guide shows the setup, the JSON you receive, and a tested receiver in Python and Node.js.
What you can build with it
- Support inboxes: turn mail to
support@into tickets in your own database. - Reply by email: let users answer a notification by replying, and attach the answer to the right thread with
in_reply_to. - Documents and invoices: take the PDF attachments sent to
invoices@and process them. - AI agents: give an agent its own address and let it read and act on what arrives.
Got an idea? Find the perfect domain for it
Our AI domain name generator suggests available names across hundreds of extensions. Free SSL, DNS and Whois privacy included.
How it works
- Your domain's MX record points to NameOcean's forwarding servers (turning forwarding on writes it).
- Mail arrives for
inbound@example.com. Instead of forwarding it to another inbox, we parse it. - We POST it as JSON to your HTTPS URL, signed with a secret only you and we know.
- Your app checks the signature, stores the message and answers with any 2xx status.
Set it up
You need a domain registered at NameOcean that uses our nameservers. Mail to webhook is free, like forwarding; we open it after a short review, so ask for it on the domain's Email Forwarding page with a line on what you will use it for. Then, in the dashboard or with an API key that has the email:write permission:
# turn on forwarding for the domain (writes the MX record)
curl -X POST https://nameocean.net/api/v1/email/forwarding/example.com \
-H "Authorization: Bearer $NAMEOCEAN_API_KEY"
# post mail for inbound@example.com to your app
curl -X POST https://nameocean.net/api/v1/email/forwarding/example.com/webhooks \
-H "Authorization: Bearer $NAMEOCEAN_API_KEY" -H "Content-Type: application/json" \
-d '{"localpart": "inbound", "url": "https://app.example.com/hooks/mail"}'
The answer to the second call includes the webhook's signing secret. Keep it in your app's environment as NAMEOCEAN_WEBHOOK_SECRET. A domain can have up to three webhook addresses, apart from its five forwarding addresses, and a new secret can be issued at any time.
The JSON you receive
POST /hooks/mail HTTP/1.1
Host: app.example.com
Content-Type: application/json
User-Agent: NameOcean-Webhook/1 (+https://nameocean.net)
X-NameOcean-Event: email.received
X-NameOcean-Delivery: 6f1c2a4e-8b3d-4f5a-9c7e-1d2b3a4c5e6f
X-NameOcean-Signature: t=1794300000,v1=5f2b…
{
"id": "6f1c2a4e-8b3d-4f5a-9c7e-1d2b3a4c5e6f",
"event": "email.received",
"received_at": "2026-11-12T09:20:00+00:00",
"envelope": {"from": "alice@example.org", "to": "inbound@example.com"},
"from": "Alice <alice@example.org>",
"to": "inbound@example.com",
"cc": "",
"reply_to": "",
"subject": "Order 1042",
"date": "Thu, 12 Nov 2026 09:19:58 +0000",
"message_id": "<abc123@example.org>",
"in_reply_to": "",
"references": "",
"text": "Hi, the invoice is attached.",
"html": "<p>Hi, the invoice is attached.</p>",
"headers": [["From", "Alice <alice@example.org>"], ["Subject", "Order 1042"], …],
"attachments": [{"filename": "invoice.pdf", "content_type": "application/pdf",
"content_id": null, "size": 48211, "content_base64": "JVBERi0…"}],
"spam_score": 0.4,
"size": 66120
}
| Field | What it holds |
|---|---|
id | Unique per message, and the same on every retry: use it to skip duplicates. |
envelope | The SMTP sender and the address on your domain that received the mail. |
from, to, cc, reply_to, subject, date | The message headers, decoded to plain text. |
message_id, in_reply_to, references | For threading replies. |
text, html | The plain-text and HTML bodies (empty when the mail has none). |
headers | Every header as a [name, value] pair, in order. |
attachments | Filename, content type, content_id for inline images, size, and the file in content_base64. |
spam_score, size | The spam filter's score and the raw message size in bytes. |
Verify the signature
Anyone who finds your URL can POST to it, so check every request. The X-NameOcean-Signature header holds t, the Unix time of the request, and v1, the hex HMAC-SHA256 of t, a dot and the raw request body, keyed with your secret. Compute it over the raw bytes before parsing the JSON, compare in constant time, and refuse timestamps more than five minutes old so a captured request can't be replayed later.
Python (Flask)
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["NAMEOCEAN_WEBHOOK_SECRET"]
def verify(header: str, body: bytes, tolerance: int = 300) -> bool:
fields = dict(part.split("=", 1) for part in header.split(",") if "=" in part)
try:
ts = int(fields["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - ts) > tolerance:
return False
expected = hmac.new(SECRET.encode(), f"{ts}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, fields.get("v1", ""))
@app.post("/hooks/mail")
def inbound_mail():
if not verify(request.headers.get("X-NameOcean-Signature", ""), request.get_data()):
abort(401)
mail = request.get_json()
print(mail["id"], mail["envelope"]["from"], mail["subject"],
[a["filename"] for a in mail["attachments"]])
# save it or queue the work, then answer quickly
return "", 204
Node.js (Express)
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.NAMEOCEAN_WEBHOOK_SECRET;
const app = express();
function verify(header, rawBody, tolerance = 300) {
const fields = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const ts = Number(fields.t);
if (!ts || Math.abs(Date.now() / 1000 - ts) > tolerance) return false;
const expected = crypto.createHmac("sha256", SECRET).update(`${ts}.`).update(rawBody).digest();
const got = Buffer.from(fields.v1 || "", "hex");
return got.length === expected.length && crypto.timingSafeEqual(expected, got);
}
// the raw bytes are needed for the signature, so parse JSON yourself
app.post("/hooks/mail", express.raw({ type: "application/json", limit: "40mb" }), (req, res) => {
if (!verify(req.get("X-NameOcean-Signature") || "", req.body)) return res.sendStatus(401);
const mail = JSON.parse(req.body);
console.log(mail.id, mail.envelope.from, mail.subject, mail.attachments.map((a) => a.filename));
// save it or queue the work, then answer quickly
res.sendStatus(204);
});
app.listen(3000);
Both receivers were tested with a payload signed by the same code our forwarding servers use: a valid request gets 204, and a changed body, a missing or malformed header, or a ten-minute-old timestamp gets 401.
Retries and limits
- Answer within 20 seconds with any 2xx. Save the message and do slow work (AI calls, file processing) afterwards.
- Retries: a timeout, a connection error, a 5xx, 408 or 429 is retried for up to 6 hours, with the same
id. Any other 4xx is final, so don't answer 4xx for a temporary problem on your side. - HTTPS on a public address only: the URL must use
httpswith a valid certificate, and it must not resolve to a private or local address. - Size: messages up to 25 MB are accepted. Attachments arrive base64-encoded inside the JSON, so allow your server a request body of at least 40 MB.
- Volume: up to 500 messages a day per domain. Mail over the limit, and mail held as spam, is not posted; it shows in the domain's message log.
FAQ
Can I reply to the mail from my app?
Yes, with the email sending API on a mail hosting plan: send from an address on your domain and put the message_id you received in the In-Reply-To and References entries of headers, so the reply lands in the same thread.
Can one address both forward and post to a webhook?
No. An address either forwards to an inbox or posts to a webhook. Use two addresses, or forward a copy from your app.
Do I get spam too?
Mail our filter is sure about is held and not posted. The rest arrives with its spam_score, so your app can apply its own threshold.
Can I test without sending real mail?
Sign a sample payload with your secret the same way (HMAC-SHA256 of "<t>." + body) and POST it to your endpoint with curl. Then send one real mail to the address and check the message log in the dashboard.
Ready to try it? See the Email API documentation, or find a domain below and get forwarding and inbound webhooks with it.
Got an idea? Find the perfect domain for it
Our AI domain name generator suggests available names across hundreds of extensions. Free SSL, DNS and Whois privacy included.
Read in other languages: