---
title: "How to have an AI agent reply to inbound email on your own domain"
description: "Point MX at a subdomain, verify the signed email.received webhook, dedupe, then send one threaded reply with an idempotency key from the inbound id."
url: https://www.unitpost.com/blog/agents/ai-agent-reply-to-inbound-email
section: Blog
updated: 2026-11-02
---
# How to have an AI agent reply to inbound email on your own domain

Point a subdomain's MX record at Unitpost and subscribe a webhook to email.received. For each delivery, verify the signature, dedupe on the received email's id, fetch the message, and let the agent draft. Your code checks the draft, then sends it with in_reply_to set to the inbound id, which threads it, and an idempotency key derived from that id, so each inbound message gets one reply at most.

## How to have an AI agent reply to inbound email on your own domain

> Point MX at a subdomain, verify the signed email.received webhook, dedupe, then send one threaded reply with an idempotency key from the inbound id.

An agent that answers email needs a domain that receives, a webhook handler, the agent that drafts, a gate that checks the draft, and a send that threads. Unitpost gives you the receiving and the send. It does not give each agent a mailbox: receiving is catch-all on a domain, mail arrives as a webhook and in the dashboard inbox, and there is no IMAP.

### How do you receive mail on a subdomain?

Use a subdomain such as reply.yourdomain.com. Our MX record must have the lowest priority number on its name, so on a root domain that runs Google Workspace or Microsoft 365 it would take mail away from those inboxes.

- Verify the subdomain for sending first. Receiving can only be turned on for a verified domain; anything else answers `409`.
- Turn receiving on in the domain's Receiving tab, or with `PATCH /email/domains/{id}` and `{ "capabilities": { "receiving": "enabled" } }` (MCP `email_domains_update`).
- Publish the MX record the API returns as `receiving.record`. Receiving is live once `receiving.status` is `verified`.
- Create a webhook subscribed to `email.received`. The URL must be public HTTPS, so develop behind a tunnel.

Every address at the subdomain arrives with no per-address setup: support@, billing@ and reply+4821@ land on the same webhook, and your handler routes on `to`, which is also how you split work between several agents.

### Verify the signature, then dedupe

Check the signature before you read anything else. Each delivery carries `svix-id`, `svix-timestamp` and `svix-signature`, an HMAC-SHA256 over the id, the timestamp and the raw body. Pass the raw body: re-serialized JSON has different bytes. In Node.js:

```bash
import { verifyWebhook } from "unitpost";

// Pass the RAW request body: re-serializing breaks the signature.
const event = verifyWebhook({
  payload: rawBody,
  secret: process.env.UNITPOST_WEBHOOK_SECRET, // whsec_…
  headers: {
    "svix-id": headers["svix-id"],
    "svix-timestamp": headers["svix-timestamp"],
    "svix-signature": headers["svix-signature"],
  },
});
```

`verifyWebhook` throws on a bad signature, so answer `401`. It also rejects a timestamp more than five minutes from now (`toleranceSeconds`, default 300), so a captured delivery can't be replayed later.

A delivery that times out or gets a 5xx is retried, so the same event can reach you twice. The envelope's `event_id` stays the same across deliveries even when `svix-id` differs, and `data.email_id` (`inb_…`) names the received message. Insert one into a column with a unique constraint before doing any work; if the insert fails, return 2xx and stop. Otherwise return 2xx at once and run the agent off the request path, so a slow model doesn't become a retry.

### Give the agent the message, not the inbox

The webhook holds metadata only: sender, recipients, subject, `message_id` and attachment descriptors. Fetch the rest with `GET /email/received/{id}` (`unitpost.email.receivedGet` in Node.js, which needs `emails:read`). It returns the `text` and `html` bodies (inlined when small; larger ones through the `/raw` link), `reply_to`, `thread_id`, the `in-reply-to` and `references` headers, and the SPF, DKIM and DMARC verdicts.

For the history, `GET /email/threads/{id}` returns the conversation oldest first: received messages with their bodies, and what you sent as metadata only. The API never returns the body of mail you sent, so if the agent needs its own earlier replies as context, keep its drafts on your side.

Do these reads in your handler with a read key, and pass the model only this message and its thread, as data written by a stranger. How to protect an AI email agent from prompt injection (/blog/agents/protect-ai-email-agent-prompt-injection) covers which verdicts to require.

### How do you thread the reply?

