Developers

MailWatch API

Read your domains, DNS analysis, DMARC reports and statistics, sending sources and alerts, and add domains from your own tools. The API is included in the Business and Agency plans.

Authentication

Owners and admins create tokens under Organization → API. A token belongs to one organization and acts for the member who created it: it stops working when they leave the organization or are no longer an owner or admin. Send it as a bearer token on every request:

curl -H "Authorization: Bearer mw_…" https://mailwatch.eu/api/v1/domains

Read only tokens get domains.view, reports.view and alerts.view. Read and write tokens can also add domains (domains.manage). A token never has more access than its creator's role. Tokens can't manage members, billing or other tokens.

Conventions

  • Base URL: https://mailwatch.eu/api/v1. Request and response bodies are JSON.
  • Responses wrap results in data. Paginated lists add meta (page, per_page up to 100, total, pages).
  • Times are ISO 8601 in UTC (2026-09-29T12:00:00Z); days are YYYY-MM-DD. Rates are percentages (0–100) or null without messages.
  • Periods (?from=&to=) are inclusive UTC days, at most 366 days, 30 days by default. They start no earlier than your plan's history; the response's period shows the period actually used.

Errors

Errors use RFC 9457 problem details (application/problem+json):

{
  "type": "about:blank",
  "title": "Unprocessable Content",
  "status": 422,
  "detail": "The request body is invalid.",
  "errors": [{"field": "name", "message": "This value should not be blank."}]
}
400
Invalid query parameter or malformed JSON.
401
Missing, unknown, expired or revoked token.
403
The plan doesn't include the API or feature, the token's scope or creator's role doesn't allow it, or a plan limit was reached.
404
Not found in the token's organization.
409
The domain is already monitored.
415
The body isn't sent as Content-Type: application/json.
422
Validation failed; see errors.
429
Rate limit exceeded; wait for Retry-After seconds.

Rate limits

Each token can make 120 requests per minute. Every response reports the budget in X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (Unix time).

Domains

GET /domains needs domains.view

All monitored domains of the organization. protocols holds the latest analysis status per protocol: pass, warning, fail or missing.

{
  "data": [{
    "id": "01928c5e-…",
    "name": "example.com",
    "display_name": "example.com",
    "status": "active",
    "reporting_address": "k3x9…@reports.mailwatch.eu",
    "protocols": {"dmarc": "pass", "spf": "warning", "dkim": "pass", "mx": "pass"},
    "verified_at": "2026-09-01T08:00:00Z",
    "last_dns_check_at": "2026-09-29T06:00:00Z",
    "created_at": "2026-09-01T07:58:12Z"
  }]
}
POST /domains needs domains.manage

Adds a domain and starts its first DNS check. Returns 201 with the domain and a Location header. Publish rua=mailto:<reporting_address> in its DMARC record to receive reports.

curl -X POST https://mailwatch.eu/api/v1/domains \
  -H "Authorization: Bearer mw_…" \
  -H "Content-Type: application/json" \
  -d '{"name": "example.com"}'
GET /domains/{id} needs domains.view

One domain, in the same form as the list.

DNS and analysis

GET /domains/{id}/dns needs domains.view

The current DNS records MailWatch monitors (DMARC, SPF, DKIM selectors, MX, MTA-STS, TLS-RPT, BIMI) and the analysis of each protocol, with its findings.

{
  "data": {
    "last_check_at": "2026-09-29T06:00:00Z",
    "records": [{
      "name": "_dmarc.example.com", "type": "TXT", "status": "found",
      "records": [{"value": "v=DMARC1; p=reject; rua=mailto:…", "ttl": 3600}],
      "cname_chain": [], "dnssec_authenticated": false,
      "changed_at": "2026-09-12T06:00:00Z", "last_seen_at": "2026-09-29T06:00:00Z"
    }],
    "analysis": {
      "dmarc": {
        "status": "pass", "summary": {"policy": "reject", …},
        "findings": [{"severity": "warning", "code": "dmarc.policy_none", "message": "…"}],
        "analyzed_at": "2026-09-29T06:00:01Z", "status_changed_at": "2026-09-12T06:00:01Z"
      }
    }
  }
}

DMARC reports

GET /domains/{id}/dmarc needs reports.view

Stored aggregate reports, newest period first, within the plan's history. Paginated with ?page= and ?per_page=.

