Skip to content

Self-Hosted Sync Server API Reference

Last Updated: 2026-02-28 | Reading Time: 20 minutes

Complete REST API specification for the PasteShelf self-hosted sync server (Vapor/Swift).



The PasteShelf sync server stores end-to-end encrypted blobs only. The server never has access to encryption keys and never decrypts encryptedData fields. All encryption and decryption happens exclusively on client devices.

┌─────────────────────────────────────────────────────────────────────────┐
│ Zero-Knowledge Sync Architecture │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Device A │
│ ┌──────────────────┐ │
│ │ Plaintext Data │ │
│ │ "Hello, World" │ │
│ └────────┬─────────┘ │
│ │ Encrypt with device key (never leaves device) │
│ ▼ │
│ ┌──────────────────┐ ┌───────────────────────────────┐ │
│ │ Encrypted Blob │──POST──▶│ Sync Server │ │
│ │ "xK93mP2Lz..." │ │ ┌─────────────────────────┐ │ │
│ └──────────────────┘ │ │ Stores opaque blob │ │ │
│ │ │ Never decrypts data │ │ │
│ Device B │ │ No plaintext exposure │ │ │
│ ┌──────────────────┐ │ └─────────────────────────┘ │ │
│ │ Encrypted Blob │◀─GET───│ │ │
│ │ "xK93mP2Lz..." │ └───────────────────────────────┘ │
│ └────────┬─────────┘ │
│ │ Decrypt with shared device key │
│ ▼ │
│ ┌──────────────────┐ │
│ │ Plaintext Data │ │
│ │ "Hello, World" │ │
│ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
PrincipleImplementation
Zero-knowledgeServer stores only ciphertext; no plaintext ever transmitted
Stateless serverJWT-based auth; server holds no session state
Idempotent syncChange IDs are UUIDs; duplicate pushes are safely ignored
Conflict resolutionClient receives conflicting server versions and resolves locally
AuditabilityAll operations emit structured audit log entries
  • Base URL: https://sync.your-domain.com (self-hosted) or configured in MDM profile
  • Protocol: HTTPS only; HTTP connections are rejected with 426 Upgrade Required
  • Content-Type: application/json for all request and response bodies
  • Character encoding: UTF-8
  • Timestamps: ISO 8601 with UTC timezone (e.g. 2026-02-28T14:30:00Z)
  • Identifiers: UUID v4 (e.g. 550e8400-e29b-41d4-a716-446655440000)
  • Encrypted payloads: Base64-encoded ciphertext (standard encoding, no line breaks)
  • Content hashes: SHA-256 hex digest of the plaintext before encryption

The server supports two authentication schemes that can be used interchangeably on all sync endpoints.

Short-lived access tokens (1-hour expiry) issued after SSO authentication. Suitable for interactive sessions.

Authorization: Bearer <JWT>

Long-lived tokens (30-day expiry by default, configurable) for persistent device authentication. Suitable for background sync operations. API keys are generated from an authenticated JWT session.

Authorization: Api-Key <key>
┌─────────────────────────────────────────────────────────────────────────┐
│ Authentication Flows │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ FLOW 1: Interactive Login (SSO → JWT) │
│ ───────────────────────────────────── │
│ │
│ Client SSO Provider Sync Server │
│ │ │ │ │
│ │─── SSO Login ─────▶│ │ │
│ │◀── SSO Token ──────│ │ │
│ │ │ │ │
│ │─── POST /api/v1/auth/token ───────────▶│ │
│ │ { ssoToken, ssoProvider, ... } │ │
│ │◀── { accessToken (1h), refreshToken } ─│ │
│ │ │ │
│ FLOW 2: Token Refresh │
│ ───────────────────── │
│ │
│ │─── POST /api/v1/auth/refresh ─────────▶│ │
│ │ Authorization: Bearer <refreshToken> │ │
│ │◀── { accessToken (1h), refreshToken } ─│ │
│ │ │ │
│ FLOW 3: API Key for Background Sync │
│ ──────────────────────────────────── │
│ │
│ │─── POST /api/v1/auth/apikey ──────────▶│ │
│ │ Authorization: Bearer <JWT> │ │
│ │◀── { keyId, apiKey, expiresAt } ───────│ │
│ │ │ │
│ │─── POST /api/v1/sync/push ────────────▶│ │
│ │ Authorization: Api-Key <apiKey> │ │
│ │◀── 200 OK ─────────────────────────────│ │
│ │
└─────────────────────────────────────────────────────────────────────────┘

