# Client API (v1)

Programmatic API for keyword ranking data, competitor analysis, page SEO, and webhook management. Designed for external integrations, automated workflows, and AI coding assistants (Claude Code).

**Base URL:** `https://seo.312elements.com`

**OpenAPI Spec:** `GET /api/v1/openapi.json` (machine-readable, no auth required)

---

## Plan Requirements

API keys can be created on any plan. **API data access is included with Pro ($500/year) and Team ($1,200/year) subscriptions at no extra cost** — it is not a separate add-on.

| Plan | API Keys | Data Access | Write Access (add/remove keywords) |
|------|----------|-------------|-------------------------------------|
| Starter ($200/yr) | 1 key | No (403 — read the OpenAPI spec to explore what's available) | No |
| Pro ($500/yr) | 1 key | Full read + write | Yes |
| Team ($1,200/yr) | 3 keys | Full read + write + multi-market | Yes |

Starter users can create a key and connect tools like Claude Code. Data endpoints return `403` with details about what's available on upgraded plans. The OpenAPI spec at `GET /api/v1/openapi.json` is accessible to all plans (no auth required).

## Authentication

All v1 data endpoints require an API key. Create one in the dashboard under **Settings > API Keys**.

Include it in the `Authorization` header:

```bash
curl -X GET "https://seo.312elements.com/api/v1/keywords" \
  -H "Authorization: Bearer kt_live_your_api_key_here" \
  -H "Content-Type: application/json"
```

API keys start with `kt_live_`. Keep your key secure and never expose it in client-side code.

---

## Rate Limits

| Endpoint Type | Limit | Window |
|--------------|-------|--------|
| All v1 endpoints | 100 requests | 1 minute |

Rate limit headers are included in all responses:

```
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1702648800
X-RateLimit-Resource: api_v1_standard
```

All responses include `X-API-Schema-Version: 1.0`. This will increment for breaking changes.

---

## Market Filtering

All SERP-dependent endpoints accept an optional `?market=` query parameter for **Team tier** users.

- **Team tier only** — non-Team users who include this parameter receive a `403 Forbidden` error.
- **Omit for default** — when absent, the primary market (zip code) is used automatically.
- **Available markets** — Team tier tenants have up to 4 markets: their primary zip plus up to 3 dedicated zips. Use `GET /api/v1/markets` to discover your market IDs (UUIDs).
- **Limitation:** `/api/v1/rankings/declining` accepts the parameter for consistency but currently returns primary market data only (uses a pre-computed materialized view).

**Example:**

```bash
curl -X GET "https://seo.312elements.com/api/v1/keywords?market=550e8400-e29b-41d4-a716-446655440000" \
  -H "Authorization: Bearer kt_live_your_api_key_here"
```

---

## Error Handling

All errors follow this format:

```json
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Invalid request parameters",
    "details": { "field": "phrase", "issue": "required" }
  }
}
```

| Status | Code | Meaning |
|--------|------|---------|
| 400 | `VALIDATION_ERROR` | Invalid parameters |
| 401 | `UNAUTHORIZED` | Invalid or missing API key |
| 403 | `FORBIDDEN` | Insufficient permissions |
| 404 | `NOT_FOUND` | Resource does not exist |
| 409 | `CONFLICT` | Resource conflict (e.g., duplicate webhook URL) |
| 429 | `RATE_LIMIT_EXCEEDED` | Too many requests |
| 500 | `INTERNAL_ERROR` | Server error |

---

## Response Conventions

Most endpoints return this standard shape:

```json
{
  "success": true,
  "data": [ ... ],
  "pagination": {
    "total": 42,
    "limit": 50,
    "offset": 0,
    "hasMore": false
  }
}
```

- **`data`** — the primary payload (array for lists, object for single resources or compound responses)
- **`pagination`** — present on paginated list endpoints; includes `total`, `limit`, `offset`, `hasMore`
- **`success`** — always `true` on 2xx responses, `false` on errors

Aggregate metadata (counts, summaries, distributions) always lives inside `data` — never as a root-level sibling. The only permitted root-level siblings of `data` are `success`, `schemaVersion`, `asOf`, and `pagination`. Every v1 endpoint conforms to this envelope.

### `asOf` — when the data was measured

Every v1 response carries `asOf`: an ISO-8601 timestamp, or `null`.

**It is a measured event, never the time the response was assembled.** What it reports
depends on where the data comes from:

| Lineage | `asOf` reports | Endpoints |
|---|---|---|
| Published snapshot | The publication boundary of the completed data waterfall being served. Every ranking, keyword, competitor and SERP-feature number in the response was computed from that one snapshot. | `/keywords*`, `/rankings*`, `/competitors*`, `/opportunities*`, `/serp-features/*`, `/paa-questions`, `/content-pages`, `/seo-score*` |
| Moz link graph | Your last **successful** referring-domain sync. Same value as `lastSyncedAt` on `/backlinks/summary`. | `/backlinks*` |
| Site health | The last time your site was checked. | `/site-health*` |
| Page metadata | The last time the requested domain's pages were fetched. | `/pages*` |
| AI visibility | The capture time of the AI-visibility runs behind the response. | `/ai-visibility*` |
| Domain authority | Moz's last authority fetch for your domain. | `/domain-metrics` |
| Reviews | The last Google-review refresh for your domain. | `/reviews` |
| Uptime | The last uptime check recorded for your sites. | `/uptime*` |
| Alerts / events / recaps / achievements / image analysis / keyword suggestions / recon | That source's own newest measured row. | `/alerts`, `/events`, `/recaps`, `/achievements`, `/image-analysis*`, `/keyword-suggestions`, `/recon/competitors` |
| Mixed | The **oldest** contributing measurement, so `asOf` is true of every field in the body. On `/overview` this can be materially older than the snapshot boundary — if your Moz sync is a week old, `/overview` reports the week-old stamp even though your positions flipped an hour ago. | `/overview`, `/export` |

`asOf` is `null` in two cases, both honest:

- the endpoint carries no measured data — configuration, webhook CRUD, quota readouts, and
  mutations that report what they just did (`/markets`, `/webhooks*`, `/image-analysis/quota`,
  `/issues/*`, `/alerts/*/read`, `/content-pages/groups*`, `/content-pages/sync`);
- the data's source tracks no measurement stamp. Exactly one endpoint is in this state today
  — `/page-audit`, whose audit fetch is not timestamped. It is recorded as a known gap rather
  than given a made-up value.

If you need to know whether two responses describe the same underlying data, compare `asOf`.
Two snapshot-backed responses with the same `asOf` were computed from the same waterfall.

---

## Response Envelope & Versioning

Some endpoints include a `schemaVersion` field in the response envelope. When present, it indicates the shape **and the meaning** of the response body, and is bumped on any breaking change to that endpoint.

```json
{
  "success": true,
  "schemaVersion": "2",
  "asOf": "2026-08-01T22:00:00.000Z",
  "data": { ... }
}
```

**Rules:**

- `schemaVersion` is **additive** — pure field additions do not bump the version.
- It is bumped when a field is **renamed, removed, or its type changes** in a way that could break a consumer.
- It is **also** bumped when an existing field keeps its shape but changes what it *means* — for example a count that starts including a category it previously omitted. A consumer that pins the version gets a loud failure instead of silently ingesting numbers that are not comparable to yesterday's.
- An endpoint without `schemaVersion` is implicitly version `"1"`. New endpoints emit an explicit version.
- Breaking changes are announced via RFC 9745 `Deprecation` and `Sunset` response headers at least 30 days before the change ships.

**Current versions:**

One row per (method, path). Endpoints not listed emit no explicit `schemaVersion` and are treated as implicit `"1"`.

<!-- SCHEMA_VERSION_TABLE_START -->
| Endpoint | schemaVersion | Notes |
|---|---|---|
| `GET /api/v1/serp-features/opportunities` | `"2"` | Bumped 2026-08-04 — `count` and `securedCount` describe the full population. They were the length of a list already capped at 100 (max 500) and a JS filter over it, so a tenant with more opportunities than the cap was told there were exactly as many as fit on one page. |
| `GET /api/v1/rankings/changes` | `"1"` | Behaviour change 2026-08-04 (no shape change): `market` now scopes the result — it was accepted and ignored, so a dedicated market's alerts surfaced as primary-market changes. Alerts written before 2026-08-02 carry no market and cannot be resolved to one; they stay in the default view and are never returned for a dedicated market. Only actively tracked keywords are included. |
| `GET /api/v1/pages` | `"2"` | Bumped 2026-08-04 — the list and `pagination.total` are the domain's **ranking** pages, via `tenant_ranking_pages()`, which is the same population `GET /api/v1/competitors/{domain}` reports as `stats.pagesAnalyzed`. It previously listed every crawled page for the domain: 116 pages for a domain whose competitor dossier reported 5 in the same cycle (68 vs 42, 44 vs 29 on two others). |
| `GET /api/v1/pages/{id}/pagespeed` | `"2"` | Bumped 2026-08-04 — an archived page (`page_status` `removed` or `redirected`) returns `404`, matching the list. 94 such rows on tenant domains were absent from `/pages` and served in full by id. |
| `GET /api/v1/pages/issues` | `"2"` | Bumped 2026-08-04 — the three findings come from the same accessor `GET /api/v1/site-health` publishes. **Orphan now means "nothing links to it"**: the previous query selected pages with at most one link *on* them, so it returned dead-end pages, and for one production domain it reported 0 orphans while `/site-health` reported 5. Duplicates are compared against every crawled page rather than case-sensitively among crawled pages. Each section is now an object carrying `dismissed`, an untruncated `total` and its `items` (`orphanPages` also carries `detectionSkipped`), and `?include_dismissed=false` has parity with `/site-health`. |
| `GET /api/v1/site-health` | `"3"` | Bumped 2026-08-04 — `issues` and every count over it include `broken_internal_links` (penalty 12), which was defined but emitted by nothing: this endpoint could publish `issueCount: 3` beside unresolved broken links listed on `/site-health/broken-links`. Duplicate and orphan findings also converge with `/pages/issues`, so `issueCount` can move for that reason. Scores drop for tenants whose ranking pages were missing schema markup — the weekly job now performs the same schema check the dashboard refresh already did (see the release notes). Bumped 2026-07-30 — `data.sitemap` and `data.robots` gain three-state `exists` (`true` present / `false` confirmed absent / `null` not measured by the last check) and `measuredAt`, the time that asset was last actually read. A preserved value is therefore never published as freshly measured, and an unfinished check is never published as an absence. Both fields come from the same accessor the dashboard GUI reads. |
| `GET /api/v1/pages/{id}/history` | `"2"` | Bumped 2026-08-04 — an archived page returns `404`, matching `GET /api/v1/pages`. |
| `GET /api/v1/pages/compare` | `"2"` | Bumped 2026-08-04 — an archived page returns `404`, matching the list, and both URLs are normalized before lookup (lowercased host, no `www.`, no trailing slash) so a cosmetic variant of a stored URL resolves instead of 404ing. |
| `POST /api/v1/issues/dismiss` | `"3"` | Bumped 2026-08-04 — `issueCode` is validated against the vocabulary its `issueType` uses: an issue code for `fix_queue`, a keyword id for `declining`, `vuln:{keywordId}:{competitorDomain}` for `vulnerable`. An unknown code is a `400` rather than a `200` that stored a dismissal matching nothing forever. Standardized May 2026 to canonical envelope. |
| `DELETE /api/v1/issues/dismiss` | `"3"` | Bumped 2026-08-04 — same validation as the POST. Standardized May 2026 to canonical envelope. |
| `POST /api/v1/page-audit` | `"2"` | Bumped 2026-08-04 — `report.siteLevel` carries `source: "live"`. Those robots / llms / sitemap facts are measured during the audit, not read from the stored site-health check, and the two can legitimately differ. No timestamp was added; `asOf` remains `null` for this endpoint. |
| `GET /api/v1/seo-score` | `"2"` | Bumped 2026-08-01 — **`data.computedAt` is removed.** It was generated at response assembly, so two consecutive calls returned different timestamps for a byte-identical score; it described when the JSON was built, not when anything was measured. The envelope's new `asOf` replaces it and reports the publication boundary of the waterfall the score was computed from. |
| `GET /api/v1/competitors` | `"7"` | Bumped 2026-08-15 — `avgPosition` is REMOVED. Ruling G4: an average of ranks is coverage-sensitive (a competitor gaining breadth at deep positions reads as weakening), so it was indefensible as a published claim, and two definitions of it shipped in the same response — 14.00 in `items[]` against 5.2 in `shareOfVoice[]` for the same competitor, found by a tenant. Ordering and `keywordOverlap` are unaffected: `sort_by=position` still orders by ranking strength, it just no longer publishes the number. Competitor rows are also now scoped to the market they were measured in. Bumped 2026-08-04 — `summary.averageDomainAuthority` comes from the one accessor `GET /api/v1/export` already used: the latest Moz metrics row per competitor, with competitors that have no metrics excluded from numerator and denominator. It was meaned in the handler with a missing DA counted as `0`, so the two endpoints published 15.80 and 15.69 for the same tenant in the same second. Bumped 2026-08-01 — `data.shareOfVoice[]` gains `sharePct` (the percentage share, same value as `items[].shareOfVoice`), and share of voice now comes from one accessor for every surface. `items[].shareOfVoice` was previously a `COALESCE(...,0)` over a view that had been silently empty since 2026-03-01, so it published a fabricated `0.0%` for every competitor; `shareOfVoice[].totalTop10` and `rank` now match `GET /api/v1/competitors/share-of-voice` exactly (they previously disagreed in both value and ordering). Bumped 2026-07-21 — competitor `brandAuthority` removed. Bumped May 2026 — `summary` and `shareOfVoice` moved inside `data`. |
| `GET /api/v1/keywords/{id}/serp` | `"2"` | Bumped 2026-08-01 — `fetchedAt` is the capture time of the SERP whose results are returned. It previously reported when the SERP job last ran for the keyword, so a run that stored nothing still advanced the stamp while the results stayed a day older. |
| `GET /api/v1/keywords/{id}/schacht` | `"2"` | Bumped 2026-08-01 — `keyword.lastFetched` reports recorded-position date rather than job-run time, matching the keywords list; job-run time is available as the new `keyword.lastFetchAttemptedAt`. |
| `GET /api/v1/keywords/{id}/competitors` | `"2"` | Bumped 2026-08-01 — `total` is the real number of distinct competing domains in the keyword's latest SERP. It was the length of a data-layer array capped at 50, so a SERP with 61 competitors reported 50 and a full page could not be told from a truncated one. |
| `GET /api/v1/serp-features/visibility` | `"3"` | Bumped 2026-08-04 — every counter describes **your** keywords. They came from a market-wide aggregate with no keyword restriction, so they counted every tenant's keywords in your market: one competitor was published at 2,129 SERP features across 95 keywords where 1,475 across 68 were on the asking tenant's keywords, and the tenant's own row read 87 against a true 81. Bumped 2026-08-01 — `totalCompetitors` is the real number of feature-owning domains across your keywords. It was the length of a data-layer array capped at 50 (minus one when your own domain fell inside that 50), so a market with 55 such domains reported 50. |
| `GET /api/v1/overview` | `"6"` | Bumped 2026-08-04 — `momentum` publishes five buckets: `declining`, `improving`, `stable`, `dropped` and `unmeasured`. A keyword with no measurement in the current cycle no longer carries a direction — `dropped` and `unmeasured` used to be folded into `declining`, so a keyword nobody had measured since April was reported as declining every week (155 such rows across 38 tenants). The five buckets partition your active keywords exactly. Also adds `include_dismissed`, which previously existed only on `GET /api/v1/site-health`: this endpoint ignored the parameter and always published the full `siteHealth.issueCount`. And `lastRefresh` is scoped to the `market` you asked for — it always reported your primary market's, 33 hours away from the dedicated market's own stamp on one tenant. Bumped 2026-08-04 — `siteHealth.issueCount` includes `broken_internal_links` and the converged duplicate/orphan findings, matching `GET /api/v1/site-health` in the same cycle. Bumped 2026-08-04 — `backlinks.totalActive` and `backlinks.totalBacklinks` count the display-filtered population, matching `GET /api/v1/backlinks/summary` and the dashboard (see that endpoint's note). Bumped 2026-07-28 — `data.siteHealth` is now **always an object and never `null`**. It was previously `null` for a tenant with no site-health record, while `GET /api/v1/site-health` published an `issueCount` for the same tenant in the same cycle; `issueCount` does not depend on that record existing. `score` and `checkedAt` remain nullable — those do come from the record. Bumped 2026-07-27 — `siteHealth.issueCount` now counts site-level **and** page-level issues; it previously counted only the site-level `site_health.issues` column and disagreed with `GET /api/v1/site-health` for the same tenant in the same request cycle. |
| `GET /api/v1/site-health/history` | `"4"` | Bumped 2026-08-04 — the **current** check is the newest entry and every entry carries `is_current`. `site_health_history` is written only by the archive trigger, from the previous row, so the chart's rightmost point was always the previous check with nothing saying so — on one tenant it ended 1.4 days before the score published beside it. `limit` counts entries, so `limit=1` returns the current check. Bumped 2026-07-31 — `health_score` is nullable: a snapshot whose check measured nothing has no score to publish, and the `0` it used to store was not "unknown" but the worst rating on the scale (migration 513). Bumped 2026-07-30 — `sitemap_exists`, `robots_exists` and `llms_txt_exists` are nullable: a snapshot whose check did not measure an asset now reports `null` instead of a fabricated `false`. |
| `POST /api/v1/content-pages/sync` | `"2"` | Bumped 2026-07-30 — adds `sitemapOutcome` (`found` \| `absent` \| `unknown`) and `sitemapReason`. `sitemapPages: 0` alone could not distinguish an empty sitemap from one the sync failed to read, and nothing is deactivated in the latter case. `sitemapReason` is non-null only when `sitemapOutcome` is `unknown`, and carries `dns_unresolvable` / `no_https_listener` (no host variant could be reached), `budget_exhausted` / `queue_timeout` / `circuit_open` (the failure was ours), or `upstream_error` (a read reached the site and it refused). Its value set is not part of the contract — the field is a string — and it comes from the same single derivation the dashboard surfaces publish, so this endpoint and the GUI cannot report different causes for the same discovery. |
| `GET /api/v1/opportunities` | `"4"` | Bumped 2026-08-05 — `declining.keywords` holds keywords that DECLINED: measured this cycle with a worse position than their previous point. Keywords with no measurement this cycle used to appear in the list carrying a stale position while `declining.count` counted only the measured ones, so the list and the count described different populations in one response. Those keywords are still published, labelled `dropped` or `unmeasured`, on `GET /api/v1/rankings/declining`. Bumped 2026-08-01 — `strikingDistance.count` and `declining.count` respect the request's own `min_position`/`max_position` and `include_dismissed` filters; they previously reported the unfiltered population beside a filtered `keywords` array (`?type=striking_distance&min_position=1&max_position=5` answered `count: 60` with zero rows). Standardized May 2026 to canonical envelope. |
| `GET /api/v1/rankings` | `"3"` | Bumped May 2026 — `distribution` and `rankingCount` moved inside `data`. |
| `GET /api/v1/competitors/share-of-voice` | `"3"` | Bumped 2026-08-15 — `avgPosition` is REMOVED. Ruling G4: an average of ranks is coverage-sensitive (a competitor gaining breadth at deep positions reads as weakening), so it was indefensible as a published claim, and two definitions of it shipped in the same response — 14.00 in `items[]` against 5.2 in `shareOfVoice[]` for the same competitor, found by a tenant. Ordering and `keywordOverlap` are unaffected: `sort_by=position` still orders by ranking strength, it just no longer publishes the number. Competitor rows are also now scoped to the market they were measured in. Bumped 2026-08-01 — rows are camelCase and carry `companyName`, `top3`, `top10`, `totalTop10`, `sharePct`, `avgPosition`, `rank` (previously snake_case `total_top10`/`avg_position` with no company name or share). `totalTop10` is now the real top-10 count: it was the top-3 count **added** to the cumulative top-10 count, double-counting every top-3 keyword, and the leaderboard's ordering was computed independently of the `rank` every other surface published. The endpoint had been returning an empty list for all tenants since 2026-03-01 (dead view join); it now returns data. |
| `GET /api/v1/rankings/declining` | `"4"` | Bumped 2026-08-04 — every item carries `state` (`measured` \| `dropped` \| `unmeasured`) and `positionChange` is nullable. A keyword with no measurement this cycle is no longer published as a decline; it is still returned, labelled, with its last-seen position and date. `previousPosition` here is documented as the **best position in the last 28 days**, not the previous data point — it never was, and now the field says so. Bumped 2026-08-01 — `pagination.total` is the real declining population; it was the length of a data-layer array capped at 50, so a tenant with more than 50 declining keywords saw `total: 50` and `hasMore: false` with the remainder unreachable. Bumped May 2026 — root `total` replaced by `pagination` block; `offset` param added. |
| `GET /api/v1/rankings/history` | `"3"` | Bumped 2026-05-28 — each row carries `state` (`active` \| `removing`); pending-add keywords absent (no history). Bumped May 2026 — `meta` moved inside `data`. |
| `GET /api/v1/serp-features/ownership` | `"3"` | Bumped 2026-08-04 — `data.total` is the real population, and `summary` describes that population rather than the returned page. `byFeatureType`, `uniqueDomains` and `ownDomainAppearances` were reduced in JS over rows already capped at 100 (max 500), so raising `limit` changed the summary. Bumped May 2026 — `summary` moved inside `data`. |
| `GET /api/v1/backlinks/opportunities` | `"3"` | Bumped 2026-08-04 — "your competitors" means the competitors ranking in your market **this cycle**, the same set every other backlink surface uses; it previously meant any domain that had ever ranked, so this endpoint's population was materially wider than the one behind `/backlinks/activity` (312 Elements: 531 opportunities before, 370 after). `domainAuthority` follows the one-DA-per-domain rule and now also drives `min_da` filtering and ordering, so a row can never be returned with a published DA under the floor you asked for. Bumped May 2026 — `totalAvailable` and `mode` moved inside `data`. |
| `GET /api/v1/backlinks/opportunities/global` | `"3"` | Bumped 2026-08-04 — `domainAuthority` follows the one-DA-per-domain rule and drives `min_da` filtering and ordering (market scoping is deliberately absent from this endpoint and is unchanged). Bumped May 2026 — `totalAvailable` moved inside `data`. |
| `GET /api/v1/recon/competitors` | `"2"` | Bumped May 2026 — `tenant` and `keywordSlots` moved inside `data`. |
| `GET /api/v1/webhooks` | `"2"` | Standardized May 2026 to canonical envelope. |
| `POST /api/v1/webhooks` | `"2"` | Standardized May 2026 to canonical envelope. |
| `GET /api/v1/webhooks/{id}` | `"2"` | Standardized May 2026 to canonical envelope. |
| `PATCH /api/v1/webhooks/{id}` | `"2"` | Standardized May 2026 to canonical envelope. |
| `DELETE /api/v1/webhooks/{id}` | `"2"` | Standardized May 2026 to canonical envelope. |
| `POST /api/v1/webhooks/{id}` | `"2"` | Test-event delivery. Standardized May 2026 to canonical envelope. |
| `GET /api/v1/issues/dismiss` | `"1"` | Explicit version added 2026-05-08; shape unchanged from prior implicit `"1"`. |
| `POST /api/v1/issues/complete` | `"1"` | Explicit version added 2026-05-08; shape unchanged from prior implicit `"1"`. |
| `GET /api/v1/issues/complete` | `"2"` | Bumped 2026-05-08 from `{ data, total }` to canonical paginated `{ data, pagination }`. |
| `GET /api/v1/alerts` | `"1"` | Explicit version added 2026-05-08; shape unchanged from prior implicit `"1"`. |
| `POST /api/v1/alerts/bulk-read` | `"1"` | Explicit version added 2026-05-08; shape unchanged from prior implicit `"1"`. |
| `POST /api/v1/alerts/{id}/read` | `"2"` | Standardized May 2026 to canonical envelope. |
| `GET /api/v1/export` | `"7"` | Bumped 2026-08-15 — `avgPosition` is REMOVED. Ruling G4: an average of ranks is coverage-sensitive (a competitor gaining breadth at deep positions reads as weakening), so it was indefensible as a published claim, and two definitions of it shipped in the same response — 14.00 in `items[]` against 5.2 in `shareOfVoice[]` for the same competitor, found by a tenant. Ordering and `keywordOverlap` are unaffected: `sort_by=position` still orders by ranking strength, it just no longer publishes the number. Competitor rows are also now scoped to the market they were measured in. Bumped 2026-08-04 — `competitors.list` holds one row per competitor. The metrics join fanned each competitor out to one row per distinct historical DA, so a read sized from the competitor count returned 305 rows holding 127 distinct competitors — 178 missing from a payload documented as a complete snapshot. `summary.averageDomainAuthority` now matches `GET /api/v1/competitors` exactly. Bumped 2026-07-28 — `keywords[].lastFetchedAt` now reports the date of the most recent **recorded** position (previously the time the SERP job last ran, which overstated freshness); the serialized form changes from timestamp to date. Job-run time is available as the new `lastFetchAttemptedAt`. | Bumped 2026-05-28 — each keyword row carries `state` (`queued` \| `active` \| `removing`); export now returns full membership including active-unranked and pending_remove rows. Bumped May 2026 — `meta` moved inside `data`. |
| `GET /api/v1/keywords` | `"4"` | Bumped 2026-07-28 — `lastFetchedAt` now reports the date of the most recent **recorded** position (same lineage as `lastChecked` on `GET /api/v1/opportunities`); it previously reported when the SERP job last **ran**, which overstated freshness whenever a run stored no new data, and the two endpoints could disagree by a full day. Job-run time is preserved as the new `lastFetchAttemptedAt`. Bumped 2026-06-21 — every row now carries `url` (the ranking URL for the current position, `null` when the keyword does not rank); previously the ranking URL was only available via per-keyword `GET /api/v1/keywords/{id}`. Bumped 2026-05-28 — unified membership shape: every row carries `state` (`queued` \| `active` \| `removing`); position fields nullable across all rows; new `counts` block with per-state counts plus `countedAgainstLimit`; `?state=` filter replaces `?status=`; active-unranked rows now visible (previously inner-joined out by position MV). |
| `GET /api/v1/keywords/{id}` | `"4"` | Bumped 2026-08-01 — `keyword.lastFetched` reports the date of the most recent **recorded** position (the lineage `GET /api/v1/keywords.lastFetchedAt` uses); it previously reported when the SERP job last **ran**, so the list and the detail for the same keyword could disagree by a full day. Job-run time is available as the new `keyword.lastFetchAttemptedAt`. Bumped 2026-07-21 — competitor `brandAuthority` removed from the SERP competitors array. Bumped 2026-05-28 — `keyword.state` added. `{id}` accepts UUID or URL-encoded phrase. |
| `DELETE /api/v1/keywords/{id}` | `"2"` | Bumped 2026-05-28 — optional `state: "removing"` on soft-delete (absent on hard-delete of pending_add). `{id}` accepts UUID or URL-encoded phrase. |
| `GET /api/v1/image-analysis/export` | `"2"` | Standardized May 2026 to canonical envelope. |
| `GET /api/v1/content-pages` | `"4"` | Bumped June 2026 — per-keyword off-page ranking fields (`rankingUrl`, `assignedPagePosition`, `isOffPage`), per-page + summary `offPageKeywords` count, and optional `?filter=off-page`. |
| `GET /api/v1/ai-visibility` | `"2"` | Rewritten April 2026 to return structured per-persona data. April 2026 additive fields: per-persona `visibilityId`, `hasCitations`, `citationSummary` (non-breaking). |
| `GET /api/v1/ai-visibility/weekly` | `"2"` | Added structured `mentionedBusinesses[]` + `tenantRank`. April 2026 additive fields: per-result `visibilityId`, `hasCitations`, `citationSummary` (non-breaking). |
| `GET /api/v1/ai-visibility/query/{visibilityId}` | `"1"` | Launched April 2026. Per-query citation detail. |
| `GET /api/v1/ai-visibility/insights` | `"1"` | Launched June 2026. Distinct search queries Claude ran + sources Claude cited; `include` + `window` params. |
| `GET /api/v1/backlinks/referring-domains` | `"2"` | Bumped 2026-08-04 — the list defaults to `status=active`, the population `GET /api/v1/backlinks/summary` counts, so `pagination.total` and that endpoint's `referringDomains` agree (they previously differed by your lost domains — 240 vs 233 on one tenant). `minDA` now raises your stored `backlink_min_da` floor instead of replacing it, and the shortener/spam-TLD blocklist applies here as it does on `GET /api/v1/backlinks`. Pass `status=all` for the previous every-status listing. |
| `GET /api/v1/domain-metrics` | `"3"` | Bumped 2026-08-04 — `backlinkCount` and `referringDomains` are published. Both read series names that do not exist in the stored Moz history, so both were `null` for every tenant on versions 1 and 2. `pageAuthority` and `spamScore` are published too, from your own latest Moz metrics row — the same row `GET /api/v1/keywords/{id}.userDomainMetrics` reads, so the two surfaces always agree. They were `null` for every tenant on versions 1 and 2 because they were read from a metrics history that does not carry them; 55 of 61 tenants had the values all along. Null now means only that no Moz metrics row exists for your domain yet. `domainAuthority` is also THE source for your own DA: `GET /api/v1/keywords/{id}.userDomainMetrics.domainAuthority` reads the same store, where the two disagreed for 16 of 61 tenants. Bumped 2026-08-04 — adds `source: "moz"`, stating that every metric in the response is Moz's own domain-level snapshot for your target domain, refreshed by the enrichment job on its own cadence. This is a different lineage from the referring domains this platform tracks row by row, which is why `referringDomains` here can differ from `GET /api/v1/backlinks/summary.referringDomains`. |
| `GET /api/v1/backlinks/summary` | `"3"` | Bumped 2026-08-04 — `referringDomains` and `totalBacklinks` count the population your display filters define (the `backlink_min_da` floor and the shortener/spam-TLD blocklist), which is the same population `GET /api/v1/backlinks/referring-domains` now lists by default. They previously counted every active row, so this endpoint and your own dashboard reported different totals for the same property in the same cycle (one tenant: 159 here, 153 in the dashboard). `source` is unchanged in value and now comes from the accessor that produces the counts. Bumped 2026-07-28 — added `source: "moz"` and `lastSyncedAt`. Backlink state is Moz's view of the link graph, reported as-is; `lastSyncedAt` is the last **successful** Moz sync, so a `lost30d: 0` alongside a stale timestamp means "no fresh data from the provider", not "nothing was lost". |
| `GET /api/v1/backlinks` | `"3"` | Bumped 2026-08-04 — `domainAuthority` is the one DA published for a source domain everywhere: the domain-level value we hold, falling back to the observation recorded with the link. It was the event-time snapshot, so the same domain could show one DA here and another on `GET /api/v1/backlinks/referring-domains`. Added `summary.trackedSince` to disambiguate the URL-level changelog window (deltas since onboarding) vs the domain-level full inventory at `/api/v1/backlinks/referring-domains`. |
| `GET /api/v1/backlinks/activity` | `"3"` | Bumped 2026-08-04 — link `domainAuthority` uses the same one-DA-per-domain rule as `GET /api/v1/backlinks`, and weekly buckets are anchored on Sunday 00:00 America/Chicago rather than the server's local timezone (a link detected Saturday evening Central could previously land in either week depending on the host). Added `pagination` sibling + `offset` query param. `weeks` is the page size; both are unbounded (full history reachable). `summary` is now a fixed trailing-30-day query independent of pagination window (prior versions filtered the page window in-memory and were wrong whenever the window did not include the last 30 days). |
| `GET /api/v1/pages/{id}` | `"3"` | Bumped 2026-08-04 — an archived page (`page_status` `removed` or `redirected`) returns `404`, matching the list's population. Bumped May 2026 — h2/h3 counts + text arrays, hierarchyBroken/hierarchyIssue, missingAltCount, noindexDetected, nofollowDetected, viewportPresent, viewportCorrect added as always-present fields; ?include=images,links and ?targetKeyword= query params added. |
<!-- SCHEMA_VERSION_TABLE_END -->

Endpoints not listed above do not currently emit a body-level `schemaVersion` (treated as implicit `"1"`).

Consumers building stable integrations should pin on `schemaVersion` and fail fast if it changes unexpectedly.

### Notices

v1 AI-visibility endpoints include an optional `notices[]` array at the envelope root carrying short-lived informational announcements:

```json
{
  "success": true,
  "data": { /* ... */ },
  "notices": [
    {
      "id": "ai-visibility-citations-2026-04",
      "severity": "info",
      "title": "New: per-query citation detail endpoint",
      "message": "Per-persona results now include visibilityId, hasCitations, and citationSummary. Drill into any run via GET /api/v1/ai-visibility/query/{visibilityId} for full citation detail.",
      "learnMoreUrl": "https://seo.312elements.com/dashboard/settings/api-keys/docs",
      "sunsetAt": "2026-04-23T04:09:20.000Z"
    }
  ]
}
```

**Rules:**

- The `notices` field is **optional** — absent when no active notice applies. Existing consumers parsing `{ success, data }` are unaffected.
- `severity` is `info` or `warning`. Notices are **never** errors — they never change response status codes.
- Clients SHOULD dedupe on `id` (stable across requests) so users don't see the same announcement on every response.
- Clients MUST NOT cache a notice past its `sunsetAt` timestamp — the server stops emitting it at that time.
- Notices are global: every tenant calling an affected endpoint during the notice's active window will see it. There is no per-tenant dismissal.

---

## Common Tasks

Quick reference for common questions. Use this to find the right endpoint.

| Task | Endpoint |
|------|----------|
| Get my domain authority (DA) | `GET /api/v1/domain-metrics` |
| Get my full authority profile (DA, PA, spam score, brand authority) | `GET /api/v1/domain-metrics` |
| Compare my DA to competitors | `GET /api/v1/domain-metrics` (yours) + `GET /api/v1/competitors` (theirs) |
| Side-by-side DA comparison per keyword | `GET /api/v1/keywords/:id` — includes `userDomainMetrics` (your DA/PA) alongside competitor DA |
| Get my SEO score with DA summary | `GET /api/v1/seo-score` — includes `domainAuthority` and `brandAuthority` inline |
| Get full SERP results for a keyword | `GET /api/v1/keywords/:id` or `GET /api/v1/keywords/:id/serp` |
| See my ranking trends | `GET /api/v1/rankings/history` |
| Find keywords where I can overtake competitors | `GET /api/v1/opportunities/vulnerable` |
| Find link building targets | `GET /api/v1/backlinks/opportunities` |
| Dashboard overview in one call | `GET /api/v1/overview` |
| Check my site health | `GET /api/v1/site-health` |
| See what AI models say about my brand | `GET /api/v1/ai-visibility` |
| Run an SEO audit on any URL | `POST /api/v1/page-audit` |
| Export all data for sync | `GET /api/v1/export` |

---

## Endpoints

### Export (Bundle)

Get all data in a single request — ideal for weekly cron jobs.

#### GET /api/v1/export

Export complete data snapshot. Perfect for weekly sync to spreadsheets or databases.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| include | string | `keywords,competitors,opportunities,rankings` | Comma-separated sections to include |
| history_days | integer | — | `7`, `14`, or `30` — include ranking history |
| market | uuid | — | Filter by market (Team tier only) |

**Response:**

```json
{
  "success": true,
  "schemaVersion": "3",
  "data": {
    "exportedAt": "2024-12-31T10:00:00Z",
    "tenant": { "domain": "example.com", "plan": "pro" },
    "keywords": [...],
    "competitors": { "list": [...], "summary": {...} },
    "opportunities": { "strikingDistance": [...], "declining": [...] },
    "rankings": { "topRankings": [...], "distribution": {...}, "history": [...] },
    "meta": { "included": ["keywords","competitors","opportunities","rankings"], "historyDays": 7, "keywordCount": 25, "market": null }
  }
}
```

> Ideal for infrequent bulk exports. `data.meta` carries export configuration (which sections were requested, history window, market filter).

---

### Keywords

#### GET /api/v1/keywords

`schemaVersion: "3"`. Returns one row per `user_keywords` membership row — every keyword the tenant is tracking, regardless of whether it has ranked yet. Each row carries a `state` field (`queued` | `active` | `removing`); position/metric fields are nullable (an active row that has never ranked surfaces with `currentPosition: null`, `competitorCount: 0`, etc.). Each row also carries `url` — the ranking URL for the current position, `null` when the keyword does not rank — so you no longer need a per-keyword `GET /api/v1/keywords/{id}` call to retrieve ranking URLs. The response includes a `counts` block at the envelope root: per-state counts plus `countedAgainstLimit` (active + queued; `removing` rows do NOT count against the keyword limit).

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| limit | integer | 50 | Max 100 |
| offset | integer | 0 | Pagination offset |
| state | string | `all` | `all`, `queued`, `active`, or `removing` |
| include_history | string | — | `7`, `14`, or `30` — days of sparkline data. Returns 400 if invalid. |
| market | uuid | — | Filter by market (Team tier only) |

**Response:**

```json
{
  "success": true,
  "schemaVersion": "3",
  "data": [
    {
      "id": "uuid",
      "phrase": "best seo tools",
      "state": "active",
      "displayName": null,
      "currentPosition": 5,
      "url": "https://example.com/seo-tools",
      "previousPosition": 8,
      "positionChange": -3,
      "positionChange7d": -5,
      "positionChange30d": -10,
      "bestPosition": 3,
      "worstPosition": 15,
      "volume": 12000,
      "difficulty": 45.2,
      "organicCtr": 0.12,
      "intent": "informational",
      "competitorCount": 15,
      "firstTrackedAt": "2024-06-01T00:00:00Z",
      "lastFetchedAt": "2024-12-31T10:00:00Z",
      "sparkline": [{ "date": "2024-12-25", "position": 8 }]
    },
    {
      "id": "uuid",
      "phrase": "actor headshots cambridge",
      "state": "active",
      "displayName": null,
      "currentPosition": null,
      "url": null,
      "previousPosition": null,
      "positionChange": null,
      "positionChange7d": null,
      "positionChange30d": null,
      "bestPosition": null,
      "worstPosition": null,
      "volume": 110,
      "difficulty": 38,
      "organicCtr": null,
      "intent": "commercial",
      "competitorCount": 0,
      "firstTrackedAt": "2026-04-06T00:00:00Z",
      "lastFetchedAt": "2026-05-24T00:00:00Z",
      "sparkline": null
    }
  ],
  "pagination": { "total": 71, "limit": 50, "offset": 0, "hasMore": true },
  "counts": { "total": 71, "active": 47, "queued": 11, "removing": 13, "countedAgainstLimit": 58, "limit": 60 }
}
```

#### GET /api/v1/keywords/:id

`schemaVersion: "3"`. Detailed keyword data. The `:id` segment accepts either a keyword UUID **or** a URL-encoded phrase (case-insensitive, trimmed) — phrase lookup is scoped to the authenticated tenant. The `keyword.state` field reports the row's lifecycle (`queued` | `active` | `removing`). Competitor `brandAuthority` is no longer included in the SERP competitors array.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| market | uuid | — | Filter by market (Team tier only) |

**Response:**

```json
{
  "success": true,
  "schemaVersion": "3",
  "data": {
    "keyword": {
      "id": "uuid",
      "phrase": "best seo tools",
      "state": "active",
      "currentPosition": 5,
      "rankingUrl": "https://example.com/seo-tools",
      "lastFetched": "2026-02-28T10:00:00Z",
      "bestPosition": 3,
      "worstPosition": 15
    },
    "serpResults": [
      {
        "position": 1,
        "title": "Top SEO Tools 2026",
        "url": "https://competitor.com/seo-tools",
        "domain": "competitor.com",
        "description": "...",
        "isOwnSite": false,
        "positionHistory": [
          { "date": "2026-02-21", "position": 1 },
          { "date": "2026-02-14", "position": 2 }
        ]
      }
    ],
    "competitors": [
      { "domain": "competitor.com", "position": 3, "domainAuthority": 45, "pageAuthority": 38, "spamScore": 2, "backlinkCount": 5000, "referringDomains": 200 }
    ],
    "sparkline30d": [{ "date": "2026-02-01", "position": 8 }],
    "sparkline180d": [{ "date": "2025-09-01", "position": 15 }],
    "mozMetrics": {
      "priority": 85,
      "difficulty": 45,
      "organicCtr": 0.12,
      "primaryIntent": "informational",
      "intentBreakdown": { "navigational": 0.1, "informational": 0.6, "commercial": 0.2, "transactional": 0.1 }
    },
    "serpFeatures": [
      { "type": "featured_snippet", "data": {} },
      { "type": "local_pack", "data": [] }
    ],
    "userDomainMetrics": {
      "domainAuthority": 38,
      "pageAuthority": 42,
      "spamScore": 1
    },
    "userBrandAuthority": 15
  }
}
```

> Moz metrics may be null if the phrase has not been analyzed. SERP features include: local_pack, featured_snippet, people_also_ask, knowledge_panel, etc. `positionHistory` in each SERP result shows weekly position snapshots.

> **PAA enrichment in `serpFeatures`:** when a keyword has a `people_also_ask` block, each question in the `data` array carries `answeringDomain`, `isAnsweredByUs`, and `answerPreview` alongside the raw scraper fields. Same per-question signals as `GET /api/v1/paa-questions` — use whichever endpoint fits your access pattern (per-keyword detail vs tenant-wide inventory).

> **Your DA in this response:** The `userDomainMetrics` object returns your domain's DA, PA, and spam score alongside competitor metrics, enabling side-by-side authority comparison per keyword. `userBrandAuthority` returns your brand authority score. For full domain metrics with history, see `GET /api/v1/domain-metrics`.

---

### Keyword Sub-Resources

Detailed data for individual keywords — SERP snapshots, history, and competitors.

#### GET /api/v1/keywords/:id/serp

Get the full SERP snapshot (top 30 results) for a keyword. Optionally specify a date for historical data.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| date | string | latest | `YYYY-MM-DD` format |
| market | uuid | — | Filter by market (Team tier only) |

**Response:**

```json
{
  "success": true,
  "data": {
    "keyword": { "id": "uuid", "phrase": "best seo tools" },
    "fetchedAt": "2024-12-31T10:00:00Z",
    "results": [
      { "position": 1, "title": "Top SEO Tools 2024", "url": "https://...", "domain": "example.com", "description": "...", "isOwnSite": false }
    ],
    "features": [{ "type": "featured_snippet", "data": {} }]
  }
}
```

#### GET /api/v1/keywords/:id/history

Get position history time series for a keyword.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| days | integer | 30 | `7`, `14`, `30`, `90`, or `180` |
| market | uuid | — | Filter by market (Team tier only) |

**Response:**

```json
{
  "success": true,
  "data": {
    "keywordId": "uuid",
    "phrase": "best seo tools",
    "days": 30,
    "history": [
      { "date": "2024-12-01", "position": 8 },
      { "date": "2024-12-02", "position": 7 }
    ]
  }
}
```

#### GET /api/v1/keywords/:id/competitors

Get competitors ranking for this specific keyword with domain metrics.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| limit | integer | 20 | Max 50 |
| market | uuid | — | Filter by market (Team tier only) |

**Response:**

```json
{
  "success": true,
  "data": [
    { "domain": "competitor.com", "position": 3, "domainAuthority": 45, "pageAuthority": 38, "spamScore": 2 }
  ],
  "total": 15
}
```

#### GET /api/v1/keywords/:id/schacht

Returns a fully assembled keyword intelligence bundle in a single call. Designed as the primary data source for content creation workflows — replaces the 8-10 orchestrated calls (SERP → per-competitor pages → competitor metrics → PAA → suggestions) with one consistent response.

**Tier:** Pro+

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| market | uuid | — | Filter by market (Team tier only) |

**Response:**

```json
{
  "success": true,
  "schemaVersion": "1",
  "data": {
    "keyword": {
      "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "phrase": "corporate headshots chicago",
      "currentPosition": 7,
      "rankingUrl": "https://example.com/corporate-headshots-chicago",
      "lastFetched": "2026-05-11T06:00:00Z",
      "marketId": "b2c3d4e5-f6a7-8901-bcde-f12345678901"
    },
    "competitors": [
      {
        "position": 1,
        "domain": "topheadshots.com",
        "url": "https://topheadshots.com/chicago",
        "domainAuthority": 28,
        "pageAuthority": 21,
        "page": {
          "title": "Chicago Corporate Headshots | Top Headshots",
          "metaDescription": "Professional corporate headshots in Chicago. Book today.",
          "h1": "Corporate Headshots Chicago",
          "headings": [
            { "level": 2, "text": "Why Corporate Headshots Matter" },
            { "level": 2, "text": "Our Process" }
          ],
          "wordCount": 1240,
          "schemaTypes": ["LocalBusiness", "Photograph"],
          "schemas": [],
          "imageAlts": ["chicago headshot photographer studio", "corporate headshot example"],
          "internalLinkCount": 14,
          "externalLinkCount": 2,
          "canonicalUrl": "https://topheadshots.com/chicago",
          "bodyText": "Full body text content of the ranking page..."
        }
      }
    ],
    "serpLeaderboard": [
      { "domain": "topheadshots.com", "position": 1, "domainAuthority": 28, "pageAuthority": 21, "isTenant": false },
      { "domain": "capturely.com", "position": 2, "domainAuthority": 35, "pageAuthority": 29, "isTenant": false },
      { "domain": "example.com", "position": 7, "domainAuthority": 18, "pageAuthority": 14, "isTenant": true }
    ],
    "paaQuestions": [
      {
        "question": "How much do corporate headshots cost?",
        "answerPreview": "Professional headshot pricing ranges from $150 to $450+...",
        "answeringDomain": "capturely.com",
        "isAnsweredByUs": false
      }
    ],
    "suggestions": [
      { "phrase": "corporate headshots near me", "occurrenceCount": 4 },
      { "phrase": "business headshots chicago", "occurrenceCount": 3 }
    ],
    "serpFeatures": [
      {
        "type": "featured_snippet",
        "data": { "answers": [{ "answer": "Corporate Headshots Chicago", "source": { "link": "https://example.com" } }] }
      },
      { "type": "people_also_ask", "data": [] }
    ],
    "refreshedAt": "2026-05-11T06:43:58Z"
  }
}
```

**Notes:**
- `competitors` contains up to 3 results. Own-site is auto-excluded — if the tenant ranks in position 1, 2, or 3, the next non-tenant result backfills the slot automatically.
- `page` is `null` when no scraped page content exists for the competitor's ranking URL.
- `bodyText` is always included. It is the highest-value field for content creation — never stripped.
- `paaQuestions[].isAnsweredByUs` is `true` when the PAA answer comes from the tenant's own domain.
- `suggestions` are keyword suggestions from the related searches scraped for this keyword. May be empty if this keyword's SERP has not yet been scraped for related searches.
- `serpFeatures` includes all feature types present: `featured_snippet`, `people_also_ask`, `image_pack`, `local_pack`, `video_results`, `knowledge_graph`, `related_searches`, `ads`.
- `serpLeaderboard` contains up to 10 entries (top 10 SERP domains for the keyword, deduplicated by domain). If the tenant domain ranks 11–30 and is not in the top 10, their row is appended as an 11th entry. `isTenant: true` identifies the tenant's row. Returns `[]` when no market is set.
- `refreshedAt` reflects when the underlying SERP data was last fetched (weekly cadence).

#### POST /api/v1/keywords

Add a keyword to your tracked keywords. The keyword is queued and ranking data will appear after the next scheduled SERP fetch.

**Request Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| phrase | string | Yes | Keyword phrase to track (1-500 chars) |

**Response (201):**

```json
{
  "success": true,
  "data": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "phrase": "actor headshots chicago",
    "status": "queued",
    "used": 15,
    "limit": 60,
    "note": "Keyword queued. Ranking data will appear after the next scheduled fetch."
  }
}
```

> `status` is `"queued"` for new keywords or `"active"` if reactivating a previously removed keyword. `used` is the enforced count (active + queued) that `limit` is checked against.

**Errors** — the shared add-keyword contract, identical across the v1 API and every dashboard add surface:

| Condition | Status | `error.code` | `error.details` |
|---|---|---|---|
| Tenant has no market set | 403 | `FORBIDDEN` | `{ locationRequired: true }` |
| Keyword limit reached | 403 | `QUOTA_EXCEEDED` | `{ resource, limit, current, upgradeRequired: true }` |
| Phrase already tracked | 409 | `CONFLICT` | — |

At-limit previously reported `FORBIDDEN` here; it now reports `QUOTA_EXCEEDED` with the limit and
current count in `details`, matching bulk-import. The status code is unchanged at `403`, and no
success payload changed, so `schemaVersion` stays `"1"`.

#### DELETE /api/v1/keywords/:id

`schemaVersion: "2"`. Remove a keyword from tracking. Active keywords are marked for removal (soft-delete) and cleaned up in the next cycle. Queued keywords are removed immediately (hard-delete).

The `:id` segment accepts either a keyword UUID **or** a URL-encoded phrase (case-insensitive, trimmed) — phrase lookup is scoped to the authenticated tenant.

To restore a soft-deleted keyword: POST the same phrase to `/api/v1/keywords` — the existing UUID and historical data are preserved.

**Response (200, soft-delete of active keyword):**

```json
{
  "success": true,
  "schemaVersion": "2",
  "data": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "phrase": "actor headshots chicago",
    "state": "removing",
    "pendingRemoval": true,
    "note": "Keyword marked for removal. Existing data preserved until next cleanup cycle.",
    "warning": "Tracked keywords are the foundation of your SEO data — rankings, competitor analysis, content planner assignments, and weekly reports all depend on them. Removing a keyword without replacing it leaves a gap in your tracking coverage."
  }
}
```

**Response (200, hard-delete of queued keyword — no `state` field since the row no longer exists):**

```json
{
  "success": true,
  "schemaVersion": "2",
  "data": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "phrase": "queued keyword",
    "pendingRemoval": false,
    "note": "Keyword removed (was still queued).",
    "warning": "..."
  }
}
```

> `pendingRemoval: true` means soft-delete (response includes `state: "removing"`). `pendingRemoval: false` means hard-delete (queued row removed immediately, no `state` field). Phrase-addressable equivalent: `DELETE /api/v1/keywords/actor%20headshots%20chicago` works the same as the UUID form. Returns `404` if not tracked, `409` if already pending removal.

---

### Overview

Dashboard summary in a single API call.

#### GET /api/v1/overview

Get a complete dashboard summary: position distribution, average position with trend, keyword counts, momentum, backlink summary, site health score, and last refresh timestamp.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| market | uuid | — | Filter by market (Team tier only) |

**Response:**

```json
{
  "success": true,
  "data": {
    "keywords": { "total": 25, "limit": 100 },
    "positions": {
      "distribution": { "position1": 2, "position2_3": 5, "position4_10": 8, "position11_20": 6, "position21Plus": 4, "notRanking": 0 },
      "averagePosition": 12.5,
      "previousAveragePosition": 14.2,
      "trend": "improving",
      "rankingCount": 25
    },
    "momentum": { "improving": 8, "declining": 3, "stable": 12 },
    "backlinks": { "totalActive": 150, "totalBacklinks": 500 },
    "siteHealth": { "score": 85, "issueCount": 11, "checkedAt": "2026-02-28T10:00:00Z" },
    "lastRefresh": "2026-02-28T10:00:00Z"
  }
}
```

> `siteHealth` is always present. Before `schemaVersion: "3"` it was `null` when no site health check had been performed; it is now always an object, with `score` and `checkedAt` `null` in that case and `issueCount` always populated (the count does not depend on a site-health record).

> `siteHealth.issueCount` is the **same number** `GET /api/v1/site-health` returns as `data.issueCount` for the same tenant in the same cycle — site-level plus page-level issues, dismissed included. Before `schemaVersion: "2"` this endpoint counted only site-level issues and the two endpoints disagreed.

> **See also:** For detailed data, see `GET /api/v1/rankings` (positions), `GET /api/v1/backlinks/summary` (backlinks), `GET /api/v1/site-health` (health).

---

### Rankings

#### GET /api/v1/rankings

Get top ranking keywords with position distribution.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| limit | integer | 20 | Max 100 (values above are clamped; `X-Clamped-Limit` header returned) |
| position_range | string | — | `top3`, `top10`, `page1`, `page2`, or `1-5`, `10-20` |
| change_direction | string | — | `improving`, `declining`, or `stable` |
| market | uuid | — | Filter by market (Team tier only) |

**Response:**

```json
{
  "success": true,
  "schemaVersion": "3",
  "data": {
    "items": [
      { "keywordId": "uuid", "keyword": "best seo tools", "position": 3, "url": "...", "previousPosition": 5, "positionChange": -2 }
    ],
    "distribution": { "position1": 2, "position2_3": 5, "position4_10": 8, "position11_20": 6, "position21Plus": 4, "notRanking": 0 },
    "rankingCount": 25
  },
  "pagination": { "total": 25, "limit": 20, "offset": 0, "hasMore": false }
}
```

#### GET /api/v1/rankings/history

Get position history for trend analysis.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| days | integer | 30 | `7`, `14`, `30`, or `90` |
| keyword_id | uuid | — | Filter to a single keyword |
| market | uuid | — | Filter by market (Team tier only) |

**Response:**

```json
{
  "success": true,
  "schemaVersion": "2",
  "data": {
    "items": [
      { "keywordId": "uuid", "phrase": "best seo tools", "date": "2024-12-25", "position": 5, "url": "..." }
    ],
    "meta": { "days": 30, "totalEntries": 750, "keywordCount": 25, "dateRange": { "from": "2024-12-01", "to": "2024-12-31" } }
  }
}
```

> **See also:** For full SERP results and competitor analysis per keyword, see `GET /api/v1/keywords/:id`.

---

### Rankings (Advanced)

Rank change feeds and declining keyword alerts.

#### GET /api/v1/rankings/changes

Get recent rank changes — an alerting feed of improvements, declines, new rankings, and lost positions.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| limit | integer | 20 | Max 100 |
| type | string | — | `improvement`, `decline`, `new`, or `lost` |
| market | uuid | — | Filter by market (Team tier only) |

**Response:**

```json
{
  "success": true,
  "schemaVersion": "1",
  "asOf": "2026-08-01T22:00:00.000Z",
  "data": [
    { "keyword": "best seo tools", "type": "improvement", "oldPosition": 12, "newPosition": 5, "change": -7, "date": "2024-12-31" }
  ]
}
```

`change` is signed current − previous, so an improvement is negative. It is **not** the
same number as `details.position_change` on `GET /api/v1/alerts`, which is previous −
current for the same alert row.

#### GET /api/v1/rankings/declining

Get keywords losing position, ordered by urgency. Each keyword includes a `dismissed` field.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| limit | integer | 20 | Max 50 |
| offset | integer | 0 | Pagination offset |
| market | uuid | — | Filter by market (Team tier only). Note: currently returns primary market data only. |
| include_dismissed | string | — | Set to `false` to exclude dismissed keywords |

**Response:**

```json
{
  "success": true,
  "schemaVersion": "2",
  "data": [
    { "keyword": "seo automation", "currentPosition": 25, "previousPosition": 12, "change": 13, "dismissed": false }
  ],
  "pagination": { "total": 8, "limit": 20, "offset": 0, "hasMore": false }
}
```

---

### Competitors

#### GET /api/v1/competitors

Get top competitors with domain authority and share of voice.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| limit | integer | 10 | Max 50 |
| min_overlap | integer | — | Minimum keyword overlap |
| min_da | integer | — | Minimum domain authority |
| sort_by | string | `overlap` | `overlap`, `da`, or `position` |
| include_sov | boolean | false | Include share of voice data |
| market | uuid | — | Filter by market (Team tier only) |

**Response:**

```json
{
  "success": true,
  "schemaVersion": "4",
  "data": {
    "items": [
      {
        "domain": "competitor.com",
        "companyName": "Competitor Inc",
        "domainAuthority": 45,
        "pageAuthority": 38,
        "spamScore": 2,
        "keywordsRankedFor": 15,
        "shareOfVoice": 0.25,
        "lastSeenAt": "2024-12-31T10:00:00Z"
      }
    ],
    "summary": { "total": 50, "averageDomainAuthority": 38 }
  },
  "pagination": { "total": 50, "limit": 10, "offset": 0, "hasMore": true }
}
```

> Set `include_sov=true` to add a `shareOfVoice` array inside `data` (per-competitor breakdown).

> **See also:** For your own domain's authority metrics (DA, PA, spam score), see `GET /api/v1/domain-metrics`. For side-by-side DA comparison on a per-keyword basis, see `GET /api/v1/keywords/:id` (`userDomainMetrics`).

#### GET /api/v1/competitors/:domain

Get a full competitor intelligence dossier: metrics, domain intelligence, site health, keyword overlap stats, scraped pages, page speed data, and keyword overlap details.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| market | uuid | — | Filter by market (Team tier only) |

**Response:**

```json
{
  "success": true,
  "data": {
    "info": { "domain": "competitor.com", "companyName": "Competitor Inc" },
    "metrics": { "domainAuthority": 45, "pageAuthority": 38, "spamScore": 2 },
    "intelligence": {},
    "siteHealth": { "healthScore": 78 },
    "stats": { "keywordsOverlapping": 15, "keywordsAhead": 8, "keywordsBehind": 7, "pagesAnalyzed": 50 },
    "pages": [
      {
        "url": "https://competitor.com/services",
        "title": "Our Services",
        "wordCount": 1200,
        "fetchedAt": "2026-02-28T10:00:00Z"
      }
    ],
    "pageSpeed": {
      "performanceScore": 72,
      "lcpMs": 2100,
      "cls": 0.08,
      "url": "https://competitor.com/"
    },
    "keywordOverlap": [
      {
        "keyword": "best seo tools",
        "keywordId": "uuid",
        "competitorPosition": 3,
        "ourPosition": 5,
        "positionGap": 2
      }
    ]
  }
}
```

> Domain must be a competitor that appears in your SERP results. `pages`, `pageSpeed`, and `keywordOverlap` provide additional detail beyond the basic dossier. `pageSpeed` may be null if no PageSpeed data has been collected for the competitor.

#### GET /api/v1/competitors/:domain/keywords

Get keyword overlap with position comparison. Shows where you and this competitor both rank.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| limit | integer | 50 | Max 100 |
| offset | integer | 0 | Pagination offset |
| sort_by | string | `gap` | `gap`, `position`, or `phrase` |
| market | uuid | — | Filter by market (Team tier only) |

**Response:**

```json
{
  "success": true,
  "data": [
    { "keyword": "best seo tools", "keywordId": "uuid", "competitorPosition": 3, "ourPosition": 5, "positionGap": 2 }
  ],
  "pagination": { "total": 15, "limit": 50, "offset": 0, "hasMore": false }
}
```

---

### Competitor Intelligence

#### GET /api/v1/competitors/:domain/metrics-history

Get domain authority, page authority, and spam score trend over time.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| limit | integer | 12 | Max 52 |

**Response:**

```json
{
  "success": true,
  "data": [
    { "date": "2024-12-31", "domainAuthority": 45, "pageAuthority": 38, "spamScore": 2 }
  ],
  "total": 12
}
```

#### GET /api/v1/competitors/share-of-voice

Get the SERP dominance leaderboard — which domains own the most top positions across your tracked keywords.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| limit | integer | 20 | Max 50 |
| market | uuid | — | Filter by market (Team tier only) |

**Response:**

```json
{
  "success": true,
  "data": [
    { "domain": "competitor.com", "top3Count": 5, "top10Count": 12 }
  ]
}
```

---

### Opportunities

#### GET /api/v1/opportunities

Get striking distance and declining keywords. Declining keywords include a `dismissed` field.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| type | string | `all` | `all`, `striking_distance`, or `declining` |
| limit | integer | 20 | Max 100 |
| min_position | integer | 1 | Minimum position |
| max_position | integer | 100 | Maximum position |
| sort_by | string | `score` | `score`, `position`, or `change`. Returns 400 if invalid. |
| market | uuid | — | Filter by market (Team tier only) |
| include_dismissed | string | — | Set to `false` to exclude dismissed declining keywords |

**Response:**

```json
{
  "success": true,
  "schemaVersion": "2",
  "data": {
    "strikingDistance": {
      "keywords": [{ "keywordId": "uuid", "keyword": "seo automation", "currentPosition": 12, "bestRecentPosition": 11, "opportunityScore": 85 }],
      "count": 5
    },
    "declining": {
      "keywords": [{ "keywordId": "uuid", "keyword": "keyword research", "currentPosition": 15, "previousPosition": 8, "positionChange": 7, "dismissed": false }],
      "count": 3
    }
  }
}
```

> `data` is a compound object with two named sub-collections — `strikingDistance` and `declining`. Either may be omitted depending on the `type` filter.

---

### Opportunities (Advanced)

#### GET /api/v1/opportunities/vulnerable

Curated Easy Wins — competitors ranking above you with weak SEO signals (missing H1, thin content, etc.) that you can realistically overtake. Each opportunity includes a `dismissed` field.

**Ranking model:** opportunities are scored by `impact × beatability`, where impact rewards higher search volume and larger position jumps, and beatability rewards the tenant's signal advantage (`gap`) and competitor red-flag count. Hard gates require `gap > 0` (tenant's composite score actually beats competitor's) and `redFlags.count >= 2`. After scoring, opportunities are de-duplicated per keyword (only the highest-scoring competitor for each keyword is returned) and soft-capped at 3 rows per competitor domain, so one weak competitor can't monopolize the list.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| limit | integer | 10 | Max 50. Default matches dashboard UX. |
| market | uuid | — | Filter by market (Team tier only) |
| include_dismissed | string | — | Set to `false` to exclude dismissed items |

**Response:**

```json
{
  "success": true,
  "data": [
    {
      "keywordId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "keyword": "local seo tips",
      "volume": 2400,
      "tenantPosition": 15,
      "tenantScore": 68.5,
      "competitor": {
        "position": 4,
        "domain": "weak-site.com",
        "url": "https://weak-site.com/guide",
        "score": 32.1,
        "redFlags": {
          "thinContent": true,
          "missingH1": true,
          "missingSchema": true,
          "count": 3
        }
      },
      "gap": 36.4,
      "weaknesses": ["Lower DA (18 vs your 34)", "Thin content"],
      "compositeScore": 79.82,
      "previousAttempts": 1,
      "dismissed": false
    }
  ],
  "total": 6
}
```

**Fields:**
- `compositeScore` — ranking score (DESC). Exposed for inspection; don't re-sort by other fields client-side and expect the same curation.
- `previousAttempts` — count of all-time Task Completed events for this `(keyword, competitor)` pair. Surfaced for context, NOT used as a score penalty. An item with prior attempts that re-appears is still considered a good opportunity based on current signals.
- `gap` — `tenantScore − competitor.score` (positive means the tenant's SEO signals are stronger overall).

---

### Pages

Access scraped page metadata for SEO analysis.

#### GET /api/v1/pages

List a domain's **ranking pages** — the pages holding a top-30 position for a tracked keyword
in the last 8 days, via `tenant_ranking_pages()`.

Both the list and `pagination.total` report that population, and it is the same population
`GET /api/v1/competitors/{domain}` reports as `stats.pagesAnalyzed` for the same domain. Pages
that were crawled but do not rank are not listed: a page that does not rank is not a page this
product reports on (see the ranking-pages premise in `docs/PRODUCT-SPEC.md`). Until
schemaVersion `"2"` this endpoint listed every crawled page for the domain, which published
116 pages for a domain whose competitor dossier reported 5 in the same cycle.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| domain | string | **required** | Domain to list pages for |
| limit | integer | 50 | Max 100 |
| offset | integer | 0 | Pagination offset |

**Response:**

```json
{
  "success": true,
  "data": [
    {
      "id": "uuid",
      "url": "https://example.com/page",
      "domain": "example.com",
      "title": "Page Title",
      "metaDescription": "Description...",
      "h1": "Main Heading",
      "canonicalUrl": "https://example.com/page",
      "wordCount": 1500,
      "h1Count": 1,
      "schemaTypes": ["Article"],
      "internalLinkCount": 25,
      "externalLinkCount": 5,
      "fetchedAt": "2024-12-31T10:00:00Z"
    }
  ],
  "pagination": { "total": 50, "limit": 50, "offset": 0, "hasMore": false }
}
```

#### GET /api/v1/pages/:id

Get detailed page metadata with derived SEO signals. Always returns heading structure, indexability, mobile, and image alt coverage signals. Heavy arrays (full image list, link URL lists) and keyword-dependent signals are opt-in via query params.

Archived pages are not served here. A page whose `page_status` is `removed` or `redirected`
returns `404` — the same population rule `GET /api/v1/pages` applies to its list. Until
schemaVersion `"3"` such a page was absent from the list and served in full by id.

**Query params:**

| Param | Description |
|---|---|
| `include` | Comma-separated opt-in fields: `images`, `links` |
| `targetKeyword` | Keyword phrase — adds `keywordPlacement` and `keywordDensity` computed from stored body text |

**Response (always-present fields):**

```json
{
  "success": true,
  "schemaVersion": "2",
  "data": {
    "id": "uuid",
    "url": "https://example.com/page",
    "title": "Page Title",
    "metaDescription": "Description...",
    "h1": "Main Heading",
    "h1Count": 1,
    "canonicalUrl": "https://example.com/page",
    "ogImage": "https://example.com/og.jpg",
    "ogTitle": "Page Title - OG",
    "ogDescription": "Description for social sharing",
    "wordCount": 1500,
    "headings": [{ "level": 1, "text": "Main Heading" }, { "level": 2, "text": "Subheading" }],
    "schemas": [{ "@type": "Article", "headline": "..." }],
    "imageAlts": ["Logo", "Hero image"],
    "internalLinkCount": 25,
    "externalLinkCount": 5,
    "bodyText": "Full body text of the page...",
    "h2Count": 3,
    "h2Text": ["Services", "About Us", "Contact"],
    "h3Count": 2,
    "h3Text": ["Our Team", "Get in Touch"],
    "hierarchyBroken": false,
    "hierarchyIssue": null,
    "missingAltCount": 1,
    "noindexDetected": false,
    "nofollowDetected": false,
    "viewportPresent": true,
    "viewportCorrect": true
  }
}
```

**With `?include=images`** — adds full image list:

```json
{
  "data": {
    "images": [
      { "src": "https://example.com/hero.jpg", "alt": "Hero image" },
      { "src": "https://example.com/logo.png", "alt": "" }
    ]
  }
}
```

**With `?include=links`** — adds full link URL arrays:

```json
{
  "data": {
    "internalLinks": ["/about", "/services", "/contact"],
    "externalLinks": ["https://facebook.com/example"]
  }
}
```

**With `?targetKeyword=headshot photographer`** — adds keyword signals:

```json
{
  "data": {
    "keywordPlacement": {
      "inTitle": true,
      "inH1": true,
      "inMetaDescription": false,
      "inFirst100Words": true,
      "inImageAlt": false,
      "inUrlSlug": false
    },
    "keywordDensity": {
      "frequency": 8,
      "percentage": 0.53,
      "topBigrams": [{ "phrase": "headshot photographer", "count": 5, "percentage": 0.33 }],
      "topTrigrams": []
    }
  }
}
```

**Note:** `keywordPlacement` and `keywordDensity` are `null` when the page's body text is absent. Body text is retained for 4 weeks on tenant pages and 2 weeks on competitor pages, then nulled per the data retention policy.

#### GET /api/v1/pages/:id/history

Get historical versions of a page to track content changes over time.

Archived pages are not served here. A page whose `page_status` is `removed` or `redirected`
returns `404` — the same population rule `GET /api/v1/pages` applies to its list. Until
schemaVersion `"2"` such a page was absent from the list and served in full by id.

**Response:**

```json
{
  "success": true,
  "data": {
    "current": {
      "id": "uuid",
      "fetchedAt": "2024-12-31T10:00:00Z",
      "title": "Page Title",
      "metaDescription": "Description...",
      "h1": "Main Heading",
      "wordCount": 1500,
      "schemaTypes": ["Article"],
      "headings": [],
      "schemas": [],
      "imageAlts": [],
      "internalLinkCount": 25,
      "externalLinkCount": 5,
      "bodyText": "..."
    },
    "history": [
      {
        "id": "uuid",
        "fetchedAt": "2024-12-15T10:00:00Z",
        "title": "Old Page Title",
        "wordCount": 1200
      }
    ]
  }
}
```

> Returns the current version and up to 4 historical snapshots. Useful for tracking content changes and their impact on rankings.

#### GET /api/v1/pages/:id/pagespeed

Get Core Web Vitals and PageSpeed metrics for a page.

Archived pages are not served here. A page whose `page_status` is `removed` or `redirected`
returns `404` — the same population rule `GET /api/v1/pages` applies to its list. Until
schemaVersion `"2"` such a page was absent from the list and served in full by id.

**Response:**

```json
{
  "success": true,
  "data": {
    "pageId": "uuid",
    "url": "https://example.com/page",
    "performanceScore": 85,
    "lcpMs": 1250,
    "cls": 0.05,
    "lcpElement": "<img src=\"hero.jpg\" />",
    "largeResources": [
      { "url": "https://example.com/hero.jpg", "size": 524288, "sizeKb": 512, "type": "image" }
    ],
    "clsCulprits": ["div.banner"]
  }
}
```

> Returns null for metrics if no PageSpeed data has been collected yet. Metrics are from mobile device audits.

#### GET /api/v1/pages/compare

Compare two pages for SEO analysis.

Archived pages are not served here. A page whose `page_status` is `removed` or `redirected`
returns `404` — the same population rule `GET /api/v1/pages` applies to its list. Until
schemaVersion `"2"` such a page was absent from the list and served in full by url.

Both URLs are normalized before lookup (lowercased host, no `www.`, no trailing slash), so a
cosmetic variation of a URL we hold resolves instead of 404ing.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| url1 | string (URL) | **required** | First page URL |
| url2 | string (URL) | **required** | Second page URL |

**Response:**

```json
{
  "success": true,
  "data": {
    "page1": {
      "url": "https://example.com/page1",
      "title": "Page 1",
      "canonicalUrl": "https://example.com/page1",
      "ogImage": "https://example.com/og1.jpg",
      "wordCount": 1500
    },
    "page2": {
      "url": "https://example.com/page2",
      "title": "Page 2",
      "wordCount": 1000
    },
    "comparison": {
      "wordCountDiff": 500,
      "h1CountDiff": 0,
      "schemaTypesOnlyIn1": ["LocalBusiness"],
      "schemaTypesOnlyIn2": [],
      "schemaTypesInBoth": ["Article"]
    }
  }
}
```

---

### Page Issues

#### GET /api/v1/pages/issues

Get SEO page issues: orphan pages, duplicate titles, and duplicate meta descriptions.

These are the same three findings `GET /api/v1/site-health` publishes as the `orphan_page`,
`duplicate_title` and `duplicate_meta_description` issues — one derivation, so the two
endpoints cannot disagree. The findings are reported **about your ranking pages** and compared
**against every crawled page on the site**, so a ranking page that collides with a page that
does not rank is still reported.

- **Orphan** means *nothing links to it*. Until schemaVersion `"2"` this endpoint answered the
  opposite question — pages with at most one link **on** them — and returned dead-end pages
  under the name "orphan"; for one production domain it reported none while `/site-health`
  reported five.
- `orphanPages.detectionSkipped` is `true` when a page in the link graph had unmeasured links,
  so no orphan verdict could be reached. An empty list then means *not decided*, not *none*.
- Each section carries `dismissed` (the issue code was dismissed in the fix queue) and `total`
  (the untruncated population — `limit` bounds `items` only).

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| type | string | `all` | `orphan`, `duplicate_titles`, `duplicate_descriptions`, or `all` |
| limit | integer | 20 | Max 100 — bounds `items`, never `total` |
| include_dismissed | boolean | `true` | `false` empties a section whose issue code has been dismissed |

**Response:**

```json
{
  "success": true,
  "schemaVersion": "2",
  "data": {
    "orphanPages": {
      "dismissed": false,
      "total": 1,
      "detectionSkipped": false,
      "items": [{ "url": "https://example.com/orphan", "title": "Orphan Page" }]
    },
    "duplicateTitles": {
      "dismissed": false,
      "total": 1,
      "items": [
        {
          "value": "Our Services",
          "pages": [
            { "url": "https://example.com/services-1", "title": "Our Services", "h1": "Our Services" },
            { "url": "https://example.com/services-2", "title": "Our Services", "h1": "Our Services" }
          ]
        }
      ]
    },
    "duplicateDescriptions": {
      "dismissed": false,
      "total": 0,
      "items": []
    }
  }
}
```

---

### Image Analysis

AI-powered image alt text analysis and suggestions.

#### GET /api/v1/image-analysis

List analyzed images with AI-suggested alt text, titles, and filenames.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| filter | string | `all` | `all` or `needs_alt`. `needs_alt` is the missing-alt POPULATION: the page's current content images (logo/tracking-pixel/placeholder patterns and AI-decorative images excluded) whose alt is missing or blank, over the tenant's ranking pages — the same number the Fix Queue shows. Never-analysed images appear with `analysisStatus: "pending"` and null suggestions; analysed rows whose image is no longer on the page (or no longer blank-alt) do not appear. |
| source | string | `all` | `all`, `cron` (ranking-page sweep), `manual` (dashboard Generate tab), or `api` (POST /generate) |
| limit | integer | 50 | Max 100 |
| offset | integer | 0 | Pagination offset |

**Response:**

```json
{
  "success": true,
  "data": [
    {
      "id": "uuid",
      "pageUrl": "https://example.com/page",
      "imageSrc": "https://example.com/image.jpg",
      "imageIndex": 0,
      "currentAlt": "",
      "currentFilename": "IMG_001.jpg",
      "suggestedAlt": "Professional headshot of team member",
      "suggestedTitle": "Team Member Photo",
      "suggestedFilename": "team-member-headshot.jpg",
      "suggestedDescription": "Professional portrait...",
      "isDecorative": false,
      "imageCategory": "photograph",
      "analysisStatus": "completed",
      "analyzedAt": "2024-12-31T10:00:00Z",
      "fileSizeBytes": 184729,
      "contentType": "image/jpeg",
      "widthAttr": 1200,
      "heightAttr": 800,
      "loadingAttr": "lazy",
      "isLazyLoaded": false,
      "source": "cron"
    }
  ],
  "pagination": { "total": 120, "limit": 50, "offset": 0, "hasMore": true },
  "summary": {
    "total": 120,
    "completed": 100,
    "needs_alt": 45,
    "decorative": 8,
    "skipped": 0,
    "failed": 0,
    "by_source": { "cron": 115, "manual": 4, "api": 1 }
  }
}
```

> Manual / API rows have synthetic `pageUrl` values (`manual:<uuid>` / `api:<uuid>`). They aren't real URLs — surface them as "Manual submission" / "API submission" in your UI.

---

#### POST /api/v1/image-analysis/generate

Generate AI alt text, title, filename, and JSON-LD ImageObject schema for a single image URL.

Counts against the tenant's monthly quota — Pro: 100/mo, Team: 200/mo. Quota is shared across the dashboard Generate tab and this endpoint.

**Body:**

```json
{ "imageUrl": "https://your-cdn.com/photo.webp" }
```

| Field | Type | Description |
|-------|------|-------------|
| imageUrl | string (https URL, ≤ 2048 chars) | Absolute https:// image URL. Must be reachable and under 5MB. |

**Response (200):**

```json
{
  "success": true,
  "data": {
    "pageUrl": "api:9f8a-1234-...",
    "imageSrc": "https://your-cdn.com/photo.webp",
    "currentFilename": "photo.webp",
    "suggestedAlt": "Outdoor portrait of a woman with her golden retriever",
    "suggestedTitle": "Sarah and Charlie | Loveschacht Studio",
    "suggestedFilename": "outdoor-portrait-woman-dog-loveschacht.webp",
    "suggestedDescription": "Sarah holding her golden retriever Charlie at sunset",
    "isDecorative": false,
    "imageCategory": "photograph",
    "suggestedSchema": { "@type": "ImageObject", "contentUrl": "https://your-cdn.com/photo.webp" },
    "attributionSuffix": null,
    "analysisStatus": "completed",
    "errorMessage": null,
    "source": "api"
  },
  "quota": { "used": 17, "limit": 100, "resetsAt": "2026-05-01T00:00:00.000Z" }
}
```

**Response (429 — quota reached):**

```json
{
  "success": false,
  "error": "Monthly quota reached",
  "quota": { "used": 100, "limit": 100, "resetsAt": "2026-05-01T00:00:00.000Z" }
}
```

> When the URL fails pre-flight (`unreachable`, `exceeds_5mb`, `http_only`, `not_image`, etc.), the response is 200 with `analysisStatus: "skipped"`. **No quota is consumed.**

#### GET /api/v1/image-analysis/quota

Check your remaining monthly generation quota.

**Response:**

```json
{
  "success": true,
  "data": { "used": 17, "limit": 100, "resetsAt": "2026-05-01T00:00:00.000Z" }
}
```

| Field | Description |
|-------|-------------|
| used | Submissions this month (manual + api combined) |
| limit | Plan-derived monthly limit (Pro: 100, Team: 200, others: 0) |
| resetsAt | First moment of the next UTC calendar month |

#### GET /api/v1/image-analysis/export

Export image analysis results as CSV or JSON for bulk processing.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| format | string | `json` | `json` or `csv` |
| filter | string | `all` | `all` or `needs_alt`. `needs_alt` is the missing-alt POPULATION: the page's current content images (logo/tracking-pixel/placeholder patterns and AI-decorative images excluded) whose alt is missing or blank, over the tenant's ranking pages — the same number the Fix Queue shows. Never-analysed images appear with `analysis_status: "pending"` and null suggestions; analysed rows whose image is no longer on the page (or no longer blank-alt) do not appear. |
| source | string | `all` | `all`, `cron`, `manual`, or `api` |
| page_url | string (URL) | — | Filter to a specific page URL (cron rows only — manual / api rows have synthetic page URLs and aren't matched) |

**Response (JSON):**

```json
{
  "success": true,
  "schemaVersion": "2",
  "data": [
    {
      "page_url": "https://example.com/page",
      "image_src": "https://example.com/image.jpg",
      "current_alt": "",
      "current_filename": "IMG_001.jpg",
      "suggested_alt": "Professional headshot",
      "attribution_suffix": "Photo by 312 Elements",
      "assembled_alt": "Professional headshot, Photo by 312 Elements",
      "suggested_title": "Team Member Photo",
      "suggested_filename": "team-member-headshot.jpg",
      "is_decorative": false,
      "image_category": "photograph",
      "analysis_status": "completed",
      "analyzed_at": "2024-12-31T10:00:00Z",
      "file_size_bytes": 184729,
      "content_type": "image/jpeg",
      "width_attr": 1200,
      "height_attr": 800,
      "loading_attr": "lazy",
      "is_lazy_loaded": false
    }
  ],
  "summary": { "domain": "example.com", "count": 120 }
}
```

> CSV format returns a downloadable file with `Content-Disposition` header.

---

### Backlinks

Backlink profile analysis, activity monitoring, and link building opportunities.

#### GET /api/v1/backlinks

URL-level backlinks tracked since your tenant onboarded. Each week the delta sync queries Moz for newly gained and lost backlinks and appends them to your changelog; this endpoint reads the changelog and returns rows whose latest event is `gained`. `summary.trackedSince` carries the onboarding anchor (your `recon_completed_at`); URL-level backlinks accrued before that date are captured at the **domain** level only — use `GET /api/v1/backlinks/referring-domains` for the full referring-domain inventory pulled at onboarding.

`firstSeenAt` on each row is the date the delta sync first discovered that URL; `lastSeenAt` is the most recent event (gained or lost) for that URL.

The data layer applies your DA threshold (`backlink_min_da` on your tenant) and the platform-wide blocklist; pass `minDA` to raise the threshold further.

Backlink data is permanent (migration 407 installs database-level deletion blocks on all four backlink tables). Pagination is required for large profiles.

> **Where `status` comes from.** Backlink state is **Moz's view of the link graph, reported as-is.** A domain is marked `lost` when Moz's own cumulative per-URL deltas account for every link Moz ever reported from it — the gained and lost events Moz sends each week, arithmetic over data we already hold, with no extra provider calls and no re-fetching of referring pages. A later Moz gain moves it back to `active`. If Moz simply stops listing a domain without ever reporting the loss, the domain stays `active` here indefinitely and the referring-domain count stays high by that many; that is the accepted consequence of reporting the provider's view rather than second-guessing it. Read `source` and `lastSyncedAt` on `GET /api/v1/backlinks/summary` before concluding anything from `lost30d: 0` — a zero beside a stale `lastSyncedAt` means we have not heard from Moz recently, not that nothing was lost.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| limit | integer | 50 | Max 100 |
| offset | integer | 0 | Pagination offset |
| minDA | integer | — | Effective filter is `max(tenant.backlink_min_da, minDA)` |
| section | string | `clean` | `clean` (default) or `quarantined`. Link-farm sources are excluded from the default listing and its counts; `section=quarantined` returns that partition — same item shape, rows intact. |

**Response:**

```json
{
  "success": true,
  "schemaVersion": "2",
  "data": [
    {
      "sourceUrl": "https://blog.example.com/best-headshot-photographers",
      "sourceDomain": "blog.example.com",
      "anchorText": "top headshot photographers",
      "domainAuthority": 52,
      "firstSeenAt": "2024-06-15T10:00:00Z",
      "lastSeenAt": "2025-01-12T10:00:00Z"
    }
  ],
  "summary": { "trackedSince": "2024-05-20T14:30:00Z" },
  "pagination": { "total": 1200, "limit": 50, "offset": 0, "hasMore": true }
}
```

**Example request:**

```bash
curl -H "Authorization: Bearer $KT_API_KEY" \
  "https://seo.312elements.com/api/v1/backlinks?limit=100&minDA=20"
```

#### GET /api/v1/backlinks/summary

Get your backlink profile overview: referring domains, total backlinks, 30-day gains/losses, competitor gains, and trend.

**Response:**

```json
{
  "success": true,
  "data": {
    "referringDomains": 150,
    "totalBacklinks": 500,
    "gained30d": 12,
    "lost30d": 3,
    "competitorGained30d": 8,
    "netChange": 9,
    "trend": "up",
    "source": "moz",
    "lastSyncedAt": "2026-07-26T03:14:00Z"
  }
}
```

**Where these numbers come from**

`source` is `"moz"` on every response. Backlink state — which domains are `active`, what
counts as gained or lost — is **Moz's view of the link graph, reported as-is**. We do not
re-fetch referring pages to second-guess the provider, so if Moz's index still lists a link
that has actually been removed, that domain stays `active` here until Moz drops it.

`lastSyncedAt` is the last time your backlink data was **successfully** synced from Moz. It
is stamped only on a successful sync — a failed or circuit-broken run does not advance it —
so it is a genuine "last time we heard from the provider" timestamp, not a job-run marker.

Read the two together. `lost30d: 0` with a `lastSyncedAt` from this week means Moz reports
no losses. `lost30d: 0` with a `lastSyncedAt` from three weeks ago means we have no fresh
data, which is not the same claim.

For per-domain freshness, `GET /api/v1/backlinks/referring-domains` carries `lastSeenAt` —
the last sync in which Moz still listed that specific domain.

> **See also:** For domain-level authority metrics (DA, PA, spam score), see `GET /api/v1/domain-metrics`.

#### GET /api/v1/backlinks/activity

Paginated weekly breakdown of gained and lost backlinks with individual link details. Full history since this tenant onboarded is reachable — pass increasing `offset` to walk backwards in `weeks`-sized pages. There is no server-imposed cap on how deep you can page; `pagination.total` reports the total calendar weeks since onboarding.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| weeks | integer | 4 | Page size — the number of weekly buckets to return. No maximum. |
| offset | integer | 0 | Number of weeks to shift the window backwards from the current week. |

**Response:**

```json
{
  "success": true,
  "schemaVersion": "2",
  "data": {
    "summary": {
      "tenantGainedLast30Days": 50,
      "tenantLostLast30Days": 10,
      "competitorGainedLast30Days": 30,
      "netChange": 40,
      "trend": "up"
    },
    "weeks": [
      {
        "weekStart": "2024-12-23",
        "weekLabel": "12/23",
        "tenant": {
          "gained": 12,
          "lost": 3,
          "gainedLinks": [
            {
              "sourceUrl": "https://blog.example.com/post",
              "sourceDomain": "blog.example.com",
              "anchorText": "best seo tools",
              "domainAuthority": 52
            }
          ],
          "lostLinks": []
        },
        "competitors": {
          "gained": 8,
          "topGainers": [
            {
              "competitorDomain": "competitor.com",
              "sourceUrl": null,
              "sourceDomain": "industry-blog.com",
              "anchorText": null,
              "domainAuthority": 60
            }
          ]
        }
      }
    ]
  },
  "pagination": {
    "total": 347,
    "limit": 4,
    "offset": 0,
    "hasMore": true
  }
}
```

`summary` is always trailing-30-day, independent of `offset` / `weeks`. It is emitted on every paginated request; its values are stable across pages.

For pre-onboarding tenants (no `recon_completed_at`), the response is `{ data: { summary, weeks: [] }, pagination: { total: 0, hasMore: false } }`.

#### GET /api/v1/backlinks/opportunities

Get link building opportunities — competitor backlinks you are missing, filterable by minimum domain authority.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| min_da | integer | 20 | Minimum domain authority |
| limit | integer | 50 | Max 200 |

**Response:**

```json
{
  "success": true,
  "schemaVersion": "2",
  "data": {
    "items": [
      { "sourceDomain": "blog.example.com", "domainAuthority": 45, "competitorCount": 3, "competitorDomains": ["a.com", "b.com", "c.com"] }
    ],
    "totalAvailable": 50
  }
}
```

> An optional `data.mode: "pitbull"` field is included when the request enables aggressive opportunity surfacing.

#### GET /api/v1/backlinks/opportunities/global

**Team tier only.** Get global backlink opportunities — domains linking to any competitor but not to you, without market or location filtering.

The standard `/api/v1/backlinks/opportunities` scopes results to competitors in your market and geographic area. This endpoint removes both filters, revealing opportunities from competitors nationwide. Ideal for cross-market link building and Claude Code analysis.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| min_da | integer | 20 | Minimum domain authority |
| limit | integer | 50 | Max 200 |

**Response:**

```json
{
  "success": true,
  "schemaVersion": "2",
  "data": {
    "items": [
      { "sourceDomain": "photography-blog.com", "domainAuthority": 52, "competitorCount": 12, "competitorDomains": ["a.com", "b.com", "..."] }
    ],
    "totalAvailable": 5432
  }
}
```

---

### Site Health

Technical SEO health score, checks, and historical trends.

#### GET /api/v1/site-health

Get current site health score with all technical SEO checks: platform, sitemap, robots.txt, schema, and a unified issues array combining both site-level infrastructure issues and page-level SEO issues (missing H1, thin content, etc.) — consistent with what the dashboard GUI shows.

`robots.robotsContent` is your robots.txt **as fetched by the last site-health check**, verbatim. It is `null` until a check has run. It is never reconstructed from `robots.blockedPaths` — before 2026-07-28 it was, which meant a site allowing everything was reported back to its owner as `User-agent: *\nDisallow: /`. Each issue includes a `dismissed` field indicating whether the user has dismissed it. Page-level issues carry the affected URL(s) in `affectedPages` so an agent can act on a specific page; site-level issues omit `affectedPages`.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| include_dismissed | string | — | Set to `false` to exclude dismissed issues from the response |

**Reconciling this endpoint with everything else you can see**

Every surface derives its issues from one accessor, so each number has an API call that
reproduces it exactly:

| What you're looking at | How to reproduce it |
|---|---|
| `GET /api/v1/site-health` `data.issueCount` | `siteIssueCount + pageIssueCount`, always |
| `GET /api/v1/overview` `data.siteHealth.issueCount` | the same total — identical in the same cycle, including when the tenant has no site-health record. As of `schemaVersion: "3"` `data.siteHealth` is never `null`, so there is no state in which one endpoint publishes a count and the other does not |
| The weekly recap email's "N open issues" | the same total |
| The dashboard's Fix Queue badge and coach count | `GET /api/v1/site-health?include_dismissed=false` → `data.issueCount` |
| The page-health modal's issue list | `data.pageIssueCount` |

If any two of these disagree for the same tenant in the same cycle, that is a bug — report it.

**Response:**

```json
{
  "success": true,
  "data": {
    "healthScore": 85,
    "checkedAt": "2026-02-27T12:00:00.000Z",
    "issueCount": 12,
    "siteIssueCount": 3,
    "pageIssueCount": 9,
    "platform": {
      "platform": "wordpress",
      "platformDisplay": "WordPress",
      "confidence": "high",
      "signals": ["wp-content", "wp-json"],
      "sitemapAutoGenerates": true,
      "sitemapLocation": "/sitemap.xml",
      "sitemapPluginRequired": false,
      "sitemapInstructions": "WordPress auto-generates sitemaps since v5.5..."
    },
    "sitemap": {
      "sitemapUrl": "https://example.com/sitemap.xml",
      "urlCount": 150,
      "lastModified": "2026-02-15T00:00:00.000Z",
      "staleDays": 12
    },
    "robots": {
      "blockedPaths": ["/wp-admin/", "/wp-includes/"],
      "robotsContent": "User-agent: *\nDisallow: /wp-admin/\nDisallow: /wp-includes/"
    },
    "schemaTypes": ["Article", "LocalBusiness"],
    "issues": [
      { "code": "missing_h1", "source": "page", "severity": "critical", "message": "1 page has no H1 tag", "tooltip": "The H1 tag tells Google what your page is about...", "affectedPages": ["https://example.com/blog/post"], "dismissed": false },
      { "code": "sitemap_missing", "source": "site", "severity": "warning", "message": "No sitemap.xml found", "dismissed": true }
    ]
  }
}
```

#### GET /api/v1/site-health/history

Get site health score trend over time.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| limit | integer | 12 | Max 52 |

**Response:**

```json
{
  "success": true,
  "data": [
    { "date": "2024-12-31", "healthScore": 85, "issueCount": 3 }
  ]
}
```

---

### Events

SEO event feed — rank changes, new competitors, backlink gains/losses, and other detected events.

#### GET /api/v1/events

List SEO events with filtering by status, category, and priority.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| status | string | — | Comma-separated: `new`, `delivered`, `viewed`, `actioned`, `dismissed`, `expired` |
| category | string | — | Comma-separated: `serp`, `backlink`, `sitemap`, `domain`, `review`, `keyword`, `pagespeed`, `brand` |
| minPriority | integer | — | Minimum priority score (0-100) |
| limit | integer | 50 | Max 100 |
| offset | integer | 0 | Pagination offset |
| orderBy | string | `detected_at` | `priority` or `detected_at` |
| orderDir | string | `desc` | `asc` or `desc` |

**Response:**

```json
{
  "success": true,
  "schemaVersion": "1",
  "asOf": "2026-08-01T22:00:00.000Z",
  "data": [
    {
      "id": "uuid",
      "tenant_id": "uuid",
      "event_type": "serp.position.improved",
      "event_hash": "abc123",
      "data": {
        "keyword_id": "uuid",
        "keyword": "best seo tools",
        "old_position": 12,
        "new_position": 5,
        "gain_amount": 7,
        "ranking_url": "https://example.com/best-seo-tools"
      },
      "context": { "volume": 2400, "difficulty": 41 },
      "category": "serp",
      "priority_score": 89,
      "status": "new",
      "detected_at": "2026-02-28T10:00:00Z",
      "expires_at": "2026-03-28T10:00:00Z",
      "viewed_at": null,
      "actioned_at": null,
      "dismissed_at": null,
      "webhook_delivered_at": null
    }
  ]
}
```

`event_type` is dotted and its `data` keys are snake_case, both fixed by the detector that
raised the event (`src/lib/events/detectors/`). `category` is derived from `event_type`, not
supplied independently.

---

### Alerts

Rank alert notifications for tracked keywords.

#### GET /api/v1/alerts

List rank alerts with filtering by type, severity, and read status.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| alertType | string | — | One of `rank_improvement`, `rank_drop`, `entered_top_10`, `exited_top_10`, `new_ranking`, `lost_ranking` |
| severity | string | — | Filter by severity: `info`, `warning`, `critical` |
| isRead | boolean | — | `true` or `false` — filter by read status |
| limit | integer | 50 | Max 100 |
| offset | integer | 0 | Pagination offset |

**Response:**

```json
{
  "success": true,
  "schemaVersion": "1",
  "asOf": "2026-08-01T22:00:00.000Z",
  "data": [
    {
      "id": "uuid",
      "keywordId": "uuid",
      "keywordPhrase": "best seo tools",
      "alertType": "rank_drop",
      "severity": "warning",
      "message": "\"best seo tools\" declined from #5 to #12",
      "details": { "previous_position": 5, "new_position": 12, "position_change": -7 },
      "isRead": false,
      "createdAt": "2026-02-28T10:00:00Z"
    }
  ],
  "pagination": { "total": 8, "limit": 50, "offset": 0, "hasMore": false }
}
```

`details` is the alert row's stored JSON, published verbatim. `position_change` is signed
previous − current, the opposite of `change` on `GET /api/v1/rankings/changes`.

#### POST /api/v1/alerts/{id}/read

Mark a single alert as read.

**Path Parameters:** `id` — Alert UUID

**Response:**

```json
{
  "success": true,
  "schemaVersion": "2",
  "data": {
    "alertId": "uuid"
  }
}
```

#### POST /api/v1/alerts/bulk-read

Mark up to 100 alerts as read in a single request.

**Body:**

```json
{
  "alertIds": ["uuid-1", "uuid-2"]
}
```

**Response:**

```json
{
  "success": true,
  "data": {
    "requested": 2,
    "updated": 2,
    "alreadyRead": 0
  }
}
```

---

### Issues (Dismiss / Restore)

Manage dismissed dashboard issues. Dismiss, snooze, or restore items from Site Health, Declining Keywords, and Easy Wins.

#### GET /api/v1/issues/dismiss

List all dismissed issue codes, grouped by type.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| issueType | string | — | Filter: `fix_queue`, `declining`, or `vulnerable` |

**Response:**

```json
{
  "success": true,
  "data": {
    "fix_queue": ["slow_lcp", "missing_review_schema"],
    "declining": ["keyword-uuid-1"],
    "vulnerable": ["vuln:keyword-uuid:competitor.com"]
  },
  "total": 4
}
```

#### POST /api/v1/issues/dismiss

Dismiss or snooze an issue.

**Body:**

```json
{
  "issueType": "fix_queue",
  "issueCode": "slow_lcp",
  "snooze": true,
  "snoozeHours": 168
}
```

- `issueType`: `fix_queue` | `declining` | `vulnerable`
- `issueCode`: The issue code. For `fix_queue`, use the issue code (e.g., `slow_lcp`). For `declining`, use the keyword ID. For `vulnerable`, use `vuln:{keywordId}:{competitorDomain}`.
- `snooze`: Optional. If `true`, snooze the issue instead of permanently dismissing.
- `snoozeHours`: Optional integer, 1–720 (max 30 days). Only applies when `snooze=true`. Defaults to 24 if omitted.

**Response:**

```json
{
  "success": true,
  "schemaVersion": "2",
  "data": { "snoozed": false }
}
```

#### DELETE /api/v1/issues/dismiss

Restore a dismissed issue so it reappears.

**Query Parameters:**

| Param | Type | Required | Description |
|-------|------|----------|-------------|
| issueType | string | Yes | `fix_queue`, `declining`, or `vulnerable` |
| issueCode | string | Yes | The issue code to restore |

**Response:**

```json
{
  "success": true,
  "schemaVersion": "2",
  "data": { "restored": true }
}
```

**Dismissed status on data endpoints:** The `GET /api/v1/site-health`, `GET /api/v1/opportunities/vulnerable`, `GET /api/v1/opportunities` (declining), and `GET /api/v1/rankings/declining` endpoints include a `dismissed: boolean` field on each item. Pass `?include_dismissed=false` to exclude dismissed items from the response entirely.

---

### Task Completions (Declining Keywords + Vulnerable Positions)

When a tenant implements an AI recommendation for a declining keyword or vulnerable opportunity, the "Task Completed" event is recorded here. Recording a completion:

1. Appends an immutable history row (feeds future AI prompts via `previousAttempts` context).
2. Mutes the issue for **28 days** — the same suppression mechanism used by `snooze` on the dismiss endpoint — so rankings have time to reflect the on-page change before the item reappears.

Writes from this endpoint are bidirectional with the dashboard — a completion recorded here surfaces in the GUI immediately, and completions clicked in the GUI are visible via `GET`.

#### POST /api/v1/issues/complete

Mark an issue as "Task Completed".

**Body:**

```json
{
  "issueType": "declining",
  "issueCode": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
```

- `issueType`: `declining` | `vulnerable` (Fix Queue and Coach are NOT supported here — use `/api/v1/issues/dismiss` for those).
- `issueCode`: For `declining`, the keyword UUID. For `vulnerable`, `vuln:{keywordId}:{competitorDomain}`.

**Response:**

```json
{
  "success": true,
  "data": {
    "completedAt": "2026-04-16T14:35:00Z",
    "mutedUntil": "2026-05-14T14:35:00Z"
  }
}
```

#### GET /api/v1/issues/complete

List the tenant's task-completion history. Most-recent first.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| issueType | string | — | Filter: `declining` or `vulnerable` |
| limit | integer | 50 | Max 200 |
| offset | integer | 0 | Pagination offset |

**Response:**

```json
{
  "success": true,
  "data": [
    {
      "id": "c0ffee11-1111-1111-1111-111111111111",
      "issueType": "declining",
      "issueCode": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "completedAt": "2026-04-16T14:35:00Z",
      "completedBy": "u5e67890-1234-5678-9abc-def012345678"
    }
  ],
  "total": 1
}
```

---

### Referring Domains

Referring domain inventory from your backlink profile.

#### GET /api/v1/backlinks/referring-domains

List referring domains with filtering by status and minimum domain authority.

> **Where `status` comes from.** Backlink state is **Moz's view of the link graph, reported as-is.** A domain is marked `lost` when Moz's own cumulative per-URL deltas account for every link Moz ever reported from it — the gained and lost events Moz sends each week, arithmetic over data we already hold, with no extra provider calls and no re-fetching of referring pages. A later Moz gain moves it back to `active`. If Moz simply stops listing a domain without ever reporting the loss, the domain stays `active` here indefinitely and the referring-domain count stays high by that many; that is the accepted consequence of reporting the provider's view rather than second-guessing it. Read `source` and `lastSyncedAt` on `GET /api/v1/backlinks/summary` before concluding anything from `lost30d: 0` — a zero beside a stale `lastSyncedAt` means we have not heard from Moz recently, not that nothing was lost.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| limit | integer | 50 | Max 100 |
| offset | integer | 0 | Pagination offset |
| status | string | `active` | `active`, `lost`, or `all`. The default is the population `GET /api/v1/backlinks/summary` counts, so `pagination.total` here equals its `referringDomains`. |
| minDA | integer | — | Raises your stored `backlink_min_da` floor; it never lowers it. The shortener/spam-TLD blocklist applies either way. |
| section | string | `clean` | `clean` (default) or `quarantined`. Domains identified as link-farm sources (large cross-site link networks) are quarantined: excluded from the default listing and from every published count, and served separately via `section=quarantined` — rows intact, same item shape. Your dashboard labels the same set "suspected link farms". |

**Response:**

```json
{
  "success": true,
  "data": [
    {
      "domain": "blog.example.com",
      "domainAuthority": 52,
      "status": "active",
      "firstSeenAt": "2025-11-15T00:00:00Z",
      "lastSeenAt": "2026-02-28T00:00:00Z",
      "backlinkCount": 3
    }
  ],
  "pagination": { "total": 150, "limit": 50, "offset": 0, "hasMore": true }
}
```

---

### Domain Metrics

Your domain's intelligence metrics and authority data.

#### GET /api/v1/domain-metrics

Get comprehensive domain metrics for your tenant's target domain including authority scores, backlinks, brand authority, reviews, and historical trends.

**Query Parameters:** None.

**Response:**

```json
{
  "success": true,
  "data": {
    "domain": "example.com",
    "domainAuthority": 38,
    "pageAuthority": 42,
    "spamScore": 1,
    "totalBacklinks": 2500,
    "referringDomains": 180,
    "brandAuthority": 15,
    "reviewCount": 87,
    "reviewRating": 4.8,
    "metricsHistory": {
      "domainAuthority": [{ "date": "2026-01-01", "value": 35 }, { "date": "2026-02-01", "value": 38 }],
      "pageAuthority": [{ "date": "2026-01-01", "value": 40 }, { "date": "2026-02-01", "value": 42 }]
    },
    "updatedAt": "2026-02-28T10:00:00Z"
  }
}
```

> Returns 404 if no target domain is configured for the tenant.

> **See also:** For competitor authority metrics, see `GET /api/v1/competitors`. For per-keyword DA comparison (your DA vs competitors in the same SERP), see `GET /api/v1/keywords/:id`.

---

### SEO Score

Composite SEO health score with component-level breakdown.

#### GET /api/v1/seo-score

Get the current SEO score with component scores, authority metrics, and keyword/competitor counts.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| market | uuid | — | Filter by market (Team tier only) |

**Response:**

```json
{
  "success": true,
  "data": {
    "score": 78,
    "componentScores": {
      "rankings": 82,
      "siteHealth": 90,
      "backlinks": 65,
      "reviews": 72,
      "content": 80
    },
    "domainAuthority": 38,
    "brandAuthority": 15,
    "brandAuthorityTrend": "up",
    "keywordCount": 25,
    "competitorCount": 45
  }
}
```

> **See also:** For full domain metrics including PA, spam score, backlink counts, and history, see `GET /api/v1/domain-metrics`.

#### GET /api/v1/seo-score/history

Get historical SEO scores for trend analysis.

**Recording cadence.** At most one entry per calendar day per tenant, written by the **publish step**
at the end of a data waterfall — never by a schedule, and never by anyone opening the dashboard. An
entry therefore exists only on days your data was actually published. If your only waterfall is the
weekly SERP run, expect roughly one entry per week rather than one per day; a mid-week refresh adds
one. The series does not depend on your activity.

**Older entries are sparse, by origin.** The series carries two seams, neither backfilled. Before
scheduled recording existed, a score was written only on days someone opened the dashboard. Between
2026-07 and 2026-08-01 a 4-hourly job recorded one entry per calendar day. From 2026-08-01 entries
follow publishes. So the earlier part of the series is irregular and can have
gaps of days or months. Those gaps were not backfilled — a missing day means the score was not measured
that day, not that data was lost, and inventing values for unmeasured days would misreport history.

Note that the series does **not** become permanently continuous at any point. It was densest during the
4-hourly period (2026-07 to 2026-08-01, one entry per calendar day); since 2026-08-01 an entry exists
only on days a publish ran, so a weekly-only tenant's recent series is sparser than its mid-2026 series
by design.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| limit | integer | 90 | Max 365 |

**Response:**

```json
{
  "success": true,
  "data": [
    { "date": "2026-02-28", "score": 78 },
    { "date": "2026-02-27", "score": 77 },
    { "date": "2026-02-26", "score": 76 }
  ]
}
```

---

### Broken Links

Unresolved broken links detected on your site.

#### GET /api/v1/site-health/broken-links

Returns every unresolved broken link, from the same accessor the `broken_internal_links` issue
is emitted from — so the list and the issue can never describe different sets. The list
previously stopped at 50 rows with nothing saying so; that cap is gone. The response shape is
unchanged, so `schemaVersion` does not move.

Get all unresolved broken links for your tenant's domain.

**Query Parameters:** None.

**Response:**

```json
{
  "success": true,
  "data": [
    {
      "sourceUrl": "https://example.com/services",
      "targetUrl": "https://example.com/old-page",
      "statusCode": 404,
      "anchorText": "Learn more",
      "detectedAt": "2026-02-20T10:00:00Z"
    }
  ]
}
```

---

### Page Audit

On-demand SEO audit analyzing 50+ signals for any URL.

#### POST /api/v1/page-audit

Scrape a URL and analyze its SEO signals: title, meta description, headings, word count, schema markup, images, internal/external links, platform detection, and more. Returns a structured report with scores and recommendations.

**Rate limit:** 10 audits/day (Pro), 20 audits/day (Team), plus the standard 100/min request limit.

**`report.siteLevel` is measured live, during the audit.** It carries `"source": "live"`: the
robots.txt, llms.txt and sitemap reads happen in this request. That is a different measurement
from `GET /api/v1/site-health`, which publishes the last **scheduled** check of a tenant's own
site, so the two can legitimately disagree — this endpoint audits any URL, and for most audits
no stored check exists at all. Neither is stale relative to the other; they answer different
questions. The audit's fetch is not timestamped, so this endpoint's `asOf` is `null` by design.

**Request Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| url | string | Yes | URL to audit (https:// prefix added if missing) |
| targetKeyword | string | Yes | Primary keyword to evaluate the page against |

**Response:**

```json
{
  "success": true,
  "data": {
    "hash": "a1b2c3d4",
    "report": {
      "url": "https://example.com/services",
      "targetKeyword": "headshot photography",
      "scores": { "overall": 72, "content": 80, "technical": 65, "onPage": 70 },
      "title": { "text": "Professional Headshots | Example Studio", "length": 42, "hasKeyword": true },
      "metaDescription": { "text": "...", "length": 145, "hasKeyword": true },
      "h1": { "text": "Professional Headshot Photography", "count": 1, "hasKeyword": true },
      "wordCount": 1200,
      "images": [],
      "internalLinks": [],
      "externalLinks": [],
      "schemaTypes": ["LocalBusiness", "FAQPage"],
      "platform": { "name": "WordPress", "confidence": "high" }
    }
  }
}
```

> The `hash` can be used to retrieve the report later via `GET /api/page-audit/report/{hash}` (session auth).

---

### Keyword Suggestions

Moz-powered and custom keyword suggestions for your domain.

#### GET /api/v1/keyword-suggestions

Get keyword suggestions based on Moz data and custom inputs.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| limit | integer | 50 | Max 200 |

**Response:**

```json
{
  "success": true,
  "data": [
    {
      "phrase": "professional headshots near me",
      "source": "moz",
      "volume": 8100,
      "difficulty": 32,
      "organicCtr": 0.15,
      "priority": 88,
      "intent": "transactional"
    }
  ]
}
```

---

### Uptime Monitoring

Monitor your website uptime, check history, and incident tracking.

#### GET /api/v1/uptime

List all monitored sites for your tenant.

**Query Parameters:** None.

**Response:**

```json
{
  "success": true,
  "data": [
    {
      "id": "uuid",
      "url": "https://example.com",
      "label": "Main Site",
      "isActive": true,
      "checkIntervalSeconds": 300,
      "lastCheckAt": "2026-02-28T10:05:00Z",
      "lastStatus": "up",
      "lastStatusCode": 200,
      "lastResponseMs": 245,
      "lastError": null,
      "consecutiveFailures": 0,
      "lastIncidentAt": null,
      "createdAt": "2026-01-15T00:00:00Z"
    }
  ]
}
```

#### GET /api/v1/uptime/checks

Get recent uptime checks for a specific monitored site.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| siteId | uuid | **required** | Monitored site ID |
| limit | integer | 50 | Max 100 |

**Response:**

```json
{
  "success": true,
  "data": [
    {
      "id": "uuid",
      "siteId": "uuid",
      "status": "up",
      "statusCode": 200,
      "responseMs": 245,
      "error": null,
      "isVerification": false,
      "checkedAt": "2026-02-28T10:05:00Z"
    }
  ]
}
```

#### GET /api/v1/uptime/incidents

Get uptime incidents for a specific monitored site.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| siteId | uuid | **required** | Monitored site ID |
| limit | integer | 20 | Max 100 |

**Response:**

```json
{
  "success": true,
  "data": [
    {
      "id": "uuid",
      "siteId": "uuid",
      "startedAt": "2026-02-25T14:30:00Z",
      "resolvedAt": "2026-02-25T14:45:00Z",
      "durationSeconds": 900,
      "cause": "Connection timeout",
      "alertSent": true,
      "createdAt": "2026-02-25T14:30:00Z"
    }
  ]
}
```

---

### Achievements

Gamification achievements earned by your tenant.

#### GET /api/v1/achievements

Get all earned achievements with full details.

**Query Parameters:** None.

**Response:**

```json
{
  "success": true,
  "data": [
    {
      "code": "page_one_hero",
      "name": "Page One Hero",
      "description": "Get a keyword to page 1 of Google",
      "category": "ranking",
      "icon": "trophy",
      "rarity": "common",
      "points": 50,
      "sortOrder": 10,
      "earnedAt": "2026-02-15T10:00:00Z",
      "metadata": { "keyword": "best seo tools", "position": 7 }
    }
  ]
}
```

---

### Recaps

AI-generated weekly performance recaps.

#### GET /api/v1/recaps

Get the latest weekly recap, or a specific recap by ID.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| id | uuid | — | Specific recap ID. Omit to get the latest recap. |

**Response:**

```json
{
  "success": true,
  "data": {
    "id": "uuid",
    "tenantId": "uuid",
    "weekStart": "2026-02-23T00:00:00.000Z",
    "weekEnd": "2026-03-01T23:59:59.999Z",
    "recapText": "<p>Monday morning, fresh data. Here's what moved since we last talked.</p><p>Your SEO score held steady at 62 this week, with <strong>corporate headshots chicago</strong> jumping eight positions to #7. Track AI Visibility next week.</p><p>Small moves compound.</p>",
    "recapTone": "encouraging",
    "keywordsImproved": 8,
    "keywordsDeclined": 3,
    "topKeyword": "professional headshots",
    "biggestGain": 7,
    "biggestLoss": 4,
    "daChange": 1,
    "newReviews": 2,
    "competitorBacklinksGained": 15,
    "generatedAt": "2026-03-02T04:00:00Z"
  }
}
```

> Returns 404 if no recap has been generated yet.

> `recapText` is sanitized HTML. Allowed tags: `<p>`, `<strong>`, `<em>`. No attributes, no links. Render as HTML, not plain text.

---

### AI Visibility

AI brand visibility — how your business appears in AI-generated responses.

#### Citation data model

Each AI-visibility run now carries per-entity citation attribution. The shape divides into three buckets:

1. **Per-entity supporting sources** (`businesses[].supportingSources[]` on the per-query endpoint) — URLs Haiku attributed to a specific business Claude named. Deduped by URL within a business. Each source carries `citedPassages` (the count), `citedText` (earliest-passage excerpt — what appears in the collapsed view), and `passages[]` (every passage citing that URL, ordered by `passagePosition`).
2. **Unattributed cited sources** (`unattributedSources[]`) — URLs Claude cited in its response as general evidence (advice blogs, ranking posts without named parties, background context). Not tied to any specific entity. Same dedup + `citedPassages` + `citedText` + `passages[]` shape.
3. **Considered but not cited** (`consideredSources[]`) — URLs Claude searched and surfaced but did not cite in its final response. Deduped by URL. `passages[]` is empty for these.

**Per-entity `reasons`:** alongside `supportingSources[]`, each entity carries a `reasons: string[]` field. These are short phrases Haiku extracted from Claude's prose describing *why* the business was recommended (e.g., `["cinematic lighting", "fast turnaround", "corporate focus"]`). Distinct from citations — these are distilled from the response text itself, not tied to any specific URL.

**Movement badges** (`new` / `retained` / `dropped` / `null`) on each source compare this run to the most-recent prior same-persona run with `extraction_version >= 2`. Null when no prior run exists.

**Tenant identification** flows through the `ai_market_tenant_matches` table — a per-(run, tenant) layered match (exact domain → normalized name → token subset → Jaro-Winkler → Haiku disambiguation). `isTenant: true` on a business means the match resolved to that business for the authenticated tenant.

**Rotation cycle scoping:** All AI visibility endpoints return only rows captured under the current rotation cycle's anchor date. Rows from a prior cycle (e.g., pre-2026-04-19 rotation) are not exposed via this API. This keeps the `weekNumber` field monotonic within a cycle and aligns every tenant on the same community-discussion prompt each week. To inspect historical cycles, use the admin API with an explicit `anchor_date` query parameter.

**Drill-down flow:**

1. Call `GET /api/v1/ai-visibility/weekly` (or `/api/v1/ai-visibility`) to list per-persona results.
2. For any result where `hasCitations: true`, call `GET /api/v1/ai-visibility/query/{visibilityId}` with the `visibilityId` to retrieve the full per-query detail.

#### GET /api/v1/ai-visibility

Structured summary of AI brand visibility across the current persona rotation.
Returns aggregate stats, a per-persona breakdown with the full business
leaderboard extracted from each AI response, and a week-over-week trend.

For raw AI response text per prompt, see [GET /api/v1/ai-visibility/weekly](#get-apiv1ai-visibilityweekly).

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| market | uuid | — | Filter by market (Team tier only) |

**Response:**

```json
{
  "success": true,
  "schemaVersion": "2",
  "data": {
    "visibility": {
      "overall": {
        "mentionRate": 0.5,
        "avgRank": 2,
        "bestRank": 1,
        "totalResponses": 4,
        "lastFetched": "2026-04-13T09:00:00Z"
      },
      "byPersona": [
        {
          "visibilityId": "9f5b4145-f94f-41d8-b39c-83c824f0a8ac",
          "persona": "ai-vis-market-1-1",
          "promptName": "CEO, Self-Booking",
          "promptText": "I'm the founder and CEO of a growing company in Chicago, IL...",
          "tenantMentioned": true,
          "tenantRank": 1,
          "totalBusinesses": 3,
          "mentionedBusinesses": [
            { "name": "Your Business", "domain": "you.com", "domainConfidence": "high", "rank": 1, "reasons": ["industry-ready"], "isTenant": true, "competitorId": null },
            { "name": "Competitor One", "domain": "comp1.com", "domainConfidence": "high", "rank": 2, "reasons": ["corporate focus"], "isTenant": false, "competitorId": "4a3f9c22-..." }
          ],
          "hasCitations": true,
          "citationSummary": { "businessesWithCitations": 3, "unattributedSources": 4 },
          "weekNumber": 3,
          "fetchedAt": "2026-04-13T09:00:00Z"
        }
      ],
      "trend": {
        "previousPeriod": { "mentionRate": 0.25, "avgRank": 3 }
      }
    },
    "sentiment": { /* ... */ },
    "discoveredCompetitors": [ /* ... */ ]
  }
}
```

**Key fields:**

- `schemaVersion: "2"` — this endpoint's response shape. Bump signals a breaking change. See [Response Envelope & Versioning](#response-envelope--versioning) below.
- `visibility.overall.mentionRate` — fraction (0–1) of the current rotation's personas where your tenant is mentioned.
- `visibility.byPersona[].tenantMentioned`/`tenantRank` — derived at query time from `mentionedBusinesses` matching your tenant's `target_domain`.
- `visibility.byPersona[].mentionedBusinesses` — every business named in the AI response, ordered by rank, with a `competitorId` when the domain matches a tracked competitor.
- `visibility.byPersona[].visibilityId` / `hasCitations` / `citationSummary` — added April 2026 (non-breaking). Pass `visibilityId` to `GET /api/v1/ai-visibility/query/{visibilityId}` when `hasCitations: true` to drill into full citation detail.
- `visibility.trend.previousPeriod` — mention rate + avg rank from the prior rotation window. `null` if no prior data.

#### GET /api/v1/ai-visibility/weekly

Get weekly AI visibility reports — what AI assistants tell potential clients when asked for photographer recommendations in your market. Each week is a 4-prompt batch spread evenly across the full prompt rotation (mix of market-scoped and tenant-scoped brand prompts depending on where the week lands) — no prompt repeats from the previous week. Each result includes the full structured business leaderboard alongside the raw AI response text. When the tenant has a stored comparison for the week, it's appended as an additional tenant-scoped result. Use the `week` parameter to paginate through historical weeks; placeholder cards for not-yet-captured slots appear only on the current week (historical weeks return exactly what was captured — the rotation in effect then may have differed).

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| week | integer | — | Specific week number. Omit for latest available week. |
| market | uuid | — | Filter by market (Team tier only) |

**Response:**

```json
{
  "success": true,
  "schemaVersion": "2",
  "data": {
    "weekNumber": 3,
    "fetchedAt": "2026-04-13T09:00:00Z",
    "results": [
      {
        "visibilityId": "9f5b4145-f94f-41d8-b39c-83c824f0a8ac",
        "promptName": "CEO, Self-Booking",
        "promptText": "I'm the founder and CEO of a growing company in Chicago, IL...",
        "responseText": "Based on my research, here are my top three recommendations...",
        "tenantMentioned": true,
        "tenantRank": 1,
        "mentionedBusinesses": [
          { "name": "Your Business", "domain": "you.com", "domainConfidence": "high", "rank": 1, "reasons": ["industry-ready"], "isTenant": true, "competitorId": null }
        ],
        "hasCitations": true,
        "citationSummary": { "businessesWithCitations": 3, "unattributedSources": 4 },
        "fetchedAt": "2026-04-13T09:00:00Z",
        "weekNumber": 3
      }
    ],
    "availableWeeks": [
      { "weekNumber": 3, "fetchedAt": "2026-04-13T09:00:00Z" },
      { "weekNumber": 2, "fetchedAt": "2026-04-06T09:00:00Z" }
    ]
  }
}
```

> `responseText` is null for prompts that have not been fetched for the tenant's tier. Starter tenants see 1 result per week; Pro/Team see all 5.
> `tenantMentioned` and `tenantRank` come from the normalized `mentionedBusinesses` rows — they're authoritative, not heuristic.
> Use `availableWeeks` to discover which weeks have data for pagination.
> `visibilityId` + `hasCitations` + `citationSummary` were added April 2026 (non-breaking). Pass `visibilityId` to `GET /api/v1/ai-visibility/query/{visibilityId}` when `hasCitations: true` to drill into full citation detail.

#### GET /api/v1/ai-visibility/query/{visibilityId}

Per-query citation detail — returns authoritative per-entity citation attribution for a single AI-visibility run. Each business Claude named is returned with its supporting URLs (deduped by URL, with cited-text excerpts and per-passage counts). A separate `unattributedSources[]` bucket holds citations Claude made as general evidence that Haiku did not tie to any specific entity.

**Obtain `visibilityId` from:** `GET /api/v1/ai-visibility/weekly` results — any row with `hasCitations: true` can be drilled into. Rows with `hasCitations: false` return 404 here.

**Plan:** Pro or Team. Rate-limited.

**Path parameter:**

| Param | Type | Description |
|-------|------|-------------|
| visibilityId | uuid | Run identifier from `/api/v1/ai-visibility/weekly` |

**Response (200):**

```json
{
  "success": true,
  "schemaVersion": "1",
  "data": {
    "visibilityId": "9f5b4145-f94f-41d8-b39c-83c824f0a8ac",
    "marketId": "065144fd-2c0b-4836-a4fe-d0c5c87cd81f",
    "persona": "ai-vis-market-3-2",
    "promptSlug": "ceo-self-booking",
    "promptText": "I'm the founder and CEO of a growing company in Chicago, IL...",
    "responseText": "Based on my research, here are my top three recommendations...",
    "fetchedAt": "2026-04-13T09:00:00Z",
    "searchQueries": [
      { "queryText": "best headshot photographers Chicago actors", "queryPosition": 1 }
    ],
    "businesses": [
      {
        "businessId": "b1111111-0000-0000-0000-000000000001",
        "name": "312 Elements",
        "rank": 1,
        "domain": "312elements.com",
        "reasons": ["industry-ready portfolios", "strong CEO headshot specialty", "fast turnaround"],
        "isTenant": true,
        "competitorId": null,
        "competitorLabel": null,
        "supportingSources": [
          {
            "url": "https://312elements.com/",
            "urlNormalized": "312elements.com/",
            "domain": "312elements.com",
            "title": "312 Elements Headshot Photography",
            "citedText": "cinematic lighting and fast turnaround",
            "citedPassages": 3,
            "passages": [
              { "citedText": "cinematic lighting and fast turnaround", "passagePosition": 142 },
              { "citedText": "boutique studio experience", "passagePosition": 410 },
              { "citedText": "curated portfolio of executive headshots", "passagePosition": 892 }
            ],
            "movement": "retained"
          }
        ]
      }
    ],
    "unattributedSources": [
      {
        "url": "https://thelightcommittee.com/blog/common-mistakes-actors-make-in-headshots/",
        "urlNormalized": "thelightcommittee.com/blog/common-mistakes-actors-make-in-headshots",
        "domain": "thelightcommittee.com",
        "title": "Common Mistakes Actors Make in Headshots",
        "citedText": "avoid busy backgrounds and dated wardrobe choices",
        "citedPassages": 1,
        "passages": [
          { "citedText": "avoid busy backgrounds and dated wardrobe choices", "passagePosition": 1024 }
        ],
        "movement": "new"
      }
    ],
    "consideredSources": [
      {
        "url": "https://headshotcrew.com/chicago",
        "urlNormalized": "headshotcrew.com/chicago",
        "domain": "headshotcrew.com",
        "title": "Chicago Headshot Photographers",
        "citedText": "",
        "citedPassages": 0,
        "passages": [],
        "movement": null
      }
    ],
    "previousVisibilityId": "412ed0ee-20fa-430f-99a2-3db12327499c"
  }
}
```

**Error responses** follow the standard v1 envelope `{ success: false, error: { code, message, details? } }` (see [Error Handling](#error-handling)):

- `400` — `code: "VALIDATION_ERROR"` when the path param isn't a valid UUID.
- `404` — `code: "NOT_FOUND"` when the run doesn't exist OR belongs to a market not owned by your tenant.

**Key fields:**

- `businesses[].supportingSources[]` — deduped per URL within a business. `citedPassages` is the number of distinct passages in Claude's response citing that URL; `citedText` is an earliest-passage excerpt (the collapsed-view quote); `passages[]` holds every passage citing that URL, ordered by `passagePosition`, each with its own `citedText`.
- `businesses[].reasons[]` — short phrases Haiku extracted from Claude's prose describing why this business was recommended (e.g., `"fast turnaround"`, `"corporate focus"`). Distilled from the response; not tied to any specific URL.
- `businesses[].isTenant` — resolved via `ai_market_tenant_matches` (layered domain/name match). True on the one business that represents the authenticated tenant in this run.
- `businesses[].competitorId` / `competitorLabel` — populated when the business domain matches a tracked competitor.
- `unattributedSources[]` — URLs Claude cited as general evidence, not attributed to any entity above.
- `consideredSources[]` — URLs Claude searched but did not cite.
- `movement` — per-URL diff against the prior same-persona run with `extraction_version >= 2`. Values: `new`, `retained`, `dropped`, `null` (no prior run).
- `previousVisibilityId` — the run used for movement diff; `null` when none exists.

#### GET /api/v1/ai-visibility/insights

The two signals AI-visibility captures but the per-run endpoints only expose one run at a time, aggregated across your market: **the search queries Claude ran** and **the sources Claude cited**. Use the queries to learn what to write about; use the cited sources to see what content Claude trusts.

**Plan:** Pro or Team. Rate-limited.

**Query parameters:**

| Param | Type | Description |
|-------|------|-------------|
| include | string | Comma-separated, any of `queries`, `sources`. Omit for both. (`include=queries` → only queries; `include=sources` → only sources; `include=queries,sources` → both.) |
| window | string | `current` (this rotation cycle, default) or `all` (full cross-cycle history). |

Both sections are deduped: `queries` case-insensitively by text, `sources` by normalized URL (cited URLs only). A section you didn't request is omitted from `data`; a requested section with no data is an empty array.

**Response (200):**

```json
{
  "success": true,
  "schemaVersion": "1",
  "data": {
    "queries": [
      { "queryText": "best corporate headshot photographers chicago" },
      { "queryText": "chicago executive portrait studio" }
    ],
    "sources": [
      { "url": "https://312elements.com/" },
      { "url": "https://yelp.com/biz/312-elements-headshot-photography-chicago-2" }
    ]
  }
}
```

---

### Reviews

Google Reviews scoreboard comparing your business to competitors.

#### GET /api/v1/reviews

Get review scoreboard data — your tenant's reviews vs competitors in the same market and geographic area.

**Query Parameters:** None.

**Response:**

```json
{
  "success": true,
  "data": {
    "tenant": {
      "targetDomain": "example.com",
      "reviewCount": 87,
      "reviewRating": 4.8,
      "reviewHistory": [{ "date": "2026-02-01", "count": 85, "rating": 4.8 }],
      "googlePlaceId": "ChIJ..."
    },
    "competitors": [
      {
        "domain": "competitor.com",
        "reviewCount": 142,
        "reviewRating": 4.5,
        "reviewHistory": [{ "date": "2026-02-01", "count": 138, "rating": 4.5 }],
        "googlePlaceId": "ChIJ..."
      }
    ]
  }
}
```

> Competitors are scoped to the same state or metro area (e.g., NYC includes NY/NJ, DC includes DC/MD/VA).

---

### PAA Questions

People Also Ask questions extracted from your tracked keyword SERPs.

#### GET /api/v1/paa-questions

Get PAA questions, optionally restricted to a single keyword and/or grouped.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| grouped | boolean | `false` | `true` to group questions by keyword |
| keyword_id | uuid | — | Restrict to PAA questions for a single tracked keyword. Silently empty (not 404) if the keyword is not tracked by your tenant. |
| market | uuid | — | Filter by market (Team tier only) |

Each question carries an `isAnsweredByUs` flag, set to `true` when `answeringDomain` matches your tenant's `target_domain` (case- and www-insensitive). Use it to identify PAA opportunities you do not currently own.

**Response (flat):**

```json
{
  "success": true,
  "data": [
    {
      "question": "What are the best SEO tools?",
      "keyword": "best seo tools",
      "keywordId": "uuid",
      "answerPreview": "Top picks include Ahrefs, SEMrush, and Moz...",
      "answeringDomain": "example.com",
      "isAnsweredByUs": true,
      "fetchedAt": "2024-12-31T10:00:00Z"
    },
    {
      "question": "How much do SEO tools cost?",
      "keyword": "best seo tools",
      "keywordId": "uuid",
      "answerPreview": "Most paid SEO tools range from $50 to $500/month...",
      "answeringDomain": "competitor.com",
      "isAnsweredByUs": false,
      "fetchedAt": "2024-12-31T10:00:00Z"
    }
  ]
}
```

**Response (grouped=true):**

```json
{
  "success": true,
  "data": {
    "keywords": [
      {
        "keyword": "best seo tools",
        "keywordId": "uuid",
        "questions": [
          {
            "question": "What are the best SEO tools?",
            "answerPreview": "Top picks include Ahrefs, SEMrush, and Moz...",
            "answeringDomain": "example.com",
            "isAnsweredByUs": true,
            "fetchedAt": "2024-12-31T10:00:00Z"
          },
          {
            "question": "How much do SEO tools cost?",
            "answerPreview": "Most paid SEO tools range from $50 to $500/month...",
            "answeringDomain": "competitor.com",
            "isAnsweredByUs": false,
            "fetchedAt": "2024-12-31T10:00:00Z"
          }
        ]
      }
    ],
    "totalQuestions": 2,
    "answeredByUs": 1
  }
}
```

---

### SERP Features

SERP feature ownership analysis and competitor visibility.

#### GET /api/v1/serp-features/opportunities

Get SERP feature opportunities — features you are not yet capturing but could target.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| limit | integer | 50 | Max 100 |
| market | uuid | — | Filter by market (Team tier only) |

**Response:**

```json
{
  "success": true,
  "data": [
    {
      "keyword": "best seo tools",
      "keywordId": "uuid",
      "featureType": "featured_snippet",
      "currentOwner": "competitor.com",
      "yourPosition": 5,
      "ownerPosition": 1
    }
  ]
}
```

#### GET /api/v1/serp-features/ownership

Get who owns each SERP feature (featured snippets, local packs, PAA, etc.) for your tracked keywords.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| feature_type | string | — | Filter by type: `featured_snippet`, `local_pack`, etc. |
| keyword_id | uuid | — | Filter to a specific keyword |
| limit | integer | 100 | Max 500 |
| market | uuid | — | Filter by market (Team tier only) |

**Response:**

```json
{
  "success": true,
  "schemaVersion": "2",
  "data": {
    "items": [
      { "keyword": "best seo tools", "featureType": "featured_snippet", "ownerDomain": "example.com", "isOwnSite": true }
    ],
    "summary": { "total": 50, "ownedByUs": 12 }
  }
}
```

#### GET /api/v1/serp-features/visibility

Get competitor SERP feature presence across your keywords — which competitors appear in the most SERP features.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| limit | integer | 20 | Max 50 |
| market | uuid | — | Filter by market (Team tier only) |

**Response:**

```json
{
  "success": true,
  "data": {
    "competitors": [
      { "domain": "competitor.com", "featureCount": 12, "featureTypes": ["featured_snippet", "local_pack"] }
    ],
    "totalCompetitors": 20
  }
}
```

---

### Webhooks

Manage webhook endpoints for real-time event notifications.

#### GET /api/v1/webhooks

List all configured webhooks for your tenant.

**Response:**

```json
{
  "success": true,
  "schemaVersion": "2",
  "data": [
    {
      "id": "uuid",
      "name": "Ranking Alerts",
      "url": "https://example.com/webhook",
      "secret": "whk_...",
      "event_types": ["ranking.changed", "keyword.added"],
      "categories": ["rankings"],
      "min_priority": 50,
      "is_active": true,
      "total_deliveries": 25,
      "successful_deliveries": 24,
      "failed_deliveries": 1,
      "last_triggered_at": "2024-12-31T10:00:00Z",
      "consecutive_failures": 0,
      "created_at": "2024-12-01T00:00:00Z"
    }
  ],
  "pagination": { "total": 1, "limit": 1, "offset": 0, "hasMore": false }
}
```

#### POST /api/v1/webhooks

Create a new webhook endpoint.

**Request Body:**

```json
{
  "name": "Ranking Alerts",
  "url": "https://example.com/webhook",
  "event_types": ["ranking.changed"],
  "categories": ["rankings"],
  "min_priority": 50,
  "test_on_create": true
}
```

**Response:**

```json
{
  "success": true,
  "schemaVersion": "2",
  "data": {
    "id": "uuid",
    "name": "Ranking Alerts",
    "url": "https://example.com/webhook",
    "secret": "whk_full_secret_shown_on_create_only",
    "event_types": ["ranking.changed"],
    "categories": ["rankings"],
    "min_priority": 50,
    "is_active": true,
    "created_at": "2024-12-31T10:00:00Z",
    "testResult": { "success": true, "statusCode": 200, "responseTime": 150 }
  }
}
```

> The webhook secret is only shown in full on creation — save it immediately. URL must use HTTPS and cannot point to localhost or internal IPs. Set `test_on_create=true` to send a test event on creation; the result lives at `data.testResult`. Maximum 10 webhooks per tenant.

#### GET /api/v1/webhooks/:id

Get detailed information for a specific webhook.

**Response:**

```json
{
  "success": true,
  "schemaVersion": "2",
  "data": {
    "id": "uuid",
    "name": "Ranking Alerts",
    "url": "https://example.com/webhook",
    "secret": "whk_...",
    "event_types": ["ranking.changed"],
    "is_active": true,
    "total_deliveries": 25,
    "successful_deliveries": 24,
    "failed_deliveries": 1,
    "last_triggered_at": "2024-12-31T10:00:00Z",
    "last_error": null,
    "consecutive_failures": 0
  }
}
```

#### PATCH /api/v1/webhooks/:id

Update an existing webhook. All fields are optional — only include fields you want to change.

**Request Body:**

```json
{
  "name": "Updated Name",
  "url": "https://example.com/new-webhook",
  "event_types": ["ranking.changed", "keyword.added"],
  "is_active": true
}
```

**Response:**

```json
{
  "success": true,
  "schemaVersion": "2",
  "data": {
    "id": "uuid",
    "name": "Updated Name",
    "url": "https://example.com/new-webhook",
    "event_types": ["ranking.changed", "keyword.added"],
    "is_active": true,
    "updated_at": "2024-12-31T10:00:00Z"
  }
}
```

#### DELETE /api/v1/webhooks/:id

Delete a webhook.

**Response:**

```json
{
  "success": true,
  "schemaVersion": "2",
  "data": { "deleted": true }
}
```

#### Delivery, retries and dead-lettering

Events matching a webhook's filters are queued when they are detected and delivered by a background
dispatcher that runs every five minutes.

- **Delivery is tracked per endpoint.** Each endpoint's acceptance of each event is recorded
  individually: once your endpoint returns 2xx for an event, it is never sent that event again for
  as long as that endpoint exists — across retries, and across deactivating and reactivating it. If
  you have more than one endpoint matching the same event and some fail, the retry re-sends only to
  the endpoints that have not yet accepted; the event is finished when every matching endpoint has
  accepted it, or when it dead-letters. (Deleting an endpoint deletes its acceptance records — an
  endpoint you delete and recreate is a new endpoint, and still-pending events will be sent to it.)
  Keep your handler idempotent on `event_id` anyway: if your handler runs but the 2xx is lost to a
  network fault before we see it, that attempt counts as failed and the event is re-sent.
- **Five attempts, then terminal.** A failed POST is retried on a fixed ladder — 1s, 5s, 30s, 1m,
  5m — as a floor: the dispatcher ticks every five minutes, so a delay shorter than the tick means
  "at the next tick". After the fifth failed attempt the event is **dead-lettered**: it is never
  attempted again, and the reason and the last error are recorded on it.
- **Expired, dismissed and actioned events are not delivered.** An event that has passed its
  `expires_at`, or that you dismissed or actioned in the dashboard before it went out, is dropped
  from the delivery queue rather than sent late.
- **Delivery follows account status.** Events for an account that is frozen or cancelled are not
  delivered while it is in that state.
- **Your endpoint should answer quickly.** A delivery is abandoned after 10 seconds and counts as a
  failed attempt. Return 2xx to acknowledge; any other status is a failure.

Each attempt is recorded with its response code and duration, and `POST /api/v1/webhooks/:id` sends
a test event on demand.

#### POST /api/v1/webhooks/:id

Send a test event to a webhook to verify it is receiving events correctly.

**Response:**

```json
{
  "success": true,
  "schemaVersion": "2",
  "data": {
    "webhookId": "uuid",
    "testResult": {
      "success": true,
      "statusCode": 200,
      "error": null,
      "responseTime": 150
    }
  }
}
```

### Content Pages (Keyword Planner)

Content page → keyword mapping from the keyword planner. Shows which keywords are assigned to which pages, with current position data and assignment status.

> Content pages now include a `groupId` field for topic group assignment. Use the topic group endpoints below to organize pages by category.

#### GET /api/v1/content-pages

List all content pages with their assigned keywords. Bounded by tenant keyword cap (Starter: 20, Pro: 60, Team: 100).

**Assigned page vs ranking page.** You assign a keyword to a content page manually, but Google decides which of your URLs actually ranks for it. This endpoint reports both: `rankingUrl` is the URL that currently earns your best position, and `isOffPage` is `true` when that URL is *not* the page the keyword is assigned to. When the assigned page itself also ranks (just not as well — self-cannibalization), `assignedPagePosition` is the assigned page's own position. `assignedPagePosition: null` means the assigned page is **not in the captured top-30 SERP results** (the depth we capture), i.e. it does not rank within our data, not necessarily that it has zero presence anywhere.

**Query parameters:**

| Param | Values | Description |
|-------|--------|-------------|
| `filter` | `off-page` | Optional. Returns only pages that have ≥1 off-page keyword, with each page's `keywords` reduced to its off-page keywords — the list of keywords ranking on the wrong page. `summary` stays unfiltered. Any other value returns `400`. |

**Response:**

```json
{
  "success": true,
  "schemaVersion": "4",
  "data": {
    "items": [
      {
        "id": "uuid",
        "url": "https://example.com/actor-headshots",
        "title": "actor headshots chicago",
        "status": "published",
        "groupId": "uuid-or-null",
        "keywords": [
          {
            "id": "uuid",
            "phrase": "actor headshots chicago",
            "role": "primary",
            "status": "active",
            "currentPosition": 3,
            "previousPosition": 5,
            "positionChange": 2,
            "rankingUrl": "https://example.com/actor-headshots",
            "assignedPagePosition": 3,
            "isOffPage": false
          },
          {
            "id": "uuid",
            "phrase": "chicago headshot photographer",
            "role": "secondary",
            "status": "active",
            "currentPosition": 6,
            "previousPosition": 6,
            "positionChange": 0,
            "rankingUrl": "https://example.com/blog/headshot-tips",
            "assignedPagePosition": 14,
            "isOffPage": true
          },
          {
            "id": "uuid",
            "phrase": "headshot photographer near me",
            "role": "secondary",
            "status": "queued",
            "currentPosition": null,
            "previousPosition": null,
            "positionChange": null,
            "rankingUrl": null,
            "assignedPagePosition": null,
            "isOffPage": false
          }
        ],
        "offPageKeywords": 1,
        "createdAt": "2024-12-01T00:00:00Z",
        "updatedAt": "2024-12-31T10:00:00Z"
      }
    ],
    "summary": {
      "totalPages": 15,
      "totalKeywords": 42,
      "pagesWithKeywords": 12,
      "offPageKeywords": 4
    }
  }
}
```

In the example above, `chicago headshot photographer` is assigned to `/actor-headshots` but actually ranks #6 via `/blog/headshot-tips` while the assigned page only ranks #14 — a self-cannibalization signal (`isOffPage: true`, `assignedPagePosition: 14`).

**Keyword fields:**

| Field | Description |
|-------|-------------|
| `role` | `"primary"` or `"secondary"` — the keyword's role on this page |
| `status` | `"active"` — has SERP data; `"queued"` — pending first weekly SERP fetch (position fields will be null) |
| `currentPosition` | Latest SERP position (null for queued keywords) |
| `previousPosition` | Previous week's position (null for queued keywords) |
| `positionChange` | Position delta (positive = improved, null for queued keywords) |
| `rankingUrl` | The URL that earns your best position for this keyword. Null when the keyword does not rank. May differ from the assigned page. |
| `assignedPagePosition` | The assigned page's *own* position for this keyword. Null when the assigned page is not in the captured top-30. |
| `isOffPage` | `true` when `rankingUrl` is a different URL than the assigned page — the keyword ranks on the "wrong" page. |

**Page fields:**

| Field | Description |
|-------|-------------|
| `offPageKeywords` | Count of this page's keywords with `isOffPage: true`. |

**Summary fields:** `totalPages`, `totalKeywords`, `pagesWithKeywords`, and `offPageKeywords` (total off-page keywords across all pages — always unfiltered, even with `?filter=off-page`).

**Page status values:** `planned`, `published`, `monitoring`

> Keywords marked for removal (`pending_remove`) are excluded automatically. Queued keywords appear immediately after being added but have no SERP data until the next weekly fetch cycle. Off-page detection uses the tenant's primary market.

#### POST /api/v1/content-pages/sync

Sync content pages from your sitemap. Fetches the sitemap, discovers pages, and syncs them into the content planner. Run this before assigning keywords if you have no content pages.

**Response (200):**

```json
{
  "success": true,
  "data": {
    "sitemapPages": 47,
    "imported": 12,
    "removed": 3,
    "skipped": 32
  }
}
```

> Safe to run repeatedly — existing pages are preserved. If sitemap returns 0 pages, existing content pages are not removed (safety guard). May take a few seconds due to live sitemap fetch.

#### POST /api/v1/content-pages/:id/keywords

Assign a tracked keyword to a content page. Each keyword can only be assigned to one page.

**Request Body:**

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| keywordId | uuid | Yes | — | ID of the tracked keyword |
| role | string | No | `"secondary"` | `"primary"` or `"secondary"` |

**Response (201):**

```json
{
  "success": true,
  "data": {
    "pageId": "f1e2d3c4-b5a6-7890-abcd-ef1234567890",
    "keywordId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "role": "primary"
  }
}
```

> Returns `409` with the existing page title if the keyword is already assigned to a different page. If the keyword is already on this page, the role is updated. Returns `404` if the content page does not exist.

#### DELETE /api/v1/content-pages/:id/keywords/:keywordId

Remove a keyword assignment from a content page. This does not remove the keyword from tracking.

**Response (200):**

```json
{
  "success": true,
  "data": {
    "deleted": true
  }
}
```

> Returns `404` if the content page does not exist.

#### GET /api/v1/content-pages/groups

List all topic groups.

**Response:**

```json
{
  "success": true,
  "data": [
    {
      "id": "uuid",
      "name": "Service Pages",
      "displayOrder": 0,
      "pageCount": 8,
      "createdAt": "2026-03-20T00:00:00Z",
      "updatedAt": "2026-03-20T00:00:00Z"
    }
  ]
}
```

#### POST /api/v1/content-pages/groups

Create a topic group.

**Request Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| name | string | Yes | Topic group name (1-255 chars) |

**Response (201):**

```json
{
  "success": true,
  "data": {
    "id": "uuid",
    "name": "Blog Posts",
    "displayOrder": 1,
    "pageCount": 0,
    "createdAt": "2026-03-20T00:00:00Z",
    "updatedAt": "2026-03-20T00:00:00Z"
  }
}
```

#### PATCH /api/v1/content-pages/groups/:groupId

Update a topic group (rename).

**Request Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| name | string | No | New name (1-255 chars) |

**Response (200):** `{ "success": true, "data": { "updated": true } }`

#### DELETE /api/v1/content-pages/groups/:groupId

Delete a topic group. Pages in the group are moved to ungrouped (not deleted).

**Response (200):** `{ "success": true, "data": { "deleted": true } }`

#### PATCH /api/v1/content-pages/:id/group

Assign a content page to a topic group, or ungroup it.

**Request Body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| groupId | uuid \| null | Yes | Topic group ID, or `null` to ungroup |

**Response (200):**

```json
{
  "success": true,
  "data": {
    "pageId": "uuid",
    "groupId": "uuid"
  }
}
```

> The `groupId` field is also returned on each content page in `GET /api/v1/content-pages`.

---

### Markets

Geographic market configuration. Returns the tenant's primary market and any dedicated zip markets (Team tier only).

#### GET /api/v1/markets

Get your market configuration including primary market and dedicated zip markets. Use the returned market IDs with the `?market=` parameter on other endpoints.

**No query parameters.**

**Response:**

```json
{
  "success": true,
  "data": {
    "primary": {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "city": "Chicago",
      "state": "IL",
      "zip": "60614",
      "country": "US"
    },
    "dedicated": [
      {
        "id": "660e8400-e29b-41d4-a716-446655440001",
        "city": "Naperville (60540)",
        "state": "IL",
        "zip": "60540",
        "country": "US",
        "isAddon": false
      }
    ],
    "limits": {
      "includedDedicatedZips": 3,
      "used": 1
    }
  }
}
```

**Response fields:**

| Field | Description |
|-------|-------------|
| `data.primary` | Primary market (null if not yet configured) |
| `data.primary.id` | Market UUID — use with `?market=` on other endpoints |
| `data.primary.zip` | Postal code (US zip, Canadian postal code, or UK postcode) |
| `data.primary.country` | Country code: `US`, `CA`, or `GB` |
| `data.dedicated[]` | Dedicated zip markets (empty for Pro users) |
| `data.dedicated[].isAddon` | Whether this is a paid add-on zip (Phase 2) |
| `data.limits.includedDedicatedZips` | Number of dedicated zips included in plan (0 for Pro, 3 for Team) |
| `data.limits.used` | Number of dedicated zips currently configured |

> Pro users see only their primary market with an empty `dedicated` array and `limits.includedDedicatedZips = 0`. Team users see up to 3 dedicated zip markets in addition to their primary.

---

### Recon

Ranking keywords discovered during tenant onboarding via Moz. Includes your own domain's ranking keywords (`tenant`) and competitor keywords (`data`), grouped by domain.

#### GET /api/v1/recon/competitors

Get ranking keywords for your domain and competitors, plus keyword slot capacity.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| limit | integer | 10 | Max competitors per page (max 50) |
| offset | integer | 0 | Number of competitors to skip |

**Response:**

```json
{
  "success": true,
  "schemaVersion": "2",
  "data": {
    "tenant": {
      "domain": "yoursite.com",
      "keywordCount": 25,
      "bestPosition": 2,
      "isTenant": true,
      "keywords": [
        {
          "phrase": "chicago headshots",
          "position": 2,
          "volume": 3200,
          "difficulty": 28,
          "rankingPage": "https://yoursite.com/headshots",
          "isTracked": true,
          "trackedPosition": 3,
          "trackedKeywordId": "550e8400-e29b-41d4-a716-446655440001"
        }
      ]
    },
    "items": [
      {
        "domain": "competitorsite.com",
        "keywordCount": 12,
        "serpAppearances": 51,
        "top3Appearances": 5,
        "top10Appearances": 21,
        "bestPosition": 3,
        "isTenant": false,
        "keywords": [
          {
            "phrase": "professional headshots chicago",
            "position": 3,
            "volume": 2400,
            "difficulty": 35,
            "rankingPage": "https://competitorsite.com/headshots",
            "isTracked": true,
            "trackedPosition": 5,
            "trackedKeywordId": "550e8400-e29b-41d4-a716-446655440002"
          },
          {
            "phrase": "corporate photography",
            "position": 8,
            "volume": 1900,
            "difficulty": 42,
            "rankingPage": "https://competitorsite.com/corporate",
            "isTracked": false,
            "trackedPosition": null,
            "trackedKeywordId": null
          }
        ]
      }
    ],
    "keywordSlots": {
      "used": 18,
      "limit": 25
    }
  },
  "pagination": {
    "total": 8,
    "limit": 10,
    "offset": 0,
    "hasMore": false
  }
}
```

**Response fields:**

| Field | Description |
|-------|-------------|
| `tenant` | Your own domain's ranking keywords (null if no Moz data yet) |
| `tenant.isTenant` | Always `true` |
| `data[].domain` | Competitor domain name |
| `data[].isTenant` | Always `false` for competitor entries |
| `data[].keywordCount` | Number of keywords this domain ranks for |
| `data[].bestPosition` | Best ranking position across all keywords (null if no positions) |
| `*.keywords[].position` | Moz ranking position |
| `*.keywords[].isTracked` | Whether you are already tracking this keyword phrase |
| `*.keywords[].trackedPosition` | Your current position from our SERP tracking (null if not tracked) |
| `*.keywords[].trackedKeywordId` | Keyword UUID for detail lookup via `GET /api/v1/keywords` (null if not tracked) |
| `*.keywords[].rankingPage` | The URL the domain ranks with for this keyword |
| `keywordSlots.used` | Current number of tracked keywords |
| `keywordSlots.limit` | Maximum keywords allowed on your plan |
| `pagination.total` | Total number of competitors available |
| `pagination.hasMore` | Whether more competitors exist beyond this page |

> Your domain is always in the `tenant` field (not paginated). Competitors in `data` are sorted by keyword count (descending). Keywords within each domain are sorted by position (ascending, nulls last). Blacklisted competitors are excluded automatically.

---

### Page Audit

#### `POST /api/v1/page-audit`

Run an on-demand SEO audit on any URL. Analyzes 50+ signals including headings, keyword placement, keyword density, content quality, images (with file sizes via PageSpeed Insights), schema markup, Open Graph, canonical tags, indexability, site-level signals, NAP presence, readability, and platform detection.

**Rate limits:** 10 audits/hour, 20/day.

**Request body:**

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `url` | string | Yes | The page URL to audit |
| `targetKeyword` | string | Yes | The keyword you want this page to rank for |

**Example:**

```bash
curl -X POST https://seo.312elements.com/api/v1/page-audit \
  -H "Authorization: Bearer kt_live_..." \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/headshots", "targetKeyword": "professional headshots chicago"}'
```

**Response:**

| Field | Description |
|-------|-------------|
| `data.hash` | Unique report ID — retrieve later via `GET /api/page-audit/report/{hash}` |
| `data.report.headings` | H1/H2/H3 counts, text, and hierarchy validation |
| `data.report.keywordPlacement` | Whether target keyword appears in title, H1, meta, first 100 words, alt text, URL slug |
| `data.report.keywordDensity` | Keyword frequency, density percentage, top 2-word and 3-word phrases |
| `data.report.titleTag` | Title presence, text, length, too short/long flags |
| `data.report.metaDescription` | Meta description presence, text, length, too short/long flags |
| `data.report.content` | Word count, thin content flag, content-to-HTML ratio, pre-JS vs post-JS comparison |
| `data.report.images` | Image list with src, alt text, loading attribute, fetchpriority, file size (bytes) |
| `data.report.schema` | Schema.org presence and types detected |
| `data.report.openGraph` | OG title, description, image presence |
| `data.report.technical` | HTTP status, redirect detection, mixed content, lang, favicon, platform |
| `data.report.siteLevel` | Sitemap, robots.txt, llms.txt, HTTPS redirect, www consistency, custom 404 — all measured live during this audit (`source: "live"`), not read from the stored site-health check |
| `data.report.nap` | Phone and address detection (local SEO) |
| `data.report.readability` | Flesch readability score and label |

---

## Cron Job Examples

### Python — Export to CSV

Run with cron: `0 9 * * 0 python sync_rankings.py`

```python
#!/usr/bin/env python3
"""Weekly ranking sync - exports all data to CSV files"""
import requests
import csv
from datetime import datetime
import os

API_KEY = os.environ.get('KEYWORD_TRACKER_API_KEY', 'kt_live_your_key_here')
BASE_URL = 'https://seo.312elements.com'

def export_rankings():
    headers = {'Authorization': f'Bearer {API_KEY}'}

    # Get all data in one request
    response = requests.get(
        f'{BASE_URL}/api/v1/export',
        headers=headers,
        params={'include': 'keywords,competitors,opportunities,rankings', 'history_days': 7}
    )
    response.raise_for_status()
    body = response.json()
    data = body.get('data', {})  # the export bundle lives under `data`

    # Export keywords to CSV
    today = datetime.now().strftime('%Y-%m-%d')
    with open(f'rankings_{today}.csv', 'w', newline='') as f:
        writer = csv.writer(f)
        writer.writerow(['Keyword', 'Position', 'Previous', 'Change', 'URL', 'Volume', 'Difficulty'])
        for kw in data.get('keywords', []):
            writer.writerow([
                kw['phrase'], kw['currentPosition'], kw['previousPosition'],
                kw['positionChange'], '', kw.get('volume', ''), kw.get('difficulty', '')
            ])

    print(f"Exported {len(data.get('keywords', []))} keywords to rankings_{today}.csv")

    # Export opportunities
    opps = data.get('opportunities', {})
    striking = opps.get('strikingDistance', {}).get('keywords', [])
    if striking:
        with open(f'opportunities_{today}.csv', 'w', newline='') as f:
            writer = csv.writer(f)
            writer.writerow(['Keyword', 'Position', 'Best Recent', 'Score'])
            for opp in striking:
                writer.writerow([opp['keyword'], opp['currentPosition'], opp['bestRecentPosition'], opp['opportunityScore']])
        print(f"Exported {len(striking)} opportunities")

if __name__ == '__main__':
    export_rankings()
```

### Node.js — Sync to Database

Run with cron: `0 9 * * 0 node sync_to_db.js`

```javascript
#!/usr/bin/env node
const fetch = require('node-fetch');
const Database = require('better-sqlite3');

const API_KEY = process.env.KEYWORD_TRACKER_API_KEY || 'kt_live_your_key_here';
const BASE_URL = 'https://seo.312elements.com';

async function syncRankings() {
  const response = await fetch(`${BASE_URL}/api/v1/export?include=keywords,rankings&history_days=30`, {
    headers: { 'Authorization': `Bearer ${API_KEY}` }
  });

  if (!response.ok) throw new Error(`API error: ${response.status}`);
  const body = await response.json();
  const data = body.data; // the export bundle lives under `data`

  const db = new Database('rankings.db');
  db.exec(`
    CREATE TABLE IF NOT EXISTS rankings (
      date TEXT, keyword TEXT, position INTEGER, url TEXT,
      PRIMARY KEY (date, keyword)
    )
  `);

  const insert = db.prepare('INSERT OR REPLACE INTO rankings VALUES (?, ?, ?, ?)');
  const today = new Date().toISOString().split('T')[0];

  const transaction = db.transaction((keywords) => {
    for (const kw of keywords) {
      insert.run(today, kw.phrase, kw.currentPosition, '');
    }
  });

  transaction(data.keywords || []);
  console.log(`Synced ${data.keywords?.length || 0} keywords to database`);
  db.close();
}

syncRankings().catch(console.error);
```

### Google Sheets — Apps Script

Go to Extensions > Apps Script > paste this code > set a weekly trigger.

```javascript
const API_KEY = 'kt_live_your_key_here';
const BASE_URL = 'https://seo.312elements.com';

function syncRankings() {
  const options = {
    method: 'GET',
    headers: { 'Authorization': 'Bearer ' + API_KEY },
    muteHttpExceptions: true
  };

  const response = UrlFetchApp.fetch(
    BASE_URL + '/api/v1/export?include=keywords,opportunities',
    options
  );

  if (response.getResponseCode() !== 200) {
    throw new Error('API Error: ' + response.getContentText());
  }

  const body = JSON.parse(response.getContentText());
  const data = body.data; // the export bundle lives under `data`
  const ss = SpreadsheetApp.getActiveSpreadsheet();

  // Update Rankings sheet
  let sheet = ss.getSheetByName('Rankings') || ss.insertSheet('Rankings');
  sheet.clear();
  sheet.appendRow(['Keyword', 'Position', 'Change', 'Volume', 'Difficulty', 'Updated']);

  const today = new Date().toISOString().split('T')[0];
  (data.keywords || []).forEach(kw => {
    sheet.appendRow([
      kw.phrase, kw.currentPosition, kw.positionChange,
      kw.volume || '', kw.difficulty || '', today
    ]);
  });

  // Update Opportunities sheet
  const opps = data.opportunities?.strikingDistance?.keywords || [];
  if (opps.length > 0) {
    let oppSheet = ss.getSheetByName('Opportunities') || ss.insertSheet('Opportunities');
    oppSheet.clear();
    oppSheet.appendRow(['Keyword', 'Position', 'Best Recent', 'Score']);
    opps.forEach(opp => {
      oppSheet.appendRow([opp.keyword, opp.currentPosition, opp.bestRecentPosition, opp.opportunityScore]);
    });
  }

  SpreadsheetApp.getUi().alert('Synced ' + (data.keywords?.length || 0) + ' keywords!');
}
```

### cURL — Quick Export

```bash
# Export all data to JSON file
curl -s -H "Authorization: Bearer kt_live_your_key_here" \
  "https://seo.312elements.com/api/v1/export?include=keywords,competitors,opportunities,rankings" \
  | jq '.' > export_$(date +%Y-%m-%d).json

# Get just keywords as CSV (using jq)
curl -s -H "Authorization: Bearer kt_live_your_key_here" \
  "https://seo.312elements.com/api/v1/keywords" \
  | jq -r '.data[] | [.phrase, .currentPosition, .positionChange, .volume] | @csv' > keywords.csv
```

### PHP — WordPress Integration

```php
<?php
/**
 * Weekly ranking sync for WordPress
 * Add to your theme's functions.php or a custom plugin
 *
 * Schedule: wp_schedule_event(time(), 'weekly', 'sync_keyword_rankings');
 */

define('KEYWORD_TRACKER_API_KEY', 'kt_live_your_key_here');
define('KEYWORD_TRACKER_API_URL', 'https://seo.312elements.com');

function sync_keyword_rankings() {
    $response = wp_remote_get(
        KEYWORD_TRACKER_API_URL . '/api/v1/export?include=keywords,opportunities',
        array(
            'headers' => array(
                'Authorization' => 'Bearer ' . KEYWORD_TRACKER_API_KEY,
            ),
            'timeout' => 30,
        )
    );

    if (is_wp_error($response)) {
        error_log('Keyword Tracker API Error: ' . $response->get_error_message());
        return;
    }

    $body = json_decode(wp_remote_retrieve_body($response), true);

    if (!$body['success']) {
        error_log('Keyword Tracker API failed: ' . json_encode($body));
        return;
    }

    $data = $body['data']; // the export bundle lives under `data`

    // Store in WordPress options or custom table
    update_option('keyword_rankings', $data['keywords'] ?? []);
    update_option('keyword_rankings_updated', current_time('mysql'));

    $striking = count($data['opportunities']['strikingDistance'] ?? []);
    $declining = count($data['opportunities']['declining'] ?? []);
    error_log("Synced rankings: {$striking} striking distance, {$declining} declining");
}

add_action('sync_keyword_rankings', 'sync_keyword_rankings');

if (!wp_next_scheduled('sync_keyword_rankings')) {
    wp_schedule_event(time(), 'weekly', 'sync_keyword_rankings');
}
?>
```

### Slack — Weekly Summary

Run with cron: `0 9 * * 1 node slack_summary.js`

```javascript
#!/usr/bin/env node
const fetch = require('node-fetch');

const API_KEY = process.env.KEYWORD_TRACKER_API_KEY || 'kt_live_your_key_here';
const SLACK_WEBHOOK = process.env.SLACK_WEBHOOK_URL || 'https://hooks.slack.com/services/xxx/xxx/xxx';
const BASE_URL = 'https://seo.312elements.com';

async function sendSlackSummary() {
  const response = await fetch(`${BASE_URL}/api/v1/export?include=keywords,opportunities`, {
    headers: { 'Authorization': `Bearer ${API_KEY}` }
  });
  const body = await response.json();
  const data = body.data; // the export bundle lives under `data`

  const keywords = data.keywords || [];
  const improving = keywords.filter(k => (k.positionChange || 0) < 0).length;
  const declining = keywords.filter(k => (k.positionChange || 0) > 0).length;
  const stable = keywords.filter(k => k.positionChange === 0).length;
  const avgPosition = keywords.length > 0
    ? (keywords.reduce((sum, k) => sum + (k.currentPosition || 100), 0) / keywords.length).toFixed(1)
    : 'N/A';

  const opportunities = data.opportunities || {};
  const strikingDistanceCount = opportunities.strikingDistance?.keywords?.length || 0;

  const message = {
    blocks: [
      { type: 'header', text: { type: 'plain_text', text: ':chart_with_upwards_trend: Weekly SEO Report' } },
      {
        type: 'section',
        fields: [
          { type: 'mrkdwn', text: `*Improving:* :arrow_up: ${improving}` },
          { type: 'mrkdwn', text: `*Declining:* :arrow_down: ${declining}` },
          { type: 'mrkdwn', text: `*Stable:* :left_right_arrow: ${stable}` },
          { type: 'mrkdwn', text: `*Avg Position:* ${avgPosition}` },
        ]
      },
      {
        type: 'section',
        text: { type: 'mrkdwn', text: `*Opportunities:* ${strikingDistanceCount} keywords in striking distance` }
      }
    ]
  };

  await fetch(SLACK_WEBHOOK, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(message)
  });

  console.log('Slack summary sent!');
}

sendSlackSummary().catch(console.error);
```

---

## SDK & Client Generation

Generate typed clients automatically from the OpenAPI specification.

### TypeScript — Generate Types

```bash
# Install the generator
npm install -D openapi-typescript

# Generate types from the API
npx openapi-typescript https://seo.312elements.com/api/v1/openapi.json -o ./src/types/api.ts

# Usage in your code:
import type { paths, components } from './types/api';

type Keyword = components['schemas']['Keyword'];
type KeywordListResponse = paths['/api/v1/keywords']['get']['responses']['200']['content']['application/json'];
```

### Python — Generate Client

```bash
# Install OpenAPI Generator (requires Java 8+)
npm install -g @openapitools/openapi-generator-cli

# Generate Python client
openapi-generator-cli generate \
  -i https://seo.312elements.com/api/v1/openapi.json \
  -g python \
  -o ./python-client \
  --additional-properties=packageName=keyword_tracker

# Usage:
cd python-client && pip install .

from keyword_tracker import ApiClient, KeywordsApi
client = ApiClient()
client.configuration.access_token = 'kt_live_your_key_here'
api = KeywordsApi(client)
keywords = api.list_keywords(limit=50)
```

### Other Languages

```bash
# JavaScript/Node.js (fetch-based)
openapi-generator-cli generate -i https://seo.312elements.com/api/v1/openapi.json -g javascript -o ./js-client

# Go
openapi-generator-cli generate -i https://seo.312elements.com/api/v1/openapi.json -g go -o ./go-client

# Ruby
openapi-generator-cli generate -i https://seo.312elements.com/api/v1/openapi.json -g ruby -o ./ruby-client

# PHP
openapi-generator-cli generate -i https://seo.312elements.com/api/v1/openapi.json -g php -o ./php-client

# C#/.NET
openapi-generator-cli generate -i https://seo.312elements.com/api/v1/openapi.json -g csharp -o ./csharp-client

# See all available generators:
openapi-generator-cli list
```

### Direct Spec Access

```bash
# Download the spec
curl -o openapi.json https://seo.312elements.com/api/v1/openapi.json

# View in Swagger UI (local)
npx @redocly/cli preview-docs openapi.json
```
