# 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.


  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

| Scope | Grants |
|---|---|
| `api.domain_auth.view` | The seven read endpoints — the domain list and one domain, sources, readiness, timeseries, timeline, and the comparison. |
| `api.domain_auth.manage` | The 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](/api-reference/oauth).

`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

| Endpoint | Returns |
|---|---|
| `GET /v1/domain-auth/domains` | Every 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}/sources` | The 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}/readiness` | The policy ladder: every criterion beside its measured value, plus what publishing would cost. |
| `POST /v1/domain-auth/domains/{domain}/readiness/acknowledge` | Accept a stated cost, so the record to publish is offered. |
| `POST /v1/domain-auth/domains/{domain}/remeasure` | Rerun this domain's source classification now instead of tonight. |
| `GET /v1/domain-auth/domains/{domain}/timeseries` | Daily volume split by authentication outcome. |
| `GET /v1/domain-auth/domains/{domain}/timeline` | What changed on the domain's DMARC configuration, and when. |
| `GET /v1/domain-auth/domains/{domain}/timeline/{annotationId}/comparison` | The same figures measured either side of one change. |
| `POST /v1/domain-auth/domains/{domain}/timeline/notes` | Append 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.


  `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](https://www.rfc-editor.org/rfc/rfc9989) (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.


  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:

| Origin | Evidence | Strength | Blind spot |
|---|---|---|---|
| `dns` | a live `_dmarc` lookup | exact record text, timestamped to the poll | describes only *now* |
| `reports` | the policy each report says was applied | retrospective; sees pre-onboarding history | lags 24–48h, no record text, coarse to the day |


  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.


  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.

## Related

- [MCP Server](/api-reference/mcp) — the same triage as an agent tool, `domain_auth_review`.
- [Moving to p=quarantine and p=reject](https://help.sendops.dev/domain-auth/moving-to-quarantine-and-reject) — the same ladder, for the person publishing the record.
- [Reading your source table](https://help.sendops.dev/domain-auth/reading-your-source-table) — what each classification and diagnosis code means.