Integrating applications: the send API, mail in to a webhook, and outbound webhooks
-
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 anOriginthat 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 thecurlexamples 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 raisesaccount.lockedonly 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/sendAn 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-webhooksview_configurationto read,manage_inbound_webhooksto change. Meant for an API key's role, confined to the application's domain.Outbound webhooks /api/v1/webhooksThe 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
RestApiPorthas to be on; outbound webhook subscriptions can also be managed over COM, on Windows.The send API: the key
POST /api/v1/me/messagesis the webmail's send and stays the webmail's. The send API is for programs.Route What it does POST /api/v1/sendSends a message as the key's sender. 202 {queue_id, message_id, recipients, recorded}GET /api/v1/sendThe 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, onPOST /api/v1/apikeyswith the administrator password or on the Control Deck's API keys page.Field What it is send_fromThe one sender the key may use: an address of a domain this server hosts, or *@domainfor any address of that domain. Empty, the default, means the key cannot send.send_per_minuteThe key's own ceiling on messages a minute. 0, the default, sets none.send_per_dayThe key's own ceiling on messages in any 24 hours. 0sets none, which only a key whose sender is an account's address may have.The built-in
serveradminrole 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, asend_fromat 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. Asend_fromthat is*@domain, or an address that is no account's, needs asend_per_dayabove 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
*@domaingrants. 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 andreply_toare 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 fromOptional when the key's sender is an address, required when it is *@domain."address"or"Name <address>".to,cc,bccArrays of mailboxes; at least one recipient between them. Bcc goes to the envelope and nowhere else. reply_toOptional. subjectRequired; it may be empty. text,htmlAt least one. Both makes a multipart/alternative.headersOptional: X-fields other than the server's ownX-hMailServer-fields,List-Unsubscribe,List-Unsubscribe-Post,In-Reply-ToandReferences. Any other field is400.attachmentsOptional: [{filename, content_type, data}],datain base64, at most 20 and twelve megabytes together.A line break is refused with
400in every value that becomes a header, and a member the route does not take (bodyfortext, for example) is400and 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 TOasks 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. AReceivedfield 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_minute429,Retry-After: 60send_per_day429,Retry-After: 3600The sender account's sending quota 429,Retry-Afterone twenty-fourth of the account's periodThe REST request budget, 200 requests in 10 seconds 429,Retry-After: 10The recipient ceiling, the size 400,413A 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_idis the one to ask about afterwards withGET /api/v1/send/4711. The answer names only the addresses the body named, each with a state.State Meaning queuedIn the queue and not yet tried deferredAn attempt failed and will be retried deliveredThe trace's deliveredeventbouncedRefused for good, or the retries ran out refusedThe suppression list stopped it at delivery unknownNothing 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 readsqueuedordeferredwhile it is in the queue andunknownonce the queue has let it go;tracein the answer isfalseand atrace_notesays so. Turn the trace on, and choose its retention, before relying on the answer. The record of a send is kept forMessageTraceRetentionDays, thirty by default. Only a send this key made is answered; any other id is the same404. 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], orexample.comfor every address of the domain. The domain must be one this server hosts.required urlhttps://to the endpoint; plainhttp://only to this machine. Printable ASCII only.required secretWrite-only. Every request is signed under it and it is never returned. required deliverfalse: the route alone receives the mail.true: what the address reaches without the route receives it as well.falseon_failurekeeporbouncekeepformatform(multipart/form-datain SendGrid's field names) orjsonformattachmentsinclude,namesornoneincludeinclude_rawAlso send the message as it arrived falsemax_size_kbA larger message is not posted. At most 81920.10240enabledA route switched off is as though it were not there for new mail trueA route decides that the address exists.
RCPT TOis 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. Withdeliveroff, 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: 1X-hMailServer-Deliveryis the same on every attempt. Theformformat carries SendGrid's fields (headers,from,to,cc,subject,text,html,envelope,sender_ip,SPF,dkim,charsets,attachments,attachment-info,attachment1onward) 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. Thejsonformat is this server's own shape. TheSPF,dkimanddmarcresults are the ones this server wrote into its ownAuthentication-Resultsfield, and only whileAuthenticationResultsEnabledis 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
2xxdelivers. Anything else, a redirect included, is a failure. The retries are the outbound webhooks':WebhookMaxAttemptsattempts (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 withGET /api/v1/inbound-webhooks/deliveries/{id}/message, tried again withPOST /api/v1/inbound-webhooks/deliveries/{id}/retryor removed withDELETE /api/v1/inbound-webhooks/deliveries/{id}. The error log says so once, asHM7401.bounce: the message is returned to its sender with a delivery-status report (4.4.7), without repeating what the endpoint answered.HM7402records 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 afterWebhookRetentionDays(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::/10and 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 andHttpProxyis never used.Who may. Only
serveradminholdsmanage_inbound_webhooksas 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 needsview_mailboxas well.POST /api/v1/inbound-webhooks/runmakes 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_subjectsand the subscription'sinclude_subjectare both on; both are off by default, and even then a subject travels onmessage.receivedandmessage.quarantinedonly.Event Raised when message.receivedThe message has been saved and the server has taken responsibility for it, once per local recipient message.deliveredA recipient, local or remote, has been delivered to message.bouncedA non-delivery report has been written and queued for the sender, once per failed recipient message.quarantinedThe held copy and its row exist account.lockedRepeated 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,domainsoraddresseslist means every one. A subscription confined to a send API key's sender ("addresses": "[email protected]") hearsmessage.deliveredandmessage.bouncedfor the recipients outside this server, with the message trace on or off. Plainhttpoff this machine is refused when the subscription is made. The secret is write-only, protected at rest, and an emptysecretin a change keeps the one that is set.Permissions. Reading needs
view_configuration; deleting needsmanage_settings; creating, changing, testing and running a pass needmanage_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. AfterWebhookMaxAttemptsthe row is dead-lettered and the error log says so once asHM6971. Past a subscription's ceiling per minute, events are dropped and counted asdropped, not queued.Setting REST key Default WebhooksEnabledwebhooks_enabledon WebhookMaxAttemptswebhook_max_attempts5 WebhookMaxPerMinutewebhook_max_per_minute60 WebhookRetentionDayswebhook_retention_days7 WebhooksEnabledsilences 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-letteredwebhooks testraises a real event through the path a real one takes and says how many subscriptions took it, so0is an answer. In PowerShell the functions areGet-HmWebhook,New-HmWebhook,Set-HmWebhook,Remove-HmWebhook,Get-HmWebhookDelivery,Invoke-HmWebhookDeliveryandTest-HmWebhook. Over COM, on Windows,Utilities.GetWebhookSubscriptions(name)andUtilities.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)- Compare in constant time.
- Check the timestamp: a signature with no freshness check can be replayed.
- De-duplicate on
X-hMailServer-Deliverytogether withX-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. - To rotate a secret,
PUTthe 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-SignatureandX-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-webhooksand the subscriptions withhmctl 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.
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