{
  "openapi": "3.1.0",
  "info": {
    "title": "RankHound API",
    "version": "1.0.0",
    "description": "RankHound is an SEO automation platform. Each week its agent researches keywords and competitor gaps, checks your Google rankings, writes articles for your approval and publishes the approved ones to your site. AI visibility tracking, included on every plan, reads how often AI assistants name and cite your brand, and which sites they read instead of yours.\n\nThis API reads both, and only reads: nothing here approves or publishes an article, starts a run, or changes what is tracked.\n\nFOUR RULES A CLIENT MUST CARRY, because ignoring them produces confidently wrong reports:\n\n1. `rate: null` means not enough answers yet (below min_sample 5), NOT zero.\n2. Engines are reported separately and never averaged. They disagree, so an average describes no real surface.\n3. An empty /alerts list does NOT mean nothing changed. Findings are suppressed when a move sits inside its own margin of error, which on a small plan is most real moves. GET /me returns the live thresholds.\n4. On the SEO endpoints a null number is never zero either. A Search Console figure that is null was not measured, and a rank position that is null inside a completed check means not in the top 100. Ranks are checked weekly, not live.\n\nFailed collections are excluded from denominators rather than counted as absence, so our outages never read as your absence."
  },
  "servers": [
    {
      "url": "https://app.rankhound.ai/api/v1"
    }
  ],
  "security": [
    {
      "ApiKeyAuth": []
    }
  ],
  "components": {
    "securitySchemes": {
      "ApiKeyAuth": {
        "type": "apiKey",
        "in": "header",
        "name": "X-API-Key",
        "description": "A key from app.rankhound.ai/api-keys, shown once at creation. `Authorization: Bearer <key>` is accepted equivalently. Keys belong to the organisation, not to a person, so they survive that person leaving."
      }
    },
    "schemas": {
      "Rate": {
        "type": "object",
        "properties": {
          "present": {
            "type": "integer",
            "description": "Answers that named this brand."
          },
          "responses": {
            "type": "integer",
            "description": "Answers in the window. The denominator, always returned."
          },
          "rate": {
            "type": [
              "number",
              "null"
            ],
            "description": "present/responses to 3dp, or NULL below min_sample (5). Null means NOT ENOUGH ANSWERS YET and must never be rendered as 0% — doing so reports a collapse in visibility that did not happen."
          },
          "min_sample": {
            "type": "integer",
            "description": "The floor below which rate is null."
          }
        },
        "required": [
          "present",
          "responses",
          "rate",
          "min_sample"
        ]
      },
      "Error": {
        "type": "object",
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "code": {
                "type": "string",
                "description": "Stable machine-readable code. Branch on this, not the message."
              },
              "message": {
                "type": "string",
                "description": "Human-readable. Wording may change; the code will not."
              }
            },
            "required": [
              "code",
              "message"
            ]
          }
        },
        "required": [
          "error"
        ]
      },
      "BrandRef": {
        "type": "object",
        "description": "Which brand a response describes. Present in meta on every data endpoint.",
        "properties": {
          "id": {
            "type": "string",
            "description": "Pass as ?brand=."
          },
          "name": {
            "type": "string",
            "description": "Display name."
          }
        },
        "required": [
          "id",
          "name"
        ]
      },
      "Engine": {
        "type": "object",
        "description": "One AI engine's numbers. NEVER average these together.",
        "properties": {
          "engine": {
            "type": "string",
            "description": "Stable slug, e.g. chatgpt. Use this for ?engine= filters."
          },
          "name": {
            "type": "string",
            "description": "Display name, e.g. ChatGPT."
          },
          "collection_method": {
            "type": "string",
            "description": "How this surface is read: api, scraper or serp."
          },
          "present": {
            "type": "integer",
            "description": "Answers that named this brand."
          },
          "responses": {
            "type": "integer",
            "description": "Answers in the window. The denominator, always returned."
          },
          "rate": {
            "type": [
              "number",
              "null"
            ],
            "description": "present/responses to 3dp, or NULL below min_sample (5). Null means NOT ENOUGH ANSWERS YET and must never be rendered as 0% — doing so reports a collapse in visibility that did not happen."
          },
          "min_sample": {
            "type": "integer",
            "description": "The floor below which rate is null."
          },
          "citations": {
            "type": "integer",
            "description": "Answers from this engine that cited the brand's own domain."
          },
          "avg_rank_when_named": {
            "type": [
              "number",
              "null"
            ],
            "description": "Mean position in the list WHEN NAMED. Null when never named."
          }
        },
        "required": [
          "engine",
          "name",
          "collection_method",
          "present",
          "responses",
          "rate",
          "min_sample"
        ]
      },
      "BrandVisibility": {
        "type": "object",
        "description": "One tracked brand, pooled across engines. The only place we pool.",
        "properties": {
          "name": {
            "type": "string",
            "description": "Brand name."
          },
          "role": {
            "type": "string",
            "enum": [
              "self",
              "competitor"
            ],
            "description": "Yours, or a rival."
          },
          "present": {
            "type": "integer",
            "description": "Answers that named this brand."
          },
          "responses": {
            "type": "integer",
            "description": "Answers in the window. The denominator, always returned."
          },
          "rate": {
            "type": [
              "number",
              "null"
            ],
            "description": "present/responses to 3dp, or NULL below min_sample (5). Null means NOT ENOUGH ANSWERS YET and must never be rendered as 0% — doing so reports a collapse in visibility that did not happen."
          },
          "min_sample": {
            "type": "integer",
            "description": "The floor below which rate is null."
          },
          "citations": {
            "type": "integer",
            "description": "Answers citing this brand's own domain."
          },
          "avg_rank_when_named": {
            "type": [
              "number",
              "null"
            ],
            "description": "Mean list position when named."
          },
          "framing": {
            "type": "object",
            "description": "How the brand was FRAMED where it appeared. Deliberately not called a sentiment score: it counts cue phrases, it does not grade tone.",
            "properties": {
              "classified": {
                "type": "integer",
                "description": "Mentions we could classify at all. The denominator here."
              },
              "recommended": {
                "type": "integer",
                "description": "Mentions framed as a recommendation."
              },
              "negative": {
                "type": "integer",
                "description": "Mentions framed negatively."
              }
            }
          }
        },
        "required": [
          "name",
          "role",
          "present",
          "responses",
          "rate",
          "min_sample"
        ]
      },
      "Brand": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "The workspace public id. Pass as ?brand=."
          },
          "name": {
            "type": "string",
            "description": "Brand name."
          },
          "domain": {
            "type": [
              "string",
              "null"
            ],
            "description": "The brand's own domain, null before setup finishes."
          },
          "configured": {
            "type": "boolean",
            "description": "False means setup was never finished. Data endpoints return 404 brand_not_configured for one of these rather than a page of zeroes, which would read as measured absence."
          },
          "active_prompts": {
            "type": "integer",
            "description": "Prompts currently running for this brand."
          },
          "market": {
            "type": "object",
            "properties": {
              "location_code": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "DataForSEO location code."
              },
              "language_code": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "ISO language code."
              }
            }
          },
          "last_collected_at": {
            "type": [
              "string",
              "null"
            ],
            "description": "ISO timestamp of the most recent stored answer."
          }
        },
        "required": [
          "id",
          "name",
          "configured",
          "active_prompts"
        ]
      },
      "Mention": {
        "type": "object",
        "properties": {
          "brand": {
            "type": "string",
            "description": "Which tracked brand this refers to."
          },
          "role": {
            "type": "string",
            "enum": [
              "self",
              "competitor"
            ]
          },
          "mentioned": {
            "type": "boolean",
            "description": "False rows exist: we looked and it was absent."
          },
          "rank": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Position in the answer's list, 1-based. Null when unranked."
          },
          "count": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Times named in this one answer."
          },
          "framing": {
            "type": [
              "string",
              "null"
            ],
            "description": "recommended | neutral | negative | null when genuinely ambivalent."
          },
          "framing_signals": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string"
            },
            "description": "The cue phrases behind `framing`, so a judgement can be checked."
          },
          "snippet": {
            "type": [
              "string",
              "null"
            ],
            "description": "The verbatim sentence naming the brand. This is the evidence."
          }
        },
        "required": [
          "brand",
          "role",
          "mentioned"
        ]
      },
      "Citation": {
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "description": "The cited page."
          },
          "domain": {
            "type": "string",
            "description": "Its host."
          },
          "rank": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Position in the engine's source list. That order IS the rank."
          },
          "title": {
            "type": [
              "string",
              "null"
            ],
            "description": "Page title where the engine supplied one."
          }
        },
        "required": [
          "url",
          "domain"
        ]
      },
      "Answer": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "Opaque public id, ans_<12>. Also the pagination cursor. Never an integer."
          },
          "engine": {
            "type": "string",
            "description": "Engine slug."
          },
          "collection_method": {
            "type": "string",
            "description": "api, scraper or serp."
          },
          "prompt": {
            "type": "string",
            "description": "The prompt that was asked. Called `prompt`, not `question`."
          },
          "answer": {
            "type": [
              "string",
              "null"
            ],
            "description": "The answer verbatim, markdown. Null when the engine returned none."
          },
          "outcome": {
            "type": "string",
            "enum": [
              "ok",
              "empty"
            ],
            "description": "'empty' is a REAL OBSERVATION: the engine answered and named nobody. It is in the denominator everywhere. Failed collections (error, timeout) are excluded entirely and never appear here."
          },
          "collected_at": {
            "type": "string",
            "description": "ISO timestamp."
          },
          "location_code": {
            "type": [
              "integer",
              "null"
            ],
            "description": "The market this was collected in."
          },
          "mentions": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Mention"
            }
          },
          "citations": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Citation"
            }
          }
        },
        "required": [
          "id",
          "engine",
          "prompt",
          "outcome",
          "collected_at",
          "mentions",
          "citations"
        ]
      },
      "Source": {
        "type": "object",
        "properties": {
          "domain": {
            "type": "string",
            "description": "Normalised host — www.example.com and example.com are one row."
          },
          "answers_citing_it": {
            "type": "integer",
            "description": "Answers in the window that cited this domain."
          },
          "answers_where_you_were_named": {
            "type": "integer",
            "description": "Of those, how many also named your brand."
          },
          "answers_citing_you_without_naming_you": {
            "type": "integer",
            "description": "The actionable number. A domain read on nine answers that named a competitor every time is a specific page to go and be on."
          }
        },
        "required": [
          "domain",
          "answers_citing_it",
          "answers_where_you_were_named",
          "answers_citing_you_without_naming_you"
        ]
      },
      "CompetitorQuote": {
        "type": "object",
        "properties": {
          "snippet": {
            "type": "string",
            "description": "The sentence the engine wrote, with its ** emphasis left intact."
          },
          "engine": {
            "type": "string",
            "description": "Engine slug the sentence came from."
          },
          "question": {
            "type": "string",
            "description": "The tracked prompt whose answer contained it."
          },
          "listRank": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Where the engine placed them; 1 is first. Null when it wrote no list."
          },
          "answerId": {
            "type": "string",
            "description": "The answer this came from, as returned by /answers."
          }
        },
        "required": [
          "snippet",
          "engine",
          "question",
          "listRank",
          "answerId"
        ]
      },
      "Competitor": {
        "type": "object",
        "properties": {
          "competitor": {
            "type": "string",
            "description": "The competitor's name as tracked in this workspace."
          },
          "domain": {
            "type": [
              "string",
              "null"
            ],
            "description": "Their domain, when one is recorded."
          },
          "answers_naming_them": {
            "type": "integer",
            "description": "Answers in the window that named them."
          },
          "answers_without_you": {
            "type": "integer",
            "description": "The actionable column. Answers that named this competitor and never mentioned you. 'Named in 48 answers' is a fact about them; 'named in 47 answers that never mentioned you' is a fact about you."
          },
          "best_list_position": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Best (lowest) position they reached. Null when no answer ranked them."
          },
          "typical_list_position": {
            "type": [
              "number",
              "null"
            ],
            "description": "Median position to 1dp. Null when no answer ranked them."
          },
          "total_mentions": {
            "type": "integer",
            "description": "Times named across those answers, which can exceed answers_naming_them."
          },
          "engines": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Engine slugs that named them. Listed, never pooled into one figure."
          },
          "quotes": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CompetitorQuote"
            },
            "description": "Up to three supporting sentences, best-ranked first. PRESENT ONLY with ?quotes=1, and absent rather than empty otherwise."
          }
        },
        "required": [
          "competitor",
          "domain",
          "answers_naming_them",
          "answers_without_you",
          "best_list_position",
          "typical_list_position",
          "total_mentions",
          "engines"
        ]
      },
      "BrandSentiment": {
        "type": "object",
        "properties": {
          "brand": {
            "type": "string",
            "description": "The brand this row describes."
          },
          "is_you": {
            "type": "boolean",
            "description": "True for the workspace's own brand."
          },
          "answers_naming_them": {
            "type": "integer",
            "description": "Answers that named this brand. NOT the denominator."
          },
          "classified": {
            "type": "integer",
            "description": "THE DENOMINATOR. Mentions the classifier was willing to call. It abstains on listicle mentions, where an engine names a brand without endorsing it, so this is usually much smaller than answers_naming_them."
          },
          "recommended": {
            "type": "integer",
            "description": "Of the classified mentions, how many read as a recommendation."
          },
          "negative": {
            "type": "integer",
            "description": "Of the classified mentions, how many read as negative."
          },
          "neutral": {
            "type": "integer",
            "description": "classified - recommended - negative, floored at 0."
          },
          "recommended_share": {
            "type": [
              "number",
              "null"
            ],
            "description": "recommended/classified to 3dp, or NULL below min_sample (5) classified mentions. Null means NOT ENOUGH CLASSIFIED MENTIONS and must never be rendered as 0%."
          }
        },
        "required": [
          "brand",
          "is_you",
          "answers_naming_them",
          "classified",
          "recommended",
          "negative",
          "neutral",
          "recommended_share"
        ]
      },
      "ShareOfVoice": {
        "type": "object",
        "properties": {
          "brand": {
            "type": "string",
            "description": "The brand this row describes."
          },
          "is_you": {
            "type": "boolean",
            "description": "True for the workspace's own brand."
          },
          "mentions": {
            "type": "integer",
            "description": "Answers in the window that named this brand."
          },
          "share": {
            "type": [
              "number",
              "null"
            ],
            "description": "This brand's mentions over every tracked brand's, to 4dp. NULL when no brand was named at all, because a share of an empty pool is undefined. That is not the same as a 0% share."
          }
        },
        "required": [
          "brand",
          "is_you",
          "mentions",
          "share"
        ]
      },
      "Alert": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "Opaque public id, alr_<12>. Use it to dedupe across polls."
          },
          "kind": {
            "type": "string",
            "description": "Finding type, e.g. visibility_drop."
          },
          "title": {
            "type": "string",
            "description": "One line."
          },
          "detail": {
            "type": "string",
            "description": "The explanation, including the numbers behind it."
          },
          "facts": {
            "type": "object",
            "description": "The figures the finding was computed from."
          },
          "created_at": {
            "type": "string",
            "description": "ISO timestamp."
          },
          "read": {
            "type": "boolean",
            "description": "Whether it has been seen in the dashboard."
          }
        },
        "required": [
          "id",
          "kind",
          "title",
          "created_at",
          "read"
        ]
      },
      "Me": {
        "type": "object",
        "properties": {
          "organization": {
            "type": "object",
            "properties": {
              "name": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Organisation name."
              }
            }
          },
          "plan": {
            "type": "object",
            "properties": {
              "name": {
                "type": "string",
                "description": "seo_solo | seo_starter | seo_growth | seo_scale, or trial. An account on a prompt-only plan from before 23 Oct 2026 reads solo | starter | growth | agency."
              },
              "status": {
                "type": "string",
                "description": "Stripe subscription status."
              },
              "trial_ends_at": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "ISO timestamp, null when not trialling."
              },
              "prompts": {
                "type": "object",
                "properties": {
                  "used": {
                    "type": "integer",
                    "description": "Active prompts across the organisation."
                  },
                  "included": {
                    "type": "integer",
                    "description": "The cap on active questions for AI visibility tracking, included on every plan: 10 per workspace."
                  }
                }
              },
              "brands": {
                "type": "integer",
                "description": "Brands in the organisation."
              }
            }
          },
          "key": {
            "type": "object",
            "properties": {
              "scopes": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "Granted to this key."
              },
              "available_scopes": {
                "type": "array",
                "items": {
                  "type": "string"
                },
                "description": "Every scope that exists, so 'not granted' is distinguishable from 'not real'."
              },
              "pinned_to_brand": {
                "type": "boolean",
                "description": "True when the key is bound to one brand. Such a key refuses any other ?brand=, and GET /brands returns only that one brand."
              }
            }
          },
          "brand": {
            "$ref": "#/components/schemas/BrandRef"
          },
          "rate_limit": {
            "type": "object",
            "properties": {
              "per_minute": {
                "type": "integer",
                "description": "Requests a minute. 300 on every plan."
              },
              "remaining": {
                "type": "integer",
                "description": "Left in the current minute."
              },
              "resets_in_seconds": {
                "type": "integer",
                "description": "Until the window rolls."
              }
            }
          },
          "reporting_rules": {
            "type": "object",
            "description": "NOT ADVISORY. A client that ignores these produces wrong reports.",
            "properties": {
              "min_sample": {
                "type": "integer",
                "description": "Below this many answers, rate is null."
              },
              "rate_null_means": {
                "type": "string",
                "description": "Plain-language restatement, for surfacing to an end user."
              },
              "engines_averaged": {
                "type": "boolean",
                "description": "Always false. Engines disagree; a mean describes no real surface."
              },
              "alerts": {
                "type": "object",
                "properties": {
                  "enabled": {
                    "type": "boolean"
                  },
                  "kinds": {
                    "type": [
                      "array",
                      "string"
                    ],
                    "items": {
                      "type": "string"
                    },
                    "description": "Array of requested kinds, or the string 'all' when none are set. Kinds: visibility_drop, visibility_rise, competitor_overtook, cited_not_named, negative_framing, engine_blackout."
                  },
                  "min_delta_points": {
                    "type": "integer",
                    "description": "Percentage points of change required to raise a finding."
                  },
                  "min_sample_each_side": {
                    "type": "integer",
                    "description": "Answers required on BOTH sides of the comparison."
                  },
                  "also_suppressed_below_statistical_noise": {
                    "type": "boolean",
                    "description": "Always true. Even a move clearing min_delta_points is suppressed if it sits inside a 95% CI on the difference of two proportions. Whichever bar is HIGHER wins."
                  },
                  "empty_list_means": {
                    "type": "string",
                    "description": "The honest phrasing for an empty /alerts response."
                  }
                }
              }
            }
          }
        },
        "required": [
          "organization",
          "plan",
          "key",
          "brand",
          "rate_limit",
          "reporting_rules"
        ]
      },
      "Recommendation": {
        "type": "object",
        "description": "One piece of work, with the pages and answers that justify it. A row with nothing to point at is filtered out rather than softened into generic advice.",
        "properties": {
          "kind": {
            "type": "string",
            "enum": [
              "own_page",
              "listing",
              "community",
              "marketplace"
            ],
            "description": "What kind of WORK this is, not what kind of site it is. You submit to a listing, you participate in a community, you rewrite your own page this afternoon."
          },
          "tier": {
            "type": "integer",
            "description": "Sort order, and an argument rather than a score: 1 your own page (nobody has to agree), 2 a listing you can submit to this week, 3 a community you must participate in, 4 a marketplace that is usually a procurement decision."
          },
          "site": {
            "type": "string",
            "description": "The registrable site. One publisher, one outreach contact."
          },
          "pages": {
            "type": "array",
            "description": "The specific pages, because 'get listed on medium.com' is advice and naming the roundup is a task. Never empty.",
            "items": {
              "type": "object",
              "properties": {
                "url": {
                  "type": "string"
                },
                "title": {
                  "type": [
                    "string",
                    "null"
                  ]
                }
              },
              "required": [
                "url",
                "title"
              ]
            }
          },
          "answers": {
            "type": "integer",
            "description": "Answers in the window that cited this site."
          },
          "answers_naming_you": {
            "type": "integer",
            "description": "Of those, how many named you. Zero for every gap, by definition."
          },
          "competitors_named": {
            "type": "integer",
            "description": "Distinct tracked competitors named in those answers."
          },
          "engines": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Which engines read it. Listed, never pooled into one figure."
          },
          "share": {
            "type": "number",
            "description": "Fraction of meta.total_answers this door accounts for. Meaningless without that denominator, which is why it is returned beside it."
          }
        },
        "required": [
          "kind",
          "tier",
          "site",
          "pages",
          "answers",
          "answers_naming_you",
          "competitors_named",
          "engines",
          "share"
        ]
      },
      "Question": {
        "type": "object",
        "description": "One tracked question. Every other measurement in this API is derived from these, so a disappointing figure may mean the wrong question rather than poor visibility.",
        "properties": {
          "id": {
            "type": "string",
            "description": "Public id."
          },
          "text": {
            "type": "string"
          },
          "category": {
            "type": [
              "string",
              "null"
            ]
          },
          "source": {
            "type": "string",
            "description": "How the question got here, for example user or suggested."
          },
          "active": {
            "type": "boolean",
            "description": "Inactive questions stop being run but keep the answers they already produced."
          },
          "answers": {
            "type": "integer",
            "description": "Answers this question has produced in total, all engines."
          },
          "answers_naming_you": {
            "type": "integer"
          },
          "web_search_volume": {
            "type": [
              "integer",
              "null"
            ],
            "description": "NULL MEANS NOT CHECKED, not zero. Reporting an unchecked volume as 0 asserts nobody searches a question nobody has measured. Read volume_checked_at before quoting demand."
          },
          "volume_checked_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When volume was last looked up. Null when it never has been."
          }
        },
        "required": [
          "id",
          "text",
          "category",
          "source",
          "active",
          "answers",
          "answers_naming_you",
          "web_search_volume",
          "volume_checked_at"
        ]
      },
      "Article": {
        "type": "object",
        "description": "One article the SEO agent chose to write, with why. The list form: the text and the evidence numbers are on GET /articles/{id}.",
        "properties": {
          "id": {
            "type": "string",
            "description": "Public id, art_<12>. Pass it to GET /articles/{id}. Also the pagination cursor."
          },
          "keyword": {
            "type": "string",
            "description": "The search the article was written for."
          },
          "intent": {
            "type": "string",
            "enum": [
              "new"
            ],
            "description": "Always new: RankHound writes new pages only and never changes a page you already have. Nothing of yours owns this search."
          },
          "source": {
            "type": "string",
            "enum": [
              "search_console",
              "competitor_gap",
              "keyword",
              "manual"
            ],
            "description": "Where the topic came from: a search Google already shows you for in your own Search Console, a keyword a tracked competitor ranks for and you do not, keyword research, or a request."
          },
          "status": {
            "type": "string",
            "enum": [
              "writing",
              "waiting",
              "scheduled",
              "published",
              "rejected",
              "skipped",
              "failed"
            ],
            "description": "writing: chosen and being written. waiting: written and waiting for your approval. scheduled: approved and waiting for its publishing slot, or going out now. published: your webhook accepted it. rejected: you said no, and it is never proposed again. skipped: the agent dropped it before writing, and status_reason says why; not a failure. failed: writing or delivery gave up, and status_reason says why."
          },
          "status_reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "Why, in a sentence, when status is skipped or failed. Null otherwise."
          },
          "title": {
            "type": [
              "string",
              "null"
            ],
            "description": "Null until the article is written."
          },
          "why": {
            "type": [
              "string",
              "null"
            ],
            "description": "Why the agent chose this topic, in one sentence a customer can read. The numbers behind it are on GET /articles/{id}."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the agent chose the topic. Lists are ordered by this, newest first."
          },
          "scheduled_for": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "The publishing slot. Null until the article is approved and given one."
          },
          "published_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When your webhook accepted it. Null until published."
          },
          "published_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "Where it went live, from your endpoint's reply or your article URL pattern. Null when neither said, which also means its results cannot be measured."
          },
          "results": {
            "type": [
              "object",
              "null"
            ],
            "description": "What Google sent this article, from your own Search Console over meta.results_window_days. NULL UNTIL THERE IS SOMETHING TO MEASURE, never 0: not published yet, no published URL, Search Console not connected, or Google has not reported the page yet. Rendering null as 0 clicks reports a failure nobody measured.",
            "properties": {
              "clicks": {
                "type": "integer",
                "description": "Clicks from Google search to this page in the window."
              },
              "impressions": {
                "type": "integer",
                "description": "Times Google showed this page in search results in the window."
              }
            },
            "required": [
              "clicks",
              "impressions"
            ]
          }
        },
        "required": [
          "id",
          "keyword",
          "intent",
          "source",
          "status",
          "status_reason",
          "title",
          "why",
          "created_at",
          "scheduled_for",
          "published_at",
          "published_url",
          "results"
        ]
      },
      "ArticleDetail": {
        "type": "object",
        "description": "One article in full: the text, the evidence it was chosen on, and the pages it has to beat.",
        "properties": {
          "id": {
            "type": "string",
            "description": "Public id, art_<12>. Pass it to GET /articles/{id}. Also the pagination cursor."
          },
          "keyword": {
            "type": "string",
            "description": "The search the article was written for."
          },
          "intent": {
            "type": "string",
            "enum": [
              "new"
            ],
            "description": "Always new: RankHound writes new pages only and never changes a page you already have. Nothing of yours owns this search."
          },
          "source": {
            "type": "string",
            "enum": [
              "search_console",
              "competitor_gap",
              "keyword",
              "manual"
            ],
            "description": "Where the topic came from: a search Google already shows you for in your own Search Console, a keyword a tracked competitor ranks for and you do not, keyword research, or a request."
          },
          "status": {
            "type": "string",
            "enum": [
              "writing",
              "waiting",
              "scheduled",
              "published",
              "rejected",
              "skipped",
              "failed"
            ],
            "description": "writing: chosen and being written. waiting: written and waiting for your approval. scheduled: approved and waiting for its publishing slot, or going out now. published: your webhook accepted it. rejected: you said no, and it is never proposed again. skipped: the agent dropped it before writing, and status_reason says why; not a failure. failed: writing or delivery gave up, and status_reason says why."
          },
          "status_reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "Why, in a sentence, when status is skipped or failed. Null otherwise."
          },
          "title": {
            "type": [
              "string",
              "null"
            ],
            "description": "Null until the article is written."
          },
          "why": {
            "type": [
              "string",
              "null"
            ],
            "description": "Why the agent chose this topic, in one sentence a customer can read. The numbers behind it are on GET /articles/{id}."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the agent chose the topic. Lists are ordered by this, newest first."
          },
          "scheduled_for": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "The publishing slot. Null until the article is approved and given one."
          },
          "published_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When your webhook accepted it. Null until published."
          },
          "published_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "Where it went live, from your endpoint's reply or your article URL pattern. Null when neither said, which also means its results cannot be measured."
          },
          "results": {
            "type": [
              "object",
              "null"
            ],
            "description": "What Google sent this article, from your own Search Console over meta.results_window_days. NULL UNTIL THERE IS SOMETHING TO MEASURE, never 0: not published yet, no published URL, Search Console not connected, or Google has not reported the page yet. Rendering null as 0 clicks reports a failure nobody measured.",
            "properties": {
              "clicks": {
                "type": "integer",
                "description": "Clicks from Google search to this page in the window."
              },
              "impressions": {
                "type": "integer",
                "description": "Times Google showed this page in search results in the window."
              }
            },
            "required": [
              "clicks",
              "impressions"
            ]
          },
          "slug": {
            "type": [
              "string",
              "null"
            ],
            "description": "URL slug, lowercase and hyphenated. Null until written."
          },
          "meta_description": {
            "type": [
              "string",
              "null"
            ],
            "description": "The search snippet, one or two sentences. Null until written."
          },
          "body_markdown": {
            "type": [
              "string",
              "null"
            ],
            "description": "The article, markdown starting at an H2; the title is the H1. Null until written."
          },
          "evidence": {
            "type": "object",
            "description": "What the agent saw when it chose this topic, stored with the article because Search Console is a rolling window and search results move every week: these numbers could not be recomputed later, and they DO NOT UPDATE. results is the live measure.",
            "properties": {
              "search_console": {
                "type": [
                  "object",
                  "null"
                ],
                "description": "Google's numbers for this search on your site over the 28 days before the topic was chosen. NULL when the topic did not come from Search Console, which means these were never measured, NOT that they were zero.",
                "properties": {
                  "impressions": {
                    "type": "integer",
                    "description": "Times Google showed your site for this search."
                  },
                  "clicks": {
                    "type": "integer",
                    "description": "Clicks from those impressions."
                  },
                  "average_position": {
                    "type": [
                      "number",
                      "null"
                    ],
                    "description": "Google's average position for your site on this search."
                  },
                  "your_pages_competing": {
                    "type": "integer",
                    "description": "How many of your own pages took impressions for this search. Above 1 means the site discusses it in several places and owns it in none, which the article is meant to fix."
                  }
                },
                "required": [
                  "impressions",
                  "clicks",
                  "average_position",
                  "your_pages_competing"
                ]
              },
              "search_volume": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "Searches a month in your market, from keyword research. Null when unknown, never zero."
              },
              "keyword_difficulty": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "0 to 100, from keyword research. Null when unknown."
              },
              "existing_page": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Your closest page, which ranks poorly for this search. Null when nothing of yours showed for it."
              },
              "estimated_position": {
                "type": [
                  "number",
                  "null"
                ],
                "description": "The keyword database's estimate of where existing_page ranked when the topic was chosen. An ESTIMATE, not a rank check and not your live position. Null when unknown."
              },
              "competitor": {
                "type": [
                  "object",
                  "null"
                ],
                "description": "For a competitor gap: whose page ranks for this search when yours does not. That page is the one to beat. Null for other sources.",
                "properties": {
                  "domain": {
                    "type": "string",
                    "description": "The competitor's domain."
                  },
                  "url": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Their ranking page."
                  },
                  "position": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "description": "Their position for this search."
                  }
                },
                "required": [
                  "domain",
                  "url",
                  "position"
                ]
              }
            },
            "required": [
              "search_console",
              "search_volume",
              "keyword_difficulty",
              "existing_page",
              "estimated_position",
              "competitor"
            ]
          },
          "pages_to_beat": {
            "type": "array",
            "description": "The search results when the article was written, in order: the pages it has to beat. Kept as they were then. Empty until the article is written.",
            "items": {
              "type": "object",
              "description": "One result.",
              "properties": {
                "rank": {
                  "type": "integer",
                  "description": "Its position when the article was written."
                },
                "domain": {
                  "type": "string",
                  "description": "Its host."
                },
                "title": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "Its title."
                },
                "url": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "The page."
                }
              },
              "required": [
                "rank",
                "domain",
                "title",
                "url"
              ]
            }
          },
          "seo_title": {
            "type": [
              "string",
              "null"
            ],
            "description": "The title for Google's result, when it differs from the headline. Null until written."
          },
          "category": {
            "type": [
              "string",
              "null"
            ],
            "description": "One of the site's own categories, by name. Null when none fitted."
          },
          "tags": {
            "type": "array",
            "description": "Tag names. Empty until written.",
            "items": {
              "type": "string",
              "description": "A tag."
            }
          },
          "image": {
            "type": [
              "object",
              "null"
            ],
            "description": "The hero image on RankHound's CDN. Null when there is none (not made yet, failed, or images are off for the site).",
            "properties": {
              "url": {
                "type": "string",
                "description": "The article-size image, WebP."
              },
              "social_url": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "The social-card size (1200 x 630), WebP."
              },
              "alt": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Alt text."
              }
            },
            "required": [
              "url",
              "social_url",
              "alt"
            ]
          },
          "google": {
            "type": "object",
            "description": "What Google's URL Inspection says about the published page, checked daily until it is indexed.",
            "properties": {
              "index_state": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Google's own words, for example \"Submitted and indexed\" or \"Discovered - currently not indexed\". Null until first checked."
              },
              "checked_at": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "When it was last checked, ISO 8601."
              },
              "indexed_at": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "When it was first seen indexed, ISO 8601. Null until indexed."
              }
            },
            "required": [
              "index_state",
              "checked_at",
              "indexed_at"
            ]
          }
        },
        "required": [
          "id",
          "keyword",
          "intent",
          "source",
          "status",
          "status_reason",
          "title",
          "why",
          "created_at",
          "scheduled_for",
          "published_at",
          "published_url",
          "results",
          "slug",
          "meta_description",
          "body_markdown",
          "evidence",
          "pages_to_beat",
          "seo_title",
          "category",
          "tags",
          "image",
          "google"
        ]
      },
      "KeywordTarget": {
        "type": "object",
        "description": "One keyword the agent ranks every week and writes for. Ranks are checked WEEKLY on Google, through the top 100 results, never live.",
        "properties": {
          "keyword": {
            "type": "string",
            "description": "The search, lowercased."
          },
          "source": {
            "type": "string",
            "enum": [
              "ranked",
              "competitor_gap",
              "idea",
              "search_console",
              "manual"
            ],
            "description": "Where the agent found it: you already rank for it, a competitor ranks and you do not, related to what you rank for, Google already shows you for it in Search Console, or you added it."
          },
          "competitor": {
            "type": [
              "object",
              "null"
            ],
            "description": "For a competitor gap: whose page ranks for it, where, and which page. Null for other sources.",
            "properties": {
              "domain": {
                "type": "string",
                "description": "The competitor's domain."
              },
              "position": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "Their position for it."
              },
              "url": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Their ranking page."
              }
            },
            "required": [
              "domain",
              "position",
              "url"
            ]
          },
          "targeted_by": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "agent",
              "customer",
              null
            ],
            "description": "Who made it a target: the agent, or you."
          },
          "search_volume": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Searches a month in your market. Null when unknown, never zero."
          },
          "keyword_difficulty": {
            "type": [
              "integer",
              "null"
            ],
            "description": "0 to 100. Null when unknown."
          },
          "search_intent": {
            "type": [
              "string",
              "null"
            ],
            "description": "What the searcher wants, for example commercial or informational. Null when unknown."
          },
          "rank": {
            "type": "object",
            "description": "Where your site ranks on Google for it, from the weekly check.",
            "properties": {
              "latest": {
                "type": [
                  "object",
                  "null"
                ],
                "description": "The most recent completed check. NULL WHEN THE KEYWORD HAS NEVER BEEN CHECKED, so there is no position to report at all.",
                "properties": {
                  "week_start": {
                    "type": "string",
                    "description": "Monday (UTC) of the week of that check, YYYY-MM-DD. Not always this week: see this_week_check."
                  },
                  "position": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "description": "Your best organic position in that check. NULL MEANS CHECKED AND NOT IN THE TOP 100: a real result, not missing data, and never position 0."
                  },
                  "url": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Your page that ranked. Null when none did."
                  }
                },
                "required": [
                  "week_start",
                  "position",
                  "url"
                ]
              },
              "previous_week": {
                "type": [
                  "object",
                  "null"
                ],
                "description": "The completed check from the week before latest. Null when there was none, so there is no movement to show.",
                "properties": {
                  "position": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "description": "Your position that week. Null means not in the top 100."
                  }
                },
                "required": [
                  "position"
                ]
              },
              "change": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "Places gained (positive) or lost (negative) since previous_week. Null unless you ranked in both weeks."
              },
              "movement": {
                "type": [
                  "string",
                  "null"
                ],
                "enum": [
                  "up",
                  "down",
                  "same",
                  "entered",
                  "dropped",
                  null
                ],
                "description": "Against previous_week. entered: into the top 100. dropped: out of it. same with both positions null: outside the top 100 both weeks. Null when there is no previous_week to compare with."
              },
              "this_week_check": {
                "type": [
                  "string",
                  "null"
                ],
                "enum": [
                  "checking",
                  "done",
                  "failed",
                  null
                ],
                "description": "This calendar week's check: checking (sent to Google, results arrive over the next few hours), done, or failed (retried next week). Null when none is scheduled this week yet."
              },
              "has_ai_overview": {
                "type": [
                  "boolean",
                  "null"
                ],
                "description": "Whether Google shows an AI Overview for this search: from the latest check, or from keyword research before the first one. Null when unknown."
              },
              "top_results": {
                "type": "array",
                "description": "Who ranks today: the top three organic results in the latest check, with your own site marked. Empty until a check has completed.",
                "items": {
                  "type": "object",
                  "description": "One organic result from the same weekly check.",
                  "properties": {
                    "position": {
                      "type": "integer",
                      "description": "Its position on Google."
                    },
                    "domain": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "Its host, without www. Null when the result carried no usable domain."
                    },
                    "url": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "The ranking page."
                    },
                    "title": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "Its title, as Google showed it."
                    },
                    "is_you": {
                      "type": "boolean",
                      "description": "True when this result is your own site."
                    }
                  },
                  "required": [
                    "position",
                    "domain",
                    "url",
                    "title",
                    "is_you"
                  ]
                }
              }
            },
            "required": [
              "latest",
              "previous_week",
              "change",
              "movement",
              "this_week_check",
              "has_ai_overview",
              "top_results"
            ]
          },
          "search_console": {
            "type": [
              "object",
              "null"
            ],
            "description": "Your own Search Console for this exact search over meta.search_console_window_days. NULL when Search Console reported nothing for it in the window, or is not connected (meta.search_console_connected). Never zero.",
            "properties": {
              "clicks": {
                "type": "integer",
                "description": "Clicks from Google search."
              },
              "impressions": {
                "type": "integer",
                "description": "Times Google showed your site for it."
              }
            },
            "required": [
              "clicks",
              "impressions"
            ]
          }
        },
        "required": [
          "keyword",
          "source",
          "competitor",
          "targeted_by",
          "search_volume",
          "keyword_difficulty",
          "search_intent",
          "rank",
          "search_console"
        ]
      },
      "CompetitorGaps": {
        "type": "object",
        "description": "One tracked competitor: what they rank for that you do not, and what they started ranking with this week.",
        "properties": {
          "competitor": {
            "type": "string",
            "description": "The competitor's name as tracked in this brand."
          },
          "domain": {
            "type": [
              "string",
              "null"
            ],
            "description": "Their domain. Null when none was given, which also means they cannot be analysed."
          },
          "analysed": {
            "type": "boolean",
            "description": "CHECK THIS FIRST. True when the weekly research looks at this competitor. False means it does not (outside the plan's meta.analysed_limit, or no domain), so an empty gap list here means NOT LOOKED AT, never no gaps."
          },
          "last_analysed_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When their keywords or pages were last refreshed. Null when never."
          },
          "gap_count": {
            "type": "integer",
            "description": "Keywords they rank for that your site does not, leaving out ones you dismissed and ones the agent set aside. The denominator for gaps."
          },
          "set_aside_count": {
            "type": "integer",
            "description": "Gap keywords the agent set aside as not about your business, not a real search, or naming a competitor or you. Left out of gap_count and gaps; the agent never writes for them on its own."
          },
          "gaps": {
            "type": "array",
            "description": "The best of them, up to meta.gaps_per_competitor, most winnable for your site first.",
            "items": {
              "type": "object",
              "description": "A keyword they rank for and you do not.",
              "properties": {
                "keyword": {
                  "type": "string",
                  "description": "The search."
                },
                "search_volume": {
                  "type": [
                    "integer",
                    "null"
                  ],
                  "description": "Searches a month in your market. Null when unknown, never zero."
                },
                "keyword_difficulty": {
                  "type": [
                    "integer",
                    "null"
                  ],
                  "description": "0 to 100. Null when unknown."
                },
                "their_position": {
                  "type": [
                    "integer",
                    "null"
                  ],
                  "description": "Their position for it."
                },
                "their_page": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "Their ranking page: what an article has to beat."
                },
                "targeted": {
                  "type": "boolean",
                  "description": "True when it is one of your target keywords, so it is rank-checked weekly."
                }
              },
              "required": [
                "keyword",
                "search_volume",
                "keyword_difficulty",
                "their_position",
                "their_page",
                "targeted"
              ]
            }
          },
          "new_page_count": {
            "type": "integer",
            "description": "Pages of theirs first seen ranking in the last 7 days. The denominator for new_pages."
          },
          "new_pages": {
            "type": "array",
            "description": "Up to meta.new_pages_per_competitor of them, best ranking first. Empty in the first week a competitor is analysed, which is a baseline: everything is first seen that week and none of it is news.",
            "items": {
              "type": "object",
              "description": "A page they started ranking with.",
              "properties": {
                "url": {
                  "type": "string",
                  "description": "The page."
                },
                "title": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "Its title."
                },
                "best_keyword": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "The keyword it ranks best for, of those seen."
                },
                "best_position": {
                  "type": [
                    "integer",
                    "null"
                  ],
                  "description": "Its position for that keyword."
                },
                "first_seen_at": {
                  "type": "string",
                  "format": "date-time",
                  "description": "When it was first seen ranking."
                }
              },
              "required": [
                "url",
                "title",
                "best_keyword",
                "best_position",
                "first_seen_at"
              ]
            }
          }
        },
        "required": [
          "competitor",
          "domain",
          "analysed",
          "last_analysed_at",
          "gap_count",
          "set_aside_count",
          "gaps",
          "new_page_count",
          "new_pages"
        ]
      },
      "SuggestedCompetitor": {
        "type": "object",
        "description": "A domain that ranks for many of the same keywords as your site and is not tracked yet. Adding it is done in RankHound.",
        "properties": {
          "domain": {
            "type": "string",
            "description": "Their domain."
          },
          "shared_keywords": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Keywords both sites rank for. Null when unknown."
          },
          "average_position": {
            "type": [
              "number",
              "null"
            ],
            "description": "Their mean position across those keywords. Null when unknown."
          }
        },
        "required": [
          "domain",
          "shared_keywords",
          "average_position"
        ]
      },
      "AgentWeek": {
        "type": "object",
        "description": "What the SEO agent has done this week, what is waiting on you, and whether it is set up to publish.",
        "properties": {
          "week_start": {
            "type": "string",
            "description": "Monday (UTC) of the current week, YYYY-MM-DD."
          },
          "status": {
            "type": "string",
            "enum": [
              "not_started",
              "running",
              "done",
              "failed"
            ],
            "description": "not_started: no run has opened for this week yet. running: the agent is working through the steps. done: every step is recorded. failed: the run gave up after retrying, and error says why."
          },
          "skipped": {
            "type": "boolean",
            "description": "True when the week closed without running because the plan is not active."
          },
          "error": {
            "type": [
              "string",
              "null"
            ],
            "description": "Why the run stopped, when status is failed. Null otherwise."
          },
          "steps": {
            "type": "object",
            "description": "Each step the agent records as it finishes, in order. A step is null until it is reached.",
            "properties": {
              "research": {
                "type": [
                  "object",
                  "null"
                ],
                "description": "Researched your keywords and competitors.",
                "properties": {
                  "at": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date-time",
                    "description": "When it finished."
                  },
                  "keywords_looked_at": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "description": "Keywords the research considered. Null when it recorded no count."
                  },
                  "competitors_analysed": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "description": "Competitors whose keywords were analysed."
                  },
                  "new_competitor_pages": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "description": "Competitor pages first seen ranking this week, baselines excluded."
                  },
                  "baseline_competitor_pages": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "description": "Pages recorded the first week a competitor was analysed. Every page is first seen that week, so none of these is news."
                  }
                },
                "required": [
                  "at",
                  "keywords_looked_at",
                  "competitors_analysed",
                  "new_competitor_pages",
                  "baseline_competitor_pages"
                ]
              },
              "relevance": {
                "type": [
                  "object",
                  "null"
                ],
                "description": "Judged which researched keywords are about your business. Only those can become targets or articles.",
                "properties": {
                  "at": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date-time",
                    "description": "When it finished."
                  },
                  "judged": {
                    "type": "integer",
                    "description": "Keywords judged this week."
                  },
                  "relevant": {
                    "type": "integer",
                    "description": "Of those, judged about your business."
                  },
                  "set_aside": {
                    "type": "integer",
                    "description": "Of those, set aside: about somebody else's business, not a real search, or naming a competitor or you."
                  },
                  "targets_dropped": {
                    "type": "integer",
                    "description": "Targets the agent had chosen that it dropped as not relevant. Targets you chose are never dropped."
                  }
                },
                "required": [
                  "at",
                  "judged",
                  "relevant",
                  "set_aside",
                  "targets_dropped"
                ]
              },
              "targets": {
                "type": [
                  "object",
                  "null"
                ],
                "description": "Chose which keywords to measure.",
                "properties": {
                  "at": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date-time",
                    "description": "When it finished."
                  },
                  "added": {
                    "type": "integer",
                    "description": "Keywords the agent made targets this week."
                  }
                },
                "required": [
                  "at",
                  "added"
                ]
              },
              "rank_check": {
                "type": [
                  "object",
                  "null"
                ],
                "description": "Checked your Google rankings for this week.",
                "properties": {
                  "at": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date-time",
                    "description": "When the checks were sent."
                  },
                  "keywords_sent": {
                    "type": "integer",
                    "description": "Keywords sent to Google for this week's check."
                  },
                  "measured": {
                    "type": "integer",
                    "description": "Checks completed so far this week."
                  },
                  "in_top_100": {
                    "type": "integer",
                    "description": "Measured keywords where your site is in the top 100."
                  },
                  "on_page_one": {
                    "type": "integer",
                    "description": "Measured keywords where your site is in the top 10."
                  },
                  "still_checking": {
                    "type": "integer",
                    "description": "Checks whose results have not arrived yet. They arrive over the next few hours."
                  },
                  "failed": {
                    "type": "integer",
                    "description": "Checks that failed. Retried next week."
                  }
                },
                "required": [
                  "at",
                  "keywords_sent",
                  "measured",
                  "in_top_100",
                  "on_page_one",
                  "still_checking",
                  "failed"
                ]
              },
              "topics": {
                "type": [
                  "object",
                  "null"
                ],
                "description": "Chose what to write.",
                "properties": {
                  "at": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "format": "date-time",
                    "description": "When it finished."
                  },
                  "chosen": {
                    "type": "array",
                    "description": "The searches chosen this week.",
                    "items": {
                      "type": "string"
                    }
                  },
                  "note": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "enum": [
                      "paused",
                      "allowance_used",
                      "no_candidates",
                      null
                    ],
                    "description": "Why nothing was chosen, when nothing was. paused: you paused writing. allowance_used: this month's articles are all written. no_candidates: nothing worth writing that would not compete with a page you already have."
                  }
                },
                "required": [
                  "at",
                  "chosen",
                  "note"
                ]
              }
            },
            "required": [
              "research",
              "relevance",
              "targets",
              "rank_check",
              "topics"
            ]
          },
          "articles": {
            "type": "object",
            "description": "Articles, by what they need from you.",
            "properties": {
              "this_week": {
                "type": "object",
                "description": "The articles chosen THIS WEEK only.",
                "properties": {
                  "being_written": {
                    "type": "integer",
                    "description": "Being written now."
                  },
                  "waiting_for_approval": {
                    "type": "integer",
                    "description": "Written and waiting for you."
                  },
                  "scheduled": {
                    "type": "integer",
                    "description": "Approved and waiting for a publishing slot, or going out now."
                  },
                  "published": {
                    "type": "integer",
                    "description": "Published."
                  }
                },
                "required": [
                  "being_written",
                  "waiting_for_approval",
                  "scheduled",
                  "published"
                ]
              },
              "waiting_for_approval_all_weeks": {
                "type": "integer",
                "description": "Every article waiting for your approval, from any week. Read this, not this_week, before saying nothing needs approval."
              }
            },
            "required": [
              "this_week",
              "waiting_for_approval_all_weeks"
            ]
          },
          "allowance": {
            "type": "object",
            "description": "Articles per calendar month, counted across the whole organisation rather than per brand. An article counts once it is being written; a rejected one still counts, a skipped one does not.",
            "properties": {
              "articles_per_month": {
                "type": "integer",
                "description": "The plan's monthly allowance."
              },
              "used": {
                "type": "integer",
                "description": "Counted so far this month."
              },
              "remaining": {
                "type": "integer",
                "description": "Zero means the agent writes no more until the 1st."
              }
            },
            "required": [
              "articles_per_month",
              "used",
              "remaining"
            ]
          },
          "setup": {
            "type": "object",
            "description": "Whether the agent can do its whole job.",
            "properties": {
              "search_console_connected": {
                "type": [
                  "boolean",
                  "null"
                ],
                "description": "False: not connected, so the agent chooses topics without seeing what Google already shows you for, and cannot measure its own articles. NULL: this deployment does not offer Search Console, so there is nothing to connect."
              },
              "publishing_destination": {
                "type": "boolean",
                "description": "True when an active publishing webhook exists. False means approved articles have nowhere to go and wait."
              },
              "auto_publish": {
                "type": "boolean",
                "description": "True when written articles are scheduled without waiting for your approval. Off by default."
              },
              "paused": {
                "type": "boolean",
                "description": "True when you paused writing and publishing. Research and rank checks continue."
              }
            },
            "required": [
              "search_console_connected",
              "publishing_destination",
              "auto_publish",
              "paused"
            ]
          },
          "latest_summary": {
            "type": [
              "object",
              "null"
            ],
            "description": "What the most recent weekly email told you. Null until one has been written. A week closed because the plan was not active sends no email and is not included.",
            "properties": {
              "week_start": {
                "type": "string",
                "description": "The week it describes, YYYY-MM-DD."
              },
              "written_at": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "date-time",
                "description": "When the summary was written for sending. Delivery is not confirmed by this field."
              },
              "topics_note": {
                "type": [
                  "string",
                  "null"
                ],
                "enum": [
                  "paused",
                  "allowance_used",
                  "no_candidates",
                  null
                ],
                "description": "Why nothing was written that week, when nothing was. Same values as steps.topics.note."
              },
              "awaiting_approval": {
                "type": "array",
                "description": "Articles that were waiting for your approval.",
                "items": {
                  "type": "object",
                  "description": "An article.",
                  "properties": {
                    "id": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "Its art_ id."
                    },
                    "title": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "Its title."
                    }
                  },
                  "required": [
                    "id",
                    "title"
                  ]
                }
              },
              "scheduled": {
                "type": "array",
                "description": "Articles that were scheduled.",
                "items": {
                  "type": "object",
                  "description": "An article.",
                  "properties": {
                    "title": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "Its title."
                    },
                    "scheduled_for": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "format": "date-time",
                      "description": "Its publishing slot."
                    }
                  },
                  "required": [
                    "title",
                    "scheduled_for"
                  ]
                }
              },
              "published": {
                "type": "array",
                "description": "Articles published in the 7 days before the email.",
                "items": {
                  "type": "object",
                  "description": "An article.",
                  "properties": {
                    "title": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "Its title."
                    },
                    "url": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "Where it went live."
                    }
                  },
                  "required": [
                    "title",
                    "url"
                  ]
                }
              },
              "failed": {
                "type": "integer",
                "description": "Articles that could not be written or published that week."
              },
              "keywords_measured": {
                "type": "integer",
                "description": "Rank checks completed that week."
              },
              "first_rank_week": {
                "type": "boolean",
                "description": "True when that week was the first with rankings, so there was no movement to report yet."
              },
              "movers": {
                "type": "array",
                "description": "The biggest rank moves against the week before.",
                "items": {
                  "type": "object",
                  "description": "A move.",
                  "properties": {
                    "keyword": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "The search."
                    },
                    "from": {
                      "type": [
                        "integer",
                        "null"
                      ],
                      "description": "Position the week before. Null means not in the top 100."
                    },
                    "to": {
                      "type": [
                        "integer",
                        "null"
                      ],
                      "description": "Position that week. Null means not in the top 100."
                    }
                  },
                  "required": [
                    "keyword",
                    "from",
                    "to"
                  ]
                }
              },
              "competitor_pages": {
                "type": "array",
                "description": "Competitor pages first seen ranking that week.",
                "items": {
                  "type": "object",
                  "description": "A page.",
                  "properties": {
                    "domain": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "The competitor."
                    },
                    "url": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "The page."
                    },
                    "position": {
                      "type": [
                        "integer",
                        "null"
                      ],
                      "description": "Its best position seen."
                    }
                  },
                  "required": [
                    "domain",
                    "url",
                    "position"
                  ]
                }
              },
              "search_console_connected": {
                "type": "boolean",
                "description": "Whether Search Console was connected when the summary was written."
              },
              "article_clicks": {
                "type": [
                  "integer",
                  "null"
                ],
                "description": "Clicks Google sent to the agent's published articles over the 28 days before, from your own Search Console. NULL when it was not connected or had nothing to report, never zero."
              }
            },
            "required": [
              "week_start",
              "written_at",
              "topics_note",
              "awaiting_approval",
              "scheduled",
              "published",
              "failed",
              "keywords_measured",
              "first_rank_week",
              "movers",
              "competitor_pages",
              "search_console_connected",
              "article_clicks"
            ]
          }
        },
        "required": [
          "week_start",
          "status",
          "skipped",
          "error",
          "steps",
          "articles",
          "allowance",
          "setup",
          "latest_summary"
        ]
      }
    }
  },
  "paths": {
    "/me": {
      "get": {
        "summary": "Who this key is, and the rules for reading the numbers",
        "description": "Call this first. The reporting_rules block is not advisory: it carries the sample floor and the live alert thresholds, which is the only way a client can tell 'no alerts' from 'nothing changed'.",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Me"
                    },
                    "meta": {
                      "type": "object",
                      "properties": {
                        "generated_at": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "brand": {
                          "$ref": "#/components/schemas/BrandRef"
                        },
                        "note": {
                          "type": "string"
                        }
                      }
                    }
                  },
                  "required": [
                    "data",
                    "meta"
                  ]
                },
                "example": {
                  "data": {
                    "organization": {
                      "name": "Acme Inc"
                    },
                    "plan": {
                      "name": "seo_growth",
                      "status": "active",
                      "trial_ends_at": null,
                      "prompts": {
                        "used": 7,
                        "included": 9
                      },
                      "brands": 2
                    },
                    "key": {
                      "scopes": [
                        "read"
                      ],
                      "available_scopes": [
                        "read",
                        "prompts:write",
                        "brands:write"
                      ],
                      "pinned_to_brand": false
                    },
                    "brand": {
                      "id": "wsp_zD6cssxGlJyF",
                      "name": "Acme"
                    },
                    "rate_limit": {
                      "per_minute": 300,
                      "remaining": 299,
                      "resets_in_seconds": 41
                    },
                    "reporting_rules": {
                      "min_sample": 5,
                      "rate_null_means": "not enough answers yet — NOT zero",
                      "engines_averaged": false,
                      "alerts": {
                        "enabled": true,
                        "kinds": "all",
                        "min_delta_points": 15,
                        "min_sample_each_side": 10,
                        "also_suppressed_below_statistical_noise": true,
                        "empty_list_means": "no change large enough to distinguish from noise at this sample size"
                      }
                    }
                  },
                  "meta": {
                    "generated_at": "2026-09-30T09:00:00.000Z"
                  }
                }
              }
            }
          },
          "400": {
            "description": "brand_required, or a cursor that does not belong to this brand.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing, malformed, revoked or expired key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "The organisation has never subscribed, or the plan excludes the API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "The key lacks the required scope.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such brand, or the brand has not finished setup.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Carries Retry-After.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/brands": {
      "get": {
        "summary": "Every brand this key may read",
        "description": "The ids to pass as ?brand=. A pinned key sees only its own.",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Brand"
                      }
                    },
                    "meta": {
                      "type": "object",
                      "properties": {
                        "generated_at": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "brand": {
                          "$ref": "#/components/schemas/BrandRef"
                        },
                        "note": {
                          "type": "string"
                        },
                        "pinned": {
                          "type": "boolean",
                          "description": "True when this key is bound to one brand."
                        }
                      }
                    }
                  },
                  "required": [
                    "data",
                    "meta"
                  ]
                },
                "example": {
                  "data": [
                    {
                      "id": "wsp_zD6cssxGlJyF",
                      "name": "Acme",
                      "domain": "acme.com",
                      "configured": true,
                      "active_prompts": 5,
                      "market": {
                        "location_code": 2840,
                        "language_code": "en"
                      },
                      "last_collected_at": "2026-09-30T04:12:00.000Z"
                    },
                    {
                      "id": "wsp_094oxa5e",
                      "name": "Acme Labs",
                      "domain": null,
                      "configured": false,
                      "active_prompts": 0,
                      "market": {
                        "location_code": 2840,
                        "language_code": "en"
                      },
                      "last_collected_at": null
                    }
                  ],
                  "meta": {
                    "generated_at": "2026-09-30T09:00:00.000Z",
                    "pinned": false,
                    "note": "Pass one of these ids as ?brand= on every other endpoint."
                  }
                }
              }
            }
          },
          "400": {
            "description": "brand_required, or a cursor that does not belong to this brand.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing, malformed, revoked or expired key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "The organisation has never subscribed, or the plan excludes the API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "The key lacks the required scope.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such brand, or the brand has not finished setup.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Carries Retry-After.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/agent": {
      "get": {
        "summary": "This week: what the SEO agent did, and what is waiting on you",
        "description": "Call this first for \"what has my SEO agent been doing?\". The steps it has reached this week, the week's articles by what they need from you, the month's article allowance, and whether Search Console and a publishing destination are set up.\n\n`articles.this_week` COUNTS ONLY THIS WEEK'S TOPICS. Read `waiting_for_approval_all_weeks` before telling anyone nothing needs approval: an article written last week and still unapproved is not in this week's number.\n\nRead-only. Approving, publishing and starting a run happen in RankHound, by a person; articles publish without approval only when `setup.auto_publish` is true.",
        "parameters": [
          {
            "name": "brand",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Which brand to report on, from GET /brands. REQUIRED when the organisation has more than one: omitting it returns 400 brand_required rather than defaulting, because a silent default files one customer's numbers under another's. Ignored by a key pinned to a single brand, which refuses any other."
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/AgentWeek"
                    },
                    "meta": {
                      "type": "object",
                      "properties": {
                        "generated_at": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "brand": {
                          "$ref": "#/components/schemas/BrandRef"
                        },
                        "note": {
                          "type": "string"
                        }
                      }
                    }
                  },
                  "required": [
                    "data",
                    "meta"
                  ]
                },
                "example": {
                  "data": {
                    "week_start": "2026-10-19",
                    "status": "done",
                    "skipped": false,
                    "error": null,
                    "steps": {
                      "research": {
                        "at": "2026-10-19T06:04:12.000Z",
                        "keywords_looked_at": 412,
                        "competitors_analysed": 3,
                        "new_competitor_pages": 7,
                        "baseline_competitor_pages": 0
                      },
                      "relevance": {
                        "at": "2026-10-19T06:04:31.000Z",
                        "judged": 148,
                        "relevant": 111,
                        "set_aside": 37,
                        "targets_dropped": 0
                      },
                      "targets": {
                        "at": "2026-10-19T06:04:33.000Z",
                        "added": 12
                      },
                      "rank_check": {
                        "at": "2026-10-19T06:04:40.000Z",
                        "keywords_sent": 100,
                        "measured": 100,
                        "in_top_100": 41,
                        "on_page_one": 9,
                        "still_checking": 0,
                        "failed": 0
                      },
                      "topics": {
                        "at": "2026-10-19T06:05:02.000Z",
                        "chosen": [
                          "crm for small agencies"
                        ],
                        "note": null
                      }
                    },
                    "articles": {
                      "this_week": {
                        "being_written": 0,
                        "waiting_for_approval": 1,
                        "scheduled": 0,
                        "published": 0
                      },
                      "waiting_for_approval_all_weeks": 2
                    },
                    "allowance": {
                      "articles_per_month": 24,
                      "used": 7,
                      "remaining": 17
                    },
                    "setup": {
                      "search_console_connected": true,
                      "publishing_destination": true,
                      "auto_publish": false,
                      "paused": false
                    },
                    "latest_summary": {
                      "week_start": "2026-10-12",
                      "written_at": "2026-10-12T09:41:00.000Z",
                      "topics_note": null,
                      "awaiting_approval": [
                        {
                          "id": "art_Q4m8Zr2Kd7Lw",
                          "title": "How agencies price retainers"
                        }
                      ],
                      "scheduled": [],
                      "published": [
                        {
                          "title": "Client onboarding checklist for agencies",
                          "url": "https://acme.com/blog/client-onboarding-checklist"
                        }
                      ],
                      "failed": 0,
                      "keywords_measured": 96,
                      "first_rank_week": false,
                      "movers": [
                        {
                          "keyword": "agency crm",
                          "from": 18,
                          "to": 9
                        },
                        {
                          "keyword": "crm for consultants",
                          "from": null,
                          "to": 64
                        }
                      ],
                      "competitor_pages": [
                        {
                          "domain": "rival.com",
                          "url": "https://rival.com/blog/agency-crm",
                          "position": 3
                        }
                      ],
                      "search_console_connected": true,
                      "article_clicks": 37
                    }
                  },
                  "meta": {
                    "generated_at": "2026-10-23T09:00:00.000Z",
                    "brand": {
                      "id": "wsp_zD6cssxGlJyF",
                      "name": "Acme"
                    },
                    "note": "week_start is Monday (UTC) of the current week. A step is null until the agent reaches it; status not_started means no run has opened for this week yet. articles.this_week counts only the topics chosen this week: use waiting_for_approval_all_weeks before saying nothing needs approval. Articles publish only once a person approves them in RankHound, unless setup.auto_publish is true; this API cannot approve, publish or start anything. setup.paused true means the customer stopped writing and publishing, while research and rank checks continue. The allowance is articles per calendar month, counted across the whole organisation; an article counts once it is being written, and a rejected one still counts. setup.publishing_destination false means approved articles have nowhere to go and wait. setup.search_console_connected null means this deployment does not offer Search Console, so do not ask the customer to connect it. latest_summary is what the last weekly email told the customer; its article_clicks is null when Search Console had nothing to report, never zero."
                  }
                }
              }
            }
          },
          "400": {
            "description": "brand_required, or a cursor that does not belong to this brand.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing, malformed, revoked or expired key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "The organisation has never subscribed, or the plan excludes the API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "The key lacks the required scope.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such brand, or the brand has not finished setup.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Carries Retry-After.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/articles": {
      "get": {
        "summary": "The articles the SEO agent chose, wrote and published, and why",
        "description": "Newest first, each with the reason the agent chose it (`why`). Filter with `status`; keyset pagination, so pass meta.next_cursor as ?cursor=.\n\n`results` IS NULL UNTIL THERE IS SOMETHING TO MEASURE, never 0. It is clicks and impressions from the customer's own Search Console, and an article that is not published, has no URL, or that Google has not reported yet has no number at all. Reporting null as 0 clicks tells a customer an article failed that nobody has measured.\n\nAn unknown `status` is refused with 400 unknown_status rather than ignored: a misspelt filter that quietly returned everything would read as every article waiting for approval.",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "writing",
                "waiting",
                "scheduled",
                "published",
                "rejected",
                "skipped",
                "failed",
                "all"
              ],
              "default": "all"
            },
            "description": "Only articles in this status. waiting is the one that needs the customer."
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "An article id from a previous page's meta.next_cursor."
          },
          {
            "name": "brand",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Which brand to report on, from GET /brands. REQUIRED when the organisation has more than one: omitting it returns 400 brand_required rather than defaulting, because a silent default files one customer's numbers under another's. Ignored by a key pinned to a single brand, which refuses any other."
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Article"
                      }
                    },
                    "meta": {
                      "type": "object",
                      "properties": {
                        "generated_at": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "brand": {
                          "$ref": "#/components/schemas/BrandRef"
                        },
                        "note": {
                          "type": "string"
                        },
                        "status": {
                          "type": "string",
                          "description": "The status filter applied, all when none was given."
                        },
                        "next_cursor": {
                          "type": [
                            "string",
                            "null"
                          ],
                          "description": "Pass as ?cursor=. Null on the last page."
                        },
                        "results_window_days": {
                          "type": "integer",
                          "description": "The Search Console window behind every results figure."
                        }
                      }
                    }
                  },
                  "required": [
                    "data",
                    "meta"
                  ]
                },
                "example": {
                  "data": [
                    {
                      "id": "art_8fK2mQ9xLp3T",
                      "keyword": "crm for small agencies",
                      "intent": "new",
                      "source": "search_console",
                      "status": "waiting",
                      "status_reason": null,
                      "title": "The CRM a 10-person agency actually needs",
                      "why": "Google showed you for this 1,840 times in the last 28 days, and the best page you have ranks 38.",
                      "created_at": "2026-10-19T06:05:02.000Z",
                      "scheduled_for": null,
                      "published_at": null,
                      "published_url": null,
                      "results": null
                    },
                    {
                      "id": "art_3Vb7nR1cWq8Z",
                      "keyword": "agency client onboarding checklist",
                      "intent": "new",
                      "source": "keyword",
                      "status": "published",
                      "status_reason": null,
                      "title": "Client onboarding checklist for agencies",
                      "why": "Related to what you already rank for, and winnable for your site. About 880 searches a month, difficulty 22.",
                      "created_at": "2026-10-05T06:10:00.000Z",
                      "scheduled_for": "2026-10-07T09:00:00.000Z",
                      "published_at": "2026-10-07T09:00:41.000Z",
                      "published_url": "https://acme.com/blog/client-onboarding-checklist",
                      "results": {
                        "clicks": 37,
                        "impressions": 2210
                      }
                    }
                  ],
                  "meta": {
                    "generated_at": "2026-10-23T09:00:00.000Z",
                    "brand": {
                      "id": "wsp_zD6cssxGlJyF",
                      "name": "Acme"
                    },
                    "status": "all",
                    "next_cursor": null,
                    "results_window_days": 28,
                    "note": "Newest first. status waiting means written and waiting for the customer's approval, which happens in RankHound (or automatically when auto-publish is on: GET /agent, setup.auto_publish). This API cannot approve, reject or publish. results is clicks and impressions from the customer's own Search Console over results_window_days, and is null until there is something to measure (not published, no URL, Search Console not connected, or not yet reported by Google). Null is not zero. Pass next_cursor as ?cursor= for the following page."
                  }
                }
              }
            }
          },
          "400": {
            "description": "brand_required, unknown_status, or a cursor that does not belong to this brand.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing, malformed, revoked or expired key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "The organisation has never subscribed, or the plan excludes the API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "The key lacks the required scope.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such brand, or the brand has not finished setup.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Carries Retry-After.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/articles/{id}": {
      "get": {
        "summary": "One article in full, with the evidence it was chosen on",
        "description": "The text (`body_markdown`), title, slug and meta description; the numbers the agent saw when it chose the topic (`evidence`); and the search results it was written against (`pages_to_beat`).\n\n`evidence` IS A SNAPSHOT FROM WHEN THE TOPIC WAS CHOSEN and does not update: Search Console is a rolling window and search results move every week, so the numbers that justified an article are stored with it. `evidence.search_console` is null when the topic did not come from Search Console, which means never measured, not zero. `results` is the live measure.\n\nAn id that belongs to another brand, in this organisation or any other, is 404 no_such_article, never 403.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "An article id, art_<12>, from GET /articles."
          },
          {
            "name": "brand",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Which brand to report on, from GET /brands. REQUIRED when the organisation has more than one: omitting it returns 400 brand_required rather than defaulting, because a silent default files one customer's numbers under another's. Ignored by a key pinned to a single brand, which refuses any other."
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/ArticleDetail"
                    },
                    "meta": {
                      "type": "object",
                      "properties": {
                        "generated_at": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "brand": {
                          "$ref": "#/components/schemas/BrandRef"
                        },
                        "note": {
                          "type": "string"
                        },
                        "results_window_days": {
                          "type": "integer",
                          "description": "The Search Console window behind results."
                        }
                      }
                    }
                  },
                  "required": [
                    "data",
                    "meta"
                  ]
                },
                "example": {
                  "data": {
                    "id": "art_8fK2mQ9xLp3T",
                    "keyword": "crm for small agencies",
                    "intent": "new",
                    "source": "search_console",
                    "status": "waiting",
                    "status_reason": null,
                    "title": "The CRM a 10-person agency actually needs",
                    "why": "Google showed you for this 1,840 times in the last 28 days, and the best page you have ranks 38.",
                    "created_at": "2026-10-19T06:05:02.000Z",
                    "scheduled_for": null,
                    "published_at": null,
                    "published_url": null,
                    "results": null,
                    "slug": "crm-for-small-agencies",
                    "meta_description": "What a 10-person agency needs from a CRM, what it can skip, and the setup that takes an afternoon rather than a quarter.",
                    "body_markdown": "## What a small agency actually needs\n\nThree things, and pipeline reporting is not one of them...",
                    "evidence": {
                      "search_console": {
                        "impressions": 1840,
                        "clicks": 6,
                        "average_position": 38.4,
                        "your_pages_competing": 2
                      },
                      "search_volume": 1300,
                      "keyword_difficulty": 27,
                      "existing_page": "https://acme.com/features",
                      "estimated_position": null,
                      "competitor": null
                    },
                    "pages_to_beat": [
                      {
                        "rank": 1,
                        "domain": "rival.com",
                        "title": "Best CRM for agencies in 2026",
                        "url": "https://rival.com/blog/agency-crm"
                      },
                      {
                        "rank": 2,
                        "domain": "reviewsite.com",
                        "title": "12 CRMs for small agencies",
                        "url": "https://reviewsite.com/crm/agencies"
                      }
                    ]
                  },
                  "meta": {
                    "generated_at": "2026-10-23T09:00:00.000Z",
                    "brand": {
                      "id": "wsp_zD6cssxGlJyF",
                      "name": "Acme"
                    },
                    "results_window_days": 28,
                    "note": "evidence is what the agent saw when it chose this topic, kept as it was then; it does not update. evidence.search_console is null when the topic did not come from Search Console, which means those numbers were never measured, not that they were zero. pages_to_beat is the search results when the article was written. results is the live measure from the customer's own Search Console over results_window_days, null until Google has reported the published page. Null is not zero. This API cannot approve, edit or publish an article."
                  }
                }
              }
            }
          },
          "400": {
            "description": "brand_required, or a cursor that does not belong to this brand.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing, malformed, revoked or expired key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "The organisation has never subscribed, or the plan excludes the API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "The key lacks the required scope.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such brand, the brand has not finished setup, or no_such_article: no article with that id for this brand.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Carries Retry-After.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/keywords": {
      "get": {
        "summary": "The keywords the SEO agent ranks weekly, with who ranks above you",
        "description": "This week's Google position for each target keyword, last week's, the movement, search volume and difficulty, the top three results (`top_results`), and your own Search Console clicks and impressions for the same search.\n\nRANKS ARE CHECKED WEEKLY, through the top 100, not live. TWO NULLS THAT LOOK ALIKE AND ARE NOT: `rank.latest` is null when a keyword has never been checked, and `rank.latest.position` is null when it was checked and the site is not in the top 100. The second is a real result. Neither is position 0.\n\nOrdered by how winnable each keyword is for this site, best first. The score behind that order is deliberately not returned; the volume and difficulty it is made from are.",
        "parameters": [
          {
            "name": "brand",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Which brand to report on, from GET /brands. REQUIRED when the organisation has more than one: omitting it returns 400 brand_required rather than defaulting, because a silent default files one customer's numbers under another's. Ignored by a key pinned to a single brand, which refuses any other."
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/KeywordTarget"
                      }
                    },
                    "meta": {
                      "type": "object",
                      "properties": {
                        "generated_at": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "brand": {
                          "$ref": "#/components/schemas/BrandRef"
                        },
                        "note": {
                          "type": "string"
                        },
                        "rank_checks": {
                          "type": "string",
                          "enum": [
                            "weekly"
                          ],
                          "description": "How often ranks are measured. Never live."
                        },
                        "rank_depth": {
                          "type": "integer",
                          "description": "How deep each check looks. A site below it reads as position null."
                        },
                        "market": {
                          "type": "object",
                          "description": "The Google market the ranks are checked in.",
                          "properties": {
                            "location_code": {
                              "type": "integer"
                            },
                            "language_code": {
                              "type": "string"
                            }
                          }
                        },
                        "search_console_connected": {
                          "type": [
                            "boolean",
                            "null"
                          ],
                          "description": "False: not connected, so every search_console is null. Null: this deployment does not offer Search Console."
                        },
                        "search_console_window_days": {
                          "type": "integer",
                          "description": "The window behind every search_console figure."
                        },
                        "keywords_researched": {
                          "type": "integer",
                          "description": "Keywords research has found for this brand. Zero means research has never run, so an empty list means not started."
                        },
                        "keywords_set_aside": {
                          "type": "integer",
                          "description": "Researched keywords the agent judged not about this business, or naming a competitor or you. It never targets or writes for these on its own."
                        },
                        "last_researched_at": {
                          "type": [
                            "string",
                            "null"
                          ],
                          "format": "date-time",
                          "description": "When research last refreshed. Null when never."
                        },
                        "rank_checks_completed": {
                          "type": "integer",
                          "description": "Rank checks ever completed for this brand, so not checked yet can be told from not ranking."
                        },
                        "keywords_tracked": {
                          "type": "integer",
                          "description": "Target keywords across the whole organisation."
                        },
                        "keywords_allowed": {
                          "type": "integer",
                          "description": "The plan's cap on target keywords, organisation-wide."
                        }
                      }
                    }
                  },
                  "required": [
                    "data",
                    "meta"
                  ]
                },
                "example": {
                  "data": [
                    {
                      "keyword": "agency crm",
                      "source": "competitor_gap",
                      "competitor": {
                        "domain": "rival.com",
                        "position": 3,
                        "url": "https://rival.com/blog/agency-crm"
                      },
                      "targeted_by": "agent",
                      "search_volume": 1900,
                      "keyword_difficulty": 34,
                      "search_intent": "commercial",
                      "rank": {
                        "latest": {
                          "week_start": "2026-10-19",
                          "position": 9,
                          "url": "https://acme.com/crm"
                        },
                        "previous_week": {
                          "position": 18
                        },
                        "change": 9,
                        "movement": "up",
                        "this_week_check": "done",
                        "has_ai_overview": false,
                        "top_results": [
                          {
                            "position": 1,
                            "domain": "rival.com",
                            "url": "https://rival.com/blog/agency-crm",
                            "title": "Best CRM for agencies in 2026",
                            "is_you": false
                          },
                          {
                            "position": 2,
                            "domain": "reviewsite.com",
                            "url": "https://reviewsite.com/crm/agencies",
                            "title": "12 CRMs for small agencies",
                            "is_you": false
                          },
                          {
                            "position": 3,
                            "domain": "otherco.com",
                            "url": "https://otherco.com/agencies",
                            "title": "CRM built for agencies",
                            "is_you": false
                          }
                        ]
                      },
                      "search_console": {
                        "clicks": 12,
                        "impressions": 640
                      }
                    },
                    {
                      "keyword": "crm for consultants",
                      "source": "idea",
                      "competitor": null,
                      "targeted_by": "agent",
                      "search_volume": 320,
                      "keyword_difficulty": 18,
                      "search_intent": "informational",
                      "rank": {
                        "latest": {
                          "week_start": "2026-10-19",
                          "position": null,
                          "url": null
                        },
                        "previous_week": {
                          "position": 87
                        },
                        "change": null,
                        "movement": "dropped",
                        "this_week_check": "done",
                        "has_ai_overview": true,
                        "top_results": [
                          {
                            "position": 1,
                            "domain": "reviewsite.com",
                            "url": "https://reviewsite.com/crm/consultants",
                            "title": "CRMs for independent consultants",
                            "is_you": false
                          }
                        ]
                      },
                      "search_console": null
                    },
                    {
                      "keyword": "client portal for agencies",
                      "source": "manual",
                      "competitor": null,
                      "targeted_by": "customer",
                      "search_volume": null,
                      "keyword_difficulty": null,
                      "search_intent": null,
                      "rank": {
                        "latest": null,
                        "previous_week": null,
                        "change": null,
                        "movement": null,
                        "this_week_check": "checking",
                        "has_ai_overview": null,
                        "top_results": []
                      },
                      "search_console": null
                    }
                  ],
                  "meta": {
                    "generated_at": "2026-10-23T09:00:00.000Z",
                    "brand": {
                      "id": "wsp_zD6cssxGlJyF",
                      "name": "Acme"
                    },
                    "rank_checks": "weekly",
                    "rank_depth": 100,
                    "market": {
                      "location_code": 2840,
                      "language_code": "en"
                    },
                    "search_console_connected": true,
                    "search_console_window_days": 28,
                    "keywords_researched": 412,
                    "keywords_set_aside": 37,
                    "last_researched_at": "2026-10-19T06:04:12.000Z",
                    "rank_checks_completed": 296,
                    "keywords_tracked": 100,
                    "keywords_allowed": 100,
                    "note": "Ranks are checked WEEKLY on Google, through the top 100 results, not live. rank.latest is null when a keyword has never been checked. rank.latest.position null means it WAS checked and the site is not in the top 100: a real result, not missing data, and never position 0. previous_week is null when there was no completed check the week before, so there is no movement to show. search_console is null when Search Console reported nothing for this search in the window or is not connected (search_console_connected), never zero. keywords_researched 0 means research has never run for this brand, which is why an empty list here can mean not started rather than nothing found. keywords_set_aside counts researched keywords the agent judged not about this business, or naming a competitor or the customer; it never targets or writes for those on its own, and the customer can track one by hand in RankHound. The keyword allowance is counted across the whole organisation."
                  }
                }
              }
            }
          },
          "400": {
            "description": "brand_required, or a cursor that does not belong to this brand.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing, malformed, revoked or expired key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "The organisation has never subscribed, or the plan excludes the API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "The key lacks the required scope.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such brand, or the brand has not finished setup.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Carries Retry-After.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/competitor-gaps": {
      "get": {
        "summary": "What each competitor ranks for that you do not, with the page that ranks",
        "description": "Per tracked competitor: the keywords they rank for that your site does not, each with their ranking page (the page an article has to beat), and the pages they started ranking with in the last 7 days.\n\nCHECK `analysed` BEFORE REPORTING. The weekly research looks at a limited number of competitors, and one outside that limit, or with no domain, is never looked at: an empty gap list there means NOT LOOKED AT, never no gaps.\n\nKeywords the agent set aside as not about your business, or naming a brand, are left out of `gaps` and `gap_count` and counted in `set_aside_count`, because a gap list is read as \"write about these\". `suggested_competitors` share keywords with your site and are not tracked yet.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 10
            },
            "description": "Gaps returned per competitor. gap_count carries the total either way."
          },
          {
            "name": "brand",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Which brand to report on, from GET /brands. REQUIRED when the organisation has more than one: omitting it returns 400 brand_required rather than defaulting, because a silent default files one customer's numbers under another's. Ignored by a key pinned to a single brand, which refuses any other."
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "competitors": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/CompetitorGaps"
                          }
                        },
                        "suggested_competitors": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/SuggestedCompetitor"
                          }
                        }
                      },
                      "required": [
                        "competitors",
                        "suggested_competitors"
                      ]
                    },
                    "meta": {
                      "type": "object",
                      "properties": {
                        "generated_at": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "brand": {
                          "$ref": "#/components/schemas/BrandRef"
                        },
                        "note": {
                          "type": "string"
                        },
                        "analysed_limit": {
                          "type": "integer",
                          "description": "How many competitors the weekly research analyses, from the plan."
                        },
                        "gaps_per_competitor": {
                          "type": "integer",
                          "description": "The cap on each gaps list for this request."
                        },
                        "new_pages_per_competitor": {
                          "type": "integer",
                          "description": "The cap on each new_pages list."
                        }
                      }
                    }
                  },
                  "required": [
                    "data",
                    "meta"
                  ]
                },
                "example": {
                  "data": {
                    "competitors": [
                      {
                        "competitor": "Rival",
                        "domain": "rival.com",
                        "analysed": true,
                        "last_analysed_at": "2026-10-19T06:04:12.000Z",
                        "gap_count": 42,
                        "set_aside_count": 9,
                        "gaps": [
                          {
                            "keyword": "agency crm",
                            "search_volume": 1900,
                            "keyword_difficulty": 34,
                            "their_position": 3,
                            "their_page": "https://rival.com/blog/agency-crm",
                            "targeted": true
                          },
                          {
                            "keyword": "crm with client portal",
                            "search_volume": 590,
                            "keyword_difficulty": 21,
                            "their_position": 6,
                            "their_page": "https://rival.com/features/portal",
                            "targeted": false
                          }
                        ],
                        "new_page_count": 1,
                        "new_pages": [
                          {
                            "url": "https://rival.com/blog/retainer-pricing",
                            "title": "How to price a retainer",
                            "best_keyword": "agency retainer pricing",
                            "best_position": 7,
                            "first_seen_at": "2026-10-19T06:04:12.000Z"
                          }
                        ]
                      },
                      {
                        "competitor": "Otherco",
                        "domain": null,
                        "analysed": false,
                        "last_analysed_at": null,
                        "gap_count": 0,
                        "set_aside_count": 0,
                        "gaps": [],
                        "new_page_count": 0,
                        "new_pages": []
                      }
                    ],
                    "suggested_competitors": [
                      {
                        "domain": "thirdco.com",
                        "shared_keywords": 214,
                        "average_position": 11.4
                      }
                    ]
                  },
                  "meta": {
                    "generated_at": "2026-10-23T09:00:00.000Z",
                    "brand": {
                      "id": "wsp_zD6cssxGlJyF",
                      "name": "Acme"
                    },
                    "analysed_limit": 5,
                    "gaps_per_competitor": 10,
                    "new_pages_per_competitor": 10,
                    "note": "gap_count is every keyword this competitor ranks for that your site does not, leaving out ones you dismissed and ones the agent set aside as not about your business or naming a brand (set_aside_count says how many); gaps holds the best gaps_per_competitor of them, each with the competitor's ranking page, which is the page an article has to beat. CHECK analysed: false means the weekly research does not look at this competitor (outside the plan's analysed_limit, or no domain), so an empty list there means not looked at, never no gaps. new_pages are pages first seen ranking in the last 7 days; a competitor's first analysed week is a baseline and shows none by design. suggested_competitors share keywords with your site and are not tracked yet."
                  }
                }
              }
            }
          },
          "400": {
            "description": "brand_required, or a cursor that does not belong to this brand.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing, malformed, revoked or expired key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "The organisation has never subscribed, or the plan excludes the API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "The key lacks the required scope.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such brand, or the brand has not finished setup.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Carries Retry-After.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/visibility": {
      "get": {
        "summary": "Presence per engine and per brand over a trailing window",
        "description": "There is no single visibility score and there will not be one. The engines disagree, so an average describes no real surface; compute one yourself if you want it, knowing exactly what went in.",
        "parameters": [
          {
            "name": "days",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 365,
              "default": 7
            }
          },
          {
            "name": "brand",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Which brand to report on, from GET /brands. REQUIRED when the organisation has more than one: omitting it returns 400 brand_required rather than defaulting, because a silent default files one customer's numbers under another's. Ignored by a key pinned to a single brand, which refuses any other."
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "engines": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/Engine"
                          }
                        },
                        "brands": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/BrandVisibility"
                          }
                        }
                      }
                    },
                    "meta": {
                      "type": "object",
                      "properties": {
                        "generated_at": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "brand": {
                          "$ref": "#/components/schemas/BrandRef"
                        },
                        "note": {
                          "type": "string"
                        },
                        "window_days": {
                          "type": "integer"
                        }
                      }
                    }
                  },
                  "required": [
                    "data",
                    "meta"
                  ]
                },
                "example": {
                  "data": {
                    "engines": [
                      {
                        "engine": "chatgpt",
                        "name": "ChatGPT",
                        "collection_method": "scraper",
                        "present": 18,
                        "responses": 30,
                        "rate": 0.6,
                        "min_sample": 5,
                        "citations": 7,
                        "avg_rank_when_named": 2.4
                      },
                      {
                        "engine": "perplexity",
                        "name": "Perplexity",
                        "collection_method": "api",
                        "present": 3,
                        "responses": 4,
                        "rate": null,
                        "min_sample": 5,
                        "citations": 1,
                        "avg_rank_when_named": 3
                      }
                    ],
                    "brands": [
                      {
                        "name": "Acme",
                        "role": "self",
                        "present": 21,
                        "responses": 34,
                        "rate": 0.618,
                        "min_sample": 5,
                        "citations": 8,
                        "avg_rank_when_named": 2.5,
                        "framing": {
                          "classified": 21,
                          "recommended": 14,
                          "negative": 1
                        }
                      }
                    ]
                  },
                  "meta": {
                    "generated_at": "2026-09-30T09:00:00.000Z",
                    "brand": {
                      "id": "wsp_zD6cssxGlJyF",
                      "name": "Acme"
                    },
                    "window_days": 30,
                    "note": "Engines are reported separately and never averaged. The perplexity row above has rate: null because 4 answers is below min_sample — that means NOT ENOUGH DATA, not 0%."
                  }
                }
              }
            }
          },
          "400": {
            "description": "brand_required, or a cursor that does not belong to this brand.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing, malformed, revoked or expired key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "The organisation has never subscribed, or the plan excludes the API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "The key lacks the required scope.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such brand, or the brand has not finished setup.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Carries Retry-After.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/answers": {
      "get": {
        "summary": "The stored answers, with their mentions and citations",
        "description": "The raw material every other number traces back to. Keyset pagination: pass meta.next_cursor as ?cursor=. outcome='empty' is a real observation (the engine answered and named nobody), not a failure.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "An answer id from a previous page."
          },
          {
            "name": "engine",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Filter to one engine slug."
          },
          {
            "name": "brand",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Which brand to report on, from GET /brands. REQUIRED when the organisation has more than one: omitting it returns 400 brand_required rather than defaulting, because a silent default files one customer's numbers under another's. Ignored by a key pinned to a single brand, which refuses any other."
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Answer"
                      }
                    },
                    "meta": {
                      "type": "object",
                      "properties": {
                        "generated_at": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "brand": {
                          "$ref": "#/components/schemas/BrandRef"
                        },
                        "note": {
                          "type": "string"
                        },
                        "next_cursor": {
                          "type": [
                            "string",
                            "null"
                          ],
                          "description": "Pass as ?cursor=. Null on the last page."
                        }
                      }
                    }
                  },
                  "required": [
                    "data",
                    "meta"
                  ]
                },
                "example": {
                  "data": [
                    {
                      "id": "ans_SfwqC8iCWwte",
                      "engine": "chatgpt",
                      "collection_method": "scraper",
                      "prompt": "best project management tools for agencies",
                      "answer": "For agencies, the most commonly recommended options are…",
                      "outcome": "ok",
                      "collected_at": "2026-09-30T04:12:00.000Z",
                      "location_code": 2840,
                      "mentions": [
                        {
                          "brand": "Acme",
                          "role": "self",
                          "mentioned": true,
                          "rank": 2,
                          "count": 1,
                          "framing": "recommended",
                          "framing_signals": [
                            "best for"
                          ],
                          "snippet": "Acme is best for agencies juggling many clients."
                        },
                        {
                          "brand": "Rival",
                          "role": "competitor",
                          "mentioned": false,
                          "rank": null,
                          "count": 0,
                          "framing": null,
                          "framing_signals": null,
                          "snippet": null
                        }
                      ],
                      "citations": [
                        {
                          "url": "https://example.com/best-pm-tools",
                          "domain": "example.com",
                          "rank": 1,
                          "title": "Best PM tools in 2026"
                        }
                      ]
                    }
                  ],
                  "meta": {
                    "generated_at": "2026-09-30T09:00:00.000Z",
                    "brand": {
                      "id": "wsp_zD6cssxGlJyF",
                      "name": "Acme"
                    },
                    "next_cursor": "ans_vIUZWjBPDEm2",
                    "note": "Pass next_cursor as ?cursor= for the following page."
                  }
                }
              }
            }
          },
          "400": {
            "description": "brand_required, or a cursor that does not belong to this brand.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing, malformed, revoked or expired key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "The organisation has never subscribed, or the plan excludes the API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "The key lacks the required scope.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such brand, or the brand has not finished setup.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Carries Retry-After.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/sources": {
      "get": {
        "summary": "The domains the engines read before answering",
        "description": "answers_citing_you_without_naming_you is the actionable column: a source read on nine answers that named a competitor every time is a specific page to go and be on. Hosts are normalised, so www.example.com and example.com are one row.",
        "parameters": [
          {
            "name": "days",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 365,
              "default": 30
            }
          },
          {
            "name": "brand",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Which brand to report on, from GET /brands. REQUIRED when the organisation has more than one: omitting it returns 400 brand_required rather than defaulting, because a silent default files one customer's numbers under another's. Ignored by a key pinned to a single brand, which refuses any other."
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Source"
                      }
                    },
                    "meta": {
                      "type": "object",
                      "properties": {
                        "generated_at": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "brand": {
                          "$ref": "#/components/schemas/BrandRef"
                        },
                        "note": {
                          "type": "string"
                        },
                        "window_days": {
                          "type": "integer"
                        },
                        "limit": {
                          "type": "integer"
                        }
                      }
                    }
                  },
                  "required": [
                    "data",
                    "meta"
                  ]
                },
                "example": {
                  "data": [
                    {
                      "domain": "g2.com",
                      "answers_citing_it": 12,
                      "answers_where_you_were_named": 3,
                      "answers_citing_you_without_naming_you": 9
                    },
                    {
                      "domain": "acme.com",
                      "answers_citing_it": 5,
                      "answers_where_you_were_named": 5,
                      "answers_citing_you_without_naming_you": 0
                    }
                  ],
                  "meta": {
                    "generated_at": "2026-09-30T09:00:00.000Z",
                    "brand": {
                      "id": "wsp_zD6cssxGlJyF",
                      "name": "Acme"
                    },
                    "window_days": 30,
                    "limit": 200,
                    "note": "g2.com was read on 12 answers and named you on 3. Those other 9 are the work."
                  }
                }
              }
            }
          },
          "400": {
            "description": "brand_required, or a cursor that does not belong to this brand.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing, malformed, revoked or expired key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "The organisation has never subscribed, or the plan excludes the API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "The key lacks the required scope.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such brand, or the brand has not finished setup.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Carries Retry-After.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/competitors": {
      "get": {
        "summary": "Who the engines name instead of you",
        "description": "answers_without_you is the column that matters. 'Named in 48 answers' is a fact about a competitor; 'named in 47 answers that never mentioned you' is a fact about you, and it is the one you can act on.\n\nQuotes are OFF by default and capped at three per competitor. They are the evidence behind the number, and pulling them for twenty competitors on every call turns a report into a transcript.",
        "parameters": [
          {
            "name": "days",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 365,
              "default": 30
            }
          },
          {
            "name": "quotes",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "1"
              ]
            },
            "description": "Pass 1 to include up to three supporting sentences per competitor. Omitted entirely when not requested."
          },
          {
            "name": "brand",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Which brand to report on, from GET /brands. REQUIRED when the organisation has more than one: omitting it returns 400 brand_required rather than defaulting, because a silent default files one customer's numbers under another's. Ignored by a key pinned to a single brand, which refuses any other."
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Competitor"
                      }
                    },
                    "meta": {
                      "type": "object",
                      "properties": {
                        "generated_at": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "brand": {
                          "$ref": "#/components/schemas/BrandRef"
                        },
                        "note": {
                          "type": "string"
                        },
                        "window_days": {
                          "type": "integer"
                        },
                        "total_answers": {
                          "type": "integer",
                          "description": "Answers in the window, all engines."
                        },
                        "answers_naming_you": {
                          "type": "integer"
                        },
                        "answers_without_you": {
                          "type": "integer",
                          "description": "total_answers - answers_naming_you."
                        }
                      }
                    }
                  },
                  "required": [
                    "data",
                    "meta"
                  ]
                },
                "example": {
                  "data": [
                    {
                      "competitor": "Rival",
                      "domain": "rival.com",
                      "answers_naming_them": 48,
                      "answers_without_you": 47,
                      "best_list_position": 1,
                      "typical_list_position": 2.5,
                      "total_mentions": 61,
                      "engines": [
                        "chatgpt",
                        "perplexity"
                      ]
                    },
                    {
                      "competitor": "Otherco",
                      "domain": null,
                      "answers_naming_them": 6,
                      "answers_without_you": 4,
                      "best_list_position": null,
                      "typical_list_position": null,
                      "total_mentions": 6,
                      "engines": [
                        "gemini"
                      ]
                    }
                  ],
                  "meta": {
                    "generated_at": "2026-09-30T09:00:00.000Z",
                    "brand": {
                      "id": "wsp_zD6cssxGlJyF",
                      "name": "Acme"
                    },
                    "window_days": 30,
                    "total_answers": 60,
                    "answers_naming_you": 8,
                    "answers_without_you": 52,
                    "note": "answers_without_you is the actionable column: answers that named this competitor and never mentioned you. Engines are listed, never pooled into one figure. Pass quotes=1 for up to three supporting sentences per competitor."
                  }
                }
              }
            }
          },
          "400": {
            "description": "brand_required, or a cursor that does not belong to this brand.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing, malformed, revoked or expired key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "The organisation has never subscribed, or the plan excludes the API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "The key lacks the required scope.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such brand, or the brand has not finished setup.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Carries Retry-After.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/recommendations": {
      "get": {
        "summary": "What to do next, ranked, with the evidence",
        "description": "CHECK `displayable` BEFORE REPORTING ANYTHING. When it is false the list is empty because the corpus is too thin to rank honestly, NOT because there is nothing to do, and reporting that as a clean bill of health is the worst mistake available on this endpoint. Those two states call for opposite reactions.\n\nOrdered by `tier`, which is an editorial judgement about whose permission the work needs rather than a measurement: your own page outranks a marketplace because nobody has to agree to it. There is deliberately NO blended score. A caller who disagrees with the order can see exactly which judgement to argue with, which is not true of 0.734.\n\nEvery row cites the pages behind it. There is no 'write more content' and no 'add schema markup': an item with nothing to point at is filtered out rather than softened into advice.",
        "parameters": [
          {
            "name": "days",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 365,
              "default": 30
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 50,
              "default": 12
            }
          },
          {
            "name": "brand",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Which brand to report on, from GET /brands. REQUIRED when the organisation has more than one: omitting it returns 400 brand_required rather than defaulting, because a silent default files one customer's numbers under another's. Ignored by a key pinned to a single brand, which refuses any other."
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Recommendation"
                      }
                    },
                    "meta": {
                      "type": "object",
                      "properties": {
                        "generated_at": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "brand": {
                          "$ref": "#/components/schemas/BrandRef"
                        },
                        "note": {
                          "type": "string"
                        },
                        "window_days": {
                          "type": "integer"
                        },
                        "total_answers": {
                          "type": "integer",
                          "description": "The denominator for every share above."
                        },
                        "displayable": {
                          "type": "boolean",
                          "description": "False when the corpus is too thin to rank anything honestly. An empty list with displayable false means NOT YET, never NOTHING TO DO."
                        }
                      }
                    }
                  },
                  "required": [
                    "data",
                    "meta"
                  ]
                },
                "example": {
                  "data": [
                    {
                      "kind": "own_page",
                      "tier": 1,
                      "site": "yoursite.com",
                      "pages": [
                        {
                          "url": "https://yoursite.com/pricing",
                          "title": "Pricing"
                        }
                      ],
                      "answers": 17,
                      "answers_naming_you": 1,
                      "competitors_named": 4,
                      "engines": [
                        "chatgpt",
                        "perplexity"
                      ],
                      "share": 0.28
                    },
                    {
                      "kind": "listing",
                      "tier": 2,
                      "site": "someroundup.com",
                      "pages": [
                        {
                          "url": "https://someroundup.com/best-tools",
                          "title": "The 12 Best Tools"
                        }
                      ],
                      "answers": 9,
                      "answers_naming_you": 0,
                      "competitors_named": 6,
                      "engines": [
                        "gemini"
                      ],
                      "share": 0.15
                    }
                  ],
                  "meta": {
                    "generated_at": "2026-10-13T09:00:00.000Z",
                    "brand": {
                      "id": "wsp_zD6cssxGlJyF",
                      "name": "Acme"
                    },
                    "window_days": 30,
                    "total_answers": 60,
                    "displayable": true,
                    "note": "Ordered by tier, which is an editorial judgement about whose permission the work needs, not by volume: your own page outranks a marketplace because nobody has to agree to it. Engines are listed, never pooled. `share` is a fraction of total_answers, which is the only denominator it is comparable against. Every row cites the pages behind it; a recommendation with nothing to point at is filtered out rather than softened into advice."
                  }
                }
              }
            }
          },
          "400": {
            "description": "brand_required, or a cursor that does not belong to this brand.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing, malformed, revoked or expired key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "The organisation has never subscribed, or the plan excludes the API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "The key lacks the required scope.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such brand, or the brand has not finished setup.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Carries Retry-After.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/questions": {
      "get": {
        "summary": "The questions this workspace runs against the engines",
        "description": "Call this before interpreting a disappointing visibility number. Everything else in this API is derived from these questions, so a low figure may mean the wrong question rather than poor visibility, and the two have opposite fixes.\n\n`web_search_volume` IS NULL WHEN UNCHECKED, not when it is zero. Read `volume_checked_at` before quoting demand: rendering an unchecked volume as 0 asserts nobody searches a question nobody has measured.\n\nThe allowance in meta counts ACTIVE questions across the whole organisation, not per workspace, because a question costs the same vendor spend whichever brand it sits under.",
        "parameters": [
          {
            "name": "brand",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Which brand to report on, from GET /brands. REQUIRED when the organisation has more than one: omitting it returns 400 brand_required rather than defaulting, because a silent default files one customer's numbers under another's. Ignored by a key pinned to a single brand, which refuses any other."
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Question"
                      }
                    },
                    "meta": {
                      "type": "object",
                      "properties": {
                        "generated_at": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "brand": {
                          "$ref": "#/components/schemas/BrandRef"
                        },
                        "note": {
                          "type": "string"
                        },
                        "questions_allowed": {
                          "type": "integer",
                          "description": "The cap on active questions for AI visibility tracking, included on every plan: 10 per workspace, counted across the organisation."
                        },
                        "questions_used": {
                          "type": "integer"
                        },
                        "questions_remaining": {
                          "type": "integer",
                          "description": "Zero means another question cannot be added on this plan."
                        }
                      }
                    }
                  },
                  "required": [
                    "data",
                    "meta"
                  ]
                },
                "example": {
                  "data": [
                    {
                      "id": "qst_7Hk2",
                      "text": "best ai visibility tools",
                      "category": "category",
                      "source": "user",
                      "active": true,
                      "answers": 121,
                      "answers_naming_you": 1,
                      "web_search_volume": 390,
                      "volume_checked_at": "2026-10-01T00:00:00.000Z"
                    },
                    {
                      "id": "qst_9Lp4",
                      "text": "how to track brand mentions in chatgpt",
                      "category": null,
                      "source": "suggested",
                      "active": true,
                      "answers": 60,
                      "answers_naming_you": 0,
                      "web_search_volume": null,
                      "volume_checked_at": null
                    }
                  ],
                  "meta": {
                    "generated_at": "2026-10-13T09:00:00.000Z",
                    "brand": {
                      "id": "wsp_zD6cssxGlJyF",
                      "name": "Acme"
                    },
                    "questions_allowed": 5,
                    "questions_used": 2,
                    "questions_remaining": 3,
                    "note": "These are the questions every other measurement in this API is derived from, so a disappointing visibility figure may mean the wrong question rather than poor visibility. web_search_volume is null when it has not been checked, NOT when it is zero: check volume_checked_at before reporting demand. The allowance counts active questions across the whole organisation, because a question costs the same vendor spend whichever brand it sits under."
                  }
                }
              }
            }
          },
          "400": {
            "description": "brand_required, or a cursor that does not belong to this brand.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing, malformed, revoked or expired key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "The organisation has never subscribed, or the plan excludes the API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "The key lacks the required scope.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such brand, or the brand has not finished setup.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Carries Retry-After.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/sentiment": {
      "get": {
        "summary": "How the engines frame each brand when they name it",
        "description": "NOT a score. THE DENOMINATOR IS `classified`, NOT `answers_naming_them`, and that is the whole shape of this endpoint. The classifier abstains on most listicle mentions by design: being item four in '12 best tools' is a mention with no opinion attached, and inventing one would be the product making something up. So `classified` is usually much smaller than `answers_naming_them`, and a share computed against the latter understates every brand's positivity by the abstention rate.\n\nThere is no single sentiment number per brand, for the same reason there is no single visibility score: recommended and negative are separate counts over a shared denominator, and collapsing them discards the actionable part.",
        "parameters": [
          {
            "name": "days",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 365,
              "default": 30
            }
          },
          {
            "name": "brand",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Which brand to report on, from GET /brands. REQUIRED when the organisation has more than one: omitting it returns 400 brand_required rather than defaulting, because a silent default files one customer's numbers under another's. Ignored by a key pinned to a single brand, which refuses any other."
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/BrandSentiment"
                      }
                    },
                    "meta": {
                      "type": "object",
                      "properties": {
                        "generated_at": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "brand": {
                          "$ref": "#/components/schemas/BrandRef"
                        },
                        "note": {
                          "type": "string"
                        },
                        "window_days": {
                          "type": "integer"
                        },
                        "min_sample": {
                          "type": "integer",
                          "description": "Classified-mention floor below which recommended_share is null."
                        }
                      }
                    }
                  },
                  "required": [
                    "data",
                    "meta"
                  ]
                },
                "example": {
                  "data": [
                    {
                      "brand": "Acme",
                      "is_you": true,
                      "answers_naming_them": 21,
                      "classified": 9,
                      "recommended": 6,
                      "negative": 1,
                      "neutral": 2,
                      "recommended_share": 0.667
                    },
                    {
                      "brand": "Rival",
                      "is_you": false,
                      "answers_naming_them": 48,
                      "classified": 3,
                      "recommended": 2,
                      "negative": 0,
                      "neutral": 1,
                      "recommended_share": null
                    }
                  ],
                  "meta": {
                    "generated_at": "2026-09-30T09:00:00.000Z",
                    "brand": {
                      "id": "wsp_zD6cssxGlJyF",
                      "name": "Acme"
                    },
                    "window_days": 30,
                    "min_sample": 5,
                    "note": "classified is the denominator, not answers_naming_them: the classifier abstains on listicle mentions where the engine named a brand without endorsing it. recommended_share is null below min_sample, which means NOT ENOUGH CLASSIFIED MENTIONS, never zero."
                  }
                }
              }
            }
          },
          "400": {
            "description": "brand_required, or a cursor that does not belong to this brand.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing, malformed, revoked or expired key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "The organisation has never subscribed, or the plan excludes the API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "The key lacks the required scope.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such brand, or the brand has not finished setup.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Carries Retry-After.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/share-of-voice": {
      "get": {
        "summary": "Each tracked brand's mentions as a share of every tracked brand's",
        "description": "THE ONE PLACE THIS API COMBINES A COMPETITOR'S NUMBER WITH YOURS. Everywhere else engines are reported separately and nothing is averaged; share of voice is a ratio by definition, so the honest move is to publish the denominator beside it rather than refuse the metric.\n\nTHE DENOMINATOR IS THE BRANDS YOU TRACK, NOT THE MARKET. A workspace tracking two competitors and one tracking twelve produce incomparable numbers from identical answers, so always report brands_counted alongside.",
        "parameters": [
          {
            "name": "days",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 365,
              "default": 30
            }
          },
          {
            "name": "brand",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Which brand to report on, from GET /brands. REQUIRED when the organisation has more than one: omitting it returns 400 brand_required rather than defaulting, because a silent default files one customer's numbers under another's. Ignored by a key pinned to a single brand, which refuses any other."
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/ShareOfVoice"
                      }
                    },
                    "meta": {
                      "type": "object",
                      "properties": {
                        "generated_at": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "brand": {
                          "$ref": "#/components/schemas/BrandRef"
                        },
                        "note": {
                          "type": "string"
                        },
                        "window_days": {
                          "type": "integer"
                        },
                        "total_mentions": {
                          "type": "integer",
                          "description": "The denominator. Sum of every tracked brand's mentions."
                        },
                        "brands_counted": {
                          "type": "integer",
                          "description": "How many brands went into the denominator."
                        }
                      }
                    }
                  },
                  "required": [
                    "data",
                    "meta"
                  ]
                },
                "example": {
                  "data": [
                    {
                      "brand": "Rival",
                      "is_you": false,
                      "mentions": 48,
                      "share": 0.6957
                    },
                    {
                      "brand": "Acme",
                      "is_you": true,
                      "mentions": 21,
                      "share": 0.3043
                    }
                  ],
                  "meta": {
                    "generated_at": "2026-09-30T09:00:00.000Z",
                    "brand": {
                      "id": "wsp_zD6cssxGlJyF",
                      "name": "Acme"
                    },
                    "window_days": 30,
                    "total_mentions": 69,
                    "brands_counted": 2,
                    "note": "The denominator is the brands THIS workspace tracks, not the market. Two workspaces tracking different competitor sets produce incomparable shares from the same answers, so always report brands_counted alongside. share is null when no brand was named at all, which is not the same as a 0% share."
                  }
                }
              }
            }
          },
          "400": {
            "description": "brand_required, or a cursor that does not belong to this brand.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing, malformed, revoked or expired key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "The organisation has never subscribed, or the plan excludes the API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "The key lacks the required scope.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such brand, or the brand has not finished setup.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Carries Retry-After.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/alerts": {
      "get": {
        "summary": "Findings about your brand, newest first",
        "description": "AN EMPTY LIST DOES NOT MEAN NOTHING CHANGED. A finding is raised only when both comparison windows carry enough answers AND the change is larger than its own margin of error. Read an empty list as 'no change large enough to distinguish from noise at this sample size'. GET /me returns the thresholds.",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          },
          {
            "name": "brand",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Which brand to report on, from GET /brands. REQUIRED when the organisation has more than one: omitting it returns 400 brand_required rather than defaulting, because a silent default files one customer's numbers under another's. Ignored by a key pinned to a single brand, which refuses any other."
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Alert"
                      }
                    },
                    "meta": {
                      "type": "object",
                      "properties": {
                        "generated_at": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "brand": {
                          "$ref": "#/components/schemas/BrandRef"
                        },
                        "note": {
                          "type": "string"
                        }
                      }
                    }
                  },
                  "required": [
                    "data",
                    "meta"
                  ]
                },
                "example": {
                  "data": [],
                  "meta": {
                    "generated_at": "2026-09-30T09:00:00.000Z",
                    "brand": {
                      "id": "wsp_zD6cssxGlJyF",
                      "name": "Acme"
                    },
                    "note": "An empty list does NOT mean nothing changed. It means no move was large enough to distinguish from noise at this sample size."
                  }
                }
              }
            }
          },
          "400": {
            "description": "brand_required, or a cursor that does not belong to this brand.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing, malformed, revoked or expired key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "The organisation has never subscribed, or the plan excludes the API.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "The key lacks the required scope.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "No such brand, or the brand has not finished setup.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded. Carries Retry-After.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    }
  },
  "x-scopes": {
    "read": {
      "capability": "view_reports"
    },
    "prompts:write": {
      "capability": "manage_questions"
    },
    "brands:write": {
      "capability": "manage_brands"
    }
  },
  "x-error-codes": {
    "not_available": {
      "status": 404,
      "meaning": "This deployment does not have the integration the endpoint needs.",
      "fix": "Treat it as permanent for this host rather than retrying: the integration is absent from the deployment, not from your account, so no key and no plan will change the answer. It is returned before your key is checked on purpose, because which integrations a deployment offers is not a per-customer secret, and a 401 first would send you looking for credentials that would not help."
    },
    "unauthorized": {
      "status": 401,
      "meaning": "No key, or a key that is malformed, revoked or expired.",
      "fix": "Create a key at /api-keys. Send it as X-API-Key or Authorization: Bearer."
    },
    "no_subscription": {
      "status": 402,
      "meaning": "This organisation has never had a plan.",
      "fix": "Choose a plan. A lapsed plan is not refused: if you have subscribed before, you keep read access to the answers you already own."
    },
    "api_not_on_plan": {
      "status": 402,
      "meaning": "This plan does not include API access.",
      "fix": "Every plan includes the API, so this should not occur in practice. If you see it, contact support."
    },
    "insufficient_scope": {
      "status": 403,
      "meaning": "The key was not granted the scope this endpoint needs.",
      "fix": "Mint a key with the required scope. Every current endpoint needs `read`."
    },
    "brand_required": {
      "status": 400,
      "meaning": "The organisation has several brands and the request did not say which.",
      "fix": "Pass ?brand=<id>. GET /brands lists them. Deliberately not defaulted: a silent default files one customer's numbers under another's."
    },
    "no_such_brand": {
      "status": 404,
      "meaning": "No brand with that id that this key may read.",
      "fix": "404 rather than 403 on purpose — a 403 would confirm the brand exists in somebody else's organisation."
    },
    "no_brand": {
      "status": 404,
      "meaning": "This organisation has no brand at all, or the brand a pinned key is bound to has been deleted.",
      "fix": "Finish setup in the dashboard. For a pinned key whose brand is gone, mint a new key."
    },
    "brand_not_configured": {
      "status": 404,
      "meaning": "The brand exists but setup was never finished, so there is nothing to report.",
      "fix": "404 rather than a page of zeroes, which would read as measured absence."
    },
    "bad_cursor": {
      "status": 400,
      "meaning": "The pagination cursor does not belong to this brand.",
      "fix": "Start again without a cursor. Not a silent restart from the top, which would produce undetectable duplicates."
    },
    "unknown_status": {
      "status": 400,
      "meaning": "The article status filter is not one this endpoint knows.",
      "fix": "Use one of the values the error message lists, or all. Refused rather than ignored: a misspelt filter that quietly returned every article would read as every article waiting for approval."
    },
    "no_such_article": {
      "status": 404,
      "meaning": "No article with that id belongs to this brand.",
      "fix": "Use an id from GET /articles for the same brand. An article in another brand or organisation answers 404 rather than 403 on purpose, so an id cannot be used to learn that someone else's article exists."
    },
    "rate_limited": {
      "status": 429,
      "meaning": "Either the per-key quota of 300 requests a minute, or the per-address limit of 200 failed authentications an hour, which is applied before the key is checked. Retry-After says how long to wait.",
      "fix": "Back off using Retry-After. If you are seeing this with a key you believe is valid, it is the second cause: the key is being rejected."
    }
  },
  "x-rate-limit": {
    "per_minute": 300,
    "uniform_across_plans": true,
    "headers": [
      "X-RateLimit-Limit",
      "X-RateLimit-Remaining",
      "X-RateLimit-Reset"
    ]
  }
}
