# Did this change work? The same figures either side of it

`GET /v1/domain-auth/domains/{domain}/timeline/{annotationId}/comparison`

- Authentication: required (Bearer token)
- Required scope: `api.domain_auth.view`

Recomputes the domain's figures over a window before and a window after
one timeline moment: pass rate raw and adjusted with all three exclusion
buckets, volume, the spoofing campaign, what publishing would have
affected, what receivers did, and source counts by class and review
state.

**This is the only place that can answer "did the change work".** Every
other figure in the product is a rolling thirty days that mixes both
sides of a change together.

**Read `delta_available` before subtracting anything.** It is false, and
`no_delta_reason` says why in a sentence, in three cases — each of which
is a refusal rather than a gap:

- a side reported fewer than `min_messages_per_side` messages, so any
  difference is noise. "Too early to tell" and "too quiet" are different
  answers and the reason says which (`after_incomplete` distinguishes
  them);
- another change happened inside one of the windows, so nothing here can
  be attributed to this one. `attribution_declined` is set and
  `neighbours` names the other changes. **The figures are still real** —
  what is withheld is the claim about cause;
- both at once.

**`guard_band_days` are excluded from the "after" side.** Aggregate
reports arrive 24–48 hours after the mail they describe, so the days
immediately following a change carry mail sent and judged under the OLD
policy. Including them would attribute the tail of the old policy to the
new one.

`truncations` names any window report history could not fill, and by how
many days. That is different from `after_incomplete`, which means the
window has not finished yet — nothing was lost, the evidence has not
arrived.

`annotationId` comes from a timeline event's `sightings`. Where both
detectors saw the change, `anchor_annotation_id` names the one the
windows were actually split on — the `dns` sighting wins, because its
timestamp is a poll rather than a midnight report bucket.

**The classification and the customer's decisions are as of now on both
sides.** A source reclassified last week would otherwise move between
the columns and read as mail that changed hands, when what changed was
our opinion of it.

## 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.
- `annotationId` (string<uuid>, required) — An annotation id from a timeline event's `sightings`.

## Example request

```bash
curl 'https://api.sendops.dev/v1/domain-auth/domains/string/timeline/00000000-0000-0000-0000-000000000000/comparison' \
  -H "Authorization: Bearer $SENDOPS_API_KEY"
```

## Responses

### 200 — The figures either side of the change

Content type: `application/json`

