{
  "openapi": "3.0.3",
  "info": {
    "title": "WarmHawk API",
    "version": "1.0.0",
    "description": "The real, current shape of warmhawk-core-engine's /v1 API and its one unauthenticated /public endpoint, transcribed field-by-field from https://warmhawk.com/docs/api-reference/*. WarmHawk is self-hosted per customer — there is no single production host, so every server URL below is a placeholder for whatever domain a customer's own install.sh run bound (e.g. api.yourcompany.com). Outbound webhooks (WarmHawk calling a customer's own URL when something happens) are planned, not implemented — poll GET /v1/replies, GET /v1/queue/status, or GET /v1/domains/{id}/placement-sample instead.",
    "contact": {
      "name": "WarmHawk support",
      "email": "support@warmhawk.com",
      "url": "https://warmhawk.com"
    },
    "license": {
      "name": "Business Source License (Apache 2.0 after 4 years)",
      "url": "https://warmhawk.com/legal/terms"
    }
  },
  "externalDocs": {
    "description": "Full prose API reference, guides, and quickstart",
    "url": "https://warmhawk.com/docs"
  },
  "servers": [
    {
      "url": "https://{instance}/v1",
      "description": "Your own WarmHawk install's authenticated API.",
      "variables": {
        "instance": {
          "default": "your-instance",
          "description": "The domain install.sh bound the API to, e.g. api.yourcompany.com."
        }
      }
    },
    {
      "url": "https://{instance}",
      "description": "Your own WarmHawk install's public, unauthenticated endpoints (no /v1 prefix).",
      "variables": {
        "instance": {
          "default": "your-instance",
          "description": "The domain install.sh bound the API to, e.g. api.yourcompany.com."
        }
      }
    }
  ],
  "tags": [
    {
      "name": "Auth",
      "description": "Engine-level login, separate from the Tier 1/2 dashboard's own login."
    },
    { "name": "Mailboxes", "description": "Mailbox CRUD plus Google/Microsoft OAuth connect." },
    {
      "name": "Leads",
      "description": "Single create, CSV bulk import, unauthenticated webhook ingest, GDPR erasure."
    },
    { "name": "Campaigns", "description": "Campaign lifecycle: create, launch, pause, archive." },
    { "name": "Queue", "description": "Send-queue status and pause/resume." },
    {
      "name": "Domains",
      "description": "Sending-domain CRUD, SPF/DKIM/DMARC + blocklist checks, seed-inbox placement sampling."
    },
    {
      "name": "Public",
      "description": "Unauthenticated endpoints, reachable with no bearer token."
    }
  ],
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Authorization: Bearer <token> obtained from POST /v1/auth/login. Required on every route except POST /v1/auth/login itself, the two /v1/oauth/* routes, and POST /v1/leads/webhook."
      }
    },
    "schemas": { "Error": { "type": "object", "properties": { "error": { "type": "string" } } } }
  },
  "paths": {
    "/auth/login": {
      "post": {
        "tags": ["Auth"],
        "summary": "Log in and receive a bearer token",
        "description": "Authenticates against the engine's own User table — separate from the Tier 1/2 dashboard's own login. Rate-limited (10/min) and brute-force locked out per account, both layers active at once.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["email", "password"],
                "properties": {
                  "email": { "type": "string", "format": "email" },
                  "password": { "type": "string", "format": "password" }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Authenticated",
            "content": {
              "application/json": {
                "schema": { "type": "object", "properties": { "token": { "type": "string" } } }
              }
            }
          },
          "401": {
            "description": "Invalid credentials — constant response shape whether the account exists or not, to avoid user enumeration.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Error" } }
            }
          },
          "429": {
            "description": "Account locked from repeated failures. Body includes retryAfterMs.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": { "type": "string" },
                    "retryAfterMs": { "type": "integer" }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/mailboxes": {
      "get": {
        "tags": ["Mailboxes"],
        "summary": "List every mailbox on the account",
        "responses": { "200": { "description": "OK" } }
      },
      "post": {
        "tags": ["Mailboxes"],
        "summary": "Create a mailbox",
        "description": "For Google Workspace / Microsoft 365, POST without authPassword, then complete the OAuth flow against the returned mailbox's id.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["email", "domainId"],
                "properties": {
                  "email": { "type": "string", "format": "email" },
                  "domainId": { "type": "string", "description": "Create the Domain first." },
                  "provider": { "type": "string", "description": "Defaults to SMTP_CUSTOM." },
                  "dailyCap": { "type": "integer", "description": "Defaults to 25." },
                  "smtpHost": { "type": "string" },
                  "smtpPort": { "type": "integer" },
                  "imapHost": { "type": "string" },
                  "imapPort": { "type": "integer" },
                  "authUsername": { "type": "string" },
                  "authPassword": {
                    "type": "string",
                    "format": "password",
                    "description": "Encrypted at rest, never returned in any response."
                  },
                  "senderName": {
                    "type": "string",
                    "maxLength": 80,
                    "description": "The From name leads see, and {{senderName}} in campaigns. OAuth mailboxes get it from the provider on connect when they have one."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": { "description": "Created — credentials stripped from the returned row." }
        }
      }
    },
    "/mailboxes/{id}": {
      "parameters": [
        { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
      ],
      "patch": {
        "tags": ["Mailboxes"],
        "summary": "Update a mailbox's status, dailyCap or senderName",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "status": { "type": "string" },
                  "dailyCap": { "type": "integer" },
                  "senderName": {
                    "type": "string",
                    "nullable": true,
                    "maxLength": 80,
                    "description": "null or blank clears it."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": { "description": "Updated" },
          "404": { "description": "Mailbox not found" }
        }
      },
      "delete": {
        "tags": ["Mailboxes"],
        "summary": "Disconnect a mailbox",
        "responses": {
          "204": { "description": "Disconnected" },
          "404": { "description": "Mailbox not found" }
        }
      }
    },
    "/oauth/{provider}/authorize": {
      "get": {
        "tags": ["Mailboxes"],
        "summary": "Redirect to the provider's OAuth consent screen",
        "security": [],
        "parameters": [
          {
            "name": "provider",
            "in": "path",
            "required": true,
            "schema": { "type": "string", "enum": ["google", "microsoft"] }
          },
          {
            "name": "mailboxId",
            "in": "query",
            "required": true,
            "schema": { "type": "string" },
            "description": "Must already exist — create the mailbox first, without authPassword."
          }
        ],
        "responses": {
          "302": { "description": "Browser redirect to the provider's consent screen" }
        }
      }
    },
    "/oauth/{provider}/callback": {
      "get": {
        "tags": ["Mailboxes"],
        "summary": "OAuth provider callback",
        "description": "Public — no bearer token, verified by the signed state param instead.",
        "security": [],
        "parameters": [
          {
            "name": "provider",
            "in": "path",
            "required": true,
            "schema": { "type": "string", "enum": ["google", "microsoft"] }
          },
          { "name": "code", "in": "query", "required": true, "schema": { "type": "string" } },
          { "name": "state", "in": "query", "required": true, "schema": { "type": "string" } }
        ],
        "responses": { "200": { "description": "OAuth connection completed" } }
      }
    },
    "/leads": {
      "get": {
        "tags": ["Leads"],
        "summary": "List leads, optionally scoped to a campaign",
        "parameters": [
          { "name": "campaignId", "in": "query", "required": false, "schema": { "type": "string" } }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "leads": { "type": "array", "items": { "type": "object" } },
                    "total": { "type": "integer" }
                  }
                }
              }
            }
          }
        }
      },
      "post": {
        "tags": ["Leads"],
        "summary": "Create a single lead",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["campaignId", "email"],
                "properties": {
                  "campaignId": { "type": "string" },
                  "email": { "type": "string", "format": "email" },
                  "firstName": { "type": "string" },
                  "lastName": { "type": "string" },
                  "company": { "type": "string" },
                  "customFields": { "type": "object", "additionalProperties": true }
                }
              }
            }
          }
        },
        "responses": {
          "201": { "description": "Created" },
          "409": { "description": "Suppressed or duplicate lead" }
        }
      }
    },
    "/leads/webhook": {
      "post": {
        "tags": ["Leads", "Public"],
        "summary": "Unauthenticated lead-ingest webhook",
        "description": "Rate-limited (30/min). Skips with 200, rather than erroring, on a suppressed or duplicate lead.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["campaignId", "email"],
                "properties": {
                  "campaignId": { "type": "string" },
                  "email": { "type": "string", "format": "email" },
                  "firstName": { "type": "string" },
                  "lastName": { "type": "string" },
                  "company": { "type": "string" },
                  "customFields": { "type": "object", "additionalProperties": true }
                }
              }
            }
          }
        },
        "responses": {
          "200": { "description": "Ingested, or silently skipped if suppressed/duplicate" }
        }
      }
    },
    "/leads/import": {
      "post": {
        "tags": ["Leads"],
        "summary": "Bulk CSV import",
        "description": "Caps: 50,000 rows / 10MB.",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": ["campaignId", "file"],
                "properties": {
                  "campaignId": { "type": "string" },
                  "file": { "type": "string", "format": "binary" }
                }
              }
            }
          }
        },
        "responses": { "200": { "description": "Import accepted" } }
      }
    },
    "/leads/{id}/suppress": {
      "post": {
        "tags": ["Leads"],
        "summary": "Manually suppress a lead",
        "description": "Adds the lead to the suppression list and flips its status.",
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
        ],
        "responses": { "200": { "description": "Suppressed" } }
      }
    },
    "/leads/erase": {
      "delete": {
        "tags": ["Leads"],
        "summary": "GDPR erasure by email",
        "description": "Anonymizes PII across every campaign, preserves aggregate counts.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["email"],
                "properties": { "email": { "type": "string", "format": "email" } }
              }
            }
          }
        },
        "responses": { "200": { "description": "Erased" } }
      }
    },
    "/leads/{id}": {
      "delete": {
        "tags": ["Leads"],
        "summary": "Hard delete one lead row",
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
        ],
        "responses": {
          "204": { "description": "Deleted" },
          "404": { "description": "Lead not found" }
        }
      }
    },
    "/campaigns": {
      "get": {
        "tags": ["Campaigns"],
        "summary": "List campaigns",
        "description": "Each row includes mailboxIds, steps, senders (each ticked mailbox, its status and whether its domain has a mailing address), launch { canLaunch, problems }, progress, leadsCount, sentCount, repliesCount, domainsCount and lastActivityAt.",
        "responses": { "200": { "description": "OK" } }
      },
      "post": {
        "tags": ["Campaigns"],
        "summary": "Create a campaign",
        "description": "Returns the created row plus a computed contentQuality score.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["name", "aiPromptTemplate"],
                "properties": {
                  "name": { "type": "string" },
                  "aiPromptTemplate": {
                    "type": "string",
                    "description": "Mustache-placeholder AI instructions, e.g. \"Write a 2-sentence opener to {{firstName}} at {{company}}.\""
                  },
                  "template": { "type": "string", "description": "Spintax-capable fallback body." },
                  "subject": {
                    "type": "string",
                    "nullable": true,
                    "description": "Subject line; spintax and {{fields}} allowed."
                  },
                  "aiProvider": {
                    "type": "string",
                    "enum": ["GEMINI", "CLAUDE"],
                    "description": "BYOK — your own Gemini or Claude API key."
                  },
                  "unsubscribeUrlTemplate": {
                    "type": "string",
                    "description": "Optional. Your own unsubscribe page; {{email}} is replaced with the recipient's address. Left out, every email links to the built-in unsubscribe page on the install's domain."
                  },
                  "mailboxIds": {
                    "type": "array",
                    "items": { "type": "string" },
                    "description": "Optional on create. Mailbox ids this campaign sends first emails from. Replaces the whole set. Only these mailboxes are used; new mailboxes are never added on their own."
                  },
                  "steps": {
                    "type": "array",
                    "maxItems": 3,
                    "description": "Follow-ups, in order. Replaces the whole list. Each goes from the lead's pinned mailbox as a reply in the same thread, and a reply, bounce, unsubscribe or suppression stops the sequence.",
                    "items": {
                      "type": "object",
                      "required": ["waitDays"],
                      "properties": {
                        "waitDays": {
                          "type": "integer",
                          "minimum": 1,
                          "maximum": 30,
                          "description": "Days after the previous email."
                        },
                        "body": {
                          "type": "string",
                          "description": "Sent as-is when AI is off or fails. May be empty in a draft; launch refuses an empty one."
                        },
                        "aiRewrite": {
                          "type": "boolean",
                          "description": "Rewrite this step with the campaign's AI provider."
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": { "201": { "description": "Created" } }
      }
    },
    "/campaigns/{id}": {
      "parameters": [
        { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
      ],
      "get": {
        "tags": ["Campaigns"],
        "summary": "Fetch one campaign",
        "responses": {
          "200": { "description": "OK" },
          "404": { "description": "Campaign not found" }
        }
      },
      "patch": {
        "tags": ["Campaigns"],
        "summary": "Update a campaign",
        "description": "Only fields present change. Recomputes contentQuality when template or subject changes. status and unknown fields are refused with 422: a campaign goes live only through POST /campaigns/{id}/launch.",
        "responses": {
          "200": { "description": "Updated" },
          "404": { "description": "Campaign not found" },
          "422": {
            "description": "status was sent, an unknown field, invalid spintax, a mailbox id that doesn't exist, or an invalid follow-up"
          }
        },
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": { "type": "string" },
                  "template": { "type": "string" },
                  "subject": {
                    "type": "string",
                    "nullable": true,
                    "description": "Subject line; spintax and {{fields}} allowed."
                  },
                  "aiPromptTemplate": { "type": "string" },
                  "aiProvider": {
                    "type": "string",
                    "enum": ["GEMINI", "CLAUDE"],
                    "nullable": true
                  },
                  "unsubscribeUrlTemplate": { "type": "string" },
                  "bounceRateThreshold": { "type": "number" },
                  "pausedForBounceRate": {
                    "type": "boolean",
                    "description": "Set false to resume after the bounce circuit breaker tripped."
                  },
                  "mailboxIds": {
                    "type": "array",
                    "items": { "type": "string" },
                    "description": "Mailbox ids this campaign sends first emails from. Replaces the whole set. Only these mailboxes are used; new mailboxes are never added on their own."
                  },
                  "steps": {
                    "type": "array",
                    "maxItems": 3,
                    "description": "Follow-ups, in order. Replaces the whole list. Each goes from the lead's pinned mailbox as a reply in the same thread, and a reply, bounce, unsubscribe or suppression stops the sequence.",
                    "items": {
                      "type": "object",
                      "required": ["waitDays"],
                      "properties": {
                        "waitDays": {
                          "type": "integer",
                          "minimum": 1,
                          "maximum": 30,
                          "description": "Days after the previous email."
                        },
                        "body": {
                          "type": "string",
                          "description": "Sent as-is when AI is off or fails. May be empty in a draft; launch refuses an empty one."
                        },
                        "aiRewrite": {
                          "type": "boolean",
                          "description": "Rewrite this step with the campaign's AI provider."
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      },
      "delete": {
        "tags": ["Campaigns"],
        "summary": "Archive a campaign",
        "description": "Archives (status: ARCHIVED) — does not hard-delete.",
        "responses": { "200": { "description": "Archived" } }
      }
    },
    "/campaigns/{id}/launch-check": {
      "get": {
        "tags": ["Campaigns"],
        "summary": "Run the launch check without launching",
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "canLaunch": { "type": "boolean" },
                    "problems": {
                      "type": "array",
                      "description": "Each blocks launch. DOMAIN_NO_ADDRESS carries domainId, domainName and mailboxCount; STEP_EMPTY carries position.",
                      "items": {
                        "type": "object",
                        "properties": {
                          "code": {
                            "type": "string",
                            "enum": [
                              "NO_SENDERS",
                              "DOMAIN_NO_ADDRESS",
                              "NO_UNSUBSCRIBE",
                              "BOUNCE_PAUSED",
                              "EMAIL_EMPTY",
                              "STEP_EMPTY"
                            ]
                          },
                          "message": { "type": "string" }
                        }
                      }
                    },
                    "warnings": {
                      "type": "array",
                      "description": "Never block launch.",
                      "items": {
                        "type": "object",
                        "properties": {
                          "code": {
                            "type": "string",
                            "enum": [
                              "SENDER_NAME_MISSING",
                              "ALL_WARMING",
                              "NO_LEADS",
                              "FIELD_BLANK",
                              "FIELD_UNKNOWN"
                            ]
                          },
                          "message": { "type": "string" }
                        }
                      }
                    },
                    "passed": {
                      "type": "array",
                      "items": {
                        "type": "string",
                        "enum": ["SENDERS", "ADDRESSES", "UNSUBSCRIBE", "BOUNCE", "COPY"]
                      }
                    }
                  }
                }
              }
            }
          },
          "404": { "description": "Campaign not found" }
        }
      }
    },
    "/campaigns/{id}/launch": {
      "post": {
        "tags": ["Campaigns"],
        "summary": "Launch a campaign",
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
        ],
        "responses": {
          "200": { "description": "status set to ACTIVE; the campaign plus warnings[]" },
          "404": { "description": "Campaign not found" },
          "422": {
            "description": "Can't launch yet: { error, problems, warnings }. Problems: NO_SENDERS (no mailbox picked, or all paused), DOMAIN_NO_ADDRESS (one per sending domain without a mailing address), NO_UNSUBSCRIBE (no unsubscribeUrlTemplate on an install with no domain for the built-in page), BOUNCE_PAUSED, EMAIL_EMPTY, STEP_EMPTY.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "error": { "type": "string" },
                    "problems": {
                      "type": "array",
                      "description": "Each blocks launch. DOMAIN_NO_ADDRESS carries domainId, domainName and mailboxCount; STEP_EMPTY carries position.",
                      "items": {
                        "type": "object",
                        "properties": {
                          "code": {
                            "type": "string",
                            "enum": [
                              "NO_SENDERS",
                              "DOMAIN_NO_ADDRESS",
                              "NO_UNSUBSCRIBE",
                              "BOUNCE_PAUSED",
                              "EMAIL_EMPTY",
                              "STEP_EMPTY"
                            ]
                          },
                          "message": { "type": "string" }
                        }
                      }
                    },
                    "warnings": {
                      "type": "array",
                      "description": "Never block launch.",
                      "items": {
                        "type": "object",
                        "properties": {
                          "code": {
                            "type": "string",
                            "enum": [
                              "SENDER_NAME_MISSING",
                              "ALL_WARMING",
                              "NO_LEADS",
                              "FIELD_BLANK",
                              "FIELD_UNKNOWN"
                            ]
                          },
                          "message": { "type": "string" }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "description": "Runs the launch check and sets status ACTIVE. Every problem comes back at once; warnings never block and are returned on success too."
      }
    },
    "/campaigns/{id}/pause": {
      "post": {
        "tags": ["Campaigns"],
        "summary": "Pause a campaign",
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
        ],
        "responses": { "200": { "description": "status set to PAUSED" } }
      }
    },
    "/queue/status": {
      "get": {
        "tags": ["Queue"],
        "summary": "Real-time send-queue status",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "counts": {
                      "type": "object",
                      "properties": {
                        "waiting": { "type": "integer" },
                        "active": { "type": "integer" },
                        "delayed": { "type": "integer" },
                        "completed": { "type": "integer" },
                        "failed": { "type": "integer" }
                      }
                    },
                    "isPaused": { "type": "boolean" },
                    "jobs": {
                      "type": "array",
                      "items": { "type": "object" },
                      "description": "Up to 50 waiting/active/delayed jobs."
                    },
                    "throttling": {
                      "type": "object",
                      "properties": {
                        "cadenceFloorSeconds": { "type": "integer", "example": 480 },
                        "jitterSeconds": { "type": "integer" }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/queue/pause": {
      "post": {
        "tags": ["Queue"],
        "summary": "Pause or resume the send queue",
        "description": "Real BullMQ pause/resume, not cosmetic.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["paused"],
                "properties": { "paused": { "type": "boolean" } }
              }
            }
          }
        },
        "responses": { "200": { "description": "Queue state updated" } }
      }
    },
    "/domains": {
      "get": {
        "tags": ["Domains"],
        "summary": "List every sending domain on the account",
        "responses": { "200": { "description": "OK" } },
        "description": "Each domain includes mailingAddress, mailingAddressParts, label and usedBy { campaigns, sending, paused, drafts }."
      },
      "post": {
        "tags": ["Domains"],
        "summary": "Register a sending domain",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["domainName"],
                "properties": {
                  "domainName": { "type": "string" },
                  "redirectUrl": { "type": "string" },
                  "dkimSelector": {
                    "type": "string",
                    "nullable": true,
                    "description": "DKIM selector this domain signs with (the part before ._domainkey). Only needed when your provider mints a per-customer selector (Amazon SES, Postmark, ZeptoMail). 422 if not a valid DNS label."
                  },
                  "label": {
                    "type": "string",
                    "nullable": true,
                    "maxLength": 80,
                    "description": "Client or brand name, shown under the domain."
                  },
                  "mailingAddress": {
                    "type": "string",
                    "nullable": true,
                    "maxLength": 500,
                    "description": "The CAN-SPAM postal address printed in the footer of every campaign email sent from this domain, one line per footer line. Blank or null clears it. There is no install-wide fallback."
                  },
                  "mailingAddressParts": {
                    "type": "object",
                    "nullable": true,
                    "description": "The address as fields; when given, mailingAddress is built from it. Needs street, city and country.",
                    "properties": {
                      "businessName": { "type": "string" },
                      "street": { "type": "string" },
                      "suite": { "type": "string" },
                      "city": { "type": "string" },
                      "region": { "type": "string" },
                      "postalCode": { "type": "string" },
                      "country": { "type": "string" }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": { "201": { "description": "Created" } },
        "description": "The mailing address is optional here; only launching a campaign that sends from the domain needs it."
      }
    },
    "/domains/{id}": {
      "patch": {
        "tags": ["Domains"],
        "summary": "Update a domain's redirectUrl, DKIM selector, label or mailing address",
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "redirectUrl": { "type": "string" },
                  "dkimSelector": {
                    "type": "string",
                    "nullable": true,
                    "description": "Only fields present change. null or \"\" clears the saved selector."
                  },
                  "label": {
                    "type": "string",
                    "nullable": true,
                    "maxLength": 80,
                    "description": "Client or brand name, shown under the domain."
                  },
                  "mailingAddress": {
                    "type": "string",
                    "nullable": true,
                    "maxLength": 500,
                    "description": "The CAN-SPAM postal address printed in the footer of every campaign email sent from this domain, one line per footer line. Blank or null clears it. There is no install-wide fallback."
                  },
                  "mailingAddressParts": {
                    "type": "object",
                    "nullable": true,
                    "description": "The address as fields; when given, mailingAddress is built from it. Needs street, city and country.",
                    "properties": {
                      "businessName": { "type": "string" },
                      "street": { "type": "string" },
                      "suite": { "type": "string" },
                      "city": { "type": "string" },
                      "region": { "type": "string" },
                      "postalCode": { "type": "string" },
                      "country": { "type": "string" }
                    }
                  },
                  "confirm": {
                    "type": "boolean",
                    "description": "Required to clear an address that campaigns send with."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": { "description": "Updated, with usedBy" },
          "404": { "description": "Domain not found" },
          "409": {
            "description": "ADDRESS_IN_USE: clearing an address that campaigns send with. The body lists the campaigns; resend with confirm: true."
          },
          "422": { "description": "Invalid dkimSelector, label or address" }
        }
      }
    },
    "/domains/{domain}/check": {
      "post": {
        "tags": ["Domains"],
        "summary": "SPF/DKIM/DMARC + blocklist check",
        "description": "Keyed by domain NAME, not id — every other domain route is id-keyed.",
        "parameters": [
          {
            "name": "domain",
            "in": "path",
            "required": true,
            "schema": { "type": "string" },
            "description": "The domain name itself, e.g. yourcompany.com."
          },
          {
            "name": "selector",
            "in": "query",
            "required": false,
            "schema": { "type": "string" },
            "description": "One-off DKIM selector for this call. Overrides the domain's saved dkimSelector; without either, common provider selectors are tried and a miss is PENDING."
          }
        ],
        "responses": { "200": { "description": "Check result" } }
      }
    },
    "/domains/{id}/placement-sample": {
      "get": {
        "tags": ["Domains"],
        "summary": "Seed-inbox placement sampling rollup",
        "description": "Keyed by id. Explicitly labeled as sampling, not exhaustive testing.",
        "parameters": [
          { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
        ],
        "responses": { "200": { "description": "Placement sample rollup" } }
      }
    },
    "/replies": {
      "get": {
        "tags": ["Campaigns"],
        "summary": "Poll for replies",
        "description": "Referenced in the Queue/domains/webhooks reference as the polling substitute for a not-yet-built outbound webhook system. Field-level response shape isn't published in the prose docs yet — see the Replies & team guide for behavior.",
        "responses": { "200": { "description": "OK" } }
      }
    },
    "/public/domain-check": {
      "servers": [
        { "url": "https://{instance}", "variables": { "instance": { "default": "your-instance" } } }
      ],
      "get": {
        "tags": ["Public"],
        "summary": "Free, unauthenticated SPF/DKIM/DMARC + List-Unsubscribe + blocklist check",
        "description": "The exact endpoint behind https://warmhawk.com/tools/domain-check. Response shape verified against warmhawk-site's own client (components/DomainCheckTool.tsx), which types this contract directly against apps/api/src/routes/publicDomainCheck.ts. spf/dkim/dmarc are bare 'PASS'|'FAIL' strings with no per-check detail — dnsChecks.ts's checkSpf/checkDkim/checkDmarc don't produce one. listUnsubscribeCheck is a plain explanatory string, not a checkable status, because RFC 8058 headers live on a sent message, not something resolvable from DNS for a bare domain.",
        "security": [],
        "parameters": [
          {
            "name": "domain",
            "in": "query",
            "required": true,
            "schema": { "type": "string" },
            "example": "acme-outreach.com"
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "domain": { "type": "string" },
                    "spf": { "type": "string", "enum": ["PASS", "FAIL"] },
                    "dkim": { "type": "string", "enum": ["PASS", "FAIL"] },
                    "dmarc": { "type": "string", "enum": ["PASS", "FAIL"] },
                    "blocklists": {
                      "type": "object",
                      "additionalProperties": { "type": "string", "enum": ["PASS", "FAIL"] },
                      "description": "Per-DNSBL-source map, e.g. { \"spamhausZen\": \"PASS\" }."
                    },
                    "listUnsubscribeCheck": {
                      "type": "string",
                      "description": "Plain-language explanation, not a PASS/FAIL status."
                    }
                  },
                  "required": [
                    "domain",
                    "spf",
                    "dkim",
                    "dmarc",
                    "blocklists",
                    "listUnsubscribeCheck"
                  ]
                }
              }
            }
          }
        }
      }
    }
  },
  "security": [{ "bearerAuth": [] }]
}
