What you will build
Every source that shows up in your DMARC reports lands in a sender catalogue, one entry per domain and source (a named service such as an ESP or a CRM, or a bare IP when no name is known). Before tightening your policy to quarantine or reject, each source that carries volume needs a status: is it yours, a relay you do not control, or someone spoofing you?
Sender Audit classifies part of it on its own. The rest is a decision only you can make. The API lets you:
- sync the catalogue with your own inventory of sending platforms, and classify in bulk what you already know;
- review the queue of unclassified sources from a script or an internal tool, with the evidence for each one;
- alert when a new source starts sending in your name.
Before you start
- An API key created from the dashboard, under Account, Developer, on a plan that includes API access.
- DMARC reports coming in for your domains: see API: track your DMARC data. The catalogue fills up with the reports.
export SA_KEY="sa_live_your_key"
export SA_API="https://senderaudit.com/api/v1"
import os, requests
API = "https://senderaudit.com/api/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['SA_KEY']}"
How a sender gets its role
Each sender carries two roles, and the one that counts is the first that is set:
user_role: what you decided. It always wins.auto_role: what Sender Audit inferred.legitimatefor a source that is well aligned at volume,forwarderfor a relay recognised as such (mailing lists, forwarding services, security gateways). Nothing when there is too little volume or a real authentication problem.
The result is effective_role. Your role can be legitimate, unauthorized, forwarder or ignored; setting it back to null returns the sender to automatic classification.
Step 1: read the catalogue
GET /dmarc/senders returns the catalogue over a window, already sorted into groups. It takes the same domain, group and days filters as the other DMARC endpoints.
curl -s "$SA_API/dmarc/senders?domain=example.com&days=30" -H "Authorization: Bearer $SA_KEY" | jq .totals
catalogue = session.get(f"{API}/dmarc/senders", params={"domain": "example.com", "days": 30}).json()
for group, total in catalogue["totals"].items():
print(f'{group:13} {total["senders"]:>5} senders {total["messages"]:>9} messages')
| Group | What it holds |
|---|---|
unclassified | No role yet: the queue to work through |
legitimate | Your own platforms |
forwarder | Relays you do not control. Their failures are expected and not yours to fix |
unauthorized | Sources you identified as not yours |
no_rdns | Unclassified bare IPs with no name and no reverse DNS, kept apart so they do not drown the queue |
ignored | Sources you chose to set aside |
totals gives the number of senders and messages per group. A large message count in unclassified is the first thing to deal with.
Step 2: find what matters
Each entry describes one source over the window:
| Field | What it is |
|---|---|
id | What every other call uses |
source_display, source_name, source_type | Who it is, when known: a service name, a hosting company, a reverse DNS |
total_msgs | Messages over the window |
pass_msgs | SPF and DKIM passed |
partial_msgs | Only one of the two passed. Still DMARC compliant if that one is aligned, but fragile |
fail_msgs | Neither passed |
pass_rate | Percentage (0 to 100) of the volume where at least one of SPF and DKIM passed, null without traffic |
quality | perfect, partial, failing, or no_traffic over the window |
disposition | How many messages receivers delivered (none), quarantined or rejected |
first_seen_at, last_seen_at | When the source appeared and was last seen |
Unclassified sources are already sorted by volume. In practice, a few sources carry almost all the volume and the long tail weighs a few messages each:
queue = catalogue["unclassified"]
total = sum(s["total_msgs"] for s in queue) or 1
for s in queue[:10]:
name = s["source_display"] or s["source_name"] or s["source_key"]
print(f'{s["id"]:>7} {s["total_msgs"] / total:6.1%} {s["quality"]:9} {name}')
Step 3: look at the evidence
Before deciding, GET /dmarc/senders/{id}/evidence sums up what the reports say about one source:
curl -s "$SA_API/dmarc/senders/$SENDER_ID/evidence" -H "Authorization: Bearer $SA_KEY"
| Field | Why it helps |
|---|---|
hosts | Reverse DNS, network operator (asn_org) and country behind its IPs. An ESP’s network or a residential connection in an unexpected country tell very different stories |
dkim | The DKIM domains and selectors it signed with, and whether each one is aligned with your domain and passing. A signature with your domain and a known selector is the strongest sign it is yours |
spf | The envelope domains it used, with the same two flags |
dkim_aligned_msgs, spf_aligned_msgs | Volume aligned on each mechanism |
dispositions | What receivers actually did with its messages |
overrides | Reasons receivers gave for overriding your policy, such as forwarded or mailing_list |
reporters | How many organisations saw it. One receiver only is often a relay on their side |
A typical reading:
- Signed with your domain, aligned, at volume: one of your platforms, usually already
legitimateautomatically. If not, its SPF or DKIM is half configured. - Signed by a third-party domain, not aligned, on an ESP’s network: probably a platform of yours that was never set up to sign with your domain. Mark it legitimate and fix its DKIM.
- No valid signature, scattered networks and countries: spoofing. Mark it unauthorized.
Two more calls when you need to dig:
GET /dmarc/senders/{id}/history: its volume and pass rate day by day, to see when it appeared or broke.GET /dmarc/senders/{id}/reports: the reports that mention it, with the source IPs, to go back to the raw data.
Step 4: classify
One sender, with a label and a note for whoever reads the catalogue after you: PATCH /dmarc/senders/{id}.
curl -s -X PATCH "$SA_API/dmarc/senders/$SENDER_ID" \
-H "Authorization: Bearer $SA_KEY" \
-H "Content-Type: application/json" \
-d '{"user_role": "legitimate", "user_label": "Newsletter platform", "user_notes": "DKIM to be set up, ticket OPS-412"}'
session.patch(f"{API}/dmarc/senders/{sender_id}", json={
"user_role": "legitimate",
"user_label": "Newsletter platform",
"user_notes": "DKIM to be set up, ticket OPS-412",
})
Only the fields you send change. An empty string clears a label or a note; "user_role": null returns the sender to automatic.
Many senders at once: POST /dmarc/senders/bulk-classify applies one role to up to 5,000 ids. Ids that do not belong to the account are skipped, and the response says how many were updated.
# Set aside the long tail of nameless IPs that sent almost nothing
tail = [s["id"] for s in catalogue["no_rdns"] if s["total_msgs"] < 5]
r = session.post(f"{API}/dmarc/senders/bulk-classify", json={"ids": tail, "user_role": "ignored"})
print(r.json()["updated"], "senders ignored")
Going further
Watching for new sources
A source that starts sending in your name raises a new_sending_source event on the domain, readable with GET /domains/{id}/events. From the catalogue alone, filter on first_seen_at:
from datetime import datetime, timedelta, timezone
since = datetime.now(timezone.utc) - timedelta(days=1)
fresh = [s for s in catalogue["unclassified"]
if s["first_seen_at"] and datetime.fromisoformat(s["first_seen_at"]).replace(tzinfo=timezone.utc) > since]
Removing a sender
DELETE /dmarc/senders/{id} removes the entry, with its role, label and note. It comes back, unclassified, with the next report that mentions it. To stop seeing a source, mark it ignored instead.
Limits and errors
| Situation | Answer |
|---|---|
| More than 30 classification calls per minute | 429, with a Retry-After header |
user_role outside the allowed values | 422 |
Sender id unknown on the account | 404 on single calls, skipped in bulk |
| Missing or invalid key | 401 |
| Plan without API access | 403 |
Related
- API reference, DMARC: every field, and a console to try the calls
- API: track your DMARC data
- API: monitor your domains