Skip to content

Mail In to a Webhook

Christopher Holloway edited this page Oct 18, 2026 · 6 revisions

Mail In to a Webhook

Written on 26 September 2026 from hmailserver/docs/InboundWebhooks.md in 6.3.4 (27 September 2026, schema 6104). Everything on this page is new in 6.3.4 (schema 6100), and nothing is on until a route is made. Brought up on 5 October 2026 to 6.3.6 (6 October 2026, schema 6108), for what 6.3.6 changes on this page. 6.3.7, the latest release (17 October 2026, schema 6108), is not yet reflected here.

An address - or a whole domain this server hosts - whose mail the server posts to a URL: parsed, signed, and retried until the endpoint takes it. SendGrid and Mailgun call it inbound parse, and it is what an application actually wants from a mail server: a helpdesk that opens a ticket from what support@ receives, a CRM that files what orders@ is sent, a service that reads the replies to reply+<token>@. Until 6.3.4 the only way to have it was a rule that ran a script (Rules and Sieve). The outbound webhooks tell another program what this server's mail did (Webhooks.md); this hands it the mail.

flowchart LR
    S["A message for<br/>support@example.com"] --> R{"A route for the address,<br/>or its domain?"}
    R -->|"no"| N["Delivered as always"]
    R -->|"yes"| Q["After the global rules, the virus scan<br/>and the sandbox: a copy queued<br/>for the route"]
    Q --> P["POST to the URL,<br/>form or JSON, signed"]
    P -->|"2xx"| OK["Delivered; the copy removed"]
    P -->|"anything else"| RT["Retried: 0, 1, 5, 15,<br/>then 60 minutes"]
    RT -->|"attempts run out"| F{"on_failure"}
    F -->|"keep"| K["Kept, to download,<br/>retry or remove"]
    F -->|"bounce"| B["Returned to its sender"]
    R -->|"deliver: true"| N
Loading

Contents

A route

Made over REST or on the Control Deck's Inbound webhooks view. There is no COM member, no Control Panel page and no setting of its own, as for single sign-on.

