DEVELOPMENT VERSION: This is an experimental DALICC environment. Features, data and APIs may change or be reset without notice. A stable version of this service, with a potentially different feature set, will be made available soon at dalicc.net.

DALICC API reference


Last changed Markdown source

On this page

This is the reference for every HTTP endpoint of the DALICC service. Each one is described with its request, its parameters, its response and an example, so that somebody building a client or a script against https://api.dalicc.net never has to open the DALICC source code.

Part of the documentation index.

Contents: 1. Conventions · 2. The documented contract · 3. Additive endpoints · 4. Behind the website · 5. Dependency graphs · 6. Review workflow and messaging · 7. Per-account API limits · 8. The translation assistant · 9. License-to-Text · 10. The public profile address · 11. Version 2: history, releases and discovery

The Python client that speaks all of it is in SDK.md. What a license, a draft, a submission or a translation is belongs to USERS.md. Nothing this API returns is legal advice.


1. Conventions

Base URL and schema

https://api.dalicc.net, or the base URL of your own deployment. Interactive documentation is at /docs (Swagger UI) and /redoc, the machine-readable document at /openapi.json: title "DALICC API", API version 1.1, OpenAPI 3.1.0. /spec serves the vocabulary specification page. Version 2 of the API has its own document at /v2/openapi.json (API version 2.0.0) and its own Swagger UI at /v2/docs; it is described in section 11, and GET /v2 says what a deployment offers.

What the document tells a client generator:

  • every success answer has a schema, including the frozen quirks (the SPARQL-JSON envelope of /list, the JSON-encoded Turtle string of format=ttl, the stringified integer keys of a compatibility verdict), and every $ref resolves;
  • components.securitySchemes.bearerAuth is the personal API token. An operation that needs one carries security: [{"bearerAuth": []}] and declares 401; one that reads more with a token (a graph of the account) carries [{}, {"bearerAuth": []}], so it works without;
  • the ten operations of section 2 carry x-dalicc-contract: frozen;
  • 404 is declared only where an operation can answer it, and /healthz declares 503.

The schemas describe what the operations send; they do not validate it on the way out, so no answer changed for them to exist.

The API carries its own version, independent of the application's release number. What that number promises is in BACKWARD_COMPATIBILITY.md.

The ten operations of section 2 are the frozen contract: paths, parameter names, parameter types, defaults and response keys do not change. Sections 3 to 8 are additive and may evolve under the deprecation policy in BACKWARD_COMPATIBILITY.md.

Versions and deprecation

The API has two versions. Version 1 is 1.1, read from info.version of /openapi.json: 1.0 is what production published until this release, and 1.1 adds operations without changing any of the ten. Version 2 is 2.0, under the path prefix /v2, with its own OpenAPI document at /v2/openapi.json (section 11). GET /v2 names both. No response header names a version, and the ten operations keep answering at their paths.

Deprecating an operation follows section 5 of BACKWARD_COMPATIBILITY.md: it is written here and in the OpenAPI description, announced by e-mail and on the site, kept working for at least twelve months and only then removed. No operation is deprecated today. Version 1 sends no Deprecation or Sunset response header, so a client learns of a deprecation from this page and from the announcement, not from an answer. Version 2 sends Deprecation on a superseded or withdrawn version of the data (section 11) and never sends Sunset for data.

Authentication

Reading, searching and reasoning need no credentials. Writing, anything that belongs to an account, and the translation assistant need a personal API token, created on /account/tokens and sent as a header:

Authorization: Bearer dalicc_...

A token identifies an account, not a script: minting a second token does not double anybody's allowance. Accounts and tokens are described in USERS.md. An endpoint that needs a token and does not get one answers 401; a token that may not see the object it asks for gets 403.

Error shape

Every API error is JSON, in one of four shapes. All four are frozen for version 1, so a client that reads errors should accept each of them:

  1. A sentence. Almost every error: the operation raised it with a message.

json {"detail": "Unknown license id: NOPE-123"}

  1. A list, when a parameter does not have the declared type. FastAPI validates path and query parameters before the operation runs and answers 422 with one entry per problem: ?format=nt on /licenselibrary/license/{id}, ?limit=x on /list, ?ports=bogus, /versions/x.

json {"detail": [{"type": "int_parsing", "loc": ["query", "limit"], "msg": "Input should be a valid integer, unable to parse string as an integer", "input": "x"}]}

loc names where the value was (path, query or body, then the name); type and msg say what was wrong. The same check made inside an operation answers the sentence shape instead: POST /licenselibrary/facetedsearch?limit=x is {"detail": "limit must be an integer value"}, because its limit is read as a string for historical reasons.

  1. A sentence with the window, when a token is over its allowance. The per-account 429 carries window, limit, remaining and reset_at next to detail (section 7).

  2. A sentence, when an address is over a per-IP limit. The anonymous 429 is shape 1: {"detail": "Rate limit exceeded: at most 60 requests per 60s for this endpoint."}, with Retry-After.

The version 2 namespace answers errors as RFC 9457 problem details instead; version 1 keeps these.

Status When
400 A GitHub URL that is not a github.com repository reference
401 No API token on an endpoint that needs one, or a token that is not live
403 A token that may not see this object, or an account whose allowance is zero
404 Unknown license id, version or graph; a repository libraries.io does not know
405 A documented /licenselibrary/<segment> reached with the wrong method (Allow names the methods)
409 POST /licenselibrary/composer: the composed license contradicts itself; the body carries the conflicts
413 A text longer than the translation assistant reads
415 GET /licenselibrary/{id}?format= with a serialisation that does not exist
422 Validation failed: a malformed license id, a non-integer limit, a license IRI that is not an absolute http(s) IRI, fewer than two ids to compare
429 A rate limit or a quota; Retry-After names the seconds
502 An upstream answered with an error or with something that is not JSON (reasoner, libraries.io, the model provider)
503 A required dependency is not configured or not reachable (libraries.io key unset, triple store down, assistant not configured)
504 An upstream did not answer within the configured timeout

API paths always answer JSON, whatever the client asks for. Only a request that explicitly ranks text/html above JSON, and only on a site path, gets a rendered error page instead.

Headers on every response

X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin and X-Request-ID, which also appears in the server log line for the request. X-Frame-Options: SAMEORIGIN everywhere except under /web/, which gets a Content-Security-Policy: frame-ancestors list the deployment configures instead, so the historical iframe embedding keeps working.

CORS

Two rules, and which one answers depends on the request:

Request Access-Control-Allow-Origin
An anonymous read: GET or HEAD under /licenselibrary, /dependencygraph, /compatibilitycheck, /githublicensechecker, /healthz, /openapi.json or /ns, with no Authorization header, or the preflight of one *, so a browser program on any site can read it
Anything else: a POST, or any request that sends a token the origins the deployment configures; on https://api.dalicc.net those are https://dalicc.net, https://www.dalicc.net, https://api.dalicc.net and https://docs.dalicc.net. Under /v2 and /.well-known/api-catalog every request answers *, token or not (section 11)

Neither allows credentials, so no cookie ever travels cross-origin; any request header may be sent. Both expose the headers a script cannot read otherwise, because none of them is on the CORS safelist: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After, X-Request-ID, Content-Disposition, ETag, Link, Deprecation and Sunset.

Caching

The ten operations of section 2 send no cache headers, as before. Elsewhere:

Answer Headers
An earlier version: /licenselibrary/license/{id}/versions/{n}, /dependencygraph/versions/{n}, /dependencygraph/{graph_id}/versions/{n}, /ns/versions/{n} a weak ETag and Cache-Control: public, max-age=86400 (private for a graph of an account)
The vocabulary in RDF (/ns under negotiation or ?format=) and its current version a weak ETag and Cache-Control: public, max-age=3600
The current version of a model no validator: it changes when the model is corrected

A request that repeats the tag in If-None-Match gets 304 with no body. The tag is weak because JSON-LD, RDF/XML and N-Triples name blank nodes afresh on every request: two answers with one tag state the same graph, not always in the same bytes.

Every route outside the ten that answers differently by Accept says so with Vary: Accept: /licenselibrary/{id}, /licenselibrary/{id}/versions, /licenselibrary/license/{id}/versions/{n}, /ns, /ns/{term} and /users/{id}.

Rate limits

Two limiters, and which one applies depends on how the request authenticates.

Per IP. This one protects the anonymous surface. It counts per client address over a sliding window: the X-Forwarded-For hop the deployment's own reverse proxy appended (the last one behind a single nginx), else the peer address. A hop a client writes into its own X-Forwarded-For is not counted, so sending a new one per request does not buy a new allowance. A deployment may switch the limiter off.

Bucket Endpoints Limit
compatibilitycheck POST /compatibilitycheck/ 60 per minute
githublicensechecker every /githublicensechecker/*, POST /github-license-compatibility-checker 30 per minute
composer POST /licenselibrary/composer, POST /license-composer, and the retired POST /web/wpcomposer, which answers 410 10 per minute
dependencygraph POST /dependencygraph 10 per minute
mixer POST /license-compatibility-checker/check, /target, /statement 30 per minute
translate POST /text-to-license, /text-to-license/retry, /license-to-text, /license-composer/write-text 5 per minute
site-contact POST /contact 5 per 10 minutes

The forms of the website have buckets of their own in the same shape; they are not part of the API.

Exceeding a limit is 429 with Retry-After. Read the numbers above as the shape of the limit rather than an exact promise: each server process counts for itself, so a deployment that runs several of them tolerates proportionally more, and the reverse proxy in front of the service has limits of its own that no client can exceed.

Per account. A request that carries a personal API token is counted per account instead, in four windows at once and with a weight per endpoint. That is section 7, and it is the only rate-limit behaviour an existing client can notice.

Content negotiation

Two paths negotiate on Accept.

GET /licenselibrary/license/{license_id} (the API path)

Request Response
no Accept, or */* JSON-LD object, application/json
?format=ttl or ?format=rdf-xml a JSON-encoded string with application/json (a historical quirk, frozen)
?format=text the license written out in words, text/plain (section 9)
Accept: text/turtle raw Turtle, text/turtle
Accept: application/rdf+xml raw RDF/XML, application/rdf+xml
Accept: application/ld+json JSON-LD compacted with the same @context, application/ld+json: every object reference stays {"@id": ...}, so a JSON-LD processor reads exactly the triples of the Turtle answer
Accept containing text/html anywhere 307 to /web/license/{id}
?download=true the chosen serialisation as an attachment, with the right media type

The HTML redirect is a 307, as production sends it, and its Location is absolute, on the API host of the deployment: https://api.dalicc.net/web/license/MIT. The test is a substring test: any Accept that mentions text/html redirects, even at a lower quality than an RDF type, so Accept: text/turtle, text/html;q=0.1 gets the redirect too. That is how production answers, so it is frozen; send an Accept without text/html to get RDF.

An explicit RDF Accept header always wins over format here. That is how a client gets real Turtle instead of the JSON-wrapped string, and real JSON-LD instead of the default body: the default flattens every {"@id": x} into the plain string x, so a JSON-LD processor reads "odrl:distribute" or "_:n80bc..." as a string literal. The default keeps that shape because clients parse it; for RDF, send Accept: application/ld+json or Accept: text/turtle. The version routes of the model history negotiate the same way.

GET /licenselibrary/{license_id} (the Linked-Data path, the canonical URI)

Request Response
Accept prefers text/html 303 to /license-library/{id}, the human-readable page
Accept or ?format= names an RDF type that serialisation, with the matching media type
anything else 303 to /licenselibrary/license/{id}, the JSON API

/licenselibrary/{license_id}/versions, the version history a record names, negotiates the same way; see the model history.

Here ?format= wins over the header, so a browser can be pointed at one serialisation with a link. The accepted media types are text/turtle, application/x-turtle, application/rdf+xml, application/ld+json, application/n-triples and text/n3; ?format= takes ttl, turtle, rdf-xml, rdfxml, xml, json-ld, jsonld, nt and n-triples, and anything else is 415.

The two vocabularies side by side. They grew apart and are frozen apart:

/licenselibrary/license/{id} and .../versions/{n} /licenselibrary/{id}
?format= values json-ld (default), ttl, rdf-xml, text ttl, turtle, rdf-xml, rdfxml, xml, json-ld, jsonld, nt, n-triples
an unknown ?format= 422, the list shape of Error shape 415, a sentence naming the nine values
Accept types honoured text/turtle, application/rdf+xml, application/ld+json the six above
Accept: application/n-triples ignored: the default JSON answer N-Triples
N-Triples at all no yes
which wins Accept over ?format= ?format= over Accept
the license in words format=text no

The version 2 namespace has one vocabulary for every record address.

Pagination

Three behaviours, all frozen for version 1. None of them sends a next link.

Operations Paged how Total
GET /licenselibrary/list, POST /licenselibrary/facetedsearch only when the request sends skip or limit in the query string; without them every row comes back, whatever the declared default of limit says (change 9) none: count the rows of an unpaged call
GET /licenselibrary/history always: skip defaults to 0, limit to 50, at most 500 total, the number of records with a history
GET /licenselibrary/mine, GET /dependencygraph/graphs, GET /dependencygraph/mine, GET /licenselibrary/compare, GET /dependencygraph/rules, the version lists never: the whole answer at once the length of the list

To walk /history, raise skip by limit until skip reaches total. The version 2 namespace pages every list the same way, with a cursor and Link: rel="next".

Names that differ between operations

