Get started › Quickstart
Quickstart
Every command here was run against the live API and the outputs are real, including the unflattering ones.
No key required
POST /v1/checks is open. One field, no signup.
curl -s -X POST https://api.propgate.dev/v1/checks \
-H 'content-type: application/json' \
-d '{"domain":"example.com"}'delegation pass
spf pass
dmarc pass
mx pass MX_NULL
MX_NULL on a passing check is the first thing worth understanding: the
domain declares it accepts no mail, which is correct rather than broken. Read
satisfied, not the presence of findings.
What a real domain looks like
curl -s -X POST https://api.propgate.dev/v1/checks -H 'content-type: application/json' \
-d '{"domain":"github.com"}'delegation warn NS_SERIAL_MISMATCH
spf warn SPF_MACRO_NOT_EVALUATED, SPF_LOOKUP_LIMIT_NEAR
dmarc pass
mx pass
Two findings on a domain nobody would call misconfigured, and neither is the kind of thing a hand-rolled verifier looks for.
Their SPF has no room left.
{
"code": "SPF_LOOKUP_LIMIT_NEAR",
"evidence": {
"detail": "0 of the ten lookups are left, so the next sending service added is likely to break SPF outright",
"expected": "at most 7 lookups, to leave room to grow",
"observed": "10 lookups"
}
}RFC 7208 caps SPF evaluation at ten DNS lookups. GitHub is at exactly ten. The
record works today and the next include: anyone adds breaks mail delivery,
with no error at the moment of the edit. Finding this needs recursive
include: expansion, which is why a regex over a TXT record is not a
verifier.
Their nameservers disagree.
{
"code": "NS_SERIAL_MISMATCH",
"evidence": {
"detail": "a zone transfer has stopped: every answer is valid, some are simply older, and which one a customer sees depends on which server they reach",
"observed": "ns-520.awsdns-01.net, … at 1; dns1.p08.nsone.net, … at 1656468023"
}
}Two nameserver sets, two different zone serials. Every answer is valid; some are older. Which one a customer gets depends on which server they happen to reach — the class of problem that reads as "intermittent" and never reproduces.
Every finding carries a diagnosis code, a slug linking to its
documentation, and the lookups behind it.
Getting a key
Two calls, no sales conversation. The middle step is reading your mail.
curl -s -X POST https://api.propgate.dev/v1/signup \
-H 'content-type: application/json' \
-d '{"email":"you@example.com"}'{ "data": { "object": "signup", "status": "pending" }, "error": null, "meta": null }A six-digit code arrives, valid for ten minutes. That response is identical whether or not the address already has an account. A signup endpoint that says already registered tells whoever holds a leaked address list which of those addresses use us.
curl -s -X POST https://api.propgate.dev/v1/signup/confirm \
-H 'content-type: application/json' \
-d '{"email":"you@example.com","code":"123456"}'{
"data": {
"apiKey": "pg_live_...",
"created": true,
"object": "account",
"tenantId": "019fcf4f-..."
},
"error": null,
"meta": null
}That is the only time the key is readable. Only a hash is stored, so no endpoint can show it again. Losing it means running the flow again, which mints an additional key against the same account rather than a second account. That doubles as the recovery path, which is why there is no separate sign-in.
The code is single-use: a second confirm with it returns 409.
confirm stores the key in $XDG_CONFIG_HOME/propgate/config.json at mode
0600 and prints it once. PROPGATE_API_KEY overrides it for CI. See
Authentication for the full key lifecycle, including how a
401 distinguishes a revoked key from an unknown one.
With a key
Registration and verification are separate calls. Registration is a write; verification is an action with latency. Importing ten thousand domains should not fire ten thousand DNS runs as a side effect of a bulk insert.
A profile: what you expect of a domain
curl -s -X POST https://api.propgate.dev/v1/profiles \
-H "authorization: Bearer pg_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H 'content-type: application/json' -d '{
"key": "sending",
"requirements": [
{ "key": "ns", "check": "delegation" },
{ "key": "spf", "check": "spf", "include": "_spf.google.com" },
{ "key": "dkim", "check": "dkim", "selector": "google" },
{ "key": "dmarc", "check": "dmarc" },
{ "key": "mail", "check": "mx", "expectsMail": true }
]
}'The CLI has no equivalent yet — a profile is created over the API only.
Editing a profile writes a new version; it never changes the old one, and domains stay pinned to the version they were registered against. Otherwise one edit silently reclassifies every domain at once.
A definition is refused at write time if any requirement could never be answered: a duplicate key, a DKIM requirement with no selector, a CAA requirement with no issuer. Accepting those would be a promise this API could not keep.
Register, then verify
curl -s -X POST https://api.propgate.dev/v1/domains \
-H "authorization: Bearer pg_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H 'content-type: application/json' \
-d '{"name":"yourdomain.dev","profile":"sending","externalId":"cust_1"}'Triggering a check needs the domain's id, here the one a fresh registration above would return:
curl -s -X POST https://api.propgate.dev/v1/domains/019fcf7a-2b3c-7d4e-9f5a-6b7c8d9e0f1a/checks \
-H "authorization: Bearer pg_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"The CLI has no equivalent yet — triggering a check is API-only.
{
"state": "failed",
"verdict": "fail",
"requirementsMet": 3,
"requirementsTotal": 5,
"requirements": [
{
"key": "spf",
"satisfied": true,
"verdict": "pass",
"findings": []
},
{
"key": "dkim",
"satisfied": false,
"verdict": "fail",
"findings": [
{
"code": "DKIM_RECORD_MISSING",
"name": "google._domainkey.yourdomain.dev"
}
]
}
]
}"3 of 5 met", the unmet ones named, and the DNS name the missing record belongs at. No instructions are rendered: you already have a UI that tells your customer what to paste, and being wrong about a provider's naming conventions is visible to your customer rather than to us.
Read it back, and watch it change
curl -s https://api.propgate.dev/v1/domains/019fcf7a-2b3c-7d4e-9f5a-6b7c8d9e0f1a \
-H "authorization: Bearer pg_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # stored, no re-check
curl -s https://api.propgate.dev/v1/domains/019fcf7a-2b3c-7d4e-9f5a-6b7c8d9e0f1a/timeline \
-H "authorization: Bearer pg_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"Check twice in a row and the timeline does not grow. An entry is appended only when an observation actually differs.
Fix the missing record, wait for the TTL, check again, and one entry appears:
{ "requirementKey": "dkim",
"previous": "fail:DKIM_RECORD_MISSING",
"current": "pass",
"observedAt": "2026-08-03T14:02:11.000Z" }Next
Full reference at the API reference. Every diagnosis code is documented at the taxonomy.