Endpoints
GET /funds/{isin} and /history, POST /funds (batch), GET /search (name, ticker, WKN), /resolve, /coverage, POST /portfolio, GET /overlap, /watchlist, /changes, webhooks, POST /factsheets, POST /reports, GET /me, the demo endpoint.Updated October 25, 2026| Endpoint | What it does | Requests |
|---|---|---|
GET /funds/{isin} | Structured factsheet for one ISIN. | 1 |
GET /funds/{isin}/history | TER, KID costs, fund size, risk and top holdings over time, one point per as-of date (Pro+). | 1 |
POST /funds | Batch lookup, up to the plan's batch size per call. | 1 per fund found |
GET /search | Name, issuer, ISIN-prefix, ticker or WKN search: loaded funds, plus listed ETFs not loaded yet (coverage: unknown). | free |
GET /resolve | ISIN of an exchange ticker: CSPX on London, VWCE on Xetra (LSE, SIX, Xetra, Euronext). | free |
GET /coverage | Whether up to 100 ISINs are covered, before spending a request. | free |
POST /portfolio | Weighted look-through across funds (Starter+). | 1 per position |
GET /overlap | Holdings overlap between two or more funds (Starter+). | 1 per ISIN |
GET /watchlist | The funds you follow; POST adds, DELETE removes (10 funds on Starter, 100 on Pro). | free |
GET /changes | Change feed: tracked fields that moved between refreshes (your watched funds on Starter and Pro, every fund on Scale). | free |
GET /webhooks | Signed POSTs when a fund changes; manage endpoints (watched funds on Starter and Pro, every fund on Scale). | free |
GET /export | Every matching fund as NDJSON, optionally gzipped (Scale+). | 1 per call |
POST /extract | Turn any KID / KIID / factsheet PDF into the same JSON shape (Pro+). | 5 per document |
POST /factsheets | A one-page factsheet as HTML or PDF, saved with keyless share and embed links. | 1 (HTML) · 2 (PDF) |
POST /reports | A client's weighted fund list as one branded report: allocation, costs & charges, look-through, overlap, fund pages, sources. Pro and the trial. | 1 per fund found |
GET /me | Plan and quota for the calling key. | free |
GET /demo/funds/{isin} | Keyless read of an already-loaded fund (30/min per IP). | none, no key |
GET /scpi/{idOrIsin} | Structured data for one French SCPI (slug, ISIN or name). | 1 |
GET /scpi | Name, manager or ISIN search over the SCPI universe. | free |
GET /funds/{isin}
Returns the structured factsheet for one ISIN. The path segment is validated (12 characters, country prefix, checksum) and normalised to upper case. One request.
Response envelope
| Field | Type | Description |
|---|---|---|
isin | string | Canonical upper-case ISIN that was resolved. |
name | string | null | Fund / share-class name. |
cached | boolean | true when the payload was served from the store, false when it was refreshed for this request. |
stale | boolean | Present, and true, only when the payload is older than 24 hours (the response came from the store at once); it is refreshed in the background, usually within a minute: ask again then for the new payload. Never older than 7 days: past that the fund is read before answering. |
generatedAt | string (ISO 8601) | When the payload was produced. |
expiresAt | string (ISO 8601) | When the payload is due for a refresh (24 hours after generatedAt), not when it disappears. A request after that date is answered at once from the store with stale: true and the payload is refreshed in the background; ?refresh=true reads first (at most once an hour per ISIN). |
plan | "free" | "starter" | "pro" | "scale" | "enterprise" | Plan of the API key used for the request. |
quota | { limit: number | null; remaining: number | null; used: number; overageUsed: number; resetAt: string; burst: { limit: number | null; remaining: number | null } } | Quota state after this request: the monthly request allowance (limit), requests left, requests used this month, requests billed as overage, the next monthly reset (UTC) and the per-minute burst allowance. null limit means metered / unlimited. |
data | object | The structured factsheet. See the field reference below. |
200 OK — IE00B4L5Y983 (performance series shortened) — json{"isin": "IE00B4L5Y983","name": "iShares Core MSCI World UCITS ETF","cached": true,"generatedAt": "2026-09-02T20:32:58.872Z","expiresAt": "2026-09-03T20:32:58.872Z","plan": "pro","quota": {"limit": 1000,"remaining": 999,"resetAt": "2026-10-01T00:00:00.000Z"},"data": {"investmentObjective": "The Fund seeks to track the performance of an index composed of companies from developed countries.","securityType": "ETF","structure": "ETF","keyFacts": {"assetClass": "Equities","currency": "USD","aum": "USD 151.7bn","inception": "2009-09-25","subAsset": "Global Equity","distribution": "Accumulating","holdings": 1253,"manager": "ETF"},"riskRating": 6,"managerTenure": "","topHoldings": [{"name": "NVIDIA","weight": 5.48},{"name": "APPLE","weight": 5.24},{"name": "MICROSOFT","weight": 3.88},{"name": "AMAZON.COM INC","weight": 2.71},{"name": "ALPHABET CLASS A","weight": 2.16},{"name": "BROADCOM INC","weight": 1.83},{"name": "ALPHABET CLASS C","weight": 1.7},{"name": "META PLATFORMS CLASS A","weight": 1.39},{"name": "MICRON TECHNOLOGY","weight": 1.16},{"name": "TESLA INC","weight": 1.12}],"geography": [{"label": "United States","weight": 72.07},{"label": "Japan","weight": 5.85},{"label": "United Kingdom","weight": 3.49},{"label": "Canada","weight": 3.4},{"label": "France","weight": 2.37},{"label": "Switzerland","weight": 2.23},{"label": "Germany","weight": 2.16},{"label": "Australia","weight": 1.59},{"label": "Netherlands","weight": 1.38},{"label": "Spain","weight": 0.98}],"sector": [{"label": "Information Technology","weight": 29.8},{"label": "Financials","weight": 16.46},{"label": "Industrials","weight": 11.03},{"label": "Health Care","weight": 9.34},{"label": "Consumer Discretionary","weight": 8.72},{"label": "Communication","weight": 7.94},{"label": "Consumer Staples","weight": 4.92},{"label": "Energy","weight": 4.18},{"label": "Materials","weight": 3.39},{"label": "Utilities","weight": 2.38}],"creditQuality": [],"assetAllocation": [{"label": "Equity","weight": 99.75},{"label": "Cash Collateral and Margins","weight": 0.02}],"region": [{"label": "United States","weight": 72.07},{"label": "Japan","weight": 5.85},{"label": "United Kingdom","weight": 3.49},{"label": "Canada","weight": 3.4},{"label": "France","weight": 2.37},{"label": "Switzerland","weight": 2.23},{"label": "Germany","weight": 2.16},{"label": "Australia","weight": 1.59},{"label": "Netherlands","weight": 1.38},{"label": "Spain","weight": 0.98}],"instrument": [],"maturity": [],"strategy": [],"exposure": [],"calendarReturns": {"years": ["2020","2021","2022","2023","2024","2025"],"fund": [15.9,21.9,-18,23.9,18.7,21.2],"benchmark": [15.9,21.8,-18.1,23.8,18.7,21.1]},"headlineMetrics": {"yieldToMaturity": "","modifiedDuration": "","ter": "0.20%","aum": "USD 151.7bn","volatility3y": "11.8%","sharpe3y": "1.68"},"metrics": {"peRatio": "","incomeYield": "","volatility3y": "11.8%","sharpe3y": "1.68","yieldToMaturity": "","effectiveDuration": "","effectiveMaturity": "","averageRating": "","sevenDayYield": "","wam": "","wal": "","maxDrawdown": "-16.5%","equityCorrelation": "","equityBondSplit": "100 / 0"},"cumulativePerformance": [{"date": "2026-04","fund": 455.4965,"benchmark": 286.4748},{"date": "2026-05","fund": 480.7259,"benchmark": 304.0676},{"date": "2026-06","fund": 476.5422,"benchmark": 301.1682},{"date": "2026-07","fund": 479.5776,"benchmark": 303.2269},{"date": "2026-08","fund": 494.48,"benchmark": 313.6179},{"date": "2026-09","fund": 490.3719,"benchmark": 310.7434}],"indexedPerformance": {"points": [{"date": "2026-04","fund": 555.5,"index": 386.47},{"date": "2026-05","fund": 580.73,"index": 404.07},{"date": "2026-06","fund": 576.54,"index": 401.17},{"date": "2026-07","fund": 579.58,"index": 403.23},{"date": "2026-08","fund": 594.48,"index": 413.62},{"date": "2026-09","fund": 590.37,"index": 410.74}],"hasIndex": true},"annualisedReturns": [{"label": "1 Year","fund": 19.57,"index": 19.55},{"label": "3 Years p.a.","fund": 19.82,"index": 19.78},{"label": "5 Years p.a.","fund": 11.05,"index": 10.98},{"label": "10 Years p.a.","fund": 12.97,"index": 12.9},{"label": "Since Inception","fund": 11.12,"index": null}],"benchmarkName": "MSCI World Index (Net)","dataAsOf": "2026-09-01","generatedAt": "2026-09-02T20:32:58.871Z","profile": {"kind": "equity","category": "Global Equity","riskBand": "high","concentration": "diversified","regionFocus": "Global","regionTilt": "United States","sectorTilt": "Broad","valuation": null,"creditQuality": null,"rateSensitivity": null,"equityShare": null,"rules": "fundfacts-profile/1"}}}
The complete data shape is in the field reference. Every payload names its sources: data.documents links the issuer's own PRIIPs KID, monthly factsheet and product page and, when the fund house publishes them as plain files, the holdings and NAV downloads. They are links to the fund house's retail documents, never copies: professional or intermediary pages are never published, and documents is null when no public link exists (absent on payloads frozen before 25 October 2026). Factsheets print the same links in their footer and client reports end with an Issuer documents table per fund. The costs object also carries the KID's “Costs over time” table when the document prints it: exampleInvestment (the amount the table is based on, normally 10,000 in the share-class currency), totalCost1y and totalCostRhp (total costs in money if you exit after one year and at the recommended holding period), each as printed, empty strings when the KID does not state them; the free fund cost calculator (?isin=) runs the same arithmetic for any amount and period from these lines.
GET /funds/{isin}/history — fund history
The same fund over time: TER, the KID cost lines (ongoing, transaction, entry, exit, reduction in yield at one year and at the recommended holding period), fund size, risk indicator and the top 10 holdings, one point per as-of date. Every successful refresh is kept as a dated snapshot; the endpoint reads those snapshots, nothing is recrawled. Pro, Scale and Enterprise (and the Pro trial). One request per call. Phase 1 adds no MCP tool.
curl "https://fundfactsapi.com/api/v1/funds/IE00B4L5Y983/history?fields=ter,costs,aum&changesOnly=1" -H "Authorization: Bearer ffk_..."
json{"isin": "IE00B4L5Y983","name": "iShares Core MSCI World UCITS ETF USD (Acc)","since": "2026-09-15","count": 2,"points": [{ "asOf": "2026-08-31", "generatedAt": "2026-09-15T02:10:44.000Z", "ter": "0.20%", "costs": { "ongoing": "0.20%", "transaction": "0.01%", "entry": "0.00%", "exit": "0.00%", "riy1y": "0.21%", "riyRhp": "0.20%" }, "aum": "USD 105.4bn" },{ "asOf": "2026-09-30", "generatedAt": "2026-10-03T03:02:11.000Z", "ter": "0.20%", "costs": { "ongoing": "0.20%", "transaction": "0.02%", "entry": "0.00%", "exit": "0.00%", "riy1y": "0.22%", "riyRhp": "0.20%" }, "aum": "USD 108.1bn" }],"plan": "pro","quota": { "limit": 1000, "remaining": 991, "used": 9, "overageUsed": 0, "resetAt": "2026-11-01T00:00:00.000Z", "burst": { "limit": 120, "remaining": 119 } }}
History starts on 15 September 2026 and grows with every refresh; depth is a few weeks today. A point is dated by the as-of date the fund house printed on the documents; a payload that states no date is dated the day it was generated. When a date was refreshed more than once, the newest payload wins. Points come oldest first, at most 500 (the newest are kept), with at most 10 holdings each; figures are served as GET /funds/{isin} serves them (fund size in the house format, a cost line stated as none left blank, an estimated risk indicator withdrawn, "" = not disclosed). topHoldings is what GET /funds/{isin} serves: for a synthetic ETF, the substitute basket (its holdingsBasis says so).
?fields=ter,costs,aum,riskRating,topHoldings keeps the named keys (asOf and generatedAt always come; an unknown key is 400 invalid_query). ?since=2026-09-15 drops the points before a calendar date. ?changesOnly=1 drops a point whose compared values equal the previous point's: formatting is ignored (“0,20 %” is “0.20%”), holdings are compared by name, so a weight drift alone is not a change; the compared values are the fields you asked for.
A plan without history gets 403 plan_required with required: "pro". A fund the store has never loaded, or whose first load is still running, gets 404 fund_not_found (not counted; no data, cached or coverage key): call GET /funds/{isin} once and its history starts with that refresh; a known miss carries the usual reason.
POST /funds — batch
Send up to the plan's batch size (Free 1, Starter 10, Pro 50, Scale 200, Enterprise 1,000) in one call. Funds already in the store are answered immediately (stale: true past 24 hours, a few refreshed in the background per call); cold ones are loaded a few at a time within the request budget, and anything left is returned as pending and warmed in the background — re-send those ISINs a minute later. An ISIN that cannot be loaded right now (a temporary upstream outage) is answered from its stored payload when there is one (cached: true, expiresAt in the past); otherwise it is pending too and retried for you. Each pending item carries retryAfter (seconds), and the top-level retryAfter is the shortest of them. One request per ISIN with status ok or not_found; pending, error and invalid are free.
Request body — json{ "isins": ["IE00B4L5Y983", "IE00B3RBWM25", "not-an-isin"], "wait": true }
Response (data shortened) — json{"count": 3,"results": [{ "input": "IE00B4L5Y983", "isin": "IE00B4L5Y983", "status": "ok", "name": "iShares Core MSCI World UCITS ETF USD (Acc)", "cached": true, "generatedAt": "…", "expiresAt": "…", "data": { … } },{ "input": "IE00B3RBWM25", "isin": "IE00B3RBWM25", "status": "pending", "name": null, "cached": true, "generatedAt": null, "expiresAt": null, "data": null, "retryAfter": 60 },{ "input": "not-an-isin", "isin": null, "status": "invalid", "name": null, "cached": false, "generatedAt": null, "expiresAt": null, "data": null }],"pending": ["IE00B3RBWM25"],"retryAfter": 60,"plan": "pro","quota": { "limit": 1000, "remaining": 711, "used": 289, "overageUsed": 0, "resetAt": "2026-10-01T00:00:00.000Z", "burst": { "limit": 120, "remaining": 118 } }}
wait: false answers from the store only and returns cold ISINs as pending at once — use it from a request handler that must respond quickly, then poll.
GET /search
?q= a fund name, issuer, the first characters of an ISIN, an exchange ticker (CSPX, VWCE.DE) or a German WKN; &limit= up to 50. Names and ISINs are searched over the funds already loaded into the store (it grows with every request and with the nightly refresh). Tickers and WKNs are matched in the exchange listings (London Stock Exchange, SIX, Xetra, Euronext lists, reloaded weekly), so a listed ETF FundFacts never loaded comes back too: each result carries coverage (covered: its data is in the store; unknown: never loaded, and lookup gives the GET /funds/{isin} URL that loads it, counted once when found), and listing names the ticker, MIC, exchange and trading currency it was found by (null for a fund found by name or ISIN). Covered funds rank first. Free; counts towards the per-minute burst limit.
curl "https://fundfactsapi.com/api/v1/search?q=msci+world&limit=5" -H "Authorization: Bearer ffk_..."
json{ "query": "msci world", "count": 2, "results": [{ "isin": "IE00B4L5Y983", "name": "iShares Core MSCI World UCITS ETF USD (Acc)", "issuer": "iShares", "currency": "USD", "assetClass": "Equity", "shareClass": "USD Acc", "income": "Accumulating", "category": "Global Equity", "dataAsOf": "2026-08-31", "url": null, "coverage": "covered", "listing": { "ticker": "IWDA", "mic": "XAMS", "exchange": "Euronext Amsterdam", "currency": "EUR" }, "lookup": null },{ "isin": "IE00BJ0KDQ92", "name": "XTR.MSCI WORLD 1C", "issuer": null, "currency": null, "assetClass": null, "shareClass": null, "income": null, "category": null, "dataAsOf": null, "url": null, "coverage": "unknown", "listing": { "ticker": "XDWL", "mic": "XETR", "exchange": "Xetra", "currency": "EUR" }, "lookup": "https://fundfactsapi.com/api/v1/funds/IE00BJ0KDQ92" }], "plan": "free", "quota": { … } }
GET /resolve
?ticker= an exchange ticker and &exchange= the venue, as a MIC (XLON, XSWX, XETR, XAMS, XPAR…), a name (London, SIX, Xetra, Euronext Paris) or a Bloomberg code (LN, GY); a Yahoo-style VWCE.DE works too. Returns the ISIN of the listing, its trading currency and whether FundFacts covers it. Without exchange, every venue listing the ticker comes back. Built from the London Stock Exchange, SIX Swiss Exchange, Xetra (shared with Börse Frankfurt) and Euronext (Amsterdam, Paris, Brussels, Lisbon, Dublin, Oslo) lists of ETFs, ETCs and ETNs, reloaded weekly; other venues answer for the funds already loaded. Free; counts towards the per-minute burst limit.
curl "https://fundfactsapi.com/api/v1/resolve?ticker=CSPX&exchange=XLON" -H "Authorization: Bearer ffk_..."
json{ "ticker": "CSPX", "exchange": "XLON", "count": 1, "isins": ["IE00B5BMR087"], "results": [{ "isin": "IE00B5BMR087", "ticker": "CSPX", "mic": "XLON", "exchange": "London Stock Exchange", "currency": "USD", "name": "iShares Core S&P 500 UCITS ETF USD (Acc)", "kind": "ETP", "coverage": { "status": "covered", "dataAsOf": "2026-09-30", "cached": true } }], "plan": "starter", "quota": { … } }
GET /coverage
?isins= up to 100 comma-separated ISINs (or POST a JSON body { "isins": [...] }). Tells, from the store and without loading anything, whether each ISIN is covered (cached: the stored payload is within its 24 hours; a covered ISIN up to 7 days old is still answered at once, flagged stale: true, and refreshed in the background; only an ISIN unseen for 7 days reloads before answering), not_covered with the reason code of the last miss, the queue's recheck promise (queuedAt, the estimated nextRecheckAt run, askers) and upload (whether your plan can make the sheet now from the fund's own KID in the dashboard, and the cheapest plan that can), or unknown: never looked up, so a lookup tries a live load. Free; counts towards the per-minute burst limit.
curl "https://fundfactsapi.com/api/v1/coverage?isins=IE00B4L5Y983,LU2000000005" -H "Authorization: Bearer ffk_..."
json{ "count": 2, "results": [{ "input": "IE00B4L5Y983", "isin": "IE00B4L5Y983", "status": "covered", "name": "iShares Core MSCI World UCITS ETF USD (Acc)", "dataAsOf": "2026-09-30", "checkedAt": "2026-10-15T06:12:09Z", "cached": true, "reason": null },{ "input": "LU2000000005", "isin": "LU2000000005", "status": "unknown", "name": null, "dataAsOf": null, "checkedAt": null, "cached": false, "reason": null }], "plan": "starter", "quota": { … } }
POST /portfolio — look-through
Post positions with weights (any scale; they are normalised to 100). The response aggregates each fund's breakdowns by its portfolio weight: asset allocation, sector, country, region, credit quality, currency split and combined top holdings (synthetic ETFs' substitute baskets left out and listed in topHoldings.excluded; their sector and country tables, which are the index's, still count; funds whose top holdings are published as names without weights are left out and listed in topHoldings.namesOnly), plus the blended TER, the weighted SRRI and a coverage figure on every block (the share of the portfolio whose funds disclose that breakdown). One request per position answered. Starter and up, as many positions per call as the plan's batch size (Starter 10, Pro 50, Scale 200, Enterprise 1,000). Funds already in the store are used as they are, even past their 24 hours (listed in stale, refreshed in the background); generatedAt is the time of this computation. Call POST /funds first if every input must be within 24 hours.
Request body — json{ "positions": [ { "isin": "IE00B4L5Y983", "weight": 60 }, { "isin": "IE00B3RBWM25", "weight": 25 }, { "isin": "LU1681043599", "weight": 15 } ] }
Response (shortened) — json{"positions": [ { "isin": "IE00B4L5Y983", "name": "…", "weight": 60, "covered": true, "ter": 0.2, "riskRating": 6, "category": "Global Equity", "kind": "equity" }, … ],"coverage": 100,"fees": { "weightedTer": 0.186, "coverage": 100, "annualCostPer10k": 18.6 },"risk": { "weightedSrri": 5.4, "band": "high", "coverage": 100 },"kinds": [ { "label": "equity", "weight": 85 }, { "label": "fixedIncome", "weight": 15 } ],"assetAllocation": { "items": [ { "label": "Equity", "weight": 85 }, { "label": "Bond", "weight": 15 } ], "coverage": 100 },"sector": { "items": [ { "label": "Information Technology", "weight": 23.4 }, … ], "coverage": 85 },"geography": { "items": [ { "label": "United States", "weight": 58.1 }, … ], "coverage": 100 },"topHoldings": { "items": [ { "label": "NVIDIA", "weight": 3.9 }, … ], "coverage": 85, "note": "Funds disclose their largest positions only, so aggregated holdings are a lower bound. Synthetic (swap-based) funds hold a substitute basket …", "excluded": ["LU1681043599"], "namesOnly": [] },"pending": [], "stale": [], "invalid": [], "rules": "fundfacts-portfolio/3", "generatedAt": "…", "plan": "pro", "quota": { … }}
GET /overlap
?isins=A,B,C — for every pair, the overlap is the sum over shared holdings of the smaller of the two weights, matched on the issuer name (so “Apple Inc” and “APPLE INC.” match). Funds that disclose only their top ten show a lower bound; disclosed tells you how much of each portfolio was visible. A synthetic (swap-based) ETF holds a substitute basket rather than the index (data.holdingsBasis is "substituteBasket"): its holdings are not compared, it is listed in excluded and its pairs carry comparable: false. A fund whose top holdings are published as names without weights (weight: null, e.g. Fundsmith) is listed in namesOnly: its pairs give the shared names in sharedNames and overlap: null, since the overlap cannot be measured. One request per fund found. Starter and up, as many ISINs per call as the plan's batch size (Starter 10, Pro 50, Scale 200, Enterprise 1,000). Funds already in the store are used as they are, even past their 24 hours (listed in stale, refreshed in the background); generatedAt is the time of this computation. Call POST /funds first if every input must be within 24 hours.
curl "https://fundfactsapi.com/api/v1/overlap?isins=IE00B4L5Y983,IE00B3RBWM25" -H "Authorization: Bearer ffk_..."
json{ "funds": [ { "isin": "IE00B4L5Y983", "name": "…", "holdings": 1355, "holdingsBasis": "portfolio" }, { "isin": "IE00B3RBWM25", "name": "…", "holdings": 3600, "holdingsBasis": "portfolio" } ],"pairs": [ { "a": "IE00B4L5Y983", "b": "IE00B3RBWM25", "overlap": 84.2, "sharedCount": 1290, "shared": [ { "label": "NVIDIA", "a": 5.48, "b": 4.9 }, … ], "sharedNames": [ "NVIDIA", … ], "disclosed": { "a": 99.8, "b": 99.6 }, "comparable": true } ],"excluded": [], "namesOnly": [], "pending": [], "stale": [], "notFound": [], "note": "…", "rules": "fundfacts-portfolio/3", "generatedAt": "…", "plan": "pro", "quota": { … } }
Watchlist — follow funds instead of polling
Polling GET /funds/{isin} every day to see whether anything changed costs a request each time and tells you nothing on the days nothing moved. Watch the fund instead: POST /watchlist { "isins": ["IE00B4L5Y983", "LU0274208692"] } adds it to your watchlist (free, not counted, nothing is loaded), GET /watchlist lists what you watch with each fund's name and last refresh, DELETE /watchlist { "isins": [...] } removes. The watchlist is a fund list named “API watchlist” in your dashboard (alerts on), so the e-mail alerts cover it too, and the dashboard can edit it. Starter watches up to 10 funds, Pro 100, Scale 500, counted across every list with alerts on; past the allowance POST answers 400 watchlist_full with the count and the plan that lifts it; Free has no watchlist (403 plan_required, required: "starter"). From Claude or ChatGPT the same list is kept with the MCP tools watch_fund, unwatch_fund and my_watched_funds.
curl -X POST "https://fundfactsapi.com/api/v1/watchlist" -H "Authorization: Bearer ffk_..." -H "Content-Type: application/json" -d '{"isins":["IE00B4L5Y983","LU0274208692"]}'
json{ "list": "API watchlist", "count": 2, "limit": 100, "watched": 2,"funds": [ { "isin": "IE00B4L5Y983", "name": "iShares Core MSCI World UCITS ETF USD (Acc)", "generatedAt": "2026-10-24T06:12:03Z" }, { "isin": "LU0274208692", "name": null, "generatedAt": null } ],"added": ["IE00B4L5Y983", "LU0274208692"], "alreadyWatched": [], "plan": "pro", "quota": { … } }
A null name and generatedAt mean the fund has not been looked up yet; the weekly refresh of watched funds and any lookup fill them in.
GET /changes — change feed
Every refresh is compared with the payload it replaces. When one of the tracked fields moves — headlineMetrics.ter, riskRating, keyFacts.aum, keyFacts.manager, keyFacts.distribution, keyFacts.holdings, benchmarkName, investmentObjective or the top-5 topHoldings — a fund.changed row is written; when the as-of date moves, a fund.refreshed row. Poll with ?since= (ISO timestamp; default the last 7 days), optionally &isins= and &event=; use nextSince for the next call. Starter, Pro and the Pro trial read the changes of their watched funds (the response says scope: "watchlist" with watched and limit; ?isins= is cut to the watched ones and the rest come back in unwatched; an empty watchlist answers an empty feed with a note telling you to fill it). Scale and Enterprise read every fund in the store (scope: "all"). Free.
curl "https://fundfactsapi.com/api/v1/changes?since=2026-09-01T00:00:00Z&isins=IE00B4L5Y983" -H "Authorization: Bearer ffk_..."
json{ "since": "2026-09-01T00:00:00.000Z", "count": 2, "hasMore": false, "nextSince": "2026-09-08T03:12:44.000Z","changes": [{ "id": 412, "isin": "IE00B4L5Y983", "name": "…", "event": "fund.changed", "field": "headlineMetrics.ter", "old": "0.20%", "new": "0.19%", "dataAsOf": "2026-08-31", "changedAt": "2026-09-08T03:12:44.000Z" },{ "id": 413, "isin": "IE00B4L5Y983", "name": "…", "event": "fund.refreshed", "field": "dataAsOf", "old": "2026-07-31", "new": "2026-08-31", "dataAsOf": "2026-08-31", "changedAt": "2026-09-08T03:12:44.000Z" }], "plan": "scale", "quota": { … } }
A fund only changes when it is refreshed, so the feed reflects the ISINs your account (and everyone else) requests plus the scheduled universe; a fund nobody has loaded for weeks has no entries.
Webhooks
Instead of polling, register an https endpoint and receive a signed POST for every fund.changed / fund.refreshed event. Manage endpoints from the Webhooks tab of the dashboard or the API: GET /webhooks, POST /webhooks { "url", "events", "isins" } (returns the signing secret once), POST /webhooks/{id} { "action": "test" | "enable" | "disable" }, DELETE /webhooks/{id}. Starter, Pro and the Pro trial: a webhook follows your watchlist; omit isins and it fires for every watched fund, including the ones you add later (the row shows isins: null, scope: "watchlist"); name isins and they must all be on your watchlist (400 watchlist_required otherwise); register the webhook after POST /watchlist, an empty watchlist is refused. Scale and Enterprise: every fund in the store (scope: "all") or the ISINs you name. Deliveries to a plan that follows watched funds only ever carry those funds; a downgraded account's webhooks go quiet and wake up with the plan, like its e-mail alerts. Free has no webhooks (403 plan_required).
Delivery body — json{ "id": "evt_9f2c…", "event": "fund.changed", "createdAt": "2026-09-08T03:12:45.000Z", "isin": "IE00B4L5Y983", "name": "…","changes": [ { "field": "headlineMetrics.ter", "old": "0.20%", "new": "0.19%" } ], "dataAsOf": "2026-08-31", "generatedAt": "2026-09-08T03:12:44.000Z","url": "https://fundfactsapi.com/api/v1/funds/IE00B4L5Y983" }
Verify the X-FundFacts-Signature: t=<unix>,v1=<hex> header: v1 is HMAC-SHA256 over `${t}.${rawBody}` with your secret; reject timestamps older than five minutes. Answer 2xx within 10 seconds; anything else is retried up to six times with back-off (1 min → 24 h), after which the endpoint is disabled.
Node.js verification — tsimport { createHmac, timingSafeEqual } from "node:crypto";export function verify(secret: string, rawBody: string, header: string) {const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");return expected.length === v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(v1));}
GET /export — bulk NDJSON
Streams every fund in the store that matches ?issuer=, &assetClass=, ¤cy=, &since= (refreshed after this date), one { isin, name, generatedAt, expiresAt, data } envelope per line. Gzipped when the client accepts it (force with &gzip=1 or off with &gzip=0). One request per call regardless of size. Scale and Enterprise.
curl "https://fundfactsapi.com/api/v1/export?issuer=iShares&gzip=1" -H "Authorization: Bearer ffk_..." -o ishares.ndjson.gz
POST /extract — any document to JSON
The extraction that powers the API, on your own documents: send a PRIIPs KID, a UCITS KIID or a factsheet and get the same data shape back — for funds whose issuer is not covered, private funds, insurers' unit-linked funds, or a document pile from a due-diligence pack. The regulated KID layout is read deterministically (risk indicator, cost table, SFDR article, holding period); the rest is read by the document model. Five requests per document. Pro and up.
curl -X POST https://fundfactsapi.com/api/v1/extract -H "Authorization: Bearer ffk_..." -F "file=@kid.pdf" -F "isin=LU0123456789" -F "kind=kid"# or by URL / raw textcurl -X POST https://fundfactsapi.com/api/v1/extract -H "Authorization: Bearer ffk_..." -H "Content-Type: application/json" -d '{"url": "https://example.com/documents/factsheet.pdf"}'
json{ "document": { "label": "kid.pdf", "kind": "kid", "pages": 3, "chars": 9120 }, "isin": "LU0123456789", "name": "…", "fieldsFilled": 14, "requests": 5, "data": { … }, "plan": "pro", "quota": { … } }
Scanned PDFs without a text layer are not supported. Files up to 8 MB; URLs are fetched respecting the publisher's robots.txt.
POST /factsheets — one-page HTML or PDF
The same data as GET /funds/{isin}, laid out as a one-page factsheet with the open-source @fundfactsapi/widgets components: growth of 100, key facts, the KID's costs, holdings, sectors, countries, allocation, risk scale and profile, calendar and annualised returns, risk metrics. Every sheet prints the fund's KID cost lines (one-off entry and exit costs, ongoing, transaction, performance fee, and the annual cost impact after one year and at the recommended holding period) exactly as the key information document states them; a line the KID states as none or does not disclose is left blank, and the card is left out with hide: ["costs"]. The footer prints “Sources: PRIIPs KID (dated …) · monthly factsheet · issuer page” with links to the issuer's own documents (data.documents; the KID is dated only when the issuer put the date in the document's address; a sheet made from an uploaded document carries no links). Send format = html or pdf; the response is the document itself (text/html or application/pdf) with the share links in the X-Factsheet-Url / X-Factsheet-Pdf-Url headers, or JSON with the links when deliver is link. Every sheet is saved to your account with the fund data frozen at generation time — see My factsheets — and gets two keyless URLs, /f/{token} and /f/{token}.pdf, to mail to a client, link from a portal or embed in an <iframe>. One request for HTML, two for PDF (it is printed by a headless browser); the share links and GET /factsheets are free. All plans. No code needed for one-off sheets: the factsheet generator in the dashboard calls this endpoint for you.
curl -X POST https://fundfactsapi.com/api/v1/factsheets -H "Authorization: Bearer ffk_..." -H "Content-Type: application/json" \-d '{"isin": "IE00B4L5Y983", "format": "pdf"}' -o IE00B4L5Y983-factsheet.pdf# links instead of the file, dark theme, your own accent, wordmark and logo (logo: Pro and above)curl -X POST https://fundfactsapi.com/api/v1/factsheets -H "Authorization: Bearer ffk_..." -H "Content-Type: application/json" \-d '{"isin": "IE00B4L5Y983", "format": "html", "deliver": "link", "theme": "dark", "accent": "#7C3AED", "title": "Acme Wealth", "logo": "https://cdn.acme-wealth.com/logo.svg"}'# Pro and above: a saved template by name (your default one applies when you name none), with one field overriddencurl -X POST https://fundfactsapi.com/api/v1/factsheets -H "Authorization: Bearer ffk_..." -H "Content-Type: application/json" \-d '{"isin": "IE00B4L5Y983", "format": "pdf", "template": "Acme Wealth", "hide": ["creditQuality", "metrics"]}' -o IE00B4L5Y983-factsheet.pdf
Response with deliver: link — json{"id": "6d1f…", "isin": "IE00B4L5Y983", "name": "iShares Core MSCI World UCITS ETF USD (Acc)", "issuer": "iShares","format": "html", "options": { "theme": "dark", "accent": "#7C3AED", "title": "Acme Wealth", "branding": true, "logo": "https://cdn.acme-wealth.com/logo.svg", "font": "system", "header": "rule", "palette": "standard", "secondary": null, "hide": [], "contact": null, "disclaimer": null, "template": null, "templateName": null },"favorite": false, "views": 0, "createdAt": "2026-09-09T14:02:11.000Z", "dataAsOf": "2026-08-31", "generatedAt": "…","urls": {"html": "https://fundfactsapi.com/f/9kQ1…","pdf": "https://fundfactsapi.com/f/9kQ1….pdf","embed": "<iframe src="https://fundfactsapi.com/f/9kQ1…" title="…" width="100%" height="1100" style="border:0;border-radius:12px" loading="lazy"></iframe>"},"requests": 1, "plan": "starter", "quota": { … }}
| Field | Type | Description |
|---|---|---|
isin | string | The fund to render. Cold ISINs are loaded first, like GET /funds/{isin}. |
format | "html" | "pdf" | Document format (default html). PDF is A4 portrait, scaled to fit one page. |
deliver | "file" | "link" | file (default) returns the document; link returns JSON with the id and URLs. |
language | "en" | "fr" | "de" | "es" | "it" | "nl" | Language of the headings, captions, country and sector names, dates, number format and standard notice (default en): English, French, German, Spanish, Italian or Dutch. In any language but en the investment objective is machine-translated from the issuer's text, and the sheet says so. Fund, index and holding names stay as the issuer publishes them. Every plan. |
theme | "light" | "dark" | Colour scheme (default light). |
accent | string | Hex colour for charts, tiles and the header rule, e.g. #1F5EFF. |
title | string | Your firm or product name in the header, in place of the FundFacts wordmark (80 characters). |
branding | boolean | The "Powered by FundFacts API" footer line (and the FundFacts bar on the public web page). Can be turned off on Pro and above; elsewhere false is ignored. |
logo | string | https URL of your logo (PNG, JPEG, WebP, GIF or SVG, up to 512 KB), shown in the header next to the title. Fetched once and frozen into the sheet. Wins over a template's logo. Pro and above. |
template | string | A saved template, by id or name (GET /factsheets/templates). Without it, your default template applies; "none" uses no template. Fields you send win over the template's. Pro and above. |
font | string | system (default), inter, ibm-plex-sans, montserrat, source-serif or lora. Embedded in the sheet, so the PDF prints it. Pro and above. |
header | "rule" | "band" | "minimal" | Header style: a line in your colour (default), a solid colour band, or logo and name only. Pro and above. |
palette | "standard" | "brand" | Chart colours: ten distinct colours (default) or shades of your accent. Pro and above. |
secondary | string | Second hex colour, for the index line and the country bars. Pro and above. |
hide | string[] | Sections to leave out: objective, growth, costs, holdings, sectors, countries, assetAllocation, creditQuality, risk, calendar, annualised, metrics. Key facts always show. costs is the KID cost lines card. Pro and above. |
contact | string | One footer line: firm, address, phone, website, registration number (240 characters). Pro and above. |
disclaimer | string | Your own notice in place of the standard one, line breaks kept (1,500 characters). Pro and above. |
Factsheet templates
On Pro and above, design your look once in the template editor (logo upload, colours, font, header, sections, footer, live preview): the default template dresses every sheet from the dashboard, this endpoint and Claude or ChatGPT. GET /factsheets/templates lists them ({ id, name, isDefault, settings, hasLogo }, free). A sheet copies the template when it is generated, so editing the template later never changes a sheet you sent. A plan without templates gets 403 plan_required for the Pro fields; an unknown template name gets 400 template_not_found.
GET /factsheets lists your sheets (?issuer=, ?favorites=1, ?limit=); GET /factsheets/{id}?format=json|html|pdf returns one sheet or re-renders it from its snapshot; PATCH /factsheets/{id} with { "favorite": true } stars it; DELETE /factsheets/{id} removes it and kills its share links. The public links serve up to 60 requests per minute per IP and are never indexed.
POST /reports — client portfolio report
A client's weighted fund list as one branded document: cover (title, preparedFor, date, note), allocation with each fund's ongoing charge and risk indicator, look-through, holdings overlap, one page per fund, then a last page with an Issuer documents table per fund (figures as-of, the day the documents were read, the KID / factsheet / issuer page / holdings / NAV links from data.documents, or “No public document link”) and a Methodology block. holdings is an array of { isin, weight? }, ISIN strings, or the pasted text (one “ISIN weight” per line); weights are optional (equal when none is given) and normalised to 100; up to the plan's batch size, at most 50 funds. The factsheet design fields and template apply. Billed like /portfolio: one request per fund found; funds without data stay listed in the report and come back in missing. Pro and up, the 7-day trial included. Returns JSON with the saved report and its keyless links (urls.html, urls.pdf); GET /reports lists, GET /reports/{id}?format=pdf re-renders, DELETE removes.
After the allocation, a Costs & charges page gives the ex-ante cost disclosure advisers owe their clients (MiFID II, Delegated Regulation 2017/565 art. 50): each fund's KID cost lines (entry, exit, ongoing, transaction, performance fee, annual cost impact after one year and at the recommended holding period), the portfolio-weighted ongoing charge (the allocation page's weighted ongoing charge: headline TER, else the KID's ongoing costs), transaction costs and annual cost impact, what 10,000 (in the portfolio currency when every fund shares one) costs a year in ongoing charges, over one year and over the recommended holding period when every fund with KID data states both the period and that impact, and a note that advice, custody and platform fees are stated separately by the adviser. Figures are the KIDs' own percentages applied to 10,000, an indication, not a quote. The page is printed only when at least one fund's KID states a cost line; a portfolio of TER-only funds has no such page, and its allocation note then reads that entry, exit and transaction costs are not included and advice fees are stated separately. Leave the page out with hide: ["costs"]. Sheets and reports carry the figures' “as of” date, not a KID date.
The API does not accept uploaded documents yet. A fund the store cannot load (the issuer's site cannot be read within our rules, or the fund is still loading) can be filled from the dashboard: under the generated report, each fund without data has an “Upload its KID / factsheet” control (PDF with a text layer, up to 950 KB) that reads the figures with the same extraction as POST /extract, keeps them for your account only, and rebuilds the report in place on the same links; the fund's rows then read “figures from the KID you provided (dated …)” in the report's language. 6 requests per fund (5 for the reading, 1 for the lookup); nothing is charged for a refused or unreadable document. Reports made later through the API reuse those documents: such a fund is included at no extra request, counted in found, absent from missing and labelled on its issuer line; the JSON does not flag it separately yet. The issuer's own documents always take precedence once loaded; uploaded figures never enter the public fund data.
GET /me
Plan and quota state for the key used; free. Call it once at startup to confirm the key works, not on every request.
{"email": "you@company.com","plan": {"id": "pro","name": "Pro","requests": 1000,"burstPerMinute": 120,"batchMax": 50,"overageUsd": 0.05,"quota": "1,000 requests / month"},"quota": {"limit": 1000,"remaining": 711,"used": 289,"overageUsed": 0,"resetAt": "2026-10-01T00:00:00.000Z","burst": {"limit": 120,"remaining": 120}},"usage": {"thisMonth": 289,"monthStart": "2026-09-01T00:00:00.000Z"}}
GET /demo/funds/{isin}
No key needed. Serves a fund that is already in the store (the same payload as the authenticated endpoint, without plan and quota), 30 requests per minute per IP. It never loads anything: an ISIN nobody has requested yet returns 404 not_cached. Use it to inspect the payload shape before signing up, or from the free tools.
curl https://fundfactsapi.com/api/v1/demo/funds/IE00B4L5Y983
Response headers
X-RateLimit-Limit (requests per month), X-RateLimit-Remaining, X-RateLimit-Reset (unix seconds of the monthly reset), X-Burst-Limit and X-Burst-Remaining (per minute) on every authenticated response; Retry-After on 429; X-FundFacts-Stale: 1 when the body carries stale: true (GET /funds/{isin} only). See requests & rate limits.