---
title: "Changelog"
description: "Dated changes to the API contract and the documentation."
publishedAt: 2026-09-16
---
Changes to the v1 API contract and the guides that document it, newest first.

## 2026-10-19

- The session routes (`/auth/login`, `/auth/register`, `/auth/refresh`,
  `/auth/logout`, the `/auth/2fa/login-*` steps, passkey sign-in
  (`/auth/passkey/options`, `/auth/passkey/verify`), password reset, email
  verification and the `/auth/me` writes) refuse a browser request sent from a
  site other than the DMARCguard app with `403` and `code: "cross_origin"`.
  Clients that send no `Origin` or `Sec-Fetch-Site` header, such as servers and
  scripts, are unaffected.

## 2026-10-18

- New `POST /auth/passkey/options` and `POST /auth/passkey/verify` sign a person
  in with a passkey, without an email or password. An account that must use
  single sign-on (its domain enforces it, or single sign-on created it and still
  links it) is refused with `403`, `code: "sso_required"` and the account's
  `email`, so you can start single sign-on for it; the password sign-in's
  answer for such a domain carries no `code`. An unverified email is refused as
  on `POST /auth/login`: `403` with `{"error": "email_not_verified", ...}`.
  Every failed assertion is the same generic `401` as a failed password sign-in;
  the one exception is a passkey the account no longer holds, which answers that
  `401` plus `code: "unknown_credential"` and the `credential_id` you sent.
- `kind` (`"passkey"` or `"security_key"`) is now required on
  `POST /auth/passkeys/register/verify`; it must match the options request.
- A password sign-in response can now carry `passkey_upgrade`, an offer to save
  a passkey automatically. Ignore it if you do not support it.
- New `GET /auth/passkeys`, `POST /auth/passkeys/register/options`,
  `POST /auth/passkeys/register/verify`, `PATCH /auth/passkeys/{id}` and
  `POST /auth/passkeys/{id}/remove` manage a person's passkeys and security
  keys. Like every `/auth/2fa/*` route they are session-only: an API key is
  refused with `403` and `code: "api_key_not_allowed"`.
- New `POST /auth/2fa/stepup/options` starts a passkey step-up for the
  two-factor routes, and `POST /auth/2fa/login-challenge` starts a passkey
  ceremony at the second sign-in step (and at lost-authenticator recovery).
  `login-challenge` refuses everything it cannot trace to a live challenge with
  the same generic `401` as `login-verify`.
- `webauthn` joins `totp` in the `methods` a sign-in can ask for, and
  `login-verify` and `login-enroll` accept `method: "webauthn"`. Ignore a method
  type you do not know.
- `GET /auth/2fa/status` now carries `factors` (`"totp"` and/or `"webauthn"`)
  and `protected` (any second factor is on). `enabled` still means an
  authenticator app.
- A change to a second factor (confirming the authenticator app on
  `POST /auth/2fa/verify`, disabling it, regenerating recovery codes, and adding,
  renaming or removing a passkey) now answers `401` when the request carries no
  live session, instead of signing the other devices out. Refresh the session
  and sign in again.

## 2026-10-17

- `POST /auth/login` now answers with a `status` field. `ok` is the existing
  response, unchanged. An account with two-factor authentication on gets
  `mfa_required` instead, with an `mfa_token`, its `expires_at` and the
  `methods` it accepts, and no session. Ignore a method type you do not know.
  Dashboard sign-in is the only caller; API keys are unaffected.
- New `POST /auth/2fa/login-verify` and `POST /auth/2fa/login-enroll` finish a
  sign-in or a lost-authenticator recovery. Every refusal is the same `401`;
  an exhausted attempt budget is `429` with `Retry-After`.
- Every `/auth/2fa/*` route is session-only: a request that authenticates with
  an API key is refused with `403` and `code: "api_key_not_allowed"`.
- `POST /auth/2fa/validate` is removed; `login-verify` replaces it.