All endpoints are versioned under /api/v1/. When breaking changes are introduced:

  1. A new version path (e.g. /api/v2/) is published alongside the existing version.
  2. The old version is supported for a minimum of 12 months after the new version ships.
  3. Deprecation notices are delivered via a Deprecation response header and documented in the server release notes.
  4. Non-breaking additions (new optional fields, new endpoints) are made to the existing version without incrementing the version number.

Rate limits are enforced per authenticated identity (user + device combination).

Endpoint GroupLimitWindow
Auth endpoints20 requests1 minute
Sync push/pull120 requests1 minute
Device management30 requests1 minute
Health endpointsUnlimited

When a limit is exceeded the server responds with 429 Too Many Requests and includes the following headers:

HeaderDescription
X-RateLimit-LimitMaximum requests allowed in the window
X-RateLimit-RemainingRequests remaining in the current window
X-RateLimit-ResetUnix timestamp when the window resets
Retry-AfterSeconds to wait before retrying
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1740751200
Retry-After: 37
Content-Type: application/json
{
"error": "rate_limit_exceeded",
"message": "Too many requests. Retry after 37 seconds.",
"retryAfter": 37
}

All error responses share a common envelope:

{
"error": "error_code",
"message": "Human-readable description.",
"requestId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}

The requestId field correlates to server log entries and should be included when contacting support.

StatusMeaningCommon Causes
200 OKSuccess
201 CreatedResource createdDevice registration
400 Bad RequestInvalid request bodyMissing required fields, malformed UUID
401 UnauthorizedAuthentication failedMissing, expired, or invalid token
403 ForbiddenAuthorization failedValid token but insufficient permissions
404 Not FoundResource not foundDevice ID or key ID does not exist
409 ConflictSync conflictServer has a newer version of the entity
413 Payload Too LargeRequest body too largePush batch exceeds 200 changes
422 Unprocessable EntityValidation errorField value out of allowed range
426 Upgrade RequiredHTTPS requiredHTTP connection attempted
429 Too Many RequestsRate limit exceededSee Rate Limiting section
500 Internal Server ErrorServer errorContact server administrator
503 Service UnavailableServer unavailableMaintenance window or overload
CodeStatusDescription
invalid_sso_token401The SSO token is expired, malformed, or from an unrecognized provider
invalid_organization403The organization ID is unknown or the license is inactive
token_expired401The JWT or refresh token has expired
token_invalid401The JWT signature is invalid
apikey_invalid401The API key is unrecognized or has been revoked
apikey_expired401The API key has passed its expiry date
device_not_registered403The device ID in the request is not registered for this user
device_limit_exceeded403The organization has reached its maximum device count
batch_too_large413Push batch contains more than 200 change records
entity_type_unknown400The entityType field contains an unrecognized value
change_type_unknown400The changeType field contains an unrecognized value
rate_limit_exceeded429See Rate Limiting section

Liveness probe. Returns immediately without checking downstream dependencies. Used by load balancers to confirm the process is running.

Authentication: None required.

Response 200

{
"status": "ok",
"version": "1.4.2",
"timestamp": "2026-02-28T14:30:00Z"
}

Readiness probe. Checks that the server can service requests, including verifying the database connection. Used by orchestrators (Kubernetes, Docker Compose) to gate traffic.

Authentication: None required.

Response 200 — Server is ready

{
"status": "ready",
"checks": {
"database": "ok",
"migrations": "up_to_date"
},
"version": "1.4.2",
"timestamp": "2026-02-28T14:30:00Z"
}

Response 503 — Server is not ready

{
"status": "not_ready",
"checks": {
"database": "error",
"migrations": "up_to_date"
},
"message": "Database connection unavailable.",
"timestamp": "2026-02-28T14:30:00Z"
}

Exchange an SSO token for a server-issued JWT access token and a long-lived refresh token. The server validates the SSO token with the configured identity provider, confirms the user belongs to a licensed organization, and returns credentials for subsequent API calls.

Authentication: SSO Token (provided in the request body; no Authorization header required).

Request

{
"ssoToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"ssoProvider": "oidc",
"organizationId": "550e8400-e29b-41d4-a716-446655440000",
"deviceId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
}
FieldTypeRequiredDescription
ssoTokenstringYesRaw token from the identity provider
ssoProvider"oidc" | "saml"YesProtocol used by the identity provider
organizationIdUUIDYesOrganization the user is authenticating against
deviceIdUUIDYesStable device identifier; used to bind the refresh token to a device

Response 200

{
"accessToken": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "dGhpcyBpcyBhIG9wYXF1ZSByZWZyZXNoIHRva2Vu",
"expiresAt": "2026-02-28T15:30:00Z",
"userId": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"organizationId": "550e8400-e29b-41d4-a716-446655440000"
}
FieldTypeDescription
accessTokenstringJWT for use in Authorization: Bearer header; expires in 1 hour
refreshTokenstringOpaque token for refreshing the access token; expires in 30 days
expiresAtISO 8601Absolute expiry time of the accessToken
userIdUUIDServer-assigned user identifier
organizationIdUUIDConfirmed organization identifier

Error responses: 400 invalid_organization, 401 invalid_sso_token, 403 device_limit_exceeded


Obtain a new access token using a valid refresh token before or after the access token expires. Refresh tokens are single-use; this endpoint issues a new refresh token with each response (token rotation).

Authentication: The refresh token is passed in the Authorization header as a Bearer token.

Authorization: Bearer <refreshToken>

Request body: Empty — no body required.

Response 200

{
"accessToken": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "bmV3UmVmcmVzaFRva2VuSGVyZQ==",
"expiresAt": "2026-02-28T16:45:00Z"
}
FieldTypeDescription
accessTokenstringNew JWT; previous access token is immediately invalidated
refreshTokenstringNew refresh token; previous refresh token is invalidated
expiresAtISO 8601Expiry of the new accessToken

Error responses: 401 token_expired, 401 token_invalid


Generate a persistent API key bound to the authenticated user and device. API keys are intended for background daemon processes that cannot perform interactive SSO on each launch.

The raw API key value is returned only once in this response. Store it securely in the macOS Keychain. The server stores only a hashed representation.

Authentication: Authorization: Bearer <JWT>

Request

{
"deviceId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"label": "MacBook Pro — Background Sync",
"expiresInDays": 30
}
FieldTypeRequiredDescription
deviceIdUUIDYesDevice the key is bound to
labelstringNoHuman-readable label shown in the admin console
expiresInDaysintegerNoKey lifetime in days (1–365); defaults to 30

Response 201

{
"keyId": "c3d4e5f6-a7b8-9012-cdef-123456789012",
"apiKey": "psk_live_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789",
"label": "MacBook Pro — Background Sync",
"createdAt": "2026-02-28T14:30:00Z",
"expiresAt": "2026-03-30T14:30:00Z"
}
FieldTypeDescription
keyIdUUIDIdentifier used to revoke this key
apiKeystringFull API key value; shown only once — store immediately
labelstringLabel as stored
createdAtISO 8601Key creation time
expiresAtISO 8601Key expiry time

Error responses: 401 token_expired, 401 token_invalid


Revoke an API key immediately. Any in-flight or subsequent requests using the revoked key will receive 401 apikey_invalid. This operation cannot be undone.

Authentication: Authorization: Bearer <JWT>

Path parameters

ParameterTypeDescription
keyIdUUIDThe key identifier returned when the key was created

Request body: Empty — no body required.

Response 204: No content. Key has been revoked.

Error responses: 401 token_expired, 401 token_invalid, 404 (key not found or does not belong to the authenticated user)


Register a device to participate in sync. A device must be registered before pushing or pulling changes. Registering an already-registered device ID is idempotent — the existing record is returned with a 200 status instead of 201.

Authentication: Authorization: Bearer <JWT>

Request

{
"deviceId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"deviceName": "MacBook Pro (14-inch, M3)",
"osVersion": "macOS 15.2",
"appVersion": "2.4.1"
}
FieldTypeRequiredDescription
deviceIdUUIDYesStable, client-generated identifier; persist across app reinstalls
deviceNamestringYesHuman-readable device name for admin console display
osVersionstringYesmacOS version string
appVersionstringYesPasteShelf app version string

Response 201 — New device registered

{
"deviceId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"registeredAt": "2026-02-28T14:30:00Z",
"syncToken": null
}

Response 200 — Device already registered (idempotent)

{
"deviceId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"registeredAt": "2026-01-15T09:00:00Z",
"syncToken": "c2luY1Rva2VuT3BhcXVlVmFsdWU="
}
FieldTypeDescription
deviceIdUUIDConfirmed device identifier
registeredAtISO 8601When the device was first registered
syncTokenstring | nullCurrent sync cursor; null for brand-new devices; use with /sync/pull

Error responses: 400 Bad Request, 403 device_limit_exceeded


List all devices registered to the authenticated user.

Authentication: Authorization: Bearer <JWT>

Request body: Empty — no body required.

Response 200

{
"devices": [
{
"deviceId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"deviceName": "MacBook Pro (14-inch, M3)",
"osVersion": "macOS 15.2",
"appVersion": "2.4.1",
"registeredAt": "2026-01-15T09:00:00Z",
"lastSeenAt": "2026-02-28T14:28:00Z"
},
{
"deviceId": "d4e5f6a7-b8c9-0123-def0-123456789012",
"deviceName": "Mac mini (M4, 2025)",
"osVersion": "macOS 15.2",
"appVersion": "2.4.1",
"registeredAt": "2026-02-01T12:00:00Z",
"lastSeenAt": "2026-02-27T20:00:00Z"
}
],
"total": 2
}
FieldTypeDescription
devicesarrayRegistered devices for this user
devices[].deviceIdUUIDDevice identifier
devices[].deviceNamestringHuman-readable device name
devices[].osVersionstringmacOS version at last registration or update
devices[].appVersionstringApp version at last registration or update
devices[].registeredAtISO 8601First registration timestamp
devices[].lastSeenAtISO 8601Most recent authenticated API call from this device
totalintegerTotal number of registered devices

Unregister a device. All sync state for the device is removed from the server. The device will need to perform a full pull sync if it is re-registered.

Authentication: Authorization: Bearer <JWT>

Path parameters

ParameterTypeDescription
deviceIdUUIDDevice to unregister

Request body: Empty — no body required.

Response 204: No content. Device unregistered.

Error responses: 401 token_expired, 401 token_invalid, 404 (device not found or does not belong to the authenticated user)


Push a batch of encrypted change records from the device to the server. The server assigns a server-side timestamp and appends the changes to the log for distribution to other devices.

The server validates structural fields (UUIDs, change type values, etc.) but never inspects encryptedData. The contentHash is stored verbatim for client-side integrity verification after decryption.

Authentication: Authorization: Bearer <JWT> or Authorization: Api-Key <key>

Limits: Maximum 200 change records per request. Split larger batches into multiple sequential calls.

Request

{
"deviceId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"changes": [
{
"id": "e5f6a7b8-c9d0-1234-ef01-234567890123",
"changeType": "insert",
"entityType": "ClipboardItem",
"entityId": "f6a7b8c9-d0e1-2345-f012-345678901234",
"encryptedData": "xK93mP2LzQr4TsUvWxYzAbCdEfGhIjKlMnOpQrStUvWx...",
"contentHash": "a3f5c8d2e1b4967f0c2e5a8b3d6f9c2e1a4b7d0f3e6c9b2a5d8f1c4e7b0a3d6",
"localTimestamp": "2026-02-28T14:29:55Z"
},
{
"id": "a7b8c9d0-e1f2-3456-0123-456789012345",
"changeType": "delete",
"entityType": "ClipboardItem",
"entityId": "b8c9d0e1-f2a3-4567-1234-567890123456",
"encryptedData": null,
"contentHash": null,
"localTimestamp": "2026-02-28T14:29:58Z"
}
]
}
FieldTypeRequiredDescription
deviceIdUUIDYesRegistered device submitting the changes
changesarrayYesBatch of change records (1–200 items)
changes[].idUUIDYesClient-generated unique ID for this change record; used for deduplication
changes[].changeType"insert" | "update" | "delete"YesType of change
changes[].entityType"ClipboardItem" | "Tag" | "Folder"YesEntity type being changed
changes[].entityIdUUIDYesStable identifier of the entity being changed
changes[].encryptedDatabase64 string | nullYes for insert/updateE2E encrypted entity payload; null for delete
changes[].contentHashstring | nullYes for insert/updateSHA-256 hex digest of plaintext before encryption; null for delete
changes[].localTimestampISO 8601YesDevice-local time when the change occurred

