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
400 | hashes 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
404 | No 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
q | Matches part of a label or of a type name |
label | Matches part of a label |
slug | An exact type name, e.g. Publication: schemas some collection uses for that type |
schema_hash | An exact schema hash (bare hex). Returns that one schema as an object instead of a list, with usageCount, or 404 |
limit | Max results (default 50, max 100) |
offset | Pagination 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
version | A semver (v1.2.0), a version hash (ulv2:…), or latest (the default) |
raw | true 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
200 | The schema already has this label: "status": "exists" |
400 | The label is empty or longer than 100 characters. |
401 / 403 | Not signed in, or a read-only key. |
404 | The 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.