Give the agent an inbox that pushes each new message to your code, then reply to the thread the message arrived in. On OpenMail that push is a message.received event, sent to your webhook or over a WebSocket. It carries the body text, the extracted text of any attachments and a thread_id. The agent reads the thread for context and calls POST /v1/inboxes/{id}/send with that threadId. OpenMail sets the In-Reply-To and References headers, so the person sees an ordinary reply in their mail client.
Homemade setups usually break at the same spots. The message lands somewhere the agent can't see, the content is one more API call away, or the reply opens a new conversation because nobody set the threading headers.
Webhook or WebSocket
| Feature | Webhook | WebSocket |
|---|---|---|
| How it works | OpenMail POSTs each event to an HTTPS URL you run | Your process holds a connection to wss://api.openmail.sh/v1/ws |
| Needs a public URL | Yes | No |
| If you miss something | OpenMail retries; deduplicate on event_id | Reconnect with last_event_id and up to 100 missed events are replayed |
| Good for | Agents running on a server | Agents on a laptop, a VPS or behind a firewall |
The event is the same either way, so you can start on a WebSocket locally and switch to a webhook in production without touching the agent.
Step 1: receive the event
Set the webhook URL account-wide in the console under Settings, or per inbox with webhookUrl when you create it. Every request carries X-Timestamp, X-Signature and X-Event-Id. Before trusting the body, compute HMAC-SHA256 over {timestamp}.{raw body} with your webhook secret and compare it to the signature. Also reject anything whose timestamp is more than five minutes old.
import crypto from "node:crypto";
import express from "express";
const app = express();
// express.raw, not express.json: the signature is over the exact bytes received
app.post("/webhooks/openmail", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.get("X-Timestamp");
const signature = req.get("X-Signature") ?? "";
const expected = crypto
.createHmac("sha256", process.env.OPENMAIL_WEBHOOK_SECRET)
.update(`${timestamp}.${req.body}`)
.digest("hex");
const valid =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!valid) return res.sendStatus(401);
queue.add(JSON.parse(req.body)); // run the agent outside the request
res.sendStatus(200); // within 15 seconds
});import hashlib, hmac
def verify(raw_body: bytes, timestamp: str, signature: str, secret: str) -> bool:
signed = timestamp.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)Answer with a 200 inside 15 seconds and do the agent's work in a queue. A model call can easily take longer than that, and a slow response gets retried. Keep the event_ids you've handled so a retry doesn't run the agent twice.
Here is what arrives:
{
"event": "message.received",
"event_id": "evt_abc123",
"occurred_at": "2026-10-14T09:12:03.000Z",
"delivered_at": "2026-10-14T09:12:04.000Z",
"attempt": 1,
"inbox_id": "inb_92ma4k",
"thread_id": "thr_xyz",
"message": {
"id": "msg_456",
"rfc_message_id": "<CAF7x2k@mail.example.com>",
"from": "vendor@example.com",
"to": "invoices@yourdomain.com",
"cc": [],
"subject": "Invoice 2847",
"body_text": "Hi, the September invoice is attached.",
"attachments": [{
"filename": "invoice-2847.pdf",
"contentType": "application/pdf",
"sizeBytes": 48211,
"url": "https://api.openmail.sh/v1/attachments/msg_456/invoice-2847.pdf",
"parsedText": "Invoice #2847\nDate: 30 September 2026\nAmount due: EUR 1,250.00",
"extractionMethod": "pdf"
}],
"raw_url": "https://api.openmail.sh/v1/messages/msg_456/raw",
"received_at": "2026-10-14T09:12:02.000Z"
}
}No endpoint? Open a WebSocket and subscribe instead.
import WebSocket from "ws";
let lastEventId = null; // store this somewhere durable in production
function connect(delay = 1000) {
const ws = new WebSocket("wss://api.openmail.sh/v1/ws", {
headers: { Authorization: `Bearer ${process.env.OPENMAIL_API_KEY}` },
});
ws.on("open", () => {
delay = 1000;
const subscribe = { type: "subscribe" };
if (lastEventId) subscribe.last_event_id = lastEventId; // replay what was missed
ws.send(JSON.stringify(subscribe));
});
ws.on("message", (data) => {
const event = JSON.parse(data);
if (event.event !== "message.received") return;
lastEventId = event.event_id;
handle(event);
});
ws.on("error", () => {}); // "close" follows and reconnects
ws.on("close", () => setTimeout(() => connect(Math.min(delay * 2, 30000)), delay));
}
connect();Step 2: give the agent the conversation
Before the agent answers, load the thread so it can see the earlier messages:
curl https://api.openmail.sh/v1/threads/thr_xyz/messages \
-H "Authorization: Bearer $OPENMAIL_AGENT_KEY"You get the thread's messages in order, each with direction (inbound or outbound), bodyText and its attachments, ready to go into the model's context.
New inbound threads start unread, and sending a reply marks the thread read. That gives you a safety net for anything the agent missed: GET /v1/inboxes/{id}/threads?isRead=false lists what nobody has answered yet. If the agent decides an email needs no reply, mark it read with PATCH /v1/threads/{id} and {"isRead": true}.
Threads are grouped the way mail clients do it. An incoming message joins a thread when its In-Reply-To or References header matches a message already there. Otherwise it starts a new one.
Step 3: reply in the thread
curl -X POST https://api.openmail.sh/v1/inboxes/inb_92ma4k/send \
-H "Authorization: Bearer $OPENMAIL_AGENT_KEY" \
-H "Idempotency-Key: reply-evt_abc123" \
-H "Content-Type: application/json" \
-d '{
"to": "vendor@example.com",
"threadId": "thr_xyz",
"body": "Got it, thanks. Payment goes out on Friday.",
"includeQuote": false
}'With a threadId you can leave out the subject; OpenMail uses the thread's subject with "Re:". By default the previous message is quoted under the reply, like a person's mail client would do. includeQuote: false sends only the agent's text. Building the Idempotency-Key from the event ID means a retried event can't send the same reply twice. Keys are remembered for 24 hours.
Attachments
PDFs, scans and photos (through OCR), Word, Excel and PowerPoint files, CSVs and plain-text formats come with their text already extracted in parsedText. extractionMethod tells you how. Files over 10 MB are skipped, and text is capped at 50,000 characters per file. The original file is at the attachment's url. The details, and how to hand all of it to a model, are in Inbound email with attachment text for LLMs.
Treat every inbound email as untrusted
Anyone can email your agent, and whatever they write ends up in its context. Give each agent a key that only opens its own inbox (POST /v1/inboxes/{id}/api-keys), and keep the account key on your server. Mark email content as untrusted in the prompt. Require a person or a rule before the agent does anything you can't undo. Sender rules let an inbox accept mail only from addresses or domains you allow. Armandas covers the threat model in What email infrastructure for AI agents should look like.
Doing this on a transactional sender
Resend, Postmark and Mailgun can all receive mail on your domain, but most of the work comes after that. On Resend, for example, the email.received webhook carries only metadata, so the agent has to fetch the body, headers and attachments from the Received emails and Attachments APIs before it can read anything. To reply in thread you put the incoming message_id in In-Reply-To and keep appending earlier IDs to References yourself. That's fine for a one-off reply. For an agent holding many conversations, it means building and storing the threads yourself. The side-by-side is on OpenMail vs Resend.
Frequently asked questions
How does an AI agent receive email?
Through a push event. The provider delivers each inbound message to a webhook URL or over a WebSocket. On OpenMail the message.received event already contains the body, the extracted text of attachments and a thread ID, so the agent can act on it without another call.
How do I reply to an email from code so it stays in the same thread?
Set In-Reply-To to the Message-ID of the email you’re answering and References to the chain before it. On OpenMail you pass the threadId to the send call and the headers are set for you.
Should I use a webhook or a WebSocket for inbound email?
A webhook if the agent runs on a server with a public URL. A WebSocket if it runs locally or behind a firewall, or you’d rather not expose an endpoint. The events are identical, and the WebSocket replays up to 100 events you missed while disconnected.
Can an AI agent read email attachments?
Yes. OpenMail extracts text from PDFs, images, Office documents, CSVs and plain-text files and puts it in the event as parsedText, up to 50,000 characters per file.



