Skip to content
mxproxy

// api v1

API reference

Domains, forwarders and logs of your team, and the signed webhook of the channels.

Base URL https://app.mxproxy.dev/api/v1 · openapi.yaml

Overview

The mxproxy REST API lets a system manage the domains and forwarders of a team and read its message log. The page at https://mxproxy.dev/docs/api is generated from this file.

Authentication

  1. In the dashboard, open the user menu → API tokens and create a token with the permissions you need. The value is shown once: store it.
  2. Send it on every request: Authorization: Bearer <token> and Accept: application/json.

A token acts on the team that was selected when it was created (switching teams later does not change that). If you leave the team, the token gets 403. Writing also requires you to be the team’s owner or an admin.

Permission Grants
domains:read GET /domains, GET /domains/{id}, GET /domains/{id}/forwarders
domains:write create, change, delete and verify domains; create, change and delete forwarders
logs:read GET /logs

Rate limit: 120 requests per minute per user (429 above that).

Responses and errors

Success is {"data": ...}; DELETE answers 204 with no body. Error messages are in Portuguese.

Status Body When
401 {"message": "Unauthenticated."} no token or an invalid one
403 {"message": "..."} a permission the token lacks, a role without write access, a team you left
404 {"message": "Não encontrado neste time."} an id of another team, or none at all
422 {"message": "...", "errors": {"field": ["msg"]}} validation
422 {"message": "...", "error": "plan_limit"} the plan allows no more (domains, forwarders); on the free plan, every write
502/503 {"message": "..."} the mail service refused or is unavailable: try again

Example

curl -s https://app.mxproxy.dev/api/v1/domains/$ID/forwarders \
  -H "Authorization: Bearer $TOKEN" -H 'Accept: application/json' -H 'Content-Type: application/json' \
  -d '{"local_part":"sales","destinations":["ana@gmail.com","bia@outlook.com"]}'

Domains

The domains of the team, the DNS records to create and their verification.

GET /domains

List the domains

Permission: domains:read.

Responses

StatusDescription
200 The domains of the team.
401 No token, or an invalid one.
403 A permission the token lacks, a role without write access, or a team you left.

200 example

{
    "data": [
        {
            "id": "0192f0a0-7d1c-7b3e-9f00-5a1b2c3d4e5f",
            "name": "cliente.com",
            "verified_at": "2026-10-17T12:00:00Z",
            "mx_ok": true,
            "spf_ok": true,
            "dkim_ok": false,
            "last_checked_at": "2026-10-17T12:00:00Z",
            "catch_all": [
                "owner@gmail.com"
            ],
            "catch_all_channel_ids": [
                "0192f0a0-7d1c-7b3e-9f00-5a1b2c3d4e5f"
            ],
            "spam_level": "normal",
            "created_at": "2026-10-17T11:00:00Z"
        }
    ]
}

POST /domains

Add a domain

Permission: domains:write. Generates the verification token and the DKIM key; read the domain to get the DNS records to create.

Body

FieldTypeDescription
name required string The domain, lowercase. Unique across mxproxy.
{
    "name": "cliente.com"
}

Responses

StatusDescription
201 The new domain.
401 No token, or an invalid one.
403 A permission the token lacks, a role without write access, or a team you left.
422 Validation (errors by field) or the plan’s limit (error = plan_limit; the free plan gets it for every write).

201 example

{
    "data": {
        "id": "0192f0a0-7d1c-7b3e-9f00-5a1b2c3d4e5f",
        "name": "cliente.com",
        "verified_at": "2026-10-17T12:00:00Z",
        "mx_ok": true,
        "spf_ok": true,
        "dkim_ok": false,
        "last_checked_at": "2026-10-17T12:00:00Z",
        "catch_all": [
            "owner@gmail.com"
        ],
        "catch_all_channel_ids": [
            "0192f0a0-7d1c-7b3e-9f00-5a1b2c3d4e5f"
        ],
        "spam_level": "normal",
        "created_at": "2026-10-17T11:00:00Z"
    }
}

GET /domains/{id}

