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.
Endpoint reference This guide shows which calls to chain and what to read in each response. Every field is documented in the API reference, generated from the API itself.

Before you start

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:

  1. user_role: what you decided. It always wins.
  2. auto_role: what Sender Audit inferred. legitimate for a source that is well aligned at volume, forwarder for 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')
GroupWhat it holds
unclassifiedNo role yet: the queue to work through
legitimateYour own platforms
forwarderRelays you do not control. Their failures are expected and not yours to fix
unauthorizedSources you identified as not yours
no_rdnsUnclassified bare IPs with no name and no reverse DNS, kept apart so they do not drown the queue
ignoredSources 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:

FieldWhat it is
idWhat every other call uses
source_display, source_name, source_typeWho it is, when known: a service name, a hosting company, a reverse DNS
total_msgsMessages over the window
pass_msgsSPF and DKIM passed
partial_msgsOnly one of the two passed. Still DMARC compliant if that one is aligned, but fragile
fail_msgsNeither passed
pass_ratePercentage (0 to 100) of the volume where at least one of SPF and DKIM passed, null without traffic
qualityperfect, partial, failing, or no_traffic over the window
dispositionHow many messages receivers delivered (none), quarantined or rejected
first_seen_at, last_seen_atWhen 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"
FieldWhy it helps
hostsReverse 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
dkimThe 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
spfThe envelope domains it used, with the same two flags
dkim_aligned_msgs, spf_aligned_msgsVolume aligned on each mechanism
dispositionsWhat receivers actually did with its messages
overridesReasons receivers gave for overriding your policy, such as forwarded or mailing_list
reportersHow 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 legitimate automatically. 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:

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

SituationAnswer
More than 30 classification calls per minute429, with a Retry-After header
user_role outside the allowed values422
Sender id unknown on the account404 on single calls, skipped in bulk
Missing or invalid key401
Plan without API access403