---
title: Receiving email
description: Receive mail at a verified domain or a hosted mailfully.app address, get an email.received webhook, and read the body, attachments, and raw MIME.
---

> **For AI agents:** the complete documentation index is at [llms.txt](/docs/llms.txt). Append `.md` to any page URL for its markdown version.

Mailfully receives email as well as sending it. Each received message is scanned for spam and viruses, stored, announced to your webhook endpoints as `email.received`, and readable over the API: body, headers, authentication results, attachments, and the original MIME.

## How it works

1. A sender's mail server looks up the MX record for the recipient's domain and finds Mailfully's receiving host.
2. Amazon SES accepts the message and runs its spam and virus scans.
3. Mailfully matches each recipient address to the organization that owns its domain, stores one copy per organization, and counts it toward that organization's monthly allowance.
4. An `email.received` webhook goes to every endpoint subscribed to it.
5. You fetch the content with [`GET /v1/emails/receiving/{id}`](/api-reference/receiving/get).

Receiving is catch-all: every address at a receiving domain is accepted, and there are no per-address routing rules. Use `received_for` on each email to see which of your addresses it was sent to.

## Two kinds of address

| | Your own domain | Hosted address |
|---|---|---|
| Address | `anything@mail.acme.com` | `anything@k7q2m9xa.mailfully.app` |
| Setup | A verified domain, receiving turned on, and one MX record | One switch in the dashboard |
| DNS changes | Yes | None |
| Turned on by | API or dashboard, with the `send` scope | Dashboard only, by an owner or admin |

Mail to either kind is handled identically once it arrives. The hosted address needs no DNS, so use it to try receiving; give customers an address at your own domain.

## Receive on your own domain

Receiving works on a domain you already use for sending. The domain must be `verified` before receiving can be turned on; see [Verify a domain](/guides/verify-a-domain).

Turn it on with [`PATCH /v1/domains/{id}`](/api-reference/domains/update-receiving):

```bash
curl -X PATCH https://api.mailfully.com/v1/domains/dom_01J1PZ3M8Q7VXCK4T2R9WFH6BD \
  -H "Authorization: Bearer mf_live_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{ "receiving": true }'
```

The response is the domain with `receiving: true`, the time it was turned on in `receiving_enabled_at`, and one more entry at the end of `records`:

```json
{
  "type": "MX",
  "name": "mail.acme.com",
  "value": "inbound-smtp.us-east-1.amazonaws.com",
  "priority": 10
}
```

