Records and schemas API

A record’s hash identifies it in every collection that holds it. These endpoints look records up by hash across collections, and find the schemas collections use. Record hashes are 64 lowercase hex characters, with or without a sha256: prefix.

Visibility

Every answer is limited to what the caller can read: the public set of a public collection, plus both sets of the collections in the caller’s own organizations. A record or schema the caller can’t read is treated as not existing, and counts include only what the caller can see. All of these endpoints work anonymously.

The index that finds records by hash is updated by a background job after each push, so a version pushed in the last few seconds may not show up yet in the record endpoints below.


POST /api/records/batch

No auth required

Fetch up to 100 records by hash in one request. Body: {"hashes": ["<hash>", …]}. The response is NDJSON, one {"id", "type", "data", "hash"} per line, in the order the hashes were given. A hash that isn’t found, or that the caller can’t read, is left out rather than reported, so the response can have fewer lines than the request had hashes, or none at all.

This request counts as 5 requests against your rate limit.

Example

curl -X POST https://www.underlay.org/api/records/batch \
  -H "Content-Type: application/json" \
  -d '{"hashes": ["3f2a9c...", "sha256:9c1e4b..."]}'
HTTP/1.1 200 OK
Content-Type: application/x-ndjson

{"id":"pub-001","type":"Publication","data":{"title":"..."},"hash":"3f2a9c..."}
{"id":"pub-002","type":"Publication","data":{"title":"..."},"hash":"9c1e4b..."}

Errors

400hashes is missing, empty, or has more than 100 entries.

GET /api/records/:hash/provenance

No auth required

A record and every version that contains it, across the collections the caller can read. references has one entry per version, oldest first; firstSeen (and its older name, createdAt) is the earliest of them. size is the length of the record’s canonical form in bytes.

Long histories are cut short: references lists at most the first 100 versions of each collection, and covers at most 300 runs of consecutive versions overall. This request counts as 5 requests against your rate limit.

Response 200

{
  "hash": "3f2a9c...",
  "recordHash": "3f2a9c...",
  "recordId": "pub-001",
  "type": "Publication",
  "data": { "title": "An Example Paper" },
  "size": 182,
  "firstSeen": "2026-01-15T00:00:00.000Z",
  "createdAt": "2026-01-15T00:00:00.000Z",
  "references": [
    {
      "owner": "kf",
      "collection": "archive",
      "collectionName": "PubPub Archive",
      "semver": "v1.0.0",
      "versionCreatedAt": "2026-01-15T00:00:00.000Z"
    },
    {
      "owner": "kf",
      "collection": "archive",
      "collectionName": "PubPub Archive",
      "semver": "v1.1.0",
      "versionCreatedAt": "2026-02-02T00:00:00.000Z"
    }
  ]
}

Errors

404No collection the caller can read holds a record with this hash.

GET /api/records/:hash/first

No auth required

Where a record or a file first appeared: the earliest version, among the collections the caller can read, that added this hash. Cheaper than provenance when that is all you need. kind is record or file; type and id are present for records only. Returns 404 when no readable collection has it.

Response 200

{
  "hash": "3f2a9c...",
  "kind": "record",
  "owner": "kf",
  "collection": "archive",
  "semver": "v1.0.0",
  "createdAt": "2026-01-15T00:00:00.000Z",
  "type": "Publication",
  "id": "pub-001"
}

GET /api/collections/files/:hash

No auth required

Download a file by hash without naming a collection. If any collection the caller can read holds the file, the endpoint 302-redirects to a short-lived presigned storage URL, as the per-collection download does. Returns 404 when no readable collection has it, blocked or not, and 451 when a readable file has been blocked.


GET /api/schemas

No auth required

Search the schemas in use, newest first. A schema’s id is its hash: SHA-256 of its canonical JSON, as bare hex.

Query parameters

qMatches part of a label or of a type name
labelMatches part of a label
slugAn exact type name, e.g. Publication: schemas some collection uses for that type
schema_hashAn exact schema hash (bare hex). Returns that one schema as an object instead of a list, with usageCount, or 404
limitMax results (default 50, max 100)
offsetPagination offset

Schema bodies are not searched: q looks only at labels and type names.

Response 200

[
  {
    "id": "b41c07...",
    "schemaHash": "b41c07...",
    "schema": { "type": "object", "properties": { "title": { "type": "string" } } },
    "createdAt": "2026-01-15T00:00:00.000Z",
    "labels": ["scholarly-publication"]
  }
]

With schema_hash, usageCount is the number of collections the caller can read that have used the schema in any version:

{
  "id": "b41c07...",
  "schemaHash": "b41c07...",
  "schema": { "type": "object", "properties": { "title": { "type": "string" } } },
  "createdAt": "2026-01-15T00:00:00.000Z",
  "labels": ["scholarly-publication"],
  "usageCount": 3
}

GET /api/schemas/:id

No auth required

One schema, by hash, with its labels and where it is used now. usage lists up to 50 types, in the collections the caller can read, whose latest version uses this schema; semver is that collection’s latest version. Here each label comes with the time it was added. Returns 404 when the caller can’t see the schema.

Response 200

{
  "id": "b41c07...",
  "schemaHash": "b41c07...",
  "schema": { "type": "object", "properties": { "title": { "type": "string" } } },
  "createdAt": "2026-01-15T00:00:00.000Z",
  "labels": [
    { "label": "scholarly-publication", "createdAt": "2026-03-01T00:00:00.000Z" }
  ],
  "usage": [
    { "slug": "Publication", "semver": "v3.2.0", "collection": "kf/archive" }
  ]
}

GET /api/collections/:owner/:slug/schemas

No auth for public collections

The schema of each type in a version. Labels are added to each schema as x-underlay-labels when it has any; pass raw=true for the schemas exactly as pushed.

Query parameters

versionA semver (v1.2.0), a version hash (ulv2:…), or latest (the default)
rawtrue leaves out x-underlay-labels

Response 200

{
  "version": "v3.2.0",
  "semver": "v3.2.0",
  "schemas": [
    {
      "slug": "Publication",
      "schemaId": "b41c07...",
      "schemaHash": "b41c07...",
      "schema": {
        "type": "object",
        "properties": { "title": { "type": "string" } },
        "x-underlay-labels": ["scholarly-publication"]
      }
    }
  ]
}

Returns 404 when the collection or version doesn’t exist, or the collection has no versions.


POST /api/schemas/:id/labels

Auth: signed in, or a write or admin API key

Add a label to a schema the caller can see, so others can find it. Body: {"label": "…"}, at most 100 characters after trimming spaces. Adding a label the schema already has is not an error.

Example

curl -X POST https://www.underlay.org/api/schemas/b41c07.../labels \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{"label": "scholarly-publication"}'

Response 201

{
  "status": "created",
  "schemaId": "b41c07...",
  "label": "scholarly-publication"
}

Errors

200The schema already has this label: "status": "exists"
400The label is empty or longer than 100 characters.
401 / 403Not signed in, or a read-only key.
404The schema doesn’t exist or the caller can’t see it.

DELETE /api/schemas/:id/labels/:label

Auth: Underlay stewards only (session, or a write or admin key)

Remove a label from a schema. A label is shared by every collection that uses the schema, so only Underlay’s stewards (Knowledge Futures administrators) can remove one, signed in or with a personal write or admin key. Anyone else is 403; a read key, or a key confined to collections or held by an organization, is 401. Returns {"ok": true}, also when the schema didn’t have the label.