Get the DMARC policy ladder for a domain
Assesses the domain against the policy rung above its current one, with
every criterion carrying the value actually measured beside its
threshold — met: false comes with "pass rate 97.2%, needs 98%", which
is a to-do list, rather than a bare cross.
record_to_publish is populated ONLY when every criterion is met.
It is a string to hand a person, not an action. SendOps does not hold
your DNS credentials and no endpoint here publishes DNS. Advancing a
policy on incomplete evidence starts discarding legitimate mail at every
receiver on the internet, and the failure is invisible from our side: the
policy tightens, the pass rate holds, and the mail stops arriving.
policy_history_unavailable says a false regressed means "we do not
know" rather than "no". Do not report a domain as un-regressed while it
is true.
Path parameters
domain string required The domain, by NAME (acme.com). A UUID is also accepted. Matched case-insensitively, and scoped to the calling org — a domain belonging to another org is a 404, never a 403.
Responses
Errors follow the RFC 7807 problem format — see the error reference.
current_policy string enum required One of: no_record, none, quarantine, reject
target_policy string enum required One of: no_record, none, quarantine, reject
ready boolean required True only when every BLOCKING criterion is met and no cost the verdict stopped blocking on is outstanding.
ready_pending_acknowledgement boolean required Every criterion is met and a cost the verdict stopped blocking on has not been accepted. A DISTINCT STATE, not a seventh unmet criterion: the domain has done everything it can do, and what is outstanding is a sentence to agree to. ready stays false and record_to_publish is empty; cost.sentence is what is owed.
cost object | null optional What publishing would actually cost, or null when it would cost nothing. Read it beside record_to_publish.
record_diff array<object> | null optional What publishing record_to_publish would change about the record live in DNS now, field by field. Null or empty when nothing is published — that domain is publishing its first record, not changing several fields — or when nothing differs.
Each entry in record_diff:
tag string required The DMARC tag name, lowercased — p, sp, rua, and so on.
kind string enum required One of: added, changed, removed
from string required The published value. Empty for an added tag.
to string required The recommended value. Empty for a removed tag.
note string required One sentence on what this tag does and what the change means.
ttl_advice object | null optional The step to take BEFORE publishing. Present alongside record_to_publish.
at_strongest boolean required Already at p=reject; there is no rung above.
needs_record boolean required No DMARC record is published at all
awaiting_first_report boolean required regressed boolean required The domain used to enforce a stronger policy than it does now — usually a DNS edit nobody meant to make.
regressed_from string enum optional One of: none, quarantine, reject
policy_history_unavailable boolean required A false regressed means "unknown" rather than "no" while this is true.
criteria array<object> required Each entry in criteria:
key string enum required no_unreviewed_sources was RETIRED and is no longer emitted. Its job is absorbed by acceptable_impact, where an unreviewed sender blocks by contributing impact rather than by existing.
One of: coverage_days, message_volume, distinct_providers, acceptable_impact, pass_rate, no_unaligned_sending, time_at_current_policy, no_new_unknown_sources
label string required met boolean required actual string required The value actually measured, populated whether or not the criterion is met — "97.2%" beside a required "98%" is a to-do list; a bare false is not.
required string required detail string optional blocking boolean required Whether failing this criterion withholds the rung. pass_rate is the only one where it is false: it explains the impact figure rather than gating on it, so a domain can be ready with pass_rate.met false. Never list a non-blocking criterion as a reason a domain is not ready.
pass_rate object required What the pass_rate criterion was actually measured over. Populated at p=reject too, where there is no criterion to judge but the rate is still real.
A distributed spoofing campaign sits in the denominator of the reported pass rate, so the domain most in need of enforcement shows the worst readiness and the ladder blocks on a number its owner cannot move. When a campaign is identified the rate is recomputed over the mail that could have authenticated, and campaign_excluded says so.
THIS IS NOT LENIENCY. The excluded mail is mail you never sent, from addresses that authenticated as nobody, and tightening the policy is exactly what stops it being delivered. There is nothing to confirm, reject or publish about it.
A second bucket comes out for a different reason: failing mail from sources you have explicitly REJECTED. You have said that mail is not yours, the impact preview already leaves it out on those grounds, and leaving it in the rate had two halves of one answer disagreeing about whose mail it is. It is reported separately from the campaign because "somebody is forging your domain" and "you told us this sender is not yours" are different facts, and only the second is a decision you can revisit.
A THIRD BUCKET COMES OUT, AND IT IS THE ONLY ONE THAT IS A COST RATHER THAN A REASON TO PUBLISH: your own mail, forwarded onward by a forwarder we can name and re-sent with a broken signature. Recipients choose to forward and you do not control that path, so it is not evidence of a fault you can fix and it does not decide the verdict. It stays in impact.would_affect_messages at full weight, because at p=quarantine that mail really is quarantined at receivers that do not override — and publishing past it requires a recorded acknowledgement.
Read the *_excluded flag beside each bucket before comparing adjusted against a pass rate from any other endpoint. A count may be non-zero while its flag is false — a bucket identified but not subtracted is named and not applied, and both rates are then the same number.
Fields of pass_rate:
raw number<double> required The pass rate over everything reported, in [0,1]. Every other endpoint reports this one.
adjusted number<double> required The rate the criterion was judged on, in [0,1]. Equal to raw when nothing was excluded.
messages integer<int64> required authenticable_messages integer<int64> required messages less the excluded campaign — the denominator adjusted was computed over. message_volume is judged on messages, not on this: a campaign never withdraws a rung the domain earned.
excluded_messages integer<int64> required excluded_sources integer<int64> required How many distinct addresses the excluded messages came from.
campaign_excluded boolean required Whether the campaign adjustment was applied. Not implied by excluded_messages being non-zero.
disowned_messages integer<int64> required Failing mail from sources you have rejected, with anything already counted in excluded_messages removed — the two overlap, and the campaign figure is a domain-level aggregate rather than a sum over source rows, so subtracting both whole would count the overlap twice.
disowned_sources integer<int64> required How many rejected sources contributed disowned_messages.
disowned_excluded boolean required Whether the disowned adjustment was applied. Not implied by disowned_messages being non-zero.
relay_broken_messages integer<int64> required Your own mail, broken on the way through a forwarder we can name. Out of this rate, IN impact.would_affect_messages at full weight: a cost of publishing, not a reason to publish.
relay_broken_sources integer<int64> required How many recognised forwarders contributed relay_broken_messages.
relay_broken_label string required The largest contributing forwarder by failures, so copy can name it. Empty when no name was available.
relay_broken_excluded boolean required Whether the relay adjustment was applied. Not implied by relay_broken_messages being non-zero.
top_failure_contributor object | null optional Null when no single source owns most of the domain's failures, which is the common case.
impact object | null optional What publishing target_policy would have done to the last thirty days of mail. Null for a domain already at p=reject, one awaiting its first report, and one with no record at all.
enforcement object | null optional What receivers actually DID with the published policy. Present only at p=quarantine or p=reject, and null when no failing mail was reported.
recommendation object | null optional What to do now: the record, the diff, the TTL step, the watch window, the way back, and copy specific to the moment. Null for a domain at the strongest rung and one awaiting its first report, where there is nothing to recommend.
PREFER IT OVER record_to_publish, which it supersedes. In particular, a domain that has just finished a watch window badly has no record_to_publish at all and a rollback_record that is the most useful string on the payload.
record_to_publish string optional Present only when ready. A record for YOU to publish — SendOps holds no DNS credentials and nothing on this API writes DNS.
A COMPATIBILITY FIELD, always equal to recommendation.record. A bare record is still the right answer to "what do I paste"; it is the wrong answer to every other question the recommendation answers.
recipient_impact string optional next_action object | null optional The single largest lever between this domain and target_policy, or null (SND-1398).
ONE, NEVER A LIST, and null is the common answer. criteria is already the list; what it cannot say is which of them costs the most and which row in sources to go and open. Quote this before the criteria when it is present, and do not manufacture one from the criteria when it is not — a weak next action is worse than none.
alignment_breadth object optional How much of this domain's PASSING mail passes on BOTH mechanisms rather than on one (SND-1383). 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 mechanisms is solid. The pass rate is identical for both. NOT A CRITERION AND NEVER BLOCKING. A fragility warning, not a fault — the remedy is a DKIM key at a third party on their timetable, and a customer forwarded to by strangers cannot make forwarding stop. It is reported beside the verdict and is no part of it.
Fields of alignment_breadth:
measured boolean required False when the window carried no passing mail. Render nothing rather than "0% on both mechanisms", which reads as a finding about a domain nobody has heard from.
both_messages integer<int64> required Aligned on DKIM and SPF — the only mail that survives both a forward (which breaks SPF) and a reseller rewriting the body (which breaks DKIM).
dkim_only_messages integer<int64> required Aligned on DKIM alone. Normal for forwarded mail, and the durable half: a DKIM signature travels with the message.
spf_only_messages integer<int64> required Aligned on SPF alone — the fragile population. The moment anybody forwards it, the envelope sender changes and it fails.
passing_messages integer<int64> required The denominator: mail that passed on at least one mechanism. The three counts above are DISJOINT and sum to it. Failing mail is the pass rate's business and is not here.
both_share number<double> required dkim_only_share number<double> required spf_only_share number<double> required fragile boolean required True when enough of the pass rate rests on SPF alone to be worth saying so. It gates the sentence, never a verdict.
suite_signing_as_itself string required The mailbox suite signing with its own domain instead of the customer's, when a source carries that diagnosis; empty otherwise. The same fact as the per-source suite_signing_as_itself finding at domain altitude — and the half with a fix attached.
sentence string required What "aligns only on SPF" means, in the same words that use the phrase. Empty when there is nothing worth saying.
summary string required code is internal_error. The
request_id field can be quoted to SendOps support to investigate.
application/problem+json