Change feed
Paid plansList every change to the data the API serves, oldest first, so a stored copy re-pulls only what changed.
GET /v1/changes lists every change to the data the API serves, oldest first. Each record names what changed and carries the request that returns the current rows, so a stored copy re-pulls only those rows.
The change feed is available on Max and Redistribution plans. A Free or Pro key receives 402. Webhooks announce each refresh at your own URL.
Keep a copy in sync
- Pull the data once, keyed by
entity_id, and savemeta.change_cursorfrom the first response of the pull. A later response carries a later cursor, which can skip a change made during the pull. A copy that starts empty savesmeta.latest_cursorfrom/v1/changesinstead. A pull through the Python client, whose tables carry nometa, readsmeta.latest_cursorjust before it starts. - When a webhook arrives, or on a schedule, call
/v1/changes?since=<cursor>. For each record, replace every row it covers with every page of itsurl, as Re-pull by scope below describes, then savemeta.next_cursor. - On
410 cursor_expired, pull once more and continue from themeta.latest_cursorin the410body.
Key the copy by entity_id, not by ticker. A record's ticker, and a url on /v1/resolve/{ticker}, use the entity's ticker today, so after a ticker change they carry the new ticker.
A response served from the cache carries the change_cursor of the moment it was cached, together with the data of that moment. Reading the feed from that cursor returns every record published since then, which can include records the copy already reflects. Re-pulling those returns the rows the copy already holds.
Webhooks are optional. A copy with no public URL reads the feed on a schedule and gets every record a copy with webhooks gets.
Records appear in the feed a few minutes after a refresh completes. A page holds up to 1,000 records. When meta.has_more is true, call again with since set to meta.next_cursor. The Python loop under Request examples runs all three steps.
Parameters
sincestrDefault: noneA cursor from an earlier response, meta.next_cursor, meta.latest_cursor or meta.change_cursor, passed back unchanged. The response holds the records published after it. since also takes a UTC date, YYYY-MM-DD, read as midnight UTC, or a UTC time such as 2026-10-01T14:30:00Z. The response then holds the records published from that moment on. A cursor or date older than 90 days returns 410. A future date returns no records, with next_cursor equal to latest_cursor. Without since, the response holds the records published in the last 24 hours.
limitintDefault: 1000Records per page, up to 1,000. A larger value returns 1,000. A value that is not a positive whole number returns 400.
datasetstrDefault: every datasetOne or more dataset names, comma-separated, for example prices,fundamentals. The names are those in /v1/status, plus metrics. An unknown name returns 400.
Response
The body has two fields. data holds the records in feed order and meta holds the cursors. After a daily price refresh, a response has this shape.
{
"data": [
{
"cursor": "MTc5MDgwNTgxMTI1MHw0ODIxMA==",
"dataset": "prices",
"kind": "added",
"reason": "new_session",
"scope": "dataset",
"entity_id": null,
"ticker": null,
"manager_id": null,
"index": null,
"start": "2026-09-30",
"end": "2026-09-30",
"url": null,
"recorded_at": "2026-09-30T22:00:12Z"
},
{
"cursor": "MTc5MDgwNTgxMTI1MHw0ODIxMQ==",
"dataset": "price_adjustments",
"kind": "added",
"reason": "new_session",
"scope": "dataset",
"entity_id": null,
"ticker": null,
"manager_id": null,
"index": null,
"start": "2026-09-30",
"end": "2026-09-30",
"url": null,
"recorded_at": "2026-09-30T22:00:12Z"
},
{
"cursor": "MTc5MDgwNTgxMTI1MHw0ODIxMg==",
"dataset": "prices",
"kind": "changed",
"reason": "revision",
"scope": "entity",
"entity_id": 10003635,
"ticker": "AAPL",
"manager_id": null,
"index": null,
"start": "2026-09-29",
"end": "2026-09-29",
"url": "https://api.xfinlink.com/v1/prices?entity_id=10003635&start=2026-09-29&end=2026-09-29",
"recorded_at": "2026-09-30T22:00:12Z"
}
],
"meta": {
"count": 3,
"has_more": false,
"next_cursor": "MTc5MDgwNTgxMTI1MHw0ODIxMg==",
"latest_cursor": "MTc5MDgwNTkwMDAwMHww",
"retention_days": 90,
"freshness": [
{"dataset": "classifications", "through": "2026-09-18", "promoted_at": "2026-09-18T05:22:46Z"},
...
{"dataset": "price_adjustments", "through": "2026-09-30", "promoted_at": "2026-09-27T20:18:18Z"},
{"dataset": "prices", "through": "2026-09-30", "promoted_at": "2026-09-27T20:18:18Z"},
{"dataset": "shares_outstanding", "through": "2026-09-25", "promoted_at": "2026-09-27T20:26:42Z"}
]
}
}Record fields
| field | type | unit | nullable | description |
|---|---|---|---|---|
cursor | str | none | no | The record's position in the feed. Sent as since, it returns the records after this one. |
dataset | str | none | no | The dataset that changed, by its public name. |
kind | str | none | no | added, changed, removed or rebuild. See Kinds below. |
reason | str | none | no | Why the data changed. See Reasons below. |
scope | str | none | no | What the record covers, entity, manager, index or dataset. See Re-pull by scope below. |
entity_id | int | none | yes | The security or issuer whose data changed. Which of the two depends on the dataset, as IDs by dataset below lists. Set on entity scope only. |
ticker | str | none | yes | The entity's ticker today. Null on other scopes and for an entity with no current ticker. |
manager_id | str | none | yes | The Form 13F manager whose filings changed, as /v1/managers/{manager_id}/holdings takes it. Set on manager scope only. |
index | str | none | yes | The index whose membership changed, such as sp500, as /v1/index/{index_name} takes it. Set on index scope only. |
start | str | date, YYYY-MM-DD | yes | The first date the change covers. Null when the change covers the whole history. |
end | str | date, YYYY-MM-DD | yes | The last date the change covers. Null with start set means every date from start on. |
url | str | none | yes | The full request on https://api.xfinlink.com that returns the current rows for this change. Null on dataset scope, and on an identity record whose entity has no current ticker. |
recorded_at | str | UTC timestamp, YYYY-MM-DDTHH:MM:SSZ | no | When the refresh recorded the change. |
Meta fields
| field | type | unit | nullable | description |
|---|---|---|---|---|
count | int | records | no | Records on this page. |
has_more | bool | none | no | true when more records follow. Call again with since set to next_cursor. |
next_cursor | str | none | no | The cursor to save and send as since on the next call. With no records on the page, it equals latest_cursor. |
latest_cursor | str | none | no | The feed's position when the response was built. Sent as since, it returns only records published after this response. |
retention_days | int | days | no | How long records stay in the feed, 90. |
freshness | list[object] | none | no | One entry per public dataset, with dataset, through and promoted_at, as on Status and freshness. |
Kinds
addedmeans new rows.changedmeans existing rows have new values.removedmeans rows are no longer served.rebuildmeans any part of the dataset may have changed. Re-pull all of it. Arebuildrecord always hasdatasetscope.
Reasons
reason explains a change. kind and scope decide what to re-pull.
new_sessionmeans a new trading day was added for every security.revisionmeans a past price bar or adjusted close was corrected.splitmeans a stock split changed the security's price history.dividendmeans a dividend changed the security's adjusted prices.late_barmeans a trading day's bar arrived after the day was first published, or filled a gap.symbol_changemeans the security's trading symbol changed.eventmeans another corporate event changed the security's price history.weekly_rebuildmeans the weekly full rebuild of prices changed served values.new_filingmeans a new SEC filing added or replaced rows.amendmentmeans an amended SEC filing changed rows.vintage_changemeans a value now comes from a different filing for the same period, or a row was withdrawn.mapping_changemeans a change in how filings map to fields moved values across the dataset.membership_changemeans an index's membership changed.identitymeans a company's or security's tickers, names, listings, classifications or corporate links changed.source_changemeans the dataset was reloaded in full from a replaced source.manualmeans a change made outside the scheduled refreshes, or one a refresh could not list in detail.serving_rulemeans a change in how the API computes or presents values, with no stored row changing.run_incompletemeans a refresh stopped before it finished.recorder_errormeans a refresh could not list what it changed, so arebuildrecord asks for the whole dataset to be re-pulled.
IDs by dataset
An entity record names a share class or a company in entity_id. A share class is a security and a company is an issuer, as Tickers and entities describes. A manager record names the filer in manager_id, and an index record names the index in index. Every dataset can also have dataset-scope records, which name no ID.
prices,price_adjustmentsandshares_outstanding: a share class. A company with two share classes gets one record per class.fundamentals,metrics,insider_transactions,insider_filingsandinsider_owners: a company.identifiers: each share class or company that held or now holds a changed ticker or other identifier.entities: the share class or company whose entry changed. When a share class moves to another company, the company it left and the company it joined also get anentitiesrecord each.listings: a share class.classificationsandentity_events: a company.institutional_holdingsandinstitutional_filings: a manager, inmanager_id.institutional_holdingsalso has adataset-scope record for each quarter end that changed.index_membership: an index, inindex.fundamentals_point_in_time,fundamentals_historical,insider_transactions_historicalandinstitutional_holdings_historical:datasetscope only.
A ticker change, or a share class moving to another company, produces records with reason identity and a null start and end: one on prices for each share class affected, and one on each of fundamentals, insider_transactions, insider_filings and insider_owners for each company affected. A share class that belongs to no company gets no records on fundamentals, metrics or the insider datasets.
Re-pull by scope
Replace every row a record covers, whatever its kind. Delete the rows the copy holds for the record's entity_id, manager_id or index from start to end, then store every page url returns. On a changed record, url can return fewer rows than the copy holds for the span, for example after an amendment takes an earlier filing's rows out of force. The rows it no longer returns stay deleted. With start and end null, the span is the whole history. With only end null, it runs from start to the latest date.
Entity
url requests the entity's rows from start to end, by entity_id, on the endpoint that serves the dataset. With no start and end, it requests the entity's whole history.
prices,price_adjustmentsandshares_outstandingre-pull through/v1/prices.fundamentals,fundamentals_point_in_timeandfundamentals_historicalre-pull through/v1/fundamentals.metricsre-pulls through/v1/metrics.insider_transactions,insider_filings,insider_ownersandinsider_transactions_historicalre-pull through/v1/insiders.institutional_holdings,institutional_filingsandinstitutional_holdings_historicalre-pull through/v1/holdings.identifiers,entities,listings,classifications,entity_eventsandindex_membershipre-pull through/v1/resolve/{ticker}, with the entity's current ticker.urlis null when the entity has no current ticker.
A record on prices, price_adjustments or shares_outstanding names a share class, and its url returns that share class's rows from start to end. A share-count record has a null end, so its url runs through the latest date. A record with a null start and end returns the share class's full history. A record on fundamentals, metrics or an insider dataset names the company.
A metrics record's url returns the default view. A copy that keeps another period re-pulls that period for the same company from start, or over the whole history when start is null. When start is null, a copy that keeps daily metrics sends its own start, the first date it keeps, because a daily request without start returns the last 90 days.
Manager
url requests the manager's holdings for the quarter ending on start, through /v1/managers/{manager_id}/holdings.
Index
url requests the index's membership events through /v1/index/{index_name}/events. start and end give the first and last membership date that changed. They bound the events, not the rosters. Re-pull the current roster from /v1/index/{index_name} as well, and each past roster the copy keeps with its as_of.
Dataset
url is null. A rebuild record means re-pull the whole dataset. Any other kind covers every entity from start to end, such as a new trading day on prices.
One url from each scope reads like these.
https://api.xfinlink.com/v1/prices?entity_id=10003635&start=2026-09-29&end=2026-09-29
https://api.xfinlink.com/v1/metrics?entity_id=1&start=2026-06-27
https://api.xfinlink.com/v1/resolve/AAPL
https://api.xfinlink.com/v1/managers/1067983/holdings?quarter=2026-06-30
https://api.xfinlink.com/v1/index/sp500/eventsEach url uses the endpoint's default parameters. Add the parameters your copy uses, such as fields or period_type. A response pages like any other response of its endpoint, as Pagination describes.
Expiry and retention
Records stay in the feed for 90 days after they are published, and meta.retention_days reads 90. A since cursor or date older than 90 days returns 410 with the error cursor_expired and a fresh meta.latest_cursor. Pull the data once more and continue from that cursor.
{
"error": "cursor_expired",
"status": 410,
"detail": "This cursor is older than the 90 days of change history the feed keeps. Pull the data you need once more, then continue from meta.latest_cursor.",
"meta": {"latest_cursor": "MTc5MDkxOTAyNDg4Nnww", "retention_days": 90}
}A cursor's age counts from the publication of the record it points to, or from the response that issued it when it points to no record.
Errors
| status | error code | when it happens | how to fix |
|---|---|---|---|
400 | bad_request | since is neither a cursor from this feed nor a UTC date or time. | Send a cursor unchanged, a date such as 2026-10-01 or a time such as 2026-10-01T14:30:00Z, or leave since out. |
400 | bad_request | limit is not a positive whole number. | Send a limit from 1 to 1,000. |
400 | bad_request | dataset is empty or names an unknown dataset. | Use the names the error lists. |
401 | unauthorized | The key is missing or invalid. | Send X-API-Key. See Authentication. |
402 | upgrade_required | The key is on the Free or Pro plan. | Use a Max or Redistribution key. |
405 | method_not_allowed | A method other than GET. | Use GET. |
410 | cursor_expired | since is older than 90 days. | Pull once more, then continue from meta.latest_cursor. |
429 | see Rate limits | A quota is exhausted. | Wait for the window to reset. |
These are the bodies the API returns.
{"error":"bad_request","status":400,"detail":"since must be a cursor from the feed (meta.next_cursor, meta.latest_cursor or meta.change_cursor) or a UTC date or time such as 2026-10-01 or 2026-10-01T14:30:00Z. Without since, the last 24 hours are returned."}
{"error":"bad_request","status":400,"detail":"limit must be a positive integer"}
{"error":"bad_request","status":400,"detail":"Unknown dataset: bogus. Valid: classifications, entities, entity_events, fundamentals, fundamentals_historical, fundamentals_point_in_time, identifiers, index_membership, insider_filings, insider_owners, insider_transactions, insider_transactions_historical, institutional_filings, institutional_holdings, institutional_holdings_historical, listings, metrics, price_adjustments, prices, shares_outstanding"}{"error":"unauthorized","message":"No API key was sent. Pass your key in the X-API-Key header or the ?api_key= query parameter. Get one free at https://xfinlink.com/signup"}{"error":"upgrade_required","status":402,"detail":"The change feed is available on Max and Redistribution. https://xfinlink.com/pricing"}{"error":"method_not_allowed","status":405}Plan differences
- Free and Pro receive
402on every request. - Max and Redistribution receive the same feed.
Each call counts as one request against your daily quota, whatever the number of records or datasets. See Plans and limits.
Edge cases
- A
dataset-scopeaddedrecord onpricesorprice_adjustmentswith reasonnew_sessionmeans a new trading day for every security, on the dates fromstarttoend. Pull the securities you keep for those dates. - A
rebuildrecord means re-pull the whole dataset. - Every
entity-scope record on fundamentals, or on share counts or prices of a share class that belongs to a company, comes with ametricsrecord keyed by the company, starting at the earliest date that changed, withendnull.startis null too when the change covers the whole history. A copy of metrics re-pulls themetricsrecords only. - A refresh that stopped early produces a
rebuildrecord with reasonrun_incompletefor each dataset it was changing. - A
dataset-scope record oninstitutional_holdingsnames a quarter end instartandend, for a copy kept by security. Themanager-scope records of the same refresh name each filer. - Records can overlap, for example a
pricesand aprice_adjustmentsrecord for the same day. Re-pulling the same rows twice returns the same data. tickeris the entity's ticker today, not the ticker onstart.- Records are in feed order.
recorded_atcan run out of order between refreshes. - With no new records,
next_cursorequalslatest_cursor.
Python client and command line
xfl.changes(since=None, dataset=None, limit=1000) in Python client 0.14.0 returns {"data": [...], "meta": {...}} with every page fetched, so meta["has_more"] is False. A call that returns records clears the client's in-memory response cache. On 410 it raises XfinlinkError, and the error's latest_cursor attribute holds the cursor to continue from. The command xfinlink changes [--since CURSOR] [--dataset NAME,...] prints the same result as JSON. The MCP server has no change feed tool. Python client reference lists the arguments.
import xfinlink as xfl
feed = xfl.changes(since="MTc5MDgwNTgxMTI1MHw0ODIwOQ==", dataset="prices")
for r in feed["data"]:
print(r["kind"], r["reason"], r["scope"], r["ticker"], r["start"], r["end"])
print(feed["meta"]["next_cursor"])added new_session dataset None 2026-09-30 2026-09-30
changed revision entity AAPL 2026-09-29 2026-09-29
MTc5MDgwNTgxMTI1MHw0ODIxMg==Request examples
The loop below runs the three steps. Run it on a schedule, or each time a webhook arrives. pull_everything, replace, kept_rosters and replace_roster stand for your own storage code. fetch_pages reads every page of a re-pull, by cursor, or by offset on the index endpoints.
import os
from pathlib import Path
import httpx
import xfinlink as xfl
SAVED = Path("xfinlink_cursor.txt")
KEY = {"X-API-Key": os.environ["XFINLINK_API_KEY"]}
def pull_everything(): ... # your full pull, stored by entity_id, never by ticker
def replace(record, pages): ... # delete the stored rows of the record's id from start to end, then store the rows in pages
# a null end runs through the latest date, and a null start and end covers every date
# pages is None when url is null: pull the dataset from start to end yourself
def kept_rosters(index): return [] # the as_of dates of the past rosters you keep for the index
def replace_roster(index, as_of, pages): ... # store the roster on as_of, None for the current one, in place of the one you hold
def fetch_pages(url):
out, page = [], httpx.URL(url)
while page is not None:
res = httpx.get(page, headers=KEY, timeout=120)
res.raise_for_status() # stops before the cursor is saved, so the next run retries
body = res.json()
out.append(body)
meta = body.get("meta", {})
cursor = meta.get("next_cursor") or body.get("next_cursor")
if cursor:
page = httpx.URL(url).copy_set_param("cursor", cursor)
elif meta.get("has_more"):
page = httpx.URL(url).copy_set_param("offset", sum(b["meta"]["count"] for b in out))
else:
page = None
return out
if not SAVED.exists():
cursor = xfl.changes()["meta"]["latest_cursor"]
pull_everything()
SAVED.write_text(cursor)
try:
feed = xfl.changes(since=SAVED.read_text())
for r in feed["data"]:
replace(r, fetch_pages(r["url"]) if r["url"] else None)
if r["scope"] == "index":
for as_of in [None, *kept_rosters(r["index"])]:
roster = f"https://api.xfinlink.com/v1/index/{r['index']}" + (f"?as_of={as_of}" if as_of else "")
replace_roster(r["index"], as_of, fetch_pages(roster))
SAVED.write_text(feed["meta"]["next_cursor"])
except xfl.XfinlinkError as e:
if getattr(e, "latest_cursor", None) is None:
raise
pull_everything()
SAVED.write_text(e.latest_cursor)The same read over REST starts from the last cursor of the response above.
curl -H "X-API-Key: YOUR_API_KEY" \
"https://api.xfinlink.com/v1/changes?since=MTc5MDgwNTgxMTI1MHw0ODIxMg=="{
"data": [],
"meta": {
"count": 0,
"has_more": false,
"next_cursor": "MTc5MDgzNDQwMDAwMHww",
"latest_cursor": "MTc5MDgzNDQwMDAwMHww",
"retention_days": 90,
"freshness": [
{"dataset": "classifications", "through": "2026-09-18", "promoted_at": "2026-09-18T05:22:46Z"},
...
]
}
}A date works as since too. This reads the records published since midnight UTC on 1 October 2026.
curl -H "X-API-Key: YOUR_API_KEY" \
"https://api.xfinlink.com/v1/changes?since=2026-10-01"{
"data": [],
"meta": {
"count": 0,
"has_more": false,
"next_cursor": "MTc5MTAwNTAxMjEzMnww",
"latest_cursor": "MTc5MTAwNTAxMjEzMnww",
"retention_days": 90,
"freshness": [
{"dataset": "classifications", "through": "2026-09-18", "promoted_at": "2026-09-18T05:22:46Z"},
...
]
}
}Was this page helpful?
Thank you for helping improve xfinlink documentation.
- Need help? Get in touch.
- Building with an LLM? Read llms.txt.
- Using Claude or ChatGPT? Connect the MCP server.