PosthasteDocsGet an API key

Receiving

Receiving mail

Point a domain’s MX record at us, claim an address, and arriving mail is parsed, authenticated, scanned and delivered to your endpoint as a signed webhook — with the SPF, DKIM and DMARC verdicts, a spam score and an antivirus result attached.

Setting it up

Receiving needs an MX record; sending needs a verified DKIM record. They are independent, so a domain can receive perfectly well and still be unable to reply — and the reverse.

# 1. Add the domain and verify it (the sending records)
POST /v1/domains  { "name": "mail.your-company.com" }

# 2. Ask what RECEIVING needs. The MX record lives here and nowhere else.
GET /v1/inbound/domains

# {
#   "data": [{
#     "name": "mail.your-company.com",
#     "receiving": false,
#     "mx": { "state": "missing", "hosts": [] },
#     "records": [{
#       "type": "MX", "name": "mail.your-company.com",
#       "value": "10 mta1.posthastemail.dev",
#       "warning": "Only add this if the domain does not already receive mail
#                   elsewhere — an MX pointing here moves ALL of its incoming
#                   mail to this platform."
#     }]
#   }]
# }

# 3. Publish it, then check
POST /v1/inbound/domains/dom_.../verify
# { "receiving": true, "verified": true, ... }

# 4. Claim an address
POST /v1/inbound/addresses  { "address": "[email protected]" }

# 5. Route it to your endpoint
POST /v1/inbound/routes  { "address": "[email protected]", "action": "webhook" }

An MX record moves all of a domain’s mail. Publishing ours on a domain that already receives elsewhere redirects every message, not just the addresses you claim here. Most people use a subdomain — mail.your-company.com — for exactly this reason.

There is deliberately no catch-all. An address that is not claimed is refused during the SMTP conversation, while the sending server is still connected and can tell its user. Accepting mail for an address nobody reads means either discarding it silently or bouncing to a forged sender — and that second one is backscatter, which is how a sending IP gets blocklisted.

Routing

A rule says what happens to mail arriving at one address, or at every address on one domain. An address rule beats a domain rule.

actionWhat happens
mailboxKept for reading in the dashboard. This is the default with no rule.
webhookPOSTed to your endpoint, signed, with retries. Still stored — but not shown in the mailbox.
bothDelivered to your endpoint and kept in the mailbox. Each exactly once.

Webhook-routed mail is still stored, and that is deliberate rather than an oversight. A remote server that loses our 250 redelivers the same message — completely ordinary in SMTP — and the stored copy is what makes that a no-op instead of a second call to your endpoint. It also means a delivery that failed every retry has something behind it rather than being gone.

There is no pattern matching, by design. The address namespace is public, so a rule whose matches cannot be listed is a rule nobody can check before it starts forwarding a stranger’s mail to their endpoint.

The payload

{
  "id": "evt_AZLm3kQ8T2Sf9pXbNc7HrQ",
  "type": "inbound.received",
  "createdAt": "2026-08-28T09:14:02.113Z",
  "data": {
    "messageId": "msg_9pXbNc7HrQAZLm3kQ8T2S",
    "to": "[email protected]",
    "domain": "mail.your-company.com",

    "envelopeFrom": "[email protected]",
    "from": "[email protected]",
    "fromName": "Jane Customer",
    "subject": "Re: your invoice",
    "messageIdHeader": "<[email protected]>",
    "sizeBytes": 24680,
    "receivedAt": "2026-08-28T09:14:01.882Z",

    "authentication": { "spf": "pass", "dkim": "pass", "aligned": true },
    "spam": {
      "score": -1.2,
      "action": "no action",
      "symbols": [{ "name": "BAYES_HAM", "score": -3 }]
    },

    "avStatus": "clean",
    "attachments": [{
      "id": "0f6c…", "filename": "receipt.pdf",
      "contentType": "application/pdf", "disposition": "attachment",
      "cid": null, "sizeBytes": 18422, "sha256": "9f2…",
      "av": { "status": "clean", "signature": null }
    }]
  }
}
FieldWhat it tells you
envelopeFrom / fromThe envelope sender and the From header, separately. A disagreement between them is the clearest phishing signal there is, so they are never collapsed into one field.
authentication.alignedWhether the From domain matches an authenticated identifier. A message can pass SPF for a domain with nothing to do with the name a human sees — that is exactly how a convincing phish is built.
spam.scorerspamd’s score; higher is worse. null means NOT SCANNED, which is not the same as clean.
avStatusThe worst antivirus verdict across the attachments: clean, infected, unscanned or error. null when there are none, and unscanned is never rounded to clean.
attachmentsMetadata only, never bytes — a payload is signed, stored for every retry and logged, and putting a stranger’s file through all of that multiplies where it lives.

