Build with the DFO / API
Quickstart
The DFO API lets software request DFO data directly, without anyone visiting the website. It's the same process as searching on /data, done by code: you send a question in plain English, the DFO works out which data you mean, and it packages that data as a file your program can download. This makes it useful for developers building tools, dashboards, or regular reports that need to pull in up-to-date development finance figures automatically. The example on this page asks how much aid Germany gave Nigeria for basic health care and returns the figures for each year. If you don't write code, you can get the same data from Get data on /data, or by asking an AI assistant connected to the DFO.
How it works
Getting data from the API takes three steps. You resolve a question into a precise data definition, create a dataset from that definition, then download the result as a file. The steps below make each request with curl, then run the whole flow in Python. It takes about ten minutes.
What you will build
The result is a ZIP archive containing a Parquet data file, citation metadata, and a data package description.
What you will learn
- How to check how the Search API interpreted a question.
- How to create a dataset from the resulting spec.
- How to download a Parquet bundle.
What you need
You need curl, jq, and unzip. The Python version also needs uv. Set the API base URL once.
export DFO_API=https://developmentfinance.org
The API accepts requests without an API key.
Resolve the question
Send a question with explicit donor and recipient roles. The confirmation sentence is the first check on the resulting data.
An example response contains these selected fields. The date range depends on the available observations.
{
"kind": "resolution",
"status": "definite",
"spec_id": "spec_774d6c001a6250aa67ca676ab067895e",
"sentence": "Germany's gross ODA disbursements to Nigeria for basic health care, 1995 to 2024. Current US dollars · OECD Creditor Reporting System (CRS)."
}
curl -fsS -X POST "$DFO_API/api/v1/search/resolve" \
-H "Content-Type: application/json" \
-d '{"input":{"kind":"raw_text","query":"ODA disbursements from Germany to Nigeria for basic health care"}}' \
> resolve.json
jq 'if .kind == "resolution" then
{kind} + (.parts[0] | {
status,
spec_id: .spec.spec_id,
sentence: .spec.resolved.rendered_sentence
})
else
{kind, reason, scope}
end' resolve.json
Check kind before reading parts.
Create the dataset
Create a Parquet bundle from the spec_id and definition returned by Search. A successful synchronous response is a manifest with a content_version, coverage, and artifacts. A background response has an operation object.
jq -e '.kind == "resolution" and .parts[0].status == "definite"' resolve.json > /dev/null || {
echo "Search did not return a definite interpretation"
exit 1
}
jq '{
schema_version: 1,
input: {
kind: "spec_id",
spec_id: .parts[0].spec.spec_id,
definition: .parts[0].spec.definition
},
options: {format: "parquet"}
}' resolve.json > mint.json
if ! curl -fsS -X POST "$DFO_API/api/v1/datasets" \
-H "Content-Type: application/json" \
-d @mint.json > response.json; then
echo "Dataset request failed" >&2
exit 1
fi
if jq -e '.operation' response.json > /dev/null; then
poll_url="$(jq -r '.operation.poll' response.json)"
wait_seconds="$(jq -r '.operation.retry_after_ms / 1000' response.json)"
while true; do
sleep "$wait_seconds"
if ! curl -fsS "$DFO_API$poll_url" > operation.json; then
echo "Dataset operation failed" >&2
exit 1
fi
if [ "$(jq -r '.operation.status' operation.json)" = "done" ]; then
jq '.manifest' operation.json > manifest.json
break
fi
done
else
cp response.json manifest.json
fi
jq '{
content_version,
coverage,
artifacts: [.artifacts[] | {rendering, media_type, url}]
}' manifest.json
The poll URL is saved from the initial response because a completed operation omits it. A failed poll stops the command before it reads the response as a completed operation.
content_version identifies the stored result. Keep it with the spec_id when you need to retrieve that same version later.
Download the Parquet bundle
Select the requested bundle by its rendering and download it. Each artifact's url is a stable download link relative to the API host. It answers 302 with a storage URL signed for five minutes, which -L follows.
bundle_url="$(jq -r 'first(.artifacts[] | select(.rendering.format == "parquet" and .rendering.bundle == true)) | .url' manifest.json)"
curl -fsSL "$DFO_API$bundle_url" -o bundle.zip
data_path="$(unzip -p bundle.zip datapackage.json | jq -r '.resources[] | select(.format == "parquet") | .path')"
unzip bundle.zip "$data_path" README.md SOURCES.md CITATION.cff datapackage.json
The bundle contains README.md, SOURCES.md, CITATION.cff, and datapackage.json. It includes data/codes.csv when the data contains codes. Read datapackage.json to find the data resource path.
The selected resource contains the observations. README.md explains the data and its columns. SOURCES.md records the data sources, licences, and processing steps. CITATION.cff and datapackage.json provide citation and machine-readable metadata.
Use Python instead
Use this script instead of the curl commands when you want to run the full flow in Python. Save it as quickstart.py.
It prints the confirmation sentence, then the first five rows of the Parquet table.
import io
import json
import os
import time
import zipfile
import httpx
import pandas as pd
DFO_API = os.environ["DFO_API"]
question = "ODA disbursements from Germany to Nigeria for basic health care"
with httpx.Client(base_url=DFO_API, timeout=120) as client:
response = client.post(
"/api/v1/search/resolve",
json={"input": {"kind": "raw_text", "query": question}},
)
response.raise_for_status()
answer = response.json()
if answer["kind"] != "resolution":
raise RuntimeError(f"Search returned {answer['kind']}: {answer}")
part = answer["parts"][0]
if part["status"] != "definite":
raise RuntimeError(f"Search returned {part['status']}: {part}")
spec = part["spec"]
response = client.post(
"/api/v1/datasets",
json={
"schema_version": 1,
"input": {
"kind": "spec_id",
"spec_id": spec["spec_id"],
"definition": spec["definition"],
},
"options": {"format": "parquet"},
},
)
response.raise_for_status()
result = response.json()
if "operation" in result:
poll_url = result["operation"]["poll"]
wait_seconds = result["operation"].get("retry_after_ms", 1500) / 1000
while True:
time.sleep(wait_seconds)
response = client.get(poll_url)
response.raise_for_status()
result = response.json()
if result["operation"]["status"] == "done":
manifest = result["manifest"]
break
else:
manifest = result
bundle = next(
artifact
for artifact in manifest["artifacts"]
if artifact["rendering"].get("format") == "parquet"
and artifact["rendering"].get("bundle") is True
)
response = client.get(bundle["url"], follow_redirects=True)
response.raise_for_status()
archive = zipfile.ZipFile(io.BytesIO(response.content))
package = json.loads(archive.read("datapackage.json"))
data_path = next(resource["path"] for resource in package["resources"] if resource["format"] == "parquet")
data = pd.read_parquet(io.BytesIO(archive.read(data_path)))
print(spec["resolved"]["rendered_sentence"])
print(data.head())
Run the script.
uv run --with httpx --with pandas --with pyarrow python quickstart.py
Next
How the APIs fit together explains specs, versions, StatVars, series, rows, and source selection. Get a dataset covers CSV, Excel, background operations, snapshots, and retention.