The field names grew with the operations and are frozen with them. What differs:

  • A graph entry names its address iri, a license entry uri; POST /dependencygraph answers both.
  • A version list says current, a change log current_version, for the same number.
  • url and api_url are paths on the same host (/license-library/MIT, /licenselibrary/license/MIT/versions/1); uri and iri are absolute.
  • The 404 sentences differ by route: /versions/0 says Unknown version: 0, /versions/99 says No version 99 of license id: MIT, and /changelog answers No change log for license id: X both for an unknown identifier and for a record that was never corrected. Test the status code, not the sentence.
  • Field names say license, and so do the messages of the API service since 2026-09-24; before, they used "license" and "licence" both.

The raw SPARQL-JSON envelope

GET /licenselibrary/list and POST /licenselibrary/facetedsearch do not return a list. They return the SPARQL 1.1 Query Results JSON envelope of the triple store, verbatim:

{
  "head": {"link": [], "vars": ["id", "title"]},
  "results": {
    "distinct": false,
    "ordered": true,
    "bindings": [
      {"id":    {"type": "uri", "value": "https://dalicc.net/licenselibrary/AFL-3.0"},
       "title": {"type": "literal", "xml:lang": "en", "value": "Academic Free License v3.0"}}
    ]
  }
}

Read results.bindings[].id.value and results.bindings[].title.value. title carries xml:lang on some rows and not on others, because the underlying data is inconsistent. The shape is frozen because clients parse it today; a cleaner endpoint would have to be additive.


2. The documented contract

2.1 GET /licenselibrary/license/{license_id}

The machine-readable representation of one license.

Parameter In Type Default Notes
license_id path string required Must match ^[A-Za-z0-9._-]{1,128}$, else 422. Resolution is case-insensitive on the filename, so Wtfpl.ttl also answers WTFPL
format query json-ld | ttl | rdf-xml | text json-ld text is additive and is not a serialisation of the graph: section 9
download query bool false Sends Content-Disposition: attachment

Response with format=json-ld: a JSON-LD document with a frozen 12-prefix @context (rdf, rdfs, xsd, dcat, dct, foaf, dalicc, dalicclib, cc, odrl, osl, scho) and a @graph whose first node is the odrl:Set:

{"@context": {"odrl": "http://www.w3.org/ns/odrl/2/", "...": "..."},
 "@graph": [{"@id": "dalicclib:MIT", "@type": "odrl:Set",
             "dct:title": "MIT License", "cc:jurisdiction": "...",
             "odrl:permission": ["_:b0", "_:b1"], "odrl:prohibition": [],
             "odrl:duty": ["_:b2"], "odrl:target": "_:b3"}, "..."]}

The document is whatever the record states, so it carries the relations the record carries: dalicc:jurisdictionPortOf on a jurisdiction port, and dalicc:variantOf with dalicc:variantKind on the 33 records that are a version option, an exception combination or a rider of another record. Both are additive: a client that reads known predicates is unaffected.

Status: 200; 404 for an unknown id; 422 for a malformed one; 503 when the id is not on disk and the triple store cannot be asked, so a store outage is never reported as "no such license". Licenses composed through the composer resolve here too, from the customlicenses graph.

curl -s https://api.dalicc.net/licenselibrary/license/MIT | head -c 300
curl -s -H 'Accept: text/turtle' https://api.dalicc.net/licenselibrary/license/MIT
curl -sOJ 'https://api.dalicc.net/licenselibrary/license/MIT?format=ttl&download=true'

2.2 GET /licenselibrary/list

Parameter In Type Default Notes
keyword query string none Case-insensitive substring of dct:title or dct:alternative
skip query int 0 Offset, applied only when the client sends it
limit query int 10 Page size, applied only when the client sends it
ports query include | exclude | only include Additive; anything else is 422

The paging quirk. skip and limit are honoured only when they are actually in the query string. A client that omits them receives every row, which is what this operation has always done; sending skip=0 would change the answer.

Ordering is alphabetical by title without a keyword, and by fuzzy match score with one.

ports selects how the jurisdiction ports take part. 290 of the 581 records are the same license adapted to another legal system, named in the data by dalicc:jurisdictionPortOf (see DATA.md):

ports Rows
include every record: 579. The default, and what the operation answered before the parameter existed
exclude only records that are not a port of another: 290
only only the ports and editions: 289, one fewer than the 290 records that carry the predicate, because one of them is a test fixture

exclude and only partition include, so the two never return the same row.

The parameter means jurisdiction ports and nothing else. A variant, which is an or-later identifier, a license read together with an SPDX exception or a license with a rider, names its base record with dalicc:variantOf and is an ordinary row of every value of ports: it is a license of its own with an address of its own.

Two records are left out of every value of ports: SampleLicenseSl and DeveloperLicense carry dalicc:recordStatus dalicc:testFixture and are not published licenses. Their documents keep resolving at GET /licenselibrary/license/{id}.

Response: the SPARQL-JSON envelope.

curl -s 'https://api.dalicc.net/licenselibrary/list?keyword=apache'
curl -s 'https://api.dalicc.net/licenselibrary/list?skip=0&limit=5'
curl -s 'https://api.dalicc.net/licenselibrary/list?ports=exclude'   # 290 rows

2.3 POST /licenselibrary/facetedsearch

Search by what a license permits, forbids and demands.

Parameter In Type Default
skip query int 0
limit query string "10"
ports query include | exclude | only include

The limit quirk. It is typed as a string here and as an integer on /list. That asymmetry is in the published schema, so it stays; a value that is not an integer is 422. skip and limit are applied only when sent, exactly as on /list, and ports means the same thing.

Body (LicenseSearch, all four keys required):

{"target":  {"creativework": "yes", "dataset": "yes", "software": "yes"},
 "actions": {"reproduce": "na", "distribute": "na", "modify": "na", "derive": "na",
             "commercial_use": "na", "charge_distribution_fee": "na", "change_license": "na"},
 "duties":  {"distribute_duty_attribution": "na", "distribute_duty_notice": "na",
             "distribute_duty_source_code": "na", "modify_duty_rename": "na",
             "modify_duty_attribution": "na", "modify_duty_modification_notice": "na",
             "modify_duty_notice": "na", "modify_duty_source_code": "na",
             "derive_duty_rename": "na", "derive_duty_attribution": "na",
             "derive_duty_modification_notice": "na", "derive_duty_notice": "na",
             "derive_duty_source_code": "na",
             "change_license_duty_compliant_license": "na"},
 "license_wide_duties": {"share_alike": "na"}}

target values are yes or no; actions are permitted, na or prohibited; duties are required or na.

Response: the SPARQL-JSON envelope, with ids normalised from http: to https: and sorted by title.

curl -s -X POST https://api.dalicc.net/licenselibrary/facetedsearch \
  -H 'Content-Type: application/json' \
  -d '{"target":{"creativework":"yes","dataset":"yes","software":"yes"},
       "actions":{"reproduce":"na","distribute":"na","modify":"na","derive":"na",
                  "commercial_use":"permitted","charge_distribution_fee":"na","change_license":"na"},
       "duties":{"distribute_duty_attribution":"required","distribute_duty_notice":"na",
                 "distribute_duty_source_code":"na","modify_duty_rename":"na",
                 "modify_duty_attribution":"na","modify_duty_modification_notice":"na",
                 "modify_duty_notice":"na","modify_duty_source_code":"na",
                 "derive_duty_rename":"na","derive_duty_attribution":"na",
                 "derive_duty_modification_notice":"na","derive_duty_notice":"na",
                 "derive_duty_source_code":"na","change_license_duty_compliant_license":"na"},
       "license_wide_duties":{"share_alike":"na"}}'

2.4 POST /licenselibrary/composer

Compose a license from a document you send and get its permanent address back.

Authentication. A personal API token as Authorization: Bearer dalicc_.... Without one the answer is 401.

Body. Either the composer document as JSON (Content-Type: application/json) or one odrl:Set as text/turtle. The JSON schema is ComposerInput in /openapi.json; the fields that matter most are title (required), creator, publisher, licensor, licensee, validity, start_date, end_date, validity_months, region, country, licensed_under, targets, permissions[].action with permissions[].duties, prohibitions, duties, spdx_id, license_version, source, legalcode, attribution_name, warranty_disclaimer, warranty_or_liability_acceptance, liability_limitation, additional_clauses, promotion_specification and license_text. Actions come from GET /licenselibrary/actions; anything else is 422. A Turtle body must contain exactly one odrl:Set and only absolute IRIs, and any identifier in it is ignored: DALICC always mints a fresh one.

license_text is the whole license in words, up to 60,000 characters, written as dalicc:licenseText. The five clause fields above it quote one clause each; this one holds the document. It is optional and additive: a body that leaves it out produces exactly the license it always did.

The provenance fields are written with the predicates the curated records use: publisher as dct:publisher, spdx_id as spdx:licenseId, license_version as dalicc:licenseVersion, legalcode as cc:legalcode, source as dct:source and alternatives as one dct:alternative per name. legalcode and source must be absolute http(s) URLs.

The expert mode of the website's composer changes nothing here: it is a second view of this same model, so permissions[].action with permissions[].duties, prohibitions and duties are exactly what its statement tree holds.

Parameter. publish (query, bool, default true). true validates, runs the consistency check and publishes. false stores a private draft in your own graph instead, to be finished at /license-composer?draft=<id> on the website.

Confirming a publication. Publishing is permanent and public: the model and the creator name in it are readable by everybody under CC BY 4.0. A license has to say who granted it, so the name stays on the document even after the account that published it is closed, and publishing cannot be undone. What can be done is marking the license withdrawn, with POST /licenselibrary/mine/{id}/deprecate: its address keeps working, its terms stay readable and its page says from which date the publisher no longer stands behind them.

The JSON body may carry publish_consent (bool) to confirm that. It defaults to true, so a client written before the field existed behaves exactly as it did; false answers 422 instead of publishing. It is ignored with publish=false, which writes only a private draft, and a Turtle body has nowhere to put it and is taken as a confirmation. The website asks the same thing with a tick box next to the Publish button.

Making the call is the consent, and it is recorded as one. Every publication writes an audit entry naming the license, the account, the fact of the consent and the version of the privacy notice that was current when it was given; the site prints that version at the end of dalicc.net/privacy. A caller that publishes on somebody else's behalf is telling us that person agreed, so tell them what the notice says about a published name before you do.

Status Body
201 {"id", "uri", "status": "published"\|"draft", "version", "conflicts": []}
409 the same shape with status: "rejected" and a non-empty conflicts list
422 {"detail": "..."} for a body that cannot be validated
401 no valid API token
429 the composer rate limit, 10 per minute per client address

The license belongs to the account behind the token: it appears under My licenses on the website and in GET /licenselibrary/mine, and its document names the owner and the creator by profile IRI (https://dalicc.net/users/<id>), never by e-mail address. A composed license is unlisted: it appears in no list, no search result and no suggestion, so keep the identifier.

# publish a license
curl -s -X POST 'https://api.dalicc.net/licenselibrary/composer' \
  -H "Authorization: Bearer $DALICC_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{
        "title": "Terms of use for the sample dataset",
        "licensor": "A Company",
        "targets": ["dataset"],
        "permissions": [
          {"action": "odrl:distribute", "duties": ["cc:Attribution"]},
          {"action": "odrl:reproduce", "duties": []}
        ],
        "prohibitions": ["dalicc:promote"],
        "duties": ["cc:ShareAlike"]
      }'
# {"id":"kQ3...","uri":"https://dalicc.net/licenselibrary/kQ3...","status":"published",
#  "version":1,"conflicts":[]}

# keep it as a draft instead
curl -s -X POST 'https://api.dalicc.net/licenselibrary/composer?publish=false' \
  -H "Authorization: Bearer $DALICC_TOKEN" \
  -H 'Content-Type: application/json' -d @license.json

# send RDF
curl -s -X POST 'https://api.dalicc.net/licenselibrary/composer' \
  -H "Authorization: Bearer $DALICC_TOKEN" \
  -H 'Content-Type: text/turtle' --data-binary @license.ttl

A 409 looks like this, and nothing was written:

{"id": "", "uri": "", "status": "rejected", "version": 0,
 "conflicts": [{"kind": "direct", "action_1": "http://www.w3.org/ns/odrl/2/distribute",
                "action_2": "http://www.w3.org/ns/odrl/2/distribute",
                "label_1": "Distribute", "label_2": "Distribute",
                "reason": "Distribute is permitted and prohibited at the same time."}],
 "detail": "The license contains contradicting statements."}

2.5 GET /dependencygraph/list

Every statement of the dependency graph the deployment reasons with, 46 of them today. No required parameters; one optional, additive graph (see section 5).

{"dependency_graph_statements": [
  {"subject":   {"type": "uri", "value": "http://www.w3.org/ns/odrl/2/modify"},
   "predicate": {"type": "uri", "value": "http://www.w3.org/ns/odrl/2/includedIn"},
   "object":    {"type": "uri", "value": "http://www.w3.org/ns/odrl/2/derive"}}]}

Predicates in use: odrl:includedIn (24), owl:sameAs (14), odrl:implies (7) and dalicc:contradicts (1). The graph is versioned data, so expect its contents to grow between releases while the shape of the answer stays the same. See DATA.md.

A graph also carries default rules, which say what applies to an action a license is silent about. They are a different kind of statement and this endpoint does not return them: its answer is the axioms and has always been. The additive GET /dependencygraph/rules returns the rules; see section 5.

2.6 POST /compatibilitycheck/

Can these licenses be combined? The trailing slash is part of the contract.

Body:

{"licenses": ["https://dalicc.net/licenselibrary/Apache-2.0",
              "https://dalicc.net/licenselibrary/MIT"]}

The identifier quirk. License references are full IRIs here, while /licenselibrary/license/{id} takes a bare id. That asymmetry is load-bearing and preserved.

Response:

