# DALICC API reference

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](../README.md#documentation).

*Contents:* [1. Conventions](#1-conventions) ·
[2. The documented contract](#2-the-documented-contract) ·
[3. Additive endpoints](#3-additive-api-endpoints) ·
[4. Behind the website](#4-endpoints-behind-the-website) ·
[5. Dependency graphs](#5-dependency-graphs) ·
[6. Review workflow and messaging](#6-review-workflow-and-messaging) ·
[7. Per-account API limits](#7-per-account-api-limits) ·
[8. The translation assistant](#8-the-translation-assistant) ·
[9. License-to-Text](#9-license-to-text) ·
[10. The public profile address](#10-the-public-profile-address) ·
[11. Version 2: history, releases and discovery](#11-version-2-history-releases-and-discovery)

The Python client that speaks all of it is in [SDK.md](SDK.md). What a license, a
draft, a submission or a translation *is* belongs to [USERS.md](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](#11-version-2-history-releases-and-discovery), and `GET /v2`
says what a deployment offers.

What the document tells a client generator:

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

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

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

The ten operations of [section 2](#2-the-documented-contract) 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](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](BACKWARD_COMPATIBILITY.md#5-deprecation-policy): 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](USERS.md). An endpoint that needs a token and does not get one answers
`401`; a token that may not see the object it asks for gets `403`.

### Error shape

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

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

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

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

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

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

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

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

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

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

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

### Headers on every response

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

### CORS

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

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

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

### Caching

The ten operations of [section 2](#2-the-documented-contract) 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](#7-per-account-api-limits), 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](#9-license-to-text)) |
| `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](#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](#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](#error-shape) | `415`, a sentence naming the nine values |
| `Accept` types honoured | `text/turtle`, `application/rdf+xml`, `application/ld+json` | the six above |
| `Accept: application/n-triples` | ignored: the default JSON answer | N-Triples |
| N-Triples at all | no | yes |
| which wins | `Accept` over `?format=` | `?format=` over `Accept` |
| the license in words | `format=text` | no |

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

### Pagination

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

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

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

### Names that differ between operations

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

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

### The raw SPARQL-JSON envelope

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

```json
{
  "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](#9-license-to-text) |
| `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`:

```json
{"@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.

```bash
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](DATA.md#ports-and-variants)):

| `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.

```bash
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):

```json
{"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.

```bash
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`](#3-additive-api-endpoints); 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`](#post-licenselibraryminelicense_iddeprecate):
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](https://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.

```bash
# 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:

```json
{"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](#5-dependency-graphs)).

```json
{"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](DATA.md#the-dependency-graph).

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](#5-dependency-graphs).

### 2.6 `POST /compatibilitycheck/`

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

Body:

```json
{"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:

```json
{"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](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](DATA.md#share-alike-between-two-licenses) 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](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](#5-dependency-graphs).

```bash
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:

```json
{"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.

```bash
curl -s 'https://api.dalicc.net/githublicensechecker/check/psf/requests?target=GPL-3.0-only'
```

```json
{"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.

```bash
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:

```json
{"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:

```json
{"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](#5-dependency-graphs).

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.

```json
{"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.

```json
{"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:

```json
{"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.

```bash
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:

```json
{
  "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.

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

### `GET /licenselibrary/spdx/{spdx_id}`

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

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

Three kinds of value resolve:

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

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

```bash
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`](../licensedata/reviews/README.md) and the outcome in
[LICENSE_REVIEW.md](LICENSE_REVIEW.md).

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

```bash
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](DATA.md#4-model-history-and-versioning).

#### `GET /licenselibrary/license/{license_id}/versions`

```json
{
  "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}`](#21-get-licenselibrarylicenselicense_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](#9-license-to-text)), and a download is
named `<id>-v<n>.txt`.

```bash
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.

```json
{"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.

```json
{"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.

```bash
curl -s https://api.dalicc.net/dependencygraph/versions
```

```json
{"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-dependencygraphgraph_idversions-versionsn-changelog).

#### `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`.

```json
{"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](#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](#10-the-public-profile-address) |
| `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:

```json
{
  "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](#24-post-licenselibrarycomposer)), 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](#25-get-dependencygraphlist); 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`

```json
{"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](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`.

```json
{"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.

```json
{"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

```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](#the-model-history): 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"`

```json
{"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](USERS.md).

---

## 7. Per-account API limits

[Section 1](#rate-limits) 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:

```json
{
  "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](USERS.md#19-text-to-license); how the feature is built is
[TRANSLATION.md](TRANSLATION.md).

### `POST /licenselibrary/translate`

Authentication: a personal API token. The call counts as **10** requests against the
account's windows ([section 7](#7-per-account-api-limits)) 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:

```json
{"text": "Permission is hereby granted ...", "title": "MIT License",
 "consent": true, "save_draft": false}
```

```bash
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`):

```bash
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:

```jsonc
{
  "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`](#24-post-licenselibrarycomposer) 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:

```json
{"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`:

```bash
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.

```jsonc
{
  "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](SDK.md) 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:

```bash
curl -s https://api.dalicc.net/licenselibrary/narrate \
  -H "Authorization: Bearer dalicc_..." \
  -H "Content-Type: application/json" \
  -d '{"license": "Apache-2.0"}'
```

```jsonc
{
  "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:

```bash
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](SDK.md) 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.

```bash
curl -s -H 'Accept: text/turtle' 'https://dalicc.net/users/<id>'
```

```turtle
@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](#2-the-documented-contract) and the
additive routes keep their paths, shapes and headers.

The decisions behind it, one paragraph each:

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

### Problem details

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

```json
{
  "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](#7-per-account-api-limits)) 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](#7-per-account-api-limits); 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](#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:

```bash
curl -s https://api.dalicc.net/v2
```

```json
{
  "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](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"`.

```json
{"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.

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

### Negotiation

One rule for all of version 2:

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

The media types answered are `application/json`, `text/turtle; charset=utf-8`,
`application/ld+json` (real JSON-LD with the library's context, never the flattened
shape of version 1), `application/n-triples; charset=utf-8` (the canonical form of
[the content hash](#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

```json
{
  "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](#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

```json
{
  "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](DATA.md#the-content-hash)).

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

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

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

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

```bash
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`):

```python
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` |

```turtle
# 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 ] .
```

```turtle
# 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](DATA.md#release-manifests)): `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 |

```bash
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`:

```json
{
  "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](DATA.md#release-manifests), 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:

```json
{
  "items": [
    {"kind": "license", "id": "Apache-2.0", "version": 2, "change": "changed",
     "date": "2026-09-15", "summary": "...", "content_hash": "sha256:...",
     "previous_version": 1, "previous_hash": "sha256:...",
     "links": {"self": "/v2/licenses/Apache-2.0/versions/2",
               "changelog": "/licenselibrary/license/Apache-2.0/changelog"}}
  ],
  "since": "2026-09-15",
  "until": {"release": "data-8c1f0e2a9b3d4c77", "date": "2026-09-24"},
  "total": 612,
  "next_cursor": "eyJrIjpbIjIwMjYtMDktMTUiLCJsaWNlbnNlIiwiQXBhY2hlLTIuMCIsMl19",
  "links": {"self": "/v2/changes?since=2026-09-15", "next": "/v2/changes?since=2026-09-15&cursor=..."}
}
```

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

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