BackendSide MailPanel

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.

Base URL   http://<your-host>:8080 Auth   X-API-Key: …  or  Authorization: Bearer … Format   application/json
What's new in Build 056. This build aligns the response and error envelopes with the rest of the BackendSide product family so a single client can drive both MailPanel and DNS Manager:
  • 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_KEY still works; you can also mint named, rotatable tokens via /api/v1/admin/tokens (see the shipped API.md for the full surface).
  • Config path renamed. /etc/backendside-mailpanel/.env is now /etc/backendside-mailpanel/config.env.
See Conventions & errors for the full shapes.

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);
Which one to use? Pick 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 uses multipart/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 200 or 201. Validation failures use 400, auth failures 401, IP / rate-limit blocks 403 and 429, missing items 404, conflicts 409, server errors 500.

Response envelopes. Build 056 unifies how every endpoint returns data:

  • List endpoints return {"data": […], "meta": {"page", "limit", "total"}}. Paginate with ?page=N&limit=N (defaults 1 / 50, max limit=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.

CodeHTTPMeaning
VALIDATION_FAILED400A field on the request body failed validation.
UNAUTHORIZED401Auth header missing, malformed, or wrong.
IP_RESTRICTED403Source 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_FOUND404Target resource doesn't exist.
CONFLICT409Resource already exists.
RATE_LIMITED429Per-IP rate limit exceeded. The response includes a Retry-After: 60 header; the current limits are visible via GET /api/v1/security/settings.
INTERNAL_ERROR500Unhandled server-side failure — error carries the operator-facing summary.
DEPENDENCY_UNAVAILABLE500/503An external dependency (DB, mail daemon) is down.
Background reconcile. Most mutations that change the mail stack (domain create/delete, TLS change, anti-spam config change, backup restore) reconcile Postfix, Dovecot, OpenDKIM, OpenDMARC, and policyd in the background — no manual restart needed.

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.

FieldTypeNotes
idintServer-assigned.
namestringDNS domain name, e.g. example.com.
activeboolfalse disables mail flow for the domain.
outgoing_ipstringSource IP for outbound mail. Empty = system default. "auto" at create time asks the server to pick the least-used host IP.
created_atRFC 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.

FieldRequiredNotes
nameyesValid DNS name.
outgoing_ipnoSpecific 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 missing name or malformed.
  • 500 — DB insert failed, maildir creation failed (in which case the DB row is rolled back), or balanced-IP selection failed when outgoing_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.

This is destructive. Every mailbox and alias under the domain is also deleted via 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: 400invalid id; 404domain 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>.

FieldTypeNotes
idintServer-assigned.
domain_idintFK to the parent domain.
usernamestringLocal part only (e.g. alice, not [email protected]).
quotaintMailbox-size cap in bytes; 0 = unlimited.
activeboolfalse disables login and delivery.
created_atRFC 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.

FieldRequiredNotes
domain_idyesMust reference an existing domain.
usernameyesLocal part. alice and [email protected] are both accepted, but the suffix (when present) must match the domain referenced by domain_id.
passwordyesPlaintext — hashed server-side before storage.
quotanoBytes. 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, or username domain "x" does not match domain_id (...).
  • 404domain 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).

FieldTypeNotes
idintServer-assigned.
domain_idintFK to the alias's source domain.
sourcestringFull address — local@domain.
destinationstringFull address. Comma-separated multi-destination is supported by the underlying mail stack.
created_atRFC 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:

  • 400source must be of the form local@domain.
  • 400source 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 domainunless overridden
              per-domain overrides             ← per-row, nullable
                       ↓
                 mailbox_defaults              ← applied to every mailboxunless 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"
}
FieldMeaning
message_size_limitHard cap, in bytes, on any single message accepted by the SMTP server.
max_smtpd_connectionsConcurrent inbound SMTP connection limit.
max_smtpd_rate_per_minPer-client incoming connection rate cap.
relay_networksNetworks allowed to relay without authentication (space-separated CIDRs).
tls_min_versionMinimum negotiated TLS version (TLSv1.2, TLSv1.3).
cipher_policyPostfix 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"
}
FieldMeaning
quota_total_bytesSum-of-mailbox cap per domain.
max_mailboxes / max_aliasesSoft caps per domain.
dkim_enabledWhether outbound mail for the domain is DKIM-signed.
spf_policySuggested SPF policy when rendering DNS hints (-all / ~all / ?all).
dmarc_policySuggested DMARC policy (none, quarantine, reject).
spam_threshold / spam_actionScore above which messages are flagged, and what to do (tag, quarantine, reject).
clamav_actiontag, quarantine, or reject for virus hits.
greylisting_enabledPer-domain greylist toggle (also requires the global switch).
require_tls_inboundRefuse 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"
}
FieldMeaning
quota_bytesDefault per-mailbox quota.
enabled_smtp / enabled_imap / enabled_pop3Protocol-level toggles.
max_send_per_daypolicyd-enforced outbound message cap per mailbox.
max_recipients_per_messageHard cap on RCPT TO count for a single outbound message.
auto_junk_thresholdScore above which mail is auto-moved to the user's Junk folder.
vacation_*Auto-reply settings (Sieve).
forward_to / forward_keep_copyDefault outbound forward address; whether to also keep a copy locally.
require_tls_clientRequire 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.

FieldRequiredNotes
domainyesMust be a valid DNS name.
selectornoDefaults 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.
  • 500opendkim-genkey failed (likely the package isn't installed on the host or the keystore directory isn't writable).
After generating, publish the TXT record at your DNS provider and run the reconcile endpoint (or just wait — config is also applied on the next domain mutation). Inbound mail will continue to verify signatures regardless; the new key only affects outbound.
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 paramDefaultNotes
domainrequired
selectordefault

Errors: 404public 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.

FieldRequiredNotes
domainyesThe hostname to issue for (typically your public MX hostname).
emailyesACME 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.

This is the only endpoint in the API that is not JSON-encoded. The request body must be multipart/form-data.
Form fieldRequiredNotes
hostnameyesCN/SAN the cert covers. Validated as a DNS name.
cert_pemyesFull PEM-encoded chain (leaf + intermediates).
key_pemyesPEM-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
}
FieldMeaning
dnsbl_enabledWhether inbound mail is checked against DNS blocklists.
dnsbl_zonesSpace-separated list of DNSBL zones to query.
greylist_enabledWhether new sender triplets are temporarily deferred.
greylist_delay_secondsMinimum wait before a retry is accepted.
greylist_max_age_daysHow 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: 400greylist 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.

FieldRequiredValues
entry_typeyesip, cidr, domain, or email.
valueyesThe IP / CIDR / domain / address.
notenoFree-text note for operator reference.

Response 200: the created entry.

Errors: 400entry_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: 400message_id missing; 500postsuper 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": ""
}
Service start/stop/restart and log streams are intentionally not exposed here — those are admin-panel-only operations.
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.

This operation cannot be undone via the API. Take a fresh 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" }
  }
}
If you only need a TCP liveness probe, hit the listening port directly — this endpoint does real work and is more expensive.
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']);
}