testmail.app's free DNS, SPF, DKIM, DMARC, and email header tools also have an API. You send a domain or a block of email headers. You get back the same report the website shows, as JSON.
That makes it easy to automate checks you'd otherwise do by hand:
- Catch DNS mistakes early. Fail a deploy, or get an alert, when SPF or DMARC breaks.
- Monitor your sending domains. Check them every morning, not after customers report missing email.
- Test the emails your app sends. Confirm they arrive with SPF passing and aligned with your From domain.
This guide shows the code for each.
Quickstart
- Sign in to testmail.app and copy an API key from Console → API Keys. It's the same key you use for the inbox API.
- Save it in an environment variable called
TESTMAIL_APIKEY. - Run a check. This one asks whether a domain's DMARC record is set up correctly.
JavaScript
// check.mjs (Node.js 18 or newer). Run with: node check.mjs
const res = await fetch('https://api.testmail.app/api/tools/dmarc?domain=yourdomain.com', {
headers: { Authorization: `Bearer ${process.env.TESTMAIL_APIKEY}` },
signal: AbortSignal.timeout(30000)
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
const report = await res.json();
console.log(report.status, report.issues);
Python
# check.py (pip install requests). Run with: python check.py
import os
import requests
res = requests.get(
'https://api.testmail.app/api/tools/dmarc',
params={'domain': 'yourdomain.com'},
headers={'Authorization': f"Bearer {os.environ['TESTMAIL_APIKEY']}"},
timeout=30,
)
res.raise_for_status()
report = res.json()
print(report['status'], report['issues'])cURL
curl "https://api.testmail.app/api/tools/dmarc?domain=yourdomain.com" \
-H "Authorization: Bearer $TESTMAIL_APIKEY"
Replace yourdomain.com with your own domain. Any HTTP client in any language works. It's a plain GET request with one header.
You'll get something like this (shortened):
{
"result": "success",
"message": null,
"tool": "dmarc",
"query": { "domain": "yourdomain.com", "resolver": "cloudflare" },
"status": { "code": "valid_with_warnings", "tone": "warning" },
"summary": [
{ "key": "recordStatus", "value": "valid", "tone": "good" },
{ "key": "effectivePolicy", "value": "none", "tone": "warning" }
],
"issues": [
{
"severity": "warning",
"code": "dmarc.policy.none",
"title": "Monitoring mode — no enforcement",
"message": "p=none asks receivers to deliver failing mail normally while you collect reports. ...",
"subjects": [{ "kind": "record", "id": "_dmarc.yourdomain.com" }],
"docsUrl": "https://datatracker.ietf.org/doc/html/rfc9989#section-4.7"
}
],
"meta": {
"requestId": "7c447545-8a15-4dce-8967-4f32bb0c30ea",
"checkedAt": "2026-09-29T09:05:29.429Z",
"cached": false,
"durationMs": 152,
"complete": true
}
}
How to read a report
result: "success"means the check ran. It doesn't mean your domain passed.statusis the overall verdict. Itstoneisgood,neutral,warningorbad.issueslists what was found, worst first. Each issue has aseverity(error,warningorinfo), a stablecodeyou can match in code, and a readabletitleandmessage.meta.completeisfalsewhen a check couldn't finish, for example after a DNS timeout. Don't treat an incomplete report as a clean one.
Each tool adds its own details next to these fields, such as the parsed dmarc record or raw DNS recordSets.
The five checks
Every check uses the same pattern: send your key in the Authorization header and get a JSON report back.
Check | Request | Parameters |
|---|---|---|
DNS records |
|
|
SPF |
|
|
DKIM |
|
|
DMARC |
|
|
Email headers |
| The raw header block as the request body, sent as |
All paths start with https://api.testmail.app. Send the domain, like yourdomain.com. If you pass a URL or an email address instead, the API keeps only the domain part.
DKIM-Signature header. The selector is the s= value and the domain is the d= value. Your email provider's DNS setup page usually lists it too.Analysing email headers
The header analyser takes the raw header block as the request body, not as query parameters. Save the headers (everything above the first blank line of an email) to a file called headers.txt and send it as plain text:
JavaScript
// analyse.mjs (Node.js 18 or newer). Run with: node analyse.mjs
import { readFile } from 'node:fs/promises';
const rawHeaders = await readFile('headers.txt');
const res = await fetch('https://api.testmail.app/api/tools/headers', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.TESTMAIL_APIKEY}`,
'Content-Type': 'text/plain'
},
body: rawHeaders,
signal: AbortSignal.timeout(30000)
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
const report = await res.json();
console.log(report.authentication, report.issues);
Python
# analyse.py (pip install requests). Run with: python analyse.py
import os
import requests
with open('headers.txt', 'rb') as f:
raw_headers = f.read()
res = requests.post(
'https://api.testmail.app/api/tools/headers',
data=raw_headers,
headers={
'Authorization': f"Bearer {os.environ['TESTMAIL_APIKEY']}",
'Content-Type': 'text/plain',
},
timeout=30,
)
res.raise_for_status()
report = res.json()
print(report['authentication'], report['issues'])
cURL
curl https://api.testmail.app/api/tools/headers \
-H "Authorization: Bearer $TESTMAIL_APIKEY" \
-H "Content-Type: text/plain" \
--data-binary @headers.txtUse --data-binary with cURL, because the shorter -d strips line breaks, and the analyser then reports headers such as From and Date as missing.
The endpoint accepts text/plain and message/rfc822. It does not accept JSON or form uploads. If you already have the headers from testmail's inbox API, join them into text first (see automation example 2).
The report lists the SPF, DKIM and DMARC results that the receiving mail server recorded (authentication), whether SPF and DKIM align with the From domain (alignment), each server the message passed through (hops), and any issues found.
Automate it
1. Check your domains in CI or on a schedule
This script checks SPF and DMARC for each domain, plus DKIM if you give it a selector. It exits with code 1 when any check reports an error, so CI marks the run as failed.
JavaScript
// check-email-auth.mjs
const API = 'https://api.testmail.app/api/tools';
const headers = { Authorization: `Bearer ${process.env.TESTMAIL_APIKEY}` };
const domains = (process.env.DOMAINS || 'yourdomain.com').split(',');
const selector = process.env.DKIM_SELECTOR;
let failed = false;
for (const domain of domains) {
const checks = [['spf', { domain }], ['dmarc', { domain }]];
if (selector) checks.push(['dkim', { domain, selector }]);
for (const [tool, params] of checks) {
const res = await fetch(`${API}/${tool}?${new URLSearchParams(params)}`, {
headers, signal: AbortSignal.timeout(30000)
});
if (!res.ok) throw new Error(`${tool} ${domain}: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`);
const report = await res.json();
console.log(`${domain} ${tool}: ${report.status.code}`);
for (const issue of report.issues) {
if (issue.severity === 'error') {
console.log(` ✗ ${issue.code}: ${issue.title}`);
failed = true;
}
}
if (!report.meta.complete) {
console.log(' ! check did not complete, try again later');
failed = true;
}
}
}
process.exit(failed ? 1 : 0);
Python
# check_email_auth.py
import os
import sys
import requests
API = 'https://api.testmail.app/api/tools'
HEADERS = {'Authorization': f"Bearer {os.environ['TESTMAIL_APIKEY']}"}
domains = os.environ.get('DOMAINS', 'yourdomain.com').split(',')
selector = os.environ.get('DKIM_SELECTOR')
failed = False
for domain in domains:
checks = [('spf', {'domain': domain}), ('dmarc', {'domain': domain})]
if selector:
checks.append(('dkim', {'domain': domain, 'selector': selector}))
for tool, params in checks:
res = requests.get(f'{API}/{tool}', params=params, headers=HEADERS, timeout=30)
if not res.ok:
sys.exit(f'{tool} {domain}: HTTP {res.status_code} {res.text[:200]}')
report = res.json()
print(f"{domain} {tool}: {report['status']['code']}")
for issue in report['issues']:
if issue['severity'] == 'error':
print(f" ✗ {issue['code']}: {issue['title']}")
failed = True
if not report['meta']['complete']:
print(' ! check did not complete, try again later')
failed = True
sys.exit(1 if failed else 0)Output looks like this:
yourdomain.com spf: valid
yourdomain.com dmarc: valid_with_warnings
yourdomain.com dkim: valid
Warnings are printed as status codes but don't fail the run. If you want stricter rules, fail on specific issue codes you care about.
To run it every morning and on demand, add this workflow to your repository as .github/workflows/email-auth.yml, then save your key as a repository secret named TESTMAIL_APIKEY:
name: Email authentication
on:
schedule:
- cron: '0 6 * * *' # every day at 06:00 UTC
workflow_dispatch: # plus a "Run workflow" button
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
- run: node check-email-auth.mjs
env:
TESTMAIL_APIKEY: ${{ secrets.TESTMAIL_APIKEY }}
DOMAINS: yourdomain.com,mail.yourdomain.com
DKIM_SELECTOR: s1
If you manage DNS as code, for example with Terraform, run the same script as a step right after your DNS changes apply.
Each check uses one call from your daily allowance. The workflow above makes 6 calls per run: 2 domains × 3 checks.
2. Check the emails your app sends
If you already test your emails with testmail.app, you can check how they authenticated in the same test. Your app sends an email to a testmail inbox. The inbox API returns it with its headers, and the header analyser checks them.
DMARC passes when SPF or DKIM passes and aligns with your From domain, and the receiving mail server records those results in the email's headers. A testmail inbox records SPF, not DKIM, so this example checks that SPF passed and aligns. SPF aligns when your return-path (bounce) domain is your own domain or a subdomain of it. If your provider uses its own bounce domain, as Amazon SES does unless you set up a custom MAIL FROM domain, this check fails even if your emails pass DMARC through DKIM. In that case, check your DKIM record with the DKIM check in example 1.
JavaScript
const APIKEY = process.env.TESTMAIL_APIKEY;
const NAMESPACE = process.env.TESTMAIL_NAMESPACE;
async function checkEmailAuthentication(tag, startTimestamp) {
// 1. Wait for the email and include its headers.
const query = new URLSearchParams({
apikey: APIKEY, namespace: NAMESPACE, tag,
timestamp_from: startTimestamp, livequery: 'true', headers: 'true'
});
const inboxRes = await fetch(`https://api.testmail.app/api/json?${query}`, {
signal: AbortSignal.timeout(120000) // stop waiting after 2 minutes
});
if (!inboxRes.ok) throw new Error(`Inbox request failed: HTTP ${inboxRes.status} ${await inboxRes.text()}`);
const inbox = await inboxRes.json();
if (inbox.emails.length === 0) throw new Error('No email arrived');
const email = inbox.emails[0];
// 2. Join its headers back into text and send them to the header analyser.
const headerText = email.headers.map((h) => h.line.replace(/[\r\n]+$/, '')).join('\r\n');
const res = await fetch('https://api.testmail.app/api/tools/headers', {
method: 'POST',
headers: { Authorization: `Bearer ${APIKEY}`, 'Content-Type': 'text/plain' },
body: headerText,
signal: AbortSignal.timeout(30000)
});
if (!res.ok) throw new Error(`Header analysis failed: HTTP ${res.status} ${await res.text()}`);
const report = await res.json();
// An incomplete analysis may have missed something, so never count it as a pass.
if (!report.meta.complete) throw new Error('Header analysis was incomplete');
// 3. DMARC needs SPF or DKIM to pass AND align with the From domain.
// A testmail inbox records SPF only, so this checks SPF.
const spfPassed = report.authentication.some(result =>
result.method === 'spf' && result.result === 'pass' && result.provenance !== 'untrusted_claim');
return {
spfAligned: spfPassed && Boolean(report.alignment.spf?.aligned),
issues: report.issues
};
}
// In your test:
const tag = `auth-${Date.now()}`;
const startTimestamp = Date.now();
await sendWelcomeEmail(`${NAMESPACE}.${tag}@inbox.testmail.app`); // your app
const result = await checkEmailAuthentication(tag, startTimestamp);
expect(result.spfAligned).toBe(true);
Python
import os
import time
import requests
APIKEY = os.environ['TESTMAIL_APIKEY']
NAMESPACE = os.environ['TESTMAIL_NAMESPACE']
def check_email_authentication(tag, start_timestamp):
# 1. Wait for the email and include its headers.
# livequery re-sends the request about once a minute while it waits. timeout only
# limits each wait, so cap the redirects to stop after about 2 minutes in total.
session = requests.Session()
session.max_redirects = 1
inbox_res = session.get('https://api.testmail.app/api/json', params={
'apikey': APIKEY, 'namespace': NAMESPACE, 'tag': tag,
'timestamp_from': start_timestamp, 'livequery': 'true', 'headers': 'true',
}, timeout=90)
inbox_res.raise_for_status()
emails = inbox_res.json()['emails']
if not emails:
raise RuntimeError('No email arrived')
email = emails[0]
# 2. Join its headers back into text and send them to the header analyser.
header_text = '\r\n'.join(h['line'].rstrip('\r\n') for h in email['headers'])
res = requests.post(
'https://api.testmail.app/api/tools/headers',
data=header_text.encode('utf-8'),
headers={'Authorization': f'Bearer {APIKEY}', 'Content-Type': 'text/plain'},
timeout=30,
)
res.raise_for_status()
report = res.json()
# An incomplete analysis may have missed something, so never count it as a pass.
if not report['meta']['complete']:
raise RuntimeError('Header analysis was incomplete')
# 3. DMARC needs SPF or DKIM to pass AND align with the From domain.
# A testmail inbox records SPF only, so this checks SPF.
spf_passed = any(result['method'] == 'spf' and result['result'] == 'pass'
and result['provenance'] != 'untrusted_claim'
for result in report['authentication'])
spf_alignment = report['alignment']['spf']
return {
'spf_aligned': spf_passed and bool(spf_alignment and spf_alignment['aligned']),
'issues': report['issues'],
}
# In your test:
tag = f'auth-{int(time.time() * 1000)}'
start_timestamp = int(time.time() * 1000)
send_welcome_email(f'{NAMESPACE}.{tag}@inbox.testmail.app') # your app
result = check_email_authentication(tag, start_timestamp)
assert result['spf_aligned']
Some things to know about this example:
- The inbox API takes your key as the
apikeyquery parameter. The tools API only accepts it in theAuthorizationheader. It's the same key. - With
headers=truethe inbox API returns the headers as a list of{key, line}items. The analyser takes text, so the example joins everylinewith a line break. livequery=truewaits until the email arrives. Set a timeout so a missing email fails the test instead of hanging it. In Python,requests'timeoutonly covers each wait, which is why the example also caps redirects.- The analyser reads the results the receiving server recorded. It doesn't re-run SPF or DKIM itself, but it does work out alignment from the headers. When several DKIM signatures pass, any one that aligns counts, as it does for DMARC. Results copied from earlier servers, such as your provider's
ARC-Authentication-Results, are markeduntrusted_claimand don't count. In this example the receiving server is testmail's, which records SPF only. - A report with
meta.complete: false(for example, a message with hundreds of headers) is treated as a failure, not a pass. result.issuesalso flags other problems worth failing on, such as aFromaddress that isn't covered by the DKIM signature.
Limits
- Each check uses one call from your team's daily allowance. Every API key and every tool in the team shares it, including checks you run on the website while signed in.
- The free plan includes 100 calls per day. Paid plans include more.
- Allowances reset at 00:00 UTC.
- Successful responses include
X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Resetheaders. The reset value is an ISO timestamp. - Requests with invalid input or a rejected key don't use your allowance.
- Space out automated requests rather than sending them in a tight loop.
Errors
Errors return result: "fail" with a code you can match on:
{
"result": "fail",
"message": "A valid API key is required. Send it as Authorization: Bearer YOUR_API_KEY.",
"code": "auth.invalid",
"requestId": "5b1a6967-7b47-4089-9c60-5dcdc78b2d45"
}
HTTP |
| What to do |
|---|---|---|
400 |
| Fix the input. |
401 |
| Check the key and the |
401 |
| Enable the key in Console → API Keys, or use another key. |
400 |
| The header analyser got an empty body. Send the header block. |
413 |
| Send only the headers, not the whole email (limit 1 MB). |
415 |
| Send the header block as plain text ( |
429 |
| Daily allowance used. The response has |
503 |
| Temporary. Try again later with backoff. |
500 |
| Temporary. Try again later; quote the |
Common mistakes
Mistake | What happens | Fix |
|---|---|---|
Putting the key in the URL ( |
| Send |
Sending headers as JSON or a form upload |
| Send the raw header block with |
| Success, but a wrong report (line breaks are stripped) | Use |
Top-level |
| Save the file as |
Treating HTTP 200 as a pass | A missing SPF record also returns 200 | Check |
Keep your key safe
- Call the API from servers, scripts, and CI only, never from browser JavaScript. Your API key also gives access to your inboxes.
- Keep it in environment variables or CI secrets, not in code.
- Log the
requestId, not the key or full email headers.
FAQ
Does the API change my DNS?
No. It only reads public DNS records and the headers you send.
Is it the same as the website?
Yes. The checks are the same, and the reports are identical.
Which DNS resolver should I use?
The defaults are fine. By default, the DNS checker asks your domain's own nameservers, so you see what your DNS host is serving right now. Use resolver=google or resolver=cloudflare to see what a public resolver has cached, which can lag behind a recent change.
Why is my DKIM check failing when my emails are signed?
The selector is probably wrong. Check the s= value in the DKIM-Signature header of an email you sent.