DALICC API reference
On this page
- 1. Conventions
- Base URL and schema
- Versions and deprecation
- Authentication
- Error shape
- Headers on every response
- CORS
- Caching
- Rate limits
- Content negotiation
- Pagination
- Names that differ between operations
- The raw SPARQL-JSON envelope
- 2. The documented contract
- 2.1 GET /licenselibrary/license/{license_id}
- 2.2 GET /licenselibrary/list
- 2.3 POST /licenselibrary/facetedsearch
- 2.4 POST /licenselibrary/composer
- 2.5 GET /dependencygraph/list
- 2.6 POST /compatibilitycheck/
- 2.7 to 2.10 GET /githublicensechecker/*
- 3. Additive API endpoints
- GET /healthz
- GET /licenselibrary/compare?ids=MIT,Apache-2.0
- GET /licenselibrary/actions
- POST /licenselibrary/consistencycheck
- GET /licenselibrary/mine
- GET /licenselibrary/mine/{license_id}/revisions
- POST /licenselibrary/mine/{license_id}/deprecate
- GET /licenselibrary/spdx/{spdx_id}
- GET /licenselibrary/spdx-mapping
- GET /licenselibrary/review/{license_id}
- The model history
- 4. Endpoints behind the website
- The legacy /web/* fragments
- 5. Dependency graphs
- GET /dependencygraph/list?graph=<id>
- GET /dependencygraph/graphs
- GET /dependencygraph/compare?left=<id>&right=<id>
- GET /dependency-graph/{id}/download
- GET /dependencygraph/rules?graph=<id>
- GET /dependencygraph/mine
- POST /dependencygraph?publish=true&title=
- GET /dependencygraph/{graph_id}/versions, /versions/{n}, /changelog
- POST /compatibilitycheck/ gains "dependency_graph"
- POST /licenselibrary/consistencycheck?dependency_graph=<id or IRI>
- 6. Review workflow and messaging
- 7. Per-account API limits
- The rule
- Weights
- Headers
- 429
- How exact the numbers are
- Seeing your own usage
- 8. The translation assistant
- POST /licenselibrary/translate
- GET /licenselibrary/translate/jobs/{job_id}
- 9. License-to-Text
- POST /licenselibrary/narrate
- GET /licenselibrary/license/{id}?format=text
- 10. The public profile address
- 11. Version 2: history, releases and discovery
- Problem details
- Cross-origin reads
- Discovery
- Versioned resources
- Negotiation
- The version object
- Lists
- Headers of a version
- The content hash
- Releases and the change feed
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 offormat=ttl, the stringified integer keys of a compatibility verdict), and every$refresolves; components.securitySchemes.bearerAuthis the personal API token. An operation that needs one carriessecurity: [{"bearerAuth": []}]and declares401; 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; 404is declared only where an operation can answer it, and/healthzdeclares503.
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:
- A sentence. Almost every error: the operation raised it with a message.
json
{"detail": "Unknown license id: NOPE-123"}
- A list, when a parameter does not have the declared type. FastAPI validates path
and query parameters before the operation runs and answers
422with one entry per problem:?format=nton/licenselibrary/license/{id},?limit=xon/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.
-
A sentence with the window, when a token is over its allowance. The per-account
429carrieswindow,limit,remainingandreset_atnext todetail(section 7). -
A sentence, when an address is over a per-IP limit. The anonymous
429is shape 1:{"detail": "Rate limit exceeded: at most 60 requests per 60s for this endpoint."}, withRetry-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 entryuri;POST /dependencygraphanswers both. - A version list says
current, a change logcurrent_version, for the same number. urlandapi_urlare paths on the same host (/license-library/MIT,/licenselibrary/license/MIT/versions/1);uriandiriare absolute.- The
404sentences differ by route:/versions/0saysUnknown version: 0,/versions/99saysNo version 99 of license id: MIT, and/changeloganswersNo change log for license id: Xboth 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. Thealiasestable 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 answer404.
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
Linkwere 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
typeper situation. Version 1 keeps its shapes. - Version 2 has its own OpenAPI document,
/v2/openapi.json, and its own Swagger UI,/v2/docs./openapi.jsonstays the version 1 document: the contract guard compares it with the live snapshot, and itsinfo.versionis1.1. - Discovery is
GET /v2, andGET /.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
urlandapi_urlin 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:
formatwins:json,ttl,jsonld,ntorrdfxml. Any other value is422 invalid-parameter.- Else
Accept, ranked byq: the first ofapplication/json(json),text/turtleorapplication/x-turtle(ttl),application/ld+json(jsonld),application/n-triples(nt),application/rdf+xml(rdfxml),*/*orapplication/*(json).text/htmlalso answers JSON: version 2 never redirects to a page, the object carries the page's address inlinks.html.application/problem+jsonis ignored. - Nothing acceptable:
406 not-acceptable. - 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 thatxsd:stringis dropped ("2"^^xsd:stringand"2"are the same term, and Turtletrueis"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 formpredicate object; deduplicate, sort by UTF-8 bytes, join each followed by LF; the label isband 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.addedfor version 1;withdrawnfor a version that statesowl:deprecated truewhile its predecessor does not (a graph: the latest version of a deprecated graph);changedotherwise.- Order. Ascending by
(date, kind, id, version), undated versions first, so that a client resumes from its last item. The cursor names that key. sincea 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.sincea 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 theuntil.releaseof the last walk and ask for what came after it.- No
sincelists everything.kindfilters: a comma list oflicense,dependency-graphandvocabulary.limitandcursorpage as everywhere else. - Hashes.
content_hashof the version andprevious_hashof the version before it (nullfor 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.