Receiving email
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.
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
- A sender's mail server looks up the MX record for the recipient's domain and finds Mailfully's receiving host.
- Amazon SES accepts the message and runs its spam and virus scans.
- 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.
- An
email.receivedwebhook goes to every endpoint subscribed to it. - You fetch the content with
GET /v1/emails/receiving/{id}.
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.
Turn it on with PATCH /v1/domains/{id}:
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:
{
"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, which also shows whether the MX record is published.
Check it from a terminal:
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.comdoes not covereu.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 withspam=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. The payload carries metadata only; fetch the body with the API.
{
"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"
}
}
toandcccome from the message headers.received_forcomes 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.bccis always[]. The receiving side is never told who was blind-copied.subjectis""when the message had none.- The
webhook-idheader is the email'sinb_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.
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.
curl https://api.mailfully.com/v1/emails/receiving/inb_01JQ4Z8M2V6KD3N7R9T1WXYB5C \
-H "Authorization: Bearer mf_live_xxxxxxxxxxxx"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 returns each attachment with a download_url that saves under the attachment's own filename. Fetch one 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 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"orvirus: "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.
Reference
| Endpoint | Scope |
|---|---|
PATCH /v1/domains/{id} | send |
GET /v1/emails/receiving | read:inbound |
GET /v1/emails/receiving/{id} | read:inbound |
GET /v1/emails/receiving/{id}/attachments | read:inbound |
GET /v1/emails/receiving/{id}/attachments/{attachment_id} | read:inbound |
If mail you expected never shows up, see Received mail is missing.
