paulobrien.dev

DMARC Reports API

DMARC Reports API, webhooks and AI connector.

See who sends email as your domains, how much passes and what to fix, from your own code and dashboards. Get told when a report arrives or a new sender appears, or ask Claude about it in plain English.

1Get started

The API comes with DMARC Reports 1.2.0 and later. It lives on your own install:

https://dmarc.yourdomain.co.uk/api/v1
  1. Open SettingsSign in to DMARC Reports and open Settings. Your API address is under API, webhooks and AI connector, ready to copy.
  2. Make a keyGive it a name, tick It can change things if your script needs to add domains or mark senders, and copy the key. It's shown once, and starts with dmarc_.
  3. Send it with each requestIn an Authorization header, like this:
curl https://dmarc.yourdomain.co.uk/api/v1/domains \
  -H "Authorization: Bearer dmarc_••••••••••••••••••••••••••••••••"

2Keys and permissions

Make a separate key for each script or app. You can have up to 10.

KeyWhat it allows
Read onlyYour domains, senders, reports and TLS results.
Read and changeAll of that, plus adding domains and marking senders Mine or Not mine.

3Every request

Requests that cover a period take days: 7, 30 (the default), 90 or 365.

RequestNeedsWhat it does
GET /domainsReadYour domains, with the last 30 days' reports, messages and pass rate, and the address reports go to.
POST /domainsChangeAdd a domain: {"domain": "yourdomain.co.uk"}.
GET /domains/{domain}ReadWho sent email as the domain, how much passed, its policy and what to fix.
GET /domains/{domain}/sendersReadEvery sender in full, with its verdict, the IP addresses it used and the fix.
POST /domains/{domain}/sendersChangeMark a sender: {"key": "…", "label": "mine"}. Label is mine, not or null to clear it.
GET /domains/{domain}/reportsReadThe latest reports that arrived. Takes limit (up to 100).
GET /domains/{domain}/tlsReadTLS reports: how much email to you was sent encrypted, and any failures.

4Examples

How is a domain doing?

curl "https://dmarc.yourdomain.co.uk/api/v1/domains/yourdomain.co.uk?days=30" \
  -H "Authorization: Bearer $DMARC_KEY"

The answer (shortened)

{
  "domain": "yourdomain.co.uk",
  "days": 30,
  "policy": "none",
  "messages": 4210,
  "passed": 4102,
  "passRate": 97,
  "senders": [
    { "key": "google", "name": "Google Workspace", "label": "mine", "status": "good", "messages": 3890, "passed": 3890, "passRate": 100 },
    { "key": "mailchimp", "name": "Mailchimp", "label": null, "status": "fix", "messages": 320, "passed": 212, "passRate": 66 }
  ]
}

Mark a sender as yours

curl https://dmarc.yourdomain.co.uk/api/v1/domains/yourdomain.co.uk/senders \
  -H "Authorization: Bearer $DMARC_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "key": "mailchimp", "label": "mine" }'

In JavaScript

const res = await fetch(`${DMARC_API_URL}/domains`, {
  headers: { Authorization: `Bearer ${process.env.DMARC_KEY}` },
});
if (!res.ok) throw new Error((await res.json()).error);
const { domains } = await res.json();

5Webhooks

Add up to 5 webhooks in Settings › API, webhooks and AI connector, each with its own https:// address.

EventWhen it's sent
report.receivedA DMARC or TLS report arrived by email and was saved.
sender.newSomeone new started sending email as one of your domains.
sender.failingA sender that's yours started failing DMARC.
reports.stoppedNo reports for 3 days.
tls.failingMail servers say email to you couldn't be sent encrypted.
testThe Test button.

What DMARC Reports sends

{
  "id": "evt_4b7c2a9e1f03d6a8c5e2b1f0",
  "type": "report.received",
  "created": "2026-11-04T06:12:40Z",
  "app": "DMARC Reports",
  "data": {
    "kind": "dmarc",
    "domain": "yourdomain.co.uk",
    "org": "google.com",
    "reportId": "1234567890123456789",
    "begin": "2026-11-03T00:00:00Z",
    "end": "2026-11-03T23:59:59Z",
    "messages": 140,
    "passed": 138
  }
}

Alerts carry domain, subject, text and a link to the domain in the app. Each request also carries DMARC-Reports-Event, DMARC-Reports-Delivery and DMARC-Reports-Signature. Answer with any 2xx status within 10 seconds. A webhook that fails 10 times in a row is switched off, and the Test button switches it back on.

Checking a webhook is genuine

Each webhook has its own signing secret (starting whsec_), shown when you add it. The app signs <t>.<body> with HMAC-SHA256 using that secret, and sends the result in the DMARC-Reports-Signature header as t=1760520601,v1=5f2b…. To check a request, work out the same signature from the raw body, compare it with v1 and refuse anything more than five minutes old.

Node.js

import crypto from "node:crypto";

function isFromDmarcReports(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const age = Math.abs(Date.now() / 1000 - Number(parts.t));
  if (!parts.v1 || age > 300) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

Python

import hashlib, hmac, time

def is_from_dmarc_reports(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    if "v1" not in parts or abs(time.time() - int(parts["t"])) > 300:
        return False
    signed = parts["t"].encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])

Use the body exactly as it arrived. Turning the JSON into an object and back into text can change it, and then the signature won't match.

6AI connector

Add DMARC Reports to Claude, ChatGPT or any MCP client. In Claude, open Settings › Connectors › Add custom connector and paste:

https://dmarc.yourdomain.co.uk/mcp

Claude asks you to sign in once. Then ask things like “who is sending email as my domain that isn't me?” or “am I ready for p=reject?”. It can only read. Disconnect AI apps in Settings signs it out. Apps that let you add a header can send a dmarc_ key instead.

7Errors and limits

Anything that goes wrong comes back with a status, a sentence you can show to a person and a link to this page:

{"error": "That domain isn't in your list. Add it first.", "docs": "https://paulobrien.dev/dmarc/api/"}
StatusWhat it means
400Something in the request needs fixing, such as a key put in the web address.
401No key, or a key that has been deleted.
403The key can only read.
404A domain that isn't in your list, or a request the API doesn't have.
405Use GET to read and POST to change.
429Each key can make 60 requests a minute. Retry-After says how long to wait.

Every answer carries X-RateLimit-Limit and X-RateLimit-Remaining. There are no browser (CORS) headers on purpose: keep keys on your server, or in tools like Zapier, Make or n8n.

Questions or ideas? Ask in GitHub Discussions, or open an issue for a bug.

Stay in the loop

Subscribe for updates.

Leave your email and I'll tell you when there's a new version of one of my apps, or a new project to try. Nothing else.

That didn't work. Please check your email address and try again.

One email to confirm. Unsubscribe with one click. Your address is only used for these updates.