Response 200 — All changes accepted

{
"accepted": 2,
"rejected": 0,
"newSyncToken": "c2luY1Rva2VuT3BhcXVlVmFsdWU=",
"serverTimestamp": "2026-02-28T14:30:01Z"
}

Response 409 — One or more changes conflict with server state

The server returns 409 when a submitted change targets an entity that has a newer server-side version. This occurs when two devices modify the same entity concurrently. The client is responsible for resolution (e.g. last-write-wins, CRDT merge, or user prompt).

{
"accepted": 1,
"conflicts": [
{
"entityId": "f6a7b8c9-d0e1-2345-f012-345678901234",
"serverVersion": {
"encryptedData": "yL04nQ3MaRs5UtVwXyZaBcDeFgHiJkLmNoPqRsTuVwXy...",
"serverTimestamp": "2026-02-28T14:29:30Z"
}
}
],
"newSyncToken": "bmV3U3luY1Rva2VuVmFsdWU=",
"serverTimestamp": "2026-02-28T14:30:01Z"
}
FieldTypeDescription
acceptedintegerNumber of changes stored successfully
rejectedintegerNumber of changes that could not be stored (validation errors)
conflictsarrayPresent in 409 only; conflicting entities with their server versions
conflicts[].entityIdUUIDEntity that has a conflict
conflicts[].serverVersion.encryptedDatabase64Server’s current encrypted payload for the entity
conflicts[].serverVersion.serverTimestampISO 8601When the server version was last written
newSyncTokenstringUpdated sync cursor; use in the next /sync/pull call
serverTimestampISO 8601Server time when the response was generated

