Receiving email
Receiving turns an address into an inbox your account can read over the API, with the same key you already send with.
Receiving
Receiving turns an address into an inbox your account can read over the API. Mail sent to it is scanned, stored and handed back as a JSON record: headers, text, html, attachments and the provider's spam and virus verdicts, fetched with the same key you already send with.
Live receiving (the managed address and receiving domains) requires a verified mobile number on the account, like live sending; test mode does not.
Every account gets a managed inbound address with nothing to set up: GET /v1/emails/received/address returns it, an example address built from it, and whether it is live or test. Any local part at that address is accepted, so you can hand out a fresh one per customer or per order without creating anything first.
The managed address has an on/off switch: GET /v1/emails/received/address adds enabled, and PATCH /v1/emails/received/address with { enabled } turns it off or back on. The switch is account-wide and turns your live address off, so it needs a live key: a test key gets 403 LIVE_KEY_REQUIRED, though its GET still reports enabled. While it is off, mail sent to it is discarded, uncharged, with no email.received webhook. A test-mode simulate to a disabled test address answers 422 INBOUND_ADDRESS_DISABLED.
To receive at your own domain instead, verify it for sending first, then turn receiving on with a PATCH to /v1/email-domains/:id setting receiving to true. The response adds a receiving_status (off, pending, verified or failed), the inbound MX record to publish at the domain, and receiving_missing, which lists that record while it is not published yet; call verify again once it resolves, and its receiving_missing says what it still did not find. A domain not yet verified for sending answers 409 RECEIVING_REQUIRES_VERIFIED_DOMAIN. Receiving stays on only while the domain remains verified for sending: if that verification lapses, receiving_status turns failed, and the next verify after sending is verified again turns it back on.
const { data, error } = await honkio.domains.update('DOMAIN_ID', { receiving: true })
if (error) throw new Error(error.message)
console.log(data.domain, data.receiving_status, data.receiving_missing) // 'mail.acme.ca', 'pending', [...]curl -X PATCH https://api.honkio.ca/v1/email-domains/DOMAIN_ID \
-H "Authorization: Bearer mk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{ "receiving": true }'
# → { "id": "...", "domain": "mail.acme.ca", "receiving_status": "pending", ... }// Publish the MX record on the domain itself, then check it:
// mail.acme.ca. MX 10 inbound-smtp.ca-central-1.amazonaws.com.
const { data, error } = await honkio.domains.verify('DOMAIN_ID')
if (error) throw new Error(error.message)
console.log(data.receiving_status) // 'verified'# Publish the MX record on the domain itself, then check it:
# mail.acme.ca. MX 10 inbound-smtp.ca-central-1.amazonaws.com.
curl -X POST https://api.honkio.ca/v1/email-domains/DOMAIN_ID/verify \
-H "Authorization: Bearer mk_live_YOUR_KEY"
# → { "receiving_status": "verified" }We recommend receiving on a subdomain, such as mail.acme.ca, rather than the domain itself: other tools that already send mail from your domain's apex can conflict with the MX record receiving needs there. Turn receiving on at the apex anyway and it still works, with the response's receiving_warning set to apex_mx as a reminder.
email.received fires as soon as a message is accepted, but carries only metadata, not the body: fetch the full record with GET /v1/emails/received/:id once it arrives, rather than trusting the webhook payload for content. Delivery is at least once: the same event can be delivered more than once, so dedupe on data.email.id.
Mail accepted at an address you receive at is delivered at least once, even across an outage on our side: anything our receiving endpoint misses is replayed within minutes of it coming back, and a replay is never charged twice.
| Field | Meaning |
|---|---|
| GET /v1/emails/received | List received mail. Filter by to, from, since, until, domain_id and status; paginate with limit and cursor. |
| GET /v1/emails/received/address | This account's managed inbound address, and whether it is live or test. |
| PATCH /v1/emails/received/address | Turn the managed inbound address on or off with { enabled }. Returns the same object as the GET. |
| GET /v1/emails/received/:id | The full record: every field the list returns (id, from, from_name, to, cc, subject, message_id, in_reply_to, received_at, status, reject_reason, verdicts, attachments_count, size_bytes, charge_millicents, domain_id, livemode), plus text, html, headers, references, reply_to, envelope_recipients, attachments and attachments_bytes (their total size). body_purged is true once the body has been purged, raw_available is false once the raw message has expired, and parse_failed is true when the message could not be parsed (download the raw message instead). body_truncated is true when text or html was cut at 2 MB when stored; the raw message keeps it whole. html_format=cid keeps cid: references in the html; the default rewrites them to attachment links. html_format=sanitized also strips scripts and unsafe markup and parks remote images in data-remote-src, counted in remote_images. |
| GET /v1/emails/received/:id/raw | The original message, as message/rfc822. |
| GET /v1/emails/received/:id/attachments/:attachmentId | One attachment's bytes. |
const { data: address, error } = await honkio.emails.received.address()
if (error) throw new Error(error.message)
console.log(address.domain, address.example, address.enabled) // 'k7m2p9q4wx.inbound.honkio.ca', 'anything@k7m2p9q4wx.inbound.honkio.ca', truecurl https://api.honkio.ca/v1/emails/received/address -H "Authorization: Bearer mk_live_YOUR_KEY"
# → { "domain": "k7m2p9q4wx.inbound.honkio.ca", "example": "anything@k7m2p9q4wx.inbound.honkio.ca", "livemode": true, "enabled": true }const { data: address, error } = await honkio.emails.received.setAddressEnabled(false)
if (error) throw new Error(error.message)
console.log(address.enabled) // falsecurl -X PATCH https://api.honkio.ca/v1/emails/received/address \
-H "Authorization: Bearer mk_live_YOUR_KEY" -H "Content-Type: application/json" -d '{ "enabled": false }'
# → { "domain": "k7m2p9q4wx.inbound.honkio.ca", "example": "anything@k7m2p9q4wx.inbound.honkio.ca", "livemode": true, "enabled": false }const { data, error } = await honkio.emails.received.list({ to: 'orders@mail.acme.ca', limit: 20 })
if (error) throw new Error(error.message)
for (const email of data.data) console.log(email.id, email.from, email.subject)curl "https://api.honkio.ca/v1/emails/received?to=orders@mail.acme.ca&limit=20" \
-H "Authorization: Bearer mk_live_YOUR_KEY"// sanitized strips scripts and unsafe markup from html and parks remote images
const { data: email, error } = await honkio.emails.received.get('EMAIL_ID', { htmlFormat: 'sanitized' })
if (error) throw new Error(error.message)
console.log(email.from, email.subject, email.message_id)# sanitized strips scripts and unsafe markup from html and parks remote images
curl "https://api.honkio.ca/v1/emails/received/EMAIL_ID?html_format=sanitized" -H "Authorization: Bearer mk_live_YOUR_KEY"The managed address itself is free: you pay only for the messages it accepts. In the Node.js SDK, honkio.emails.received.address() returns it with enabled, list and get return the parsed message (headers, text or html, verdicts and attachment metadata), and raw() and attachment() resolve to a Blob rather than JSON: read it with arrayBuffer() or pipe it to a file. get takes an htmlFormat option, the html_format above: links (the default), cid or sanitized.
Every record carries verdicts for spf, dkim, dmarc, spam and virus, as the provider scored them. Mail its scan flags as a virus is stored with status rejected and reject_reason virus, headers only (no body, no attachments), is never charged and fires no webhook; the rest are informational, for your own filtering to act on.
Attachments and the original message are kept for 40 days from receipt; each attachment lists its own expires_at. After that, the raw message and attachment endpoints answer 410 ATTACHMENT_EXPIRED, so keep what you need before then.
An email's subject, text, html and headers are purged 90 days after receipt, the same window as sent email; the record itself, and its metadata, stay, with body_purged set to true.
Receiving costs $0.0025 per message for its first 1 MB, and $0.002 for each started MB of the whole message after that (email_inbound_price_millicents and email_inbound_price_millicents_per_mb on GET /v1/pricing), charged once it is accepted. Mail blocked as a virus, or over the daily cap, is not charged.
Each account can receive at most 1,000 messages per rolling 24 hours by default, raised for individual accounts by support on request. Mail over the cap is stored the same way as a virus rejection, with status rejected and reject_reason daily_cap, headers only, not charged and with no email.received webhook (reject_reason is virus or daily_cap). HonkIO fires account.inbound_email_capped and emails the account owner once per 24 hour window, not on every message over the cap.
To keep a reply in the same thread in the recipient's mail client, send it from a domain you own, not the inbound address itself, through the send API's headers map: put the received email's message_id (the RFC 5322 Message-ID, angle brackets included) in an In-Reply-To header, and its references followed by that same message_id in a References header.
// The received email's message_id, e.g. "<abc123@example.com>", goes in In-Reply-To;
// References is its references followed by that same message_id.
const { data, error } = await honkio.emails.send({
from: 'support@mail.acme.ca',
to: 'ada@example.com',
subject: 'Re: Where is my order?',
text: 'It ships tomorrow.',
headers: {
'In-Reply-To': '<abc123@example.com>',
References: '<abc123@example.com>',
},
})
if (error) throw new Error(error.message)
console.log(data.id)# The received email's message_id, e.g. "<abc123@example.com>", goes in In-Reply-To;
# References is its references followed by that same message_id.
curl -X POST https://api.honkio.ca/v1/emails \
-H "Authorization: Bearer mk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "support@mail.acme.ca",
"to": "ada@example.com",
"subject": "Re: Where is my order?",
"text": "It ships tomorrow.",
"headers": {
"In-Reply-To": "<abc123@example.com>",
"References": "<abc123@example.com>"
}
}'Test keys can simulate an inbound message with nothing actually delivered: POST /v1/emails/received/simulate takes from, to, cc, subject, and text or html, plus optional attachments, verdicts, in_reply_to and headers. Every recipient must be your test address, the one GET /v1/emails/received/address returns for a test key (it ends in test-inbound.honkio.ca, not inbound.honkio.ca), or a domain this account owns in test mode. It answers 403 TEST_KEY_REQUIRED for a live key, and 422 SIMULATE_RECIPIENT_NOT_OWNED otherwise.
// With a test key (mk_test_YOUR_KEY): nothing is actually delivered.
const { data, error } = await honkio.emails.received.simulate({
from: 'ada@example.com',
to: ['orders@k7m2p9q4wx.test-inbound.honkio.ca'],
subject: 'Where is my order?',
text: 'Order 1042 has not arrived.',
})
if (error) throw new Error(error.message)
console.log(data.id, data.status) // 'rcv_...', 'received'curl -X POST https://api.honkio.ca/v1/emails/received/simulate \
-H "Authorization: Bearer mk_test_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "ada@example.com",
"to": ["orders@k7m2p9q4wx.test-inbound.honkio.ca"],
"subject": "Where is my order?",
"text": "Order 1042 has not arrived."
}'
# → 201 { "id": "rcv_...", "status": "received" }
HonkIO