# 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`, and `pagination`. Every v1 endpoint conforms to this envelope.

---

## Response Envelope & Versioning

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

```json
{
  "success": true,
  "schemaVersion": "2",
  "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.
- 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/competitors` | `"3"` | Bumped May 2026 — `summary` and `shareOfVoice` moved inside `data`. |
| `GET /api/v1/opportunities` | `"2"` | Standardized May 2026 to canonical envelope. |
| `GET /api/v1/rankings` | `"3"` | Bumped May 2026 — `distribution` and `rankingCount` moved inside `data`. |
| `GET /api/v1/rankings/declining` | `"2"` | 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` | `"2"` | Bumped May 2026 — `summary` moved inside `data`. |
| `GET /api/v1/backlinks/opportunities` | `"2"` | Bumped May 2026 — `totalAvailable` and `mode` moved inside `data`. |
| `GET /api/v1/backlinks/opportunities/global` | `"2"` | 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/dismiss` | `"2"` | Standardized May 2026 to canonical envelope. |
| `DELETE /api/v1/issues/dismiss` | `"2"` | Standardized May 2026 to canonical envelope. |
| `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` | `"4"` | 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` | `"3"` | 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}` | `"2"` | 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` | `"2"` | 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` | `"2"` | 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}` | `"2"` | 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: "2"`. 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`).

**Query Parameters:**

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

**Response:**

```json
{
  "success": true,
  "schemaVersion": "2",
  "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, "brandAuthority": 12 }
    ],
    "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, "brandAuthority": 12 }
  ],
  "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. Returns `409` if already tracked. Returns `403` if keyword limit is reached.

#### 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": 3, "checkedAt": "2026-02-28T10:00:00Z" },
    "lastRefresh": "2026-02-28T10:00:00Z"
  }
}
```

> `siteHealth` is `null` if no site health check has been performed yet.

> **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,
  "data": [
    { "keyword": "best seo tools", "type": "improvement", "oldPosition": 12, "newPosition": 5, "change": -7, "date": "2024-12-31" }
  ]
}
```

#### 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": "3",
  "data": {
    "items": [
      {
        "domain": "competitor.com",
        "companyName": "Competitor Inc",
        "domainAuthority": 45,
        "pageAuthority": 38,
        "spamScore": 2,
        "brandAuthority": 12,
        "keywordsRankedFor": 15,
        "avgPosition": 8.5,
        "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, "avgPosition": 8.5 }
  ]
}
```

---

### 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 scraped pages by domain.

**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.

**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.

**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.

**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.

**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 (not linked internally), duplicate titles, and duplicate meta descriptions.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| type | string | `all` | `orphan`, `duplicate_titles`, `duplicate_descriptions`, or `all` |
| limit | integer | 20 | Max 100 |

**Response:**

```json
{
  "success": true,
  "data": {
    "orphanPages": [],
    "duplicateTitles": [],
    "duplicateDescriptions": []
  }
}
```

---

### 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` |
| 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` |
| 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.

**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)` |

**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"
  }
}
```

> **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. 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 |

**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,
  "data": [
    {
      "id": "uuid",
      "tenant_id": "uuid",
      "event_type": "serp.position_improved",
      "event_hash": "abc123",
      "data": { "keyword": "best seo tools", "oldPosition": 12, "newPosition": 5 },
      "context": { "keywordId": "uuid" },
      "category": "serp",
      "priority_score": 85,
      "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,
      "prompt_template_slug": "position_improved"
    }
  ]
}
```

---

### 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 | — | Filter by alert type (e.g., `position_drop`, `new_competitor`) |
| 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,
  "data": [
    {
      "id": "uuid",
      "keywordId": "uuid",
      "keywordPhrase": "best seo tools",
      "alertType": "position_drop",
      "severity": "warning",
      "message": "Position dropped from 5 to 12",
      "details": { "oldPosition": 5, "newPosition": 12 },
      "isRead": false,
      "createdAt": "2026-02-28T10:00:00Z"
    }
  ],
  "pagination": { "total": 8, "limit": 50, "offset": 0, "hasMore": false }
}
```

#### 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",
  "promptTemplateSlug": "keyword-recovery-elite"
}
```

- `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}`.
- `promptTemplateSlug`: Optional. Slug of the template rendered at copy time. If omitted, the server derives the canonical slug from current keyword position (declining) or issue type (vulnerable).

**Response:**

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

#### 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",
      "promptTemplateSlug": "keyword-recovery-elite"
    }
  ],
  "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.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| limit | integer | 50 | Max 100 |
| offset | integer | 0 | Pagination offset |
| status | string | — | `active` or `lost` |
| minDA | integer | — | Minimum domain authority |

**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,
    "computedAt": "2026-02-28T10:00:00Z"
  }
}
```

> **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.

**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

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.

**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 from a rolling 41-week rotation (mix of market-scoped and tenant-scoped brand prompts depending on where the week lands). 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.

**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 }
}
```

#### 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 |
| `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
```