{"conflicting_statements": {
  "direct": {
    "0": {"statement_1": ["https://dalicc.net/licenselibrary/Apache-2.0",
                          "http://www.w3.org/ns/odrl/2/permission",
                          "https://dalicc.net/ns#ChangeLicense"],
          "statement_2": ["https://dalicc.net/licenselibrary/GPL-3.0-only",
                          "http://www.w3.org/ns/odrl/2/prohibition",
                          "https://dalicc.net/ns#ChangeLicense"],
          "reason": "Direct permission-prohibition conflict."}},
  "derived": {}}}

The envelope quirk. The keys of direct and derived are stringified integers inside a JSON object, not an array, and an empty result is {}, not []. A derived entry has the same shape with a longer reason naming the dependency-graph statement it came from.

The reason of a direct entry says which reading produced it:

reason opens with Reading
Direct permission-prohibition conflict. one license permits an act the other prohibits
Direct duty-prohibition conflict. an act is required as a duty and prohibited
Direct conflict. The action is prohibited, so the duties attached to it can never apply. a duty hangs on a prohibition of the same license
Direct conflict. Share alike is required for the whole work while changing the license is permitted ... the work could leave the license that requires it to stay
Direct conflict. Both licenses require the whole work, or every work derived from it, to stay under themselves ... two licenses each keep the whole work under themselves and no later-version option or compatibility clause links them; both statements read [license, odrl:duty, cc:ShareAlike], and the pair is reported once
Direct restriction. Both licenses require the whole work to stay under themselves, and the first names the second ... the same pair, but the first license names the second with dalicc:compatibleWith, so the combined work has to be released under the second; statement_1 is [license, dalicc:compatibleWith, other license], statement_2 is [other license, odrl:duty, cc:ShareAlike], and there is one entry per direction the records state

The last reading is new (change 55 in docs/BACKWARD_COMPATIBILITY.md): the shape is unchanged, and a request for GPL-2.0-only and GPL-3.0-only, the ODbL and CC-BY-SA-4.0, or BUSL-1.1 and GPL-3.0-only now answers with one direct entry where it answered with none. docs/DATA.md says what links two such licenses. The restriction reading replaces the conflict for a pair whose records name one another (change 59): LGPL-3.0-only with GPL-3.0-only, CC-BY-SA-4.0 with GPL-3.0-only and AGPL-3.0-only with GPL-3.0-only answer with it once the records carry dalicc:compatibleWith. The operation knows no target license; a client that has one compares it with the object of statement_1.

Not every entry means the licenses cannot be combined. A permission against a prohibition (direct or derived) is a restriction on the combined work: MIT permits changing the license and GPL-3.0-only forbids it, so the combined work may not change its license, and MIT code can still go into a GPL-3.0 work. The License Compatibility Checker and the GitHub License Compatibility Checker sort the entries this way (see docs/USERS.md); the operation itself reports every entry as it always has.

X-DALICC-Compat: 2 is an opt-in request header. It guarantees the normalised empty object in the one residual case where the reasoner reaches no conclusion at all. The ordinary no-conflict case already returns the empty object.

Status: 200 with the verdict; 422 for a malformed IRI; 502 when the reasoner errors, is unreachable or answers with something that is not JSON; 504 when it does not answer within the deployment's budget for one call, 60 seconds by default.

The optional, additive dependency_graph body field is in section 5.

curl -s -X POST https://api.dalicc.net/compatibilitycheck/ \
  -H 'Content-Type: application/json' \
  -d '{"licenses":["https://dalicc.net/licenselibrary/Apache-2.0",
                   "https://dalicc.net/licenselibrary/GPL-3.0-only"]}'

