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.

Endpoint reference This guide shows the calls in the order you make them and what to read in each response. Every field of every endpoint is documented in the API reference, generated from the API itself.

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:

StatusMeaning
pendingWaiting for your email
processingEmail received, analysis running
completedResults available
failedThe 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:

FieldWhat it is
overallScore out of 100. Start at 100, each problem found takes points off
email_typebulk (marketing, newsletter) or interpersonal (one-to-one, transactional), as detected
interpersonalScore out of 100 reweighted for an interpersonal email, which is not held to bulk-sender rules such as the unsubscribe header
email_type_overrideThe type you forced with PATCH /scans/{local_part}/email-type, if any
spamRaw 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": {}
}
  • key names the check (SPF, DKIM, DMARC, RBL, SPAM, IMG_ALT, SUBJECT…).
  • impact is the number of points it took off, 0 when it passed.
  • status is ok, warn or bad.
  • tab is 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.

BlockWhat you find there
authentication.spf / .dkim / .dmarcResult 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.alignmentThe domains compared for DMARC (From header, envelope, DKIM) and whether they match, strictly and relaxed
authentication.bulk_complianceThe Gmail and Yahoo bulk sender requirements: DMARC present and aligned, List-Unsubscribe, one-click unsubscribe, and compliant overall
authentication.bimiBIMI record, logo and certificate checks
reputation.rbllisted_count out of total_checked blocklists, and the listings themselves
reputation.senderscore, .abuseipdbReputation of the sending IP
content.spamassassin, .rspamdscore, threshold and the list of rules that fired, for each filter
content.images_accessibilityImages without alt text, buttons made only of an image, and whether the email reads without images
serverIP, hostname and EHLO of the server that delivered the email
security.encryptionTLS protocol and cipher used for delivery
emailSize, From, To, Subject

Three companion downloads, which require the key of the account that owns the scan:

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/scans lists the account’s scans, most recent first, with their score and subject. Filters: domain (sender domain), status, q (From or subject), and limit / offset for paging.
  • GET /auth/me/scan-usage returns scans_used_today, scans_limit and scans_remaining. Check it before a batch rather than discovering the limit halfway.

Limits and errors

SituationAnswer
More than 5 scan creations per minute429, with a Retry-After header
Plan’s daily scan quota reached429, detail says so. The quota resets every day
Results read before completion404
Missing or invalid key401
Plan without API access403

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.