# Available Endpoints

This page lists all endpoints that support API key authentication. Most endpoints use your **property** API key; everything under `/api/v1/` — report generation, AIVPS, PQS, and coverage — uses your **organization** API key. For interactive documentation and testing, visit the [API Reference](/api/docs#tag/external-api).

<Callout type="info">
The API Reference shows all endpoints, but only the ones listed below support external API key access. Other endpoints are for internal application use only.
</Callout>

## AI Placement Value Score (AIVPS)

Programmatically score one or more publisher domains against the AI Placement Value Score (AIVPS) layers — organic authority, AI citation accessibility, and AI training influence. Use this to rank placement opportunities, monitor AI-era authority of partner and competitor domains, and feed SEO/PR workflows that already track traditional authority metrics.

<Callout type="warn">
**These endpoints use an organization-level API key, not your property API key.** The two are not interchangeable. Generate a key under **Organization Settings → API Key**. A single organization key authorizes AIVPS scoring across every domain your organization queries.
</Callout>

### Score Publishers

Compute the full AIVPS for one or more publisher domains. If the domain is unknown to Spyglasses, a publisher record is auto-created and enriched asynchronously in the background — the first call may return `"confidence": "estimated"` while enrichment finishes.

```
POST /api/v1/ai-impact
```

**Headers:**
- `x-api-key`: Your organization's AIVPS API key (required)
- `Content-Type`: application/json

**Body (primary use case — score a batch against a brand):**
```json
{
  "domains": ["wirecutter.com", "nytimes.com", "techcrunch.com"],
  "brandContext": {
    "propertyId": "YOUR_PROPERTY_ID"
  }
}
```

- `domain` (string) **or** `domains` (array, 1–25) — supply exactly one. Domains are normalized (protocol/`www`/trailing slash stripped).
- `brandContext.propertyId` (optional) — must reference a Property owned by the authenticated organization. Enables category-relevance scoring and AI training influence uplift. Omit if you don't have brand context.
- `baseline` (optional) — enables Bring-Your-Own-Baseline mode. See below.

**Response:**
```json
{
  "success": true,
  "results": [
    {
      "domain": "wirecutter.com",
      "publisher": {
        "id": "cm...",
        "domain": "wirecutter.com",
        "name": "Wirecutter",
        "logoUrl": "https://...",
        "aiPolicyStatus": "green",
        "organicMetricsFetchedAt": "2026-04-01T12:00:00.000Z"
      },
      "aipvs": {
        "domain": "wirecutter.com",
        "aipvs": 78,
        "tier": 1,
        "tierLabel": "Premium",
        "aiImpactMultiplier": 0.82,
        "confidence": "exact",
        "confidenceReason": "...",
        "layers": { "oab": { "..." }, "aca": { "..." }, "ati": { "..." } },
        "weightVersion": "2026-Q1",
        "scoredAt": "2026-04-12T18:30:00.000Z"
      },
      "needsEnrichment": false,
      "created": false
    }
  ],
  "errors": []
}
```

If any individual domain fails (for example, a missing baseline value in BYOB mode), that domain appears in `errors[]` and the rest of the batch still succeeds.

---

### Score Publishers with Bring-Your-Own-Baseline (BYOB)

If your team already tracks organic authority with Ahrefs DR, Moz DA, SEMrush AS, Majestic TF, or a custom score, you can supply that baseline and have Spyglasses apply **only** the AI layers on top. The response's `aipvsByob.compositeScore` is computed as `normalizedBaseline × aiImpactMultiplier`.

```
POST /api/v1/ai-impact
```

**Body:**
```json
{
  "domains": ["wirecutter.com", "techcrunch.com"],
  "brandContext": { "propertyId": "YOUR_PROPERTY_ID" },
  "baseline": {
    "source": "ahrefs_dr",
    "values": {
      "wirecutter.com": 88,
      "techcrunch.com": 91
    }
  }
}
```

- `baseline.source` — one of `ahrefs_dr`, `moz_da`, `semrush_as`, `majestic_tf`, `dataforseo`, `custom`.
- `baseline.values` — map of domain → baseline value. Must contain an entry for every domain you pass in.
- `baseline.customMin` / `customMax` — required when `source` is `custom`; values are linearly rescaled to 0–100.

**Response** (adds an `aipvsByob` block per result; standard `aipvs` is still included for reference):
```json
{
  "success": true,
  "results": [
    {
      "domain": "wirecutter.com",
      "publisher": { "..." },
      "aipvs": { "..." },
      "aipvsByob": {
        "baselineSource": "ahrefs_dr",
        "externalBaseline": 88,
        "compositeScore": 72,
        "tier": 2,
        "tierLabel": "Strong",
        "aiImpactMultiplier": 0.82,
        "layers": { "aca": { "..." }, "ati": { "..." } }
      },
      "needsEnrichment": false,
      "created": false
    }
  ],
  "errors": []
}
```

---

### Get AI Impact Multiplier

Returns just the raw AI impact multiplier (0.40–1.00) plus the ACA and ATI layers for one or more domains. Use this when you want to combine Spyglasses' AI layers with a baseline you compute yourself at call time — the most portable form of the AIVPS signal.

```
POST /api/v1/ai-impact/multiplier
```

**Headers:**
- `x-api-key`: Your organization's AIVPS API key (required)
- `Content-Type`: application/json

**Body:**
```json
{
  "domains": ["wirecutter.com", "techcrunch.com"],
  "brandContext": { "propertyId": "YOUR_PROPERTY_ID" }
}
```

**Response:**
```json
{
  "success": true,
  "results": [
    {
      "domain": "wirecutter.com",
      "aiImpactMultiplier": 0.82,
      "layers": {
        "aca": { "composite": 0.91, "platforms": { "..." } },
        "ati": { "composite": 0.74, "dti": 0.8, "uplift": 1.1, "trainingAccessComposite": 0.84, "platforms": { "..." } }
      },
      "confidence": "exact",
      "needsEnrichment": false
    }
  ],
  "errors": []
}
```

---

## AI Placement Quality Score (PQS)

Programmatically score placements against the [AI Placement Quality Score](/ai-placement-quality-score) factors — document position, chunk containment, placement type, editorial control, link attribute, and sentiment multiplier. When a publisher domain is supplied, the response also includes the publisher's AIPVS and a combined **Total Placement Value** (`AIPVS × PQS / 100`).

The PQS API supports both scoring modes:

- **Prospective** (synchronous) — score a hypothetical placement from user-supplied inputs. Returns the full score in the POST response.
- **Retrospective** (async, polling) — score an existing placement at a live URL. The POST creates a placement and enqueues the background job; the client polls a GET endpoint for the result.

<Callout type="warn">
**All PQS endpoints use an organization-level API key**, the same key used for the AIVPS endpoints above. Generate one under **Organization Settings → API Key**.
</Callout>

### Score a Prospective Placement

Compute the PQS for a hypothetical placement given its scoring inputs. Use this to rank placement offers, run "what-if" scenarios before negotiating, or integrate PQS into your own media planning tools.

```
POST /api/v1/ai-impact/placement
```

**Headers:**
- `x-api-key`: Your organization's API key (required)
- `Content-Type`: application/json

**Body:**
```json
{
  "placementType": "roundup_top_third",
  "editorialControl": "collaborative",
  "linkAttribute": "dofollow",
  "assumedPosition": 0.12,
  "publisherDomain": "wirecutter.com",
  "brandContext": {
    "propertyId": "YOUR_PROPERTY_ID"
  }
}
```

- `placementType` (required) — one of `dedicated_article`, `roundup_top`, `roundup_top_third`, `roundup_middle_third`, `roundup_bottom_third`, `passing_mention`, `sidebar_widget_biobox`, `quote_only`
- `editorialControl` (required) — one of `full_authorship`, `collaborative`, `quoted`, `mentioned`, `unpredictable`
- `linkAttribute` (required) — one of `dofollow`, `nofollow`, `sponsored`, `ugc`, `no_link`
- `assumedPosition` (optional, default `0.15`) — the fraction of the document (0.0–1.0) where you expect the brand mention to land. `0.05` = first 10%, `0.5` = middle, `0.95` = footer.
- `publisherDomain` (optional) — when supplied, the publisher's AIPVS is computed and combined with the PQS for a Total Placement Value.
- `brandContext.propertyId` (optional) — must reference a Property owned by the authenticated organization. Narrows AIPVS category relevance and training influence uplift.

**Response:**
```json
{
  "success": true,
  "pqs": {
    "pqsScore": 79,
    "rawWeightedScore": 79,
    "subScores": {
      "position": 0.85,
      "chunk": 0.7,
      "type": 0.75,
      "editorial": 0.85,
      "link": 1.0
    },
    "sentimentMultiplier": 1.0,
    "sentimentCategory": "neutral",
    "effectiveWeights": {
      "position": 0.3,
      "chunk": 0.2,
      "type": 0.2,
      "editorial": 0.15,
      "link": 0.15
    },
    "defaultWeights": {
      "position": 0.3,
      "chunk": 0.2,
      "type": 0.2,
      "editorial": 0.15,
      "link": 0.15
    },
    "inputs": {
      "placementType": "roundup_top_third",
      "editorialControl": "collaborative",
      "linkAttribute": "dofollow",
      "assumedPosition": 0.12
    }
  },
  "aipvs": {
    "publisher": {
      "id": "cm...",
      "domain": "wirecutter.com",
      "name": "Wirecutter",
      "logoUrl": "https://...",
      "aiPolicyStatus": "green",
      "organicMetricsFetchedAt": "2026-04-01T12:00:00.000Z"
    },
    "aipvs": {
      "domain": "wirecutter.com",
      "aipvs": 78,
      "tier": 1,
      "tierLabel": "Premium",
      "aiImpactMultiplier": 0.82,
      "confidence": "exact",
      "layers": { "oab": { "..." }, "aca": { "..." }, "ati": { "..." } }
    },
    "needsEnrichment": false
  },
  "totalPlacementValue": 62
}
```

**Notes:**
- `pqs.subScores.chunk` is always `0.7` for prospective placements. Actual chunk containment can only be computed from real content, so the API treats prospective placements as "partial" containment by default. Retrospective scoring (via the web UI) computes the real value.
- `pqs.sentimentMultiplier` is always `1.0` for prospective placements. Sentiment analysis requires real content around the mention.
- `aipvs` and `totalPlacementValue` are `null` when `publisherDomain` is omitted or the AIPVS lookup fails.
- `pqs.effectiveWeights` matches `defaultWeights` in prospective mode. Retrospective mode (web UI only) redistributes the editorial weight proportionally across the other four signals.

**Example — run a lift preview:** Fire several requests with the same publisher and editorial control but different placement positions / link attributes to quantify how much better terms would be worth. This is exactly what the "What if you negotiated better terms?" panel in the web UI does.

---

### Score a Retrospective Placement (asynchronous)

Score an existing placement at a live URL. Spyglasses fetches the page, locates the brand mention (using the property's name and aliases), auto-detects the link attribute, classifies placement type and sentiment with an AI model, and computes the PQS. The pipeline typically takes 15–30 seconds and runs as a background job, so this endpoint returns a placement ID immediately — you poll for the result.

```
POST /api/v1/ai-impact/placement/retrospective
```

**Headers:**
- `x-api-key`: Your organization's API key (required)
- `Content-Type`: application/json

**Body:**
```json
{
  "url": "https://www.prnewswire.com/news-releases/example-article",
  "propertyId": "YOUR_PROPERTY_ID"
}
```

- `url` (required) — the live URL to score. Must be publicly accessible.
- `propertyId` (required) — the Property whose brand name and aliases should be used to locate the mention in the content. Must belong to the authenticated organization.
- `title` (optional) — a human-readable label for the placement. Auto-extracted from the page's `<title>` / `og:title` / `h1` if omitted.
- `placementType` / `editorialControl` / `linkAttribute` (optional) — all three are auto-detected from the page during scoring. Only supply them if you want to override the auto-detection.

**Response (HTTP 202):**
```json
{
  "success": true,
  "id": "cm_placement_abc123",
  "status": "pending",
  "mode": "retrospective",
  "url": "https://www.prnewswire.com/news-releases/example-article",
  "createdAt": "2026-04-15T14:30:00.000Z",
  "pollUrl": "/api/v1/ai-impact/placement/cm_placement_abc123",
  "pollIntervalSeconds": 3,
  "estimatedCompletionSeconds": 30
}
```

The returned `id` is the placement's unique identifier. Use it with the polling endpoint below.

---

### Poll a Placement's Status and Result

Fetch the current state of a placement created via the retrospective endpoint (or any placement owned by the authenticated organization). Poll at roughly 3-second intervals until `status` is `completed` or `failed`.

```
GET /api/v1/ai-impact/placement/:id
```

**Headers:**
- `x-api-key`: Your organization's API key (required)

**Response while scoring is still in progress (HTTP 200):**
```json
{
  "success": true,
  "id": "cm_placement_abc123",
  "status": "running",
  "mode": "retrospective",
  "url": "https://www.prnewswire.com/news-releases/example-article",
  "createdAt": "2026-04-15T14:30:00.000Z",
  "startedAt": "2026-04-15T14:30:01.000Z",
  "pollUrl": "/api/v1/ai-impact/placement/cm_placement_abc123",
  "pollIntervalSeconds": 3
}
```

- `status` is either `pending` (queued, not yet running) or `running` (actively being scored).
- Keep polling until `status` changes to `completed` or `failed`.

**Response when scoring has completed (HTTP 200):**
```json
{
  "success": true,
  "id": "cm_placement_abc123",
  "status": "completed",
  "mode": "retrospective",
  "url": "https://www.prnewswire.com/news-releases/example-article",
  "title": "Example Company partners with Acme for industrial decarbonization",
  "createdAt": "2026-04-15T14:30:00.000Z",
  "completedAt": "2026-04-15T14:30:24.000Z",
  "pqs": {
    "pqsScore": 72,
    "rawWeightedScore": 72,
    "subScores": {
      "position": 0.85,
      "chunk": 1.0,
      "type": 0.75,
      "editorial": 0.5,
      "link": 0.6
    },
    "sentimentMultiplier": 1.0,
    "sentimentCategory": "neutral",
    "sentimentPolarity": 0.52,
    "effectiveWeights": {
      "position": 0.353,
      "chunk": 0.235,
      "type": 0.235,
      "editorial": 0,
      "link": 0.176
    },
    "defaultWeights": { "position": 0.3, "chunk": 0.2, "type": 0.2, "editorial": 0.15, "link": 0.15 },
    "inputs": {
      "placementType": "roundup_top_third",
      "editorialControl": "mentioned",
      "linkAttribute": "nofollow",
      "positionPercent": 0.18
    },
    "brandMention": {
      "matchedAlias": "Example Company",
      "tokenIndex": 287,
      "totalTokens": 1620
    },
    "keyMessagePullThrough": {
      "matches": [
        {
          "keyMessageId": "cm_km_001",
          "message": "Zero-carbon industrial heat",
          "sentenceText": "Example Company provides zero-carbon industrial heat and power...",
          "sentenceCharStart": 1240,
          "sentenceCharEnd": 1380,
          "score": 0.51,
          "matched": true
        }
      ],
      "threshold": 0.4
    }
  },
  "aipvs": {
    "publisher": { "id": "cm_pub_xyz", "domain": "prnewswire.com", "name": "PR Newswire", "..." },
    "aipvs": { "domain": "prnewswire.com", "aipvs": 68, "tier": 2, "tierLabel": "Strong", "..." },
    "needsEnrichment": false
  },
  "totalPlacementValue": 49
}
```

**Notes:**
- In retrospective mode, the editorial weight is redistributed proportionally across the other four signals (`effectiveWeights.editorial` = 0) because the editorial decision has already been made. The `defaultWeights` field shows the original distribution for reference.
- `pqs.inputs` reflects the **auto-detected** values after scoring, not necessarily what you passed in the create request. If the scorer detected a different link attribute or placement type, those take precedence.
- `pqs.brandMention` shows which alias matched and where it was located in the tokenized content.
- `keyMessagePullThrough.matches` is always one entry per configured key message (even below-threshold "closest match" entries); the `matched` flag indicates whether each crossed the threshold.
- `totalPlacementValue` is `null` when the publisher couldn't be looked up or hasn't been enriched yet.

**Response when scoring has failed (HTTP 200):**
```json
{
  "success": false,
  "id": "cm_placement_abc123",
  "status": "failed",
  "errorMessage": "No brand mention found in content. Check the URL or add brand aliases to the Property.",
  "mode": "retrospective",
  "url": "https://www.prnewswire.com/news-releases/example-article",
  "createdAt": "2026-04-15T14:30:00.000Z",
  "completedAt": "2026-04-15T14:30:12.000Z"
}
```

Common failure modes:
- **"No brand mention found in content"** — the scorer couldn't locate any occurrence of the Property's name or aliases in the page. Check the URL is correct and add the exact form of the brand name used in the article to the Property's aliases.
- **"The URL returned 404 — check that it's correct and publicly accessible."** — the page no longer exists or isn't publicly available.
- **"The URL returned 403 — the site is blocking our crawler."** — the site is blocking the Spyglasses fetcher via WAF or bot protection. Try a different page on the same site.
- **"Could not extract readable content from the page."** — the page is JavaScript-rendered, paywalled, or has an unusual layout that prevents content extraction.

**Polling example (JavaScript):**
```javascript
async function scoreRetrospectivePlacement(url, propertyId, apiKey) {
  // 1. Create the placement and get back an ID
  const createRes = await fetch("/api/v1/ai-impact/placement/retrospective", {
    method: "POST",
    headers: { "Content-Type": "application/json", "x-api-key": apiKey },
    body: JSON.stringify({ url, propertyId }),
  });
  const { id, pollUrl, pollIntervalSeconds } = await createRes.json();

  // 2. Poll until the placement is completed or failed
  while (true) {
    await new Promise((r) => setTimeout(r, pollIntervalSeconds * 1000));
    const poll = await fetch(pollUrl, { headers: { "x-api-key": apiKey } });
    const result = await poll.json();
    if (result.status === "completed") return result;
    if (result.status === "failed") throw new Error(result.errorMessage);
    // Otherwise keep polling (status === "pending" or "running")
  }
}
```

---

## AI Visibility Reports

Generate an AI Visibility Report — how a brand appears across ChatGPT, Gemini, Claude, Perplexity, and Google AI Overviews — for one property, or for a list of prospects at once. Use this to run a report from a CRM trigger, a pitch workflow, or a nightly job, instead of clicking through the app.

Reports are generated in the background and take a few minutes. Every endpoint here returns immediately and hands you an id to poll.

<Callout type="warn">
**These endpoints use an organization-level API key, not your property API key.** Generate one under **Organization Settings → API Key**. One organization key works across every property in the organization.
</Callout>

### Credits, and why the API is stricter than the app

Every report costs credits. Before anything starts, the full cost must already be sitting in your organization's credit balance:

- The API **never bills overage** and **never triggers an automatic top-up**. An integration that runs unattended must not be able to run up a bill.
- If the balance is short, the call returns `402` and nothing is created or charged.
- In the app, a subscriber can acknowledge overage and continue. There is no equivalent on the API — buy credits in the app and retry.
- Organizations on contractual (enterprise) billing are not balance-checked at all. Their usage is recorded and invoiced under their agreement rather than drawn from a credit balance, so these endpoints do not return `402` for them.

A `402` looks like this:

```json
{
  "error": "INSUFFICIENT_CREDITS",
  "requiredCredits": 10,
  "availableBalance": 4,
  "message": "This report costs 10 credits and 4 are available. The API does not bill overage — add credits and retry."
}
```

---

### Generate a Report

Start a report for one property you already track.

```
POST /api/v1/ai-reports
```

**Headers:**
- `x-api-key`: Your organization's API key (required)
- `Content-Type`: application/json

```bash
curl -X POST "https://www.spyglasses.io/api/v1/ai-reports" \
  -H "x-api-key: sg_YOUR_ORGANIZATION_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "propertyId": "cm9ovvnnc0001wjubh0en0cpe" }'
```

**Body:**
```json
{
  "propertyId": "cm9ovvnnc0001wjubh0en0cpe",
  "notifyEmail": "pr@example.com"
}
```

- `propertyId` (required) — the property to report on. Must belong to the authenticated organization.
- `locationCity` / `locationRegion` / `locationCountry` (optional) — location context for this run. Each falls back to the property's own default when omitted.
- `notifyEmail` (optional) — who to email when the report finishes.

**Response (HTTP 202):**
```json
{
  "id": "V1StGXR8Z5jdHi6BmyT",
  "status": "pending",
  "propertyId": "cm9ovvnnc0001wjubh0en0cpe",
  "publicToken": "example-com-3f9a",
  "publicUrl": "https://www.spyglasses.io/reports/example-com-3f9a",
  "createdAt": "2026-09-09T14:30:00.000Z",
  "completedAt": null
}
```

`publicUrl` is the shareable report link. It works as soon as generation finishes — no login required — so you can put it straight into a CRM field or a pitch email.

**If a report is already in flight (HTTP 200):** when the property already has a report that is running, or finished but not yet purchased, that report is returned instead and **nothing new is started or billed**. The body is the same shape; the status code is `200` rather than `202`. This is the same rule the app applies, and it means a retrying integration cannot double-bill.

**Errors:**

| Status | `error` | Meaning |
|---|---|---|
| `400` | validation detail | Missing or malformed `propertyId` |
| `401` | `API key is required` / `Invalid API key` | No `x-api-key` header, or it matches no organization |
| `402` | `INSUFFICIENT_CREDITS` | The full cost is not available. No overage on the API |
| `404` | `Property not found` | No such property **in your organization**. A property owned by someone else is answered `404`, not `403`, so the endpoint cannot be used to probe which ids exist |
| `500` | `Failed to create AI report` | Server error; safe to retry |

---

### Poll a Report

Fetch a report's current status. Generation takes a few minutes — poll every 30 seconds or so rather than continuously.

```
GET /api/v1/ai-reports/:id
```

**Headers:**
- `x-api-key`: Your organization's API key (required)

```bash
curl "https://www.spyglasses.io/api/v1/ai-reports/V1StGXR8Z5jdHi6BmyT" \
  -H "x-api-key: sg_YOUR_ORGANIZATION_API_KEY"
```

**Response (HTTP 200):**
```json
{
  "id": "V1StGXR8Z5jdHi6BmyT",
  "status": "complete",
  "propertyId": "cm9ovvnnc0001wjubh0en0cpe",
  "publicToken": "example-com-3f9a",
  "publicUrl": "https://www.spyglasses.io/reports/example-com-3f9a",
  "createdAt": "2026-09-09T14:30:00.000Z",
  "completedAt": "2026-09-09T14:36:12.000Z"
}
```

- `pending` / `running` — still generating; keep polling.
- `complete` / `partial` — readable at `publicUrl`.
- `failed` — it will not finish.

**Errors:**

| Status | `error` | Meaning |
|---|---|---|
| `401` | `API key is required` / `Invalid API key` | No `x-api-key` header, or it matches no organization |
| `404` | `AI report not found` | No such report **in your organization**. A report owned by someone else is answered `404`, the same as one that does not exist |

---

### Generate Reports for a List of Prospects

Start one report per row. Each row creates a prospect property in your organization and runs its report. This is the API form of the app's bulk import, and carries the same limits: a single batch accepts at most 20 rows, and rows whose domain is unparseable or already tracked in the organization are dropped.

<Callout type="info">
Bulk generation is available on active Agency plans, the same gate as the in-app bulk import. Other plans get `403`.
</Callout>

```
POST /api/v1/ai-report-batches
```

**Headers:**
- `x-api-key`: Your organization's API key (required)
- `Content-Type`: application/json

```bash
curl -X POST "https://www.spyglasses.io/api/v1/ai-report-batches" \
  -H "x-api-key: sg_YOUR_ORGANIZATION_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "rows": [
      { "value": "acme.com" },
      { "value": "initech.com", "locationCity": "Austin", "locationRegion": "Texas", "locationCountry": "US" }
    ]
  }'
```

**Body:**
```json
{
  "rows": [
    { "value": "acme.com" },
    { "value": "https://initech.com/products/widget" }
  ],
  "notifyEmail": "newbiz@example.com"
}
```

- `rows` (required, at least 1) — one entry per prospect. `value` is a bare domain (tracked as a brand) or a product URL with a path (tracked as that product).
- `rows[].locationCity` / `locationRegion` / `locationCountry` (optional) — location context for that row's report.
- `notifyEmail` (optional) — who to email when the batch finishes.

The projected cost is `rows × the report credit cost`, and the whole of it must be available before anything starts. A short balance returns `402` with a `projectedCredits` field alongside the usual `requiredCredits` and `availableBalance`, and no property or report is created.

**Response (HTTP 202):**
```json
{
  "batchId": "cm_batch_9f2",
  "total": 2,
  "rejected": [
    {
      "index": 2,
      "value": "not a domain",
      "subjectType": "BRAND",
      "domain": "not a domain",
      "location": {},
      "status": "bad_domain",
      "message": "Could not read a valid domain from this value."
    }
  ],
  "projectedCredits": 20,
  "status": "running"
}
```

`total` is the number of rows accepted. `rejected` lists every row that was dropped, with its position in the array you sent, why it was dropped (`bad_domain`, or `duplicate` when the organization already tracks that domain), and a message you can show a user. `rejected` is `[]` when everything was accepted.

**Errors:**

| Status | `error` | Meaning |
|---|---|---|
| `400` | `NO_VALID_ROWS` / `IMPORT_LIMIT_EXCEEDED` | Nothing survived validation, or more rows than a single import allows |
| `401` | `API key is required` / `Invalid API key` | No `x-api-key` header, or it matches no organization |
| `402` | `INSUFFICIENT_CREDITS` | The full projected cost is not available. No overage on the API |
| `403` | plan message | Bulk generation is not available on this organization's plan |
| `500` | `Failed to start the batch` | Server error; safe to retry |

---

### Poll a Batch

Track a batch's progress and collect each row's report link as it becomes available.

```
GET /api/v1/ai-report-batches/:batchId
```

**Headers:**
- `x-api-key`: Your organization's API key (required)

```bash
curl "https://www.spyglasses.io/api/v1/ai-report-batches/cm_batch_9f2" \
  -H "x-api-key: sg_YOUR_ORGANIZATION_API_KEY"
```

**Response (HTTP 200):**
```json
{
  "batchId": "cm_batch_9f2",
  "status": "running",
  "total": 2,
  "completed": 1,
  "failed": 0,
  "createdAt": "2026-09-09T14:30:00.000Z",
  "updatedAt": "2026-09-09T14:36:20.000Z",
  "reports": [
    {
      "propertyId": "cm_prop_a1",
      "domain": "acme.com",
      "reportId": "V1StGXR8Z5jdHi6BmyT",
      "status": "complete",
      "publicToken": "acme-com-7c2",
      "publicUrl": "https://www.spyglasses.io/reports/acme-com-7c2",
      "createdAt": "2026-09-09T14:30:05.000Z",
      "completedAt": "2026-09-09T14:36:18.000Z"
    },
    {
      "propertyId": "cm_prop_a2",
      "domain": "initech.com",
      "reportId": null,
      "status": null,
      "publicToken": null,
      "publicUrl": null,
      "createdAt": null,
      "completedAt": null
    }
  ]
}
```

Entries appear in `reports[]` as the background job works through the rows, so a row with `null` fields early on will fill in on a later poll. `status` on the batch stays `running` until every row has finished or failed.

**Errors:**

| Status | `error` | Meaning |
|---|---|---|
| `401` | `API key is required` / `Invalid API key` | No `x-api-key` header, or it matches no organization |
| `404` | `Batch not found` | No such batch **in your organization**. A batch owned by someone else is answered `404`, the same as one that does not exist |

---

## Coverage Groups

A **coverage group** is a named list of earned-media placement URLs for one property — a PR team's coverage for a client. These endpoints let an automation feed placements into a group as they are won, instead of pasting them into the app in batches.

For a step-by-step walkthrough of wiring this to Zapier, n8n, or a social-listening tool, see [Automating Coverage Tracking](/docs/api/coverage-automation).

<Callout type="warn">
**These endpoints use an organization-level API key, not your property API key.** Generate one under **Organization Settings → API Key**. One organization key works across every client property in the organization.
</Callout>

### List Coverage Groups

Return a property's coverage groups with their item counts. Call this once when setting up an automation to find the `groupId` you'll feed.

```
GET /api/v1/coverage/groups?propertyId=YOUR_PROPERTY_ID
```

**Headers:**
- `x-api-key`: Your organization's API key (required)

**Response:**
```json
{
  "success": true,
  "propertyId": "cm9ovvnnc0001wjubh0en0cpe",
  "groups": [
    {
      "id": "cm_grp_q3",
      "name": "Q3 Coverage",
      "description": null,
      "itemCount": 142,
      "trackedByProjectGoals": 1,
      "createdAt": "2026-07-01T00:00:00.000Z",
      "updatedAt": "2026-07-28T16:04:00.000Z"
    }
  ]
}
```

The property must belong to the authenticated organization, or the call returns `403`.

---

### Add Placement URLs to a Coverage Group

The automation entry point. Append one URL or a batch of up to 1000.

```
POST /api/v1/coverage/groups/:groupId/items
```

**Headers:**
- `x-api-key`: Your organization's API key (required)
- `Content-Type`: application/json

**Body (single URL — the usual shape for a real-time trigger):**
```json
{
  "url": "https://techcrunch.com/2026/07/28/example-article"
}
```

**Body (batch — for a nightly export or a backfill):**
```json
{
  "urls": [
    "https://techcrunch.com/2026/07/28/example-article",
    "https://www.reddit.com/r/example/comments/abc123/thread-title",
    "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
  ],
  "score": true
}
```

- `url` (string) **or** `urls` (array, 1–1000) — supply exactly one.
- `score` (boolean, default `true`) — whether to create and score a Placement for each new URL. See "Controlling scoring cost" below.

**Response:**
```json
{
  "success": true,
  "groupId": "cm_grp_q3",
  "added": 2,
  "duplicates": 1,
  "invalid": [],
  "placementsLinked": 1,
  "scoringQueued": true
}
```

- `added` — item rows created.
- `duplicates` — submitted URLs the group already had. **Safe and free**: re-posting the same URL costs nothing.
- `invalid` — rejected lines, each with a `value` and a `reason`.
- `placementsLinked` — URLs that already had a scored placement for this property and were linked immediately, at no cost.
- `scoringQueued` — whether placement scoring was dispatched for the new items.

**Notes:**
- URLs are matched on a **normalized** form, so `www`, trailing slashes, tracking parameters, and fragments all collapse to the same item. This is what makes the endpoint safe to call from a trigger that may fire more than once for the same story.
- YouTube links are expanded to their equivalent `watch`, `youtu.be`, and `shorts` forms automatically, because AI platforms cite the same video under any of them.
- A URL that already has a placement for this property is linked rather than rescored.

**Controlling scoring cost**

By default each newly added URL gets a Placement created and scored — the page is fetched, the brand mention located, the placement type and sentiment classified, and the PQS computed. That is the same work the app does when you paste URLs in, and it is the expensive step.

Pass `score: false` to skip it. Classification and project-goal backfill still run, so citations still attribute correctly; you just don't get a PQS until you run **Score placements** on the group in the app, which is idempotent and only scores what's missing. Use this when bulk-loading a large historical archive where per-placement quality scores aren't the point.

---

### Read a Coverage Group

Return a group with a page of its items, each carrying its publisher, classification, and linked placement. Use this to read scores back into your workflow after ingestion.

```
GET /api/v1/coverage/groups/:groupId?limit=100&offset=0
```

**Headers:**
- `x-api-key`: Your organization's API key (required)

**Query Parameters:**
- `limit` (optional, default `100`, max `500`) — items per page
- `offset` (optional, default `0`)

**Response:**
```json
{
  "success": true,
  "id": "cm_grp_q3",
  "name": "Q3 Coverage",
  "propertyId": "cm9ovvnnc0001wjubh0en0cpe",
  "totalItems": 142,
  "limit": 100,
  "offset": 0,
  "items": [
    {
      "id": "cm_item_001",
      "url": "https://techcrunch.com/2026/07/28/example-article",
      "urlNormalized": "techcrunch.com/2026/07/28/example-article",
      "domain": "techcrunch.com",
      "title": "Example Company raises Series B",
      "pageType": "blog_article",
      "contentFormat": "news",
      "isListicle": false,
      "classifiedAt": "2026-07-28T16:05:12.000Z",
      "createdAt": "2026-07-28T16:04:00.000Z",
      "publisher": {
        "domain": "techcrunch.com",
        "mediaType": "earned_press",
        "authorityType": "trade_media"
      },
      "placement": {
        "id": "cm_placement_abc123",
        "status": "completed",
        "failureCode": null,
        "pqsScore": 71,
        "placementType": "dedicated_article",
        "title": "Example Company raises Series B",
        "authorName": "Jane Reporter",
        "completedAt": "2026-07-28T16:05:38.000Z"
      }
    }
  ]
}
```

Items are newest-first. `placement` is `null` until the scoring job creates one, then carries `status` `pending` → `running` → `completed`, with `pqsScore` populated on completion. Poll this endpoint if your workflow needs the score.

---

### Get Citations Earned by a Coverage Group

The read side of the loop: which of the group's URLs are actually being cited by AI assistants, in which answers, on which platforms. Scoped to the property's own tracked prompts and reports.

```
GET /api/v1/coverage/groups/:groupId/citations
```

**Headers:**
- `x-api-key`: Your organization's API key (required)

**Query Parameters:**
- `platform` (optional) — filter to a single AI platform
- `citationType` (optional) — one of `cited_inline`, `evaluated_source`, `search_result`, `unclassified`, `all`
- `url` (optional) — filter to a single placement URL
- `startDate` / `endDate` (optional) — `YYYY-MM-DD`, inclusive
- `limit` (optional, max `1000`) / `offset` (optional)

The response contains a funnel, a cited-vs-uncited comparison across the group's URLs, and per-citation hits with the prompt, platform, and the surrounding answer text.

This is the endpoint to poll for a "notify me when a placement gets cited" automation — filter by `startDate` to the period since your last run.

---

## Property Information

### Get Property Details

Retrieve information about your property using your API key.

```
GET /api/me
```

**Headers:**
- `x-api-key`: Your API key (required)

**Response:**
```json
{
  "propertyId": "cm9ovvnnc0001wjubh0en0cpe",
  "domain": "example.com",
  "companyName": "Example Company",
  "apiKey": "sg_XXX...",
  "verified": true,
  "verifiedAt": "2025-01-01T00:00:00.000Z",
  "blockAiModelTrainers": false,
  "customBlocks": [],
  "customAllows": []
}
```

---

## AI Visibility Rankings

### Get AI Visibility Rankings

Retrieve prioritized grounding search opportunities from your most recent AI report.

```
GET /api/ai-rankings/:propertyId
```

**Headers:**
- `x-api-key`: Your API key (required)

**Parameters:**
- `propertyId`: Your property ID (from `/api/me`)

---

## Historical Metrics

### Get Brand Consistency History

Retrieve brand consistency score trends over time.

```
GET /api/properties/:id/historical-metrics/brand-consistency
```

**Headers:**
- `x-api-key`: Your API key (required)

**Parameters:**
- `id`: Your property ID

---

### Get Discovery Metrics History

Retrieve discovery query performance trends over time.

```
GET /api/properties/:id/historical-metrics/discovery
```

**Headers:**
- `x-api-key`: Your API key (required)

**Parameters:**
- `id`: Your property ID

---

### Get Grounding Search Metrics History

Retrieve grounding search ranking trends over time.

```
GET /api/properties/:id/historical-metrics/grounding-searches
```

**Headers:**
- `x-api-key`: Your API key (required)

**Parameters:**
- `id`: Your property ID

**Query Parameters:**
- `onlyGaps`: Set to `"true"` to filter to gap opportunities only

---

### Get Competitor Share of Voice

Retrieve share of voice data for competitors from your most recent AI report.

```
GET /api/properties/:id/competitor-sov
```

**Headers:**
- `x-api-key`: Your API key (required)

**Parameters:**
- `id`: Your property ID

---

### Get AI Visibility Rankings (Alternative)

Alternative endpoint for AI visibility rankings data.

```
GET /api/properties/:id/ai-visibility-rankings
```

**Headers:**
- `x-api-key`: Your API key (required)

**Parameters:**
- `id`: Your property ID

---

## AI Traffic Analytics

### Get AI Timeline with Trends

Retrieve AI traffic timeline data with comparison to previous period.

```
POST /api/analytics/ai/timeline-with-trends
```

**Headers:**
- `x-api-key`: Your API key (required)
- `Content-Type`: application/json

**Body:**
```json
{
  "propertyApiKey": "sg_YOUR_API_KEY",
  "timeRange": {
    "start": "2025-01-01T00:00:00Z",
    "end": "2025-01-31T23:59:59Z"
  },
  "granularity": "day"
}
```

---

### Get AI Conversion Metrics

Retrieve AI conversion metrics showing bot visits vs human visits.

```
POST /api/analytics/ai/conversion-metrics
```

**Headers:**
- `x-api-key`: Your API key (required)
- `Content-Type`: application/json

**Body:**
```json
{
  "propertyApiKey": "sg_YOUR_API_KEY",
  "timeRange": {
    "start": "2025-01-01T00:00:00Z",
    "end": "2025-01-31T23:59:59Z"
  },
  "sessionWindowMinutes": 5
}
```

---

### Get AI Traffic Summary

Retrieve AI traffic summary with breakdowns by category and referrer.

```
POST /api/analytics/ai/traffic-summary
```

---

### Get AI Traffic Timeline

Retrieve AI traffic timeline data for charts.

```
POST /api/analytics/ai/timeline
```

---

### Get AI Traffic Timeline by Intent

Retrieve AI traffic timeline segmented by page intent.

```
POST /api/analytics/ai/timeline-by-intent
```

---

### Get AI Overview Visits

Retrieve visits from Google AI Overviews, Featured Snippets, and People Also Ask.

```
POST /api/analytics/ai/ai-overview-visits
```

---

### Get Bot Types

Retrieve list of AI visitor bot types for filtering.

```
GET /api/analytics/ai/bot-types
```

---

## Agent Analytics

### Get Agent Data by Category

Retrieve analytics data for agents grouped by category.

```
POST /api/analytics/agent/category
```

**Headers:**
- `x-api-key`: Your API key (required)
- `Content-Type`: application/json

**Body:**
```json
{
  "propertyId": "YOUR_PROPERTY_ID",
  "timeRange": {
    "start": "2025-01-01T00:00:00Z",
    "end": "2025-01-31T23:59:59Z"
  }
}
```

---

### Get Agent Data by Subcategory

Retrieve analytics data for agents grouped by subcategory.

```
POST /api/analytics/agent/subcategory
```

---

### Get Agent Data by Bot Type

Retrieve analytics data for agents grouped by bot type.

```
POST /api/analytics/agent/bot-type
```

---

### Get Agent Data by Location

Retrieve analytics data for agents grouped by location.

```
POST /api/analytics/agent/location
```

---

### Get Agent Data by Pages

Retrieve analytics data for agents grouped by page path.

```
POST /api/analytics/agent/pages
```

---

### Get Live Agent Data

Retrieve real-time agent data for a property.

```
GET /api/analytics/agent/live/:propertyId
```

---

### Get Blocked Requests Statistics

Retrieve statistics about blocked vs allowed requests.

```
POST /api/analytics/agent/blocked-stats
```

---

## Recrawl Analytics

### Get Site Recrawl Summary

Retrieve site-wide recrawl frequency statistics.

```
POST /api/analytics/recrawl/summary
```

**Headers:**
- `x-api-key`: Your API key (required)
- `Content-Type`: application/json

**Body:**
```json
{
  "propertyId": "YOUR_PROPERTY_ID",
  "timeRange": {
    "start": "2025-01-01T00:00:00Z",
    "end": "2025-01-31T23:59:59Z"
  }
}
```

---

### Get Page Recrawl Frequency

Retrieve recrawl frequency statistics for each page.

```
POST /api/analytics/recrawl/pages
```

---

### Get Bot Recrawl Frequency

Retrieve recrawl frequency statistics for each crawler bot.

```
POST /api/analytics/recrawl/bots
```

---

### Get Bot Page Recrawl Frequency

Retrieve recrawl frequency for pages crawled by a specific bot.

```
POST /api/analytics/recrawl/bots/:botTypeId/pages
```

---

## Internal-Only Endpoints

<Callout type="warn">
The [API Reference](/api/docs) shows additional endpoints that are for internal application use only. These endpoints use session-based authentication and are not supported for external API access.

If you need access to data not available through the endpoints listed above, please [contact us](mailto:support@spyglasses.io) to discuss your requirements.
</Callout>