Get a domain and its DNS records

Permission: domains:read. dns_records are the records to create at your DNS provider: MX, SPF (TXT @), DKIM (TXT <selector>._domainkey) and the _mxproxy verification TXT.

Parameters

NameInTypeDescription
id required path string (uuid) The domain’s id.

Responses

StatusDescription
200 The domain with the records to create.
401 No token, or an invalid one.
403 A permission the token lacks, a role without write access, or a team you left.
404 An id of another team, or none at all.

200 example

{
    "data": {
        "id": "0192f0a0-7d1c-7b3e-9f00-5a1b2c3d4e5f",
        "name": "cliente.com",
        "verified_at": "2026-10-17T12:00:00Z",
        "mx_ok": true,
        "spf_ok": true,
        "dkim_ok": false,
        "last_checked_at": "2026-10-17T12:00:00Z",
        "catch_all": [
            "owner@gmail.com"
        ],
        "catch_all_channel_ids": [
            "0192f0a0-7d1c-7b3e-9f00-5a1b2c3d4e5f"
        ],
        "spam_level": "normal",
        "created_at": "2026-10-17T11:00:00Z",
        "dns_records": [
            {
                "type": "MX",
                "host": "@",
                "value": "mx.mxproxy.dev",
                "priority": 10
            }
        ]
    }
}

PATCH /domains/{id}

Change the catch-all or the spam filter

Permission: domains:write. Send only the fields to change. An empty catch_all (and no catch-all channel) refuses mail to addresses without a forwarder.

Parameters

NameInTypeDescription
id required path string (uuid) The domain’s id.

Body

FieldTypeDescription
catch_all string[]
catch_all_channel_ids string[] Channels (created in the dashboard) that get a summary of each catch-all message. Replaces the list.
spam_level "off" | "lenient" | "normal" | "strict" How strict the spam filter is. lenient refuses blatant spam and flags the very suspicious; normal refuses clear spam and flags the doubtful; strict refuses even likely spam and flags any suspicion; off passes everything. A flagged message arrives with X-Spam: Yes, X-Spam-Score and [SPAM] at the start of the subject.
{
    "catch_all": [
        "owner@gmail.com"
    ],
    "catch_all_channel_ids": [
        "0192f0a0-7d1c-7b3e-9f00-5a1b2c3d4e5f"
    ],
    "spam_level": "normal"
}

Responses

StatusDescription
200 The changed domain.
401 No token, or an invalid one.
403 A permission the token lacks, a role without write access, or a team you left.
404 An id of another team, or none at all.
422 Validation (errors by field) or the plan’s limit (error = plan_limit; the free plan gets it for every write).

200 example

{
    "data": {
        "id": "0192f0a0-7d1c-7b3e-9f00-5a1b2c3d4e5f",
        "name": "cliente.com",
        "verified_at": "2026-10-17T12:00:00Z",
        "mx_ok": true,
        "spf_ok": true,
        "dkim_ok": false,
        "last_checked_at": "2026-10-17T12:00:00Z",
        "catch_all": [
            "owner@gmail.com"
        ],
        "catch_all_channel_ids": [
            "0192f0a0-7d1c-7b3e-9f00-5a1b2c3d4e5f"
        ],
        "spam_level": "normal",
        "created_at": "2026-10-17T11:00:00Z"
    }
}

DELETE /domains/{id}

Delete a domain

Permission: domains:write. Takes its forwarders and SMTP credentials with it; mail to the domain is refused from then on.

Parameters

NameInTypeDescription
id required path string (uuid) The domain’s id.

Responses

StatusDescription
204 Deleted.
401 No token, or an invalid one.
403 A permission the token lacks, a role without write access, or a team you left.
404 An id of another team, or none at all.

POST /domains/{id}/verify

Check the DNS now

Permission: domains:write. Looks up MX, SPF, DKIM and the verification TXT now and returns the result. DNS changes may take a few hours to propagate.

Parameters

NameInTypeDescription
id required path string (uuid) The domain’s id.

Responses