2.7 to 2.10 GET /githublicensechecker/*

Four operations, two shapes of the same two questions.

Path Parameters
/githublicensechecker/dependencies/{owner}/{name} path
/githublicensechecker/dependencies?github_url= query
/githublicensechecker/check/{owner}/{name} path
/githublicensechecker/check?github_url= query

github_url accepts https://github.com/owner/name, github.com/owner/name and the bare owner/name; a trailing .git is stripped. Any other host is 400.

/dependencies passes the libraries.io document through verbatim. /check maps each normalised dependency license onto a DALICC license and compatibility-checks the resulting set:

{"dependencies": [{"name": "requests", "license": "Apache-2.0",
                   "dalicc_id": "https://dalicc.net/licenselibrary/Apache-2.0"}],
 "name": "psf/requests", "language": "Python",
 "compatibilitycheck": {"conflicting_statements": {"direct": {}, "derived": {}}}}

dalicc_id is the full DALICC IRI, or null when the library does not know that license. The license libraries.io reports is read as an SPDX identifier first, through the SPDX mapping of the library with its aliases, so MPL-2.0 resolves to MozillaPublicLicenseVersion20, ISC to IscLicense and the deprecated LGPL-2.1 to LGPL-2.1-only; only a value the mapping does not know is tried as a DALICC identifier. Each value is looked up once per request. With fewer than two resolvable licenses the reasoner is not asked at all and the empty verdict is returned.

Both /check operations take an optional target, the license the combined work would carry, as a DALICC identifier or its full URI. Given, the compatibility check runs over the dependency licenses and that license, so one dependency license plus a target is already two licenses to check. The answer then carries two more keys and nothing else changes: target is {"id", "uri", "title"}, and target_conflicts repeats the conflicts in which the target license is one of the two sides, direct ones first. Without the parameter the answer is byte for byte what it was.

curl -s 'https://api.dalicc.net/githublicensechecker/check/psf/requests?target=GPL-3.0-only'
{"target": {"id": "GPL-3.0-only", "uri": "https://dalicc.net/licenselibrary/GPL-3.0-only",
            "title": "GNU General Public License 3"},
 "target_conflicts": [{"statement_1": ["https://dalicc.net/licenselibrary/Apache-2.0",
                                       "http://www.w3.org/ns/odrl/2/permission",
                                       "https://dalicc.net/ns#ChangeLicense"],
                       "statement_2": ["https://dalicc.net/licenselibrary/GPL-3.0-only",
                                       "http://www.w3.org/ns/odrl/2/prohibition",
                                       "https://dalicc.net/ns#ChangeLicense"],
                       "reason": "Direct permission-prohibition conflict."}]}

A target that is not a license identifier is 422; one the library does not know is 404.

Status: 503 when the deployment has no libraries.io key configured, with a message saying so; 404 for a repository libraries.io does not know; 502 for an upstream error or an unexpected payload; 504 on timeout; 400 for a non-GitHub URL.

curl -s https://api.dalicc.net/githublicensechecker/check/psf/requests
curl -s 'https://api.dalicc.net/githublicensechecker/check?github_url=https://github.com/psf/requests'

3. Additive API endpoints

GET /healthz

Reachability of the triple store and the reasoner. 200 with {"status": "ok", "checks": {...}} when both answer, 503 with {"status": "degraded", ...} otherwise. Used by the container healthcheck and suitable for external monitoring.

What checks holds depends on who asks. An anonymous caller gets ok per service and the reasoner's status codes, and nothing that names an internal address:

{"status": "degraded",
 "checks": {"virtuoso": {"ok": false},
            "reasoner": {"ok": true, "status": 200, "reasoner_status": "degraded"}}}

An administrator's session or a request with an administrator's API token (Authorization: Bearer dalicc_...) also gets the store endpoint, the reasoner url and, for a failed check, the error text in detail. The token of any other account gets the anonymous answer: an API token is issued to every member who asks for one, so it is no proof that the caller runs the service. These three keys used to be in every answer; they were taken out of the anonymous one because they told any visitor the internal addresses of the service and the text of its errors. The status code, status and ok are unchanged, so a probe that reads those keeps working. A failed check is also logged at WARNING with the address and the error text.

GET /licenselibrary/compare?ids=MIT,Apache-2.0

Side by side comparison of 2 to 20 licenses; outside that range is 422, and so is a malformed id. Returns {"ids": [], "licenses": [], "rows": [], "unknown_ids": [], "groups": []}: one vector per license (title, asset targets, permissions with their duties, prohibitions, license-wide duties, and terms), a flat rows matrix, and groups mirroring the License Comparator layout with a differs flag per row.

terms holds the policy-quality and annotation properties a record states about itself (dalicc:terminatesOnBreach, dalicc:curePeriod, dalicc:orLaterVersionOption, dalicc:governingLaw, dct:language and the rest), as property IRI to raw value. They appear in groups as a Further terms section.

GET /licenselibrary/actions

The controlled action vocabulary behind every permission, prohibition and duty:

{"actions": [{"iri": "...", "curie": "cc:Attribution", "label": "...",
              "description": "...", "rule_action": true, "duty_action": true,
              "group": "..."}],
 "asset_types": [{"iri": "...", "label": "..."}]}
Parameter Type Default Effect
duties_only bool false Only terms usable as an odrl:Duty action
include_reasoning_terms bool false Adds the terms the composer never offers: those that occur only in the dependency graph, and documented but non-composable ones

The two combine, so both together return every term that may act as a duty.

POST /licenselibrary/consistencycheck

Checks one license for contradictions between its own statements, reasoning over the dependency graph. The body is either a composer document or {"license": "<identifier or DALICC URI>"}. Optional query parameter dependency_graph selects the axioms; see section 5.

Returns {"consistent": bool, "conflicts": [{"kind", "reason", "action_1", "action_2", "label_1", "label_2", "statements", "chain", "origin_1", "origin_2", "rule_1", "rule_2"}], "defaults": [...]}. 422 for a URI outside the DALICC library, 404 for an unknown id, 503 when the store is down.

statements is the list of statements of the license that clash, each written as its rule and its action, and chain is the step of the dependency graph that connects them, empty for a direct conflict. Both were added later; the six keys before them are unchanged.

origin_1 and origin_2 say where each side of the conflict comes from: https://dalicc.net/ns#FromText for a statement the license makes, and https://dalicc.net/ns#FromDefaultRule for one a default rule of the graph supplied for an act the license is silent about. rule_1 and rule_2 name that rule and are empty otherwise. All four are additive and always present.

{"kind": "derived",
 "reason": "Distribute is permitted while Use is prohibited, and both cover Distribute. Distribute is included in Use.",
 "action_1": "http://www.w3.org/ns/odrl/2/distribute",
 "action_2": "http://www.w3.org/ns/odrl/2/use",
 "label_1": "Distribute", "label_2": "Use",
 "statements": ["Permission: Distribute", "Prohibition: Use"],
 "chain": "Distribute is included in Use.",
 "origin_1": "https://dalicc.net/ns#FromText",
 "origin_2": "https://dalicc.net/ns#FromText",
 "rule_1": "", "rule_2": "",
 "rule_status_1": "", "rule_status_2": ""}

rule_status_1 and rule_status_2 are additive (2026-09-24): adopted or proposed for a side a default rule supplied, the status of that rule, and empty for a side that comes from the text. The site shows the same word beside the finding: "by default rule (proposed)".

defaults is the additive array of what the default rules of the graph say about this license: one entry per statement a rule supplied because the license is silent, and one per finding a rule reported about a statement the license does make. Nothing in it is legal advice. rule_status (additive, 2026-09-24) is adopted or proposed, the status of the rule that supplied the entry, and explanation (additive, later the same day) is the rule's dalicc:ruleExplanation: two or three plain sentences on what the rule does to a license that is silent, why the law leads there, and what a person combining licenses will notice. It is empty for a rule that carries none. contributor and date_accepted (additive) are the rule's dct:contributor and dct:dateAccepted: who adopted it, in the words the graph gives, and the day, YYYY-MM-DD. Both are empty for a rule that is only proposed.

{"kind": "prohibition",
 "action": "https://dalicc.net/ns#promote",
 "action_label": "Promote",
 "origin": "https://dalicc.net/ns#FromDefaultRule",
 "rule": "https://dalicc.net/dependencygraph/rules/endorsement-worldwide",
 "outcome": "https://dalicc.net/ns#NotGrantedByDefault",
 "outcome_label": "Not granted by default",
 "jurisdiction": "https://dalicc.net/ns#worldwide",
 "jurisdiction_label": "Worldwide",
 "basis": "Trademark and name rights are separate from copyright: Regulation (EU) 2017/1001 article 9 ...",
 "reason": "This license says nothing about Promote, and the graph applies this reading in all jurisdictions it covers: it is not permitted unless the license permits it.",
 "label": "",
 "rule_status": "adopted",
 "explanation": "A license that says nothing about endorsement is read as not allowing it: ...",
 "contributor": "Giray Havur, for the association's review, adopted this rule on 2026-09-23",
 "date_accepted": "2026-09-23"}

kind is permission, prohibition, duty or finding.

GET /licenselibrary/mine

Every license of the account behind the API token, owned and shared:

{"licenses": [{"id": "...", "uri": "...", "title": "...", "status": "published",
               "version": 1, "visibility": "private", "role": "owner",
               "created_at": "...", "published_at": "...",
               "replaces": "", "replaced_by": ""}]}

status is draft, published or deprecated; role is owner, editor or viewer. A license composed anonymously belongs to nobody and never appears here. 401 without a token.

curl -s https://api.dalicc.net/licenselibrary/mine \
  -H "Authorization: Bearer $DALICC_TOKEN"

GET /licenselibrary/mine/{license_id}/revisions

Every saved state of one of your licenses, newest first. A draft is rewritten in place on every save, so DALICC keeps a snapshot of each one:

{
  "id": "Xq3f...",
  "uri": "https://dalicc.net/licenselibrary/Xq3f...",
  "current": 3,
  "revisions": [
    {"revision": 3, "kind": "publish", "saved_at": "2026-09-15T11:02:14",
     "actor": "Mia Member", "actor_role": "owner", "note": "Published as version 1.",
     "url": "/my/licenses/Xq3f.../revisions/3"},
    {"revision": 1, "kind": "draft", "saved_at": "2026-09-15T10:41:37",
     "actor": "Mia Member", "actor_role": "owner",
     "note": "Saved in the License Composer.",
     "url": "/my/licenses/Xq3f.../revisions/1"}
  ]
}

kind is draft (an ordinary save), publish (the state a version was published in) or restore. actor_role is owner, editor or admin. actor is a display name, never an e-mail address.

The owner, an editor and an administrator may read it; a viewer may not, because a revision list says as much about the work as the document does. 403 for a viewer, 404 when no license of yours has that identifier, 401 without a token. The document of one revision is at /my/licenses/{id}/revisions/{r}/download, behind a session rather than a token.

POST /licenselibrary/mine/{license_id}/deprecate

Marks one of your published licenses deprecated. Optional query parameters replaced_by (the identifier of its successor) and reason. Nothing is deleted: the document keeps resolving at its own address and gains owl:deprecated true, dalicc:deprecatedOn and, when a successor is named, dct:isReplacedBy.

200 with the {"id", "uri", "status", "version", "conflicts"} shape; 403 for a license you do not own, 404 for one you cannot see, 422 for a malformed successor or a license named as its own successor.

curl -s -X POST \
  "https://api.dalicc.net/licenselibrary/mine/$OLD/deprecate?replaced_by=$NEW" \
  -H "Authorization: Bearer $DALICC_TOKEN"

GET /licenselibrary/spdx/{spdx_id}

Resolves an SPDX license list identifier to the DALICC record that declares it: {"spdx_id", "id", "uri"}. The lookup is case-insensitive, so mit works. 404 when nothing resolves to a record, 422 for a value outside [A-Za-z0-9.+ -]{1,160}.

276 of the 581 records carry an spdx:licenseId; most jurisdiction ports have none, and that absence is deliberate.

Three kinds of value resolve:

  • a plain identifier, Apache-2.0;
  • an expression with an exception, GPL-2.0-only WITH Classpath-exception-2.0, which 18 records declare. The space may be percent-encoded (%20) or written as a +; both reach the same record;
  • a deprecated identifier or a + form, GPL-2.0, GPL-2.0+, LGPL-2.1+, BSD-2-Clause-FreeBSD, bzip2-1.0.5, which GitHub and most package managers still report. The aliases table of the mapping document maps each of the 35 of them onto the identifier that replaced it, and the answer resolves to that record. Nine of the 35 name an identifier the library does not hold and still answer 404.

spdx_id in the answer is always the identifier the record itself declares, not the one in the request, so a lookup for GPL-2.0 answers GPL-2.0-only.

curl -s https://api.dalicc.net/licenselibrary/spdx/Apache-2.0
# {"spdx_id":"Apache-2.0","id":"Apache-2.0","uri":"https://dalicc.net/licenselibrary/Apache-2.0"}
curl -s 'https://api.dalicc.net/licenselibrary/spdx/GPL-2.0%2B'
# {"spdx_id":"GPL-2.0-or-later","id":"GPL-2.0-or-later","uri":"https://dalicc.net/licenselibrary/GPL-2.0-or-later"}
curl -s 'https://api.dalicc.net/licenselibrary/spdx/GPL-2.0-only%20WITH%20Classpath-exception-2.0'
# {"spdx_id":"GPL-2.0-only WITH Classpath-exception-2.0","id":"GPL-2.0-only-with-Classpath-exception-2.0", ...}

GET /licenselibrary/spdx-mapping

The whole mapping, both directions: dalicc_to_spdx (identifier to SPDX id), spdx_to_dalicc (SPDX id to a list of identifiers, because one SPDX id could describe more than one record), aliases (a deprecated SPDX identifier or a + form to the identifier that replaced it, 35 rows), aliases_resolvable (how many of those rows reach a record today, 26), license_total (581), mapped (276) and a comment naming what generated the file. 404 when the generated file is missing from the deployment.

GET /licenselibrary/review/{license_id}

The content-review record of one license, as JSON: the verdict, the summary, the ten-point findings with their severity and whether each was applied to the record or only proposed, the family, and the parent record when the license is a jurisdiction port. The format is documented in licensedata/reviews/README.md and the outcome in LICENSE_REVIEW.md.

404 when a record has no review file, 422 for a malformed identifier.

curl -s https://api.dalicc.net/licenselibrary/review/MIT | jq '.verdict, (.findings | length)'

The model history

Every curated model keeps its previous versions and says why each statement changed. How a version comes about and what a change entry records is in DATA.md.

GET /licenselibrary/license/{license_id}/versions

{
  "id": "Apache-2.0",
  "title": "Apache License, Version 2.0",
  "current": 2,
  "versions": [
    {"version": 2, "date": "2026-09-24", "summary": "...",
     "current": true,  "url": "/license-library/Apache-2.0",
     "api_url": "/licenselibrary/license/Apache-2.0"},
    {"version": 1, "date": "", "summary": "",
     "current": false, "url": "/license-library/Apache-2.0/versions/1",
     "api_url": "/licenselibrary/license/Apache-2.0/versions/1"}
  ]
}

Newest first. 404 for an unknown identifier, 422 for a malformed one. A record that has never been corrected lists one version. A version an administrator published on the server appears in the same sequence as one that came from a commit.

GET /licenselibrary/{license_id}/versions

The address every curated record states as its dalicc:versionHistory, for example <https://dalicc.net/licenselibrary/MIT/versions>. It negotiates like the record's own URI and never answers a body of its own:

Request Response
Accept ranks text/html first 303 to /license-library/{id}#history-heading, the History section of the record page
anything else 303 to /licenselibrary/license/{id}/versions, the version list above

422 for a malformed identifier; an unknown one arrives at the version list and gets its 404 there. The answer carries Vary: Accept.

GET /licenselibrary/license/{license_id}/versions/{n}

One version of the model, in the formats of GET /licenselibrary/license/{license_id}: format=json-ld (the default), ttl or rdf-xml, Accept negotiation for the raw serialisation, and download=true (the file is named <id>-v<n>.<ext>). Asking for the current version serves the live record, so the two answers are identical. 404 for a version that was never published.

format=text works on an archived version too and answers the license text of that version as text/plain: the text the record carried then where it carries one, and a reading of the model of that version everywhere else, so it differs from the current text wherever the model was corrected. It asks no provider and spends no allowance, exactly as on the live record (section 9), and a download is named <id>-v<n>.txt.

curl -s 'https://api.dalicc.net/licenselibrary/license/Apache-2.0/versions/1?format=ttl'
curl -s -H 'Accept: text/turtle' \
     https://api.dalicc.net/licenselibrary/license/Apache-2.0/versions/1
curl -s 'https://api.dalicc.net/licenselibrary/license/Apache-2.0/versions/1?format=text'

GET /licenselibrary/license/{license_id}/changelog

One entry per version from 2 upwards, each with its date, its reviewer, a one-sentence summary and the individual changes.

{"id": "Apache-2.0", "title": "...", "current_version": 2,
 "entries": [{"version": 2, "date": "2026-09-24", "reviewer": "Giray Havur",
              "summary": "...",
              "changes": [{"action": "added", "statement": "permission odrl:grantUse",
                           "previous": "", "reason": "Section 2 grants ...",
                           "source": "review-finding 3/odrl:permission"}]}]}

action is added, removed or changed; previous is filled for changed. source says where the reason comes from: review-finding <rubric>/<field>, consolidation-decision <n>, review-state, ports-metadata, model-history or manual. 404 when a record has never been corrected.

GET /licenselibrary/history?skip=&limit=

The index of every record that has a history, most recently changed first. skip defaults to 0, limit to 50 with a ceiling of 500.

{"total": 581, "skip": 0, "limit": 50,
 "records": [{"id": "WTFPL", "title": "WTFPL", "version": 2,
              "last_change": "2026-09-24", "summary": "...",
              "url": "/license-library/WTFPL"}]}

total is the number of records that have a history, not the size of the library: All 581 records have been changed since they were first written, so today the index lists every one; a record that never changed is at version 1 and has nothing to list. The numbers move with every correction, so read total rather than these.

GET /dependencygraph/versions, /versions/{n}, /changelog

The same three for the graph the deployment reasons with, dg_default. A version is served as text/turtle, because the graph is a Turtle document and there is nothing to negotiate. The repository ships two versions of every graph and of the vocabulary: version 1, as published before the 2026 work, and version 2, into which every later version of that work was merged before the first deployment. So /dependencygraph/versions/3 and above answer 404 until a deployment publishes a new version; the change log of version 2 lists every merged change with its reason.

curl -s https://api.dalicc.net/dependencygraph/versions
{"id": "dg_default", "title": "DALICC deontic dependency graph", "current": 2,
 "versions": [
   {"version": 2, "date": "2026-09-24", "summary": "...", "current": true,
    "url": "/dependency-graph", "api_url": "/dependency-graph.ttl"},
   {"version": 1, "date": "", "summary": "...", "current": false,
    "url": "/dependencygraph/versions/1", "api_url": "/dependencygraph/versions/1"}]}

/changelog has the shape of the license change log (id, title, current_version, entries with version, date, reviewer, summary and changes); a change of a rule reads "statement": "rule dalicc:promote dalicc:NotGrantedByDefault in dalicc:worldwide".

Request Status
/versions 200
/versions/{n} for a version that exists 200, text/turtle; the current one is licensedata/dependencygraph/dg_default.ttl, an earlier one the archived file or the runtime copy
/versions/0 404 Unknown version: 0
/versions/99 404 No version 99 of the dependency graph
/versions/x 422, the list shape
/changelog 200, or 404 The dependency graph has no change log.

The same routes for any graph by its identifier are in section 5.

GET /ns/versions, /ns/versions/{n}, /ns/changelog

The same three for the DALICC vocabulary, with a version served as text/turtle and its comments intact. A vocabulary term called versions cannot shadow them. Not in the OpenAPI document, like the rest of /ns.

{"id": "dalicc-ns", "title": "DALICC vocabulary", "current": 2,
 "versions": [
   {"version": 2, "date": "2026-09-24", "summary": "...", "current": true,
    "url": "/ns", "api_url": "/ns?format=ttl"},
   {"version": 1, "date": "", "summary": "...", "current": false,
    "url": "/ns/versions/1", "api_url": "/ns/versions/1"}]}
Request Status
/ns/versions 200
/ns/versions/{n} for a version that exists 200, text/turtle, with a weak ETag (Caching)
/ns/versions/0 404 Unknown version: 0
/ns/versions/99 404 No version 99 of the DALICC vocabulary
/ns/changelog 200, or 404 when the vocabulary has no change log

4. Endpoints behind the website

Not in the OpenAPI document, but stable HTTP endpoints that return JSON or HTML fragments.

Endpoint Returns
GET /license-library/suggest?q= [{"id", "title", "url", "port_of", "parent_title"}] for the search box autocomplete. Parents come before their jurisdiction ports; port_of is empty for a parent and holds the parent identifier for a port. A keyword that matches nothing answers with up to three listed titles closest to it, in the same shape
GET /license-library/{id}/license.json?asset=&attribution= The JSON sidecar of the License Annotator, as a download: {"license-uri", "license-title", "spdx-id", "attribution"?, "asset", "generated-by"}. attribution is present only when the license carries an attribution duty; an asset that is not an absolute http(s) URL is ignored
GET /license-library/{id}/badge.svg A small flat SVG badge for a README, image/svg+xml with Cache-Control: public, max-age=86400
GET /license-compatibility-checker/licenses [{"uri", "id", "title", "port_of"}]. Parents first, then the jurisdiction ports
GET /license-compatibility-checker/bundle?ids= The bundle list as an HTML fragment
GET /license-compatibility-checker/license?id= The target-license summary as an HTML fragment
POST /license-compatibility-checker/check Bundle conflict check, HTML (a full page or a fragment)
POST /license-compatibility-checker/target The licensing statement for a target license, HTML
POST /license-compatibility-checker/statement statements.txt as a download
GET /dependency-graph.ttl The current core dependency graph, licensedata/dependencygraph/dg_default.ttl, as a text/turtle attachment named dalicc-dependency-graph.ttl. The same triples as GET /dependencygraph/versions/{current}
GET /ns, GET /ns/{term} The vocabulary as HTML or, under negotiation, RDF
GET /users/{id} A publisher's public profile as HTML or, under negotiation, RDF. See section 10
GET /sitemap.xml, GET /robots.txt, GET /favicon.ico As named

The legacy /web/* fragments

Server-rendered pages that predate the site. Every URL and every form field name of the pages that remain is preserved: GET /web/list, /web/wplist, /web/search, /web/wpsearch, POST /web/searchresults, POST /web/wpsearchresults, GET /web/license/{license_id}. GET /web/ redirects to / and GET /web/compose to /license-composer. All of them only read.

/web/wpcomposer is retired. It published a person's name into the public graph with no consent box and no link to the privacy notice, and the composer at /license-composer states every term it could state. GET answers 301 to /license-composer. POST answers 410 with a JSON body naming the composer and this API:

{
  "detail": "The form at /web/wpcomposer has been retired. Compose a license at /license-composer, which asks the same questions and states the same terms, or publish one through POST /licenselibrary/composer.",
  "composer": "/license-composer"
}

A script that published through that form moves to POST /licenselibrary/composer (section 2.4), which takes the same license as JSON or Turtle, needs a personal API token, and records the publication.

The two list routes always render the whole library: the page has no pager, so skip and limit are accepted and deliberately not applied.


5. Dependency graphs

A dependency graph is the set of axioms the reasoner draws its conclusions from. It is a first-class object somebody owns, so it can be listed, created, versioned and chosen per request. GET /dependencygraph/list is the documented operation of section 2.5; everything below is additive.

The rule that matters. A caller that chooses nothing gets exactly the answer it always got. No response on this router grows a key, and no request body field is required. Choosing a graph is opt-in, per request, and access-checked.

A graph is named by identifier (dg_default) or by an IRI under https://dalicc.net/dependencygraph/ or https://dalicc.net/users/. A published core graph is readable by anybody; a graph belonging to an account needs that account's session cookie or API token, else 403. An unknown identifier is 404, an IRI outside the two DALICC spaces 422.

GET /dependencygraph/list?graph=<id>

Without graph, the configured default graph, byte for byte as before. With it, the same dump of another graph, in the same {"dependency_graph_statements": [...]} shape. Every published graph is complete, so the answer is the graph's own axioms and nothing is read through a link: ?graph=dg_eu answers with the 46 axioms dg_eu holds, which are the core graph's, because the European Union graph removes none of them.

GET /dependencygraph/graphs

{"graphs": [{"id": "dg_default", "title": "...", "description": "...",
             "kind": "core", "status": "published", "version": 3,
             "iri": "...", "axioms": 46, "default": true, "role": "",
             "based_on": "", "based_on_version": 0, "added_rules": 0,
             "removed_axioms": 0, "replaced_rules": 0,
             "group": "core", "summary": ""},
            {"id": "dg_eu", "...": "...", "version": 3, "axioms": 46,
             "based_on": "dg_default", "based_on_version": 3, "added_rules": 9,
             "removed_axioms": 0, "replaced_rules": 0,
             "group": "jurisdiction",
             "summary": "the core graph plus 9 default rules"}]}

axioms counts the axioms of the graph, which are the ones the reasoner reads: every published graph is complete. The seven further keys are additive. based_on is the identifier of the graph this one was built from (dalicc:basedOnGraph, dg_default for a jurisdiction graph) and based_on_version the version it was built from, or "" and 0; added_rules, removed_axioms and replaced_rules count what the graph adds to, removes from and replaces in that graph; group is core, jurisdiction, or empty for a graph of an account; summary is the line the site shows for a jurisdiction graph, with the zeros left out. The counts come from the triple store: when it does not answer, or does not hold the graph, axioms is what the graph itself holds (usually 0) and the rest is empty. GET /dependencygraph/mine answers with the same entries.

Two keys that stood here on the development branch for one day, own_axioms and extends, were never deployed and are gone; see BACKWARD_COMPATIBILITY.md.

The published core graphs for everybody, plus the caller's own and the ones shared with them when a session or a token is sent. A graph somebody composed is never listed to anybody else. Eight core graphs ship: dg_default, which is the default for every check, and dg_eu, dg_us, dg_cn, dg_gb, dg_jp, dg_in and dg_br, complete graphs built from it that add the default rules proposed for one market each and are never a default.

GET /dependencygraph/compare?left=<id>&right=<id>

Additive. Compares two dependency graphs statement by statement: any two graphs the caller may read (a core graph, a jurisdiction graph, or a graph of an account that is the caller's own, shared with them, or published), resolved and access-checked exactly as graph is above, so a graph the caller may not read is 403 and an unknown one 404. Without left the core graph dg_default is compared; without right, the first jurisdiction graph, dg_eu.

{"left":  {"id": "dg_default", "title": "...", "version": 3, "iri": "...",
           "url": "/dependency-graph", "api_url": "/dependencygraph/list?graph=dg_default"},
 "right": {"id": "dg_eu", "title": "European Union default rules", "version": 3, "...": "..."},
 "summary": {"axioms": {"added": 0, "removed": 0, "changed": 0},
             "rules":  {"added": 9, "removed": 0, "changed": 0}},
 "axioms": {"added": [], "removed": [], "changed": []},
 "rules":  {"added": [{"iri": "...", "action": "...", "outcome": "...",
                       "jurisdiction": "...", "basis": "...", "explanation": "...",
                       "status": "https://dalicc.net/ns#Proposed",
                       "status_label": "Proposed", "...": "..."}],
            "removed": [], "changed": []},
 "removals": [],
 "same": false}

added is what the right graph states and the left one does not, removed the other way round. An axiom is changed when both graphs relate the same two actions with a different relation ({"left", "right"}); a rule is changed when both graphs have a rule for the same action (paired by address, then by action, kind and territory, then by action and kind) whose outcome, territory, basis, explanation or status differ ({"left", "right", "fields"}). A dalicc:NotWaivable rule is a different kind from the three that supply a statement and never pairs with one. removals lists the axiom removals the right graph records against the graph it is based on, with their basis and explanation. The site renders the same structure at /dependency-graph/compare, and /dependency-graph/compare?format=json answers with exactly this JSON.

GET /dependency-graph/{id}/download

A site route, not part of the API router: the Turtle of the current version of a graph (?version=<n> serves an archived one as it was stored). The file is the complete graph: its title and description, its axioms, its default rules with their basis and explanation and, for a graph built from another one, dalicc:basedOnGraph, dalicc:basedOnVersion and its axiom removals, after a provenance comment that names the graph and its version and, for a jurisdiction graph, the core version and the difference file it was built from. The layout is the one scripts/build_dependency_graphs.py writes, so dg_eu downloads with the same statements as licensedata/dependencygraph/dg_eu.ttl. The attachment is named <id>.ttl, or <id>-v<n>.ttl when ?version= is given, as a license version downloads as <id>-v<n>.<ext>.

GET /dependencygraph/rules?graph=<id>

The default rules of a graph: what applies to an action a license is silent about. Additive, and the counterpart of /list, which answers with the axioms.

{"dependency_graph": "https://dalicc.net/dependencygraph/dg_default",
 "default_rules": [
   {"iri": "https://dalicc.net/dependencygraph/rules/endorsement-worldwide",
    "action": "https://dalicc.net/ns#promote",
    "action_label": "Promote", "action_curie": "dalicc:promote",
    "action_url": "/ns#promote",
    "outcome": "https://dalicc.net/ns#NotGrantedByDefault",
    "outcome_label": "Not granted by default",
    "outcome_curie": "dalicc:NotGrantedByDefault",
    "jurisdiction": "https://dalicc.net/ns#worldwide",
    "jurisdiction_label": "Worldwide",
    "basis": "Trademark and name rights are separate from copyright: ...",
    "status": "https://dalicc.net/ns#Adopted", "status_label": "Adopted",
    "adopted": true, "date": "2026-09-23",
    "label": "Endorsement is not granted by default",
    "sentence": "The graph applies this reading in all jurisdictions it covers: Promote is not permitted unless the license permits it."}]}

A rule whose status is dalicc:Proposed is a proposal for a legal reviewer and not an answer; the graph that holds it is published all the same, which is why the status is a field of each rule and never part of a graph's title. Nothing this endpoint returns is legal advice.

GET /dependencygraph/mine

The same shape, token only: every graph of the account behind the token, drafts included, with the per-graph role.

POST /dependencygraph?publish=true&title=

Token only. The body is either JSON

{"title": "My graph", "description": "...",
 "axioms": [{"subject": "cc:Attribution", "relation": "odrl:implies", "object": "cc:Notice"}]}

or the same statements as text/turtle, in which case the title query parameter names it. IRIs and compact spellings are both accepted in all three positions.

Every statement is validated: both sides have to be actions of the DALICC vocabulary, the relation has to be odrl:includedIn, odrl:implies, owl:sameAs or dalicc:contradicts, an action may not relate to itself, and no statement may be repeated. A cycle in odrl:includedIn or odrl:implies is reported in warnings rather than refused.

201 with {"id", "uri", "iri", "status", "version", "axioms", "warnings"}. 422 when a statement cannot be validated, 401 without a token, 503 when the triple store cannot be written. publish=false keeps the graph a private draft. A published graph is unlisted, like a composed license: the identifier is the only way back to it.

GET /dependencygraph/{graph_id}/versions, /versions/{n}, /changelog

The model history of one named graph, in the shape the core graph answers with in section 3: a version list with the date and summary per version, one archived version as text/turtle, and the change log as JSON. 404 for a graph or a version that never existed, 403 for a graph the caller may not see.

The current version is the complete graph: its metadata, its axioms and its default rules, the statements GET /dependency-graph/{graph_id}/download serves. For the core graph, /dependencygraph/dg_default/versions/{current}, /dependencygraph/versions/{current}, /dependency-graph.ttl and /dependency-graph/dg_default/download carry the same triples; the bytes differ only in the comment header and the order of the statements. When the triple store cannot be reached, a graph that ships with the repository is served from its file and any other graph answers 503.

POST /compatibilitycheck/ gains "dependency_graph"

{"licenses": ["https://dalicc.net/licenselibrary/MIT", "..."],
 "dependency_graph": "dg_default"}

Optional, an identifier or an IRI, resolved and access-checked as above. Omitted, or naming the default graph, the request and the response are unchanged. When a non-default graph is used the answer carries one additive key, "dependency_graph": "<iri>", naming the graph that produced it; the conflicting_statements object is untouched.

When a default rule of the chosen graph fired, the answer carries a second additive key, "defaults", in the shape POST /licenselibrary/consistencycheck publishes, with the license each entry is about under "license" and the statement under "statement"; and every conflict then also carries origin_1, origin_2, rule_1 and rule_2. Each entry of "defaults" also carries "explanation", the plain explanation of the rule that fired, and "contributor" and "date_accepted", who adopted it and when, which the API service reads from the graph the check ran under and adds beside "rule" and "origin". None of this appears when no rule fired, so a deployment whose graph carries no rule gets the answer it has always got.

The reasoner validates the graph a second time: it has to be an absolute IRI in one of the two DALICC graph spaces, so a caller can never aim the solver at an arbitrary named graph of the triple store.

POST /licenselibrary/consistencycheck?dependency_graph=<id or IRI>

The same optional selection for the consistency check. Without it the check reasons over the configured graph.


6. Review workflow and messaging

Submissions, correction requests, threads, messages and notifications are browser pages only: there is no HTTP operation for them, documented or additive. Every one of them is a state change on behalf of a signed-in person, and the credential for that is the session cookie with its double-submit CSRF token, not a Bearer token; and the objects are conversations, with participants, read state and an e-mail side effect. How they work for the person driving them is in USERS.md.


7. Per-account API limits

Section 1 describes the per-IP limits that protect the anonymous surface. This section describes the second limiter, which applies to requests carrying a personal API token.

The rule

Every token request is counted against the account that owns the token, never against the token. Four windows apply at once and a request has to fit in all of them:

Window Default
second 5
minute 60
hour 1000
day 10000

These are the figures https://api.dalicc.net serves; a deployment may set others, and an administrator can raise or lower them for a single account. A deployment may also switch the whole thing off, as it may the per-IP limiter. Whatever the numbers are, your own account's are on /account/tokens and in the headers below.

Weights

Expensive endpoints count as more than one request:

Path Counts as
POST /compatibilitycheck/ 5
POST /licenselibrary/consistencycheck 3
POST /licenselibrary/translate 10
everything else 1

A request that is refused consumes nothing: the windows are checked before any of them is charged, so a 429 from the minute window does not eat part of your day.

A weight never makes a call impossible. The burst ceiling is five requests a second by default, and a translation weighing ten would otherwise be refused however idle the account, so the weight is capped at each window's own limit: the burst window counts such a call as five, one at a time, while the minute, the hour and the day count it as ten.

Headers

Every answer to a token request carries three headers describing the window closest to refusing the next call:

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 41
X-RateLimit-Reset: 1789567260

X-RateLimit-Reset is a UNIX timestamp in seconds (UTC), not a number of seconds to wait. Anonymous requests carry none of these headers, and neither does a 401 for an unknown token: a rate-limit answer must never tell a caller that a token exists.

429

Going over a limit is 429 with Retry-After in whole seconds and a JSON body that carries the machine-readable detail next to the sentence:

{
  "detail": "You have used your DALICC API allowance for this minute: at most 60 requests per minute. The window restarts in 19 seconds.",
  "window": "minute",
  "limit": 60,
  "remaining": 0,
  "reset_at": "2026-09-16T12:31:00Z"
}

window is second, minute, hour, day or global. The global case is the service-wide ceiling across all token traffic (600 requests a minute by default), and its detail says that the service is busy rather than that the caller is over a limit.

Read Retry-After, wait that long, and retry. X-RateLimit-Remaining lets a well behaved client pace itself without ever seeing a 429.

How exact the numbers are

The four per-account windows are counted centrally, so they hold whichever server process answers your request and they survive a restart: the documented limit is the real one. The service-wide ceiling is counted per process, so a deployment running several of them tolerates proportionally more; it is a stampede brake rather than a promise, and the reverse proxy in front of the service remains the hard edge.

Seeing your own usage

/account/tokens shows the four windows with used, limit, remaining and the seconds until each restarts, plus the last-used time of every token. It is a page behind a session rather than part of the public API, so a program reads the headers above instead.


8. The translation assistant

Two additive endpoints. They read the text of a license and propose the DALICC model for it, with the sentence of the text behind every statement. They are available only while the deployment has a provider key configured, and they publish nothing. What the proposal is, how to read it and why it is a draft rather than an answer is section 19 of USERS.md; how the feature is built is TRANSLATION.md.

POST /licenselibrary/translate

Authentication: a personal API token. The call counts as 10 requests against the account's windows (section 7) and against a separate daily quota of its own, ten runs per UTC day by default, which an administrator can change per account.

Consent is required on every call. The text is sent to an external model provider for processing; do not submit confidential texts. DALICC does not store the text: it records a job row with the size and the cost, and the draft you asked for.

Body, as JSON:

{"text": "Permission is hereby granted ...", "title": "MIT License",
 "consent": true, "save_draft": false}
curl -sS https://api.dalicc.net/licenselibrary/translate \
  -H "Authorization: Bearer $DALICC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"text": "Permission is hereby granted ...", "title": "MIT License",
       "consent": true, "save_draft": false}'

or as text/plain with the flags in the query string (consent, title, save_draft):

curl -sS "https://api.dalicc.net/licenselibrary/translate?consent=true&title=MIT%20License" \
  -H "Authorization: Bearer $DALICC_TOKEN" \
  -H "Content-Type: text/plain" \
  --data-binary @MIT.txt

The answer:

{
  "job_id": "9a5b8f3c-...",
  "prompt_version": "2026-09-16.3",
  "title": "MIT License",
  "language": "en",
  "parts": {"total": 1, "translated": [1], "failed": []},
  "license": { /* the document POST /licenselibrary/composer accepts */ },
  "statements": [
    {"kind": "permission", "term": "odrl:distribute", "iri": "...",
     "label": "Distribute", "attached_to": "",
     "evidence": "Permission is hereby granted ...", "evidence_all": ["..."],
     "confidence": 1.0, "note": "", "known_term": true}
  ],
  "unmodelled": [{"clause_quote": "...", "proposed_term": "trademarkUse", "note": "..."}],
  "warnings": ["..."],
  "conflicts": [],
  "usage": {"tokens_in": 4942, "tokens_out": 2678, "seconds": 6.8, "model": "...",
            "provider": "groq", "part_providers": {"1": "groq"}},
  "quota": {"provider_remaining_requests": 997, "provider_reset_at": "...",
            "user_remaining_today": 9, "global_remaining_today": 199}
}

