GraphQL API
The GraphQL API is intended for software developers, who may want to integrate it in Collection Management Systems, Service Platforms and other software.
If you simply want to search for terms, you can use the demonstrator.
Endpoint
The GraphQL API is available at:
https://termennetwerk-api.netwerkdigitaalerfgoed.nl/graphql
For a web interface to try out queries, you can use https://termennetwerk-api.netwerkdigitaalerfgoed.nl.
TODO: https://github.com/netwerk-digitaal-erfgoed/network-of-terms-tutorial/wiki/GraphQL-API
List terminology sources
For a list of terminology sources that can be consulted through the Network of Terms, use this query:
query Sources {
sources {
uri
name
alternateName
description
inLanguage
mainEntityOfPage
creators {
uri
name
alternateName
}
genres {
uri
name
}
features {
type
url
}
status {
isAvailable
lastChecked
}
}
}
Filter sources by subject
The sources query accepts an optional genres parameter to filter sources by subject.
Genre URIs can be discovered from the genres field on each source (shown in the query above).
query Sources {
sources(genres: ["https://data.cultureelerfgoed.nl/termennetwerk/onderwerpen/Periodes"]) {
uri
name
genres {
uri
name
}
}
}
Source language
Terminology source information (name, description, genre and creator names, and the
sort order of the result) is returned in the language requested via the Accept-Language
HTTP header. Currently honored values are nl and en; the default and fallback is nl.
Accept-Language: en
See Language selection for the full picture of how language is
negotiated across the API, including the languages parameter that controls term labels.
Search terms
Query a single source
To query a single terminology source, select a source from the list of sources
and use its URI in the sources query parameter. For query, enter any literal search string that you want to find terms for.
query {
terms(
sources: ["https://data.cultureelerfgoed.nl/term/id/cht"],
query: "fiets"
) {
result {
__typename
... on Terms {
terms {
uri
prefLabel
altLabel
hiddenLabel
definition
scopeNote
seeAlso
broader {
uri
prefLabel
}
narrower {
uri
prefLabel
}
related {
uri
prefLabel
}
exactMatch {
uri
prefLabel
}
}
}
... on Error {
message
}
}
responseTimeMs
}
}
The result element is either:
- a list of terms found (
... on Terms), which can be empty if no terms were found - or an error message (
... on Error). Errors include timeouts.
Search result
The list of terms is sorted by relevance, if supported by the terminology source, or simply alphabetically if not. Each result contains the terminology source and the terms found in it.
Source
A terminology source object has:
- a URI that identifies the source
namedescriptionthat explains in a single sentence the intended use of the sourceinLanguagethat shows in which languages the source is available, usuallynland/orenalternateName: an optional, well-known short name for display purposescreators: the publisher or the source, with its ownuri,nameandalternateName(short name)genres: subjects that the source contains information aboutfeatures: capabilities supported by the source (e.g.GENRE_FILTER)mainEntityOfPage: a website that provides more information about the sourcestatus: availability of the source’s endpoint, with sub-fieldsisAvailable(boolean) andlastChecked(ISO 8601 timestamp).
Terms
A term object has:
- a URI that identifies the term
prefLabelthat is the main name of the termaltLabels andhiddenLabels that are alternative namesdefinitionthat defines the termscopeNotethat provides additional informationseeAlsothat links to a web page for humans about the termbroader,narrower, andrelatedterms that are related to the termexactMatches that link to terms in other terminology sources.
In collection management systems, the term’s URI is used to link data to the term.
Filter by subject
Some terminology sources cover multiple subjects. You can narrow search results to specific subjects
using the genres parameter with a list of genre URIs.
Genre URIs can be discovered from the genres field when listing sources.
Genre filtering is only available for sources that declare the GENRE_FILTER feature,
which is visible via the features field on each source.
query {
terms(
sources: ["https://data.cultureelerfgoed.nl/term/id/cht"],
query: "steen",
genres: ["https://data.cultureelerfgoed.nl/termennetwerk/onderwerpen/Periodes"]
) {
source {
uri
name
}
result {
__typename
... on Terms {
terms {
uri
prefLabel
altLabel
definition
scopeNote
}
}
... on Error {
message
}
}
}
}
Sources that currently support genre filtering include the Art & Architecture Thesaurus (AAT), Cultuurhistorische Thesaurus (CHT), and Wikidata.
Query multiple sources
Querying multiple sources is very similar to querying a single source, but now providing multiple URIs in the sources query parameter:
query {
terms(
sources: [
"https://data.rkd.nl/rkdartists",
"http://data.bibliotheken.nl/id/dataset/persons"
],
query: "Gogh"
) {
source {
uri
name
creators {
uri
name
alternateName
}
status {
isAvailable
lastChecked
}
}
result {
__typename
... on Terms {
terms {
uri
prefLabel
altLabel
hiddenLabel
definition
scopeNote
seeAlso
}
}
... on Error {
message
}
}
responseTimeMs
}
}
The results are grouped by terminology source. Each source returns either a list of terms or an error.
Look up terms by URI
Use the lookup query to look up terms whose URIs you know (for example, because you have stored the URIs previously).
You can look up multiple URIs at once, by providing them in the uris query parameter.
query {
lookup(
uris: ["https://data.rkd.nl/artists/32439", "https://data.cultureelerfgoed.nl/term/id/cht/15e29ea3-1b4b-4fb2-b970-a0c485330384"],
) {
uri
source {
... on Source {
uri
name
creators {
uri
name
alternateName
}
}
... on Error {
__typename
message
}
}
result {
... on Term {
uri
prefLabel
altLabel
hiddenLabel
definition
scopeNote
seeAlso
broader {
uri
prefLabel
}
exactMatch {
uri
prefLabel
}
}
... on Error {
__typename
message
}
}
responseTimeMs
}
}
Each URI is routed to the source whose url prefix it starts with. Sources declare one or more URL prefixes in the catalog, and the Network of Terms picks the matching source for every URI in the request. If no source claims the prefix, the source field returns an Error for that URI.
When several sources are subsets of one broader source in the same URI space (for example ‘Wikidata: persons’ and ‘Wikidata: all entities’), only the broadest source claims the prefix, so lookups report that broadest source. Sources that share a prefix without such a broader source (such as the GTAA sub-schemes) are told apart by the term’s skos:inScheme value instead.
There is no practical limit on the number of URIs in one request, and no need to split a large one yourself: the Network of Terms groups the URIs by the source that claims their prefix and queries each source in batches, sizing those batches to what the source will take.
That size is not the same everywhere. A lookup answers with every label, note and related term of every URI it is given, so a batch that one source answers in a second is more than another will accept – and a source accepts less when it is busy. Rather than fix a number per source, the Network of Terms lowers a source’s batch size when it fails to answer one and raises it again when it does, so a request of many URIs takes as few queries as each source allows.
Requests of more than 1000 URIs are refused. That is a safety valve, not a working limit: a request that large keeps the slowest source busy for minutes.
Looking up terms in several languages
Add the languages argument, and each result is a TranslatedTerm instead of a Term,
with every label field a list of { language, value } pairs. Note that the fragment
matches the term itself – lookup has no plural wrapper, unlike
terms.
query {
lookup(
uris: ["https://data.cultureelerfgoed.nl/term/id/cht/15e29ea3-1b4b-4fb2-b970-a0c485330384"],
languages: [nl, en]
) {
uri
result {
__typename
... on TranslatedTerm {
uri
prefLabel { language value }
altLabel { language value }
broader {
uri
prefLabel { language value }
}
}
... on Error {
message
}
}
}
}
__typenameSelect ... on Error { message } to tell a failure from a term, rather than comparing
__typename against a string such as "TranslatedTerm". Error is an interface over
every error the API can return (NotFoundError, TimeoutError, ServerError,
SourceNotFoundError), so the fragment keeps working when a new error type is added,
and a client that tests for the error case treats everything else as a term.
Comparing __typename to a fixed string is the fragile half of the same choice: it
breaks silently – no GraphQL error, just a branch that stops being taken – whenever a
result type is renamed or a union member is replaced.
What a term denotes
SKOS describes the term: its labels, its place in the hierarchy, its mappings to other
vocabularies. Some sources also describe the thing the term denotes – for a place,
where it is. That lives on the place field, which both Term and TranslatedTerm
carry:
query {
lookup(uris: ["https://sws.geonames.org/2751283/"], languages: [nl, en]) {
result {
... on TranslatedTerm {
prefLabel { language value }
broader { uri prefLabel { language value } }
place {
latitude
longitude
}
}
... on Error {
message
}
}
}
}
{
"__typename": "TranslatedTerm",
"prefLabel": [
{ "language": "nl", "value": "Maastricht (NL)" },
{ "language": "en", "value": "Maastricht (NL)" }
],
"broader": [{ "uri": "https://sws.geonames.org/2751596/" }],
"place": { "latitude": 50.84833, "longitude": 5.68889 }
}
latitude and longitude are in the WGS 84 coordinate reference system. Both are
nullable: sources are not validated, so a coordinate that is absent, empty or not a
number comes back as null rather than raising an error that would drop the whole term
from the result.
place itself is null when the source describes no place. That covers both a term
that denotes something else entirely and a place the source publishes no usable
coordinate for – nothing distinguishes the two, so do not read significance into it.
The place hierarchy stays on broader and narrower, where a client that walks
hierarchies already looks for it. Only the coordinates move to place.
This layer carries what SKOS has no way to state, and nothing else. Coordinates qualify;
a place’s position in its hierarchy does not, because skos:broader and skos:narrower
already state it. Stating it twice would only invite the two to disagree.
Typing comes from the source query, which asserts it explicitly – it is never inferred from the presence of coordinates or from the source’s catalog genre, since a source may cover several genres.
Discover reconciliation endpoints
Sources that offer a Reconciliation API advertise it via the features field. Each feature has a type and a url; the entry with type: RECONCILIATION carries the endpoint URL to configure in OpenRefine.
query {
sources {
uri
name
features {
type
url
}
}
}
Filter client-side for entries where type equals RECONCILIATION. Sources that publish their own reconciliation service (such as Wikidata) are not re-exposed here – see Reconciliation API for details.
Language selection
The Network of Terms uses two language controls:
Accept-Language– for single-language fields: source information (names, descriptions, genre and creator names) in this API, and all responses from the Reconciliation API. Served from our local catalog, translated to a fixed set (nlanden).languagesGraphQL argument – for multilingual term labels (prefLabel,altLabel,definition, etc.), returned side by side as{ language, value }pairs. Fetched in real time from remote sources, so available languages vary per source – see Discovering supported languages.
Two axes of language control
Accept-Language HTTP header | languages GraphQL argument | |
|---|---|---|
| Used for | Catalog metadata (sources, genres, creators) | Term labels (prefLabel, altLabel, etc.) |
| Input shape | Ranked list with quality values, per RFC 9110 (e.g. en, nl;q=0.8) | Ordered list of preferred languages |
| Output shape | One String per field – the server selects a single best match | Each term-label field is a list of { language, value } pairs on TranslatedTerm |
| Unknown language | Silently falls back to nl | Raises a GraphQL error (closed enum) |
| Multilingual output? | No – one chosen language wins | Yes – every requested language with available data is returned |
Which fields are affected
| Field | Controlled by |
|---|---|
All localized fields on Source returned by the top-level sources query | Accept-Language |
prefLabel, altLabel, hiddenLabel, definition, scopeNote on terms | languages |
Embedded Source inside a terms or lookup query result | First entry of languages, else Accept-Language |
Inside a terms or lookup operation, the languages argument doubles as a hint for
which catalog translation to pick for the embedded Source. The top-level sources
query has no operation-level language preference and uses Accept-Language directly.
The Accept-Language header
Accept-Language is the standard HTTP content-negotiation header: clients send a ranked
list of preferences and the server picks the best match. The Network of Terms currently
matches against nl and en; the default and fallback when nothing matches is nl.
Tags that don’t intersect with the honored set fall through to nl.
Accept-Language: en, nl;q=0.8
Catalog metadata is only translated into nl and en, even when the catalog has
term-level data in other languages. For example, fy (Frisian) is a valid value for
the languages GraphQL argument and returns Frisian
term labels where sources provide them, but Accept-Language: fy still resolves source
names, descriptions, genre names and creator names to nl.
The languages GraphQL argument
languages is a GraphQL argument accepted by the terms and lookup queries. It
takes a list of values from the Language enum, in order of preference.
Passing languages changes the result type. Without it, term results come back as
Terms with each label field as a plain [String!]. With it, results come back as
TranslatedTerms and each label field is a
list of { language, value } pairs covering every requested language that the source
provides data for. This is the only way to get a multilingual response from the API.
Untagged literals are treated as nl. Passing a value that is not part of the enum
(for example fr when the catalog has no French data) raises a GraphQL parse error.
This is by design: the enum is the API’s declarative statement of which languages it
currently offers.
Discovering supported languages
The Language enum is the authoritative list of supported languages. It is derived
from the catalog at server startup, so the schema grows automatically as new languages
are added. Clients can discover the current set via GraphQL introspection:
query SupportedLanguages {
__type(name: "Language") {
enumValues { name }
}
}
The recommended pattern for forward-compatible clients:
- Fetch the supported set via introspection (once, at startup or at build time via codegen).
- Intersect it with the languages your application supports.
- Send only that intersection in the
languagesargument.
When the Network of Terms adds a new language to its catalog, a fresh introspection picks it up without any change to the API contract on the client side.
Multilingual results: TranslatedTerm
Passing languages is what turns the API multilingual. Both
search and lookup queries switch as soon as
the argument is present, exposing each label as a { language, value } pair so
translations can be rendered side by side. Without languages, you get single-language
labels as [String!].
The two queries wrap it differently, which matters when you write the fragment:
| Query | Without languages | With languages |
|---|---|---|
terms | Terms, holding [Term] | TranslatedTerms, holding [TranslatedTerm] |
lookup | Term – one per URI | TranslatedTerm – one per URI |
So a search selects through a wrapper (... on TranslatedTerms { terms { … } }), while
a lookup matches the term itself (... on TranslatedTerm { … }).
query {
terms(
sources: [
"https://data.rkd.nl/rkdartists",
"http://data.bibliotheken.nl/id/dataset/persons"
],
query: "Gogh",
languages: [en, nl]
) {
source {
uri
name
}
result {
__typename
... on TranslatedTerms {
terms {
uri
prefLabel { language value }
altLabel { language value }
hiddenLabel { language value }
definition { language value }
scopeNote { language value }
seeAlso
}
}
... on Error {
message
}
}
responseTimeMs
}
}
Not all terminology sources are available in all languages. Inspect the inLanguage
field on each source (see List terminology sources) to
see which languages it provides.
If you omit languages, the result type is Terms instead and label fields are
returned as plain [String!]. Their values come from whichever language the source
provides, with untagged literals treated as nl.
Per-term label fallback
The languages parameter filters labels per term – it does not exclude terms from the
results. If a term has no labels in any of the preferred languages, it is still
returned, but its label fields (prefLabel, altLabel, hiddenLabel, definition,
scopeNote) fall back as follows:
- If the source provides literals tagged
mul(Wikidata’s ‘default for all languages’), they are returned in the first preferred language. - If the source provides untagged literals for the field, they are returned and treated
as Dutch (
nl). - Otherwise, the field is returned as an empty list.
The languages you can actually get back depend on what each source provides: see the
inLanguage field when listing sources.
Response times
Response times from the Network of Terms will vary depending on how fast each terminology source returns results for the query.
Use the responseTimeMs response property to inspect the response time (in ms) for each source.
query {
terms(
sources: [...],
query: "..."
) {
source { ... }
result { ... }
responseTimeMs
}
}
Timeouts
The default timeout is 10 seconds. You can raise this up till 60 seconds using the
timeoutMs query parameter. For example, to wait a maximum of 15 seconds for each terminology source to respond:
query {
terms(
sources: [...],
query: "...",
timeoutMs: 15000,
) {
...
}
}