---
title: "Receiving Mail"
description: "Wire the inbound webhook, verify its signature, and wake an agent on the reply."
canonical_url: "https://www.agent-identity.dev/docs/mail/receiving"
markdown_url: "https://www.agent-identity.dev/docs/mail/receiving.md"
last_updated: "2018-10-20"
x_farming_labs_generated_preamble: true
agent:
  tokenBudget: 1000
  task: "Configure inbound mail delivery so replies reach an agent's mailbox and appear as mail.received events."
  outcome: "The provider's inbound webhook reaches POST /v1/webhooks/mail/inbound over a public HTTPS origin, signature verification passes, and a mail.received event is appended with the correct thread_id."
  appliesTo:
    package:
      - "@aid/api"
      - "@aid/providers"
  prerequisites:
    - "An agent with a mailbox. Addresses on the service domain receive mail with no setup."
    - "For a custom domain: the domain verified, with its MX record published. See Domains."
  files:
    - "apps/api/src/services/mail.ts"
  sideEffects:
    - "Ingesting an inbound message can create a suggested contact for an unknown sender."
    - "Attachments are persisted at ingest time and stay downloadable with the message."
  verification:
    - description: "Send a message to an agent's mailbox from an external account."
      expect: "The webhook returns 200 with ingested 1, and GET /v1/events shows a mail.received event carrying threadId, from, and subject."
    - description: "Inspect the webhook response counters after a provider retry."
      expect: "duplicates increments instead of ingested; the same message is never stored twice."
  rollback:
    - "Point the provider's inbound route away from this API. Already-ingested messages remain; there is no uningest."
  failureModes:
    - symptom: "A message sent to the agent never produces a mail.received event."
      resolution: "For a custom domain, confirm the MX record is published and the domain is verified (GET /v1/domains). For the service domain, check the address with client.identities.get and look at the webhook counters below."
    - symptom: "200 with unroutable greater than zero."
      resolution: "The recipient address does not match any mailbox in any organization. Confirm the handle and that the domain matches your mailbox address exactly."
    - symptom: "200 with blocked greater than zero."
      resolution: "An access rule with action block matched the sender. The message is stored but no mail.received event is appended, so waits will not fire."
    - symptom: "Replies start a new thread instead of continuing one."
      resolution: "The sending client stripped In-Reply-To and References. Correlation then falls back to subject matching within the mailbox, which a changed subject defeats."
---

# Receiving Mail
URL: /docs/mail/receiving
LLM index: /llms.txt
Description: Wire the inbound webhook, verify its signature, and wake an agent on the reply.
Related: /docs/mail/sending, /docs/concepts/events, /docs/mail/domains, /docs/mail/contacts

<!-- farming-labs:agent-contract:start -->
## Agent Contract

Task: Configure inbound mail delivery so replies reach an agent's mailbox and appear as mail.received events.
Outcome: The provider's inbound webhook reaches POST /v1/webhooks/mail/inbound over a public HTTPS origin, signature verification passes, and a mail.received event is appended with the correct thread_id.

### Applies To

- Package: `@aid/api`, `@aid/providers`

### Prerequisites

- An agent with a mailbox. Addresses on the service domain receive mail with no setup.
- For a custom domain: the domain verified, with its MX record published. See Domains.

### Files

- `apps/api/src/services/mail.ts`

### Side Effects

- Ingesting an inbound message can create a suggested contact for an unknown sender.
- Attachments are persisted at ingest time and stay downloadable with the message.

### Verification

- Send a message to an agent's mailbox from an external account.
  - Expected: The webhook returns 200 with ingested 1, and GET /v1/events shows a mail.received event carrying threadId, from, and subject.
- Inspect the webhook response counters after a provider retry.
  - Expected: duplicates increments instead of ingested; the same message is never stored twice.

### Rollback

- Point the provider's inbound route away from this API. Already-ingested messages remain; there is no uningest.

### Failure Modes

