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_versionRequired. The version number this push is based on. Use null for the first version. If the current version doesn't match, returns 409 Conflict.
messageHuman-readable commit message.
app_idIdentifier for the application that pushed this version.
actor_idIdentifier for the user or process that triggered the push.
schemaJSON Schema for the records. If omitted, the previous version's schema is reused.
changes.addedNew records to add. Each record can include "private": true to hide it from public readers.
changes.updatedExisting records to replace (by id). Can include "private": true.
changes.removedRecord IDs to remove.

Schema privacy

You can add "private": true at two levels in the schema:

  • Type-level: Add "private": true to 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": true to 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

409Version conflict — someone pushed since your base_version. Re-fetch and retry.
422Missing 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

limitMax results (default 50, max 100)
offsetPagination 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

typeFilter by record type
limitMax results (default 100, max 1000)
afterCursor: return records with IDs after this value (preferred for large sets)
offsetLegacy 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

fromVersion 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

  1. Start session — POST metadata (base_version, schemas, message)
  2. Append batches — PUT changes in chunks of up to 10,000 records each
  3. 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

409Version conflict — someone pushed since your base_version. Start a new session.
410Session expired — the 1-hour window elapsed.
422Schema 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.