Article featured image

Automate SPF, DKIM, DMARC and DNS checks with the testmail.app tools API

11min readLast updated: October 20, 2026

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

  1. 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.
  2. Save it in an environment variable called TESTMAIL_APIKEY.
  3. 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
  }
}
💡
Tip: every tool page on the website has a View JSON button. Run a check there to see the exact response your code will get.

How to read a report

  • result: "success" means the check ran. It doesn't mean your domain passed.
  • status is the overall verdict. Its tone is good, neutral, warning or bad.
  • issues lists what was found, worst first. Each issue has a severity (error, warning or info), a stable code you can match in code, and a readable title and message.
  • meta.complete is false when 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

GET /api/tools/dns

domain (required). type: all (default), A, AAAA, CNAME, MX, TXT, NS, SOA, CAA or DNSKEY. resolver: authoritative (default), cloudflare or google

SPF

GET /api/tools/spf

domain (required). resolver: cloudflare (default) or google

DKIM

GET /api/tools/dkim

domain and selector (both required). resolver as for SPF

DMARC

GET /api/tools/dmarc

domain (required). resolver as for SPF

Email headers

POST /api/tools/headers

The raw header block as the request body, sent as text/plain (or message/rfc822). JSON is not accepted

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.

💡
Finding your DKIM selector: open an email you sent and find the 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.txt

Use --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 apikey query parameter. The tools API only accepts it in the Authorization header. It's the same key.
  • With headers=true the inbox API returns the headers as a list of {key, line} items. The analyser takes text, so the example joins every line with a line break.
  • livequery=true waits until the email arrives. Set a timeout so a missing email fails the test instead of hanging it. In Python, requests' timeout only 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 marked untrusted_claim and 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.issues also flags other problems worth failing on, such as a From address 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-Remaining and X-RateLimit-Reset headers. 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

code

What to do

400

invalid.*

Fix the input. field names the parameter.

401

auth.invalid

Check the key and the Authorization: Bearer header.

401

auth.disabled

Enable the key in Console → API Keys, or use another key.

400

invalid.source.empty

The header analyser got an empty body. Send the header block.

413

invalid.source.too_large

Send only the headers, not the whole email (limit 1 MB).

415

invalid.content_type

Send the header block as plain text (text/plain or message/rfc822), not as JSON and not as a form upload.

429

ratelimit.free, ratelimit.plan

Daily allowance used. The response has resetAt, and Retry-After gives the seconds to wait.

503

quota.unavailable

Temporary. Try again later with backoff.

500

internal

Temporary. Try again later; quote the requestId if you contact support.

Common mistakes

Mistake

What happens

Fix

Putting the key in the URL (?apikey=), as the inbox API does

401 auth.invalid

Send Authorization: Bearer YOUR_APIKEY

Sending headers as JSON or a form upload

415 invalid.content_type

Send the raw header block with Content-Type: text/plain

curl -H "Content-Type: text/plain" -d @headers.txt

Success, but a wrong report (line breaks are stripped)

Use --data-binary @headers.txt

Top-level await in a .js file on Node 18

SyntaxError

Save the file as .mjs, which works on Node 18 and newer

Treating HTTP 200 as a pass

A missing SPF record also returns 200

Check status and issues

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.

Subscribe to blog

Stay updated with our latest insights and curated articles delivered straight to your inbox.