# Poll one subscription's change feed

`GET /api/v1/data/subscriptions/{subscriptionId}/changes` - [human page](https://aicdapi.com/api-reference#readFeedSubscriptionChanges)

**Release status.** Limited availability

Limited, machine-only release. Returns the next ordered page of the durable civic change feed for one live feed. The resource filter is fixed by the subscription and cannot be overridden on the request: only `cursor` and `limit` are accepted. Without a cursor, polling starts at the start cursor captured when the feed was created; from then on the caller sends the cursor it stored after committing the previous page, exactly as on `GET /api/v1/data/changes`. The cursor stays an opaque position, not an authorization: it is bound to the subscription's resource filter, and it is derived from the account's feed position rather than from the key secret, so a new feed registered by a replacement key can resume from a retained position of the old feed while that position is still replayable. The stored start position carries no exemption from the same retention window. The server never advances the cursor and keeps no acknowledgement: a client advances only after its own work commits. The request is served only while the feed's owner, the presented key, and the account's paid eligibility are all current and the feed is unrevoked; otherwise the poll is refused before any read. Replay is retained for 30 days and an older cursor is refused rather than silently skipped.

**Authentication.** Customer API key: `Authorization: Bearer aicd_...`. A browser session does not authenticate this endpoint. Required scope: `civic:read`.

### Parameters

| Field | Type | Required | Bounds | Description |
| --- | --- | --- | --- | --- |
| `path subscriptionId` | string (uuid) | yes | pattern ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}\|00000000-0000-0000-0000-000000000000\|ffffffff-ffff-ffff-ffff-ffffffffffff)$ | The subscription's id, as returned by the create or list operation. |
| `query cursor` | string | no | length 1..512 | Opaque continuation token, bound to the subscription's resource filter. Omit it to start at the feed's stored start cursor. Store and replay it without modification. |
| `query limit` | integer | no | >= 1, <= 9007199254740991 | Page size. Effective limit is clamped to the customer cap and 500. |

### Example request

```bash
curl -X GET 'https://aicdapi.com/api/v1/data/subscriptions/3f2504e0-4f89-41d3-9a0c-0305e82c3301/changes?cursor=v1.eyJzZXF1ZW5jZSI6MTI4fQ&limit=100' \
  -H "Authorization: Bearer $AICD_API_KEY"
```

### Response 200

Ordered change page for the feed's pinned resource filter. The envelope and cursor format are the durable change feed's.

| Field | Type | Required | Bounds | Description |
| --- | --- | --- | --- | --- |
| `data` | object | yes |  |  |
| `data.items` | object[] | yes |  | The ordered change page, each item carrying its own cursor. Item keys and nullability are the durable change feed's. |
| `meta` | object | yes |  |  |
| `meta.nextCursor` | string | yes | length 1..512 | Opaque continuation token. Persist it even when no items come back. |
| `meta.hasMore` | boolean | yes |  | True when another page is available now. |
| `meta.replayExpiresAt` | string (date-time) | yes |  | When this position stops being replayable. After it, the cursor is refused with 410. |

Example response:

```json
{
  "data": {
    "items": [
      {
        "cursor": "v1.eyJzZXF1ZW5jZSI6MTI5fQ",
        "resource": "topics",
        "operation": "upsert",
        "recordId": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
        "version": "42",
        "changedAt": "2026-09-28T12:34:56Z",
        "data": {
          "id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
          "slug": "example-topic",
          "name": "Example topic",
          "publicLabel": "Example topic",
          "description": "An example classified topic.",
          "keywords": [
            "budget"
          ],
          "negativeKeywords": [],
          "selectable": true,
          "classificationActive": true,
          "taxonomyManaged": false,
          "createdAt": "2026-01-05T09:00:00Z",
          "updatedAt": "2026-09-28T12:34:56Z"
        }
      }
    ]
  },
  "meta": {
    "nextCursor": "v1.eyJzZXF1ZW5jZSI6MTI5fQ",
    "hasMore": false,
    "replayExpiresAt": "2026-10-28T12:34:56Z"
  }
}
```

### Errors

| Status | `error.code` | Description |
| --- | --- | --- |
| **400** | `invalid_parameter` | An unknown or repeated query key, a limit that is not a positive integer, or a cursor that cannot be decoded or was minted for a different resource filter. |
| **401** | `unauthorized` | The API key is missing, malformed, unknown, revoked, expired, or belongs to a customer that is not active. |
| **403** | `billing_channel_unset`, `billing_entitlement_required`, `feed_subscription_plan_required`, `forbidden` | The key lacks the required grant, the account has no billing channel while billing enforcement is on, is not paid through, or is not on a current Pro or Ultra paid tier. |
| **404** | `not_found` | The feed is unknown, belongs to another account or another key, is not a UUID at all, or has been revoked. A revoked feed is not distinguishable from a missing one. |
| **410** | `cursor_expired` | The supplied position is older than the retained replay floor, and this feed's stored start cursor may have aged out as well. Retrying that position cannot make progress, and omitting the cursor must not be used to skip unread changes. Reconcile from a fresh export snapshot (`POST /api/v1/data/snapshots`, which needs `civic:export`) and continue this feed by sending that snapshot's `boundaryCursor` as `cursor`: a boundary cursor carries no resource binding, so the feed's pinned filter accepts it, and it closes the gap between the frozen export and later commits. New registration is required after key rotation, but not for cursor expiry alone. |
| **413** | `response_too_large` | The serialized response exceeds the response size allowance on your account. |
| **429** | `rate_limited` | Either a per-account allowance was exhausted or the account-wide abuse ceiling was reached. The abuse ceiling is account-wide, is not charged against the paid quota, and is charged after the identity lookup and before a revoked, expired, unpaid, or scope-denied rejection is decided. |
| **500** | `internal_error` | Unexpected failure. No private database detail is returned. |
| **503** | `authentication_unavailable`, `feed_unavailable` | Authentication, limit store, or feed state is unavailable. |
| **504** | `timeout` | The read deadline for this operation elapsed. |

### Behaviour

- **Pagination.** Opaque cursor pagination, bound to the subscription's resource filter. Send the previous response's `meta.nextCursor` back as `cursor`; an empty page can still advance the cursor to the feed high-water mark, so persist it even when no items come back.
- **Limits.** Counted against the per-account minute, burst, and response-byte allowances shared by every key of the account. A subscription carries no budget of its own: subscribing does not add, reserve, or partition paid usage, and the account's admitted-request and byte allowances are what a poll consumes. The account-wide abuse ceiling applies here as it does to the rest of the machine API: it is account-wide, is not charged against the paid quota, and is charged before a revoked, expired, unpaid, or scope-denied rejection is decided.
- **Retry-After.** Sent on 429 only, as whole seconds.
- **Caching.** Customer feed responses must not be cached.