StatusDescription
200 The domain with the DNS just checked.
401 No token, or an invalid one.
403 A permission the token lacks, a role without write access, or a team you left.
404 An id of another team, or none at all.

200 example

{
    "data": {
        "id": "0192f0a0-7d1c-7b3e-9f00-5a1b2c3d4e5f",
        "name": "cliente.com",
        "verified_at": "2026-10-17T12:00:00Z",
        "mx_ok": true,
        "spf_ok": true,
        "dkim_ok": false,
        "last_checked_at": "2026-10-17T12:00:00Z",
        "catch_all": [
            "owner@gmail.com"
        ],
        "catch_all_channel_ids": [
            "0192f0a0-7d1c-7b3e-9f00-5a1b2c3d4e5f"
        ],
        "spam_level": "normal",
        "created_at": "2026-10-17T11:00:00Z"
    }
}

Forwarders

Where each address of a domain goes.

GET /domains/{id}/forwarders

List the forwarders of a domain

Permission: domains:read.

Parameters

NameInTypeDescription
id required path string (uuid) The domain’s id.

Responses

StatusDescription
200 The forwarders.
401 No token, or an invalid one.
403 A permission the token lacks, a role without write access, or a team you left.
404 An id of another team, or none at all.

200 example

{
    "data": [
        {
            "id": "0192f0a0-7d1c-7b3e-9f00-5a1b2c3d4e5f",
            "domain_id": "0192f0a0-7d1c-7b3e-9f00-5a1b2c3d4e5f",
            "local_part": "sales",
            "destinations": [
                "ana@gmail.com",
                "bia@outlook.com"
            ],
            "enabled": true,
            "created_at": "2026-10-17T12:00:00Z",
            "channel_ids": [
                "0192f0a0-7d1c-7b3e-9f00-5a1b2c3d4e5f"
            ]
        }
    ]
}

POST /domains/{id}/forwarders

Create a forwarder

Permission: domains:write. In effect at once. No destination only when the forwarder has at least one channel: it then sends only the summary.

Parameters

NameInTypeDescription
id required path string (uuid) The domain’s id.

Body

FieldTypeDescription
local_part required string What comes before the @, lowercase letters, digits, ., -, + and _.
destinations string[]
enabled boolean
channel_ids string[] Channels of the team (created in the dashboard, under Channels).
{
    "local_part": "sales",
    "destinations": [
        "ana@gmail.com",
        "bia@outlook.com"
    ],
    "enabled": true,
    "channel_ids": [
        "0192f0a0-7d1c-7b3e-9f00-5a1b2c3d4e5f"
    ]
}

Responses

StatusDescription
201 The new forwarder.
401 No token, or an invalid one.
403 A permission the token lacks, a role without write access, or a team you left.
404 An id of another team, or none at all.
422 Validation (errors by field) or the plan’s limit (error = plan_limit; the free plan gets it for every write).

201 example

{
    "data": {
        "id": "0192f0a0-7d1c-7b3e-9f00-5a1b2c3d4e5f",
        "domain_id": "0192f0a0-7d1c-7b3e-9f00-5a1b2c3d4e5f",
        "local_part": "sales",
        "destinations": [
            "ana@gmail.com",
            "bia@outlook.com"
        ],
        "enabled": true,
        "created_at": "2026-10-17T12:00:00Z",
        "channel_ids": [
            "0192f0a0-7d1c-7b3e-9f00-5a1b2c3d4e5f"
        ]
    }
}

PATCH /forwarders/{id}

Change a forwarder

Permission: domains:write. Send only the fields to change; channel_ids replaces the whole list.

Parameters

NameInTypeDescription
id required path string (uuid) The forwarder’s id.

Body

FieldTypeDescription
destinations string[]
enabled boolean
channel_ids string[]
{
    "destinations": [
        "ana@gmail.com"
    ],
    "enabled": true,
    "channel_ids": [
        "0192f0a0-7d1c-7b3e-9f00-5a1b2c3d4e5f"
    ]
}

Responses