## 2026-10-14

- [`GET /domains`](/docs/api/v1/domains/#listDomains): each domain now carries
  `aprf_enabled` and `aprf_state` (APRF performance reporting; same states as
  `dmarc_state`).
- [`GET /domains/{domain}/health`](/docs/api/v1/domains/#getDomainHealth): new
  `dns.aprf` record status (`ok`, `warning` or `unknown`) and `aprf_placement`
  (APRF placement data: each provider's, DKIM domain's and selector's most
  recent report within the last 7 UTC days, today included; an empty list when
  none). Each row carries the provider's `version`, `sdi_used` and
  `extra_info`; a change of `version` means its categories may count different
  actions, so compare counts only within one version. Rows are independent
  measurements, so do not add them together.

## 2026-10-07

- [`GET /domains/{domain}/health`](/docs/api/v1/domains/#getDomainHealth):
  `dns.mta_sts` carries an optional `hosted_cta` (`label`, `url`) when the
  domain's MTA-STS record is missing or failing, or a hosted setup is still in
  progress. The `url` is a path on the app. The `dns_mta_sts` action item now
  links there too, and a missing record raises a `low` item it did not before.

## 2026-10-02

- [`GET /reports/{id}`](/docs/api/v1/reports/#getReport) no longer adds a
  `parse_warnings` entry for a report whose `version` is `1` or `0.1`. Large
  reporters send both routinely and neither says anything about the report's
  structure. Any other unexpected version is still flagged.

## 2026-09-19

- [`GET /enums`](/docs/api/v1/vocabulary/#listEnums) returns every category
  the product uses, not only the ones the reference documents. The documented
  categories and their shape are unchanged; read the ones your integration
  needs by name.

## 2026-09-16

- Timestamps are RFC 3339 in UTC with whole seconds on every endpoint in the
  reference. A field naming a day rather than a moment is `YYYY-MM-DD`. An
  instant a reporter got wrong, one no RFC 3339 date can hold, renders `null`.
  See the [conventions](/docs/getting-started/#conventions).
- A request that authenticates with an API key is refused with `403` on
  `DELETE /api-keys/{id}`, matching the existing rule for creating keys: a
  full key gets `code: "api_key_cannot_revoke"`, a read key is stopped earlier
  with `permission_denied`. See [authentication](/docs/authentication/#lifetime).
- An unknown path under the base URL returns a JSON `404` instead of an HTML
  page.
- Lists in documented responses are never `null`; an empty list is `[]`.
- [`GET /enums`](/docs/api/v1/vocabulary/#listEnums) returns the categories
  the reference documents.
- `grade` documents its `N/A` value, returned before the first report.
- [`GET /domains`](/docs/api/v1/domains/#listDomains) and
  [`GET /top-sources`](/docs/api/v1/analytics/#listTopSources) now return a
  paged envelope (`total`, `limit`, `offset`) instead of a bare array.
- [`GET /events`](/docs/api/v1/events/#listEvents) echoes back the `limit`
  and `offset` it actually applied.
- A `403` permission denial now carries `code: "permission_denied"` and a
  `required` field naming the permission the key lacked. See
  [reading a rejection](/docs/authentication/#reading-a-rejection).
- [`GET /sources/{ip}`](/docs/api/v1/sources/#getSource) gained
  `spam_signals`, renamed from an earlier field.
- [`GET /enums`](/docs/api/v1/vocabulary/#listEnums) is now documented in the
  reference.
- A malformed `from` or `to` on [`GET /reports`](/docs/api/v1/reports/#listReports)
  now returns `400` instead of being silently dropped.
- The `domain` filter on statistics, compliance score, trends, top sources
  and sources is now case-insensitive, and a malformed value returns `400`
  instead of an empty result.
- [`GET /api-keys/usage`](/docs/api/v1/api-keys/#getAPIKeyUsage) documents
  its `503`.