Versions API
Versions are the core of Underlay. Each version is an immutable snapshot of a collection: schema + records + file references.
POST /api/collections/:owner/:slug/versions
Auth: write scope
Push a new version. This is the primary write operation. You send a base_version for optimistic locking, an optional schema, and a set of changes (added, updated, removed records). The server computes the full snapshot from the previous version plus your changes.
Request
{
"base_version": 1,
"message": "Add new publications",
"app_id": "pubpub-sync",
"actor_id": "user-42",
"schema": { ... },
"changes": {
"added": [
{"id": "rec-1", "type": "Publication", "data": {"title": "..."}}
],
"updated": [
{"id": "rec-0", "type": "Publication", "data": {"title": "Updated"}}
],
"removed": ["rec-old"]
}
} Fields
base_version | Required. The version number this push is based on. Use null for the first version. If the current version doesn't match, returns 409 Conflict. |
message | Human-readable commit message. |
app_id | Identifier for the application that pushed this version. |
actor_id | Identifier for the user or process that triggered the push. |
schema | JSON Schema for the records. If omitted, the previous version's schema is reused. |
changes.added | New records to add. Each record can include "private": true to hide it from public readers. |
changes.updated | Existing records to replace (by id). Can include "private": true. |
changes.removed | Record IDs to remove. |
Schema privacy
You can add "private": true at two levels in the schema:
- Type-level: Add
"private": trueto a type definition to hide all records of that type from public readers. The type is also stripped from the public schema response. - Field-level: Add
"private": trueto a field definition to strip that field from records returned to public readers. The field is also removed from the public schema.
Example schema with privacy:
{
"type": "object",
"properties": {
"Article": {
"type": "object",
"properties": {
"title": {"type": "string"},
"body": {"type": "string"},
"internalScore": {"type": "number", "private": true}
}
},
"InternalNote": {
"type": "object",
"private": true,
"properties": {
"note": {"type": "string"}
}
}
}
} Response 201
{
"version": 2,
"semver": "v1.1.0",
"hash": "a1b2c3d4...",
"recordCount": 150,
"fileCount": 12
} Errors
409 | Version conflict — someone pushed since your base_version. Re-fetch and retry. |
422 | Missing files — records reference files that haven't been uploaded yet. Response includes filesNeeded array. |
GET /api/collections/:owner/:slug/versions
No auth for public collections
List versions, newest first.
Query parameters
limit | Max results (default 50, max 100) |
offset | Pagination offset |
Response 200
[
{
"number": 2,
"semver": "v1.1.0",
"hash": "a1b2c3d4...",
"message": "Add new publications",
"appId": "pubpub-sync",
"actorId": "user-42",
"recordCount": 150,
"fileCount": 12,
"totalBytes": 52428800,
"createdAt": "2026-04-01T00:00:00.000Z"
}
] GET /api/collections/:owner/:slug/versions/latest
No auth for public collections
Get the most recent version. Returns the full version object.
GET /api/collections/:owner/:slug/versions/:n
No auth for public collections
Get a specific version by number. Returns the full version object including schema.
GET /api/collections/:owner/:slug/versions/:n/records
No auth for public collections
Get records for a specific version. Supports cursor-based pagination for efficient traversal of large collections.
Query parameters
type | Filter by record type |
limit | Max results (default 100, max 1000) |
after | Cursor: return records with IDs after this value (preferred for large sets) |
offset | Legacy offset-based pagination (still supported) |
Response 200
{
"records": [
{
"id": "pub-001",
"type": "Publication",
"data": {
"title": "Example Paper",
"doi": "10.1234/example"
}
}
],
"pagination": {
"limit": 100,
"hasMore": true,
"nextCursor": "pub-002",
"total": 150
}
} Use pagination.nextCursor as the after parameter in the next request. When hasMore is false, you've reached the end.
GET /api/collections/:owner/:slug/versions/:n/manifest
No auth for public collections
Get the manifest: a lightweight summary of what's in a version without the full record data.
Response 200
{
"version": 2,
"semver": "v1.1.0",
"hash": "a1b2c3d4...",
"records": [
{"id": "pub-001", "type": "Publication"},
{"id": "pub-002", "type": "Publication"}
],
"files": ["a1b2c3...", "d4e5f6..."]
} GET /api/collections/:owner/:slug/versions/:n/diff
No auth for public collections
Diff two versions. By default compares version :n against :n-1.
Query parameters
from | Version number to diff from (default: n-1) |
Response 200
{
"from": 1,
"to": 2,
"added": [
{"id": "pub-003", "type": "Publication", "data": {...}}
],
"updated": [
{"id": "pub-001", "type": "Publication", "data": {...}}
],
"removed": ["pub-old"]
} Chunked Upload (Large Pushes)
For pushes exceeding 100MB or containing millions of records, use the chunked upload protocol instead of the single-request push. This streams changes in batches to avoid body size limits and server memory pressure.
Flow
- Start session — POST metadata (base_version, schemas, message)
- Append batches — PUT changes in chunks of up to 10,000 records each
- Finalize — POST to create the immutable version from all staged records
POST /api/collections/:owner/:slug/versions/upload
Auth: write scope
Start a new upload session. Returns a session ID valid for 1 hour.
{
"base_version": 3,
"message": "Bulk import",
"app_id": "my-app",
"schemas": { "Article": { "type": "object", "properties": { ... } } }
} Response 201
{
"sessionId": "uuid",
"expiresAt": "2026-04-30T01:00:00.000Z"
} PUT /api/collections/:owner/:slug/versions/upload/:sessionId
Auth: write scope
Append a batch of changes to the session. Call as many times as needed. Max 10,000 records per batch. If the same record ID appears in multiple batches, last write wins.
{
"changes": {
"added": [{"id": "rec-1", "type": "Article", "data": {...}}],
"updated": [...],
"removed": ["rec-old"]
}
} Response 200
{
"received": { "added": 5000, "updated": 0, "removed": 0 },
"totalStaged": 15000
} POST /api/collections/:owner/:slug/versions/upload/:sessionId/finalize
Auth: write scope
Finalize the session: applies all staged changes to the base version, validates records against schemas, computes hashes, and creates the new immutable version.
Response 201
{
"version": 4,
"semver": "v1.3.0",
"hash": "...",
"recordCount": 2000000,
"fileCount": 5
} Errors
409 | Version conflict — someone pushed since your base_version. Start a new session. |
410 | Session expired — the 1-hour window elapsed. |
422 | Schema validation failed or missing files. |
GET /api/collections/:owner/:slug/versions/upload/:sessionId
Auth: read scope
Check session status. Returns status (open, finalizing, completed, failed, expired), record count, and expiry.
DELETE /api/collections/:owner/:slug/versions/upload/:sessionId
Auth: write scope
Cancel and discard a session. Staged records are deleted. Returns 204.