StampWorkspace

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

What the forgery badge reads
BadgeMeaning
forgery: refusedRefused on every route.
forgery: spam folderAt worst, the spam folder.
forgery: not stoppedNothing requires receivers to stop it.
forgery: subdomain not stoppedThe address meets more, but a made-up subdomain is not stopped.
forgery: unknownA 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

Badge grades and verdicts
GradeVerdict
AReady to land
BMostly ready
CNeeds work
D, FLikely spam
?Not a domain, or the check failed
Email health for stripe.com on Stamp
Markdown
[![Email health for stripe.com on Stamp](https://stamp-zeta-two.vercel.app/badge/stripe.com.svg)](https://stamp-zeta-two.vercel.app/check/stripe.com)
HTML
<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-Remaining and RateLimit-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" } }

API errors
StatusCodeWhen
400missing_domainNo domain parameter.
400invalid_domainThe value is not a domain name.
429rate_limitedOver 30 a minute. Wait Retry-After seconds.
502check_failedThe lookups could not be completed. Retry shortly.
curl
curl "https://stamp-zeta-two.vercel.app/api/v1/check?domain=example.com"
JavaScript
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);
Response, abridged
{
  "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

Fields in the API response
FieldTypeWhat it holds
domainstringThe normalised domain. URLs and email addresses are reduced to it.
checkedAtISO 8601When the lookups ran. Cached up to ten minutes.
gradeA to F, or nullA from 90, B 78, C 62, D 45, else F. p=none caps it at B. Null if the check could not finish.
statestringcomplete, partial, could-not-check or no-such-domain. Only the first two are graded.
incompletestring[]Areas that did not answer. Absent, not clean.
nonSendingbooleanNo MX and an SPF that approves nothing before -all: declared as sending no mail.
score0 to 100, or null100 minus per-issue penalties: critical 26, high 14, medium 7, low 2.
gradeNotestring or nullWhat held the grade back, or what an A could not confirm.
summarystringThe one-sentence verdict shown on the report.
issues[]IssueBy severity: id, area, severity, title, detail and the fix if any.
spfobjectRecords, include tree, total and void lookups, all qualifier.
dkimobjectKeys on probed selectors: type, size, testing or revoked.
dmarcobjectRecord, parsed tags, and the domain it was inherited from, if any.
mxobjectHosts by priority with addresses, provider, and null MX.
transportobjectMTA-STS record, mode, policy errors; TLS-RPT record.
bimiobjectBIMI record, logo and certificate URLs.
reputationobjectEach blocklist, target, and listed or unreachable.
takeoverobject or nullTrusted 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
curl "https://stamp-zeta-two.vercel.app/api/v1/lookup?name=example.com&type=MX,TXT"
Response, abridged
{
  "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" }, ...] }
  ]
}
JavaScript: blocklist
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.

GitHub Actions workflow
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"