{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://artifacts.apis.io/dropto-run/json-schema/dropto-run-site-detail-schema.json",
  "title": "SiteDetail",
  "description": "Response of `GET /sites/{siteId}`.",
  "x-generated": "2026-11-06",
  "x-method": "derived",
  "x-generator": "derive-json-schema.py",
  "x-source": "openapi/dropto-run-openapi.json#/components/schemas/SiteDetail",
  "properties": {
    "analyticsDays": {
      "description": "How many days of traffic this site's plan allows seeing, or zero when it includes none. Sent for the\nsame reason as the flag above: the page can state what the plan covers instead of requesting the\nfigures and rendering a refusal.",
      "format": "int32",
      "pattern": "^-?(?:0|[1-9]\\d*)$",
      "type": [
        "integer",
        "string"
      ]
    },
    "analyticsExportAvailable": {
      "description": "Whether this site's plan is sold with the traffic export. Read from the site's own plan rather than\nfrom the signed-in account's, because the two differ — an anonymous site is on the anonymous tier\nwhoever is looking at it.",
      "type": "boolean"
    },
    "anonymous": {
      "description": "Whether the site has no account behind it.\n\nStated rather than inferred, and it replaces an inference the dashboard was making from\nExpiresAt being set. That proxy held only while an expiry meant one thing: now\nthat an owner can schedule a takedown on a site of their own, a paid site with a date on it would\nhave read as anonymous and been told it gets deleted after a few days.",
      "type": "boolean"
    },
    "browserEditAvailable": {
      "description": "Whether this site's plan lets its owner edit a file in the browser. Sent so the file list can leave\nthe Edit action out rather than offering one the server refuses.",
      "type": "boolean"
    },
    "commentAudience": {
      "description": "Who the owner chose may comment — `Off`, `Invited` or `Anyone`. The stored choice; it takes\neffect only where FeedbackAvailable is true and, for `Invited`, while the site is\ninvite-only, which the Sharing tab says in words.",
      "type": "string"
    },
    "createdAt": {
      "description": "When the site was created.",
      "format": "date-time",
      "type": "string"
    },
    "customDomainAllowance": {
      "description": "How many hostnames of the customer's own this site's plan allows across the account, or zero when it\nincludes none.\n\nSent here rather than looked up from the public plans list, which is where the dashboard read it and\nwhere it is not always findable: `GET /plans` filters to public tiers, so a site on a plan cut\nfor one account matched nothing and the section rendered as a heading with nothing underneath —\nexactly the account most likely to have been given an allowance. The same argument as\nPasswordProtectionAvailable, which is why it sits beside it.",
      "format": "int32",
      "pattern": "^-?(?:0|[1-9]\\d*)$",
      "type": [
        "integer",
        "string"
      ]
    },
    "deploys": {
      "description": "The most recent deploys, newest first.",
      "items": {
        "$ref": "#/$defs/DeploySummary"
      },
      "type": "array"
    },
    "docsMode": {
      "description": "Whether the site is served through the documents viewer.",
      "type": "boolean"
    },
    "expiresAt": {
      "description": "When an unclaimed anonymous site is removed, or null once claimed.",
      "format": "date-time",
      "type": [
        "null",
        "string"
      ]
    },
    "expiryAction": {
      "description": "What the expiry does when it falls due, `Pause` or `Delete`. Meaningless while\nExpiresAt is null, and sent anyway so the form has something to show when the\nowner sets a date.",
      "type": "string"
    },
    "feedbackAvailable": {
      "description": "Whether this site's plan includes comments. Sent for PasswordProtectionAvailable's\nreason: the control shows disabled with a reason rather than failing on submit.",
      "type": "boolean"
    },
    "folderId": {
      "description": "The folder it is filed in, or null when it sits at the root of the listing.\n\nThe same field `SiteSummary` carries. Sent here as well so the site's own page can put that\nfolder in its trail and return to it — after a removal, too — instead of to the root of the listing,\nwhich is where somebody who opened the site from inside a folder did not come from.",
      "type": [
        "null",
        "string"
      ]
    },
    "formerSubdomains": {
      "description": "The names the site used to answer on, newest first, each redirecting to it.",
      "items": {
        "$ref": "#/$defs/FormerSubdomain"
      },
      "type": "array"
    },
    "formsAvailable": {
      "description": "Whether the account's tier includes forms at all.\n\nReported beside the switch for the reason PasswordProtectionAvailable is: the\ndashboard has to tell \"off\" apart from \"not yours to turn on\", and a control that looks available and\nthen refuses is worse than one that says why.",
      "type": "boolean"
    },
    "formsEnabled": {
      "description": "Whether the owner has switched form submissions on for this site.",
      "type": "boolean"
    },
    "invitedOnly": {
      "description": "Whether only the people on the site's list may see it. Never true together with\nPasswordProtected.",
      "type": "boolean"
    },
    "invitedViewersAvailable": {
      "description": "Whether this site's plan offers invite-only sites. Sent for PasswordProtectionAvailable's\nreason: the Sharing tab shows the third choice disabled with a reason rather than offering it and failing.",
      "type": "boolean"
    },
    "lastActiveAt": {
      "description": "When it last received a deploy.",
      "format": "date-time",
      "type": "string"
    },
    "liveDeployId": {
      "description": "The deploy being served, or null.",
      "type": [
        "null",
        "string"
      ]
    },
    "maxSiteViewers": {
      "description": "How many people this site's plan lets the list hold, the owner included — and, per site, how many\ninvitations it may mail in a day.",
      "format": "int32",
      "pattern": "^-?(?:0|[1-9]\\d*)$",
      "type": [
        "integer",
        "string"
      ]
    },
    "modeIsManual": {
      "description": "Whether the serving mode was chosen by the owner rather than detected from the files of the deploy\nthat went live. What lets the dashboard say which of the two it is showing.",
      "type": "boolean"
    },
    "name": {
      "description": "What to call it, or null when it has none and the subdomain should be shown instead.\n\nA label only. The site is reached by Subdomain whatever this says, which is why\nthe dashboard shows the address underneath rather than replacing it: two sites are allowed the same\nname, and the name is not what anybody types into a browser.",
      "type": [
        "null",
        "string"
      ]
    },
    "openFeedback": {
      "description": "Threads open on the site, the number on the Feedback tab.",
      "format": "int32",
      "pattern": "^-?(?:0|[1-9]\\d*)$",
      "type": [
        "integer",
        "string"
      ]
    },
    "passwordProtected": {
      "description": "Whether visitors must enter a password. Only whether one is set is reported — the password itself is\nstored as a hash, so there is nothing to return.",
      "type": "boolean"
    },
    "passwordProtectionAvailable": {
      "description": "Whether this site's plan permits a password at all. Sent so the dashboard can show the control in a\ndisabled state with a reason, rather than offering it and failing on submit.",
      "type": "boolean"
    },
    "planId": {
      "description": "Plan whose limits apply.",
      "type": "string"
    },
    "primaryUrl": {
      "description": "The URL to lead with: a hostname of the customer's own once one is serving, and\nUrl otherwise.\n\nSent as its own field rather than by overwriting Url, because the two are not\ninterchangeable and the subdomain does not stop mattering: it needs no DNS of anybody's and keeps\nworking when a custom domain's records are changed or lapse, which makes it the address to fall back\nto when the other one is what is being diagnosed.",
      "type": "string"
    },
    "publicDocumentCounts": {
      "description": "Whether readers of this documents site see each document's views and downloads. Every plan may turn\nit on.",
      "type": "boolean"
    },
    "scheduledExpiryAvailable": {
      "description": "Whether this site's plan lets its owner schedule when it comes down. Sent so the dashboard can show\nthe control disabled with a reason rather than offering it and failing on submit.",
      "type": "boolean"
    },
    "siteId": {
      "description": "ULID of the site.",
      "type": "string"
    },
    "spaMode": {
      "description": "Whether an unmatched path falls back to index.html.",
      "type": "boolean"
    },
    "status": {
      "description": "Whether the site is serving, and if not, who switched it off — the `SiteStatus` name.\n\nWhat the pause control reads, and what tells the three off-states apart for the customer. They need\ndifferent words and different offers: their own pause has a button that undoes it, a suspension has\nsupport to contact, and being held for quota has an upgrade. One boolean could not say which.",
      "type": "string"
    },
    "statusChangedAt": {
      "description": "When the state last changed, or null if it never has.",
      "format": "date-time",
      "type": [
        "null",
        "string"
      ]
    },
    "subdomain": {
      "description": "Its subdomain.",
      "type": "string"
    },
    "subdomainChangesLeft": {
      "description": "How many more times the owner may change Subdomain, or null when the plan does not\nlimit it. What the change-of-address control and its confirmation show.",
      "format": "int32",
      "pattern": "^-?(?:0|[1-9]\\d*)$",
      "type": [
        "null",
        "integer",
        "string"
      ]
    },
    "trafficBreakdownAvailable": {
      "description": "Whether this site's plan includes top lists, the globe and filters on the traffic page. Sent so the\npage draws a locked card naming the tier instead of requesting a list and rendering a 403.",
      "type": "boolean"
    },
    "trafficDetailAvailable": {
      "description": "Whether this site's plan splits people from bots and AI and shows devices, browsers and campaigns.",
      "type": "boolean"
    },
    "url": {
      "description": "Its public URL on the platform's own content domain.",
      "type": "string"
    }
  },
  "required": [
    "siteId",
    "subdomain",
    "name",
    "folderId",
    "url",
    "primaryUrl",
    "liveDeployId",
    "spaMode",
    "docsMode",
    "modeIsManual",
    "passwordProtected",
    "passwordProtectionAvailable",
    "invitedOnly",
    "invitedViewersAvailable",
    "maxSiteViewers",
    "commentAudience",
    "feedbackAvailable",
    "openFeedback",
    "formsEnabled",
    "formsAvailable",
    "publicDocumentCounts",
    "analyticsDays",
    "trafficBreakdownAvailable",
    "trafficDetailAvailable",
    "analyticsExportAvailable",
    "scheduledExpiryAvailable",
    "browserEditAvailable",
    "anonymous",
    "expiryAction",
    "customDomainAllowance",
    "planId",
    "status",
    "statusChangedAt",
    "expiresAt",
    "createdAt",
    "lastActiveAt",
    "deploys",
    "subdomainChangesLeft",
    "formerSubdomains"
  ],
  "type": "object",
  "$defs": {
    "DeploySummary": {
      "description": "One deploy in a site's history.",
      "properties": {
        "completedAt": {
          "description": "When it reached a terminal state, or null while in flight.",
          "format": "date-time",
          "type": [
            "null",
            "string"
          ]
        },
        "createdAt": {
          "description": "When it was prepared.",
          "format": "date-time",
          "type": "string"
        },
        "deployId": {
          "description": "ULID of the deploy.",
          "type": "string"
        },
        "error": {
          "description": "Why it failed, when it did.",
          "type": [
            "null",
            "string"
          ]
        },
        "fileCount": {
          "description": "Files it holds.",
          "format": "int32",
          "pattern": "^-?(?:0|[1-9]\\d*)$",
          "type": [
            "integer",
            "string"
          ]
        },
        "filesKept": {
          "description": "Whether this version is inside the retention window with its files still on storage, and therefore\nwhether it can be previewed or rolled back to.\n\nIt is a field rather than something a client works out because it cannot be worked out from the rest\nof the response. `site.trim_deploys` keeps the plan's `retainedDeploys` most recently live\ndeploys, a rollback counting as going live, and deletes the objects of the rest, leaving the rows. A\nversion outside that window reads false at once, before the trim has run, because `promote`\nrefuses it by the same rule. The dashboard used to redraw that rule from a deploy's position in this\nlist, and disagreed with the server in both directions — and a plan whose retention was lowered then\nraised has versions inside the window whose files went weeks ago.\n\nstring DeploySummary.Status does not answer it either: a `superseded` deploy may be fully stored or\nentirely gone. Nothing about the row changes when its objects are deleted except this.",
          "type": "boolean"
        },
        "previewUrl": {
          "description": "URL serving this deploy on its own hostname, or null when there is no reachable preview of it.\n\nTwo reasons for null, and they are unrelated. The site's subdomain may be too long to leave room for a\npreview hostname (see int PreviewHostFormat.MaxSubdomainLength). Or the site's hostnames may\nnot be answering at all — see bool SiteLifecycle.HostnamesServe(Site site): a preview resolves through\nthe site's own hostname, so a tombstoned site has no reachable preview of any version, however sound\nthe deploy behind it is. A site an administrator restores is exactly that, and reporting a URL for it\nwas how the dashboard came to offer a history of versions none of which opened.\n\nIt works for any deploy whose files are still on storage, including one that was never published, and\nstops working once retention removes them — the same condition `promote` reports as a 410. Nobody\nis asked to log in: the URL is unlisted rather than access-controlled, and what protects it is the 80\nbits of randomness in a ULID. A password-protected site's previews are behind that password like the\nrest of it.",
          "type": [
            "null",
            "string"
          ]
        },
        "status": {
          "description": "Lowercase status name.",
          "type": "string"
        },
        "totalBytes": {
          "description": "Its size.",
          "format": "int64",
          "pattern": "^-?(?:0|[1-9]\\d*)$",
          "type": [
            "integer",
            "string"
          ]
        }
      },
      "required": [
        "deployId",
        "status",
        "fileCount",
        "totalBytes",
        "createdAt",
        "completedAt",
        "error",
        "previewUrl",
        "filesKept"
      ],
      "type": "object"
    },
    "FormerSubdomain": {
      "description": "One name a site used to answer on.",
      "properties": {
        "retiredAt": {
          "description": "When the site stopped answering on it.",
          "format": "date-time",
          "type": "string"
        },
        "subdomain": {
          "description": "The former subdomain, which redirects to the current one.",
          "type": "string"
        }
      },
      "required": [
        "subdomain",
        "retiredAt"
      ],
      "type": "object"
    }
  }
}