> ## Documentation Index
> Fetch the complete documentation index at: https://ploti.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Subscriptions

> Choose the places and topics you receive, preview what a subscription would deliver, change it safely, and read its manifest

A Data Feeds subscription is the set of datasets Ploti delivers to your own systems and keeps current. It covers every deliverable dataset in the places you name, optionally limited to some topics, plus any datasets you name on their own. It belongs to your workspace (your team's Ploti account), not to the person who created it. Datasets Ploti adds to its places later join it on their own.

## Places

A subscription is built from places and topics, the same ones [Find datasets](https://ploti.ai/docs/developers/find-datasets.md#places) describes.

A place is where a dataset applies:

| Kind | Id | Example |
| - | - | - |
| `county` | `<state>-county-<name>` | `fl-county-orange`, Orange County, Florida |
| `state` | `<state>` | `fl`, statewide records such as contractor licenses |
| `state_counties` | `<state>-counties` | `fl-counties`, every county your coverage has in Florida |
| `us` | `us` | records published nationally |

A city or region dataset is filed under its county.

**A dataset that covers several counties** counts toward each one's `datasetCounts`, is listed under each with `GET /v1/feeds/datasets?place=`, and joins a subscription that names any of them. Its `place.id` and its rows' `_ploti_place_id` still name one county: the county the dataset is filed under or, when it is filed under none, the first county it covers.

**A `<state>-counties` place** is every county your coverage has in that state, as one place.

* A subscription that names it picks up a county added to your coverage later, and drops one taken out.
* It does not include the state's statewide records. Name the `state` place for those.
* A dataset over several of its counties counts once toward it.

**`GET /v1/places`** lists the places in your workspace's coverage: its counties, their states, each state's counties and `us`.

* Each place comes with its `state` (null for `us`) and how many datasets it holds, in all (`datasetCount`) and by topic (`datasetCounts`).
* Pass `deliverable=true` to count only the datasets Data Feeds delivers.
* It returns every place at once, filtered by `state` or `kind` if you pass them.
* Use the ids exactly as it gives them.

A statewide dataset is in your coverage when a county of its state is, and a national one with any coverage.

## Topics

A dataset's `topic` is its primary topic, from a fixed list such as `parcels-property`, `zoning-land-use` or `development-activity` (permits, plans and inspections), or null when it has none. `GET /v1/topics` returns the list. The `topic` filter on `GET /v1/feeds/datasets`, a subscription's `topics` and a place's `datasetCounts` all use the same primary topic.

## Datasets

A dataset is one set of public records from one source, such as Orange County's building permits. Dataset ids are the same ones Ploti's other APIs and agents use, so an id from [`datasets search`](https://ploti.ai/docs/developers/find-datasets) works here too.

* `GET /v1/feeds/datasets` (`ploti feeds datasets list`) lists the datasets your subscriptions could deliver, up to 200 a page. Narrow it by `place`, `topic`, `subscription` or `updatedSince`.
* `GET /v1/feeds/datasets/{dataset}` (`ploti feeds datasets get`) returns one.

```json theme={null}
{
  "id": "ds_orange_fl_building_permits",
  "dataAtlasId": "DATASET-1A2B-3C4D5E",
  "title": "Building permits",
  "description": "Building permits issued by Orange County.",
  "place": {
    "id": "fl-county-orange",
    "name": "Orange County",
    "kind": "county"
  },
  "topic": "development-activity",
  "source": {
    "publisher": "Orange County Division of Building Safety",
    "url": "https://..."
  },
  "keyed": true,
  "keyColumns": ["permit_number"],
  "geometry": { "type": "Point", "srid": 4326 },
  "current": {
    "version": 42,
    "schemaVersion": "2.1",
    "publishedAt": "2026-10-18T06:12:40Z",
    "rowCount": 184220
  },
  "status": "available",
  "refresh": {
    "lastCheckedAt": "2026-10-18T06:00:03Z",
    "nextCheckBy": "2026-10-19T06:00:00Z"
  }
}
```

* Only deliverable datasets are listed: datasets Ploti's agents collect that your workspace's coverage and plan open.
* `dataAtlasId` opens the dataset in Ploti's app: `https://app.ploti.ai/map?dataAtlasDatasetId=` with it shows the dataset on the map.
* `place.kind` is `county`, `state` or `us`. `description`, `place.id`, `place.name`, `topic`, `source.publisher` and `source.url` can be null.
* `keyed` and `keyColumns` are those of the version in `current`, so a key a newer version brings shows once that version is released.
* `current`: the newest released version, or `null` until the first release. `current.publishedAt` is when that version was released to subscribers, not when it was built, and the `updatedSince` filter on `GET /v1/feeds/datasets` compares against it. A dataset's files are built only once a subscription includes it, so a dataset you have just subscribed to can be `null` until its first version is built and sent.
* `status`: `available` when `current` is set. Otherwise why not: `not_built` until a subscription includes the dataset, then `building` until its first version is sent; `held` or `failed` while its first version waits on Ploti's staff; or `awaiting_release` when Ploti has nothing to build it from until its source is released again.
* `refresh`: when Ploti last checked the source and when the next check is due. Ploti checks a source on schedule while a subscription includes its dataset, so for other datasets these times can be old. A check that finds nothing new publishes no version but still moves `lastCheckedAt`. A day past `nextCheckBy`, the dataset is stale and you receive `feed.dataset.stale`.

## What a subscription covers

```json theme={null}
{
  "name": "Central Florida",
  "places": ["fl-county-orange", "fl-county-seminole"],
  "topics": ["development-activity", "zoning-land-use"],
  "datasetIds": ["ds_fl_statewide_contractor_licenses"]
}
```

* `places`: every deliverable dataset in each place, now and later. A dataset Ploti adds to one of them joins the subscription and sends `feed.dataset.added`.
  `fl-counties` names every county your coverage has in Florida as one place, so a subscription to a whole state fits within the 500 places a subscription may name, and picks up counties added to your coverage later.
* `topics`: only datasets whose topic is one of these, within `places`. Every topic when left out. A dataset's topic is its primary topic, the `topic` field on the dataset.
* `datasetIds`: datasets to include on their own, such as statewide ones, from anywhere in your coverage.

Three rules apply:

* A subscription names at least one place or dataset.
* A workspace may have at most 25 subscriptions at a time. Deleting one makes room for another.
* Any member can read the workspace's subscriptions and what they deliver. Creating, changing and deleting one takes a workspace admin.

Every field is checked when you create or change a subscription:

| Refused with | When |
| - | - |
| `400` `invalid_input` | A topic is not one `GET /v1/topics` lists, or the subscription would name no place and no dataset. |
| `403` `not_allowed` | The key belongs to a member who is not a workspace admin. |
| `409` `outside_coverage` | A place is not in your workspace's coverage. |
| `409` `not_deliverable` | A dataset cannot be delivered to your workspace through Data Feeds. |
| `409` `limit_reached` | A create when the workspace already has 25 subscriptions. Widen one with more places or topics, or delete one first. |
| `409` `idempotency_conflict` | An `Idempotency-Key` was sent before with a different body. |
| `409` `idempotency_in_progress` | The first request with that `Idempotency-Key` is still being served. |

A create takes an `Idempotency-Key` header. A retry with the same key within a day returns the first answer instead of creating twice, with an `Idempotent-Replayed: true` header. The first answer carries the `ETag` header. A replay does not, but its body always has `revision`.

## Preview before you subscribe

`POST /v1/feeds/subscriptions/preview` takes the same `places`, `topics` and `datasetIds` and answers what a subscription with them would deliver now, without creating one:

```json theme={null}
{
  "total": 214,
  "matchedBy": { "places": 214, "datasets": 0 },
  "byTopic": [{ "topic": "zoning-land-use", "count": 62 }],
  "byPlace": [
    {
      "id": "fl-county-orange",
      "name": "Orange County",
      "kind": "county",
      "count": 131
    }
  ],
  "matching": 214,
  "datasets": [
    { "id": "…", "title": "Zoning - Winter Park", "matchedBy": ["places"] }
  ],
  "nextCursor": "…"
}
```

* `total` is exact: a dataset over several of the places you name counts once. It includes datasets with no topic, which a place's `datasetCounts` in `GET /v1/places` leaves out, so the two can differ by those.
* `byTopic` and `byPlace` count the same datasets, by primary topic (`null` for none) and by the place each is filed under.
* `datasets` is one page. Narrow it with `q` (title search), `place`, `topic` (`none` for no topic), `matchedBy` and `sort` (`place`, `title` or `updated`). `matching` says how many are left after them.

A preview is refused with:

* `400` `invalid_input` for an empty or malformed body
* `409` `outside_coverage` for a place outside your coverage
* `409` `not_deliverable` for a dataset Ploti cannot deliver to you
* `503` `plan_unavailable` while Ploti cannot read your plan

The 25-subscription limit does not apply to a preview. A preview counts toward the stricter limit of 20 calls a minute.

## Changing a subscription

Change a subscription with `PATCH /v1/feeds/subscriptions/{subscriptionId}`. Fields you send replace those fields, and fields you leave out are kept. `null` is not accepted: send `[]` to clear a list.

Every subscription has a `revision`, and reads return it as the `ETag` header (`"3"`). Send it back as the `If-Match` header, or as `revision` in the body, so two edits never overwrite each other. When both are sent, the body's `revision` is used.

* neither: `400` `invalid_input`
* a stale one: `412` `precondition_failed`. Read the subscription again and resend.
* a change that would leave no places and no datasets: `400` `invalid_input`. Delete the subscription to stop receiving.

Datasets that join send `feed.dataset.added`; datasets that leave send `feed.dataset.removed`. Rows you already loaded from a removed dataset stay yours, and stop updating.

`DELETE /v1/feeds/subscriptions/{subscriptionId}` answers `200` with `{"deleted": true}`. While the subscription's datasets are changing, it answers `409` `busy`: try again in a moment. Deleting a subscription:

* stops its events and its webhooks
* stops `GET /v1/feeds/datasets/{dataset}/versions/{version}` signing links for its datasets, unless another of the workspace's subscriptions delivers them. It answers `409` `not_subscribed` instead.

Links already issued keep working until they expire, up to an hour. Files you already downloaded are yours.

## The manifest

`GET /v1/feeds/subscriptions/{subscriptionId}/manifest` is the subscription's list of datasets, with links to load each one. Without `asOf`, it does two things:

1. It brings the subscription's datasets up to date. A dataset the coverage or plan no longer opens leaves with a `feed.dataset.removed` event before the manifest's cursor.
2. It lists the datasets the subscription delivers as of its `eventCursor` that have a released version. Each comes with that version and signed links to its snapshot, schema and [attachments](https://ploti.ai/docs/feeds/versions-and-changes.md#attachments), the files Ploti holds for the dataset.

`eventCursor` is the last event already reflected in those snapshots: load them, then read events after it. Each dataset's `keyed` is that of the version listed.

A dataset is listed only while the subscription still delivers it and your workspace's coverage and plan still open it. Two kinds of dataset are listed under `unavailable` instead:

* **Files being rebuilt:** `{"datasetId", "version", "reason": "rebuilding"}`. Ask for the manifest again in a few minutes to load it. Its events after `eventCursor` still apply.
* **No released version yet:** `{"datasetId", "reason"}`, with its `status` as the reason: `building`, `held`, `failed` or `awaiting_release` (see [datasets](#datasets)). Its first version arrives as `feed.dataset.resync_required` with reason `first_version`.

When Ploti cannot read your workspace's plan just now, the manifest answers `503` `plan_unavailable`: try again in a minute.

Pass `asOf` with an `eventCursor` from an earlier manifest to rebuild the datasets and versions as of that event, with fresh links. It is for refreshing links to a recent cursor, such as one from a load that is still running. It lists only datasets the subscription still delivers, so it never returns links to a dataset removed since.

`asOf` is refused with:

* `410` `expired` once any event up to it has been pruned, when a version it reached has since been released again and rebuilt, or when a listed version's snapshot is past retention
* `400` `invalid_input` if it is past the subscription's newest event

Without `asOf`, the manifest does not answer `410`. Events are kept for 90 days, so once a subscription is older than that, its first events are pruned and every `asOf` answers `410` `expired`.

To refresh links to a version you are loading, use one of these:

* `GET /v1/feeds/datasets/{dataset}/versions/{version}` signs fresh links to one version, for a dataset one of your subscriptions delivers.
* `POST /v1/feeds/datasets/{dataset}/versions/{version}/links` signs links for only the files you name.

## Getting the files

You fetch files yourself with signed links from the manifest and from each version. Links last one hour. Ask again for fresh ones at any time while a subscription delivers the dataset. This is how Data Feeds delivers today.

## Push to your storage (planned)

Writing each version's files into your own storage is planned and not yet built. The plan, starting with Amazon S3:

* You create a role Ploti can assume, with `s3:PutObject` on your bucket and prefix.
* Ploti gives each subscription its own `ExternalId`, which your role's trust policy must require, so another Ploti customer cannot name your role.
* Ploti writes each version's files under `{prefix}{datasetId}/versions/{version}/`, with a `current.json` written last.

[Tell us](mailto:support@ploti.ai) if you need push delivery, and where you ingest, so we know which destination to build first.

## Format

Files are GeoParquet: geometry as WKB in EPSG:4326, readable by warehouses, DuckDB, pandas and Spark. Each version's `schema.json` and `manifest.json` are JSON. Attachments are in whatever format Ploti stored them, named by `contentType`.

## Rate limits

Each API key may make 120 calls a minute. Creating, changing or previewing a subscription and getting a manifest also count toward a stricter limit of 20 a minute, shared with Ploti's other calls that search widely or start work, such as `datasets search`. Over a limit, the answer is `429` `rate_limited` with a `Retry-After` header: wait that many seconds.

```bash CLI theme={null}
ploti feeds subscriptions preview --places fl-county-orange,fl-county-seminole \
  --topics development-activity,zoning-land-use
ploti feeds subscriptions manifest sub_8f3k2m
```

```python Python theme={null}
import ploti

ploti.feeds.subscriptions.preview(
    places=["fl-county-orange", "fl-county-seminole"],
    topics=["development-activity", "zoning-land-use"],
)
ploti.feeds.subscriptions.manifest("sub_8f3k2m")
```

```bash REST theme={null}
curl -X POST https://api.ploti.ai/v1/feeds/subscriptions/preview \
  -H "Authorization: Bearer $PLOTI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "places": ["fl-county-orange", "fl-county-seminole"],
    "topics": ["development-activity", "zoning-land-use"]
  }'
```

```text MCP theme={null}
Data Feeds is not offered over MCP.
Use the CLI, Python or REST with an API key that holds the Data Feeds permission.
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.