Inbound Email Webhook: Receive Email as JSON

Inbound Email Webhook: Receive Email as JSON

Nov 12, 2026 email webhooks api developers email forwarding python node.js

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

  1. Your domain's MX record points to NameOcean's forwarding servers (turning forwarding on writes it).
  2. Mail arrives for inbound@example.com. Instead of forwarding it to another inbox, we parse it.
  3. We POST it as JSON to your HTTPS URL, signed with a secret only you and we know.
  4. 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
}
FieldWhat it holds
idUnique per message, and the same on every retry: use it to skip duplicates.
envelopeThe SMTP sender and the address on your domain that received the mail.
from, to, cc, reply_to, subject, dateThe message headers, decoded to plain text.
message_id, in_reply_to, referencesFor threading replies.
text, htmlThe plain-text and HTML bodies (empty when the mail has none).
headersEvery header as a [name, value] pair, in order.
attachmentsFilename, content type, content_id for inline images, size, and the file in content_base64.
spam_score, sizeThe 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 https with 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: