Skip to content
  • Categories
  • Recent
  • Popular
  • Tags
  • Users
  • Groups
  • Documentation
  • Downloads
  • Report a bug
  • GitHub mirror
  • Paid support
Skins
  • Light
  • Brite
  • Cerulean
  • Cosmo
  • Flatly
  • Journal
  • Litera
  • Lumen
  • Lux
  • Materia
  • Minty
  • Morph
  • Pulse
  • Sandstone
  • Simplex
  • Sketchy
  • Spacelab
  • United
  • Yeti
  • Zephyr
  • Dark
  • Cyborg
  • Darkly
  • Quartz
  • Slate
  • Solar
  • Superhero
  • Vapor

  • Default (No Skin)
  • No Skin
Collapse
hMailServer Community Forum
hMailServer 6.3.8 is the current release, signed 2 November 2026. Windows and Linux, and an in-place upgrade from 5.3 or later, but for the original hMailServer's 5.7.Download 6.3.8 Documentation FAQ
  1. Support and discussion for hMailServer
  2. Automation & integration
  3. REST API & integrations
  4. Integrating applications: the send API, mail in to a webhook, and outbound webhooks

Integrating applications: the send API, mail in to a webhook, and outbound webhooks

Scheduled Pinned Locked Moved REST API & integrations
rest-apiapismtpsecurity
1 Posts 1 Posters 122 Views 1 Watching
  • Oldest to Newest
  • Newest to Oldest
  • Most Votes
Reply
  • Reply as topic
