Endpoint#
GET /api/changes
Polling feed over the retained documents: what we newly collected (new, newly_discovered) or saw change (revised) after since, oldest first, with change_at in UTC. Changes from the last 10 minutes are held back so a slow collector commit is never skipped. Same rows as /api/datasets?dataset=documents&mode=changes, collapsed to one item per document. Anonymous, no key; tiers bound the page size and history window.
Authentication#
[]An empty security requirement means this operation does not declare authentication. Read operation notes for entitlement and protocol-method requirements.
Parameters#
| Name | In | Required | Description |
|---|---|---|---|
| dataset | query | No | Only documents has a change feed. |
| since | query | No | Required on the first call (or pass cursor instead): an ISO-8601 instant with a timezone, UTC recommended (2026-10-29T00:00:00Z). Inclusive. Compared with change_at, the time of the change on our collection clock (first collection for new and newly_discovered, the observation of the changed body for revised), not publication time. Must lie inside the tier history window (x-api-tiers history_days). A next_since token is also accepted here. |
| cursor | query | No | next_cursor (== next_since) from the previous page; do not combine with since. The walk resumes strictly after the last item returned, so no document repeats and none is skipped. A document that changes again later reappears once, at its new change. The token is signed; an altered or malformed one returns invalid_cursor. |
| limit | query | No | Maximum rows in this page. Anonymous callers may ask for up to 100; keyed tiers up to their rows_per_page (x-api-tiers). |
| source | query | No | Case-insensitive exact publisher label; use the response sources list. |
| q | query | No | Literal case-insensitive substring of displayed/original title or publisher. No semantic search, query translation, or full-body search. |
| watch | query | No | Alias of topic (the older name). Passing both with different values is invalid_parameter. |
| topic | query | No | Reviewed bilingual topic filter (documents, social): a case-insensitive title match on one of the listed terms, nothing inferred. export-controls is conservative: every term names export control or dual-use items itself. Terms and review dates: list_watchlists (MCP) or api/_watchlists.py. |
| fields | query | No | Comma-separated unique field keys of the change item; defaults to all. |
| format | query | No | CSV downloads one bounded page as UTF-8 with BOM and formula-safe string cells, never the entire matching archive. The selected fields are followed by a computed citation column. |
Responses and errors#
| HTTP status | Meaning |
|---|---|
| 200 | One page of changes, oldest first. Each document appears once, at its latest settled change; change_type is new, newly_discovered (published more than 2 days, Beijing dates, before we first collected it) or revised. |
| 400 | Invalid query, field, date or cursor. Body carries code: see x-error-codes. |
| 405 | Use GET. Body carries code: see x-error-codes. |
| 429 | Rate limit exceeded (60 requests per minute). Body carries code: see x-error-codes. |
| 503 | The change feed is not yet enabled on this deployment (database migration 043 pending: code unavailable, error "not yet enabled"), or the archive is unavailable; retry later. Body carries code: see x-error-codes. |
Response examples#
No response example is supplied by OpenAPI for this operation. The complete response schemas are preserved below; use an actual response for observed values.
Retry guidance#
See errors and retries. Honor operation-specific response headers. A POST may create state; do not replay it automatically.
Complete operation contract#
The expanded contract below includes all parameter constraints, response headers, schemas and examples from OpenAPI.
{
"path": "/api/changes",
"method": "GET",
"parameters": [
{
"name": "dataset",
"in": "query",
"required": false,
"schema": {
"type": "string",
"enum": [
"documents"
],
"default": "documents"
},
"description": "Only documents has a change feed."
},
{
"name": "since",
"in": "query",
"required": false,
"schema": {
"type": "string",
"format": "date-time"
},
"description": "Required on the first call (or pass cursor instead): an ISO-8601 instant with a timezone, UTC recommended (2026-10-29T00:00:00Z). Inclusive. Compared with change_at, the time of the change on our collection clock (first collection for new and newly_discovered, the observation of the changed body for revised), not publication time. Must lie inside the tier history window (x-api-tiers history_days). A next_since token is also accepted here."
},
{
"name": "cursor",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "next_cursor (== next_since) from the previous page; do not combine with since. The walk resumes strictly after the last item returned, so no document repeats and none is skipped. A document that changes again later reappears once, at its new change. The token is signed; an altered or malformed one returns invalid_cursor."
},
{
"name": "limit",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"minimum": 1,
"maximum": 5000,
"default": 50
},
"description": "Maximum rows in this page. Anonymous callers may ask for up to 100; keyed tiers up to their rows_per_page (x-api-tiers)."
},
{
"name": "source",
"in": "query",
"required": false,
"schema": {
"type": "string",
"maxLength": 180
},
"description": "Case-insensitive exact publisher label; use the response sources list."
},
{
"name": "q",
"in": "query",
"required": false,
"schema": {
"type": "string",
"maxLength": 180
},
"description": "Literal case-insensitive substring of displayed/original title or publisher. No semantic search, query translation, or full-body search."
},
{
"name": "watch",
"in": "query",
"required": false,
"schema": {
"type": "string",
"enum": [
"data-security",
"export-controls",
"property-support",
"semiconductors"
]
},
"description": "Alias of topic (the older name). Passing both with different values is invalid_parameter."
},
{
"name": "topic",
"in": "query",
"required": false,
"schema": {
"type": "string",
"enum": [
"data-security",
"export-controls",
"property-support",
"semiconductors"
]
},
"description": "Reviewed bilingual topic filter (documents, social): a case-insensitive title match on one of the listed terms, nothing inferred. export-controls is conservative: every term names export control or dual-use items itself. Terms and review dates: list_watchlists (MCP) or api/_watchlists.py."
},
{
"name": "fields",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "Comma-separated unique field keys of the change item; defaults to all."
},
{
"name": "format",
"in": "query",
"required": false,
"schema": {
"type": "string",
"enum": [
"json",
"csv"
],
"default": "json"
},
"description": "CSV downloads one bounded page as UTF-8 with BOM and formula-safe string cells, never the entire matching archive. The selected fields are followed by a computed citation column."
}
],
"operationId": "getChanges",
"summary": "Documents added or revised since a time",
"description": "Polling feed over the retained documents: what we newly collected (new, newly_discovered) or saw change (revised) after since, oldest first, with change_at in UTC. Changes from the last 10 minutes are held back so a slow collector commit is never skipped. Same rows as /api/datasets?dataset=documents&mode=changes, collapsed to one item per document. Anonymous, no key; tiers bound the page size and history window.",
"tags": [
"data",
"Retrieve changes",
"Export"
],
"security": [],
"responses": {
"200": {
"description": "One page of changes, oldest first. Each document appears once, at its latest settled change; change_type is new, newly_discovered (published more than 2 days, Beijing dates, before we first collected it) or revised.",
"headers": {
"X-Dataset-Row-Count": {
"schema": {
"type": "integer"
},
"description": "Rows in this response."
},
"X-Dataset-Next-Cursor": {
"schema": {
"type": "string"
},
"description": "Continuation cursor; empty at the end. Also exposed to cross-origin browsers."
},
"X-Dataset-Next-Since": {
"schema": {
"type": "string"
},
"description": "mode=changes and /api/changes: the next_since token; empty otherwise."
},
"X-Dataset-Export-Scope": {
"schema": {
"enum": [
"page",
"record"
]
},
"description": "Explicit export scope."
}
},
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"items",
"row_count",
"next_since",
"next_cursor"
],
"properties": {
"schema_version": {
"const": 1
},
"dataset": {
"const": "documents"
},
"mode": {
"const": "changes"
},
"endpoint": {
"const": "/api/changes"
},
"as_of": {
"type": "string",
"format": "date-time"
},
"since": {
"type": "string",
"format": "date-time",
"description": "The resolved start of this page (UTC)."
},
"items": {
"type": "array",
"maxItems": 5000,
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"id": {
"type": "string",
"pattern": "^documents:[1-9][0-9]*$",
"description": "The document the change is about. Appears at most once per walk."
},
"title": {
"type": [
"string",
"null"
],
"description": "Title"
},
"title_lang": {
"type": [
"string",
"null"
],
"description": "Title language"
},
"is_translated": {
"type": [
"boolean",
"null"
],
"description": "Title is a machine translation"
},
"source": {
"type": [
"string",
"null"
],
"description": "Publisher"
},
"source_url": {
"type": [
"string",
"null"
],
"description": "Original source"
},
"change_type": {
"type": [
"string",
"null"
],
"description": "Change type",
"enum": [
"new",
"newly_discovered",
"revised",
null
]
},
"change_at": {
"type": [
"string",
"null"
],
"description": "Change time (UTC)",
"format": "date-time"
},
"event": {
"type": [
"string",
"null"
],
"description": "Event"
},
"event_at": {
"type": [
"string",
"null"
],
"description": "Event time",
"format": "date-time"
},
"original_title": {
"type": [
"string",
"null"
],
"description": "Original title"
},
"published_at": {
"type": [
"string",
"null"
],
"description": "Publication date",
"format": "date"
},
"first_seen_at": {
"type": [
"string",
"null"
],
"description": "First collected",
"format": "date-time"
},
"content_hash": {
"type": [
"string",
"null"
],
"description": "Hash of the extracted text at this event"
},
"language": {
"type": [
"string",
"null"
],
"description": "Original language"
}
},
"description": "One document at its latest settled event in the walk."
}
},
"row_count": {
"type": "integer",
"minimum": 0,
"maximum": 5000
},
"next_since": {
"type": "string",
"description": "Signed resume position after the last item; never null. Pass back as since (or as cursor)."
},
"next_cursor": {
"type": "string",
"description": "Same token as next_since, for clients that page with cursor=."
},
"has_more": {
"type": "boolean",
"description": "More settled changes exist after this page."
},
"change_types": {
"type": "object",
"description": "Counts of change_type in this page only.",
"properties": {
"new": {
"type": "integer",
"minimum": 0
},
"newly_discovered": {
"type": "integer",
"minimum": 0
},
"revised": {
"type": "integer",
"minimum": 0
}
}
},
"settle_minutes": {
"type": "integer",
"description": "Events newer than this are held back until settled."
},
"coverage": {
"type": "object",
"required": [
"kind",
"unit",
"date_field",
"date_timezone",
"order",
"notes"
],
"properties": {
"kind": {
"const": "retained_archive"
},
"unit": {
"enum": [
"documents",
"document_events",
"hot_list_observations",
"platform_topics",
"indicator_observations",
"indicator_releases"
]
},
"date_field": {
"enum": [
"collected_at",
"observed_at",
"first_seen_at",
"last_seen_at",
"event_at"
]
},
"date_timezone": {
"const": "UTC"
},
"order": {
"enum": [
"date_desc_id_desc",
"event_at_asc"
]
},
"notes": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
}
},
"text/csv": {
"schema": {
"type": "string"
}
}
}
},
"400": {
"description": "Invalid query, field, date or cursor. Body carries `code`: see x-error-codes.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error",
"code"
],
"properties": {
"error": {
"type": "string"
},
"retry_after": {
"type": "integer"
},
"code": {
"type": "string",
"enum": [
"invalid_parameter",
"invalid_cursor",
"unauthorized",
"forbidden",
"not_found",
"method_not_allowed",
"rate_limited",
"server_error",
"unavailable"
],
"description": "Stable machine code; see x-error-codes."
}
}
}
}
}
},
"405": {
"description": "Use GET. Body carries `code`: see x-error-codes.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error"
],
"properties": {
"error": {
"type": "string"
},
"retry_after": {
"type": "integer"
}
}
}
}
}
},
"429": {
"description": "Rate limit exceeded (60 requests per minute). Body carries `code`: see x-error-codes.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error"
],
"properties": {
"error": {
"type": "string"
},
"retry_after": {
"type": "integer"
}
}
}
}
},
"headers": {
"Retry-After": {
"schema": {
"type": "integer"
},
"description": "Seconds until retry; also sends RateLimit-* and X-RateLimit-* headers."
}
}
},
"503": {
"description": "The change feed is not yet enabled on this deployment (database migration 043 pending: code unavailable, error \"not yet enabled\"), or the archive is unavailable; retry later. Body carries `code`: see x-error-codes.",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"error"
],
"properties": {
"error": {
"type": "string"
},
"retry_after": {
"type": "integer"
}
}
}
}
}
}
},
"x-codeSamples": [
{
"lang": "curl",
"label": "curl",
"source": "curl -s 'https://china-snapshot.com/api/changes?dataset=documents&since=2026-10-29T00:00:00Z&limit=3'"
},
{
"lang": "curl",
"label": "curl",
"source": "curl -s -o changes.csv 'https://china-snapshot.com/api/changes?dataset=documents&since=2026-10-29T00:00:00Z&watch=export-controls&format=csv&limit=20'"
}
],
"x-pagination": {
"first_call": "since=<ISO-8601 instant with timezone>, inclusive",
"continue": "cursor=<next_cursor> or since=<next_since> (the same signed token)",
"repeats": "none within a walk: each document appears once, at its latest settled change",
"end_of_results": "has_more is false; next_since stays valid for polling later",
"settle_minutes": 10
}
}