POST /api/v1/inbound-webhooks
{
  "address": "support@example.com",
  "url": "https://helpdesk.example/hooks/mail",
  "secret": "a-long-random-string"
}
Field What it is Default
address user@example.com, or example.com for every address of the domain; the domain must be one this server hosts required
url https:// to the endpoint; plain http:// only to this machine required
secret Write-only: every request is signed under it, and it is never returned required
deliver false: the route alone receives the address's mail. true: what the address reaches without the route - its mailbox, an alias's target, a list - receives it as well false
on_failure keep or bounce: what a message the endpoint never accepted becomes keep
format form (multipart/form-data in SendGrid's field names) or json form
attachments include (carried as files), names (listed without their bytes) or none include
include_raw Also send the message as it arrived false
max_size_kb A larger message is not posted, but kept or returned as on_failure says; at most 81920 10240
enabled A route switched off is as though it were not there for new mail true

The Deck lists the routes - the address, whether it posts only or posts and delivers, the URL - and the last twenty-five deliveries with the reason one has not arrived; a kept message has Download, Retry and Remove.

Which route an address takes, and what it replaces

An address route wins over its domain's. A recipient with a plus tag (reply+1234@example.com) is found by the address as written, then by the address before the tag, then by the domain - and the tag is kept in what is posted, which is usually the point of it.

A route decides that the address exists. RCPT TO is accepted for a routed address whether or not an account, an alias or a list has it, and a route is asked before any of them (Aliases Lists and Public Folders):

  • with deliver off, the route takes the address's mail instead of whatever has the address. A list whose address a route takes never sees the message, nor applies its posting rules to it - so route a list's domain with care, or route the list addresses you mean (Mailing Lists);
  • with deliver on, the message is posted and delivered as the address would have been without the route.

A message to several addresses of one route is posted once, with every one of them in its envelope.

What the endpoint receives

A POST, signed, with X-hMailServer-Event: message.inbound and X-hMailServer-Delivery - the same on every attempt, so a receiver can tell a retry from a second message.

  • format: form: multipart/form-data in the field names SendGrid's Inbound Parse posts - headers, from, to, cc, subject, text, html, envelope, sender_ip, SPF, dkim, charsets, attachments, attachment-info and the attachments as attachment1 onward - so a receiver written for SendGrid reads it, with a few fields of this server's own beside them (spam, dmarc, route, queue_id). Mailgun's field names are not sent.
  • format: json: one JSON document of this server's own shape - the envelope, the header fields, the text and HTML bodies, the verdicts, the attachments with their content in base64.

The SPF, DKIM and DMARC results are the ones this server wrote into its own Authentication-Results field, and only while AuthenticationResultsEnabled is on; a field somebody else wrote is never read. The spam verdict is this server's own.

Signing is exactly the outbound webhooks' recipe: HMAC-SHA256 over the timestamp, a full stop and the raw body, under the route's secret, in X-hMailServer-Signature. A receiver that verifies one verifies the other; verify against the bytes received, in constant time, and refuse an old timestamp. To rotate the secret, PUT the new one and accept either signature while the change is made.

Retries, and what is kept

The message is taken after the global rules, the virus scan and the sandbox have had it, and a copy is queued; nothing is posted from the delivery thread, and an endpoint hears of it in seconds. Any 2xx delivers. Anything else - a 4xx, a 5xx, a redirect, no answer - is a failure, retried on the outbound webhooks' schedule: WebhookMaxAttempts attempts (5 by default), waiting 0, 1, 5, 15 and then 60 minutes. An endpoint that is down holds up only itself.

When the attempts run out, the route's on_failure decides:

  • keep (the default): the message is kept, never swept, to be downloaded, retried or removed, and the error log says so once (HM7401).
  • bounce: the message is returned to its sender with an ordinary delivery-status report saying the application that receives the address's mail did not accept it - without repeating what the endpoint answered. Where the sender cannot be told, it is kept instead.

Delivered and returned rows are removed after WebhookRetentionDays (7 by default). The queue survives a restart. The erasure of an address's personal data sweeps this queue too (Compliance and eDiscovery).

Where an endpoint may be

The endpoint is reached from this server, so where it may be depends on who made the route:

  • The server's administrator may name an address only this server can reach - a receiver on the same network is the common case - but never a link-local or cloud metadata address (169.254.0.0/16, fe80::/10 and the clouds' own metadata addresses), which hand out the machine's own credentials.
  • Every other credential - an API key of any scope or role, or an account whose role is not the server's administrator - may name a public address only: loopback, the private ranges and the reserved ranges are refused.

Both are judged when the route is saved and again at every connection, by what the name resolves to then. No redirect is followed. Since 6.3.6 a route held to public addresses - one that anybody but the server's administrator made - posts through HttpProxy when one is set, asking the proxy for a tunnel to the address the server checked and never to the name (over https only); a route the server's administrator made, which may reach this server's own network, posts directly. Up to 6.3.5 no route went through HttpProxy. Plain http is taken only to this machine.

Who may

To Needs
Read the routes and the queue view_configuration - a route's URL whole only beside manage_inbound_webhooks; anybody else sees its scheme and host
Read a kept message view_configuration and view_mailbox
Make, change or remove a route; retry or remove a delivery manage_inbound_webhooks

manage_inbound_webhooks is held by the server administrator's role alone among the built-in roles (Sign-in and Identity §7): routing a domain's mail to a URL is as much as reading all of it. It is meant for an API key's role - an application that receives mail is given a key whose role holds it, confined to its own domain, and routes that domain's addresses and no other. Routes made, changed or removed are on the audit trail, their secrets and URLs recorded as (changed); a kept message downloaded, retried or removed is recorded as an event. Nothing secret reaches a log.

What it does not do

  • No COM and no Control Panel: the REST API and the Deck.
  • No setting of its own: the attempts and the retention are the outbound webhooks', and WebhooksEnabled silences the outbound webhooks and not these.
  • No redirect followed, and no proxy used.
  • One post per route per message, not one per recipient.
  • Not in the configuration backup: export the routes with GET /api/v1/inbound-webhooks - the secrets are in no read.
  • Mailgun's field names are not sent; SendGrid's are, and the JSON form is this server's own.

The full document, with every field, header, error code and test, is InboundWebhooks.md.

See also: Routes and Relays · Rules and Sieve · Aliases Lists and Public Folders · The REST API · The Control Deck

Home — full index

Start here

1. Install and run

2. Secure it

3. Operate it

4. Extend it

5. Contribute to it

Look it up — from any journey


About this wiki

Clone this wiki locally