What you will build
A scan audits one real email, exactly as it left your infrastructure: authentication (SPF, DKIM, DMARC, alignment), sending IP reputation, spam filter verdicts, content and readability. The API lets you run it from your own tools, for example:
- a CI step that sends a template to Sender Audit and fails the build when the score drops below a threshold;
- a pre-send check in your campaign workflow, run before every broadcast;
- a nightly test that sends from each of your platforms and alerts you when one of them breaks.
The flow has four steps: create the scan, send your email to the address it gives you, wait until the analysis completes, read the results.
Before you start
- An API key. Create one from the dashboard, under Account, Developer. It starts with
sa_live_and belongs to the account, not to you: everything it creates lands in the account’s scan history. API access requires a plan that includes it. - A way to send an email to an arbitrary address from the infrastructure you want to test: your ESP, your SMTP server, your application. A scan only makes sense on an email sent through the real path, the one your recipients will see.
Every call uses the same base URL and header:
export SA_KEY="sa_live_your_key"
export SA_API="https://senderaudit.com/api/v1"
import os, time, requests
API = "https://senderaudit.com/api/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['SA_KEY']}"
The Python examples use requests and reuse this session.
Step 1: create the scan
POST /scans creates an empty scan and returns the address to send your email to. The body is an empty JSON object.
curl -s -X POST "$SA_API/scans" \
-H "Authorization: Bearer $SA_KEY" \
-H "Content-Type: application/json" \
-d '{}'
scan = session.post(f"{API}/scans", json={}).json()
local_part = scan["local_part"]
print("Send your email to", scan["email_address"])
{
"local_part": "s3f9a1c2e",
"email_address": "s3f9a1c2e@in.senderaudit.com",
"status": "pending",
"created_at": "2026-10-15T09:12:44",
"received_at": null,
"processed_at": null,
"processing_time_ms": null,
"view_count": 0
}
Keep local_part: it identifies the scan in every following call. A scan that receives nothing is deleted after 24 hours.
Step 2: send your email
Send the email you want to audit to email_address, from the platform you want to test. Send it as is: same sender, same template, same sending domain as a real broadcast. Changing the From address or going through another server audits a different email.
Step 3: wait for the analysis
GET /scans/{local_part} returns the scan and its status:
| Status | Meaning |
|---|---|
pending | Waiting for your email |
processing | Email received, analysis running |
completed | Results available |
failed | The analysis could not finish |
Poll every 5 seconds or so. The analysis usually takes a few dozen seconds once the email has arrived, and delivery itself depends on your sending platform.
while :; do
STATUS=$(curl -s "$SA_API/scans/$LOCAL" -H "Authorization: Bearer $SA_KEY" | jq -r .status)
[ "$STATUS" = completed ] && break
[ "$STATUS" = failed ] && { echo "Scan failed"; exit 1; }
sleep 5
done
def wait_for(local_part, timeout=600):
deadline = time.time() + timeout
while time.time() < deadline:
status = session.get(f"{API}/scans/{local_part}").json()["status"]
if status == "completed":
return
if status == "failed":
raise RuntimeError("Scan failed")
time.sleep(5)
raise TimeoutError("No email received or analysis still running")
wait_for(local_part)
Always put a timeout on the loop: if the email never leaves your platform, the scan stays pending.
Step 4: read the key results
GET /scans/{local_part}/results returns the full audit. It answers 404 until the scan is completed.
curl -s "$SA_API/scans/$LOCAL/results" -H "Authorization: Bearer $SA_KEY" | jq .scores
results = session.get(f"{API}/scans/{local_part}/results").json()
scores = results["scores"]
The response is large. For most integrations, a handful of fields is enough.
The score
scores holds the verdict:
| Field | What it is |
|---|---|
overall | Score out of 100. Start at 100, each problem found takes points off |
email_type | bulk (marketing, newsletter) or interpersonal (one-to-one, transactional), as detected |
interpersonal | Score out of 100 reweighted for an interpersonal email, which is not held to bulk-sender rules such as the unsubscribe header |
email_type_override | The type you forced with PATCH /scans/{local_part}/email-type, if any |
spam | Raw SpamAssassin score. The lower the better; 5 is the usual spam threshold |
Read interpersonal rather than overall when the email is a one-to-one message: that is the score the results page shows for that type.
What cost points
scores.breakdown lists every check that counts in the score, one item per check:
{
"key": "DMARC_NONE",
"tab": "auth",
"impact": -5,
"status": "warn",
"detail_key": "breakdown.dmarc_none",
"detail_params": {}
}
keynames the check (SPF,DKIM,DMARC,RBL,SPAM,IMG_ALT,SUBJECT…).impactis the number of points it took off,0when it passed.statusisok,warnorbad.tabis the family the check belongs to:auth,domain,content,readability,spam,headers.
To list what to fix, keep the items with a negative impact and sort them by impact: the first ones are worth the most points. scores.breakdown_interpersonal holds the same list reweighted for an interpersonal email.
to_fix = sorted((i for i in scores["breakdown"] if i["impact"] < 0), key=lambda i: i["impact"])
for item in to_fix:
print(f'{item["impact"]:>4} {item["key"]} ({item["status"]})')
A CI gate
THRESHOLD = 80
score = scores["interpersonal"] if scores["email_type"] == "interpersonal" else scores["overall"]
if score < THRESHOLD:
raise SystemExit(f"Deliverability score {score} < {THRESHOLD}")
Going further: the detail
The rest of the response is the raw material behind the score. Read it when you need to explain a result or to store more than a number.
| Block | What you find there |
|---|---|
authentication.spf / .dkim / .dmarc | Result of each mechanism (pass, fail, softfail, none…), the record found in DNS, the domain checked, and failure_detail when it failed. dkim.signatures details each signature, dmarc.policy the published policy |
authentication.alignment | The domains compared for DMARC (From header, envelope, DKIM) and whether they match, strictly and relaxed |
authentication.bulk_compliance | The Gmail and Yahoo bulk sender requirements: DMARC present and aligned, List-Unsubscribe, one-click unsubscribe, and compliant overall |
authentication.bimi | BIMI record, logo and certificate checks |
reputation.rbl | listed_count out of total_checked blocklists, and the listings themselves |
reputation.senderscore, .abuseipdb | Reputation of the sending IP |
content.spamassassin, .rspamd | score, threshold and the list of rules that fired, for each filter |
content.images_accessibility | Images without alt text, buttons made only of an image, and whether the email reads without images |
server | IP, hostname and EHLO of the server that delivered the email |
security.encryption | TLS protocol and cipher used for delivery |
email | Size, From, To, Subject |
Three companion downloads, which require the key of the account that owns the scan:
GET /scans/{local_part}/pdfreturns the report as a PDF,?lang=fr|en|de|es;GET /scans/{local_part}/download-emlreturns the raw email as received;GET /scans/{local_part}/optimized-imagesreturns the email’s images, recompressed.
The human-readable report is always one link away: https://senderaudit.com/results/{local_part}, handy to paste in a CI log or a ticket.
History and quota
GET /auth/me/scanslists the account’s scans, most recent first, with their score and subject. Filters:domain(sender domain),status,q(From or subject), andlimit/offsetfor paging.GET /auth/me/scan-usagereturnsscans_used_today,scans_limitandscans_remaining. Check it before a batch rather than discovering the limit halfway.
Limits and errors
| Situation | Answer |
|---|---|
| More than 5 scan creations per minute | 429, with a Retry-After header |
| Plan’s daily scan quota reached | 429, detail says so. The quota resets every day |
| Results read before completion | 404 |
| Missing or invalid key | 401 |
| Plan without API access | 403 |
Scans are kept 30 days after their last view, longer when they are viewed often, and one year at most. Store the fields you need on your side if you want a longer history.
Related
- API reference, Scans: every field, and a console to try the calls
- API: track your DMARC data
- API: monitor your domains
- Previews by mail client: the rendering side of a scan