usage.provider names the provider that read most of the text and usage.part_providers says which provider read which part; the service uses several and moves a run to the next one when the first is out of its allowance. Both keys are additive, and a caller that ignores them reads what it always read.

license is exactly what POST /licenselibrary/composer accepts. Every statement carries its verbatim evidence and a confidence between 0 and 1, and known_term is false for a term the DALICC vocabulary does not define, which is reported and left out of license. unmodelled holds the clauses the vocabulary cannot express. prompt_version identifies the instructions the proposal was produced with, so an old proposal can be read against them. With save_draft: true the answer also carries draft_id and draft_uri.

Status When
200 the proposal, whole or partial (parts.failed names the parts that were lost)
202 the run was started rather than answered: the text needs more than one part, or the provider's allowance has to come back first. job_id collects it
401 no valid API token
403 an administrator set this account's translation allowance to zero
413 the text is longer than the deployment accepts; it is never truncated
422 consent was not given, the text was empty, or the body could not be read
429 a quota is exhausted; Retry-After and the countdown in detail
502 the provider refused or could not be read
503 no provider key is configured on this deployment

A 429 here can come from three different quotas, and detail says which: the provider's own allowance, the account's daily quota, or the deployment's daily cap.

GET /licenselibrary/translate/jobs/{job_id}

