# 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 |
|--------------|-------|--------|
| Standard (GET) | 100 requests | 1 minute |
| Heavy (POST/export/detail) | 20 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)
- **`pagination`** — present on paginated list endpoints; includes `total`, `limit`, `offset`, `hasMore`
- **`success`** — always `true` on 2xx responses, `false` on errors

**Legacy endpoints with non-standard keys:** Four endpoints predate this convention and use custom top-level keys instead of `data`. They will be standardized in a future API version.

| Endpoint | Uses | Instead of |
|---|---|---|
| `/api/v1/competitors` | `competitors`, `summary`, `meta` | `data`, `pagination` |
| `/api/v1/opportunities` | `opportunities`, `meta` | `data`, `pagination` |
| `/api/v1/rankings` | `rankings`, `distribution`, `rankingCount`, `pagination`, `meta` | `data`, `pagination` |
| `/api/v1/webhooks` (GET list) | `webhooks`, `meta` | `data` |

All new endpoints follow the standard `{ success, data, pagination }` shape.

---

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

| Endpoint | schemaVersion |
|---|---|
| `/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). |
| `/api/v1/ai-visibility/weekly` | `"2"` — added structured `mentionedBusinesses[]` + `tenantRank`. April 2026 additive fields: per-result `visibilityId`, `hasCitations`, `citationSummary` (non-breaking). |
| `/api/v1/ai-visibility/query/{visibilityId}` | `"1"` — launched April 2026. Per-query citation detail. |

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

> Uses heavier rate limit (20/min). Ideal for infrequent bulk exports.

---

### Keywords

#### GET /api/v1/keywords

Get all tracked keywords with current positions, Moz metrics, and optional history sparklines.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| limit | integer | 50 | Max 100 |
| offset | integer | 0 | Pagination offset |
| status | string | `all` | `all` (includes queued), `active` (with ranking data only), or `queued` (pending first fetch only) |
| 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,
  "data": [
    {
      "id": "uuid",
      "phrase": "best seo tools",
      "displayName": null,
      "currentPosition": 5,
      "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 }]
    }
  ],
  "pagination": { "total": 25, "limit": 50, "offset": 0, "hasMore": false }
}
```

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

Get detailed keyword data including SERP results with position history, competitors, Moz metrics, sparklines, SERP features, and your domain's own metrics.

**Query Parameters:**

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

**Response:**

```json
{
  "success": true,
  "data": {
    "keyword": {
      "id": "uuid",
      "phrase": "best seo tools",
      "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
  }
}
```

> Uses heavier rate limit (20/min) due to multiple table joins. 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.

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

> Uses heavier rate limit (20/min) due to SERP data size.

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

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

Remove a keyword from tracking. Active keywords are marked for removal and cleaned up in the next cycle. Queued keywords are removed immediately.

**Important — read before deleting keywords:**

Tracked keywords are the foundation of all SEO data in AiSEO. Rankings, competitor analysis, content planner assignments, weekly reports, and SEO score calculations all depend on tracked keywords. Removing a keyword without adding a replacement leaves a permanent gap in your tracking coverage. There is no undo — once the weekly cleanup runs, historical ranking data for that keyword is no longer associated with your account.

**Before removing keywords, consider:**
- Do you have a replacement keyword to add? If swapping, add the new keyword first.
- Is this keyword assigned to a content page? Remove the page assignment first to keep your planner clean.
- Removing all keywords effectively disables your SEO tracking.

**Response (200):**

```json
{
  "success": true,
  "data": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "phrase": "actor headshots chicago",
    "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."
  }
}
```

> `pendingRemoval: true` means the keyword has existing SERP data. `pendingRemoval: false` means it was still queued and removed immediately. 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,
  "rankings": [
    { "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 },
  "meta": { "total": 25, "limit": 20, "positionRange": null, "changeDirection": null, "market": null }
}
```

#### 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,
  "data": [
    { "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" }
  ],
  "total": 30
}
```

#### 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 |
| 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,
  "data": [
    { "keyword": "seo automation", "currentPosition": 25, "previousPosition": 12, "change": 13, "dismissed": false }
  ],
  "total": 8
}
```

---

### 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,
  "competitors": [
    {
      "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 },
  "meta": { "limit": 10, "minOverlap": 0, "minDa": 0, "sortBy": "overlap", "includeShareOfVoice": false }
}
```

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