StatusDescription
200 The changed forwarder.
401 No token, or an invalid one.
403 A permission the token lacks, a role without write access, or a team you left.
404 An id of another team, or none at all.
422 Validation (errors by field) or the plan’s limit (error = plan_limit; the free plan gets it for every write).

200 example

{
    "data": {
        "id": "0192f0a0-7d1c-7b3e-9f00-5a1b2c3d4e5f",
        "domain_id": "0192f0a0-7d1c-7b3e-9f00-5a1b2c3d4e5f",
        "local_part": "sales",
        "destinations": [
            "ana@gmail.com",
            "bia@outlook.com"
        ],
        "enabled": true,
        "created_at": "2026-10-17T12:00:00Z",
        "channel_ids": [
            "0192f0a0-7d1c-7b3e-9f00-5a1b2c3d4e5f"
        ]
    }
}

DELETE /forwarders/{id}

Delete a forwarder

Permission: domains:write. Mail to the address then goes to the catch-all, or is refused.

Parameters

NameInTypeDescription
id required path string (uuid) The forwarder’s id.

Responses

StatusDescription
204 Deleted.
401 No token, or an invalid one.
403 A permission the token lacks, a role without write access, or a team you left.
404 An id of another team, or none at all.

Logs

What happened to each message, kept for 30 days.

GET /logs

Read the message log

Permission: logs:read. Newest first. For the next page, pass the next_before you got as before. Kept for 30 days.

Parameters

NameInTypeDescription
domain_id query string (uuid)
status query "accepted" | "rejected" | "spam" | "delivered" | "deferred" | "failed" | "skipped"
before query string (uuid) The next_before of the previous page.
limit query integer

Responses

StatusDescription
200 A page of the log.
401 No token, or an invalid one.
403 A permission the token lacks, a role without write access, or a team you left.
422 Validation (errors by field) or the plan’s limit (error = plan_limit; the free plan gets it for every write).

200 example

{
    "data": [
        {
            "id": "0192f0a0-7d1c-7b3e-9f00-5a1b2c3d4e5f",
            "domain_id": "0192f0a0-7d1c-7b3e-9f00-5a1b2c3d4e5f",
            "direction": "inbound",
            "mail_from": "joao@loja.com.br",
            "rcpt": "contato@cliente.com",
            "destination": "ana@gmail.com",
            "subject": "Pedido 42",
            "size": 48213,
            "status": "accepted",
            "detail": "rspamd score 8.20",
            "created_at": "2026-10-17T12:00:00Z",
            "channel_id": "0192f0a0-7d1c-7b3e-9f00-5a1b2c3d4e5f"
        }
    ],
    "next_before": "0192f0a0-7d1c-7b3e-9f00-5a1b2c3d4e5f"
}

Webhooks

POST your URL

The summary of a received email (webhook channel)

A webhook channel (created in the dashboard, under Channels) gets a POST with the summary of each email of the forwarders and the catch-all you picked it on. The full email never goes to the webhook: only sender, recipients, subject, the first lines of the text (up to 3,500 characters, from text/plain or the HTML without tags) and the names of the attachments.

  • Only https:// and a public address: a URL that resolves to an internal network (loopback, private, link-local, CGNAT, cloud metadata) is refused.
  • Redirects are not followed (the delivery fails): use the final URL.
  • Timeout of 10 s. Answer 2xx quickly and process later.
  • 5xx, 408, 429 (with Retry-After in seconds, if you like) and connection failures are retried after 1 min, 5 min, 15 min, 30 min, 1 h, 2 h and every 4 h, for up to 5 days. Any other 4xx (and a redirect) gives up at once. Everything shows up in Logs.
  • Up to 60 summaries per hour per channel; the rest are not sent (skipped in the log). A message flagged as spam does not go to channels.
  • A retry repeats the same id: use it to ignore duplicates.

Verifying the signature

The secret (whsec_...) is shown once, when the channel is created: store it. X-Mxproxy-Signature is sha256= followed by the hex HMAC-SHA256, with the secret, of <X-Mxproxy-Timestamp>.<body> (the timestamp, a dot and the raw body, byte for byte, before any JSON parsing). Compare in constant time and refuse a timestamp more than 5 minutes away (it stops the replay of an old POST).