A license that is read in more than one part takes minutes, and no proxy holds a request open that long, so POST /licenselibrary/translate answers 202 for it:

{"job_id": "9a5b8f3c-...", "status": "queued",
 "parts": {"total": 15, "translated": [], "failed": []},
 "poll_url": "/licenselibrary/translate/jobs/9a5b8f3c-...", "save_draft": false}

Ask this endpoint with the same token until it answers 200:

curl -sS https://api.dalicc.net/licenselibrary/translate/jobs/9a5b8f3c-... \
  -H "Authorization: Bearer $DALICC_TOKEN"
Status Body
202 still working: status (queued or running), part_current, parts_total, parts_done, parts_failed, with Retry-After: 5
200 the run is over: the whole proposal, exactly as a short text gets it, with status: "done"; or status: "failed" and the reason in message
401 no valid API token
404 no such run for this account

A run that failed answers 200, not an error status: the failure is the result of the run, not of the request that asked about it.

The answer carries more about where the run has got to. These keys are additive, so nothing that read the ones above has to change:

Key What it is
stage the step: planning, sending, waiting, received, merging, reviewing, done or failed
stage_since when that step began, ISO 8601 in UTC, or "" before the first one
stage_seconds how long it has lasted so far
elapsed_seconds how long the whole run has been going
part_seconds_avg the mean a part of this text has taken, 0.0 until one is over
estimated_remaining_seconds the parts that are left times that mean, null until one part is over
provider which provider is reading the run, or read it; "" before the first part goes out
provider_label that provider as a page names it, "" while there is none
events the last sixty steps, newest last, each {"t", "stage", "part", "text"}
partial what has come back so far: lines (at most two hundred entries of {"label", "quote"}, where the quote is the first eight words of the sentence a statement was read out of), statements, parts_done and failed_parts

partial is a preview, not the result: read it to show progress, and take the model itself from the 200 answer. Both lists are bounded and both are purged with the run. estimated_remaining_seconds is an estimate from what this text has cost so far, not a promise.

The 200 answer also carries comparison, which is additive: it is the curated record nearest to the proposal, with the difference between the two. It is null when the proposal states nothing that can be compared, or when no record shares a statement with it.

{
  "comparison": {
    "license_id": "Apache-2.0",
    "title": "Apache License, Version 2.0",
    "uri": "https://dalicc.net/licenselibrary/Apache-2.0",
    "match": "spdx",
    "reason": "The reading names the SPDX identifier Apache-2.0, which is this record.",
    "deprecated": false,
    "in_common": 9, "record_total": 19, "reading_total": 10, "score": 0.45,
    "only_in_reading": [{"key": "reading|duty|https://dalicc.net/ns#includeNoticeFile",
                         "side": "reading", "kind": "duty",
                         "term": "dalicc:includeNoticeFile", "iri": "...",
                         "label": "Include notice file",
                         "reading_attached": "Distribute", "record_attached": "",
                         "evidence": ["..."]}],
    "only_in_record": [/* the same shape, side "record" */],
    "in_both": [/* the same shape, side "both" */],
    "placement": [/* duties both state, hung on different permissions */]
  }
}

match is spdx, title or nearest and says how the record was found: by the SPDX identifier the assistant read, by the title against dct:title and dct:alternative, or by the largest overlap of (kind, action) pairs over the library. score is the Jaccard overlap of the two statement sets. in_common counts in_both, and record_total counts the statements of the record, which is what the website's heading reports as "n of m statements in common". placement repeats rows of in_both whose duty hangs somewhere else in the record; an empty reading_attached or record_attached means the license as a whole. The key of a row is what the website's adopt boxes send back; it is stable for a given reading and record.

?save_draft=true writes the proposal as a private draft when it is collected; it is idempotent, so a second poll returns the same draft_id rather than a second draft. Finished runs are purged a couple of hours after they end (two by default), because the proposal quotes the text it was read from.

A run that came back partial is retried by sending the same text again; the answer's parts.failed says which parts to ask for.

The Python client hides all of this: client.translate.text(..., consent=True) polls by itself and returns the finished proposal, and wait=False gives the job_id back instead.

9. License-to-Text

The other direction of section 8: a license model in, the license text out. Two endpoints, both additive.

POST /licenselibrary/narrate

Needs a personal API token. Send either a license of the library or a model you hold:

curl -s https://api.dalicc.net/licenselibrary/narrate \
  -H "Authorization: Bearer dalicc_..." \
  -H "Content-Type: application/json" \
  -d '{"license": "Apache-2.0"}'
{
  "title": "Apache License, Version 2.0",
  "preamble": "This license covers software, datasets and creative works ...",
  "sections": [
    {
      "heading": "What you may do",
      "text": "You may copy the work, change it and share it ...",
      "statements": ["odrl:distribute", "odrl:modify", "odrl:derive"]
    }
  ],
  "closing": "... This text was generated from the machine readable model of the license. It is not legal advice, and only the license itself is binding.",
  "coverage": {
    "complete": false,
    "statements": 24,
    "covered": 22,
    "missing": [{"kind": "prohibition", "term": "dalicc:promote",
                 "iri": "https://dalicc.net/ns#promote",
                 "label": "Promote", "attached_to": ""}],
    "note": "Not covered by the text above: Promote. ..."
  },
  "produced_by": "provider",
  "provider": "groq",
  "license_id": "Apache-2.0",
  "prompt_version": "2026-09-24.1",
  "names_removed": false
}
Field Meaning
license identifier or address of a license, curated or unlisted
model a license model as Turtle or JSON-LD, instead of license
without_names replace the creator, licensor and publisher names by "the licensor" before the model is sent (default false)

Send one of license or model; model wins if both are there. The sections are in the order permissions, conditions, prohibitions, further terms, and statements names the terms of the model each section covers. coverage is the service's own check of that list against the model: missing is what no section named, and note is the sentence the website shows for it.

produced_by is provider when the assistant wrote the text and fallback when it was written from the DALICC vocabulary alone, which happens when the assistant is not configured on the deployment, the daily allowance is used up or the provider did not answer. There is always a text, so a caller never has to handle "no answer": it has to read produced_by if it cares which writer produced the prose.

provider names the provider that wrote the text and is empty when the deterministic writer did; it is additive, like usage.provider on a translation.

The model is sent to an external model provider unless the answer says fallback. A curated record carries no personal data; a composed license carries the name of whoever composed it, which is what without_names is for. Nothing of the text is stored, and the call spends one of the account's daily license texts, an allowance of its own: writing a license text never takes a translation away, and running a translation never takes a license text away.

An administrator can switch License-to-Text off for the whole deployment, and can set how many license texts one account may write in a day. While it is off this endpoint answers 503 with {"detail": "License-to-Text is switched off on this deployment."}, the way an assistant without a key answers. Nothing else changes: GET /licenselibrary/license/{id}?format=text below is unaffected, because it is written from the DALICC vocabulary and asks no provider.

401 without a token, 404 for an identifier nothing resolves to, 422 for an empty body or a model that cannot be read, 503 while License-to-Text is switched off or when the license store cannot be asked.

GET /licenselibrary/license/{id}?format=text

The deterministic reading alone, as text/plain. No token, no provider call, no allowance:

curl -s "https://api.dalicc.net/licenselibrary/license/Apache-2.0?format=text"

format gains text next to json-ld, ttl and rdf-xml; the three that were there answer exactly as before. Unlike those three it is not a serialisation of the graph, so it is always text/plain rather than the historical JSON wrapper. With download=true it comes back as a .txt file. It is what Download > Text on a license page gives you.

Where the record carries a dalicc:licenseText, that text is what comes back, unchanged and on its own: it is the license rather than a reading of it. Every other record is written out from its model by the deterministic writer, as it always was. Either way no provider is asked and no allowance is spent.

The Python client has both: client.translate.narrate(id), client.translate.narrate_model(turtle) and client.licenses.get(id, format="text").


10. The public profile address

A published license names its owner and its creator by a profile IRI, https://dalicc.net/users/<id>, and never by an e-mail address. That IRI resolves.

GET /users/{id} answers with an HTML page for a browser: the publisher's name, the month the account was created in, and the licenses and dependency graphs that account has published, each one linked. An identifier nothing is recorded under is 404.

With Accept: text/turtle, application/ld+json or application/rdf+xml, or with ?format=ttl, ?format=json-ld or ?format=rdf-xml, the same address answers with a small RDF document instead. ?format= wins over Accept, because a browser sends text/html on every request including the one where somebody typed the parameter. A format value that is not one of the three serves the page.

The JSON-LD answer, by Accept or by ?format=json-ld, is compacted with the @context of the license documents and keeps every object reference as {"@id": ...}, like the negotiated JSON-LD of a license, so a JSON-LD processor reads exactly the triples of the Turtle answer. The profile has no frozen default body, so ?format=json-ld answers the same real JSON-LD.

curl -s -H 'Accept: text/turtle' 'https://dalicc.net/users/<id>'
@prefix dct: <http://purl.org/dc/terms/> .
@prefix foaf: <http://xmlns.com/foaf/0.1/> .

<https://dalicc.net/users/<id>> a foaf:Agent ;
    foaf:name "Ada Lovelace" .

<https://dalicc.net/licenselibrary/<license id>> dct:creator <https://dalicc.net/users/<id>> .

The document holds three kinds of statement and nothing else: the IRI is a foaf:Agent, it has a foaf:name, and one dct:creator statement per published license. That statement is written the way the license document writes it, with the license as the subject, so a reader that holds both documents reads one fact twice instead of two facts that disagree. Drafts are not in either answer, and neither is anything belonging to another account.

An account that was deleted keeps its address, because the licenses that name it are still published. It then answers with the neutral label the row keeps (a former member) and the same list of published work.


11. Version 2: history, releases and discovery

Version 2 lives under /v2. It holds the operations that need conventions version 1 cannot take on without breaking a client: every version of every license record, dependency graph and the vocabulary, a content hash to verify a download, named data releases, a change feed and a discovery document. Version 1 is not mirrored there and not changed by it: the ten operations of section 2 and the additive routes keep their paths, shapes and headers.

The decisions behind it, one paragraph each:

  • Version 2 hosts only the new operations. The ten frozen operations cannot change shape and the additive version 1 routes already work, so a mirror would double the tests and the documentation for no new capability. Version 2 has one envelope for lists, one error format and one negotiation rule, and it is where later operations grow.
  • Version 1 gets no new headers. No ETag and no Link were added there: two sources of the same navigation would have to agree forever, and "version 1 is byte-identical" is easiest to keep when it also holds for headers.
  • Errors are RFC 9457 problem details. One shape for validation, not found, store outage, rate limits and authentication, with a stable type per situation. Version 1 keeps its shapes.
  • Version 2 has its own OpenAPI document, /v2/openapi.json, and its own Swagger UI, /v2/docs. /openapi.json stays the version 1 document: the contract guard compares it with the live snapshot, and its info.version is 1.1.
  • Discovery is GET /v2, and GET /.well-known/api-catalog (RFC 9727) points to it. The path carries the version; there is no version header.
  • Reads are open to every origin. Version 2 answers are anonymous reads of CC BY 4.0 data, so Access-Control-Allow-Origin: * without credentials, and the headers a client needs are exposed to browsers.
  • Links are absolute paths, like url and api_url in version 1, so a deployment on any host is right without configuration. The one exception is the API catalog, whose anchor has to be absolute.