Mail clients group a conversation by the In-Reply-To and References headers. You don't have to build them: set `in_reply_to` to the inbound id on `POST /email`. Unitpost looks up the stored Message-ID and References chain, fills both headers, and adds `Re:` to the subject if it isn't there. Headers you set yourself always win. An unknown id is a `404`; a received message with no Message-ID is a `422`.

Send to the message's `reply_to` addresses when it has them, else to `from`. A contact form's relay address is not the person who wrote in.

`POST /email/threads/{id}/reply` also threads, quotes the original and marks the conversation read, but it needs `emails:read` as well as `emails:send`, and the same call can forward. For an agent, `POST /email` with `in_reply_to` keeps the sending key send-only.

### Draft, gate, then send once

The model writes; your code decides. Before anything goes out, check that:

- the recipient is the one your code picked, not one the model suggested
- the sender's `dmarc_verdict` is `PASS`, or the reply says nothing account-specific
- the draft has no links or attachments the agent made up
- the thread is under your cap of agent replies
- `from` isn't your own address: a copy of your own send that comes back to your receiving domain arrives as received mail, and answering it starts a loop

Then send with an idempotency key built from the inbound id. Here `inbound` is the message `receivedGet` returned and `draft` is the text that passed your checks:

```bash
await unitpost.email.send(
  {
    from: "Acme Support <support@reply.acme.com>",
    to: inbound.reply_to.length ? inbound.reply_to : inbound.from,
    subject: inbound.subject,
    text: draft,
    in_reply_to: inbound.id, // inb_…
  },
  { idempotencyKey: `reply-${inbound.id}` },
);
```

The key names the unit of work: one inbound message, one reply. A retry with the same key within 24 hours returns the first result, marked `Idempotent-Replayed: true`, instead of sending again. If a worker dies after the send and a rerun makes the model write a different draft, the same key with a different body is refused with `409 invalid_idempotent_request`, and nothing new goes out. Treat that `409` as "already answered", never as a reason for a fresh key. `409 concurrent_idempotent_requests` means the first attempt is still in flight: wait and retry. The window is 24 hours, so keep your own "replied" flag as well (what is an idempotency key (/blog/concepts/what-is-an-idempotency-key)).

The platform's gate still runs underneath: the `from` domain must be verified in the key's workspace, and suppressed addresses are skipped.

### When should the agent hand off to a person?

Some messages should never get an automatic reply. Stop and hand off when:

- the sender's `dmarc_verdict` or `spam_verdict` isn't `PASS`
- they ask for a person, or the thread is about money, refunds, cancellations or anything legal
- the agent isn't confident, or the thread has reached your cap of agent replies
- the message looks automated, such as an out-of-office or a bounce notice, which is how two bots start a loop

A handoff is mostly not sending. Every received message is already in the dashboard inbox under Emails, Received, threaded with what you sent. Notify your team with the thread and the reason the agent stopped; a person replies from there, from the same domain and thread, and the shared read state shows it's been handled.

## FAQ

### Does each AI agent get its own mailbox?

No. Receiving is catch-all on a domain: every address at it arrives as an email.received event, and you route on the to field. Send support@ to one agent, billing@ to another, or give each conversation a reply+ address. There is no IMAP or POP access and no per-address setup.

### Is the email body in the email.received webhook?

No. The webhook carries metadata: sender, recipients, subject, Message-ID and attachment descriptors. Fetch the body with GET /email/received/{id} using a key with emails:read. The message lists attachments as metadata; get each one's bytes from GET /email/received/{id}/attachments/{attachmentId}, which returns a signed URL that expires after about five minutes.

### What if the same webhook arrives twice?

Expect it: a delivery that times out or gets a 5xx is retried. Dedupe on event_id or data.email_id in a unique column, and send with an idempotency key derived from the inbound id, so even a missed dedupe can't produce a second reply within 24 hours.

## Related

- [How to send appointment reminders by SMS from an AI agent](https://www.unitpost.com/blog/agents/ai-agent-sms-appointment-reminders): Let an AI agent text appointment reminders: consent on record, a registered use case, a fixed template, the local hour, and one idempotency key per reminder.
- [How to handle STOP and HELP when an AI agent sends texts](https://www.unitpost.com/blog/agents/ai-agent-sms-stop-help): Keep STOP and HELP away from the model: handle keywords first, send one confirmation, honor plain-language opt-outs, log consent, check before every send.
- [How to let an AI agent answer text messages](https://www.unitpost.com/blog/agents/ai-agent-two-way-sms): Let an AI agent reply to texts safely: the sms.received webhook, messages it must not answer, opt-out words it shouldn't guess at, and when to hand off.