Verifying it

Verify the raw bytes, before parsing. A body that has been through JSON.parse and back has a different encoding and will not verify — most frameworks need an explicit opt-in to keep the raw body, and that is the one setup step nobody can do for you.

import { parseInboundWebhook } from '@posthaste/sdk'

// Express: give this route the RAW body, not the parsed one.
app.post('/hooks/mail', express.raw({ type: 'application/json' }), (req, res) => {
  const result = parseInboundWebhook(
    req.body,                              // raw bytes, exactly as they arrived
    req.header('posthaste-signature'),
    process.env.POSTHASTE_WEBHOOK_SECRET,
  )

  if (!result.ok) {
    // Never act on a body that did not verify.
    if (result.reason === 'not_inbound') return res.sendStatus(200)
    return res.sendStatus(400)
  }

  const mail = result.event.data
  console.log(mail.from, mail.subject, mail.attachments.length)

  // Answer quickly. Anything slow belongs on a queue — we retry on a 500,
  // and a timeout looks the same to us as an outage.
  res.sendStatus(200)
})

Deliveries are retried on a failure with the same Posthaste-Delivery-Id every time, so deduplicate on it rather than on anything in the body. See Webhooks for the retry ladder.

Fetching the message

# The payload carries no body — fetch it when you want it.
GET /v1/inbound/messages/msg_9pXbNc7HrQAZLm3kQ8T2S

# HTML comes back SANITISED. Remote images stay blocked unless you ask:
GET /v1/inbound/messages/msg_…?images=show

# Attachments, one at a time. Always Content-Disposition: attachment.
GET /v1/inbound/messages/msg_…/attachments/0f6c…

HTML is sanitised on the way out, every time, by the same filter — so no client can forget. Attachments are always served as a download with a content type from a small allowlist, never rendered, and one that matched a virus signature is refused outright with attachment_infected. It is still listed on the message, because you should know it arrived.

The mailbox itself is in the dashboard, not the API. Reading threads, drafting replies and the sent view are features of the app rather than an API we version and support. If you want mail programmatically, that is what an inbound route and this webhook are for.

Enforced TLS to your inbound domain

MTA-STS tells senders to refuse delivery rather than downgrade when TLS to your MX cannot be verified — which closes the attack where somebody on the path strips STARTTLS and reads your mail in clear text. It needs a policy served over HTTPS at mta-sts.your-domain.com, with a certificate for that name. We host and certify it; you publish two records.

# 1. Ask us to host a policy for a verified domain
PUT /v1/inbound/mta-sts/mail.your-company.com
{ "mode": "testing", "maxAge": 604800 }

# {
#   "policyId": "8f1c2a3b4d5e6f70",
#   "txtValue": "v=STSv1; id=8f1c2a3b4d5e6f70",
#   "policy": "version: STSv1\r\nmode: testing\r\nmx: mta1.posthastemail.dev\r\nmax_age: 604800\r\n",
#   "next": "Publish the TXT record with id=8f1c2a3b4d5e6f70 — until it
#            changes, senders keep using the policy they already have."
# }

# 2. Publish both records
#    mta-sts.mail.your-company.com   CNAME  mta-sts.posthastemail.dev
#    _mta-sts.mail.your-company.com  TXT    "v=STSv1; id=8f1c2a3b4d5e6f70"

# 3. Check they landed. We issue the certificate once the CNAME resolves.
POST /v1/inbound/mta-sts/mail.your-company.com/verify
# { "cname": "ok", "txt": "ok", "note": null }

# The policy is then live at:
#   https://mta-sts.mail.your-company.com/.well-known/mta-sts.txt

Start in testing, and change the TXT id every time the policy changes. A sender re-reads the policy only when that id moves, so a change with a stale id reaches nobody — for up to max_age — while everything appears to have been applied. The API returns the id on every write, and verify reports txt: "stale" when the published one does not match.

Under enforce, a certificate problem at our MX stops being a warning and becomes mail that is refused — including your bounces and complaints. Move there once your TLS reports have been clean across a certificate renewal. To stop enforcing, set none rather than deleting the record: deleting leaves every sender on its cached copy until max_age expires.

NextWebhooksSignature verification over raw bytes, the retry ladder, and the full event list.