# Automating Coverage Tracking

If your team already tracks placements somewhere else — a dedicated PR platform, a social-listening tool, a shared sheet, a Slack channel — you shouldn't have to paste them into Spyglasses a second time. The coverage API lets an automation append placements to a coverage group the moment they're won, so AI citation tracking starts on day one rather than at the end of the quarter.

Two shapes of workflow are common:

- **Real-time.** A trigger fires per placement — a new Reddit thread or comment surfaced by social listening, a new row in your PR tracker, a Slack message in `#coverage-wins`. Each fires one `POST` with a single URL.
- **Batch.** A nightly or weekly export from your PR platform posts a batch of URLs in one call.

Both use the same endpoint. Both are safe to run repeatedly.

## Before You Start

You need three things:

1. **An organization API key.** Open **Organization Settings → API Key** and generate one. This is not your property API key — see [Authentication](/docs/api/authentication) for the difference.
2. **A property ID.** The client whose coverage you're tracking.
3. **A coverage group ID.** Create the group in the app first (**Coverage Groups** on the property), then look up its ID with the API:

```bash
curl "https://www.spyglasses.io/api/v1/coverage/groups?propertyId=YOUR_PROPERTY_ID" \
  -H "x-api-key: YOUR_ORGANIZATION_API_KEY"
```

```json
{
  "success": true,
  "groups": [
    { "id": "cm_grp_q3", "name": "Q3 Coverage", "itemCount": 142, "...": "..." }
  ]
}
```

Copy the `id`. That's the only value your automation needs to hold besides the key.

<Callout type="info">
Groups are created in the app, not through the API. That's deliberate: a group is a reporting unit your team names and attaches to project goals, and an automation that could invent them tends to produce a long tail of near-duplicate groups nobody meant to create.
</Callout>

## Sending a Placement

One placement, one call:

```bash
curl -X POST "https://www.spyglasses.io/api/v1/coverage/groups/cm_grp_q3/items" \
  -H "x-api-key: YOUR_ORGANIZATION_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://techcrunch.com/2026/07/28/example-article"}'
```

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

Or a batch, up to 1000 URLs per call:

```bash
curl -X POST "https://www.spyglasses.io/api/v1/coverage/groups/cm_grp_q3/items" \
  -H "x-api-key: YOUR_ORGANIZATION_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"urls": ["https://techcrunch.com/a", "https://www.theverge.com/b"]}'
```

### Duplicates Are Free

You do **not** need to deduplicate before calling. URLs are matched on a normalized form, so `www`, trailing slashes, UTM parameters, and `#fragments` all collapse to the same item. A URL the group already has comes back in `duplicates` and costs nothing.

This matters more than it sounds. Social-listening triggers fire more than once for the same story; PR platform exports overlap at the boundaries; a retried webhook resends the same payload. All of it is safe.

YouTube links get the same treatment one level deeper: a `youtu.be` short link, a `/watch?v=` link, and a `/shorts/` link for the same video are recognized as one placement, because AI assistants cite the same video under any of those forms.

### Handling the Response

- `added > 0` — new placements are being classified and scored in the background.
- `duplicates > 0` — already tracked. Not an error.
- `invalid` — lines that couldn't be parsed as an `http(s)` URL, each with a `reason`. Worth logging; a run where everything lands in `invalid` usually means the field mapping in your automation is pointing at the wrong column.

## What Happens Next

Adding a URL kicks off three things in the background:

1. **Classification** — the publisher is resolved and the page is classified (page type, content format, listicle detection).
2. **Placement scoring** — a Placement is created and scored, producing a [PQS](/ai-placement-quality-score). A URL that already has a placement for this property is linked instead, at no cost.
3. **Goal backfill** — every active project tracking this group re-checks its goal hits, so a placement that was *already* being cited before you added it is credited retroactively.

Scoring typically completes within a minute of ingestion. Read it back:

```bash
curl "https://www.spyglasses.io/api/v1/coverage/groups/cm_grp_q3?limit=25" \
  -H "x-api-key: YOUR_ORGANIZATION_API_KEY"
```

Each item carries a `placement` block whose `status` moves `pending` → `running` → `completed`, with `pqsScore` populated on completion.

### Controlling Cost

Scoring a placement fetches the page and runs classifiers over it — the expensive part of ingestion. If you're bulk-loading a large historical archive and per-placement quality scores aren't the point, pass `score: false`:

```json
{ "urls": ["...", "..."], "score": false }
```

Classification and goal backfill still run, so citation attribution is unaffected. You can score later with **Score placements** on the group in the app, which is idempotent and only scores what's missing.

For steady-state real-time ingestion, leave scoring on. The volume is one placement at a time, and the PQS is most of why the placement is worth tracking.

## Closing the Loop

The point of getting placements into Spyglasses is finding out which ones AI assistants actually cite. Poll the citations endpoint on a schedule — daily is plenty — and filter to the window since your last run:

```bash
curl "https://www.spyglasses.io/api/v1/coverage/groups/cm_grp_q3/citations?startDate=2026-07-29" \
  -H "x-api-key: YOUR_ORGANIZATION_API_KEY"
```

The response includes per-citation hits with the prompt that triggered them, the platform that answered, and the surrounding answer text — enough to post a message like *"Your TechCrunch piece was cited by Perplexity answering 'best industrial heat providers'"* straight into Slack.

## Platform Notes

### Zapier

Use the **Webhooks by Zapier → POST** action.

- **URL** — `https://www.spyglasses.io/api/v1/coverage/groups/YOUR_GROUP_ID/items`
- **Payload Type** — `json`
- **Data** — key `url`, value mapped from your trigger's link field
- **Headers** — `x-api-key` set to your organization API key, `Content-Type` set to `application/json`

Store the key in the Zap's header field rather than in the URL. Zapier logs request URLs in the task history.

### n8n

Use an **HTTP Request** node.

- **Method** — `POST`
- **URL** — `https://www.spyglasses.io/api/v1/coverage/groups/YOUR_GROUP_ID/items`
- **Authentication** — Generic Credential Type → Header Auth, name `x-api-key`, value your organization API key
- **Body Content Type** — JSON
- **Body** — `{ "url": "{{ $json.link }}" }`

For batch flows, collect items with an **Aggregate** node first and send a single call with `urls` — one request of 200 URLs is far better than 200 requests.

### Anything Else

Any tool that can send an authenticated HTTP POST works. There is nothing Zapier- or n8n-specific about the endpoint.

## Errors

| Status | Meaning |
|---|---|
| `400` | Malformed body — most often both `url` and `urls`, or neither |
| `401` | Missing or unrecognized `x-api-key`. Check you used the **organization** key, not a property key |
| `404` | The group doesn't exist, or it belongs to a different organization |
| `500` | Something failed on our side — safe to retry, since duplicates are free |

## Limits

- **1000 URLs** per `urls` batch.
- **500 items** per page when reading a group back (`limit`).
- **1000 citations** per page on the citations endpoint (`limit`).

If you're planning ingestion at a volume that makes these awkward, [get in touch](mailto:support@spyglasses.io) — we'd rather size it with you than have you discover a ceiling in production.