```json
{
  "annotation_id": "00000000-0000-0000-0000-000000000000",
  "anchor_annotation_id": "00000000-0000-0000-0000-000000000000",
  "anchor_origin": "dns",
  "occurred_at": "2026-05-17T20:00:00Z",
  "kind": "string",
  "old_policy": "string",
  "new_policy": "string",
  "old_record": "string",
  "new_record": "string",
  "before": {
    "from": "2026-05-17T20:00:00Z",
    "to": "2026-05-17T20:00:00Z",
    "days": 0,
    "measured": true,
    "volume": 0,
    "pass_volume": 0,
    "dkim_volume": 0,
    "spf_volume": 0,
    "pass_rate": {
      "raw": 0,
      "adjusted": 0,
      "messages": 0,
      "authenticable_messages": 0,
      "excluded_messages": 0,
      "excluded_sources": 0,
      "campaign_excluded": true,
      "disowned_messages": 0,
      "disowned_sources": 0,
      "disowned_excluded": true,
      "relay_broken_messages": 0,
      "relay_broken_sources": 0,
      "relay_broken_label": "string",
      "relay_broken_excluded": true
    },
    "dispersion": {
      "sources": 0,
      "volume": 0,
      "largest_source_volume": 0,
      "distributed_spoofing": true,
      "window_days": 0,
      "assessed_at": "2026-05-17T20:00:00Z"
    },
    "would_affect_messages": 0,
    "enforcement": {
      "policy": "quarantine",
      "receivers_reporting": 0,
      "receivers_applying": 0,
      "messages_failed": 0,
      "messages_actioned": 0
    },
    "sources_by_class": {},
    "sources_by_review_state": {}
  },
  "after": {
    "from": "2026-05-17T20:00:00Z",
    "to": "2026-05-17T20:00:00Z",
    "days": 0,
    "measured": true,
    "volume": 0,
    "pass_volume": 0,
    "dkim_volume": 0,
    "spf_volume": 0,
    "pass_rate": {
      "raw": 0,
      "adjusted": 0,
      "messages": 0,
      "authenticable_messages": 0,
      "excluded_messages": 0,
      "excluded_sources": 0,
      "campaign_excluded": true,
      "disowned_messages": 0,
      "disowned_sources": 0,
      "disowned_excluded": true,
      "relay_broken_messages": 0,
      "relay_broken_sources": 0,
      "relay_broken_label": "string",
      "relay_broken_excluded": true
    },
    "dispersion": {
      "sources": 0,
      "volume": 0,
      "largest_source_volume": 0,
      "distributed_spoofing": true,
      "window_days": 0,
      "assessed_at": "2026-05-17T20:00:00Z"
    },
    "would_affect_messages": 0,
    "enforcement": {
      "policy": "quarantine",
      "receivers_reporting": 0,
      "receivers_applying": 0,
      "messages_failed": 0,
      "messages_actioned": 0
    },
    "sources_by_class": {},
    "sources_by_review_state": {}
  },
  "window_days": 0,
  "guard_band_days": 0,
  "min_messages_per_side": 0,
  "delta_available": true,
  "no_delta_reason": "string",
  "attribution_declined": true,
  "neighbours": [
    {
      "annotation_id": "00000000-0000-0000-0000-000000000000",
      "kind": "string",
      "origin": "string",
      "occurred_at": "2026-05-17T20:00:00Z",
      "old_policy": "string",
      "new_policy": "string",
      "side": "before"
    }
  ],
  "truncations": [
    {
      "side": "before",
      "requested_from": "2026-05-17T20:00:00Z",
      "from": "2026-05-17T20:00:00Z",
      "lost_days": 0,
      "reason": "string"
    }
  ],
  "after_incomplete": true
}
```

### 401 — Missing, malformed, or unknown API key

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "retention_days": 0,
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ],
  "attribute_id": "00000000-0000-0000-0000-000000000000",
  "content_hash": "string",
  "differs": [
    "string"
  ]
}
```

### 403 — Key lacks the required scope or plan limit violated

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "retention_days": 0,
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ],
  "attribute_id": "00000000-0000-0000-0000-000000000000",
  "content_hash": "string",
  "differs": [
    "string"
  ]
}
```

### 404 — Resource not found

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "retention_days": 0,
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ],
  "attribute_id": "00000000-0000-0000-0000-000000000000",
  "content_hash": "string",
  "differs": [
    "string"
  ]
}
```

### 422 — Query parameter or path value failed validation

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "retention_days": 0,
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ],
  "attribute_id": "00000000-0000-0000-0000-000000000000",
  "content_hash": "string",
  "differs": [
    "string"
  ]
}
```

### 429 — Per-org rate limit exceeded

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "retention_days": 0,
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ],
  "attribute_id": "00000000-0000-0000-0000-000000000000",
  "content_hash": "string",
  "differs": [
    "string"
  ]
}
```

### 500 — Unexpected server-side failure. The `code` is `internal_error`. The
`request_id` field can be quoted to SendOps support to investigate.

Content type: `application/problem+json`

```json
{
  "type": "https://example.com",
  "title": "string",
  "status": 0,
  "detail": "string",
  "code": "invalid_key",
  "request_id": "string",
  "retry_after": 0,
  "retention_days": 0,
  "scope": "string",
  "resource": "string",
  "errors": [
    {
      "field": "string",
      "reason": "string"
    }
  ],
  "attribute_id": "00000000-0000-0000-0000-000000000000",
  "content_hash": "string",
  "differs": [
    "string"
  ]
}
```