Log in to reply
This topic has been deleted. Only users with topic management privileges can see it.
  • ProgressiverobotP
    ProgressiverobotP
    Progressiverobot
    hMailServer Team
    wrote on last edited by chris
    #1

    Written for 6.3.4. hMailServer 6.3.7 (17 October 2026) refuses a state-changing REST request that carries a credential when the browser making it reports it as cross-site (Sec-Fetch-Site, or an Origin that is not this server's), so a page on another site cannot post to these routes with a credential, whether a key or the administrator password the browser holds; a program or script that sends no such header, as the curl examples here do not, is unaffected. See the 6.3.7 announcement. hMailServer 6.3.8 (2 November 2026) answers 400, saying why, before a route reads a member, to a body that is not one JSON object, names a member twice, holds an escaped U+0000 or passes 100,000 values or 32 levels, so correct a program that sends such a body to these routes; and it raises account.locked only for a locked name that is an account's own. See the 6.3.8 announcement.

    Since 6.3.4 hMailServer has three ways for another program to work with the server's mail: a send API, through which an application sends as one sender and asks afterwards what became of each recipient; mail in to a webhook, where the mail for an address or a whole domain is posted to a URL; and outbound webhooks, which tell a program what the server's mail did. Nothing is on until a key, a route or a subscription is made. The one thing to know first: each of the three takes a different credential, and the difference is deliberate.

    Feature Routes Who may use it
    The send API /api/v1/send An API key that names a sender and whose role holds send_through_api. Not the administrator password, not an account.
    Mail in to a webhook /api/v1/inbound-webhooks view_configuration to read, manage_inbound_webhooks to change. Meant for an API key's role, confined to the application's domain.
    Outbound webhooks /api/v1/webhooks The administrator password, or an account whose role holds the permission. No API key reaches these routes; the refusal is 401.

    All three are made and managed over the REST listener, so RestApiPort has to be on; outbound webhook subscriptions can also be managed over COM, on Windows.

    The send API: the key

    POST /api/v1/me/messages is the webmail's send and stays the webmail's. The send API is for programs.

    Route What it does
    POST /api/v1/send Sends a message as the key's sender. 202 {queue_id, message_id, recipients, recorded}
    GET /api/v1/send The key's own sends, newest first, paged
    GET /api/v1/send/{queue_id} One of them, with the state of each address its body named

    A key sends when two things are true of it: its role holds send_through_api, and it names a sender. Either alone sends nothing. Three fields are set when the key is made, on POST /api/v1/apikeys with the administrator password or on the Control Deck's API keys page.

    Field What it is
    send_from The one sender the key may use: an address of a domain this server hosts, or *@domain for any address of that domain. Empty, the default, means the key cannot send.
    send_per_minute The key's own ceiling on messages a minute. 0, the default, sets none.
    send_per_day The key's own ceiling on messages in any 24 hours. 0 sets none, which only a key whose sender is an account's address may have.

    The built-in serveradmin role holds the permission, so a key made with "scope": "full" holds it. No other built-in role does. A custom role is how a key that sends and does nothing else is made:

    POST /api/v1/roles
    {"name": "mailer", "description": "sends mail", "permissions": "send_through_api"}
    
    POST /api/v1/apikeys
    {"label": "shop confirmations", "role": "mailer", "send_from": "[email protected]",
     "send_per_day": 5000}
    

    The route refuses with 400, among other things, a send_from at a domain this server does not host (a domain alias does not count), at a domain that is switched off, on a read-only key, or outside the domains a key is confined to. A send_from that is *@domain, or an address that is no account's, needs a send_per_day above 0, because such a sender has no account's sending quota to hold it. A key's sender cannot be changed afterwards: a new sender is a new key.

    What *@domain grants. To the server, such a key is every mailbox of that domain sending as itself. It can post to a members-only list as any member and send as the domain administrator's mailbox. The display name and reply_to are the application's own and are not checked against anything; what the key vouches for is the address.

    On Windows, the desktop Control Panel's key page does not show these three fields and cannot set them, so a key made there cannot send. The Control Deck and the route do.

    The send API: sending and asking afterwards

    curl https://mail.example.com:8443/api/v1/send \
      -H "Authorization: Bearer hmapi_0123…" \
      -H "Content-Type: application/json" \
      -d '{"to":["[email protected]"],"subject":"Your order","text":"Thank you."}'
    
    Member Rule
    from Optional when the key's sender is an address, required when it is *@domain. "address" or "Name <address>".
    to, cc, bcc Arrays of mailboxes; at least one recipient between them. Bcc goes to the envelope and nowhere else.
    reply_to Optional.
    subject Required; it may be empty.
    text, html At least one. Both makes a multipart/alternative.
    headers Optional: X- fields other than the server's own X-hMailServer- fields, List-Unsubscribe, List-Unsubscribe-Post, In-Reply-To and References. Any other field is 400.
    attachments Optional: [{filename, content_type, data}], data in base64, at most 20 and twelve megabytes together.

    A line break is refused with 400 in every value that becomes a header, and a member the route does not take (body for text, for example) is 400 and named, not dropped.

    What the server does to the message. It is written and checked by the same code as the webmail's send. Every recipient goes through what RCPT TO asks of an authenticated sender, with the server's recipient ceiling (MaxSMTPRecipientsInBatch) and the suppression list. The domain's signature and disclaimer are added, the rules that block are asked (403, policy_rejected), and the message is held to the maximum message size (413). It is DKIM-signed at delivery by the domain's key. The envelope sender is the From address, so a bounce comes back to it. A Received field names the server, the send API and the key's label, never the key's id. No Sent copy is kept anywhere. When the sender is an account's address, that account's sending quota is consumed as an SMTP submission would consume it.

    Ceiling Answer
    send_per_minute 429, Retry-After: 60
    send_per_day 429, Retry-After: 3600
    The sender account's sending quota 429, Retry-After one twenty-fourth of the account's period
    The REST request budget, 200 requests in 10 seconds 429, Retry-After: 10
    The recipient ceiling, the size 400, 413

    A message is counted only once every other check has passed, and when a later ceiling refuses, every earlier one is given back. A refused message is never a counted one.

    The answer to a message that is taken is 202:

    {"queue_id": 4711, "message_id": "<…@mail.example.com>", "recipients": 2, "recorded": true}
    

    queue_id is the one to ask about afterwards with GET /api/v1/send/4711. The answer names only the addresses the body named, each with a state.

    State Meaning
    queued In the queue and not yet tried
    deferred An attempt failed and will be retried
    delivered The trace's delivered event
    bounced Refused for good, or the retries ran out
    refused The suppression list stopped it at delivery
    unknown Nothing names the address; the answer says why

    It needs the message trace, and the trace is off by default (MessageTraceEnabled), because it records who corresponds with whom. With it off, a send reads queued or deferred while it is in the queue and unknown once the queue has let it go; trace in the answer is false and a trace_note says so. Turn the trace on, and choose its retention, before relying on the answer. The record of a send is kept for MessageTraceRetentionDays, thirty by default. Only a send this key made is answered; any other id is the same 404. The backup does not carry the records.

    Mail in to a webhook: a route

    SendGrid and Mailgun call this inbound parse. A route is made over REST or on the Control Deck's Inbound webhooks view. There is no COM member and no Control Panel page, on either platform.

    POST /api/v1/inbound-webhooks
    {
      "address": "[email protected]",
      "url": "https://helpdesk.example/hooks/mail",
      "secret": "a-long-random-string"
    }
    
    Field What it is Default
    address [email protected], 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. Printable ASCII only. required
    secret Write-only. Every request is signed under it and it is never returned. required
    deliver false: the route alone receives the mail. true: what the address reaches without the route receives it as well. false
    on_failure keep or bounce keep
    format form (multipart/form-data in SendGrid's field names) or json form
    attachments include, names or none include
    include_raw Also send the message as it arrived false
    max_size_kb A larger message is not posted. At most 81920. 10240
    enabled A route switched off is as though it were not there for new mail true

    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. With deliver off, the route takes the mail instead of whatever has the address, so a list whose address a route takes never sees the message. Route a list's domain with care. An address route wins over its domain's, and a recipient with a plus tag ([email protected]) is found by the address as written, then by the address before the tag, then by the domain, with the tag kept in what is posted.

    What arrives is a signed POST:

    POST /hooks/mail HTTP/1.0
    Content-Type: multipart/form-data; boundary=hMailServer-4193-...
    X-hMailServer-Timestamp: 1789000042
    X-hMailServer-Signature: sha256=<hex>
    X-hMailServer-Event: message.inbound
    X-hMailServer-Delivery: 4193
    X-hMailServer-Attempt: 1
    

    X-hMailServer-Delivery is the same on every attempt. The form format carries SendGrid's fields (headers, from, to, cc, subject, text, html, envelope, sender_ip, SPF, dkim, charsets, attachments, attachment-info, attachment1 onward) with a few of this server's own beside them (spam, dmarc, route, queue_id, attachments_left_out). Mailgun's field names are not sent. The json format is this server's own shape. The SPF, dkim and dmarc results are the ones this server wrote into its own Authentication-Results field, and only while AuthenticationResultsEnabled is on.

    Retries and what is kept. The message is taken after the global rules, the virus scan and the sandbox have had it, and nothing is posted from the delivery thread. Any 2xx delivers. Anything else, a redirect included, is a failure. The retries are the outbound webhooks': WebhookMaxAttempts attempts (5 by default), with waits of 0, 1, 5, 15 and then 60 minutes. When they run out:

    • keep: the message is kept and never swept. It can be read with GET /api/v1/inbound-webhooks/deliveries/{id}/message, tried again with POST /api/v1/inbound-webhooks/deliveries/{id}/retry or removed with DELETE /api/v1/inbound-webhooks/deliveries/{id}. The error log says so once, as HM7401.
    • bounce: the message is returned to its sender with a delivery-status report (4.4.7), without repeating what the endpoint answered. HM7402 records the return. Where the sender cannot be told, the message is kept instead.

    A message larger than the route's ceiling is not posted at all: it is returned at once (5.3.4) or kept at once (HM7403). Delivered and returned rows are removed after WebhookRetentionDays (7 by default), and the rows survive a restart.

    Where an endpoint may be depends on who made the route. The server's administrator may name an address only this server can reach, but never a link-local or cloud metadata address (169.254.0.0/16, fe80::/10 and the clouds' own metadata addresses). Every other credential, an API key of any scope or role included, may name a public address only. Both are judged when the route is saved and again at every connection. No redirect is followed and HttpProxy is never used.

    Who may. Only serveradmin holds manage_inbound_webhooks as shipped, since routing a domain's mail to a URL is as much as reading all of it. A reader without it is answered a route's URL as its scheme and host alone, so put a token in the secret, not in the URL. Reading a kept message needs view_mailbox as well. POST /api/v1/inbound-webhooks/run makes one pass now and needs a credential that reaches every domain.

    Outbound webhooks

    An outbound webhook does not carry the message. No body, no attachments, no header block. It is a notification. The one piece of content it can carry is the subject, and only where the domain's webhook_subjects and the subscription's include_subject are both on; both are off by default, and even then a subject travels on message.received and message.quarantined only.

    Event Raised when
    message.received The message has been saved and the server has taken responsibility for it, once per local recipient
    message.delivered A recipient, local or remote, has been delivered to
    message.bounced A non-delivery report has been written and queued for the sender, once per failed recipient
    message.quarantined The held copy and its row exist
    account.locked Repeated authentication failures have locked a name

    A delivery attempt that failed and will be retried is not a bounce, and no event says deferred.

    POST /api/v1/webhooks
    {
      "name": "ticketing",
      "url": "https://tickets.example/hooks/mail",
      "secret": "a-long-random-string",
      "events": "message.bounced,account.locked",
      "domains": "example.com",
      "max_per_minute": 120
    }
    

    An empty events, domains or addresses list means every one. A subscription confined to a send API key's sender ("addresses": "[email protected]") hears message.delivered and message.bounced for the recipients outside this server, with the message trace on or off. Plain http off this machine is refused when the subscription is made. The secret is write-only, protected at rest, and an empty secret in a change keeps the one that is set.

    Permissions. Reading needs view_configuration; deleting needs manage_settings; creating, changing, testing and running a pass need manage_server, because whoever can do those can make the server connect to a host of their choosing. A role confined to named domains is refused all eight routes.

    The queue. The delivery pass runs once a minute, attempts at most 200 rows and runs for at most 60 seconds. A delivery succeeds on any 2xx; a request that has taken more than ten seconds is a failure. After WebhookMaxAttempts the row is dead-lettered and the error log says so once as HM6971. Past a subscription's ceiling per minute, events are dropped and counted as dropped, not queued.

    Setting REST key Default
    WebhooksEnabled webhooks_enabled on
    WebhookMaxAttempts webhook_max_attempts 5
    WebhookMaxPerMinute webhook_max_per_minute 60
    WebhookRetentionDays webhook_retention_days 7

    WebhooksEnabled silences the outbound webhooks and not the inbound routes. From a shell:

    hmctl webhook create --set name=ticketing --set url=https://tickets.example/hook \
                         --set-string secret="$SECRET" --set events=message.bounced --set domains=example.com
    hmctl webhooks test --event message.bounced --domain example.com
    hmctl webhooks run
    hmctl webhooks deliveries --state dead-lettered
    

    webhooks test raises a real event through the path a real one takes and says how many subscriptions took it, so 0 is an answer. In PowerShell the functions are Get-HmWebhook, New-HmWebhook, Set-HmWebhook, Remove-HmWebhook, Get-HmWebhookDelivery, Invoke-HmWebhookDelivery and Test-HmWebhook. Over COM, on Windows, Utilities.GetWebhookSubscriptions(name) and Utilities.SaveWebhookSubscription(json) take and answer the same JSON documents.

    Verifying a signature

    One recipe signs all of them: the outbound webhooks, the inbound routes and the webhook of an alert rule. The signature is HMAC-SHA256 over the timestamp, a full stop, and the raw request body, under the shared secret, as lower-case hex, in X-hMailServer-Signature. Verify it against the bytes received, before parsing them.

    import hmac, hashlib, time
    
    def verify(secret, headers, raw_body):
        timestamp = headers["X-hMailServer-Timestamp"]
        signature = headers["X-hMailServer-Signature"]
    
        # Refuse anything older than five minutes, or the signature can be replayed.
        if abs(time.time() - int(timestamp)) > 300:
            return False
    
        expected = hmac.new(secret.encode(), (timestamp + ".").encode() + raw_body,
                            hashlib.sha256).hexdigest()
    
        return hmac.compare_digest("sha256=" + expected, signature)
    
    1. Compare in constant time.
    2. Check the timestamp: a signature with no freshness check can be replayed.
    3. De-duplicate on X-hMailServer-Delivery together with X-hMailServer-Event. A retry is the same body with a fresh timestamp and so a fresh signature, and an inbound and an outbound delivery can carry the same number.
    4. To rotate a secret, PUT the new one and accept either signature at the receiver while the change is made. There is no overlap window in the server.

    The documents also give the recipe in JavaScript. An alert's webhook carries three of the headers: X-hMailServer-Timestamp, X-hMailServer-Signature and X-hMailServer-Event.

    What they do not do

    • The send API has no templates, no substitutions, no scheduled send and no per-recipient ceiling of the key's own, and gives no state without the trace once a message has left the queue.
    • An inbound route makes one post per route per message, not one per recipient. It has no setting of its own and no per-route retry ladder.
    • An outbound webhook does not carry the message and has no per-subscription retry policy.
    • Neither kind of webhook is in the configuration backup. Export the routes with GET /api/v1/inbound-webhooks and the subscriptions with hmctl webhook list; the secrets are in no read.
    • One pass at a time is per process, not per database, for both kinds of webhook.

    Where to read more

    • The operator documents: SendApi.md, InboundWebhooks.md, Webhooks.md and, for the alerts' webhook, AlertsAndAuditTrail.md.
    • The wiki: Mail In to a Webhook and The REST API.
    • The public documentation: chapter 24, The REST API.
    • On this forum: The Control Deck, the 6.3.4 announcement, the REST API guide and hmctl and the PowerShell module; turning the message trace on is in Troubleshooting by symptom, and the alert rules whose webhook is signed the same way are in Alerts, the audit trail, syslog and the journal.
    1 Reply Last reply
    0

    Hello! It looks like you're interested in this conversation, but you don't have an account yet.

    Getting fed up of having to scroll through the same posts each visit? When you register for an account, you'll always come back to exactly where you were before, and choose to be notified of new replies (either via email, or push notification). You'll also be able to save bookmarks and upvote posts to show your appreciation to other community members.

    With your input, this post could be even better 💗

    Register Login
    Reply
    • Reply as topic
    Log in to reply
    • Oldest to Newest
    • Newest to Oldest
    • Most Votes


    hMailServer Community

    The community support forum for hMailServer — the free, open-source mail server for Windows and Linux — maintained by Progressive Robot Ltd. The software is AGPL-3.0 and stays that way; this is where it is discussed, debugged and documented.

    The software

    • Documentation
    • Downloads
    • hMailServer 6.3
    • Source on GitLab

    Getting help

    • Forum rules
    • About this forum
    • Support plans
    • Pay monthly support
    • Talk to Progressive Robot

    Legal

    • Privacy policy
    • Terms and conditions
    • Cookie policy

    Progressive Robot Ltd · 220 Chester Business Park, Heronsway, Chester, Cheshire CH4 9GB, United Kingdom · +44 (0)1244 911212 · [email protected] · VAT 506152326

    Powered by NodeBB

    • Login

    • Don't have an account? Register

    • Login or register to search.
    • First post
      Last post
    0
    • Categories
    • Recent
    • Popular
    • Tags
    • Users
    • Groups
    • Documentation
    • Downloads
    • Report a bug
    • GitHub mirror
    • Paid support