- A message sent to the agent never produces a mail.received event. — Recovery: For a custom domain, confirm the MX record is published and the domain is verified (GET /v1/domains). For the service domain, check the address with client.identities.get and look at the webhook counters below.
- 200 with unroutable greater than zero. — Recovery: The recipient address does not match any mailbox in any organization. Confirm the handle and that the domain matches your mailbox address exactly.
- 200 with blocked greater than zero. — Recovery: An access rule with action block matched the sender. The message is stored but no mail.received event is appended, so waits will not fire.
- Replies start a new thread instead of continuing one. — Recovery: The sending client stripped In-Reply-To and References. Correlation then falls back to subject matching within the mailbox, which a changed subject defeats.
<!-- farming-labs:agent-contract:end -->

# Receiving Mail

Every agent's mailbox receives mail as soon as the agent exists — there is nothing to
configure for an address on the service domain. Each inbound message is threaded, stored,
and appended to the event log as `mail.received`, so an agent reacts by
[waiting on the event](#waiting-for-a-reply-instead-of-polling) rather than polling a mailbox.

To receive at your own domain, verify it and publish the MX record from
[Domains](/docs/mail/domains).

## Behind the scenes

The mail provider delivers inbound mail to the API at `POST /v1/webhooks/mail/inbound`.
That route is not for you to call: it is authenticated by the provider's signature over
the raw body rather than an API key, and a bad signature is a `401` with nothing persisted.
It matters here only because its response counters are how ingestion problems show up.

## What the endpoint reports

```json
{
  "ingested": 1,
  "duplicates": 0,
  "unroutable": 0,
  "blocked": 0,
  "delivered": 0,
  "bounced": 0,
  "complained": 0,
  "unmatched_delivery_status": 0
}
```

The same endpoint handles inbound messages *and* delivery-status callbacks, which is why
the counters cover both. Read them when debugging: a `200` with `ingested: 0` and
`unroutable: 1` is a very different problem from a `401`.

| Counter | Meaning |
| --- | --- |
| `ingested` | Stored and an event appended. |
| `duplicates` | Already seen, by provider message id. Safe; providers retry. |
| `unroutable` | No mailbox in any organization matches the recipient. |
| `blocked` | An access rule blocked the sender. Stored, but no event. |
| `delivered` / `bounced` / `complained` | Delivery-status callbacks matched to an outbound message. |
| `unmatched_delivery_status` | A status callback for a message this system did not send. |

## Threading

An inbound message is correlated to an existing thread by its `In-Reply-To` header first,
then `References`, then by subject within the same mailbox. Failing all three it starts a
new thread. Subject matching is the weakest link — a correspondent who edits the subject
line gets a new thread.

## Waiting for a reply instead of polling

```ts title="await-reply.ts"
const message = await client.mail.send({
  identityId,
  to: ["customer@example.com"],
  subject: "Your order",
  text: "Shipped this morning.",
});

const { event, timedOut } = await client.events.wait({
  type: "mail.received",
  identityId,
  filter: { threadId: message.threadId },
  timeoutMs: 55_000,
});

if (!timedOut) {
  const thread = await client.mail.getThread(identityId, message.threadId);
  // thread.messages is the full conversation, oldest first
}
```

This is the loop the whole system exists to support: send, block on the reply, read the
thread, decide what to do. No polling, no cron, no "check every thirty seconds".

For a long-lived listener, re-arm the wait and advance `since` — see
[Events](/docs/concepts/events#waiting-instead-of-polling).

## Side effects at ingest

An unknown sender becomes a **suggested** contact automatically, which you can approve or
reject later. Attachments are extracted and written to object storage as part of ingest,
so they are available through
[the attachments endpoints](/docs/mail/attachments) immediately.

## Sitemap

See the full [sitemap](/sitemap.md) for all pages.
Docs-scoped sitemap: [/docs/sitemap.md](/docs/sitemap.md).
Well-known sitemap: [/.well-known/sitemap.md](/.well-known/sitemap.md).
