Domain Auth (DMARC)

Search Documentation

Search across all developer documentation

Domain Auth (DMARC)

Receiving mail providers send DMARC aggregate reports about mail that claimed to be from your domains. SendOps collects them, groups every sender it sees into a source, classifies each one, and measures the domain against the criteria for tightening its DMARC policy. This surface exposes all of that: what is sending as you, what each source was judged to be, what publishing a stricter policy would cost, and what has already changed on the domain.

Nothing here publishes DNS

No endpoint on this API — on this scope or any other — advances a domain’s DMARC policy. The readiness endpoint hands you a record to publish; a person pastes it into their own DNS. That is deliberate: the blast radius of a policy change is the customer’s mail, and consent obtained from an API call is not consent.

No recipient PII

A DMARC aggregate report names sending hosts and volumes — never recipients. It carries no message content, no subject lines and no recipient addresses. Unlike the messages and suppressions surfaces, api.domain_auth.view returns nothing about the people your customer writes to.

The timeline is held to the same line, one step further: the dashboard names the colleague who published a change, and this surface deliberately does not, because a read-only DMARC token should not double as a way to enumerate an organization’s staff. The one address that does appear is the caller’s own, echoed back as the actor on an acknowledgement they just made.

Scopes

ScopeGrants
api.domain_auth.viewThe seven read endpoints — the domain list and one domain, sources, readiness, timeseries, timeline, and the comparison.
api.domain_auth.manageThe four writes — source decisions, re-measure, acknowledgement, timeline notes.

Three of the four writes require a user identity. Recording a source decision, acknowledging a cost and writing a note all record a person, and all three refuse a client_credentials token: the timeline shows who decided and who wrote, and a machine identity would leave the next reader with nobody to ask.

The refusal is a 422 validation_failed on the authorization field, not a 401 or a 403 — the credential is valid and carries the scope; what it cannot do is name a person. Retrying it will never succeed. Use an authorization_code token for those three — see OAuth 2.1.

POST …/remeasure is the exception and accepts either. It names nobody and records no decision.

None of the four is classified destructive, so a test-environment credential (sk_test_ / oc_test_) may call all of them.

The endpoints

EndpointReturns
GET /v1/domain-auth/domainsEvery domain’s reporting posture — policy, pass rate, coverage.
GET /v1/domain-auth/domains/{domain}One domain’s posture in full.
GET /v1/domain-auth/domains/{domain}/sourcesThe grouped sending sources, with each one’s classification and open finding.
PATCH /v1/domain-auth/domains/{domain}/sources/{sourceId}Record that a source is the org’s own, is not, or withdraw an earlier answer.
GET /v1/domain-auth/domains/{domain}/readinessThe policy ladder: every criterion beside its measured value, plus what publishing would cost.
POST /v1/domain-auth/domains/{domain}/readiness/acknowledgeAccept a stated cost, so the record to publish is offered.
POST /v1/domain-auth/domains/{domain}/remeasureRerun this domain’s source classification now instead of tonight.
GET /v1/domain-auth/domains/{domain}/timeseriesDaily volume split by authentication outcome.
GET /v1/domain-auth/domains/{domain}/timelineWhat changed on the domain’s DMARC configuration, and when.
GET /v1/domain-auth/domains/{domain}/timeline/{annotationId}/comparisonThe same figures measured either side of one change.
POST /v1/domain-auth/domains/{domain}/timeline/notesAppend your own text to the domain’s timeline.

Full request and response schemas are in the sidebar under Domain Auth.

Deciding a source

PATCH …/sources/{sourceId} records one of confirmed, rejected or unreviewed. It is reversible in both directions, which is why it is not destructive.

Two things it does not do, both of which callers assume:

  • It publishes nothing. Confirming a source does not authorize it to send; it records that a human recognised it.
  • It silences nothing. A rejected source keeps alerting. Rejecting records that somebody looked, not that the problem is gone.

Readiness: what the verdict turns on

GET …/readiness returns criteria — each one carrying the value actually measured, not a bare pass/fail, so "pass rate 97.2%, needs 98%" is reconstructable from the payload.

Read `blocking` on each criterion — do not infer it from the key

pass_rate is reported and not blocking. It explains the impact figure; it does not gate on itself. A client that treats every unmet criterion as a blocker will report a domain as not ready when the product says it is.

The criterion that decides is acceptable_impact, and it is counted in messages, not in a rate:

  • Reviewed senders get a budget — at most 10 failing messages a day over the 30-day window. Ten a day means the same thing at 3,000 messages a month and at 300,000, which a percentage does not.
  • Unreviewed senders get none. Any sender still awaiting a decision with 100 or more failing messages in the window blocks on its own.
  • no_unreviewed_sources is retired and is emitted by nothing. Its job is absorbed here: a source now blocks by contributing impact rather than by existing. The key is reserved so it can never be reused to mean something else — a consumer holding a saved view keyed on it will find it absent rather than find it changed meaning.

