{
  "openapi": "3.1.0",
  "info": {
    "title": "InboxGuard API",
    "version": "1.2.0",
    "summary": "Email deliverability monitoring, DMARC reporting, and DNS authentication scanning.",
    "description": "The InboxGuard REST API lets developers and AI agents run on-demand deliverability scans (SPF, DKIM, DMARC, MTA-STS, TLS-RPT, BIMI, and DNS blocklists), re-verify raw email headers, manage monitored domains, read DMARC aggregate reports, list alerts, and manage API keys.\n\n## Authentication\nAuthenticated endpoints accept an InboxGuard API key as a bearer token:\n\n```\nAuthorization: Bearer ig_live_xxxxxxxxxxxxxxxxxxxxxxxx\n```\n\nMint keys in the dashboard (Settings → API keys) or via `POST /api-keys`. Keys carry scopes: `read` (GET only) or `full` (all methods). See https://inboxguard.io/auth.md.\n\n## Errors\nEvery error returns the same structured envelope (`#/components/schemas/Error`): `{ \"error\": { \"code\": \"RATE_LIMIT\", \"message\": \"...\", \"requestId\": \"...\" } }`.\n\n## Rate limits\nThe anonymous `POST /scan-domain` endpoint allows 5 requests/hour per IP; authenticated callers get their plan's hourly limit. `POST /scan-domain` responses carry `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` (and `X-RateLimit-*` equivalents); 429 responses add `Retry-After`.\n\n## Idempotency\n`POST /scan-domain` accepts an optional `Idempotency-Key` header with real replay semantics: a retry with the same key from the same caller within 1 hour returns the stored response instead of re-running the scan, marked with an `Idempotency-Replayed: true` response header.\n\n## Versioning\nThe API uses unversioned, stable URLs (one surface — no `/v1` path prefix). The current API version is echoed on every response in the `API-Version` header. Additive, backward-compatible changes (new endpoints, new optional fields) ship continuously without a version bump. Breaking changes — when they are ever needed — ship under a NEW version that you opt into by sending an optional `Accept-Version` request header; the existing behaviour remains the default so unversioned callers are never broken. Omit `Accept-Version` to always get the current stable version. Upcoming breaking changes are announced in these docs and at https://inboxguard.io/llms-full.txt in advance.\n\n## Pagination\nOnly `GET /scans` is paginated: pass `?before=<ISO timestamp>` (from the previous response's `nextCursor`) to walk older scans. `GET /domains`, `GET /alerts`, and `GET /api-keys` return the full (limit-capped) list in one response — there are no `cursor` parameters.",
    "termsOfService": "https://inboxguard.io/terms",
    "contact": { "name": "InboxGuard Support", "email": "support@inboxguard.io", "url": "https://inboxguard.io/contact" },
    "license": { "name": "Proprietary", "url": "https://inboxguard.io/terms" }
  },
  "servers": [{ "url": "https://api.inboxguard.io", "description": "Production" }],
  "externalDocs": { "description": "API quickstart and guides", "url": "https://inboxguard.io/guides/api-quickstart" },
  "tags": [
    { "name": "Scanning", "description": "Run deliverability and authentication scans." },
    { "name": "Domains", "description": "Manage monitored domains." },
    { "name": "Reports", "description": "DMARC aggregate reports and scan history." },
    { "name": "Alerts", "description": "Posture-change alerts." },
    { "name": "Account", "description": "API keys and account metadata." },
    { "name": "System", "description": "Health and status." }
  ],
  "security": [{ "ApiKeyBearer": [] }],
  "paths": {
    "/health": {
      "get": {
        "operationId": "getHealth",
        "tags": ["System"],
        "summary": "Liveness and database connectivity probe",
        "description": "Unauthenticated. Returns service status and database connectivity.",
        "security": [],
        "parameters": [{ "$ref": "#/components/parameters/AcceptVersion" }],
        "responses": {
          "200": { "description": "Service healthy", "headers": { "API-Version": { "$ref": "#/components/headers/ApiVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Health" }, "example": { "status": "ok", "db": "ok", "durationMs": 7, "env": "production", "version": "a1b2c3d" } } } },
          "503": { "description": "Service degraded (database unreachable). Note: this probe returns the `Health` schema (with `db: \"error\"`), NOT the error envelope.", "headers": { "API-Version": { "$ref": "#/components/headers/ApiVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Health" } } } },
          "500": { "$ref": "#/components/responses/InternalServerError" }
        }
      }
    },
    "/scan-domain": {
      "post": {
        "operationId": "scanDomain",
        "tags": ["Scanning"],
        "summary": "Run a deliverability scan for a domain",
        "description": "Evaluates SPF (with 10-DNS-lookup budget), DKIM selectors, DMARC posture and alignment, PTR/FCrDNS, MTA-STS, TLS-RPT, MX TLS, BIMI, and DNS blocklists, then returns a unified score. Works anonymously (5/hour per IP, free-tier blocklist subset) or authenticated (plan tier, persisted to scan history). Each check object sits at the TOP level of the response (`spf`, `dmarc`, `ptr`, `dkim`, `mtaSts`, `tlsRpt`, `mxTls`, `bimi`, `blocklist`) — there is no `checks` wrapper.",
        "security": [{}, { "ApiKeyBearer": [] }],
        "parameters": [
          { "$ref": "#/components/parameters/IdempotencyKey" },
          { "$ref": "#/components/parameters/AcceptVersion" }
        ],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ScanRequest" }, "example": { "domain": "example.com", "dkimSelectors": ["selector1", "google"] } } } },
        "responses": {
          "200": {
            "description": "Scan completed",
            "headers": {
              "API-Version": { "$ref": "#/components/headers/ApiVersion" },
              "RateLimit-Limit": { "$ref": "#/components/headers/RateLimitLimit" },
              "RateLimit-Remaining": { "$ref": "#/components/headers/RateLimitRemaining" },
              "RateLimit-Reset": { "$ref": "#/components/headers/RateLimitReset" },
              "Idempotency-Key": { "$ref": "#/components/headers/IdempotencyKeyEcho" },
              "Idempotency-Replayed": { "$ref": "#/components/headers/IdempotencyReplayed" }
            },
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ScanResult" } } }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "429": { "$ref": "#/components/responses/RateLimited" },
          "503": { "$ref": "#/components/responses/ServiceUnavailable" },
          "500": { "$ref": "#/components/responses/InternalServerError" }
        }
      }
    },
    "/analyze-headers": {
      "post": {
        "operationId": "analyzeHeaders",
        "tags": ["Scanning"],
        "summary": "Re-verify SPF/DKIM/DMARC/ARC from a raw email",
        "description": "Paste a raw RFC 5322 message (headers + optional body). InboxGuard RE-VERIFIES the signatures from the raw bytes via mailauth — DKIM public-key fetch + signature recompute, SPF against the sending IP, DMARC alignment, and the ARC chain — instead of trusting a (forgeable) `Authentication-Results` header. The receivers' own `Authentication-Results` claims are reported separately under `theirs`, and `agreement` is false when our verdict diverges from theirs. Works anonymously or authenticated (authenticated calls persist a redacted history sample).",
        "security": [{}, { "ApiKeyBearer": [] }],
        "parameters": [{ "$ref": "#/components/parameters/AcceptVersion" }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AnalyzeHeadersRequest" }, "example": { "message": "Received: from mail.example.com ([192.0.2.1])...\nDKIM-Signature: v=1; a=rsa-sha256; d=example.com; s=selector1; ...\nFrom: sender@example.com\nSubject: Hello\n\nBody text." } } } },
        "responses": {
          "200": { "description": "Verification report", "headers": { "API-Version": { "$ref": "#/components/headers/ApiVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AnalyzeHeadersResult" } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "500": { "$ref": "#/components/responses/InternalServerError" }
        }
      }
    },
    "/domains": {
      "get": {
        "operationId": "listDomains",
        "tags": ["Domains"],
        "summary": "List monitored domains",
        "description": "Returns ALL domains monitored by the caller's organization (no pagination parameters), each with its latest scan score, last scan time, and open-alert count. Fields are snake_case.",
        "parameters": [{ "$ref": "#/components/parameters/AcceptVersion" }],
        "responses": {
          "200": { "description": "Domain list", "headers": { "API-Version": { "$ref": "#/components/headers/ApiVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DomainList" }, "example": { "items": [{ "id": "8f14e45f-ceea-467f-aa65-4c4e28f6f3f1", "domain": "example.com", "status": "active", "quota_used": 3, "created_at": "2026-01-04T12:00:00Z", "latest_score": 88, "latest_scan_at": "2026-06-09T07:00:00Z", "open_alerts": 1 }], "total": 1 } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "500": { "$ref": "#/components/responses/InternalServerError" }
        }
      }
    },
    "/domains/{id}": {
      "parameters": [{ "$ref": "#/components/parameters/DomainId" }],
      "get": {
        "operationId": "getDomain",
        "tags": ["Domains"],
        "summary": "Get a monitored domain",
        "description": "Returns full detail for a single monitored domain: the domain row, the latest scan with all per-check JSON (note the latest-scan check keys are snake_case: `mta_sts`, `tls_rpt`, `mx_tls`), a thin scan history (last 30), recent alerts, and Google Postmaster state.",
        "parameters": [{ "$ref": "#/components/parameters/AcceptVersion" }],
        "responses": {
          "200": { "description": "Domain detail", "headers": { "API-Version": { "$ref": "#/components/headers/ApiVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DomainDetail" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "500": { "$ref": "#/components/responses/InternalServerError" }
        }
      },
      "delete": {
        "operationId": "deleteDomain",
        "tags": ["Domains"],
        "summary": "Stop monitoring a domain",
        "description": "Removes a domain from monitoring. Requires the `full` scope.",
        "parameters": [{ "$ref": "#/components/parameters/AcceptVersion" }],
        "responses": {
          "200": { "description": "Deleted", "headers": { "API-Version": { "$ref": "#/components/headers/ApiVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DeleteDomainResult" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "500": { "$ref": "#/components/responses/InternalServerError" }
        }
      }
    },
    "/domains/{id}/dns-diff": {
      "parameters": [{ "$ref": "#/components/parameters/DomainId" }],
      "get": {
        "operationId": "getDnsDiff",
        "tags": ["Domains"],
        "summary": "Preview the DNS fix plan for a domain",
        "description": "Computes the DNS-record diff between what's currently published at the domain's connected registrar and what the latest scan recommends. Read-only — nothing in DNS or the database changes. InboxGuard tries each connected registrar (Cloudflare/Route 53/GoDaddy/Namecheap) until one's zones cover the apex. The returned `ops` are passed verbatim to `POST /domains/{id}/dns-apply`; `manualReview` lists fixes that need a human decision (SPF sender list, DKIM keys, BIMI logo) and can't be auto-applied. Requires the domain to have at least one scan (400 otherwise). Requires an authenticated credential (403 when unauthenticated). When no registrar connection manages the zone, returns 200 with `connected:false`/`reason` and empty `ops`.",
        "parameters": [{ "$ref": "#/components/parameters/AcceptVersion" }],
        "responses": {
          "200": { "description": "DNS fix plan (diff)", "headers": { "API-Version": { "$ref": "#/components/headers/ApiVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DnsDiffResult" } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "500": { "$ref": "#/components/responses/InternalServerError" }
        }
      }
    },
    "/domains/{id}/dns-apply": {
      "parameters": [{ "$ref": "#/components/parameters/DomainId" }],
      "post": {
        "operationId": "applyDnsFix",
        "tags": ["Domains"],
        "summary": "Apply a previewed DNS fix plan",
        "description": "Applies a DNS fix plan by publishing records at the connected registrar. DESTRUCTIVE: creates/updates/deletes DNS records. Two-step by design — pass the `connectionId` and `ops` array returned by `GET /domains/{id}/dns-diff` verbatim. The server re-derives the diff from the latest scan and rejects any op that no longer matches (400, stale plan), so a client can never publish or delete arbitrary records. Requires an owner/admin credential with the `full` scope (403 otherwise). NOT transactional: ops run one at a time and each per-op outcome is reported in `results` — partial success is possible. Re-scan afterward to confirm.",
        "parameters": [{ "$ref": "#/components/parameters/AcceptVersion" }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DnsApplyRequest" }, "example": { "connectionId": "7c9e6679-7425-40de-944b-e07fc1f90ae7", "ops": [{ "kind": "create", "reason": "Publish a DMARC monitoring policy", "before": null, "after": { "name": "_dmarc.example.com", "type": "TXT", "value": "v=DMARC1; p=none; rua=mailto:rua@example.com; fo=1", "ttl": 3600 } }] } } } },
        "responses": {
          "200": { "description": "Apply result (per-op outcomes)", "headers": { "API-Version": { "$ref": "#/components/headers/ApiVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DnsApplyResult" } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "500": { "$ref": "#/components/responses/InternalServerError" }
        }
      }
    },
    "/scans": {
      "get": {
        "operationId": "listScans",
        "tags": ["Reports"],
        "summary": "List recent scans",
        "description": "Returns recent scans across all of the organization's domains (thin rows: id, domain, run time, score), most recent first. Filter to one domain with `?domainId=<uuid>`. Cursor pagination: pass the previous response's `nextCursor` as `?before=` to walk older scans.",
        "parameters": [
          { "name": "domainId", "in": "query", "required": false, "description": "Restrict to one monitored domain by its UUID (from `GET /domains`).", "schema": { "type": "string", "format": "uuid" } },
          { "name": "before", "in": "query", "required": false, "description": "ISO 8601 timestamp cursor — return scans strictly older than this. Use the previous response's `nextCursor`.", "schema": { "type": "string", "format": "date-time" } },
          { "name": "limit", "in": "query", "required": false, "description": "Max scans to return (default 20, max 100).", "schema": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 } },
          { "$ref": "#/components/parameters/AcceptVersion" }
        ],
        "responses": {
          "200": { "description": "Scan history", "headers": { "API-Version": { "$ref": "#/components/headers/ApiVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ScanList" }, "example": { "items": [{ "id": "0b8c7f3a-1f6e-4c2a-9a3d-2f9d2f3e4a5b", "domain_id": "8f14e45f-ceea-467f-aa65-4c4e28f6f3f1", "domain": "example.com", "run_at": "2026-06-09T07:00:00Z", "score": 88 }], "nextCursor": "2026-06-09T07:00:00Z" } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "500": { "$ref": "#/components/responses/InternalServerError" }
        }
      }
    },
    "/domains/{id}/dmarc-reports": {
      "parameters": [{ "$ref": "#/components/parameters/DomainId" }],
      "get": {
        "operationId": "getDmarcReports",
        "tags": ["Reports"],
        "summary": "DMARC aggregate report data for a domain",
        "description": "Returns DMARC RUA aggregate report data for one monitored domain over the last N days: the org's hosted `rua-…@reports.inboxguard.io` inbox (with a ready-to-publish DMARC snippet), per-day per-reporter rollups, top sending sources, and window totals. The domain id is the UUID from `GET /domains` — resolve a domain NAME to its id by listing domains and matching `domain`. Requires a plan with DMARC ingest (403 otherwise).",
        "parameters": [
          { "name": "days", "in": "query", "required": false, "description": "Lookback window in days (default 30; values above 90 are clamped to 90).", "schema": { "type": "integer", "minimum": 1, "maximum": 90, "default": 30 } },
          { "$ref": "#/components/parameters/AcceptVersion" }
        ],
        "responses": {
          "200": { "description": "DMARC aggregate summary", "headers": { "API-Version": { "$ref": "#/components/headers/ApiVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DmarcReportsResponse" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "500": { "$ref": "#/components/responses/InternalServerError" }
        }
      }
    },
    "/alerts": {
      "get": {
        "operationId": "listAlerts",
        "tags": ["Alerts"],
        "summary": "List alerts",
        "description": "Returns the organization's alerts, newest first. Defaults to OPEN alerts only (`resolved=false`). Rows are snake_case and use `kind` (e.g. `score_drop`, `blocklist_listed`) plus `resolved_at` (null = open).",
        "parameters": [
          { "name": "resolved", "in": "query", "required": false, "description": "'false' = open only (default), 'true' = resolved only, 'all' = both.", "schema": { "type": "string", "enum": ["true", "false", "all"], "default": "false" } },
          { "name": "severity", "in": "query", "required": false, "description": "Filter by severity (default 'all').", "schema": { "type": "string", "enum": ["critical", "warn", "info", "all"], "default": "all" } },
          { "name": "entityType", "in": "query", "required": false, "description": "Filter by the entity the alert is about (default 'all').", "schema": { "type": "string", "enum": ["domain", "org", "user", "all"], "default": "all" } },
          { "name": "limit", "in": "query", "required": false, "description": "Max alerts to return (default 50, max 200).", "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 } },
          { "$ref": "#/components/parameters/AcceptVersion" }
        ],
        "responses": {
          "200": { "description": "Alert list", "headers": { "API-Version": { "$ref": "#/components/headers/ApiVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AlertList" } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "500": { "$ref": "#/components/responses/InternalServerError" }
        }
      }
    },
    "/alerts/{id}": {
      "parameters": [{ "name": "id", "in": "path", "required": true, "description": "Alert id (UUID).", "schema": { "type": "string", "format": "uuid" } }],
      "patch": {
        "operationId": "resolveAlert",
        "tags": ["Alerts"],
        "summary": "Resolve or reopen an alert",
        "description": "Body `{ \"resolved\": true }` marks the alert resolved (preserving an earlier auto-resolve timestamp if present); `{ \"resolved\": false }` reopens it. Requires an owner/admin credential with the `full` scope.",
        "parameters": [{ "$ref": "#/components/parameters/AcceptVersion" }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ResolveAlertRequest" }, "example": { "resolved": true } } } },
        "responses": {
          "200": { "description": "Updated alert", "headers": { "API-Version": { "$ref": "#/components/headers/ApiVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Alert" } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "500": { "$ref": "#/components/responses/InternalServerError" }
        }
      }
    },
    "/me": {
      "get": {
        "operationId": "getMe",
        "tags": ["Account"],
        "summary": "Current identity, org, plan, and usage",
        "description": "Returns the caller's identity, organization context, effective plan/trial state, and domain usage. Also the cheapest way for an agent to verify an API key is valid.",
        "parameters": [{ "$ref": "#/components/parameters/AcceptVersion" }],
        "responses": {
          "200": { "description": "Identity and plan", "headers": { "API-Version": { "$ref": "#/components/headers/ApiVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Me" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "500": { "$ref": "#/components/responses/InternalServerError" }
        }
      }
    },
    "/api-keys": {
      "get": {
        "operationId": "listApiKeys",
        "tags": ["Account"],
        "summary": "List API keys",
        "description": "Lists non-revoked API keys for the caller's organization (fields are snake_case: `key_prefix`, `last_used_at`, `created_at`, …). Secrets are never returned after creation. Requires session (Cognito) auth — API keys cannot manage API keys.",
        "parameters": [{ "$ref": "#/components/parameters/AcceptVersion" }],
        "responses": {
          "200": { "description": "API keys", "headers": { "API-Version": { "$ref": "#/components/headers/ApiVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiKeyList" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "500": { "$ref": "#/components/responses/InternalServerError" }
        }
      },
      "post": {
        "operationId": "createApiKey",
        "tags": ["Account"],
        "summary": "Create an API key",
        "description": "Mints a new API key and returns the secret (`token`) exactly once. Requires session (owner/admin) auth. Choose scopes `read` or `full`.",
        "parameters": [{ "$ref": "#/components/parameters/AcceptVersion" }],
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateApiKeyRequest" }, "example": { "name": "CI deploy bot", "environment": "live", "scopes": ["read"] } } } },
        "responses": {
          "201": { "description": "Key created (secret returned once)", "headers": { "API-Version": { "$ref": "#/components/headers/ApiVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiKeyWithSecret" } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "500": { "$ref": "#/components/responses/InternalServerError" }
        }
      }
    },
    "/api-keys/{id}": {
      "parameters": [{ "name": "id", "in": "path", "required": true, "description": "API key id (UUID).", "schema": { "type": "string", "format": "uuid" } }],
      "delete": {
        "operationId": "revokeApiKey",
        "tags": ["Account"],
        "summary": "Revoke an API key",
        "description": "Soft-revokes an API key. The key stops working immediately. Requires session (owner/admin) auth.",
        "parameters": [{ "$ref": "#/components/parameters/AcceptVersion" }],
        "responses": {
          "200": { "description": "Revoked", "headers": { "API-Version": { "$ref": "#/components/headers/ApiVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RevokeApiKeyResult" }, "example": { "ok": true, "revoked": { "id": "5c2f7a16-9d0e-4f6b-8a3c-1e2d3f4a5b6c" } } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "403": { "$ref": "#/components/responses/Forbidden" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "500": { "$ref": "#/components/responses/InternalServerError" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ApiKeyBearer": { "type": "http", "scheme": "bearer", "bearerFormat": "InboxGuard API key (ig_live_… / ig_test_…)", "description": "PRIMARY auth: send an InboxGuard API key as a bearer token (`Authorization: Bearer ig_live_…`). Scopes: `read` (GET) and `full` (all methods). Mint a key at https://inboxguard.io/settings or per https://inboxguard.io/auth.md." },
      "OAuth2": { "type": "oauth2", "description": "OAuth-shaped wrapper around the same API key. The ONLY grant is client_credentials at POST https://api.inboxguard.io/oauth/token, where the client_secret IS an InboxGuard API key (HTTP Basic or body); the returned access_token is that same key. There is NO authorization_code flow, NO PKCE, and NO /oauth/authorize endpoint. Discovery: /.well-known/oauth-authorization-server and /.well-known/oauth-protected-resource on api.inboxguard.io. See https://inboxguard.io/auth.md.", "flows": { "clientCredentials": { "tokenUrl": "https://api.inboxguard.io/oauth/token", "scopes": { "read": "Read-only access (GET).", "full": "Full read/write access (all methods)." } } } }
    },
    "parameters": {
      "DomainId": { "name": "id", "in": "path", "required": true, "description": "Monitored domain id (UUID, from GET /domains).", "schema": { "type": "string", "format": "uuid" } },
      "IdempotencyKey": { "name": "Idempotency-Key", "in": "header", "required": false, "description": "Client-generated key that makes a retried scan safe: the same key from the same caller within 1 hour replays the stored response (marked `Idempotency-Replayed: true`) instead of re-running and re-billing the scan.", "schema": { "type": "string", "maxLength": 255 } },
      "AcceptVersion": { "name": "Accept-Version", "in": "header", "required": false, "description": "Optional API version opt-in. Omit to receive the current stable version (recommended). Send a specific version string only to opt into a future breaking-change version once one is published; unknown values fall back to the current stable version. The version actually served is echoed in the `API-Version` response header.", "schema": { "type": "string" } }
    },
    "headers": {
      "RateLimitLimit": { "description": "Request quota for the current window (also sent as X-RateLimit-Limit).", "schema": { "type": "integer" } },
      "RateLimitRemaining": { "description": "Requests remaining in the current window (also sent as X-RateLimit-Remaining).", "schema": { "type": "integer" } },
      "RateLimitReset": { "description": "Seconds until the window resets (also sent as X-RateLimit-Reset).", "schema": { "type": "integer" } },
      "RetryAfter": { "description": "Seconds to wait before retrying.", "schema": { "type": "integer" } },
      "IdempotencyKeyEcho": { "description": "Echo of the request's Idempotency-Key, if provided.", "schema": { "type": "string" } },
      "IdempotencyReplayed": { "description": "Present and 'true' when this response was replayed from the idempotency store rather than freshly computed.", "schema": { "type": "string", "enum": ["true"] } },
      "ApiVersion": { "description": "The API version that served this response. The URL surface is unversioned and stable; this header reports the current version so clients can detect breaking-change rollouts. Send the optional `Accept-Version` request header to pin a version once a future breaking version is published.", "schema": { "type": "string" } }
    },
    "responses": {
      "BadRequest": { "description": "Invalid request. Returns the structured error envelope with code `BAD_REQUEST`.", "headers": { "API-Version": { "$ref": "#/components/headers/ApiVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
      "Unauthorized": { "description": "Missing or invalid credentials. Returns the structured error envelope with code `UNAUTHORIZED`.", "headers": { "API-Version": { "$ref": "#/components/headers/ApiVersion" }, "WWW-Authenticate": { "schema": { "type": "string" }, "description": "Bearer resource_metadata=\"https://api.inboxguard.io/.well-known/oauth-protected-resource\"" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
      "Forbidden": { "description": "Authenticated but missing required scope or role. Returns the structured error envelope with code `FORBIDDEN`.", "headers": { "API-Version": { "$ref": "#/components/headers/ApiVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
      "NotFound": { "description": "Resource not found. Returns the structured error envelope with code `NOT_FOUND`.", "headers": { "API-Version": { "$ref": "#/components/headers/ApiVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
      "RateLimited": { "description": "Rate limit exceeded. Returns the structured error envelope with code `RATE_LIMIT`.", "headers": { "API-Version": { "$ref": "#/components/headers/ApiVersion" }, "Retry-After": { "$ref": "#/components/headers/RetryAfter" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
      "ServiceUnavailable": { "description": "Temporarily unavailable. Code `SERVICE_UNAVAILABLE` = scanning disabled by operators; code `DNS_TEMPORARY_FAILURE` = every DNS resolver SERVFAILed for this domain (NOT a statement about the domain's records — retry after the `Retry-After` header, ~30s).", "headers": { "API-Version": { "$ref": "#/components/headers/ApiVersion" }, "Retry-After": { "$ref": "#/components/headers/RetryAfter" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": { "code": "DNS_TEMPORARY_FAILURE", "message": "DNS resolution for this domain failed temporarily (resolver SERVFAIL). Nothing is wrong with your records as far as we can tell — retry in ~30 seconds." } } } } },
      "InternalServerError": { "description": "Unexpected server error. Any unhandled failure is normalised by the root middleware into the structured error envelope with code `INTERNAL_ERROR` and a `requestId` for support.", "headers": { "API-Version": { "$ref": "#/components/headers/ApiVersion" } }, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": { "code": "INTERNAL_ERROR", "message": "Internal server error", "requestId": "9f8c1b2a-3d4e-4f5a-8b6c-7d8e9f0a1b2c" } } } } }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "description": "Structured error envelope returned by every non-2xx response.",
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "code": { "type": "string", "description": "Stable machine-readable code.", "enum": ["BAD_REQUEST", "UNAUTHORIZED", "FORBIDDEN", "NOT_FOUND", "CONFLICT", "RATE_LIMIT", "UPSTREAM_ERROR", "INTERNAL_ERROR", "SERVICE_UNAVAILABLE", "DNS_TEMPORARY_FAILURE"] },
              "message": { "type": "string", "description": "Human-readable description." },
              "requestId": { "type": "string", "description": "Correlation id for support." },
              "details": { "description": "Optional structured validation detail (e.g. zod field errors)." },
              "retryAfterSeconds": { "type": "integer", "description": "Present on RATE_LIMIT responses." }
            },
            "required": ["code", "message"]
          }
        },
        "required": ["error"]
      },
      "Health": { "type": "object", "properties": { "status": { "type": "string", "enum": ["ok", "degraded"] }, "db": { "type": "string", "enum": ["ok", "error"] }, "dbError": { "type": "string", "description": "Present when db = error." }, "durationMs": { "type": "integer" }, "env": { "type": "string" }, "version": { "type": "string" } }, "required": ["status", "db"] },
      "Issue": {
        "type": "object",
        "description": "One finding inside a check.",
        "properties": {
          "severity": { "type": "string", "enum": ["info", "warn", "critical"] },
          "message": { "type": "string" }
        },
        "required": ["severity", "message"]
      },
      "ScanRequest": {
        "type": "object",
        "properties": {
          "domain": { "type": "string", "minLength": 1, "maxLength": 253, "description": "The domain to scan, e.g. example.com.", "examples": ["example.com"] },
          "dkimSelectors": { "type": "array", "items": { "type": "string", "maxLength": 64 }, "maxItems": 20, "description": "Optional DKIM selectors to probe (in addition to common ESP selectors)." },
          "turnstileToken": { "type": "string", "description": "Cloudflare Turnstile token for anonymous browser scans; ignored when authenticated." }
        },
        "required": ["domain"]
      },
      "ScanScore": {
        "type": "object",
        "description": "Overall deliverability score with a per-check point breakdown.",
        "properties": {
          "total": { "type": "integer", "minimum": 0, "maximum": 100, "description": "Overall 0–100 score." },
          "grade": { "type": "string", "enum": ["A", "B", "C", "D", "F"] },
          "breakdown": { "type": "object", "description": "Points contributed by each check.", "properties": { "spf": { "type": "number" }, "dmarc": { "type": "number" }, "ptr": { "type": "number" }, "dkim": { "type": "number" }, "bimi": { "type": "number" }, "mtaSts": { "type": "number" }, "tlsRpt": { "type": "number" }, "blocklist": { "type": "number" } } }
        },
        "required": ["total", "grade", "breakdown"]
      },
      "DomainCheck": {
        "type": "object",
        "description": "Result of one authentication/transport/reputation check. Carries `healthy` (boolean) and `issues` (array of {severity, message}) plus check-specific fields — e.g. SPF `record`/`lookupCount`/`allQualifier`/`permerrors`, DMARC `policy`/`pct`/`rua`, DKIM `keys[]` (selector, keyLengthBits, …), PTR `mxIps[]`, MTA-STS `policy`, BIMI `logoUrl`/`vmcUrl`, blocklist `hits[]`/`targets[]`/`checked`/`errored`. Exceptions: `mxTls` has `status` ('unsupported'|'pass'|'fail'|'partial') instead of `healthy`, and `bimi` uses `found`/`logoReachable`/`displayEligible`.",
        "properties": {
          "healthy": { "type": "boolean", "description": "Whether this check passed cleanly (absent on mxTls and bimi — see description)." },
          "issues": { "type": "array", "items": { "$ref": "#/components/schemas/Issue" }, "description": "Findings, each with severity and message." }
        },
        "additionalProperties": true
      },
      "ScanResult": {
        "type": "object",
        "description": "Scan response. The per-check objects are TOP-LEVEL properties — there is no `checks` wrapper and no per-check {status, summary} shape.",
        "properties": {
          "domain": { "type": "string" },
          "score": { "$ref": "#/components/schemas/ScanScore" },
          "scoringVersion": { "type": "integer", "description": "Version of the scoring model that produced `score`." },
          "spf": { "$ref": "#/components/schemas/DomainCheck" },
          "dmarc": { "$ref": "#/components/schemas/DomainCheck" },
          "ptr": { "$ref": "#/components/schemas/DomainCheck" },
          "dkim": { "$ref": "#/components/schemas/DomainCheck" },
          "mtaSts": { "$ref": "#/components/schemas/DomainCheck" },
          "tlsRpt": { "$ref": "#/components/schemas/DomainCheck" },
          "mxTls": { "$ref": "#/components/schemas/DomainCheck" },
          "bimi": { "$ref": "#/components/schemas/DomainCheck" },
          "blocklist": { "$ref": "#/components/schemas/DomainCheck" },
          "dnssec": { "$ref": "#/components/schemas/DnssecResult" },
          "remediations": { "type": "array", "description": "Per-failing-check fix list (critical first). Each item is the concrete next action — the exact DNS record to publish plus whether the registrar auto-fix engine can apply it. Healthy checks contribute nothing, so this is empty when the domain is clean.", "items": { "$ref": "#/components/schemas/Remediation" } },
          "durationMs": { "type": "integer" },
          "plan": {
            "type": "object",
            "properties": {
              "tier": { "type": "string", "description": "public (anonymous), free, entry, pro, or agency." },
              "trialing": { "type": "boolean" },
              "blocklistsChecked": { "description": "Number of blocklists checked, or 'all'.", "anyOf": [{ "type": "integer" }, { "type": "string" }] }
            }
          }
        },
        "required": ["domain", "score", "scoringVersion", "spf", "dmarc", "ptr", "dkim", "mtaSts", "tlsRpt", "mxTls", "bimi", "blocklist", "dnssec", "remediations", "durationMs", "plan"]
      },
      "DnssecResult": {
        "type": "object",
        "description": "DNSSEC + DANE/TLSA detection (INFORMATIONAL — does not feed the score). Always present on the live /scan-domain response.",
        "properties": {
          "domain": { "type": "string" },
          "dsPresent": { "type": "boolean", "description": "DS record present at the parent — the chain of trust is anchored." },
          "dnskeyPresent": { "type": "boolean", "description": "Zone publishes DNSKEY records." },
          "enabled": { "type": "boolean", "description": "True only when the zone is signed AND anchored (DS + DNSKEY). DNSKEY without DS is treated as unsigned by validating receivers." },
          "dane": { "type": "array", "description": "Per-MX DANE/TLSA presence (TLSA at _25._tcp.<mxhost>). Empty when the domain has no MX.", "items": { "type": "object", "properties": { "mxHost": { "type": "string" }, "tlsaPresent": { "type": "boolean" } }, "required": ["mxHost", "tlsaPresent"] } },
          "daneEnabled": { "type": "boolean", "description": "True when at least one MX host advertises DANE TLSA." },
          "checkError": { "type": "boolean", "description": "DNS resolution temp-failed; the result is \"unknown\", not \"off\". Re-scan to confirm." },
          "issues": { "type": "array", "items": { "$ref": "#/components/schemas/Issue" } }
        },
        "required": ["domain", "dsPresent", "dnskeyPresent", "enabled", "dane", "daneEnabled", "checkError", "issues"]
      },
      "RemediationRecord": {
        "type": "object",
        "description": "The exact DNS record to publish for a fix. Kept byte-for-byte identical to what the registrar auto-fix engine would write.",
        "properties": {
          "host": { "type": "string", "description": "Record name/host, e.g. '@' for the apex or '_dmarc.example.com'." },
          "type": { "type": "string", "enum": ["TXT", "CNAME", "MX"] },
          "value": { "type": "string" },
          "note": { "type": "string", "description": "Optional caveat (e.g. replace placeholder senders, also host the MTA-STS policy file)." }
        },
        "required": ["host", "type", "value"]
      },
      "Remediation": {
        "type": "object",
        "description": "One concrete fix for a failing/weak check, derived from the structured scan result.",
        "properties": {
          "check": { "type": "string", "enum": ["spf", "dmarc", "dkim", "ptr", "mta-sts", "tls-rpt", "bimi", "blocklist", "dnssec"] },
          "code": { "type": "string", "description": "Stable machine code, e.g. 'dmarc_missing'." },
          "severity": { "type": "string", "enum": ["critical", "warn", "info"] },
          "title": { "type": "string" },
          "summary": { "type": "string" },
          "steps": { "type": "array", "items": { "type": "string" } },
          "record": { "$ref": "#/components/schemas/RemediationRecord" },
          "links": { "type": "array", "items": { "type": "object", "properties": { "label": { "type": "string" }, "href": { "type": "string", "format": "uri" } }, "required": ["label", "href"] } },
          "effort": { "type": "string", "enum": ["5 min", "15 min", "30 min", "1 hour"] },
          "autoFixable": { "type": "boolean", "description": "True when the registrar auto-fix engine (POST /domains/{id}/dns-apply) can publish this without customer input. SPF/DKIM/BIMI are guidance-only (false) even though a record is shown; a MISSING DMARC, the MTA-STS version TXT, and TLS-RPT are auto-fixable." }
        },
        "required": ["check", "code", "severity", "title", "summary", "steps", "autoFixable"]
      },
      "DnsRecord": {
        "type": "object",
        "description": "A single DNS record in a fix-plan op.",
        "properties": {
          "id": { "type": "string", "description": "Provider-side record id (present on existing records; needed for update/delete)." },
          "name": { "type": "string", "minLength": 1, "maxLength": 253, "description": "Fully-qualified record name, e.g. _dmarc.example.com." },
          "type": { "type": "string", "enum": ["TXT", "CNAME", "A", "AAAA", "MX"] },
          "value": { "type": "string", "minLength": 1, "maxLength": 4096 },
          "ttl": { "type": ["integer", "null"] },
          "priority": { "type": ["integer", "null"], "description": "MX priority (null for non-MX)." }
        },
        "required": ["name", "type", "value"]
      },
      "DnsOp": {
        "type": "object",
        "description": "One change in a DNS fix plan. `noop` ops describe records already correct and are skipped on apply.",
        "properties": {
          "kind": { "type": "string", "enum": ["create", "update", "delete", "noop"] },
          "reason": { "type": "string", "maxLength": 500, "description": "Human-readable explanation of the change." },
          "before": { "anyOf": [{ "$ref": "#/components/schemas/DnsRecord" }, { "type": "null" }], "description": "The record being replaced/deleted (null for create)." },
          "after": { "anyOf": [{ "$ref": "#/components/schemas/DnsRecord" }, { "type": "null" }], "description": "The record being created/updated (null for delete)." }
        },
        "required": ["kind", "before", "after"]
      },
      "DnsDiffResult": {
        "type": "object",
        "description": "The DNS fix plan returned by GET /domains/{id}/dns-diff. When `connected` is false, or no connected registrar manages the apex, `ops`/`manualReview` are empty and `reason` explains why.",
        "properties": {
          "connected": { "type": "boolean", "description": "Whether the org has any registrar connection (false = none configured)." },
          "connectionId": { "type": ["string", "null"], "format": "uuid", "description": "The connection whose zone covers the apex — pass to POST /dns-apply. Null when no connection matches." },
          "provider": { "type": ["string", "null"], "enum": ["cloudflare", "route53", "godaddy", "namecheap", null], "description": "The matched registrar provider id." },
          "identity": { "type": ["string", "null"], "description": "Human-readable identity of the matched connection (e.g. account/email)." },
          "apex": { "type": "string", "description": "The apex domain the diff was computed for." },
          "scan": { "type": "object", "description": "The scan the diff was derived from.", "properties": { "id": { "type": "string", "format": "uuid" }, "runAt": { "type": "string", "format": "date-time" } } },
          "ops": { "type": "array", "description": "The record changes to apply. Pass verbatim to POST /dns-apply.", "items": { "$ref": "#/components/schemas/DnsOp" } },
          "manualReview": { "type": "array", "description": "Fixes that need a human decision and can't be auto-applied (SPF sender list, DKIM keys, BIMI logo).", "items": { "type": "object", "additionalProperties": true } },
          "reason": { "type": "string", "description": "Present when ops is empty — why no auto-fix is available (no connection, or no connected registrar manages this zone)." }
        },
        "required": ["connected", "ops", "manualReview"]
      },
      "DnsApplyRequest": {
        "type": "object",
        "description": "The fix plan to apply, taken verbatim from GET /domains/{id}/dns-diff.",
        "properties": {
          "connectionId": { "type": "string", "format": "uuid", "description": "The `connectionId` from the diff." },
          "ops": { "type": "array", "minItems": 1, "maxItems": 50, "description": "The `ops` array from the diff. The server re-validates each op against a freshly recomputed diff before executing.", "items": { "$ref": "#/components/schemas/DnsOp" } }
        },
        "required": ["connectionId", "ops"]
      },
      "DnsApplyResult": {
        "type": "object",
        "description": "Per-op outcome of applying a fix plan. NOT transactional — inspect `results` for partial success.",
        "properties": {
          "planId": { "type": "string", "format": "uuid", "description": "The remediation plan id (audit trail)." },
          "connectionId": { "type": "string", "format": "uuid" },
          "provider": { "type": "string", "enum": ["cloudflare", "route53", "godaddy", "namecheap"] },
          "apex": { "type": "string" },
          "results": { "type": "array", "description": "One entry per non-noop op, in order.", "items": { "type": "object", "properties": { "kind": { "type": "string", "enum": ["create", "update", "delete"] }, "name": { "type": ["string", "null"] }, "type": { "type": ["string", "null"] }, "ok": { "type": "boolean" }, "error": { "type": "string", "description": "Present when ok is false." }, "stepId": { "type": "string", "format": "uuid" } }, "required": ["kind", "name", "type", "ok"] } }
        },
        "required": ["planId", "connectionId", "provider", "apex", "results"]
      },
      "AnalyzeHeadersRequest": {
        "type": "object",
        "properties": {
          "message": { "type": "string", "minLength": 50, "maxLength": 2000000, "description": "Full RFC 5322 message (headers + body) OR just the headers block." },
          "senderIp": { "type": "string", "description": "Sending IP override — needed if the pasted headers lack the Received chain back to the original sender." },
          "helo": { "type": "string", "maxLength": 253, "description": "HELO/EHLO domain override." },
          "mailFrom": { "type": "string", "format": "email", "description": "Envelope MAIL FROM (Return-Path) override." }
        },
        "required": ["message"]
      },
      "AuthVerdict": {
        "type": "object",
        "description": "One re-verified authentication result.",
        "properties": {
          "status": { "type": "string", "enum": ["pass", "fail", "neutral", "softfail", "temperror", "permerror", "none", "policy"] },
          "domain": { "type": "string" },
          "selector": { "type": "string" },
          "comment": { "type": "string" },
          "info": { "type": "string" }
        },
        "required": ["status"]
      },
      "AnalyzeHeadersResult": {
        "type": "object",
        "properties": {
          "ours": {
            "type": "object",
            "description": "InboxGuard's independent verdict, re-verified from the raw bytes.",
            "properties": {
              "spf": { "$ref": "#/components/schemas/AuthVerdict" },
              "dkim": { "type": "array", "items": { "$ref": "#/components/schemas/AuthVerdict" }, "description": "One entry per DKIM signature." },
              "dmarc": { "allOf": [{ "$ref": "#/components/schemas/AuthVerdict" }, { "type": "object", "properties": { "alignment": { "type": "object", "properties": { "spf": { "type": "boolean" }, "dkim": { "type": "boolean" } } }, "policy": { "type": "string" } } }] },
              "arc": { "allOf": [{ "$ref": "#/components/schemas/AuthVerdict" }, { "type": "object", "properties": { "chainLength": { "type": "integer" } } }] }
            },
            "required": ["spf", "dkim", "dmarc", "arc"]
          },
          "theirs": {
            "type": "object",
            "description": "What the receivers' own Authentication-Results headers claimed (NOT trusted).",
            "properties": {
              "rawHeaders": { "type": "array", "items": { "type": "string" } },
              "parsed": { "type": "object", "additionalProperties": { "$ref": "#/components/schemas/AuthVerdict" }, "description": "Keyed by method (spf, dkim, dmarc, arc, bimi)." }
            }
          },
          "agreement": { "type": "boolean", "description": "True when our verdict matches the receivers'. False = something fishy (possibly forged Authentication-Results)." },
          "headers": {
            "type": "object",
            "properties": {
              "from": { "type": "string" },
              "returnPath": { "type": "string" },
              "subject": { "type": "string" },
              "messageId": { "type": "string" },
              "date": { "type": "string" },
              "receivedFromIp": { "type": "string" }
            }
          },
          "durationMs": { "type": "integer" }
        },
        "required": ["ours", "theirs", "agreement", "headers", "durationMs"]
      },
      "ScanListItem": {
        "type": "object",
        "description": "Thin scan-history row (snake_case fields). For the full per-check JSON of the latest scan, use GET /domains/{id}.",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "domain_id": { "type": "string", "format": "uuid" },
          "domain": { "type": "string" },
          "run_at": { "type": "string", "format": "date-time" },
          "score": { "type": "integer", "minimum": 0, "maximum": 100 }
        },
        "required": ["id", "domain_id", "domain", "run_at", "score"]
      },
      "ScanList": { "type": "object", "properties": { "items": { "type": "array", "items": { "$ref": "#/components/schemas/ScanListItem" } }, "nextCursor": { "type": ["string", "null"], "description": "run_at of the last item when the page is full (pass as ?before=); null when there are no more." } }, "required": ["items", "nextCursor"] },
      "DomainSummary": {
        "type": "object",
        "description": "One monitored domain with latest-scan summary (snake_case fields).",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "domain": { "type": "string" },
          "status": { "type": "string", "description": "Monitoring status, e.g. active." },
          "quota_used": { "type": "integer", "description": "On-demand scans used this month against the plan's per-domain quota." },
          "created_at": { "type": "string", "format": "date-time" },
          "latest_score": { "type": ["integer", "null"], "description": "Most recent scan score, or null if never scanned." },
          "latest_scan_at": { "type": ["string", "null"], "format": "date-time" },
          "open_alerts": { "type": "integer", "description": "Count of unresolved alerts for this domain." }
        },
        "required": ["id", "domain", "status", "quota_used", "created_at", "latest_score", "latest_scan_at", "open_alerts"]
      },
      "DomainList": { "type": "object", "properties": { "items": { "type": "array", "items": { "$ref": "#/components/schemas/DomainSummary" } }, "total": { "type": "integer" } }, "required": ["items", "total"] },
      "DomainDetail": {
        "type": "object",
        "description": "Full detail for one monitored domain.",
        "properties": {
          "domain": {
            "type": "object",
            "properties": {
              "id": { "type": "string", "format": "uuid" },
              "domain": { "type": "string" },
              "status": { "type": "string" },
              "quota_used": { "type": "integer" },
              "created_at": { "type": "string", "format": "date-time" },
              "updated_at": { "type": "string", "format": "date-time" }
            },
            "required": ["id", "domain", "status"]
          },
          "latestScan": {
            "type": ["object", "null"],
            "description": "Latest scan with all per-check JSON blobs. NOTE: stored check keys here are snake_case (`mta_sts`, `tls_rpt`, `mx_tls`), unlike the live /scan-domain response (`mtaSts`, `tlsRpt`, `mxTls`).",
            "properties": {
              "id": { "type": "string", "format": "uuid" },
              "run_at": { "type": "string", "format": "date-time" },
              "score": { "type": "integer" },
              "spf": { "$ref": "#/components/schemas/DomainCheck" },
              "dmarc": { "$ref": "#/components/schemas/DomainCheck" },
              "ptr": { "$ref": "#/components/schemas/DomainCheck" },
              "dkim": { "$ref": "#/components/schemas/DomainCheck" },
              "mta_sts": { "$ref": "#/components/schemas/DomainCheck" },
              "tls_rpt": { "$ref": "#/components/schemas/DomainCheck" },
              "mx_tls": { "$ref": "#/components/schemas/DomainCheck" },
              "blocklist": { "$ref": "#/components/schemas/DomainCheck" }
            }
          },
          "history": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "run_at": { "type": "string", "format": "date-time" }, "score": { "type": "integer" } } }, "description": "Last 30 scans (thin rows) for charting." },
          "alerts": { "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "kind": { "type": "string" }, "severity": { "type": "string", "enum": ["critical", "warn", "info"] }, "message": { "type": "string" }, "created_at": { "type": "string", "format": "date-time" }, "resolved_at": { "type": ["string", "null"], "format": "date-time" } } }, "description": "Recent alerts for this domain (last 50)." },
          "postmaster": {
            "type": "object",
            "description": "Google Postmaster Tools state for this domain.",
            "properties": {
              "state": { "type": "string", "enum": ["not_connected", "connected_no_data", "active"] },
              "verified": { "type": "boolean" },
              "latest": { "type": ["object", "null"], "properties": { "date": { "type": "string" }, "spamRate": { "type": ["number", "null"] }, "domainReputation": { "type": ["string", "null"] }, "ipReputation": { "type": ["string", "null"] }, "dkimPassRate": { "type": ["number", "null"] }, "spfPassRate": { "type": ["number", "null"] }, "deliveryErrors": {} } }
            }
          }
        },
        "required": ["domain", "latestScan", "history", "alerts", "postmaster"]
      },
      "DeleteDomainResult": { "type": "object", "properties": { "ok": { "type": "boolean" }, "deleted": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "domain": { "type": "string" } } } }, "required": ["ok"] },
      "DmarcReportsResponse": {
        "type": "object",
        "properties": {
          "domain": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "name": { "type": "string" } }, "required": ["id", "name"] },
          "days": { "type": "integer", "description": "Effective lookback window applied." },
          "ruaInbox": {
            "type": "object",
            "description": "The org's hosted RUA mailbox — publish this in the domain's DMARC record.",
            "properties": {
              "address": { "type": "string", "description": "e.g. rua-abc123…@reports.inboxguard.io" },
              "configured": { "type": "boolean", "description": "True once at least one aggregate report has arrived for the org." },
              "dmarcSnippet": { "type": "string", "description": "Ready-to-publish DMARC TXT value containing the rua address." }
            },
            "required": ["address", "configured", "dmarcSnippet"]
          },
          "daily": { "type": "array", "description": "Per-day, per-reporting-source rollups.", "items": { "type": "object", "properties": { "date": { "type": "string", "description": "YYYY-MM-DD" }, "source": { "type": "string", "description": "Reporting receiver (e.g. google.com)." }, "spfAligned": { "type": "integer" }, "dkimAligned": { "type": "integer" }, "passCount": { "type": "integer" }, "failCount": { "type": "integer" } } } },
          "topSenders": { "type": "array", "description": "Per-(source IP, From-domain) totals — \"what's sending mail as you\" (top 25 by volume).", "items": { "type": "object", "properties": { "sourceIp": { "type": "string" }, "headerFrom": { "type": "string" }, "count": { "type": "integer" }, "passCount": { "type": "integer" }, "failCount": { "type": "integer" } } } },
          "totals": { "type": "object", "properties": { "volume": { "type": "integer" }, "pass": { "type": "integer" }, "fail": { "type": "integer" }, "sources": { "type": "integer", "description": "Distinct reporting sources in the window." } } }
        },
        "required": ["domain", "days", "ruaInbox", "daily", "topSenders", "totals"]
      },
      "Alert": {
        "type": "object",
        "description": "One alert row (snake_case fields). An alert is open when resolved_at is null.",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "kind": { "type": "string", "description": "Alert category, e.g. score_drop, check_failed, blocklist_listed." },
          "severity": { "type": "string", "enum": ["critical", "warn", "info"] },
          "message": { "type": "string" },
          "details": { "type": ["object", "null"], "description": "Structured context for the alert (returned by GET /alerts; omitted from the PATCH response)." },
          "entity_type": { "type": "string", "enum": ["domain", "org", "user"] },
          "entity_id": { "type": "string", "description": "Id of the entity the alert is about (e.g. the domain UUID)." },
          "created_at": { "type": "string", "format": "date-time" },
          "resolved_at": { "type": ["string", "null"], "format": "date-time" },
          "escalated_at": { "type": ["string", "null"], "format": "date-time", "description": "Returned by GET /alerts; omitted from the PATCH response." }
        },
        "required": ["id", "kind", "severity", "message", "entity_type", "entity_id", "created_at", "resolved_at"]
      },
      "AlertList": { "type": "object", "properties": { "items": { "type": "array", "items": { "$ref": "#/components/schemas/Alert" } }, "total": { "type": "integer" } }, "required": ["items", "total"] },
      "ResolveAlertRequest": { "type": "object", "properties": { "resolved": { "type": "boolean", "description": "true = mark resolved; false = reopen." } }, "required": ["resolved"] },
      "Me": {
        "type": "object",
        "properties": {
          "user": { "type": "object", "properties": { "id": { "type": "string" }, "email": { "type": "string" }, "appRole": { "type": "string" } } },
          "org": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" }, "role": { "type": "string", "enum": ["owner", "admin", "analyst"] } } },
          "plan": { "type": "object", "properties": { "tier": { "type": "string" }, "trialing": { "type": "boolean" }, "trialExpired": { "type": "boolean" }, "trialEndsAt": { "type": ["string", "null"] }, "trialDaysRemaining": { "type": ["integer", "null"] }, "limits": { "type": "object" } } },
          "usage": { "type": "object", "properties": { "domains": { "type": "integer" }, "domainsLimit": { "type": "integer" } } },
          "catalog": { "type": "object", "description": "The canonical plan catalog (per-tier limits)." },
          "stripePublishableKey": { "type": ["string", "null"] }
        },
        "required": ["user", "org", "plan", "usage"]
      },
      "ApiKey": {
        "type": "object",
        "description": "API key metadata (snake_case fields). The secret is only ever present in the POST /api-keys response.",
        "properties": {
          "id": { "type": "string", "format": "uuid" },
          "name": { "type": "string" },
          "key_prefix": { "type": "string", "description": "First 12 chars of the key for identification (e.g. ig_live_xxxx)." },
          "environment": { "type": "string", "enum": ["live", "test"] },
          "scopes": { "type": "array", "items": { "type": "string", "enum": ["read", "full"] } },
          "created_by": { "type": ["string", "null"], "description": "User id that minted the key." },
          "last_used_at": { "type": ["string", "null"], "format": "date-time" },
          "last_used_ip": { "type": ["string", "null"] },
          "expires_at": { "type": ["string", "null"], "format": "date-time" },
          "created_at": { "type": "string", "format": "date-time" }
        },
        "required": ["id", "name", "key_prefix", "environment", "scopes", "last_used_at", "created_at"]
      },
      "ApiKeyList": { "type": "object", "properties": { "items": { "type": "array", "items": { "$ref": "#/components/schemas/ApiKey" } } }, "required": ["items"] },
      "CreateApiKeyRequest": {
        "type": "object",
        "properties": {
          "name": { "type": "string", "minLength": 1, "maxLength": 120 },
          "environment": { "type": "string", "enum": ["live", "test"], "default": "live" },
          "scopes": { "type": "array", "items": { "type": "string", "enum": ["read", "full"] }, "minItems": 1, "default": ["full"] },
          "expiresAt": { "type": "string", "format": "date-time" }
        },
        "required": ["name"]
      },
      "ApiKeyWithSecret": { "allOf": [{ "$ref": "#/components/schemas/ApiKey" }, { "type": "object", "properties": { "token": { "type": "string", "description": "The secret key (ig_live_… / ig_test_…). Shown only once." } }, "required": ["token"] }] },
      "RevokeApiKeyResult": { "type": "object", "properties": { "ok": { "type": "boolean" }, "revoked": { "type": "object", "properties": { "id": { "type": "string", "format": "uuid" } } } }, "required": ["ok", "revoked"] }
    }
  }
}