{
  "data": [{
    "id": "…", "report_id": "1234567890",
    "reporter": {"organization": "google.com", "email": "noreply-dmarc-support@google.com"},
    "period": {"begin": "2026-09-28T00:00:00Z", "end": "2026-09-28T23:59:59Z"},
    "policy_published": {"domain": "example.com", "adkim": "r", "aspf": "r", "p": "reject", "sp": null, "pct": 100},
    "totals": {"messages": 1520, "dmarc_pass": 1498, "dmarc_fail": 22, "spf_aligned_pass": 1490,
               "dkim_aligned_pass": 1497, "dmarc_pass_rate": 98.55, "spf_pass_rate": 98.03, "dkim_pass_rate": 98.49},
    "records": 14,
    "received_at": "2026-09-29T02:11:40Z"
  }],
  "meta": {"page": 1, "per_page": 25, "total": 88, "pages": 4}
}

Statistics

GET /domains/{id}/statistics?from=&to= needs reports.view

DMARC totals for a period, and one entry per day (days without reports have zero messages).

{
  "data": {
    "period": {"from": "2026-08-31", "to": "2026-09-29"},
    "summary": {"totals": {"messages": 45210, "dmarc_pass": 44870, …}, "sources": 31, "unknown_sources": 2},
    "daily": [{"date": "2026-08-31", "totals": {"messages": 1480, …}}, …]
  }
}

Sending sources

GET /domains/{id}/sources?from=&to=&limit=&unknown= needs reports.view

Who sends as the domain: totals per provider (_unknown for unidentified sources, _pending while being identified), and the busiest source IPs (limit up to 500, default 100). unknown=true lists only sources that match no known provider.

{
  "data": {
    "period": {"from": "2026-08-31", "to": "2026-09-29"},
    "providers": [{"key": "google", "name": "Google Workspace / Gmail", "sources": 12, "totals": {…}}],
    "sources": [{
      "ip": "209.85.220.41", "provider": "google", "reverse_dns": "mail-sor-f41.google.com",
      "asn_name": "GOOGLE", "country": "US", "identified": true,
      "first_seen": "2026-09-01", "last_seen": "2026-09-29", "totals": {…}
    }]
  }
}

Alerts

GET /domains/{id}/alerts?status= needs alerts.view

Alerts of the domain, newest first. status is active (open and acknowledged, the default), open, acknowledged, resolved or all. Paginated.

{
  "data": [{
    "id": "…", "type": "dns_record_changed", "severity": "warning", "status": "open",
    "title": "DMARC record changed on example.com", "body": "…", "context": {…},
    "created_at": "2026-09-29T06:00:02Z", "acknowledged_at": null, "resolved_at": null
  }],
  "meta": {"page": 1, "per_page": 25, "total": 1, "pages": 1}
}

Webhooks

Instead of polling, let MailWatch push events to you. Owners and admins add HTTPS endpoints under Organization → API & webhooks → Webhooks (Business and Agency plans). Endpoints must use port 443 and a public host name.

alert.created
An alert was raised; data.alert has the same fields as the alerts endpoint.
alert.resolved
An alert resolved itself because its condition cleared.
dns.changed
A DNS check found changed records; data.changes lists each record with its old and new values.
dmarc_report.processed
An aggregate report was stored; data.report has its reporter, period and totals.
ping
Sent by Send test event.
POST /your/endpoint
Content-Type: application/json
MailWatch-Event: alert.created
MailWatch-Delivery: 01928c5e-…
MailWatch-Signature: t=1790683200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

{
  "id": "01928c5e-…",
  "type": "alert.created",
  "created_at": "2026-09-29T12:00:00Z",
  "organization": {"id": "…", "name": "Acme", "slug": "acme"},
  "data": {"domain": {"id": "…", "name": "example.com"}, "alert": {"type": "spf_lookup_limit", "severity": "critical", …}}
}

Verify every request. Compute HMAC-SHA256 over t + "." + raw body with the endpoint's signing secret, compare it to v1 in constant time, and reject timestamps older than a few minutes:

$expected = hash_hmac('sha256', $t.'.'.$rawBody, $secret);
if (!hash_equals($expected, $v1) || abs(time() - $t) > 300) {
    http_response_code(400);
    exit;
}

Answer with a 2xx status within 10 seconds; redirects aren't followed. Other answers are retried after 1, 5 and 30 minutes, 2 and 6 hours. After 15 events in a row fail, the endpoint is disabled until you enable it again. MailWatch-Delivery is unique per delivery and the body's id per event, so you can drop duplicates.