> Uses heavier rate limit (20/min). 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 }
  ],
  "total": 20
}
```

---

### 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,
  "opportunities": {
    "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
    }
  },
  "meta": { "type": "all", "limit": 20, "minPosition": 1, "maxPosition": 100, "sortBy": "score" }
}
```

---

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

> Uses heavier rate limit (20/min).

---

### 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 including headings, schema, body text, and OpenGraph data.

**Response:**

```json
{
  "success": true,
  "data": {
    "id": "uuid",
    "url": "https://example.com/page",
    "title": "Page Title",
    "metaDescription": "Description...",
    "h1": "Main Heading",
    "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..."
  }
}
```

#### 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,
  "domain": "example.com",
  "count": 120,
  "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
    }
  ]
}
```

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

---

### Backlinks

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

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

Get weekly breakdown of gained and lost backlinks with individual link details.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| weeks | integer | 4 | Max 12 |

**Response:**

```json
{
  "success": true,
  "data": { "weeks": [] }
}
```

> Uses heavier rate limit (20/min).

#### 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,
  "data": [
    { "sourceDomain": "blog.example.com", "domainAuthority": 45, "competitorCount": 3, "competitorDomains": ["a.com", "b.com", "c.com"] }
  ],
  "total": 50
}
```

#### 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,
  "data": [
    { "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.

**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...", "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 }
  ],
  "total": 12
}
```

---

### 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,
  "message": "Alert marked as read"
}
```

#### 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,
  "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
}
```

**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). Uses heavier rate limit (20/min) on the standard rate limiter.

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

---

### 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 grouped by keyword.

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| grouped | boolean | `false` | `true` to group questions by keyword |
| market | uuid | — | Filter by market (Team tier only) |

**Response (flat):**

```json
{
  "success": true,
  "data": [
    { "question": "What are the best SEO tools?", "keyword": "best seo tools", "keywordId": "uuid" },
    { "question": "How much do SEO tools cost?", "keyword": "best seo tools", "keywordId": "uuid" }
  ]
}
```

**Response (grouped=true):**

```json
{
  "success": true,
  "data": [
    {
      "keyword": "best seo tools",
      "keywordId": "uuid",
      "questions": [
        "What are the best SEO tools?",
        "How much do SEO tools cost?"
      ]
    }
  ]
}
```

---

### 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,
  "data": [
    { "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,
  "webhooks": [
    {
      "id": "uuid",
      "name": "Ranking Alerts",
      "url": "https://example.com/webhook",
      "secret": "whk_...",
      "event_types": ["ranking.changed", "keyword.added"],
      "categories": ["rankings"],
      "min_priority": "medium",
      "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"
    }
  ],
  "meta": { "total": 1, "limit": 10 }
}
```

#### 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": "medium",
  "test_on_create": true
}
```

**Response:**

```json
{
  "success": true,
  "webhook": {
    "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": "medium",
    "is_active": true,
    "created_at": "2024-12-31T10:00:00Z"
  },
  "test_result": { "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. Maximum 10 webhooks per tenant.

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

Get detailed information for a specific webhook.

**Response:**

```json
{
  "success": true,
  "webhook": {
    "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,
  "webhook": {
    "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,
  "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,
  "test_result": {
    "success": true,
    "statusCode": 200,
    "error": null,
    "responseTime": 150
  },
  "webhook_id": "uuid"
}
```

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

**Response:**

```json
{
  "success": true,
  "data": [
    {
      "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
        },
        {
          "id": "uuid",
          "phrase": "headshot photographer near me",
          "role": "secondary",
          "status": "queued",
          "currentPosition": null,
          "previousPosition": null,
          "positionChange": null
        }
      ],
      "createdAt": "2024-12-01T00:00:00Z",
      "updatedAt": "2024-12-31T10:00:00Z"
    }
  ],
  "meta": {
    "totalPages": 15,
    "totalKeywords": 42,
    "pagesWithKeywords": 12
  }
}
```

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

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

#### 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,
  "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"
      }
    ]
  },
  "data": [
    {
      "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()
    data = response.json()

    # 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 data = await response.json();

  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 data = JSON.parse(response.getContentText());
  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;
    }

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

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

    // 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 data = await response.json();

  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 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:* ${data.opportunities?.strikingDistance?.length || 0} 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
```
