End-user Mail Server API
A focused reference for managing virtual mail domains, mailboxes, aliases, and the surrounding mail-stack settings (DKIM, TLS, anti-spam) over HTTP. Every UI action is also a JSON API call — with side-by-side cURL and PHP examples for every endpoint, ready to drop into your shell scripts, deploy pipelines, or dashboards.
- Unified list envelope. Every list endpoint now returns
{"data": […], "meta": {"page", "limit", "total"}}. Single-resource GETs return the bare object; mutations return{"message": "…"}. - Flat error shape. Errors are now
{"error": "…", "detail": "…", "code": "SCREAMING_SNAKE_CODE"}— no more nested{error:{code,message,hint}}. - Named API tokens. The static
API_KEYstill works; you can also mint named, rotatable tokens via/api/v1/admin/tokens(see the shippedAPI.mdfor the full surface). - Config path renamed.
/etc/backendside-mailpanel/.envis now/etc/backendside-mailpanel/config.env.
1.Authentication Two equivalent schemes
Every /api/v1/* endpoint accepts either an API key sent as an X-API-Key header (programmatic) or a short-lived JWT issued by the admin login endpoint and sent as Authorization: Bearer <jwt> (interactive). If both are present, the X-API-Key header wins.
Two kinds of API keys are accepted as X-API-Key: a named, managed token minted via POST /api/v1/admin/tokens (preferred for new integrations — named, rotatable, stored hashed) or the legacy static API_KEY generated by the installer. Both are sent the same way; the token store is checked first and the static key is the fallback. The managed-token endpoints are documented in the shipped API.md.
Only /health, /auth/login, and /auth/verify are unauthenticated. Every other route is rate-limited per source IP (defaults: 5 logins/min, 60 API calls/min) and may be gated by an optional IP whitelist — see Conventions & errors for the 429 / 403 IP_RESTRICTED responses you may see.
Using your API key
The installer generates the static API key at setup time and writes it into /etc/backendside-mailpanel/config.env as API_KEY=.... Read it from that file on the server — you can rotate it by editing that file and restarting the service, or by minting a named managed token instead.
# Pull the key out of the installer's config.env, then list domains
KEY=$(grep ^API_KEY= /etc/backendside-mailpanel/config.env | cut -d= -f2)
curl -H "X-API-Key: $KEY" \
http://$HOST:8080/api/v1/domains
<?php
$host = 'http://your-host:8080';
$key = 'your_api_key_here';
$ch = curl_init("$host/api/v1/domains");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"X-API-Key: $key",
"Accept: application/json",
],
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
throw new RuntimeException("API error $status: $body");
}
$domains = json_decode($body, true)['data'];
Using a JWT
POST your admin credentials to /auth/login to exchange them for a JWT. The token is valid for 24 hours and is the same token the web UI uses for browser sessions — useful when you want a script to act exactly like a logged-in operator.
TOKEN=$(curl -s -X POST http://$HOST:8080/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"..."}' \
| jq -r .token)
curl -H "Authorization: Bearer $TOKEN" \
http://$HOST:8080/api/v1/domains
$ch = curl_init("$host/auth/login");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode([
'username' => 'admin',
'password' => 'your-password',
]),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
]);
$token = json_decode(curl_exec($ch), true)['token'];
curl_close($ch);
$ch = curl_init("$host/api/v1/domains");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token"],
]);
$domains = json_decode(curl_exec($ch), true)['data'];
curl_close($ch);
X-API-Key for anything unattended — cron jobs, CI, monitoring agents. Use a JWT when you're driving the panel from an end-user-facing tool where the operator already typed a password. JWTs expire after 24 hours; API keys (static or managed token) do not.
Conventions & errors
- All request and response bodies are
application/json— with one exception:POST /api/v1/tls/upload, which usesmultipart/form-data. - Timestamps are RFC 3339, UTC (e.g.
2026-05-02T03:11:42Z). - Sizes that look like quotas are bytes unless explicitly suffixed.
- IDs are integers assigned by the server and stable for the lifetime of the row.
- Successful responses use
200or201. Validation failures use400, auth failures401, IP / rate-limit blocks403and429, missing items404, conflicts409, server errors500.
Response envelopes. Build 056 unifies how every endpoint returns data:
- List endpoints return
{"data": […], "meta": {"page", "limit", "total"}}. Paginate with?page=N&limit=N(defaults1/50, maxlimit=500).{ "data": [ { "id": 1, "name": "example.com", "…": "…" } ], "meta": { "page": 1, "limit": 50, "total": 17 } } - Single-resource GETs return the bare object — e.g.
{"id": 1, "name": "example.com", …}— with no wrapping key. - Mutation acknowledgements return
{"message": "…"}. When the change triggers a background mail-stack reconcile, the text typically ends in"; reconciling…"— the data write is durable, you don't need to retry.
Error shape. Errors are now a single flat object:
{
"error": "short human message",
"detail": "longer explanation when available",
"code": "MACHINE_STABLE_CODE"
}
Codes are stable SCREAMING_SNAKE strings. Always branch on code for programmatic handling; error and detail are for humans. The previous nested {"error":{code,message,hint}} form is gone — clients written against Build 053 will need to be updated.
| Code | HTTP | Meaning |
|---|---|---|
VALIDATION_FAILED | 400 | A field on the request body failed validation. |
UNAUTHORIZED | 401 | Auth header missing, malformed, or wrong. |
IP_RESTRICTED | 403 | Source IP is not in the relevant whitelist (the admin-login whitelist for /auth/login, the API-key whitelist for X-API-Key calls — JWT calls bypass the API-key whitelist). |
NOT_FOUND | 404 | Target resource doesn't exist. |
CONFLICT | 409 | Resource already exists. |
RATE_LIMITED | 429 | Per-IP rate limit exceeded. The response includes a Retry-After: 60 header; the current limits are visible via GET /api/v1/security/settings. |
INTERNAL_ERROR | 500 | Unhandled server-side failure — error carries the operator-facing summary. |
DEPENDENCY_UNAVAILABLE | 500/503 | An external dependency (DB, mail daemon) is down. |
2.Domains
A domain is a virtual mail domain handled by this server (e.g. example.com). Mailboxes and aliases hang off it. Creating a domain provisions its on-disk maildir root and rebuilds the Postfix sender transport map so outbound mail for the new domain is routed via the chosen IP.
| Field | Type | Notes |
|---|---|---|
id | int | Server-assigned. |
name | string | DNS domain name, e.g. example.com. |
active | bool | false disables mail flow for the domain. |
outgoing_ip | string | Source IP for outbound mail. Empty = system default. "auto" at create time asks the server to pick the least-used host IP. |
created_at | RFC 3339 |
GET /api/v1/domains
List every managed domain.
Response 200:
{
"data": [
{
"id": 1,
"name": "example.com",
"active": true,
"outgoing_ip": "203.0.113.10",
"created_at": "2026-05-02T03:11:42Z"
}
],
"meta": { "page": 1, "limit": 50, "total": 1 }
}
curl -H "X-API-Key: $KEY" \
http://$HOST:8080/api/v1/domains
$ch = curl_init("$host/api/v1/domains");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["X-API-Key: $key"],
]);
$resp = json_decode(curl_exec($ch), true);
curl_close($ch);
foreach ($resp['data'] as $d) {
echo $d['name'] . PHP_EOL;
}
POST /api/v1/domains
Create a domain.
| Field | Required | Notes |
|---|---|---|
name | yes | Valid DNS name. |
outgoing_ip | no | Specific IP, or "auto" to let the server pick the least-used host IP. Omit to use the system default. |
Response 201: the created domain object.
Errors:
400— request body is missingnameor malformed.500— DB insert failed, maildir creation failed (in which case the DB row is rolled back), or balanced-IP selection failed whenoutgoing_ip=auto.
curl -X POST http://$HOST:8080/api/v1/domains \
-H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
-d '{"name":"example.com","outgoing_ip":"auto"}'
$payload = json_encode([
'name' => 'example.com',
'outgoing_ip' => 'auto',
]);
$ch = curl_init("$host/api/v1/domains");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
"X-API-Key: $key",
"Content-Type: application/json",
],
]);
$resp = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 201) {
throw new RuntimeException("Create failed: $resp");
}
$domain = json_decode($resp, true);
echo "Created domain id: {$domain['id']}\n";
DELETE /api/v1/domains/:id
Permanently delete a domain. The maildir tree is removed from disk and the Postfix sender transport map is rebuilt.
ON DELETE CASCADE. Maildir contents on disk go with them. Take a backup with GET /api/v1/backup before running this on a production domain.
Response 200: {"message": "domain deleted"}
Errors: 400 — invalid id; 404 — domain not found.
curl -X DELETE -H "X-API-Key: $KEY" \
http://$HOST:8080/api/v1/domains/1
$ch = curl_init("$host/api/v1/domains/1");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_HTTPHEADER => ["X-API-Key: $key"],
]);
curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
3.Mailboxes
A mailbox is a user-style account under a managed domain. The username is the local part (the bit before @); the full address is <username>@<domain>.
| Field | Type | Notes |
|---|---|---|
id | int | Server-assigned. |
domain_id | int | FK to the parent domain. |
username | string | Local part only (e.g. alice, not [email protected]). |
quota | int | Mailbox-size cap in bytes; 0 = unlimited. |
active | bool | false disables login and delivery. |
created_at | RFC 3339 |
The password is never returned by the API. The stored value is a bcrypt-style hash prefixed with {BLF-CRYPT} for the IMAP/POP3 stack; resetting goes through PATCH /api/v1/mailboxes/:id/password.
GET /api/v1/mailboxes
List every mailbox across every domain.
Response 200:
{
"data": [
{
"id": 12,
"domain_id": 1,
"username": "alice",
"quota": 1073741824,
"active": true,
"created_at": "2026-05-02T03:11:42Z"
}
],
"meta": { "page": 1, "limit": 50, "total": 1 }
}
curl -H "X-API-Key: $KEY" \
http://$HOST:8080/api/v1/mailboxes
$ch = curl_init("$host/api/v1/mailboxes");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["X-API-Key: $key"],
]);
$resp = json_decode(curl_exec($ch), true);
curl_close($ch);
foreach ($resp['data'] as $m) {
printf("%-20s quota=%d\n", $m['username'], $m['quota']);
}
POST /api/v1/mailboxes
Create a mailbox. The on-disk Maildir tree (new/, cur/, tmp/ plus the standard Sent, Drafts, Trash, Junk IMAP folders) is created automatically.
| Field | Required | Notes |
|---|---|---|
domain_id | yes | Must reference an existing domain. |
username | yes | Local part. alice and [email protected] are both accepted, but the suffix (when present) must match the domain referenced by domain_id. |
password | yes | Plaintext — hashed server-side before storage. |
quota | no | Bytes. 0 (or omitted) means unlimited. |
Username constraints: must be non-empty; must not contain @, /, \, NUL bytes, or ...
Response 201: the created mailbox object.
Errors:
400— missing field, invalid username characters, orusername domain "x" does not match domain_id (...).404—domain not found.500— password hashing failed, DB insert failed, or maildir creation failed (DB row is rolled back if the filesystem step fails).
curl -X POST http://$HOST:8080/api/v1/mailboxes \
-H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
-d '{
"domain_id":1,
"username":"alice",
"password":"S3cret-pass",
"quota":1073741824
}'
function createMailbox(string $host, string $key, array $mb): array {
$ch = curl_init("$host/api/v1/mailboxes");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($mb),
CURLOPT_HTTPHEADER => [
"X-API-Key: $key",
"Content-Type: application/json",
],
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 201) {
throw new RuntimeException("Create failed ($status): $body");
}
return json_decode($body, true);
}
$row = createMailbox($host, $key, [
'domain_id' => 1,
'username' => 'alice',
'password' => 'S3cret-pass',
'quota' => 1073741824,
]);
echo "Created mailbox id: {$row['id']}\n";
PATCH /api/v1/mailboxes/:id/password
Set a new password for an existing mailbox. The plaintext is hashed server-side; the old password is not required.
Request body: { "password": "new-strong-password" }
Response 200: {"message": "password updated"}
curl -X PATCH http://$HOST:8080/api/v1/mailboxes/12/password \
-H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
-d '{"password":"new-strong-password"}'
$ch = curl_init("$host/api/v1/mailboxes/12/password");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'PATCH',
CURLOPT_POSTFIELDS => json_encode(['password' => 'new-strong-password']),
CURLOPT_HTTPHEADER => [
"X-API-Key: $key",
"Content-Type: application/json",
],
]);
curl_exec($ch);
curl_close($ch);
DELETE /api/v1/mailboxes/:id
Permanently delete a mailbox and its on-disk Maildir.
Response 200: {"message": "mailbox deleted"}
curl -X DELETE -H "X-API-Key: $KEY" \
http://$HOST:8080/api/v1/mailboxes/12
$ch = curl_init("$host/api/v1/mailboxes/12");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_HTTPHEADER => ["X-API-Key: $key"],
]);
curl_exec($ch);
curl_close($ch);
4.Aliases
An alias forwards mail addressed to one address to one or more destinations. The source must live in a domain managed by this server; the destination can be any RFC 5321-valid address (local or remote).
| Field | Type | Notes |
|---|---|---|
id | int | Server-assigned. |
domain_id | int | FK to the alias's source domain. |
source | string | Full address — local@domain. |
destination | string | Full address. Comma-separated multi-destination is supported by the underlying mail stack. |
created_at | RFC 3339 |
GET /api/v1/aliases
List every alias.
Response 200:
{
"data": [
{
"id": 7,
"domain_id": 1,
"source": "[email protected]",
"destination": "[email protected]",
"created_at": "2026-05-02T03:11:42Z"
}
],
"meta": { "page": 1, "limit": 50, "total": 1 }
}
curl -H "X-API-Key: $KEY" \
http://$HOST:8080/api/v1/aliases
$ch = curl_init("$host/api/v1/aliases");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["X-API-Key: $key"],
]);
$resp = json_decode(curl_exec($ch), true);
curl_close($ch);
POST /api/v1/aliases
Create an alias. To forward to multiple destinations, send them comma-separated in destination, e.g. "[email protected], [email protected]".
Request body:
{
"source": "[email protected]",
"destination": "[email protected]"
}
Response 201: the created alias object.
Errors:
400—source must be of the form local@domain.400—source domain <name> is not managed by this server.
curl -X POST http://$HOST:8080/api/v1/aliases \
-H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
-d '{"source":"[email protected]","destination":"[email protected]"}'
$payload = json_encode([
'source' => '[email protected]',
'destination' => '[email protected]',
]);
$ch = curl_init("$host/api/v1/aliases");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
"X-API-Key: $key",
"Content-Type: application/json",
],
]);
$alias = json_decode(curl_exec($ch), true);
curl_close($ch);
DELETE /api/v1/aliases/:id
Delete an alias.
Response 200: {"message": "alias deleted"}
curl -X DELETE -H "X-API-Key: $KEY" \
http://$HOST:8080/api/v1/aliases/7
$ch = curl_init("$host/api/v1/aliases/7");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_HTTPHEADER => ["X-API-Key: $key"],
]);
curl_exec($ch);
curl_close($ch);
5.Configuration tiers
Mail-stack settings are organised in three tiers. Each tier is a single row; each tier sets defaults that the next-narrower tier can override.
global_config ← system-wide caps
↓
domain_defaults ← applied to every domain
↓ unless overridden
per-domain overrides ← per-row, nullable
↓
mailbox_defaults ← applied to every mailbox
↓ unless overridden
per-mailbox overrides ← per-row, nullable
Where multiple tiers define the same field, the narrowest non-null value wins. The fully-resolved view is exposed by the effective endpoints (domain / mailbox).
GET /api/v1/config/global PUT /api/v1/config/global
System-wide caps and policy defaults. PUT replaces the row in full.
Response 200:
{
"message_size_limit": 26214400,
"max_smtpd_connections": 100,
"max_smtpd_rate_per_min": 600,
"relay_networks": "127.0.0.0/8 [::1]/128",
"tls_min_version": "TLSv1.2",
"cipher_policy": "high",
"spam_remote_host": "",
"spam_remote_port": 783,
"spam_remote_network": "",
"dnsbl_enabled": false,
"dnsbl_zones": "",
"greylist_enabled": false,
"greylist_delay_seconds": 300,
"greylist_max_age_days": 35,
"updated_at": "2026-05-02T03:11:42Z"
}
| Field | Meaning |
|---|---|
message_size_limit | Hard cap, in bytes, on any single message accepted by the SMTP server. |
max_smtpd_connections | Concurrent inbound SMTP connection limit. |
max_smtpd_rate_per_min | Per-client incoming connection rate cap. |
relay_networks | Networks allowed to relay without authentication (space-separated CIDRs). |
tls_min_version | Minimum negotiated TLS version (TLSv1.2, TLSv1.3). |
cipher_policy | Postfix cipher policy preset (medium, high). |
spam_remote_* | Optional external SpamAssassin host. |
dnsbl_* | DNS blocklist toggle and space-separated zone list — see Anti-spam. |
greylist_* | Greylisting toggle, delay before retry is accepted, and how long an auto-allow lasts. |
curl -H "X-API-Key: $KEY" \
http://$HOST:8080/api/v1/config/global
$ch = curl_init("$host/api/v1/config/global");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["X-API-Key: $key"],
]);
$cfg = json_decode(curl_exec($ch), true);
curl_close($ch);
GET /api/v1/config/domain-defaults PUT /api/v1/config/domain-defaults
Defaults applied to every domain unless its row overrides them. A null value in a pointer field means “defer to the next tier up” — for message_size_limit that's the global config.
Response 200:
{
"quota_total_bytes": 10737418240,
"max_mailboxes": 100,
"max_aliases": 100,
"message_size_limit": null,
"dkim_enabled": true,
"spf_policy": "~all",
"dmarc_policy": "none",
"spam_threshold": 5.0,
"spam_action": "tag",
"clamav_action": "quarantine",
"greylisting_enabled": false,
"require_tls_inbound": false,
"updated_at": "2026-05-02T03:11:42Z"
}
| Field | Meaning |
|---|---|
quota_total_bytes | Sum-of-mailbox cap per domain. |
max_mailboxes / max_aliases | Soft caps per domain. |
dkim_enabled | Whether outbound mail for the domain is DKIM-signed. |
spf_policy | Suggested SPF policy when rendering DNS hints (-all / ~all / ?all). |
dmarc_policy | Suggested DMARC policy (none, quarantine, reject). |
spam_threshold / spam_action | Score above which messages are flagged, and what to do (tag, quarantine, reject). |
clamav_action | tag, quarantine, or reject for virus hits. |
greylisting_enabled | Per-domain greylist toggle (also requires the global switch). |
require_tls_inbound | Refuse plaintext inbound SMTP for this domain. |
GET /api/v1/config/mailbox-defaults PUT /api/v1/config/mailbox-defaults
Defaults applied to every mailbox unless its row overrides them. null in any pointer field means “defer to domain or global”.
Response 200:
{
"quota_bytes": 1073741824,
"enabled_smtp": true,
"enabled_imap": true,
"enabled_pop3": false,
"max_send_per_day": 1000,
"max_recipients_per_message": 100,
"message_size_limit": null,
"spam_threshold": null,
"spam_action": null,
"auto_junk_threshold": 7.0,
"vacation_enabled": false,
"vacation_message": "",
"forward_to": "",
"forward_keep_copy": true,
"require_tls_client": false,
"updated_at": "2026-05-02T03:11:42Z"
}
| Field | Meaning |
|---|---|
quota_bytes | Default per-mailbox quota. |
enabled_smtp / enabled_imap / enabled_pop3 | Protocol-level toggles. |
max_send_per_day | policyd-enforced outbound message cap per mailbox. |
max_recipients_per_message | Hard cap on RCPT TO count for a single outbound message. |
auto_junk_threshold | Score above which mail is auto-moved to the user's Junk folder. |
vacation_* | Auto-reply settings (Sieve). |
forward_to / forward_keep_copy | Default outbound forward address; whether to also keep a copy locally. |
require_tls_client | Require TLS for submissions from this mailbox. |
6.Per-domain overrides + effective config
GET /api/v1/domains/:id/overrides PUT /api/v1/domains/:id/overrides
The override columns for a specific domain. Any field that is null means the domain inherits from domain_defaults. On PUT, send only the fields you want to set; omitted or null fields revert to inheritance.
Response 200:
{
"quota_total_bytes": 53687091200,
"max_mailboxes": null,
"max_aliases": null,
"message_size_limit": 52428800,
"dkim_enabled": null,
"spf_policy": null,
"dmarc_policy": "reject",
"spam_threshold": null,
"spam_action": null,
"clamav_action": null,
"greylisting_enabled": null,
"require_tls_inbound": true
}
In this example the domain has a custom storage quota, custom message size cap, custom DMARC policy, and requires TLS inbound; everything else inherits from defaults.
# Tighten one domain — DMARC reject + require inbound TLS
curl -X PUT http://$HOST:8080/api/v1/domains/1/overrides \
-H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
-d '{"dmarc_policy":"reject","require_tls_inbound":true}'
$ch = curl_init("$host/api/v1/domains/1/overrides");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'PUT',
CURLOPT_POSTFIELDS => json_encode([
'dmarc_policy' => 'reject',
'require_tls_inbound' => true,
]),
CURLOPT_HTTPHEADER => [
"X-API-Key: $key",
"Content-Type: application/json",
],
]);
curl_exec($ch);
curl_close($ch);
GET /api/v1/domains/:id/effective
The fully-resolved config for a domain — overrides merged on top of domain_defaults, with cascaded fields resolved against global_config. Read-only; to change a value, PUT the appropriate tier instead.
Response 200:
{
"domain_id": 1,
"domain_name": "example.com",
"outgoing_ip": "203.0.113.10",
"quota_total_bytes": 53687091200,
"max_mailboxes": 100,
"max_aliases": 100,
"message_size_limit": 52428800,
"dkim_enabled": true,
"spf_policy": "~all",
"dmarc_policy": "reject",
"spam_threshold": 5.0,
"spam_action": "tag",
"clamav_action": "quarantine",
"greylisting_enabled": false,
"require_tls_inbound": true,
"active": true
}
curl -H "X-API-Key: $KEY" \
http://$HOST:8080/api/v1/domains/1/effective
$ch = curl_init("$host/api/v1/domains/1/effective");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["X-API-Key: $key"],
]);
$cfg = json_decode(curl_exec($ch), true);
curl_close($ch);
7.Per-mailbox overrides + effective config
GET /api/v1/mailboxes/:id/overrides PUT /api/v1/mailboxes/:id/overrides
Override columns for a specific mailbox. null means inherit from mailbox_defaults.
Response 200:
{
"enabled_smtp": null,
"enabled_imap": null,
"enabled_pop3": true,
"max_send_per_day": 5000,
"max_recipients_per_message": null,
"message_size_limit": null,
"spam_threshold": null,
"spam_action": null,
"auto_junk_threshold": null,
"vacation_enabled": true,
"vacation_message": "On leave until May 15",
"forward_to": null,
"forward_keep_copy": null,
"require_tls_client": null
}
# Boost a mailbox's daily-send limit and require TLS on submit
curl -X PUT http://$HOST:8080/api/v1/mailboxes/12/overrides \
-H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
-d '{
"max_send_per_day":5000,
"require_tls_client":true
}'
$ch = curl_init("$host/api/v1/mailboxes/12/overrides");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'PUT',
CURLOPT_POSTFIELDS => json_encode([
'max_send_per_day' => 5000,
'require_tls_client' => true,
]),
CURLOPT_HTTPHEADER => [
"X-API-Key: $key",
"Content-Type: application/json",
],
]);
curl_exec($ch);
curl_close($ch);
GET /api/v1/mailboxes/:id/effective
Fully-resolved per-mailbox settings — overrides merged on top of mailbox defaults, then domain defaults / overrides for the inherited fields, then global. Read-only.
Response 200:
{
"mailbox_id": 12,
"mailbox_username": "alice",
"domain_id": 1,
"domain_name": "example.com",
"quota_bytes": 1073741824,
"enabled_smtp": true,
"enabled_imap": true,
"enabled_pop3": true,
"max_send_per_day": 5000,
"max_recipients_per_message": 100,
"message_size_limit": 52428800,
"spam_threshold": 5.0,
"spam_action": "tag",
"auto_junk_threshold": 7.0,
"vacation_enabled": true,
"vacation_message": "On leave until May 15",
"forward_to": "",
"forward_keep_copy": true,
"require_tls_client": false,
"clamav_action": "quarantine",
"greylisting_enabled": false,
"require_tls_inbound": true,
"dkim_enabled": true,
"spf_policy": "~all",
"dmarc_policy": "reject",
"mailbox_active": true,
"domain_active": true
}
8.DKIM keys
DKIM signs outbound mail with a private key; the matching public key is published in DNS so receivers can verify the signature. The panel generates and serves keys per <domain, selector> pair.
The TXT-record DNS owner is <selector>._domainkey.<domain> with the value returned in public_key. Most registrars accept the full multi-line value as-is.
POST /api/v1/dkim/generate
Generate a 2048-bit RSA key pair for a domain + selector. If a key already exists for that pair it is overwritten.
| Field | Required | Notes |
|---|---|---|
domain | yes | Must be a valid DNS name. |
selector | no | Defaults to default; must be a valid DKIM selector (alphanumerics + -/_). |
Response 201:
{
"domain": "example.com",
"selector": "default",
"public_key": "default._domainkey IN TXT ( \"v=DKIM1; h=sha256; k=rsa; \" \"p=MIIBIj...\" )",
"private_key_path": "/etc/opendkim/keys/example.com/default.private"
}
Errors:
400— invalid domain name or selector.500—opendkim-genkeyfailed (likely the package isn't installed on the host or the keystore directory isn't writable).
curl -X POST http://$HOST:8080/api/v1/dkim/generate \
-H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
-d '{"domain":"example.com","selector":"default"}'
$payload = json_encode([
'domain' => 'example.com',
'selector' => 'default',
]);
$ch = curl_init("$host/api/v1/dkim/generate");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
"X-API-Key: $key",
"Content-Type: application/json",
],
]);
$key_info = json_decode(curl_exec($ch), true);
curl_close($ch);
echo "Publish at DNS:\n {$key_info['public_key']}\n";
GET /api/v1/dkim/list
List every DKIM key stored on the host (one entry per <domain, selector>).
Response 200:
{
"data": [
{
"domain": "example.com",
"selector": "default",
"dns_record": "default._domainkey IN TXT ( \"v=DKIM1; h=sha256; k=rsa; \" \"p=MIIBIj...\" )"
}
],
"meta": { "page": 1, "limit": 50, "total": 1 }
}
curl -H "X-API-Key: $KEY" \
http://$HOST:8080/api/v1/dkim/list
$ch = curl_init("$host/api/v1/dkim/list");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["X-API-Key: $key"],
]);
$resp = json_decode(curl_exec($ch), true);
curl_close($ch);
GET /api/v1/dkim?domain=<name>&selector=<sel>
Return the public-key TXT for a single <domain, selector> pair. Useful when you need to re-fetch the value to paste at a registrar.
| Query param | Default | Notes |
|---|---|---|
domain | required | |
selector | default |
Errors: 404 — public key not found.
curl -H "X-API-Key: $KEY" \
"http://$HOST:8080/api/v1/dkim?domain=example.com&selector=default"
$qs = http_build_query([
'domain' => 'example.com',
'selector' => 'default',
]);
$ch = curl_init("$host/api/v1/dkim?$qs");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["X-API-Key: $key"],
]);
$dkim = json_decode(curl_exec($ch), true);
curl_close($ch);
9.TLS certificates
Mail-stack TLS certificates for SMTP (Postfix) and IMAP/POP3 (Dovecot). The panel manages three kinds of certs: self-signed (created by the installer), Let's Encrypt (issued via certbot), and operator-uploaded PEM bundles. After any cert change the mail stack is reconciled in the background, so the new cert is in effect within a couple of seconds.
GET /api/v1/tls/certs
List every registered cert. expires_at is omitted when unknown.
Response 200:
{
"data": [
{
"id": 1,
"hostname": "mail.example.com",
"cert_path": "/etc/letsencrypt/live/mail.example.com/fullchain.pem",
"key_path": "/etc/letsencrypt/live/mail.example.com/privkey.pem",
"issuer": "letsencrypt",
"expires_at": "2026-08-10T12:00:00Z",
"created_at": "2026-05-02T03:11:42Z"
}
],
"meta": { "page": 1, "limit": 50, "total": 1 }
}
issuer is one of self, letsencrypt, or uploaded.
curl -H "X-API-Key: $KEY" \
http://$HOST:8080/api/v1/tls/certs
$ch = curl_init("$host/api/v1/tls/certs");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["X-API-Key: $key"],
]);
$certs = json_decode(curl_exec($ch), true)['data'];
curl_close($ch);
POST /api/v1/tls/issue
Issue a fresh Let's Encrypt cert via certbot --standalone. Port 80 must be free on the host for the HTTP-01 challenge while the call is running. The cert is recorded in the panel DB on success and the mail stack is reconciled in the background.
| Field | Required | Notes |
|---|---|---|
domain | yes | The hostname to issue for (typically your public MX hostname). |
email | yes | ACME registration email; used by Let's Encrypt for expiry notifications. |
Response 200: {"message": "certificate issued"}
Errors: 400 — invalid domain; 500 — certbot failed. error contains the trimmed certbot output and detail the exit-code message. Common causes: port 80 already in use, DNS doesn't resolve to this host, rate-limited by Let's Encrypt.
curl -X POST http://$HOST:8080/api/v1/tls/issue \
-H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
-d '{"domain":"mail.example.com","email":"[email protected]"}'
$payload = json_encode([
'domain' => 'mail.example.com',
'email' => '[email protected]',
]);
$ch = curl_init("$host/api/v1/tls/issue");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
"X-API-Key: $key",
"Content-Type: application/json",
],
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
throw new RuntimeException("Issue failed: $body");
}
POST /api/v1/tls/upload multipart/form-data
Upload a custom PEM cert + key pair. The bundle is stored under /etc/backendside-mailpanel/tls/uploaded/<hostname>/ and registered in the panel DB. The mail stack is reconciled in the background.
multipart/form-data.
| Form field | Required | Notes |
|---|---|---|
hostname | yes | CN/SAN the cert covers. Validated as a DNS name. |
cert_pem | yes | Full PEM-encoded chain (leaf + intermediates). |
key_pem | yes | PEM-encoded private key. |
Response 200:
{
"message": "certificate uploaded and applied",
"expires_at": "2027-01-15T00:00:00Z"
}
Errors: 400 — missing field, invalid hostname, or unparseable PEM; 500 — filesystem write or DB upsert failed.
curl -X POST http://$HOST:8080/api/v1/tls/upload \
-H "X-API-Key: $KEY" \
-F 'hostname=mail.example.com' \
-F "cert_pem=$(cat /path/to/fullchain.pem)" \
-F "key_pem=$(cat /path/to/privkey.pem)"
$ch = curl_init("$host/api/v1/tls/upload");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => [
'hostname' => 'mail.example.com',
'cert_pem' => file_get_contents('/path/to/fullchain.pem'),
'key_pem' => file_get_contents('/path/to/privkey.pem'),
],
CURLOPT_HTTPHEADER => ["X-API-Key: $key"],
]);
$resp = json_decode(curl_exec($ch), true);
curl_close($ch);
echo "Cert expires: {$resp['expires_at']}\n";
POST /api/v1/tls/renew
Run certbot renew --quiet to refresh any near-expiry Let's Encrypt cert, then reconcile the mail stack. Safe to call on a schedule (e.g. weekly cron) — certbot renew is a no-op for certs that aren't due yet.
Response 200: {"message": "certificates renewed"}
curl -X POST -H "X-API-Key: $KEY" \
http://$HOST:8080/api/v1/tls/renew
$ch = curl_init("$host/api/v1/tls/renew");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["X-API-Key: $key"],
]);
curl_exec($ch);
curl_close($ch);
DELETE /api/v1/tls/certs/:id
Remove a cert record from the panel DB. Does not delete the underlying PEM files from disk; clean those up by hand if desired.
Response 200: {"message": "certificate record removed"}
curl -X DELETE -H "X-API-Key: $KEY" \
http://$HOST:8080/api/v1/tls/certs/1
$ch = curl_init("$host/api/v1/tls/certs/1");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_HTTPHEADER => ["X-API-Key: $key"],
]);
curl_exec($ch);
curl_close($ch);
10.Anti-spam (DNSBL + greylist + whitelist)
A focused subset of the global config for DNSBL and greylisting, plus a full CRUD over a sender/network whitelist that bypasses spam checks.
GET /api/v1/config/antispam
Read the DNSBL + greylist subset of global_config.
Response 200:
{
"dnsbl_enabled": true,
"dnsbl_zones": "zen.spamhaus.org bl.spamcop.net",
"greylist_enabled": true,
"greylist_delay_seconds": 300,
"greylist_max_age_days": 35
}
| Field | Meaning |
|---|---|
dnsbl_enabled | Whether inbound mail is checked against DNS blocklists. |
dnsbl_zones | Space-separated list of DNSBL zones to query. |
greylist_enabled | Whether new sender triplets are temporarily deferred. |
greylist_delay_seconds | Minimum wait before a retry is accepted. |
greylist_max_age_days | How long an auto-allow entry persists once a sender passes. |
PUT /api/v1/config/antispam
Update the DNSBL + greylist settings. Other global config fields are not touched. After saving, the mail stack is reconciled in the background.
Response 200: {"message": "anti-spam config saved; reconciling…"}
Errors: 400 — greylist delay and max-age must be non-negative.
curl -X PUT http://$HOST:8080/api/v1/config/antispam \
-H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
-d '{
"dnsbl_enabled":true,
"dnsbl_zones":"zen.spamhaus.org bl.spamcop.net",
"greylist_enabled":true,
"greylist_delay_seconds":300,
"greylist_max_age_days":35
}'
$payload = json_encode([
'dnsbl_enabled' => true,
'dnsbl_zones' => 'zen.spamhaus.org bl.spamcop.net',
'greylist_enabled' => true,
'greylist_delay_seconds' => 300,
'greylist_max_age_days' => 35,
]);
$ch = curl_init("$host/api/v1/config/antispam");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'PUT',
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
"X-API-Key: $key",
"Content-Type: application/json",
],
]);
curl_exec($ch);
curl_close($ch);
GET /api/v1/antispam/whitelist
List every whitelist entry. Whitelisted senders/networks bypass DNSBL and greylisting.
Response 200:
{
"data": [
{
"id": 1,
"entry_type": "ip",
"value": "203.0.113.10",
"note": "monitoring sender",
"created_at": "2026-05-02T03:11:42Z"
},
{
"id": 2,
"entry_type": "domain",
"value": "trusted-partner.com",
"note": "",
"created_at": "2026-05-02T03:11:42Z"
}
],
"meta": { "page": 1, "limit": 50, "total": 2 }
}
POST /api/v1/antispam/whitelist
Add a whitelist entry. Triggers a background reconcile.
| Field | Required | Values |
|---|---|---|
entry_type | yes | ip, cidr, domain, or email. |
value | yes | The IP / CIDR / domain / address. |
note | no | Free-text note for operator reference. |
Response 200: the created entry.
Errors: 400 — entry_type must be one of ip, cidr, domain, email or value is required.
curl -X POST http://$HOST:8080/api/v1/antispam/whitelist \
-H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
-d '{
"entry_type":"domain",
"value":"trusted-partner.com",
"note":"newsletter"
}'
$payload = json_encode([
'entry_type' => 'domain',
'value' => 'trusted-partner.com',
'note' => 'newsletter',
]);
$ch = curl_init("$host/api/v1/antispam/whitelist");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
"X-API-Key: $key",
"Content-Type: application/json",
],
]);
$entry = json_decode(curl_exec($ch), true);
curl_close($ch);
DELETE /api/v1/antispam/whitelist/:id
Remove a whitelist entry. Triggers a background reconcile.
Response 200: {"message": "whitelist entry deleted"}
curl -X DELETE -H "X-API-Key: $KEY" \
http://$HOST:8080/api/v1/antispam/whitelist/1
$ch = curl_init("$host/api/v1/antispam/whitelist/1");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => 'DELETE',
CURLOPT_HTTPHEADER => ["X-API-Key: $key"],
]);
curl_exec($ch);
curl_close($ch);
11.Mail queue
Inspect and operate on the Postfix outbound queue. Useful when troubleshooting delivery delays or wiping a backlog after fixing a misconfiguration.
GET /api/v1/postfix/queue
List every message currently in the queue. queue_name is one of active, deferred, hold, incoming. arrival_time is a Unix timestamp.
Response 200:
{
"data": [
{
"queue_name": "active",
"queue_id": "A1B2C3D4E5",
"arrival_time": 1714686702,
"message_size": 4823,
"sender": "[email protected]",
"recipients": [
{ "address": "[email protected]", "delay_reason": "" }
]
},
{
"queue_name": "deferred",
"queue_id": "F6G7H8I9J0",
"arrival_time": 1714686500,
"message_size": 1024,
"sender": "[email protected]",
"recipients": [
{ "address": "[email protected]",
"delay_reason": "Connection timed out" }
]
}
],
"meta": { "page": 1, "limit": 50, "total": 2 }
}
# Quick count
curl -s -H "X-API-Key: $KEY" http://$HOST:8080/api/v1/postfix/queue \
| jq '.data | length'
$ch = curl_init("$host/api/v1/postfix/queue");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["X-API-Key: $key"],
]);
$messages = json_decode(curl_exec($ch), true)['data'];
curl_close($ch);
echo "Queue depth: " . count($messages) . PHP_EOL;
POST /api/v1/postfix/flush
Attempt to redeliver every deferred message immediately (postqueue -f).
Response 200: {"message": "queue flushed"}
curl -X POST -H "X-API-Key: $KEY" \
http://$HOST:8080/api/v1/postfix/flush
$ch = curl_init("$host/api/v1/postfix/flush");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["X-API-Key: $key"],
]);
curl_exec($ch);
curl_close($ch);
POST /api/v1/postfix/queue/delete
Delete a single message by queue ID (postsuper -d <id>).
Request body: { "message_id": "A1B2C3D4E5" }
Response 200: {"message": "message deleted"}
Errors: 400 — message_id missing; 500 — postsuper returned non-zero (most often the ID is no longer in the queue).
curl -X POST http://$HOST:8080/api/v1/postfix/queue/delete \
-H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
-d '{"message_id":"A1B2C3D4E5"}'
$payload = json_encode(['message_id' => 'A1B2C3D4E5']);
$ch = curl_init("$host/api/v1/postfix/queue/delete");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => [
"X-API-Key: $key",
"Content-Type: application/json",
],
]);
curl_exec($ch);
curl_close($ch);
12.Statistics & service status
GET /api/v1/stats/mail
Sent + received counts for the last 1 hour and 24 hours, derived from the mail journal.
Response 200:
{
"sent_1h": 12,
"sent_24h": 248,
"recv_1h": 47,
"recv_24h": 1893
}
- Sent counts authenticated submissions on the submission port (one per user-initiated outbound message, regardless of whether the final delivery is local or external).
- Received counts local-delivery handoffs to the IMAP/POP3 store (one per successfully delivered inbound message).
When the journal can't be read (e.g. on a host without systemd-journald), the endpoint returns zeros rather than an error so the UI can render cleanly.
curl -H "X-API-Key: $KEY" \
http://$HOST:8080/api/v1/stats/mail
$ch = curl_init("$host/api/v1/stats/mail");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["X-API-Key: $key"],
]);
$stats = json_decode(curl_exec($ch), true);
curl_close($ch);
printf("Sent: %d/24h Recv: %d/24h\n",
$stats['sent_24h'], $stats['recv_24h']);
GET /api/v1/postfix/status
GET /api/v1/dovecot/status
Read-only status of the Postfix and Dovecot daemons. Useful for monitoring integration. status is one of running, stopped, or unknown; message carries the underlying systemctl is-active output when status is not running.
Response 200:
{
"name": "postfix",
"status": "running",
"message": ""
}
curl -H "X-API-Key: $KEY" http://$HOST:8080/api/v1/postfix/status
curl -H "X-API-Key: $KEY" http://$HOST:8080/api/v1/dovecot/status
foreach (['postfix', 'dovecot'] as $svc) {
$ch = curl_init("$host/api/v1/$svc/status");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["X-API-Key: $key"],
]);
$s = json_decode(curl_exec($ch), true);
curl_close($ch);
echo "$svc: {$s['status']}\n";
}
13.Apply configuration (reconcile)
Most mutations reconcile the mail stack automatically. The reconcile endpoint is exposed in case you've made a change you want to apply immediately, or are recovering from a partial state.
POST /api/v1/system/reconcile
Re-renders Postfix main.cf / master.cf, Dovecot user/auth configs, OpenDKIM key tables, OpenDMARC config, the policyd recipient maps, and the spam/anti-spam wiring from the current DB state, then reloads each daemon.
Response 200:
{
"message": "mail config applied",
"log": [
"applying postfix transport...",
"regenerated dovecot user db",
"..."
]
}
Errors: 500 — reconcile failed; error contains the failing step and log the trace up to that point.
curl -X POST -H "X-API-Key: $KEY" \
http://$HOST:8080/api/v1/system/reconcile
$ch = curl_init("$host/api/v1/system/reconcile");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["X-API-Key: $key"],
]);
$resp = json_decode(curl_exec($ch), true);
curl_close($ch);
foreach ($resp['log'] as $line) echo $line . PHP_EOL;
14.Backup + restore
JSON dump of the data plane: domains, mailboxes (with hashed passwords), aliases, configuration tiers (global, defaults, overrides), TLS cert registrations, DKIM key references, and anti-spam whitelist. Maildir contents on disk and the actual DKIM private keys are not included; back those up separately with file-level tooling.
GET /api/v1/backup
Stream the snapshot as a downloadable JSON attachment. Filename is backendside-mailpanel-backup-YYYYMMDD-HHMMSS.json (UTC). The body is a self-describing object including a schema_version field — keep that consistent between export and a later restore.
curl -H "X-API-Key: $KEY" -O -J \
http://$HOST:8080/api/v1/backup
$ch = curl_init("$host/api/v1/backup");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["X-API-Key: $key"],
]);
$body = curl_exec($ch);
curl_close($ch);
$ts = gmdate('Ymd-His');
file_put_contents("mailpanel-backup-$ts.json", $body);
POST /api/v1/backup/restore
Destructive restore: existing rows in domains / mailboxes / aliases / config tables are dropped and replaced with the snapshot contents. The mail stack is reconciled in the background once the import succeeds.
GET /api/v1/backup before running a restore so you can roll back.
Response 200:
{
"message": "backup restored",
"domains": 3,
"mailboxes": 27,
"aliases": 14
}
Errors: 400 — body is not a valid backup document; 500 — restore failed (commonly: schema version mismatch or DB unreachable).
curl -X POST http://$HOST:8080/api/v1/backup/restore \
-H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
--data @mailpanel-backup-20260502-031142.json
$ch = curl_init("$host/api/v1/backup/restore");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => file_get_contents('mailpanel-backup-20260502-031142.json'),
CURLOPT_HTTPHEADER => [
"X-API-Key: $key",
"Content-Type: application/json",
],
]);
$resp = json_decode(curl_exec($ch), true);
curl_close($ch);
printf("Restored: %d domains, %d mailboxes, %d aliases\n",
$resp['domains'], $resp['mailboxes'], $resp['aliases']);
15.System info & health
GET /api/v1/system/about
Build identification and software-version snapshot of the host. postfix / dovecot are reported as "not installed" when the respective binary isn't on $PATH.
Response 200:
{
"app": "BackendSide MailPanel",
"version": "0.1.0",
"build": "056",
"copyright": "© 2025 backendside.com",
"website": "https://backendside.com",
"hostname": "mail.example.com",
"os": "Ubuntu 24.04.1 LTS",
"postfix": "3.8.1",
"dovecot": "2.3.21"
}
curl -H "X-API-Key: $KEY" \
http://$HOST:8080/api/v1/system/about
$ch = curl_init("$host/api/v1/system/about");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["X-API-Key: $key"],
]);
$about = json_decode(curl_exec($ch), true);
curl_close($ch);
GET /health unauthenticated
Deep health probe. Pings the database, checks Postfix and Dovecot service status, and warns on low maildir disk free. Returns 503 Service Unavailable when any critical dependency is down so a load balancer can pull the panel out of rotation.
Response 200 (or 503):
{
"status": "ok",
"version": "0.1.0",
"build": "056",
"checks": {
"database": { "status": "ok" },
"postfix": { "status": "ok" },
"dovecot": { "status": "ok" },
"maildir": { "status": "ok", "free_space": "412G" }
}
}
curl http://$HOST:8080/health
$ch = curl_init("$host/health");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
]);
$health = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) {
// 503 — at least one critical check failed
print_r($health['checks']);
}