Put the postmark where your team looks
A live README badge and a JSON API. No key, no signup.
README badge
One image URL per domain: grade, score and a verdict that reads without colour.
GET /badge/<domain>.svg
image/svg+xml, cached ten minutes.- An invalid domain returns 200 with a question-mark badge, never a broken image.
- No scripts or external requests, so GitHub, GitLab and npm render it.
Forgery badge
What the records ask receivers to do with mail forged in this domain's name.
GET /badge/<domain>.svg?kind=forgery
| Badge | Meaning |
|---|---|
| forgery: refused | Refused on every route. |
| forgery: spam folder | At worst, the spam folder. |
| forgery: not stopped | Nothing requires receivers to stop it. |
| forgery: subdomain not stopped | The address meets more, but a made-up subdomain is not stopped. |
| forgery: unknown | A lookup did not answer and nothing worse was found. |
Shows the weakest of three routes: forged address, made-up subdomain, lapsed SPF domain. Worked out from DNS; no mail is sent. It shows what records ask, not what any inbox will do.
What the ring means
| Grade | Verdict |
|---|---|
| A | Ready to land |
| B | Mostly ready |
| C | Needs work |
| D, F | Likely spam |
| ? | Not a domain, or the check failed |
[](https://stamp-zeta-two.vercel.app/check/stripe.com)<a href="https://stamp-zeta-two.vercel.app/check/stripe.com"><img src="https://stamp-zeta-two.vercel.app/badge/stripe.com.svg" alt="Email health for stripe.com on Stamp" height="56"></a>JSON API
The full report: grade, score, issues with fixes, and raw findings.
GET /api/v1/check?domain=<domain>
Limits
- 30 requests a minute per IP address. Per server instance, not a global quota. Every answer has
RateLimit-RemainingandRateLimit-Reset. - Cached ten minutes, shared with reports and badges. To refresh, use Check again on the report page.
- CORS is open (
Access-Control-Allow-Origin: *).
Errors
Errors are JSON too: { "error": { "code", "message" } }
| Status | Code | When |
|---|---|---|
| 400 | missing_domain | No domain parameter. |
| 400 | invalid_domain | The value is not a domain name. |
| 429 | rate_limited | Over 30 a minute. Wait Retry-After seconds. |
| 502 | check_failed | The lookups could not be completed. Retry shortly. |
curl "https://stamp-zeta-two.vercel.app/api/v1/check?domain=example.com"const res = await fetch(
"https://stamp-zeta-two.vercel.app/api/v1/check?domain=" + encodeURIComponent("example.com"),
);
if (!res.ok) {
const { error } = await res.json();
throw new Error(error.message); // error.code: invalid_domain, rate_limited, ...
}
const report = await res.json();
console.log(report.grade, report.score, report.summary);{
"domain": "stripe.com",
"checkedAt": "2026-11-03T15:02:01.437Z",
"grade": "A",
"score": 96,
"summary": "Authenticated and protected. Mail from this domain should reach the inbox.",
"issues": [
{
"id": "mta-sts-missing",
"area": "transport",
"severity": "low",
"title": "Inbound mail can be downgraded to plain text",
"detail": "Without MTA-STS, an attacker on the network can strip TLS ...",
"fix": { "kind": "record", "type": "TXT", "host": "_mta-sts", "value": "v=STSv1; id=20261103" }
}
],
"spf": { "status": "pass", "records": ["v=spf1 ... ~all"], "totalLookups": 8, ... },
"dkim": { "status": "pass", "found": [{ "selector": "s1", "bits": 1024, ... }], ... },
"dmarc": { "status": "pass", "record": "v=DMARC1; p=reject; ...", "tags": { "p": "reject" } },
"mx": { "status": "pass", "provider": "Google Workspace", "hosts": [...], "nullMx": false },
"transport": { "status": "warn", "mtaSts": { "record": null, ... }, "tlsRpt": { "record": null } },
"bimi": { "status": "info", "record": null, "logo": null, "vmc": null },
"reputation": { "status": "pass", "checks": [{ "list": "...", "target": "...", "listed": false }] }
}Response fields
| Field | Type | What it holds |
|---|---|---|
| domain | string | The normalised domain. URLs and email addresses are reduced to it. |
| checkedAt | ISO 8601 | When the lookups ran. Cached up to ten minutes. |
| grade | A to F, or null | A from 90, B 78, C 62, D 45, else F. p=none caps it at B. Null if the check could not finish. |
| state | string | complete, partial, could-not-check or no-such-domain. Only the first two are graded. |
| incomplete | string[] | Areas that did not answer. Absent, not clean. |
| nonSending | boolean | No MX and an SPF that approves nothing before -all: declared as sending no mail. |
| score | 0 to 100, or null | 100 minus per-issue penalties: critical 26, high 14, medium 7, low 2. |
| gradeNote | string or null | What held the grade back, or what an A could not confirm. |
| summary | string | The one-sentence verdict shown on the report. |
| issues[] | Issue | By severity: id, area, severity, title, detail and the fix if any. |
| spf | object | Records, include tree, total and void lookups, all qualifier. |
| dkim | object | Keys on probed selectors: type, size, testing or revoked. |
| dmarc | object | Record, parsed tags, and the domain it was inherited from, if any. |
| mx | object | Hosts by priority with addresses, provider, and null MX. |
| transport | object | MTA-STS record, mode, policy errors; TLS-RPT record. |
| bimi | object | BIMI record, logo and certificate URLs. |
| reputation | object | Each blocklist, target, and listed or unreachable. |
| takeover | object or null | Trusted outside domains, and any unregistered or without DNS. |
Want to see one first? Open the stripe.com report.
DNS and blocklist API
Raw records and blocklist status. No key, open CORS, JSON errors.
GET /api/v1/lookup?name=domain or IP- Domain: its records. IP: reverse DNS and whether it points back. 60 a minute.
&type=optional- Comma-separated: A, AAAA, MX, TXT, NS, CNAME, SOA, CAA, DMARC. Omit for all.
GET /api/v1/blocklist?ip=IPv4- Spamhaus ZEN, SpamCop, Barracuda, PSBL, Mailspike and UCEPROTECT. 30 a minute.
GET /api/v1/blocklist?domain=domain- Spamhaus DBL.
A list that does not answer comes back as unavailable, never as clear. See it on a page.
curl "https://stamp-zeta-two.vercel.app/api/v1/lookup?name=example.com&type=MX,TXT"{
"name": "stripe.com",
"exists": true,
"answers": [
{ "type": "MX", "status": "ok", "records": [{ "value": "aspmx.l.google.com", "priority": 1 }, ...] },
{ "type": "TXT", "status": "ok", "records": [{ "value": "v=spf1 include:_spf.google.com ... ~all" }, ...] }
]
}const res = await fetch("https://stamp-zeta-two.vercel.app/api/v1/blocklist?ip=192.0.2.1");
const { listed, clear, unavailable, results } = await res.json();
// results: [{ "list": "Spamhaus ZEN", "state": "clear" | "listed" | "unavailable", "site": "https://..." }, ...]
if (listed) alert(`Listed on ${listed} blocklist(s)`);Fail CI when a grade drops
A GitHub Actions workflow using curl and jq. Save as .github/workflows/email-grade.yml and set DOMAINS and MIN_GRADE.
What it does
- Fails the run when any domain grades below
MIN_GRADE(A, B, C or D) or does not exist, after checking them all. - Warns and does not fail when a check gets no answer, since that says nothing about the domain.
- Waits out the rate limit using
Retry-After(30 a minute). - Reads the shared result, which can be up to ten minutes old.
For a one-off look across many domains, check a fleet.
name: Email grade
on:
schedule:
- cron: "17 6 * * *" # every day, 06:17 UTC
workflow_dispatch:
permissions:
contents: read
jobs:
grade:
runs-on: ubuntu-latest
steps:
- name: Fail if a domain drops below the grade
shell: bash
env:
STAMP_API: https://stamp-zeta-two.vercel.app/api/v1/check
DOMAINS: example.com example.org
MIN_GRADE: B
run: |
case "$MIN_GRADE" in A|B|C|D) ;; *) echo "::error::MIN_GRADE must be A, B, C or D"; exit 2 ;; esac
GRADE_CHECK='{"A":5,"B":4,"C":3,"D":2,"F":1} as $rank
| if type != "object" then "unknown\tThe answer was not a report."
elif .error then "unknown\t\(.error.message)"
elif .state == "no-such-domain" then "fail\tThe domain does not exist."
elif .grade == null then "unknown\t\(.summary)"
elif $rank[.grade] < $rank[$min] then "fail\tGrade \(.grade) (\(.score) of 100), below \($min). \(.summary)"
else "ok\tGrade \(.grade) (\(.score) of 100)." end'
answer=$(mktemp)
status=0
for domain in $DOMAINS; do
curl -sS -G --max-time 40 --retry 4 --retry-max-time 180 -o "$answer" "$STAMP_API" --data-urlencode "domain=$domain" || : > "$answer"
verdict=$(jq -r --arg min "$MIN_GRADE" "$GRADE_CHECK" "$answer" 2>/dev/null || true)
reason="${verdict#*$'\t'}"
case "$verdict" in
ok*) echo "$domain: $reason" ;;
fail*) echo "::error title=$domain::$reason"; status=1 ;;
unknown*) echo "::warning title=$domain::Could not check, so this run says nothing about its grade. $reason" ;;
*) echo "::warning title=$domain::Could not check, so this run says nothing about its grade. No readable answer came back." ;;
esac
done
exit "$status"