Skip to main content

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:

List terminology sources
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).

List only sources about ‘periodes’
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 Cultuurhistorische Thesaurus (CHT)
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
  • name
  • description that explains in a single sentence the intended use of the source
  • inLanguage that shows in which languages the source is available, usually nl and/or en
  • alternateName: an optional, well-known short name for display purposes
  • creators: the publisher or the source, with its own uri, name and alternateName (short name)
  • genres: subjects that the source contains information about
  • features: capabilities supported by the source (e.g. GENRE_FILTER)
  • mainEntityOfPage: a website that provides more information about the source
  • status: availability of the source’s endpoint, with sub-fields isAvailable (boolean) and lastChecked (ISO 8601 timestamp).

Terms

A term object has:

  • a URI that identifies the term
  • prefLabel that is the main name of the term
  • altLabels and hiddenLabels that are alternative names
  • definition that defines the term
  • scopeNote that provides additional information
  • seeAlso that links to a web page for humans about the term
  • broader, narrower, and related terms that are related to the term
  • exactMatches 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.

Search CHT for ‘steen’, filtered to ‘Periodes’
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
}
}
}
}
tip

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 RKDartists and NTA simultaneously
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.

Look up a term with labels in both Dutch and English
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
}
}
}
}
Do not switch on __typename

Select ... 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:

Coordinates of the place a term denotes
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
}
}
}
}
Response (abridged)
{
"__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.

Only what SKOS cannot express

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.

List reconciliation endpoints
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 (nl and en).
  • languages GraphQL 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 headerlanguages GraphQL argument
Used forCatalog metadata (sources, genres, creators)Term labels (prefLabel, altLabel, etc.)
Input shapeRanked list with quality values, per RFC 9110 (e.g. en, nl;q=0.8)Ordered list of preferred languages
Output shapeOne String per field – the server selects a single best matchEach term-label field is a list of { language, value } pairs on TranslatedTerm
Unknown languageSilently falls back to nlRaises a GraphQL error (closed enum)
Multilingual output?No – one chosen language winsYes – every requested language with available data is returned

Which fields are affected

FieldControlled by
All localized fields on Source returned by the top-level sources queryAccept-Language
prefLabel, altLabel, hiddenLabel, definition, scopeNote on termslanguages
Embedded Source inside a terms or lookup query resultFirst 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.

important

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:

List supported languages
query SupportedLanguages {
__type(name: "Language") {
enumValues { name }
}
}

The recommended pattern for forward-compatible clients:

  1. Fetch the supported set via introspection (once, at startup or at build time via codegen).
  2. Intersect it with the languages your application supports.
  3. Send only that intersection in the languages argument.

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:

QueryWithout languagesWith languages
termsTerms, holding [Term]TranslatedTerms, holding [TranslatedTerm]
lookupTerm – one per URITranslatedTerm – one per URI

So a search selects through a wrapper (... on TranslatedTerms { terms { … } }), while a lookup matches the term itself (... on TranslatedTerm { … }).

Return results in both English and Dutch
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
}
}
tip

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,
) {
...
}
}