Publish that MX record at your DNS provider. It sits on the domain name itself (`mail.acme.com`), not on the MAIL FROM subdomain (`send.mail.acme.com`) that already carries Mailfully's other MX record. Mail starts arriving once the record resolves. The same toggle is on the domain's page in the [dashboard](https://dashboard.mailfully.com), which also shows whether the MX record is published.

Check it from a terminal:

```bash
dig +short mail.acme.com MX
```

The answer should be `10 inbound-smtp.us-east-1.amazonaws.com.` and nothing else.

### When the domain already receives mail

Before turning receiving on, Mailfully looks up the domain's current MX records. If any of them points somewhere other than Mailfully, the request is refused with `409 mx_conflict`. Adding a second MX record beside your existing mail provider's would split incoming mail between the two, and some of the mail you expect in Google Workspace or Microsoft 365 would land here instead.

Use a subdomain instead. Add `inbound.acme.com` as a new domain, verify it, and turn receiving on there. Your existing mailboxes at `acme.com` are not touched.

The other refusals when turning receiving on:

| Status | `type` | Cause |
|---|---|---|
| 409 | `domain_not_verified` | The domain is not `verified` yet |
| 409 | `mx_conflict` | The domain already has MX records for another provider |
| 503 | `dns_lookup_failed` | The MX lookup timed out or failed; nothing was changed, so retry |

Sending `{ "receiving": true }` to a domain that already has it on changes nothing and does not look up DNS again.

### Turning receiving off

Send `{ "receiving": false }`. This is always allowed, whatever state the domain is in. Remove the MX record as well. While the record still points at Mailfully, mail to the domain is accepted and then dropped: it is not stored or counted, and the sender gets no bounce.

## The hosted address

Every organization can turn on one hosted address at `<label>.mailfully.app`. The label is eight random letters and digits, assigned the first time the address is turned on and kept after that, so turning it off and on again gives back the same address. Any local part works: `support@k7q2m9xa.mailfully.app` and `orders@k7q2m9xa.mailfully.app` both arrive.

Turn it on in the dashboard's settings. It needs an owner or admin, and there is no API endpoint for it. While it is off, mail to the address is dropped.

`mailfully.app`, `mailfully.ai`, and `mailfully.net` are reserved. Adding any of them, or a name under them, as a sending domain returns `422 domain_reserved`.

## What gets accepted

- **Only the exact domain receives.** A domain with receiving on accepts mail for itself, not its subdomains. Receiving on `mail.acme.com` does not cover `eu.mail.acme.com`; add that name as its own domain.
- **Messages can be up to 40 MB**, attachments included. SES refuses anything larger before it reaches Mailfully.
- **Spam is kept.** A message SES marks as spam is stored and delivered like any other, with `spam: "FAIL"`. Filter on that field yourself, or list only spam with `spam=true`.
- **Viruses are not.** A message with `virus: "FAIL"` keeps its metadata (sender, recipients, subject, attachment names and sizes), but its body, headers, raw MIME, and attachment files are never stored.
- **Each organization gets its own copy.** A message sent to addresses at two organizations' domains becomes two separate emails. Each organization sees only its own addresses in `received_for`.

## Get notified

Subscribe an endpoint to `email.received` in the [dashboard](https://dashboard.mailfully.com). The payload carries metadata only; fetch the body with the API.

```json
{
  "type": "email.received",
  "created_at": "2026-09-27T14:12:08.000Z",
  "data": {
    "email_id": "inb_01JQ4Z8M2V6KD3N7R9T1WXYB5C",
    "created_at": "2026-09-27T14:12:08.000Z",
    "from": "ada@example.com",
    "to": ["support@mail.acme.com"],
    "cc": [],
    "bcc": [],
    "received_for": ["support@mail.acme.com"],
    "message_id": "<CAF3x9k2@mail.example.com>",
    "subject": "Order 1234 arrived damaged",
    "attachments": [
      {
        "id": "att_01JQ4Z8M3A5E7G9H1K2M4P6R8S",
        "filename": "photo.jpg",
        "content_type": "image/jpeg",
        "content_disposition": "attachment",
        "content_id": null
      }
    ],
    "spam": "PASS",
    "virus": "PASS"
  }
}
```

- `to` and `cc` come from the message headers. `received_for` comes from the SMTP envelope, so it is the list to route on: it includes blind-copied recipients and any other address the headers never showed.
- `bcc` is always `[]`. The receiving side is never told who was blind-copied.
- `subject` is `""` when the message had none.
- The `webhook-id` header is the email's `inb_` id and stays the same on every retry, so use it to drop duplicates.

Signing, retries, and dead letters work as they do for every other event. See [Webhooks](/guides/webhooks).

## Read received mail

Every receiving endpoint needs the `read:inbound` scope, and nothing else grants it: a `send` or `read:emails` key gets `403 insufficient_scope`. Keys cannot be edited, so create a new key with `read:inbound` in the dashboard.

Received mail has no test or live split. A `mf_test_` key with the scope reads the same mail as a `mf_live_` key.

<Tabs>
<Tab title="curl">

```bash
curl https://api.mailfully.com/v1/emails/receiving/inb_01JQ4Z8M2V6KD3N7R9T1WXYB5C \
  -H "Authorization: Bearer mf_live_xxxxxxxxxxxx"
```

</Tab>
<Tab title="Node">

```typescript
import { Mailfully } from "mailfully";

const mailfully = new Mailfully({ apiKey: process.env.MAILFULLY_API_KEY ?? "" });

const { data, error } = await mailfully.emails.receiving.get(
  "inb_01JQ4Z8M2V6KD3N7R9T1WXYB5C",
);
if (error) throw new Error(`${error.type}: ${error.message}`);

console.log(data.text);
```

</Tab>
</Tabs>

The response holds everything the list row has, plus:

| Field | Contents |
|---|---|
| `html`, `text` | The message body; either is `null` when the message had no such part |
| `headers` | Every header as `{ name, value }`, in the order it appeared |
| `reply_to`, `in_reply_to`, `references` | Threading headers, parsed |
| `authentication` | `spf`, `dkim`, and `dmarc` results from SES |
| `attachments` | Metadata for each attachment, without download links |
| `raw` | `{ download_url, expires_at }` for the original `.eml` file |
| `size_bytes` | The size of the message as received |

### Inline images

By default, `html` has every `cid:` image link replaced with the image itself as a `data:` URI, so the HTML renders on its own. Pass `html_format=cid` to get the original `cid:` links back, and match them to attachments by `content_id`. Images are embedded up to 5 MB in total; past that, or when no attachment matches, the `cid:` link is left as it was.

### Attachments and raw MIME

[`GET /v1/emails/receiving/{id}/attachments`](/api-reference/receiving/list-attachments) returns each attachment with a `download_url` that saves under the attachment's own filename. [Fetch one attachment](/api-reference/receiving/get-attachment) by its `att_` id when you only need one.

Download links last at most one hour. Read `expires_at` for the exact time a link stops working, since it can be sooner than an hour. Request a fresh link rather than storing one. For a virus-flagged email, `raw` and each attachment's `download_url` and `expires_at` are `null`.

### List and search

[`GET /v1/emails/receiving`](/api-reference/receiving/list) returns received mail newest first. The list has metadata only, no bodies.

| Parameter | Filters to |
|---|---|
| `q` | Subject, sender address, or sender display name containing the text (1-200 characters) |
| `domain` | Mail sent to any address at this domain |
| `has_attachments=true` | Mail with at least one attachment |
| `spam=true` | Mail SES marked as spam |

This list pages in both directions. Pass `next_cursor` as `after` for the next, older page, or `prev_cursor` as `before` to go back toward newer mail. Every page is newest first either way. `limit` defaults to 25 and caps at 100.

## Verdicts

Each email carries SES's results, all using one vocabulary: `PASS`, `FAIL`, `GRAY` (SES had nothing to check or could not decide, such as a sender with no SPF record), `PROCESSING_FAILED`, or `null` when SES reported nothing.

| Field | Where | Meaning of `FAIL` |
|---|---|---|
| `spam` | List, detail, webhook | SES classed the message as spam |
| `virus` | List, detail, webhook | SES found a virus; content was not stored |
| `authentication.spf` | Detail | The sending server is not authorized by the sender's SPF record |
| `authentication.dkim` | Detail | A DKIM signature on the message did not validate |
| `authentication.dmarc` | Detail | The message failed the sender domain's DMARC policy |

Mailfully does not reject or drop mail based on SPF, DKIM, or DMARC. If you act on a message's `from` address, such as resetting a password or opening a ticket, check `authentication.dmarc` first.

## Billing and limits

Received mail counts toward the same monthly allowance as sent mail. Each received email counts once, no matter how many of your addresses it was sent to. Sent mail, by contrast, counts each recipient.

- **Not counted:** email with `spam: "FAIL"` or `virus: "FAIL"`, and mail dropped because receiving was off.
- **Daily caps do not apply.** They limit sending only.
- **The free plan's marketing allowance is unaffected.** Received mail counts toward the overall allowance, not the marketing share.

Received mail can use up the allowance your sends also need. On the free plan, a flood of incoming mail can stop you sending until the month resets.

### Mail received over the limit

Mail keeps arriving after your organization crosses its monthly ceiling or its spend cap. It is stored and its `email.received` webhook fires, but it is locked: it shows in the list with `quota_locked: true`, and the detail and attachment endpoints return `403 email_above_quota`.

A locked email unlocks once your usage for the current month is back within your limits: at the start of the next month, after upgrading, or after raising the spend cap. Locked mail is never billed, even after it unlocks.

### Retention

Received mail follows your plan's retention window: 30 days on Free and Starter, 60 on Growth, 90 on Scale. After that the email and its files are deleted, and its id returns `404 not_found`. See [Quotas and plan limits](/concepts/quotas-and-plan-limits).

## Reference

| Endpoint | Scope |
|---|---|
| [`PATCH /v1/domains/{id}`](/api-reference/domains/update-receiving) | `send` |
| [`GET /v1/emails/receiving`](/api-reference/receiving/list) | `read:inbound` |
| [`GET /v1/emails/receiving/{id}`](/api-reference/receiving/get) | `read:inbound` |
| [`GET /v1/emails/receiving/{id}/attachments`](/api-reference/receiving/list-attachments) | `read:inbound` |
| [`GET /v1/emails/receiving/{id}/attachments/{attachment_id}`](/api-reference/receiving/get-attachment) | `read:inbound` |

If mail you expected never shows up, see [Received mail is missing](/troubleshooting/received-mail-missing).