Problem details

Every error under /v2 and /.well-known/api-catalog answers Content-Type: application/problem+json, never an HTML page, whatever the request accepts:

{
  "type": "https://dalicc.net/documentation/api#problem-version-not-found",
  "title": "No such version",
  "status": 404,
  "detail": "MIT has versions 1 to 2; version 7 does not exist.",
  "instance": "/v2/licenses/MIT/versions/7",
  "request_id": "a05f7812c1254244"
}

request_id is the X-Request-ID of the response, which is what the logs carry. A validation error adds errors, one object per parameter: {"in": "query", "name": "limit", "detail": "Input should be less than or equal to 1000"}. The per-account 429 adds window, limit, remaining and reset_at (the fields of section 7) and keeps Retry-After and the X-RateLimit-* headers. A 401 carries WWW-Authenticate: Bearer, a 405 carries Allow, a 406 adds available, the media types that can be produced. The type names the situation; a client switches on it rather than on the status code.

Problem: invalid-parameter

422. A path or query parameter fails validation: a version that is not an integer, a limit outside 1 to 1000, a cursor this API did not write, an unknown format, kind or since.

Problem: not-acceptable

406. Nothing in Accept can be produced, for example only image/png. available lists what can.

Problem: license-not-found

404. No license has that identifier, or it is a draft: a draft does not dereference, exactly as in version 1.

Problem: graph-not-found

404. No dependency graph has that identifier.

Problem: version-not-found

404. The model exists but never had that version, including version 0 and negative numbers.

Problem: release-not-found

404. A release identifier that is neither registered nor the current state, in /v2/releases/{release_id} or in since.

Problem: route-not-found

404. Any other path under /v2.

Problem: method-not-allowed

405, with Allow. Version 2 is read-only.

Problem: unauthenticated

401, with WWW-Authenticate: Bearer. A graph of an account, asked for without credentials.

Problem: forbidden

403. A graph of an account the caller may not see.

Problem: rate-limited

429. The per-account limiter of section 7; version 2 routes weigh 1.

Problem: store-unavailable

503. The triple store is needed and did not answer. Only a composed license and the current version of a graph of an account need it; everything else is served from the repository files and the account database.

Problem: internal-error

500. Anything unhandled. detail is a fixed sentence and never the exception.

Cross-origin reads

A preflight OPTIONS on a version 2 path answers 204 with Access-Control-Allow-Origin: *, Access-Control-Allow-Methods: GET, OPTIONS, Access-Control-Allow-Headers: Accept, Authorization, If-None-Match and Access-Control-Max-Age: 600. Every other version 2 answer, errors included, carries Access-Control-Allow-Origin: * and Access-Control-Expose-Headers: ETag, Link, Deprecation, Sunset, Retry-After, X-Request-ID, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Content-Disposition. There is no Access-Control-Allow-Credentials: a bearer token still works, because it is a header and not a credential in the CORS sense, so on version 2 a request with a token is open too. Paths outside version 2 keep the policy of CORS. No per-address bucket is added for version 2; the per-account limiter counts token requests there as everywhere.

Discovery

GET /v2 (getDiscovery) answers what the deployment offers, in one request:

curl -s https://api.dalicc.net/v2
{
  "service": "DALICC",
  "application_version": "2.0.0",
  "edition": "2026",
  "api_versions": [
    {"version": "1.1", "status": "stable", "base": "/", "openapi": "/openapi.json",
     "docs": "/docs", "deprecated": false, "sunset": null,
     "frozen_operations": ["GET /licenselibrary/license/{license_id}", "..."]},
    {"version": "2.0", "status": "stable", "base": "/v2", "openapi": "/v2/openapi.json",
     "docs": "/v2/docs", "deprecated": false, "sunset": null}
  ],
  "release": {"id": "data-...", "library": {"id": "lib-...", "records": 581, "last_change": "2026-09-24"},
              "graphs": {"id": "dg-...", "graphs": 8, "last_change": "2026-09-24"},
              "vocabulary": {"id": "ns-...", "version": 3, "content_hash": "sha256:..."},
              "registered": false, "href": "/v2/releases/data-..."},
  "dependency_graphs": [{"id": "dg_default", "title": "DALICC deontic dependency graph", "version": 3,
                         "content_hash": "sha256:...", "default": true, "group": "core",
                         "href": "/v2/dependency-graphs/dg_default"}],
  "vocabulary": {"iri": "https://dalicc.net/ns#", "version": 3, "href": "/v2/vocabulary", "html": "https://dalicc.net/ns"},
  "identifiers": {"license_base": "https://dalicc.net/licenselibrary/",
                  "graph_bases": ["https://dalicc.net/dependencygraph/", "https://dalicc.net/users/"],
                  "vocabulary_namespace": "https://dalicc.net/ns#", "hash_algorithm": "dalicc-c14n-1"},
  "rate_limits": {"enabled": true, "per_ip": [{"bucket": "compatibilitycheck", "operations": ["POST /compatibilitycheck/"], "limit": 60, "window_seconds": 60}],
                  "per_account": {"enabled": true, "windows": {"second": 5, "minute": 60, "hour": 1000, "day": 10000},
                                  "global_per_minute": 600,
                                  "weights": {"/licenselibrary/consistencycheck": 3, "/licenselibrary/translate": 10, "/compatibilitycheck": 5, "default": 1},
                                  "headers": ["X-RateLimit-Limit", "X-RateLimit-Remaining", "X-RateLimit-Reset"]}},
  "features": {"text_to_license": true, "license_to_text": true, "github_checker": false, "registration": "invite-only"},
  "resources": {"actions": "/licenselibrary/actions", "health": "/healthz", "...": "..."},
  "documentation": {"api_reference": "https://dalicc.net/documentation/api", "...": "..."},
  "licenses": {"data": {"spdx": "CC-BY-4.0", "...": "..."}, "service": {"spdx": "AGPL-3.0-only", "...": "..."}},
  "contact": {"emails": ["..."], "url": "https://dalicc.net/contact"},
  "notice": null,
  "notice_closable": false,
  "links": {"self": "/v2", "licenses": "/v2/licenses", "dependency_graphs": "/v2/dependency-graphs",
            "vocabulary": "/v2/vocabulary", "releases": "/v2/releases", "changes": "/v2/changes"}
}

Every value is read from where the service keeps it: application_version from the package, edition, the contact addresses and the site addresses from the settings, the version 1 number from info.version of /openapi.json itself, frozen_operations from a constant a unit test holds equal to section 1 of BACKWARD_COMPATIBILITY.md, per_ip from a table a unit test holds equal to the limits the routes really mount, per_account from the limiter's settings and weights (weights is keyed by path prefix, the way the limiter matches it), features from the translation providers, the License-to-Text switch, the GitHub checker key and the registration policy, and notice from the site banner an administrator switches on at /admin/appearance: its text, a string such as "DEVELOPMENT VERSION: This is an experimental DALICC environment. ...", while the banner is on, and null while it is off; notice_closable says whether the site lets a visitor close that banner, and is false while it is off. Nothing secret is in it: no key and no address of the triple store. It is cached for five minutes (Cache-Control: public, max-age=300) with a weak ETag over its JSON, whose keys are sorted.

GET /.well-known/api-catalog answers the RFC 9727 linkset, Content-Type: application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727", with absolute addresses because a linkset anchor has to be absolute. HEAD answers the same headers, and both carry Link: </.well-known/api-catalog>; rel="api-catalog".

{"linkset": [{
  "anchor": "https://api.dalicc.net/v2",
  "service-desc": [{"href": "https://api.dalicc.net/v2/openapi.json", "type": "application/vnd.oai.openapi+json"},
                   {"href": "https://api.dalicc.net/openapi.json", "type": "application/vnd.oai.openapi+json"}],
  "service-doc": [{"href": "https://dalicc.net/documentation/api", "type": "text/html"}],
  "service-meta": [{"href": "https://api.dalicc.net/v2", "type": "application/json"}]
}]}

/v2/openapi.json is the version 2 OpenAPI document (title "DALICC API", version 2.0.0, one tag per group: discovery, licenses, dependency-graphs, vocabulary, releases, an explicit operationId on every route and the bearerAuth scheme on the three graph routes that read a graph of an account). /v2/docs is its Swagger UI.

Versioned resources

Every route is a GET, anonymous unless noted, JSON unless negotiated.

Path operationId Returns
/v2/licenses listLicenses a page of the current versions of the curated records
/v2/licenses/{license_id} getLicense the current version (object, or RDF when negotiated)
/v2/licenses/{license_id}/versions listLicenseVersions every version, newest first
/v2/licenses/{license_id}/versions/{version} getLicenseVersion one version
/v2/dependency-graphs listDependencyGraphs the current versions of the published core graphs
/v2/dependency-graphs/{graph_id} getDependencyGraph the current version; a graph of an account needs its token or session
/v2/dependency-graphs/{graph_id}/versions listDependencyGraphVersions every version
/v2/dependency-graphs/{graph_id}/versions/{version} getDependencyGraphVersion one version
/v2/vocabulary getVocabulary the current version
/v2/vocabulary/versions listVocabularyVersions every version
/v2/vocabulary/versions/{version} getVocabularyVersion one version

license_id follows the version 1 rule, ^[A-Za-z0-9._-]{1,128}$. graph_id is a graph identifier (dg_default, dg_eu, ...). version is an integer from 1: 0 or a negative number is 404 version-not-found, anything that is not an integer is 422.

The curated records, the core graphs and the vocabulary are listed. A composed license and a graph of an account are readable by identifier, with the access check of version 1, but never listed: composed licenses are unlisted by design. A new version of a composed license is a new identifier, so its navigation follows dct:replaces and dct:isReplacedBy, and its /versions lists the one version it has.

curl -s https://api.dalicc.net/v2/licenses/MIT
curl -s https://api.dalicc.net/v2/licenses/MIT/versions
curl -s 'https://api.dalicc.net/v2/licenses/MIT/versions/1?format=ttl'
curl -s 'https://api.dalicc.net/v2/licenses?limit=100&ports=exclude'
curl -s https://api.dalicc.net/v2/dependency-graphs
curl -s -H 'Accept: application/n-triples' https://api.dalicc.net/v2/dependency-graphs/dg_eu
curl -s -H "Authorization: Bearer $DALICC_TOKEN" https://api.dalicc.net/v2/dependency-graphs/<your graph>
curl -s https://api.dalicc.net/v2/vocabulary/versions

Negotiation