Error responses: 400 entity_type_unknown, 400 change_type_unknown, 403 device_not_registered, 413 batch_too_large


Pull changes from the server since a given sync token. Returns changes made by all other devices (the requesting device’s own changes are excluded from the response). Responses are paginated; call repeatedly with the returned newSyncToken until hasMore is false.

Pass sinceSyncToken: null for a brand-new device to receive the full change history.

Authentication: Authorization: Bearer <JWT> or Authorization: Api-Key <key>

Request

{
"deviceId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"sinceSyncToken": "c2luY1Rva2VuT3BhcXVlVmFsdWU=",
"limit": 100
}
FieldTypeRequiredDescription
deviceIdUUIDYesRegistered device requesting changes
sinceSyncTokenstring | nullYesPagination cursor from a previous push or pull response; null for initial sync
limitintegerNoMaximum changes to return per page (1–200); defaults to 100

Response 200

{
"changes": [
{
"id": "e5f6a7b8-c9d0-1234-ef01-234567890123",
"changeType": "insert",
"entityType": "ClipboardItem",
"entityId": "f6a7b8c9-d0e1-2345-f012-345678901234",
"encryptedData": "xK93mP2LzQr4TsUvWxYzAbCdEfGhIjKlMnOpQrStUvWx...",
"contentHash": "a3f5c8d2e1b4967f0c2e5a8b3d6f9c2e1a4b7d0f3e6c9b2a5d8f1c4e7b0a3d6",
"serverTimestamp": "2026-02-28T14:30:01Z",
"sourceDeviceId": "d4e5f6a7-b8c9-0123-def0-123456789012"
}
],
"newSyncToken": "bmV3U3luY1Rva2VuVmFsdWU=",
"hasMore": false
}
FieldTypeDescription
changesarrayChange records from other devices since the sync token
changes[].idUUIDOriginal client-generated change record ID
changes[].changeTypestring"insert", "update", or "delete"
changes[].entityTypestring"ClipboardItem", "Tag", or "Folder"
changes[].entityIdUUIDEntity being changed
changes[].encryptedDatabase64 | nullEncrypted payload; null for delete changes
changes[].contentHashstring | nullSHA-256 hex digest for integrity verification after decryption; null for delete
changes[].serverTimestampISO 8601When the server received and stored this change
changes[].sourceDeviceIdUUIDDevice that originally pushed this change
newSyncTokenstringCursor to use in the next pull request
hasMorebooleanTrue if more pages are available; continue pulling until false

