propgate docs

Get startedQuickstart

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.