One rule for all of version 2:

  1. format wins: json, ttl, jsonld, nt or rdfxml. Any other value is 422 invalid-parameter.
  2. Else Accept, ranked by q: the first of application/json (json), text/turtle or application/x-turtle (ttl), application/ld+json (jsonld), application/n-triples (nt), application/rdf+xml (rdfxml), */* or application/* (json). text/html also answers JSON: version 2 never redirects to a page, the object carries the page's address in links.html. application/problem+json is ignored.
  3. Nothing acceptable: 406 not-acceptable.
  4. Every negotiated answer carries Vary: Accept.

The media types answered are application/json, text/turtle; charset=utf-8, application/ld+json (real JSON-LD with the library's context, never the flattened shape of version 1), application/n-triples; charset=utf-8 (the canonical form of the content hash, exactly) and application/rdf+xml. For a license the RDF is the record's closure; for a graph or the vocabulary the whole graph. An archived version of a graph or the vocabulary served as ttl is the archived file verbatim, comments included, as version 1 serves it; every other RDF answer is serialised from the parsed graph. Lists and the discovery document are JSON only.

The version object

{
  "kind": "license",
  "id": "MIT",
  "iri": "https://dalicc.net/licenselibrary/MIT",
  "title": "The MIT License",
  "version": 1,
  "latest_version": 4,
  "status": "superseded",
  "record_status": "published",
  "date": "",
  "published_on": "",
  "summary": "",
  "content_hash": "sha256:<64 hex>",
  "hash_algorithm": "dalicc-c14n-1",
  "withdrawn_on": null,
  "replaces": null,
  "replaced_by": null,
  "port_of": null,
  "formats": {
    "ttl": "/v2/licenses/MIT/versions/1?format=ttl",
    "jsonld": "/v2/licenses/MIT/versions/1?format=jsonld",
    "nt": "/v2/licenses/MIT/versions/1?format=nt",
    "rdfxml": "/v2/licenses/MIT/versions/1?format=rdfxml"
  },
  "links": {
    "self": "/v2/licenses/MIT/versions/1",
    "canonical": "/v2/licenses/MIT/versions/1",
    "latest": "/v2/licenses/MIT/versions/2",
    "predecessor": null,
    "successor": "/v2/licenses/MIT/versions/2",
    "version_history": "/v2/licenses/MIT/versions",
    "changelog": "/licenselibrary/license/MIT/changelog",
    "html": "https://dalicc.net/license-library/MIT/versions/1",
    "v1": "/licenselibrary/license/MIT/versions/1"
  }
}
Field Meaning
kind license, dependency-graph or vocabulary
id the license id, the graph id, or dalicc-ns
iri the record IRI, the named graph of the graph, or https://dalicc.net/ns#
version, latest_version integers
status current; superseded when a later version exists; withdrawn when this is the latest version and it states owl:deprecated true (a graph: its row is deprecated)
record_status licenses: published, test-fixture (from dalicc:recordStatus) or composed; graphs: the row's status; the vocabulary: published
date the change-log date of this version, "" when none is recorded (version 1 of an original record)
published_on licenses: dct:issued when stated, else date
summary the change-log summary of this version; version 1 says how it was written, or ""
content_hash, hash_algorithm the content hash
withdrawn_on dalicc:deprecatedOn of a withdrawn version, else null
replaces, replaced_by license ids from dct:replaces and dct:isReplacedBy; graphs: the previous and the replacing graph
port_of licenses: the dalicc:jurisdictionPortOf id; the jurisdiction graphs: dg_default, the graph they are built from
formats the RDF addresses of this version
links the neighbours; an absent relation is null, never a missing key

A graph object adds axioms, default_rules and default; the vocabulary object adds terms, the number of subjects in the namespace. The current resource (/v2/licenses/{id}) answers the same object as /versions/{latest}, and its canonical address is the version-specific one.

Lists

{
  "items": [{"...": "version objects"}],
  "total": 581,
  "release": "lib-3f9a0c1e7b2d4a55",
  "next_cursor": "eyJhIjoiQUZMLTIuMCJ9",
  "links": {"self": "/v2/licenses?limit=100", "next": "/v2/licenses?limit=100&cursor=eyJhIjoiQUZMLTIuMCJ9"}
}

/v2/licenses and /v2/dependency-graphs are ordered by id (UTF-8 bytes) and paged by cursor: limit 1 to 1000 (default 100), cursor the next_cursor of the previous page, and Link: <...>; rel="next" while there is one. A cursor names the last id of the page, so a record published mid-walk does not shift the pages; a cursor this API did not write is 422. release is the lib- or dg- identifier of the state the page was built from: a client that sees it change while walking starts again. /v2/licenses lists every curated record, test fixtures included with their record_status, and takes ports (include, exclude, only) with the version 1 meaning. The /versions lists are never paged. Every list is Cache-Control: public, max-age=60 with a weak ETag over its JSON.

Headers of a version

ETag: W/"<first 32 hex of content_hash>-json"
Cache-Control: public, max-age=86400, immutable     (a superseded version)
Cache-Control: public, max-age=300                  (the current version)
Last-Modified: Tue, 15 Sep 2026 00:00:00 GMT        (only when date is known)
Vary: Accept
Link: </v2/licenses/MIT/versions/1>; rel="canonical",
      </v2/licenses/MIT/versions/2>; rel="latest-version",
      </v2/licenses/MIT/versions/2>; rel="successor-version",
      </v2/licenses/MIT/versions>; rel="version-history",
      <https://dalicc.net/documentation/api#superseded-and-withdrawn>; rel="deprecation"; type="text/html"
Deprecation: @1789430400

The ETag is weak and differs per representation (-json, -ttl, -jsonld, -nt, -rdfxml): Turtle and JSON-LD bytes are not guaranteed stable across releases of the RDF library, and one tag must not name two representations. If-None-Match is compared weakly against the tag of the selected representation; a match, or *, answers 304 with the same headers and no body. The relations are RFC 8288 Link with the RFC 5829 names and RFC 6596 canonical: canonical always, latest-version always, predecessor-version when version > 1, successor-version when version < latest, version-history always. Sunset is never sent for data.

Superseded and withdrawn

Request Answer
a superseded version 200, status: "superseded", Deprecation dated the day its successor was published, successor-version, cached for a day as immutable
a withdrawn record (the latest version states owl:deprecated true) 200, status: "withdrawn", withdrawn_on, Deprecation dated dalicc:deprecatedOn, replaced_by and Link: </v2/licenses/{replaced_by}>; rel="successor-version"
a version that never existed 404 version-not-found
a draft (composed, unpublished) 404 license-not-found, as in version 1

Deprecation is the RFC 9745 structured date, @ and the Unix seconds of midnight UTC of that day; it is left out when the date is not recorded. With it comes the rel="deprecation" link to this section. Nothing is deleted (a withdrawn record keeps resolving), so no sunset date would be true; the deprecation date is what a client needs to decide whether to refresh.

The content hash

Every version object carries content_hash, sha256: and 64 hex digits, and hash_algorithm: "dalicc-c14n-1". The hash covers the triples, never a serialisation: the same version served as Turtle, JSON-LD, RDF/XML or N-Triples has one hash, and a record's file in licensedata/ gives the same hash as the record in the triple store (details in DATA.md).

The canonical form. The triples of a license record are its closure: every triple whose subject is the record IRI, plus, recursively, every triple whose subject is a blank node reached from it. A graph and the vocabulary are hashed whole. Each triple is written as one N-Triples line:

  • an IRI as <, the IRI, >, where each character from U+0000 to U+0020 and each of <>"{}|^, backtick and backslash is written \uXXXX (upper-case hex); nothing else is escaped or normalised;
  • a literal as ", the lexical form exactly as parsed with backslash, ", LF and CR escaped as \\, \", \n, \r, ", then @ and the language tag in lower case, or ^^ and the datatype IRI, except that xsd:string is dropped ("2"^^xsd:string and "2" are the same term, and Turtle true is "true"^^<http://www.w3.org/2001/XMLSchema#boolean>);
  • a blank node as _: and its label: for each of its triples, first label a blank-node object, then form predicate object; deduplicate, sort by UTF-8 bytes, join each followed by LF; the label is b and the first 32 hex digits of the SHA-256 of that text.

The lines (subject predicate object .) are deduplicated, sorted by UTF-8 bytes and each ends with LF, so the empty graph is the empty string. The hash is sha256: and the SHA-256 of that text. A blank node that is the object of two triples, or a chain of blank nodes that loops, is refused rather than hashed wrongly; no DALICC data has either.

Verifying a download needs nothing but SHA-256, because format=nt answers exactly the canonical text:

curl -s 'https://api.dalicc.net/v2/licenses/MIT?format=nt' | sha256sum
curl -s https://api.dalicc.net/v2/licenses/MIT | jq -r .content_hash

Reproducing it from a file needs an RDF parser; app/services/content_hash.py is the reference (standard library and rdflib, and copied to the public data repository as scripts/dalicc_check/c14n.py):

from rdflib import Graph, URIRef
from app.services.content_hash import content_hash, record_closure

graph = Graph().parse("licensedata/licenses/MIT.ttl", format="turtle")
print(content_hash(record_closure(graph, URIRef("https://dalicc.net/licenselibrary/MIT"))))

From the store, the closure of one record is read inside GRAPH <https://dalicc.net/licenselibrary/> (a composed license: GRAPH <https://dalicc.net/customlicenses/>): the triples of the record IRI, then those of every blank node they reach, until no new blank node turns up. Ask per record; a whole-graph CONSTRUCT is cut off at the endpoint's row limit.

Test vectors. Another implementation is correct when it reproduces these four:

Name Turtle Triples Hash
empty (empty) 0 sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
one-iri <https://dalicc.net/licenselibrary/X> <http://purl.org/dc/terms/title> "X"@EN . 1 sha256:3c7d3927826ea9b1ba0b8f9baf869fdc3598b1bfb29388e729831c4a7771179c
bnode-tree below 6 sha256:7761b85dae28bdbe9c29b63ac6a0f0568d27d3ca667cd60a98206742ff3ea793
typed-and-escapes below 4 sha256:d57b4a92570a280044dc46ffc452006b0b8f131932f8bad50c827ca7b0fdd0e0
# bnode-tree
@prefix odrl: <http://www.w3.org/ns/odrl/2/> .
<https://dalicc.net/licenselibrary/X> odrl:permission [ odrl:action odrl:distribute ; odrl:duty [ odrl:action <http://creativecommons.org/ns#Attribution> ] ] , [ odrl:action odrl:reproduce ] .
# typed-and-escapes
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
<https://dalicc.net/ns#> <http://purl.org/dc/terms/modified> "2026-09-24"^^xsd:date ;
 <http://www.w3.org/2000/01/rdf-schema#comment> """two
lines \"quoted\" \\ """ ;
 <http://www.w3.org/2002/07/owl#deprecated> true ;
 <http://purl.org/dc/terms/hasVersion> "2"^^xsd:string .

The canonical form of bnode-tree:

<https://dalicc.net/licenselibrary/X> <http://www.w3.org/ns/odrl/2/permission> _:b37daaf8f2e9345dff366bd0fb88080ca .
<https://dalicc.net/licenselibrary/X> <http://www.w3.org/ns/odrl/2/permission> _:bb62e4dba317152c6ef8fbbcd691b687e .
_:b37daaf8f2e9345dff366bd0fb88080ca <http://www.w3.org/ns/odrl/2/action> <http://www.w3.org/ns/odrl/2/distribute> .
_:b37daaf8f2e9345dff366bd0fb88080ca <http://www.w3.org/ns/odrl/2/duty> _:beb0b6f92f6fe02eb623de8b350703cca .
_:bb62e4dba317152c6ef8fbbcd691b687e <http://www.w3.org/ns/odrl/2/action> <http://www.w3.org/ns/odrl/2/reproduce> .
_:beb0b6f92f6fe02eb623de8b350703cca <http://www.w3.org/ns/odrl/2/action> <http://creativecommons.org/ns#Attribution> .

Hashes are computed when a version is served and cached by the source they come from: an administrator can publish a version between deployments, so a hash written into the repository would be wrong on a running server.

Releases and the change feed

A release is a named state of the whole data: the version and content hash of every curated record, every published core graph and the vocabulary. It has four identifiers, each a prefix and 16 hex digits of a SHA-256 over the versions and hashes it covers (the computation is in DATA.md): lib- for the library, dg- for the core graphs, ns- for the vocabulary, and data- for the three together. The same state has the same identifiers on every server and in every checkout; a server where an administrator published one more version has other ones at once. Composed licenses and the graphs of accounts are never part of a release. A release registered when a version of the application was cut is a file under licensedata/releases/ and never changes.

Path operationId Returns
/v2/releases listReleases the current state and every registered release, newest first
/v2/releases/{release_id} getRelease one manifest; any of the four identifiers finds it
/v2/changes listChanges every published version since a date or a release
curl -s https://api.dalicc.net/v2/releases
curl -s https://api.dalicc.net/v2/releases/data-8c1f0e2a9b3d4c77
curl -s 'https://api.dalicc.net/v2/changes?since=2026-09-15&kind=license'
curl -s 'https://api.dalicc.net/v2/changes?since=data-8c1f0e2a9b3d4c77'

GET /v2/releases:

{
  "current": {"release": "data-8c1f0e2a9b3d4c77", "library": "lib-...", "graphs": "dg-...",
              "vocabulary": "ns-...", "date": "2026-09-24", "registered": false,
              "href": "/v2/releases/data-8c1f0e2a9b3d4c77"},
  "items": [{"release": "data-...", "library": "lib-...", "graphs": "dg-...", "vocabulary": "ns-...",
             "date": "2026-09-25", "application_version": "2.0.0", "href": "/v2/releases/data-..."}],
  "total": 1, "next_cursor": null, "links": {"self": "/v2/releases", "next": null}
}

current.date is the newest change of any model; a registered release is dated the day it was cut. registered says whether the current state equals a registered release; it stops being true as soon as an administrator publishes a version on the server. GET /v2/releases/{release_id} answers the manifest document of DATA.md, for a registered release with Cache-Control: public, max-age=86400, immutable, for the current state with max-age=60; any other identifier is 404 release-not-found.

GET /v2/changes lists one item per published version of a curated record, a core graph or the vocabulary:

{
  "items": [
    {"kind": "license", "id": "Apache-2.0", "version": 2, "change": "changed",
     "date": "2026-09-15", "summary": "...", "content_hash": "sha256:...",
     "previous_version": 1, "previous_hash": "sha256:...",
     "links": {"self": "/v2/licenses/Apache-2.0/versions/2",
               "changelog": "/licenselibrary/license/Apache-2.0/changelog"}}
  ],
  "since": "2026-09-15",
  "until": {"release": "data-8c1f0e2a9b3d4c77", "date": "2026-09-24"},
  "total": 612,
  "next_cursor": "eyJrIjpbIjIwMjYtMDktMTUiLCJsaWNlbnNlIiwiQXBhY2hlLTIuMCIsMl19",
  "links": {"self": "/v2/changes?since=2026-09-15", "next": "/v2/changes?since=2026-09-15&cursor=..."}
}
  • Where it comes from. The change logs (file entries and the versions published on the server, merged) and the manifests; nothing is stored for it. Version 1 of a record has no change-log entry: its date is the day the record was written for a record that was written from its license text, else "", and its summary says the same or is "".
  • change. added for version 1; withdrawn for a version that states owl:deprecated true while its predecessor does not (a graph: the latest version of a deprecated graph); changed otherwise.
  • Order. Ascending by (date, kind, id, version), undated versions first, so that a client resumes from its last item. The cursor names that key.
  • since a date (YYYY-MM-DD) keeps the versions dated that day or later. The day is inclusive because dates are days: a client deduplicates on (kind, id, version). Undated versions are left out.
  • since a release id (data-, lib-, dg-, ns-) keeps every version newer than the one that release had, whatever its date, and every version of a model the release did not have. This is exact, and it is the form to use: remember the until.release of the last walk and ask for what came after it.
  • No since lists everything. kind filters: a comma list of license, dependency-graph and vocabulary. limit and cursor page as everywhere else.
  • Hashes. content_hash of the version and previous_hash of the version before it (null for version 1), so a client can tell exactly which copy it holds.

Composed licenses and graphs of accounts never appear. The lists are Cache-Control: public, max-age=60 with a weak ETag over their JSON.