Error responses: 400 Bad Request, 403 device_not_registered


Return the current sync state for the authenticated device, including the latest sync token and the count of changes pending delivery.

Authentication: Authorization: Bearer <JWT> or Authorization: Api-Key <key>

Query parameters

ParameterTypeRequiredDescription
deviceIdUUIDYesDevice to query (?deviceId=<uuid>)

Response 200

{
"deviceId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"currentSyncToken": "c2luY1Rva2VuT3BhcXVlVmFsdWU=",
"pendingChanges": 14,
"lastSyncAt": "2026-02-28T14:28:00Z",
"serverTimestamp": "2026-02-28T14:30:00Z"
}
FieldTypeDescription
deviceIdUUIDDevice the status is for
currentSyncTokenstring | nullMost recent sync cursor for this device; null if never synced
pendingChangesintegerNumber of changes from other devices not yet pulled by this device
lastSyncAtISO 8601 | nullTimestamp of last successful push or pull; null if never synced
serverTimestampISO 8601Server time when the response was generated

Error responses: 400 Bad Request, 403 device_not_registered


Delete all sync state for the authenticated user. This removes all stored change records and sync tokens for every device. All devices will need to perform a full re-upload followed by a full pull. This operation is irreversible.

This endpoint requires a Bearer JWT; API keys are not accepted to prevent automated processes from triggering a reset accidentally.

Authentication: Authorization: Bearer <JWT> only.

Request body: Empty — no body required.

Response 200

{
"message": "Sync data reset. All devices must re-synchronize.",
"resetAt": "2026-02-28T14:30:00Z",
"affectedDevices": 2
}
FieldTypeDescription
messagestringConfirmation message
resetAtISO 8601When the reset was performed
affectedDevicesintegerNumber of devices whose sync state was cleared

Error responses: 401 token_expired, 401 token_invalid, 401 apikey_invalid (API keys rejected for this endpoint)


DocumentDescription
Enterprise DeploymentHow to deploy the self-hosted sync server
Enterprise Admin GuideAdmin console and policy management
SecurityEncryption implementation details
Legal & ComplianceData residency and compliance

Last updated: 2026-02-28