PHP:

<?php
$secret = getenv('MXPROXY_WEBHOOK_SECRET');
$body = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_MXPROXY_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_MXPROXY_SIGNATURE'] ?? '';

$expected = 'sha256='.hash_hmac('sha256', $timestamp.'.'.$body, $secret);

if (! hash_equals($expected, $signature) || abs(time() - (int) $timestamp) > 300) {
    http_response_code(401);
    exit;
}

$summary = json_decode($body, true);
http_response_code(204);

Node (Express; the body must arrive raw):

import crypto from 'node:crypto';
import express from 'express';

const app = express();

app.post('/mxproxy', express.raw({ type: 'application/json' }), (req, res) => {
  const timestamp = req.get('X-Mxproxy-Timestamp') ?? '';
  const signature = Buffer.from(req.get('X-Mxproxy-Signature') ?? '');
  const expected = Buffer.from(
    'sha256=' + crypto.createHmac('sha256', process.env.MXPROXY_WEBHOOK_SECRET).update(`${timestamp}.`).update(req.body).digest('hex'),
  );

  const valid = signature.length === expected.length && crypto.timingSafeEqual(signature, expected);
  if (!valid || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return res.sendStatus(401);
  }

  const summary = JSON.parse(req.body);
  res.sendStatus(204);
});

Python (Flask):

import hashlib, hmac, os, time
from flask import Flask, abort, request

app = Flask(__name__)

@app.post('/mxproxy')
def mxproxy():
    timestamp = request.headers.get('X-Mxproxy-Timestamp', '')
    signature = request.headers.get('X-Mxproxy-Signature', '')
    body = request.get_data()  # raw bytes
    expected = 'sha256=' + hmac.new(os.environ['MXPROXY_WEBHOOK_SECRET'].encode(), timestamp.encode() + b'.' + body, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(expected, signature) or abs(time.time() - int(timestamp or 0)) > 300:
        abort(401)

    summary = request.get_json()
    return '', 204

Telegram and WhatsApp need nothing on your side: the summary arrives as a message from the platform’s bot or sending number (WhatsApp, beta).

Parameters

NameInTypeDescription
X-Mxproxy-Timestamp required header string Unix time of this attempt, in seconds.
X-Mxproxy-Signature required header string sha256= + hex HMAC-SHA256(secret, <timestamp>.<raw body>).
User-Agent header string

Body

FieldTypeDescription
id required string (uuid) The delivery’s id, the same on every attempt.
received_at required string (date-time) When mxproxy received the email (UTC).
domain required string
alias required string The address that received it (with its +tag, if any).
from required string The From header (name and address).
to required string The addresses of the To header
subject required string | null
date required string | null (date-time) The email’s Date (RFC 3339).
text required string The beginning of the text.
truncated required boolean true when the text was cut.
attachments required object[] Name, type and size of each attachment; never the content.
attachments[].name required string
attachments[].content_type required string
attachments[].size required integer Bytes.
spam_score required number | null The spam filter’s score, or null if the domain does not filter.
{
    "id": "0192f0a0-7d1c-7b3e-9f00-5a1b2c3d4e5f",
    "received_at": "2026-10-17T14:06:40.123456Z",
    "domain": "cliente.com",
    "alias": "contato@cliente.com",
    "from": "João Silva <joao@loja.com.br>",
    "to": "string",
    "subject": "Pedido 42",
    "date": "2026-10-17T11:06:38-03:00",
    "text": "Olá,\n\nchegou o pedido 42.",
    "truncated": false,
    "attachments": [
        {
            "name": "nota.pdf",
            "content_type": "application/pdf",
            "size": 48213
        }
    ],
    "spam_score": 1.2
}

Responses

StatusDescription
2XX Delivered.
408 Retried later.
429 Retried later, not before the Retry-After you send.
5XX Retried later.
4XX Any other 4xx gives up at once (failed in the log).