---
title: "Integrating applications: the send API, mail in to a webhook, and outbound webhooks"
url: https://www.hmailserver.co.uk/topic/28266/integrating-applications-the-send-api-mail-in-to-a-webhook-and-outbound-webhooks
author: "Progressiverobot"
published: 2026-09-27T19:48:13.731Z
updated: 2026-09-27T19:48:13.731Z
category: "REST API & integrations"
tags: ["rest-api","api","smtp","security"]
site: hMailServer Community Forum
---

# 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 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](/topic/28277). 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](/topic/28278).*



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": "orders@example.com",
 "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

```bash
curl https://mail.example.com:8443/api/v1/send \
  -H "Authorization: Bearer hmapi_0123…" \
  -H "Content-Type: application/json" \
  -d '{"to":["ann@example.net"],"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": "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. 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 (`reply+1234@example.com`) 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": "orders@example.com"`) 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:

```bash
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.

```python
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](https://gitlab.com/hmailserver/hmailserver/-/blob/v6.3.4/hmailserver/docs/SendApi.md), [InboundWebhooks.md](https://gitlab.com/hmailserver/hmailserver/-/blob/v6.3.4/hmailserver/docs/InboundWebhooks.md), [Webhooks.md](https://gitlab.com/hmailserver/hmailserver/-/blob/v6.3.4/hmailserver/docs/Webhooks.md) and, for the alerts' webhook, [AlertsAndAuditTrail.md](https://gitlab.com/hmailserver/hmailserver/-/blob/v6.3.4/hmailserver/docs/AlertsAndAuditTrail.md).
- The wiki: [Mail In to a Webhook](https://gitlab.com/hmailserver/hmailserver/-/wikis/Mail-In-to-a-Webhook) and [The REST API](https://gitlab.com/hmailserver/hmailserver/-/wikis/The-REST-API).
- The public documentation: [chapter 24, The REST API](https://www.progressiverobot.com/hmailserver-documentation/#24-the-rest-api).
- On this forum: [The Control Deck](/topic/28192), the [6.3.4 announcement](/topic/28218), [the REST API guide](/topic/28264) and [hmctl and the PowerShell module](/topic/28265); turning the message trace on is in [Troubleshooting by symptom](/topic/28225), and the alert rules whose webhook is signed the same way are in [Alerts, the audit trail, syslog and the journal](/topic/28273).
