Workflows

How do I build and maintain my own database of funds from the API?

A read-model pattern: one row per ISIN with the fields you filter on plus the raw payload and expiresAt, loaded in batches, refreshed after expiry, with quota awareness. Schema and loader code.Updated 12 September 2026 · by FundFacts API

Short answer

Treat the API as the source of truth and your table as a read model: one row per ISIN with the columns you filter and sort on (category, risk band, TER, AUM, currency, distribution, inception), the raw JSON payload, dataAsOf and expiresAt. Load in batches with POST /funds, re-fetch a row only after its expiresAt, spread refreshes over the day, and pause when X-RateLimit-Remaining nears zero. A few hundred funds refreshed daily fit Pro; thousands fit Scale.

Schema

sql
create table funds (
isin text primary key,
name text,
kind text, -- data.profile.kind
category text, -- data.profile.category
risk_rating int, -- data.riskRating
risk_band text, -- data.profile.riskBand
ter_pct numeric, -- parsed from data.headlineMetrics.ter
aum_text text, -- data.keyFacts.aum as printed
currency text,
distribution text,
inception date,
sfdr_article int,
data_as_of date,
expires_at timestamptz not null,
payload jsonb not null,
status text not null -- ok | not_found
);
create index on funds (expires_at);
create index on funds (category, risk_band);

Keep the full payload in jsonb; you will want a field you did not extract.

Loader

python
import os, requests, time, psycopg
H = {"Authorization": f"Bearer {os.environ['FUNDFACTS_API_KEY']}"}
def upsert(cur, x):
d = x.get("data") or {}
ter = d.get("headlineMetrics", {}).get("ter") or ""
cur.execute("""insert into funds values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
on conflict (isin) do update set name=excluded.name, kind=excluded.kind, category=excluded.category,
risk_rating=excluded.risk_rating, risk_band=excluded.risk_band, ter_pct=excluded.ter_pct, aum_text=excluded.aum_text,
currency=excluded.currency, distribution=excluded.distribution, inception=excluded.inception, sfdr_article=excluded.sfdr_article,
data_as_of=excluded.data_as_of, expires_at=excluded.expires_at, payload=excluded.payload, status=excluded.status""",
(x["isin"], x.get("name"), d.get("profile", {}).get("kind"), d.get("profile", {}).get("category"), d.get("riskRating"),
d.get("profile", {}).get("riskBand"), float(ter.rstrip("%")) if ter.endswith("%") else None, d.get("keyFacts", {}).get("aum"),
d.get("keyFacts", {}).get("currency"), d.get("keyFacts", {}).get("distribution"), d.get("keyFacts", {}).get("inception") or None,
d.get("sfdrArticle"), d.get("dataAsOf") or None, x.get("expiresAt") or "now()", psycopg.types.json.Jsonb(x), x["status"]))
def refresh(isins, batch=50):
with psycopg.connect(os.environ["DATABASE_URL"]) as conn, conn.cursor() as cur:
for i in range(0, len(isins), batch):
r = requests.post("https://fundfactsapi.com/api/v1/funds", json={"isins": isins[i:i+batch], "wait": True}, headers=H, timeout=300)
if int(r.headers.get("X-RateLimit-Remaining", "1")) < 20:
break # keep headroom for interactive use
for x in r.json()["results"]:
if x["status"] in ("ok", "not_found"):
upsert(cur, x)
# pending: picked up on the next run
conn.commit()
time.sleep(1)

Run it against select isin from funds where expires_at < now() on a schedule, plus new ISINs from your intake.

Rules of thumb

  • Never refresh before `expiresAt`: the payload cannot have changed.
  • Prioritise: refresh the funds users open first; the long tail can lag a day.
  • Keep 404s: a not_found row stops you from re-spending on a stock ISIN; review monthly.
  • Show `data_as_of`, not the refresh time.

The fund screener use case and the Next.js screener guide build the UI on top of this table; on Scale the change feed tells you which rows actually changed.

Verify it yourself

The demo endpoint returns the live payload for a fund that is already in the store, without a key. Everything on this page can be checked against it.
bash
curl -s https://fundfactsapi.com/api/v1/demo/funds/IE00B4L5Y983 | jq '{name, asOf: .data.dataAsOf, ter: .data.headlineMetrics.ter, risk: .data.riskRating, top: .data.topHoldings[:3]}'

Frequently asked questions

How much does it cost to keep 1,000 funds current?

About 30,000 requests a month at one refresh per fund per day, which is the Scale plan. Refreshing the long tail every other day roughly halves it.

Try it on your own ISINs

One request returns key facts, holdings, risk and performance as JSON. Free plan, no card.