// 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
- 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.
- Send it on every request:
Authorization: Bearer <token>andAccept: 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
| Status | Description |
|---|---|
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
| Field | Type | Description |
|---|---|---|
name required |
string |
The domain, lowercase. Unique across mxproxy. |
{
"name": "cliente.com"
}
Responses
| Status | Description |
|---|---|
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
| Name | In | Type | Description |
|---|---|---|---|
id required |
path | string (uuid) |
The domain’s id. |
Responses
| Status | Description |
|---|---|
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
| Name | In | Type | Description |
|---|---|---|---|
id required |
path | string (uuid) |
The domain’s id. |
Body
| Field | Type | Description |
|---|---|---|
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
| Status | Description |
|---|---|
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
| Name | In | Type | Description |
|---|---|---|---|
id required |
path | string (uuid) |
The domain’s id. |
Responses
| Status | Description |
|---|---|
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
| Name | In | Type | Description |
|---|---|---|---|
id required |
path | string (uuid) |
The domain’s id. |
Responses
| Status | Description |
|---|---|
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
| Name | In | Type | Description |
|---|---|---|---|
id required |
path | string (uuid) |
The domain’s id. |
Responses
| Status | Description |
|---|---|
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
| Name | In | Type | Description |
|---|---|---|---|
id required |
path | string (uuid) |
The domain’s id. |
Body
| Field | Type | Description |
|---|---|---|
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
| Status | Description |
|---|---|
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
| Name | In | Type | Description |
|---|---|---|---|
id required |
path | string (uuid) |
The forwarder’s id. |
Body
| Field | Type | Description |
|---|---|---|
destinations |
string[] |
|
enabled |
boolean |
|
channel_ids |
string[] |
{
"destinations": [
"ana@gmail.com"
],
"enabled": true,
"channel_ids": [
"0192f0a0-7d1c-7b3e-9f00-5a1b2c3d4e5f"
]
}
Responses
| Status | Description |
|---|---|
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
| Name | In | Type | Description |
|---|---|---|---|
id required |
path | string (uuid) |
The forwarder’s id. |
Responses
| Status | Description |
|---|---|
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
| Name | In | Type | Description |
|---|---|---|---|
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
| Status | Description |
|---|---|
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
2xxquickly and process later. 5xx,408,429(withRetry-Afterin 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 other4xx(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 (
skippedin 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
| Name | In | Type | Description |
|---|---|---|---|
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
| Field | Type | Description |
|---|---|---|
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
| Status | Description |
|---|---|
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). |