The remaining blocking criteria are coverage_days (≥ 14), message_volume (≥ 1,000), distinct_providers (≥ 3), no_unaligned_sending, and at the reject rung time_at_current_policy (≥ 30 days) and no_new_unknown_sources.

ready and ready_pending_acknowledgement are different states

Every criterion met is not the same as ready to publish. When a cost the verdict stopped blocking on has not been accepted, ready stays false, ready_pending_acknowledgement is true, and record_to_publish is withheld.

Only relay-broken forwarding triggers it — mail of the customer’s own that a forwarder re-sends with the signature broken. That cost left the verdict because the customer cannot reach it, and precisely because nothing is judging it, nothing else would ever make them look at it.

POST …/readiness/acknowledge clears it, and the record is then offered. The figure is frozen at the moment of acceptance, never recomputed, and an acknowledgement is about one rung: accepting “your forwarded mail goes to a spam folder” at quarantine is not accepting “it is refused” at reject, so a frozen target_policy that does not match the rung now on offer means the customer is asked again.

The record to publish carries sp=

The recommended record is v=DMARC1; p=…; sp=…; rua=mailto:…, with sp equal to p. With no sp, subdomains inherit p — the same outcome today, and a different one the moment anybody adds an sp for one subdomain. record_diff names each tag that changes and what it does, because two long TXT records differing by six characters in the middle is a diff nobody performs correctly at the moment they are about to paste one over the other.

ttl_advice is a field, not prose: lower the _dmarc TTL to 300 seconds before publishing, so the undo is fast.

There is no pct= ramp. RFC 9989 (May 2026) removed the tag from DMARC outright; see Appendix A.6.

alignment_breadth is a fragility warning, never a criterion

How much of the passing mail passes on both mechanisms rather than one. A domain at 99% whose passes are almost all SPF-only is one forward away from failing — SPF is the leg forwarding destroys, and a recipient setting up a forward is not something the sender can prevent or even see. A domain at 99% on both is solid. The pass rate is identical for both. It is reported beside the verdict and is no part of it.

impact.completeness, and the endpoint that fixes it

The impact preview reports its own completeness. A caller that reads completeness: partial and can do nothing about it until the nightly pass runs gives worse advice than the dashboard does — POST …/remeasure is what closes that gap.

A spent window is a 200, not a 429

One run per domain per hour. Being early is not a client error: the answer is “the last pass was recent enough”, and the body says so with queued: false, an honest retry_after_seconds, and when the last pass ran. Retry-After is set on the response too.

The limit is a rule about a domain, not about a caller — the same window and the same limiter the dashboard’s control uses, so the two surfaces cannot quote different waits.

The response describes the registry as it stood when the request arrived. It is never a prediction of what the queued run will find.

The timeline

GET …/timeline returns one event per notable moment in a domain’s DMARC life: policy_change, setup_change, source_decisions, acknowledgement, note.

setup_change is a record edit that did not move the policy — a rewritten rua, a new reporting address. It is a separate kind rather than a variant because it is how reporting silently stops, which is the commonest way a healthy domain becomes an unmonitored one.

Events come from two origins, and the disagreement between them is the point:

OriginEvidenceStrengthBlind spot
dnsa live _dmarc lookupexact record text, timestamped to the polldescribes only now
reportsthe policy each report says was appliedretrospective; sees pre-onboarding historylags 24–48h, no record text, coarse to the day

Two sightings is one change seen twice, never two changes

Most real changes are witnessed by both origins, and both are correct. The API folds them into one event and lists each witness under sightings. Do not count sightings as changes — a domain whose policy moved once would be reported as having moved twice.

occurred_at comes from the evidence; observed_at is when we first knew. They are separate fields because collapsing them would date every report-derived moment to whenever the job ran, placing a policy change after mail already judged under it.

old_record and new_record are the _dmarc TXT verbatim as observed. This is the reason the timeline exists: a customer whose registrar rewrote their record needs the previous value to paste back, and the current record is the damage.

”Did that change work?”

GET …/timeline/{annotationId}/comparison recomputes the readiness figures over a window either side of one change. It is the only thing in the product that can answer it — every other figure is a rolling thirty days that mixes both sides together.

Branch on `delta_available` before subtracting

A consumer that subtracts the two sides regardless will be wrong on a real domain. When it is false, no_delta_reason says why in a sentence you can show a customer.

Every refusal is a field, and each one costs you a number rather than being silently absorbed:

  • A two-day guard band after occurred_at is excluded. Reports lag 24–48 hours, so those days carry mail sent and judged under the old policy.
  • A floor. Under the minimum message count a side renders no delta. after_incomplete separates “too early to tell” from “too quiet” — nothing was lost, the evidence has not arrived, and the difference is the whole of what a reader does next.
  • attribution_declined means another change fell inside one of the windows. The figures are still real; what is withheld is the claim that this change caused them. neighbours names the other change.
  • truncations say which side was cut short and by how many days.

The registry classification and the customer’s decisions are read as of now on both sides, deliberately: a source reclassified last week would otherwise move between columns and read as mail that changed hands.